diff --git a/src/cockpit/control.js b/src/cockpit/control.js index 3328c49..f7ba1bf 100644 --- a/src/cockpit/control.js +++ b/src/cockpit/control.js @@ -5,6 +5,7 @@ const { renderSidebar } = require('./sidebar'); const { renderSettingsScreen } = require('./settings-render'); const { CONTROL_KEY_HELP } = require('./shortcuts'); const { stripAnsi } = require('./theme'); +const { renderWelcomePage } = require('./welcome'); const { runCockpitAction } = require('./action-runner'); const { PANE_MENU_ITEMS, @@ -22,7 +23,8 @@ const DEFAULT_SETTINGS = { defaultBase: 'main', }; -const MODES = new Set(['details', 'menu', 'settings']); +const MODES = new Set(['main', 'menu', 'settings', 'shortcuts', 'new-agent', 'terminal']); +const EMPTY_ACTION_ROWS = Object.freeze(['new-agent', 'terminal', 'settings', 'shortcuts']); const SETTINGS_FIELDS = [ 'theme', 'sidebarWidth', @@ -150,8 +152,17 @@ function normalizeSettings(settings) { }; } +function normalizeActionRows(rows) { + if (!Array.isArray(rows) || rows.length === 0) { + return [...EMPTY_ACTION_ROWS]; + } + const normalized = rows.map((row) => text(row)).filter(Boolean); + return normalized.length > 0 ? normalized : [...EMPTY_ACTION_ROWS]; +} + function normalizeMode(mode) { - return MODES.has(mode) ? mode : 'details'; + if (mode === 'details') return 'main'; + return MODES.has(mode) ? mode : 'main'; } function normalizeControlState(state = {}) { @@ -163,8 +174,10 @@ function normalizeControlState(state = {}) { : Array.isArray(cockpitState.sessions) ? cockpitState.sessions : []; + const actionRows = normalizeActionRows(state.actionRows); const selectedIndex = clampIndex(number(state.selectedIndex, 0), sessions.length); const selected = sessions[selectedIndex] || null; + const selectedScope = sessions.length > 0 ? 'lane' : 'action'; return { ...state, @@ -174,6 +187,9 @@ function normalizeControlState(state = {}) { sessions, selectedIndex, selectedSessionId: text(state.selectedSessionId || (selected && sessionId(selected))), + selectedScope, + actionRows, + actionIndex: wrapIndex(number(state.actionIndex, 0), actionRows.length), mode: normalizeMode(state.mode), menuIndex: wrapIndex(number(state.menuIndex, 0), MENU_ITEMS.length), settingsIndex: wrapIndex(number(state.settingsIndex, 0), SETTINGS_FIELDS.length), @@ -266,7 +282,7 @@ function chooseMenuItem(state) { const intent = buildIntent(current, result.actionId); return normalizeControlState({ ...current, - mode: 'details', + mode: 'main', paneMenuMessage: '', shouldExit: intent.type === 'quit', lastIntent: intent, @@ -290,9 +306,54 @@ function normalizeKey(value) { if (raw === '\u001b[B') return 'down'; if (raw === '\t') return 'tab'; if (/^alt(?:\+|-)?shift(?:\+|-)?m$/i.test(raw)) return 'alt-shift-m'; + if (/^(esc|escape)$/i.test(raw)) return 'escape'; return raw.toLowerCase(); } +function moveSelection(state, direction) { + const current = normalizeControlState(state); + if (current.sessions.length > 0) { + return normalizeControlState({ + ...current, + selectedScope: 'lane', + selectedIndex: wrapIndex(current.selectedIndex + direction, current.sessions.length), + selectedSessionId: '', + lastIntent: null, + }); + } + + return normalizeControlState({ + ...current, + selectedScope: 'action', + selectedIndex: 0, + actionIndex: wrapIndex(current.actionIndex + direction, current.actionRows.length), + selectedSessionId: '', + lastIntent: null, + }); +} + +function openActionRow(state, actionId) { + const current = normalizeControlState(state); + if (actionId === 'new-agent') { + return normalizeControlState({ ...current, mode: 'new-agent', lastIntent: null }); + } + if (actionId === 'terminal') { + return normalizeControlState({ ...current, mode: 'terminal', lastIntent: null }); + } + if (actionId === 'settings') { + return normalizeControlState({ ...current, mode: 'settings', lastIntent: null }); + } + if (actionId === 'shortcuts') { + return normalizeControlState({ ...current, mode: 'shortcuts', lastIntent: null }); + } + return normalizeControlState({ ...current, lastIntent: null }); +} + +function openSelectedActionRow(state) { + const current = normalizeControlState(state); + return openActionRow(current, current.actionRows[current.actionIndex] || current.actionRows[0]); +} + function applyKey(state, rawKey) { const current = normalizeControlState(state); const key = normalizeKey(rawKey); @@ -303,7 +364,7 @@ function applyKey(state, rawKey) { if (result.action === 'cancel') { return normalizeControlState({ ...current, - mode: 'details', + mode: 'main', paneMenuMessage: '', lastIntent: null, }); @@ -311,7 +372,7 @@ function applyKey(state, rawKey) { if (result.action === 'select') { return normalizeControlState({ ...current, - mode: 'details', + mode: 'main', menuIndex: result.state.selectedIndex, paneMenuMessage: '', lastIntent: buildIntent(current, result.actionId), @@ -335,26 +396,11 @@ function applyKey(state, rawKey) { if (key === 'escape') { return normalizeControlState({ ...current, - mode: 'details', - lastIntent: null, - }); - } - if (key === 's') { - return normalizeControlState({ - ...current, - mode: 'settings', - lastIntent: null, - }); - } - if (key === 'm' || key === 'tab' || key === 'alt-shift-m') { - return normalizeControlState({ - ...current, - mode: 'menu', - paneMenuMessage: '', + mode: 'main', lastIntent: null, }); } - if (DIRECT_DETAIL_PANE_KEYS.has(normalizePaneMenuKey(rawKey))) { + if (mode === 'main' && DIRECT_DETAIL_PANE_KEYS.has(normalizePaneMenuKey(rawKey))) { const result = applyPaneMenuKey(paneMenuStateFromControl(current), rawKey); if (result.action === 'select') { return normalizeControlState({ @@ -369,6 +415,26 @@ function applyKey(state, rawKey) { lastIntent: null, }); } + if (key === 'n') { + return openActionRow(current, 'new-agent'); + } + if (key === 't') { + return openActionRow(current, 'terminal'); + } + if (key === '?') { + return openActionRow(current, 'shortcuts'); + } + if (key === 's') { + return openActionRow(current, 'settings'); + } + if (key === 'm' || key === 'tab' || key === 'alt-shift-m') { + return normalizeControlState({ + ...current, + mode: 'menu', + paneMenuMessage: '', + lastIntent: null, + }); + } if (key === 'enter') { if (mode === 'menu') return chooseMenuItem(current); if (mode === 'settings') { @@ -377,10 +443,27 @@ function applyKey(state, rawKey) { lastIntent: buildIntent(current, 'settings:edit'), }); } + if (mode === 'new-agent') { + return normalizeControlState({ + ...current, + mode: 'main', + lastIntent: buildIntent(current, 'agent:start'), + }); + } + if (mode === 'terminal') { + return normalizeControlState({ + ...current, + mode: 'main', + lastIntent: buildIntent(current, 'terminal:open'), + }); + } + if (current.sessions.length === 0 && current.selectedScope === 'action') { + return openSelectedActionRow(current); + } return normalizeControlState({ ...current, - mode: 'menu', - lastIntent: null, + mode: 'main', + lastIntent: buildIntent(current, 'view'), }); } if (key === 'down' || key === 'j') { @@ -390,7 +473,7 @@ function applyKey(state, rawKey) { if (mode === 'settings') { return normalizeControlState({ ...current, settingsIndex: current.settingsIndex + 1, lastIntent: null }); } - return normalizeControlState({ ...current, selectedIndex: current.selectedIndex + 1, selectedSessionId: '', lastIntent: null }); + return moveSelection(current, 1); } if (key === 'up' || key === 'k') { if (mode === 'menu') { @@ -399,7 +482,7 @@ function applyKey(state, rawKey) { if (mode === 'settings') { return normalizeControlState({ ...current, settingsIndex: current.settingsIndex - 1, lastIntent: null }); } - return normalizeControlState({ ...current, selectedIndex: current.selectedIndex - 1, selectedSessionId: '', lastIntent: null }); + return moveSelection(current, -1); } return current; @@ -481,11 +564,21 @@ function selectedField(state) { return SETTINGS_FIELDS[current.settingsIndex] || SETTINGS_FIELDS[0]; } +function welcomeState(state) { + const current = normalizeControlState(state); + return { + ...current.cockpitState, + repoPath: current.repoPath, + baseBranch: current.baseBranch, + sessions: current.sessions, + }; +} + function renderDetailsPanel(state) { const current = normalizeControlState(state); const session = selectedSession(current); const lines = [ - 'details', + 'main', `repo: ${current.repoPath || '-'}`, `base: ${current.baseBranch || '-'}`, `mode: ${current.mode}`, @@ -517,6 +610,54 @@ function renderDetailsPanel(state) { return `${lines.join('\n')}\n`; } +function renderShortcutsPanel() { + return [ + 'shortcuts', + '', + 'j/down: next lane', + 'k/up: previous lane', + 'enter: view selected lane / open selected action', + 'n: new agent', + 't: terminal', + 'm or Alt+Shift+M: pane menu', + 's: settings', + 'v/h/x/p/r/c/o/a/b/f/T/A: pane actions', + 'esc: back to main', + 'q: quit', + '', + ].join('\n'); +} + +function renderNewAgentPanel(state) { + const current = normalizeControlState(state); + return [ + 'new agent', + '', + `agent: ${current.settings.defaultAgent}`, + `base: ${current.settings.defaultBase}`, + '', + 'Enter: open a guarded agent lane in Kitty', + 'Esc: back to main', + '', + ].join('\n'); +} + +function renderTerminalPanel(state) { + const current = normalizeControlState(state); + const session = selectedSession(current); + return [ + 'terminal', + '', + session + ? `target: ${sessionId(session) || text(session.branch, 'selected lane')}` + : `target: ${current.repoPath || 'repo'}`, + '', + 'Enter: open Kitty terminal', + 'Esc: back to main', + '', + ].join('\n'); +} + function renderMenuPanel(state) { const current = normalizeControlState(state); return renderPaneMenu(paneMenuStateFromControl(current), { width: 72, theme: current.settings.theme }); @@ -534,6 +675,12 @@ function renderPanel(state) { const current = normalizeControlState(state); if (current.mode === 'menu') return renderMenuPanel(current); if (current.mode === 'settings') return renderSettingsPanel(current); + if (current.mode === 'shortcuts') return renderShortcutsPanel(current); + if (current.mode === 'new-agent') return renderNewAgentPanel(current); + if (current.mode === 'terminal') return renderTerminalPanel(current); + if (current.sessions.length === 0) { + return renderWelcomePage(welcomeState(current), current.settings); + } return renderDetailsPanel(current); } @@ -542,7 +689,7 @@ function renderControlFrame(state) { const width = number(current.settings.sidebarWidth, DEFAULT_SETTINGS.sidebarWidth); const sidebar = splitLines(renderSidebar(current, { width, theme: current.settings.theme })); const framePanelState = current.mode === 'menu' - ? normalizeControlState({ ...current, mode: 'details' }) + ? normalizeControlState({ ...current, mode: 'main' }) : current; const panel = splitLines(renderPanel(framePanelState)); const leftWidth = Math.max(width, ...sidebar.map((line) => stripAnsi(line).length)); @@ -704,6 +851,7 @@ module.exports = { MENU_ITEMS, SETTINGS_FIELDS, applyCockpitAction, + applyCockpitKey: applyKey, buildCockpitActionContext, normalizeControlState, normalizeKey, diff --git a/src/cockpit/index.js b/src/cockpit/index.js index 6ca910b..bb21305 100644 --- a/src/cockpit/index.js +++ b/src/cockpit/index.js @@ -1,11 +1,13 @@ const { readCockpitState } = require('./state'); +const { readCockpitSettings } = require('./settings'); const { renderCockpit } = require('./render'); +const { openKittyCockpit } = require('./kitty-layout'); const control = require('./control'); const actions = require('./actions'); const { normalizeBackendName, selectTerminalBackend } = require('../terminal'); const DEFAULT_SESSION_NAME = 'guardex'; -const DEFAULT_BACKEND = 'tmux'; +const DEFAULT_BACKEND = 'auto'; const DEFAULT_INTERACTIVE_BACKEND = 'auto'; function parseCockpitArgs(rawArgs = []) { @@ -22,6 +24,10 @@ function parseCockpitArgs(rawArgs = []) { options.attach = true; continue; } + if (arg === '--kitty') { + options.backend = 'kitty'; + continue; + } if (arg === '--session') { const next = rawArgs[index + 1]; if (!next || next.startsWith('-')) { @@ -154,6 +160,28 @@ function writeOpenedCockpitMessage({ backend, action, options, repoRoot, control function openWithBackend(backend, options, repoRoot, controlCommand, deps = {}) { const stdout = deps.stdout || process.stdout; const toolName = deps.toolName || 'gitguardex'; + const env = deps.env || process.env; + if (backend.name === 'kitty') { + const result = openKittyCockpit({ + repoRoot, + sessionName: options.sessionName, + controlCommand, + state: deps.state, + settings: deps.settings, + readState: deps.readState || readCockpitState, + readSettings: deps.readSettings || readCockpitSettings, + dryRun: deps.dryRun, + controlTitle: options.controlTitle || 'gx cockpit', + focusControl: deps.focusControl === undefined ? true : deps.focusControl, + runner: deps.kittyRunner || deps.runner, + kittyBin: deps.kittyBin || env.GUARDEX_KITTY_BIN, + env, + }); + const action = result && result.action ? result.action : 'created'; + writeOpenedCockpitMessage({ backend, action, options, repoRoot, controlCommand, stdout, toolName }); + return { action, backend: backend.name, sessionName: options.sessionName, repoRoot, plan: result.plan }; + } + const result = backend.openCockpitLayout({ repoRoot, sessionName: options.sessionName, @@ -324,6 +352,7 @@ module.exports = { parseCockpitControlArgs, openDefaultCockpit, openCockpit, + openKittyCockpit, render, startCockpit, ...control, diff --git a/src/cockpit/keybindings.js b/src/cockpit/keybindings.js index aa50759..63787aa 100644 --- a/src/cockpit/keybindings.js +++ b/src/cockpit/keybindings.js @@ -2,7 +2,8 @@ const { PANE_MENU_ACTION_IDS } = require('./pane-menu'); -const VALID_MODES = new Set(['main', 'menu', 'settings', 'prompt']); +const DEFAULT_ACTION_ROWS = Object.freeze(['new-agent', 'terminal', 'settings', 'shortcuts']); +const VALID_MODES = new Set(['main', 'menu', 'settings', 'shortcuts', 'new-agent', 'terminal', 'prompt']); function action(type, payload = {}) { return { type, payload }; @@ -22,6 +23,7 @@ const MAIN_BINDINGS = { m: action('menu'), 'alt-shift-m': action('menu'), s: action('settings'), + '?': action('shortcuts'), x: action(PANE_MENU_ACTION_IDS.CLOSE), b: action(PANE_MENU_ACTION_IDS.CREATE_CHILD_WORKTREE), f: action(PANE_MENU_ACTION_IDS.BROWSE_FILES), @@ -52,6 +54,20 @@ const BASE_BINDINGS = { esc: action('close-settings'), q: action('quit'), }, + shortcuts: { + esc: action('close-popup'), + q: action('quit'), + }, + 'new-agent': { + enter: action('agent:start'), + esc: action('close-popup'), + q: action('quit'), + }, + terminal: { + enter: action('terminal:open'), + esc: action('close-popup'), + q: action('quit'), + }, prompt: {}, }; @@ -120,7 +136,89 @@ function resolveKeyAction(key, context = {}) { }); } +function number(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function wrapIndex(index, length) { + if (length <= 0) return 0; + const next = Number.isInteger(index) ? index : 0; + return ((next % length) + length) % length; +} + +function actionRows(state = {}) { + if (!Array.isArray(state.actionRows) || state.actionRows.length === 0) { + return [...DEFAULT_ACTION_ROWS]; + } + return state.actionRows.map((row) => String(row || '').trim()).filter(Boolean); +} + +function moveSelection(state = {}, direction) { + const sessions = Array.isArray(state.sessions) ? state.sessions : []; + if (sessions.length > 0) { + return { + ...state, + selectedScope: 'lane', + selectedIndex: wrapIndex(number(state.selectedIndex, 0) + direction, sessions.length), + selectedSessionId: '', + }; + } + + const rows = actionRows(state); + return { + ...state, + actionRows: rows, + selectedScope: 'action', + selectedIndex: 0, + actionIndex: wrapIndex(number(state.actionIndex, 0) + direction, rows.length), + selectedSessionId: '', + }; +} + +function closeMode(state = {}) { + return { + ...state, + mode: 'main', + lastIntent: null, + }; +} + +function applyCockpitKey(state = {}, key) { + const current = { + ...state, + mode: normalizeMode(state), + }; + const resolved = resolveKeyAction(key, current); + + switch (resolved.type) { + case 'next': + return moveSelection(current, 1); + case 'previous': + return moveSelection(current, -1); + case 'new-agent': + return { ...current, mode: 'new-agent', lastIntent: null }; + case 'terminal': + return { ...current, mode: 'terminal', lastIntent: null }; + case 'shortcuts': + return { ...current, mode: 'shortcuts', lastIntent: null }; + case 'settings': + return { ...current, mode: 'settings', lastIntent: null }; + case 'menu': + return { ...current, mode: 'menu', lastIntent: null }; + case 'close-menu': + case 'close-settings': + case 'close-popup': + return closeMode(current); + case 'quit': + return { ...current, shouldExit: true }; + default: + return current; + } +} + module.exports = { + applyCockpitKey, defaultKeybindings, resolveKeyAction, }; diff --git a/src/cockpit/kitty-layout.js b/src/cockpit/kitty-layout.js index 65339a0..24492af 100644 --- a/src/cockpit/kitty-layout.js +++ b/src/cockpit/kitty-layout.js @@ -1,5 +1,9 @@ 'use strict'; +const { readCockpitSettings } = require('./settings'); +const { readCockpitState } = require('./state'); +const kittyRuntime = require('../kitty/runtime'); + const DEFAULT_SESSION_NAME = 'guardex'; const DEFAULT_COLUMNS = 120; const DEFAULT_KITTY_BIN = 'kitty'; @@ -110,6 +114,93 @@ function agentTitle(agent, index) { return `${String(index + 1).padStart(2, '0')}: ${agentLabel(agent, index)}`; } +function objectValue(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; +} + +function canonicalSessions(state = {}) { + const source = objectValue(state); + const agentsStatus = objectValue(source.agentsStatus); + if (Array.isArray(agentsStatus.sessions)) return agentsStatus.sessions; + if (Array.isArray(source.sessions)) return source.sessions; + if (Array.isArray(source.agents)) return source.agents; + return []; +} + +function isInactiveSession(session = {}) { + const status = firstText(session.status, session.activity).toLowerCase(); + return new Set([ + 'closed', + 'complete', + 'completed', + 'dead', + 'done', + 'exited', + 'finished', + 'merged', + 'stopped', + ]).has(status); +} + +function repoRootFrom(state = {}, settings = {}) { + const agentsStatus = objectValue(state.agentsStatus); + return requireText( + firstText(settings.repoRoot, settings.repoPath, state.repoRoot, state.repoPath, agentsStatus.repoRoot), + 'repoRoot', + ); +} + +function laneShellCommand(session = {}) { + const label = firstText(session.branch, session.id, session.sessionId, 'agent lane'); + return `printf '%s\\n' ${shellQuote(`GitGuardex cockpit lane: ${label}`)}; exec \${SHELL:-bash}`; +} + +function normalizeCockpitSession(session, index, repoRoot, total) { + const source = objectValue(session); + const worktree = firstText(source.worktreePath, source.worktree, source.path, source.cwd); + const missingWorktree = !worktree || source.worktreeExists === false; + const cwd = requireText(missingWorktree ? repoRoot : worktree, `sessions[${index}].cwd`); + const title = agentTitle({ + ...source, + agentName: firstText(source.agentName, source.agent), + }, index); + + return { + id: agentId(source, index), + index, + total, + role: 'agent', + title, + cwd, + worktree, + worktreeExists: !missingWorktree, + missingWorktree, + branch: text(source.branch), + status: text(source.status), + activity: text(source.activity), + task: text(source.task), + metadata: objectValue(source.metadata), + launchCommand: text(source.launchCommand), + command: laneShellCommand(source), + match: matchTitle(title), + location: index === 0 ? 'vsplit' : 'hsplit', + }; +} + +function shouldShowDetailsPane(settings = {}) { + return Boolean( + settings.showDetailsPane || + settings.detailsPane || + settings.showLogPane || + settings.logPane || + settings.bottomPane, + ); +} + +function detailsPaneCommand(repoRoot, settings = {}) { + return text(settings.detailsCommand || settings.logCommand, `gx agents status --target ${shellQuote(repoRoot)}`); +} + function normalizeAgent(agent, index, repoRoot, total) { const source = agent && typeof agent === 'object' ? agent : {}; const cwd = requireText( @@ -130,6 +221,130 @@ function normalizeAgent(agent, index, repoRoot, total) { }; } +function buildKittyCockpitPlan(state = {}, settings = {}) { + const normalizedState = objectValue(state); + const normalizedSettings = objectValue(settings); + const repoRoot = repoRootFrom(normalizedState, normalizedSettings); + const sessionName = text(normalizedSettings.sessionName, DEFAULT_SESSION_NAME); + const columns = positiveInteger(normalizedSettings.columns, DEFAULT_COLUMNS); + const kittyBin = text(normalizedSettings.kittyBin, DEFAULT_KITTY_BIN); + const controlCommand = text( + normalizedSettings.controlCommand, + `gx cockpit control --target ${shellQuote(repoRoot)}`, + ); + const activeSessions = canonicalSessions(normalizedState).filter((session) => !isInactiveSession(session)); + const agentWindows = activeSessions.map((session, index) => ( + normalizeCockpitSession(session, index, repoRoot, activeSessions.length) + )); + const hasAgents = agentWindows.length > 0; + const controlTitle = text( + normalizedSettings.controlTitle, + hasAgents ? `${sessionName}: control` : `${sessionName}: welcome`, + ); + const controlWindow = { + id: 'control', + role: 'control', + title: controlTitle, + cwd: repoRoot, + command: controlCommand, + match: matchTitle(controlTitle), + persistent: true, + welcome: !hasAgents, + }; + + const detailWindow = hasAgents && shouldShowDetailsPane(normalizedSettings) + ? { + id: 'details', + role: 'details', + title: `${sessionName}: details`, + cwd: repoRoot, + command: detailsPaneCommand(repoRoot, normalizedSettings), + match: matchTitle(`${sessionName}: details`), + location: 'hsplit', + } + : null; + + const steps = [ + { + id: 'launch-control', + role: 'control', + action: 'launch', + window: controlWindow, + command: launchCommand(controlWindow, kittyBin), + }, + ...agentWindows.map((window) => ({ + id: `launch-agent-${window.index + 1}`, + role: 'agent', + action: 'launch', + agentId: window.id, + window, + command: launchCommand(window, kittyBin), + })), + ]; + + if (detailWindow) { + steps.push({ + id: 'launch-details', + role: 'details', + action: 'launch', + window: detailWindow, + command: launchCommand(detailWindow, kittyBin), + }); + } + + if (normalizedSettings.focusControl !== false) { + steps.push({ + id: 'focus-control', + role: 'control', + action: 'focus', + window: controlWindow, + command: focusCommand(controlWindow, kittyBin), + }); + } + + const panes = [controlWindow, ...agentWindows, ...(detailWindow ? [detailWindow] : [])]; + + return { + schemaVersion: 1, + backend: 'kitty', + dryRun: Boolean(normalizedSettings.dryRun), + sessionName, + repoRoot, + columns, + welcome: !hasAgents, + controlPaneCommand: controlWindow.command, + agentPaneCommands: agentWindows.map((window) => ({ + id: window.id, + title: window.title, + cwd: window.cwd, + worktree: window.worktree, + command: window.command, + missingWorktree: window.missingWorktree, + })), + titles: panes.map((pane) => pane.title), + workingDirectories: panes.map((pane) => ({ + id: pane.id, + role: pane.role, + cwd: pane.cwd, + worktree: pane.worktree || '', + })), + layout: { + control: controlWindow, + agentArea: { + id: 'agent-area', + role: 'agent-area', + title: `${sessionName}: agents`, + cwd: repoRoot, + panes: agentWindows.length, + }, + agents: agentWindows, + details: detailWindow, + }, + steps, + commands: steps.map((step) => step.command), + }; +} + function createKittyCockpitPlan(options = {}) { const repoRoot = requireText(options.repoRoot, 'repoRoot'); const sessionName = text(options.sessionName, DEFAULT_SESSION_NAME); @@ -220,8 +435,50 @@ function createKittyCockpitPlan(options = {}) { }; } +function openKittyCockpit(options = {}) { + const repoRoot = requireText( + firstText(options.repoRoot, options.repoPath, options.target, process.cwd()), + 'repoRoot', + ); + const readState = typeof options.readState === 'function' ? options.readState : readCockpitState; + const readSettings = typeof options.readSettings === 'function' ? options.readSettings : readCockpitSettings; + const state = options.state || readState(repoRoot); + const settings = { + ...readSettings(repoRoot), + ...(options.settings || {}), + repoRoot, + sessionName: options.sessionName, + controlCommand: options.controlCommand, + controlTitle: options.controlTitle, + columns: options.columns, + kittyBin: options.kittyBin, + dryRun: options.dryRun, + focusControl: options.focusControl, + }; + const plan = buildKittyCockpitPlan(state, settings); + const execution = kittyRuntime.openKittyCockpit({ + plan, + dryRun: plan.dryRun, + runner: options.runner, + env: options.env, + timeout: options.timeout, + }); + + return { + action: 'created', + backend: 'kitty', + sessionName: plan.sessionName, + repoRoot: plan.repoRoot, + dryRun: plan.dryRun, + plan, + execution, + }; +} + module.exports = { + buildKittyCockpitPlan, DEFAULT_COLUMNS, DEFAULT_SESSION_NAME, createKittyCockpitPlan, + openKittyCockpit, }; diff --git a/src/cockpit/shortcuts.js b/src/cockpit/shortcuts.js index bdd58c2..05f325b 100644 --- a/src/cockpit/shortcuts.js +++ b/src/cockpit/shortcuts.js @@ -15,7 +15,7 @@ const SETTINGS_KEYBINDINGS = Object.freeze([ 'q quit', ]); -const CONTROL_KEY_HELP = 'keys: up/down select m/Alt+Shift+M menu v/h/x/p/r/c/o/a/b/f/T/A pane actions s settings q quit'; +const CONTROL_KEY_HELP = 'keys: up/down select j/k move enter view/open n new agent t terminal m menu s settings ? shortcuts q quit'; module.exports = { CONTROL_KEY_HELP, diff --git a/src/kitty/runtime.js b/src/kitty/runtime.js index 7519c75..b6e1d10 100644 --- a/src/kitty/runtime.js +++ b/src/kitty/runtime.js @@ -112,6 +112,68 @@ function runCommand(command, action, options = {}) { return runKitty(command.args, runOptions); } +function cloneCommand(command) { + if (!command || typeof command !== 'object' || !Array.isArray(command.args)) { + throw new TypeError('kitty cockpit command must include args'); + } + const clone = { + cmd: requireText(command.cmd, 'kitty cockpit command cmd'), + args: command.args.map((arg) => { + if (arg === undefined || arg === null) { + throw new TypeError('kitty cockpit command args must be strings'); + } + return String(arg); + }), + }; + if (Object.prototype.hasOwnProperty.call(command, 'input')) { + clone.input = command.input === undefined || command.input === null ? '' : String(command.input); + } + return clone; +} + +function cockpitCommands(plan = {}) { + if (!plan || typeof plan !== 'object') { + throw new TypeError('kitty cockpit plan must be an object'); + } + const commands = Array.isArray(plan.commands) + ? plan.commands + : Array.isArray(plan.steps) + ? plan.steps.map((step) => step && step.command).filter(Boolean) + : []; + return commands.map(cloneCommand); +} + +function assertCommandResult(command, result) { + if (result && result.error) throw result.error; + if (!result || result.status === 0) return result; + const detail = String(result.stderr || result.stdout || '').trim(); + throw new Error(`kitty cockpit command failed: ${command.cmd} ${command.args.join(' ')}${detail ? `: ${detail}` : ''}`); +} + +function openKittyCockpit(options = {}) { + const plan = options.plan && typeof options.plan === 'object' ? options.plan : options; + const commands = cockpitCommands(plan); + const dryRun = Boolean(options.dryRun || plan.dryRun); + + if (dryRun) { + return { + dryRun: true, + action: 'open-kitty-cockpit', + commands, + plan, + }; + } + + const results = commands.map((command) => ( + assertCommandResult(command, runCommand(command, 'open-kitty-cockpit', options)) + )); + return { + action: 'open-kitty-cockpit', + commands, + results, + }; +} + function launchKittyWindow(options = {}) { return runCommand( buildKittyLaunchCommand({ @@ -182,6 +244,7 @@ module.exports = { launchKittyWindow, launchKittyTab, launchKittyPane, + openKittyCockpit, sendTextToKitty, setKittyWindowTitle, }; diff --git a/test/cockpit-command.test.js b/test/cockpit-command.test.js index dc3e7ff..b100e28 100644 --- a/test/cockpit-command.test.js +++ b/test/cockpit-command.test.js @@ -18,6 +18,15 @@ function fakeTmux(scriptBody) { return { bin, log }; } +function fakeKitty(scriptBody) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-fake-kitty-')); + const bin = path.join(dir, 'kitty'); + const log = path.join(dir, 'kitty.log'); + fs.writeFileSync(bin, `#!/usr/bin/env bash\nset -euo pipefail\nLOG=${JSON.stringify(log)}\n${scriptBody}\n`, 'utf8'); + fs.chmodSync(bin, 0o755); + return { bin, log }; +} + function captureStdout() { const chunks = []; return { @@ -33,26 +42,31 @@ function captureStdout() { }; } -test('cockpit creates the default tmux session in the repo root', () => { +test('cockpit opens Kitty by default when remote control is available', () => { const repoDir = initRepo(); - const { bin, log } = fakeTmux( + const { bin, log } = fakeKitty( 'printf "%s\\n" "$PWD :: $*" >> "$LOG"\n' + - 'if [[ "$1" == "-V" ]]; then echo "tmux 3.4"; exit 0; fi\n' + - 'if [[ "$1" == "has-session" ]]; then exit 1; fi\n' + - 'if [[ "$1" == "new-session" ]]; then exit 0; fi\n' + - 'if [[ "$1" == "send-keys" ]]; then exit 0; fi\n' + + 'if [[ "${1:-}" == "--version" ]]; then echo "kitty 0.35.0"; exit 0; fi\n' + + 'if [[ "${1:-}" == "@" && "${2:-}" == "ls" ]]; then exit 0; fi\n' + + 'if [[ "${1:-}" == "@" && "${2:-}" == "launch" ]]; then exit 0; fi\n' + + 'if [[ "${1:-}" == "@" && "${2:-}" == "focus-window" ]]; then exit 0; fi\n' + 'exit 9\n', ); + const missingTmux = path.join(os.tmpdir(), `missing-tmux-${process.pid}-${Date.now()}`); - const result = runNodeWithEnv(['cockpit', '--target', repoDir], repoDir, { GUARDEX_TMUX_BIN: bin }); + const result = runNodeWithEnv(['cockpit', '--target', repoDir], repoDir, { + GUARDEX_KITTY_BIN: bin, + GUARDEX_TMUX_BIN: missingTmux, + }); assert.equal(result.status, 0, result.stderr || result.stdout); - assert.match(result.stdout, /Created tmux session 'guardex'/); + assert.match(result.stdout, /Created kitty cockpit window 'guardex'/); assert.match(result.stdout, /Control pane: gx cockpit control --target/); const lines = fs.readFileSync(log, 'utf8').trim().split('\n'); - assert.match(lines[1], /^.* :: has-session -t guardex$/); - assert.match(lines[2], new RegExp(`^${repoDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} :: new-session -d -s guardex -c ${repoDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`)); - assert.match(lines[3], /^.* :: send-keys -t guardex gx cockpit control --target .* C-m$/); + assert.match(lines[0], /^.* :: --version$/); + assert.match(lines[1], /^.* :: @ ls$/); + assert.match(lines[2], /^.* :: @ launch --type=window --cwd .* --title gx cockpit -- sh -lc gx cockpit control --target .*$/); + assert.match(lines[3], /^.* :: @ focus-window --match title:gx cockpit$/); }); test('cockpit attaches when the tmux session already exists', () => { @@ -65,7 +79,7 @@ test('cockpit attaches when the tmux session already exists', () => { 'exit 9\n', ); - const result = runNodeWithEnv(['cockpit', '--session', 'guardex-dev', '--target', repoDir], repoDir, { + const result = runNodeWithEnv(['cockpit', '--backend', 'tmux', '--session', 'guardex-dev', '--target', repoDir], repoDir, { GUARDEX_TMUX_BIN: bin, }); @@ -89,7 +103,7 @@ test('cockpit --attach creates then attaches when the session is missing', () => 'exit 9\n', ); - const result = runNodeWithEnv(['cockpit', '--session=guardex-dev', '--attach', '--target', repoDir], repoDir, { + const result = runNodeWithEnv(['cockpit', '--backend=tmux', '--session=guardex-dev', '--attach', '--target', repoDir], repoDir, { GUARDEX_TMUX_BIN: bin, }); @@ -104,7 +118,10 @@ test('cockpit reports a helpful error when tmux is unavailable', () => { const repoDir = initRepo(); const missingTmux = path.join(os.tmpdir(), `missing-tmux-${process.pid}-${Date.now()}`); - const result = runNodeWithEnv(['cockpit', '--target', repoDir], repoDir, { GUARDEX_TMUX_BIN: missingTmux }); + const result = runNodeWithEnv(['cockpit', '--target', repoDir], repoDir, { + GUARDEX_KITTY_BIN: path.join(os.tmpdir(), `missing-kitty-${process.pid}-${Date.now()}`), + GUARDEX_TMUX_BIN: missingTmux, + }); assert.equal(result.status, 1); assert.match(result.stderr, /tmux is required for gx cockpit\. Install tmux and retry\./); @@ -143,13 +160,20 @@ test('default cockpit launcher prefers Kitty when remote control is available', resolveRepoRoot: (target) => target, terminalBackends: { kitty: kittyBackend, tmux: tmuxBackend }, stdout, + dryRun: true, + readState: () => ({ + repoPath: repoDir, + baseBranch: 'main', + sessions: [], + }), + readSettings: () => ({}), env: {}, }); assert.equal(result.backend, 'kitty'); - assert.equal(calls.length, 1); - assert.equal(calls[0].config.repoRoot, repoDir); - assert.match(calls[0].config.command, /gx cockpit control --target /); + assert.equal(calls.length, 0); + assert.equal(result.plan.repoRoot, repoDir); + assert.match(result.plan.controlPaneCommand, /gx cockpit control --target /); assert.match(output(), /Created kitty cockpit window 'guardex'/); }); @@ -214,5 +238,5 @@ test('default cockpit launcher renders inline when terminal backends fail', () = assert.equal(result.backend, 'inline'); assert.deepEqual(result.failures, [{ backend: 'tmux', message: 'tmux unavailable' }]); assert.match(output(), /gx cockpit/); - assert.match(output(), /no active lanes/); + assert.match(output(), /No active agent lanes|no agent lanes/); }); diff --git a/test/cockpit-control.test.js b/test/cockpit-control.test.js index e3c55bf..74f7312 100644 --- a/test/cockpit-control.test.js +++ b/test/cockpit-control.test.js @@ -246,6 +246,21 @@ test('renderControlFrame renders sidebar with details, menu, and settings modes' assert.match(shortcuts, /j\/down: next lane/); }); +test('renderControlFrame shows the GitGuardex welcome panel when no lanes exist', () => { + const state = applyCockpitAction({}, { + type: 'refresh', + cockpitState: snapshot([]), + settings: { sidebarWidth: 34, theme: 'none' }, + }); + + const output = renderControlFrame(state); + + assert.match(output, /gitguardex \| gx cockpit/); + assert.match(output, /Guardian cockpit ready/); + assert.match(output, /n new agent/); + assert.doesNotMatch(output, /No session selected/); +}); + test('control re-exports pure applyCockpitKey helper', () => { assert.equal(applyCockpitKey({ mode: 'main' }, 's').mode, 'settings'); }); @@ -308,10 +323,10 @@ test('startCockpitControl reads state/settings, refreshes, and handles TTY keys' assert.deepEqual(input.rawModes, [true, false]); }); -test('openCockpit sends the control loop command into the tmux control pane', () => { +test('openCockpit --backend tmux sends the control loop command into the tmux control pane', () => { const stdout = []; const sent = []; - const result = cockpit.openCockpit(['--target', '/repo/gitguardex'], { + const result = cockpit.openCockpit(['--backend', 'tmux', '--target', '/repo/gitguardex'], { resolveRepoRoot: (target) => target, toolName: 'gx', stdout: { diff --git a/test/cockpit-kitty-integration.test.js b/test/cockpit-kitty-integration.test.js index 2bdcd58..e05442d 100644 --- a/test/cockpit-kitty-integration.test.js +++ b/test/cockpit-kitty-integration.test.js @@ -37,6 +37,7 @@ function fakeKitty() { 'if [[ "${1:-}" == "--version" ]]; then echo "kitty 0.35.0"; exit 0; fi', 'if [[ "${1:-}" == "@" && "${2:-}" == "ls" ]]; then exit 0; fi', 'if [[ "${1:-}" == "@" && "${2:-}" == "launch" ]]; then exit 0; fi', + 'if [[ "${1:-}" == "@" && "${2:-}" == "focus-window" ]]; then exit 0; fi', 'exit 9', ].join('\n')); } @@ -63,12 +64,12 @@ function assertKittyLaunchLine(line, repoDir) { assert.match(line, /-- sh -lc gx cockpit control --target /); } -test('gx cockpit --backend auto command path opens Kitty when remote control answers', () => { +test('gx cockpit command path opens Kitty by default when remote control answers', () => { const repoDir = initRepo(); const kitty = fakeKitty(); const missingTmux = path.join(os.tmpdir(), `guardex-missing-tmux-${process.pid}-${Date.now()}`); - const result = runNodeWithEnv(['cockpit', '--backend', 'auto', '--session', 'guardex-auto', '--target', repoDir], repoDir, { + const result = runNodeWithEnv(['cockpit', '--session', 'guardex-auto', '--target', repoDir], repoDir, { GUARDEX_KITTY_BIN: kitty.bin, GUARDEX_TMUX_BIN: missingTmux, }); @@ -76,10 +77,11 @@ test('gx cockpit --backend auto command path opens Kitty when remote control ans assert.equal(result.status, 0, result.stderr || result.stdout); assert.match(result.stdout, /Created kitty cockpit window 'guardex-auto'/); const lines = readLogLines(kitty.log); - assert.equal(lines.length, 3); + assert.equal(lines.length, 4); assert.match(lines[0], / :: --version$/); assert.match(lines[1], / :: @ ls$/); assertKittyLaunchLine(lines[2], repoDir); + assert.match(lines[3], / :: @ focus-window --match title:gx cockpit$/); }); test('gx cockpit --backend kitty dry-run layout plan is deterministic and does not execute Kitty', () => { diff --git a/test/cockpit-kitty-layout.test.js b/test/cockpit-kitty-layout.test.js index 930d130..d5f71e1 100644 --- a/test/cockpit-kitty-layout.test.js +++ b/test/cockpit-kitty-layout.test.js @@ -3,25 +3,71 @@ const assert = require('node:assert/strict'); const test = require('node:test'); -const { createKittyCockpitPlan } = require('../src/cockpit/kitty-layout'); +const cockpit = require('../src/cockpit'); +const { + buildKittyCockpitPlan, + openKittyCockpit, +} = require('../src/cockpit/kitty-layout'); -function agent(id, extra = {}) { +function session(id, extra = {}) { return { id, agent: 'codex', - worktree: `/repo/.omx/agent-worktrees/${id}`, - command: `cd /repo/.omx/agent-worktrees/${id} && exec codex`, + branch: `agent/codex/${id}`, + status: 'active', + worktreePath: `/repo/.omx/agent-worktrees/${id}`, + worktreeExists: true, + metadata: { + 'colony.task': id, + }, + launchCommand: 'exec codex', ...extra, }; } -test('one agent creates control and agent launch commands', () => { - const plan = createKittyCockpitPlan({ +function state(sessions) { + return { + repoPath: '/repo/gitguardex', + baseBranch: 'main', + agentsStatus: { + schemaVersion: 1, + repoRoot: '/repo/gitguardex', + sessions, + }, + }; +} + +test('empty state opens welcome/control only in dry-run mode', () => { + let called = false; + const result = openKittyCockpit({ repoRoot: '/repo/gitguardex', + state: state([]), + settings: {}, + readSettings: () => ({}), + sessionName: 'guardex-dev', + dryRun: true, + runner() { + called = true; + }, + }); + + assert.equal(called, false); + assert.equal(result.dryRun, true); + assert.equal(result.plan.welcome, true); + assert.deepEqual( + result.plan.steps.map((step) => step.id), + ['launch-control', 'focus-control'], + ); + assert.equal(result.plan.layout.control.title, 'guardex-dev: welcome'); + assert.deepEqual(result.plan.layout.agents, []); + assert.equal(result.plan.controlPaneCommand, "gx cockpit control --target '/repo/gitguardex'"); + assert.deepEqual(result.plan.titles, ['guardex-dev: welcome']); + assert.deepEqual(result.execution.commands, result.plan.commands); +}); + +test('one agent gets one safe lane pane without launching an agent', () => { + const plan = buildKittyCockpitPlan(state([session('alpha')]), { sessionName: 'guardex-dev', - agents: [agent('alpha')], - controlCommand: "gx cockpit control --target '/repo/gitguardex'", - welcomeCommand: 'gx', dryRun: true, }); @@ -29,25 +75,17 @@ test('one agent creates control and agent launch commands', () => { assert.equal(plan.dryRun, true); assert.deepEqual( plan.steps.map((step) => step.id), - ['launch-control', 'launch-agent-area', 'launch-agent-1', 'focus-control'], + ['launch-control', 'launch-agent-1', 'focus-control'], ); - assert.deepEqual(plan.steps[0].command, { - cmd: 'kitty', - args: [ - '@', - 'launch', - '--type=window', - '--cwd', - '/repo/gitguardex', - '--title', - 'guardex-dev: control', - '--', - 'sh', - '-lc', - "gx cockpit control --target '/repo/gitguardex'", - ], - }); - assert.deepEqual(plan.steps[2].command.args, [ + assert.equal(plan.welcome, false); + assert.equal(plan.layout.agents.length, 1); + assert.equal(plan.layout.agents[0].title, '01: codex alpha'); + assert.equal(plan.layout.agents[0].cwd, '/repo/.omx/agent-worktrees/alpha'); + assert.equal(plan.layout.agents[0].metadata['colony.task'], 'alpha'); + assert.equal(plan.layout.agents[0].launchCommand, 'exec codex'); + assert.match(plan.layout.agents[0].command, /exec \$\{SHELL:-bash\}/); + assert.doesNotMatch(plan.layout.agents[0].command, /exec codex/); + assert.deepEqual(plan.steps[1].command.args, [ '@', 'launch', '--type=window', @@ -59,72 +97,95 @@ test('one agent creates control and agent launch commands', () => { '--', 'sh', '-lc', - 'cd /repo/.omx/agent-worktrees/alpha && exec codex', + "printf '%s\\n' 'GitGuardex cockpit lane: agent/codex/alpha'; exec ${SHELL:-bash}", ]); }); -test('many agents create stable titles', () => { - const agents = Array.from({ length: 12 }, (_, index) => agent(`agent-${index + 1}`, { - agent: index % 2 === 0 ? 'codex' : 'claude', - })); - const plan = createKittyCockpitPlan({ - repoRoot: '/repo/gitguardex', +test('two agents create one pane per active lane with stable titles and cwd', () => { + const plan = buildKittyCockpitPlan(state([ + session('alpha'), + session('beta', { agent: 'claude' }), + ]), { sessionName: 'guardex', - agents, + dryRun: true, }); + assert.equal(plan.layout.agentArea.panes, 2); assert.deepEqual( - plan.layout.agents.map((entry) => entry.title), + plan.titles, + ['guardex: control', '01: codex alpha', '02: claude beta'], + ); + assert.deepEqual( + plan.agentPaneCommands.map((pane) => ({ title: pane.title, cwd: pane.cwd, worktree: pane.worktree })), + [ + { + title: '01: codex alpha', + cwd: '/repo/.omx/agent-worktrees/alpha', + worktree: '/repo/.omx/agent-worktrees/alpha', + }, + { + title: '02: claude beta', + cwd: '/repo/.omx/agent-worktrees/beta', + worktree: '/repo/.omx/agent-worktrees/beta', + }, + ], + ); + assert.equal(plan.layout.agents[0].location, 'vsplit'); + assert.equal(plan.layout.agents[1].location, 'hsplit'); + assert.deepEqual( + plan.workingDirectories.map((entry) => [entry.role, entry.cwd]), [ - '01: codex agent-1', - '02: claude agent-2', - '03: codex agent-3', - '04: claude agent-4', - '05: codex agent-5', - '06: claude agent-6', - '07: codex agent-7', - '08: claude agent-8', - '09: codex agent-9', - '10: claude agent-10', - '11: codex agent-11', - '12: claude agent-12', + ['control', '/repo/gitguardex'], + ['agent', '/repo/.omx/agent-worktrees/alpha'], + ['agent', '/repo/.omx/agent-worktrees/beta'], ], ); - assert.equal(new Set(plan.layout.agents.map((entry) => entry.match)).size, 12); }); -test('repoRoot and worktree cwd are preserved', () => { - const plan = createKittyCockpitPlan({ - repoRoot: '/repo/gitguardex', - agents: [ - agent('alpha', { - cwd: '/repo/worktrees/alpha', - worktree: '/repo/worktrees/alpha', - title: 'alpha lane', - }), - ], +test('missing worktree falls back to repo root cwd', () => { + const plan = buildKittyCockpitPlan(state([ + session('missing', { + worktreePath: '/repo/.omx/agent-worktrees/missing', + worktreeExists: false, + }), + ]), { + sessionName: 'guardex', + dryRun: true, }); assert.equal(plan.layout.control.cwd, '/repo/gitguardex'); - assert.equal(plan.layout.agentArea.cwd, '/repo/gitguardex'); - assert.equal(plan.layout.agents[0].cwd, '/repo/worktrees/alpha'); - assert.equal(plan.layout.agents[0].worktree, '/repo/worktrees/alpha'); - assert.equal(plan.steps[2].command.args[5], '/repo/worktrees/alpha'); + assert.equal(plan.layout.agents[0].cwd, '/repo/gitguardex'); + assert.equal(plan.layout.agents[0].worktree, '/repo/.omx/agent-worktrees/missing'); + assert.equal(plan.layout.agents[0].missingWorktree, true); + assert.deepEqual(plan.agentPaneCommands[0], { + id: 'missing', + title: '01: codex missing', + cwd: '/repo/gitguardex', + worktree: '/repo/.omx/agent-worktrees/missing', + command: "printf '%s\\n' 'GitGuardex cockpit lane: agent/codex/missing'; exec ${SHELL:-bash}", + missingWorktree: true, + }); + assert.deepEqual(plan.steps[1].command.args.slice(4, 6), ['--cwd', '/repo/gitguardex']); }); -test('plan is deterministic', () => { - const input = { - repoRoot: '/repo/gitguardex', - sessionName: 'guardex-dev', - agents: [agent('alpha'), agent('beta', { agent: 'claude' })], - controlCommand: 'gx cockpit control', - welcomeCommand: 'gx', - columns: 160, +test('cockpit index exports Kitty opener and parses --kitty', () => { + const stdout = []; + const result = cockpit.openCockpit(['--kitty', '--target', '/repo/gitguardex'], { + resolveRepoRoot: (target) => target, + toolName: 'gx', + stdout: { + write(chunk) { + stdout.push(String(chunk)); + }, + }, dryRun: true, - }; + readState: () => state([session('alpha')]), + readSettings: () => ({}), + }); - assert.deepEqual( - createKittyCockpitPlan(JSON.parse(JSON.stringify(input))), - createKittyCockpitPlan(JSON.parse(JSON.stringify(input))), - ); + assert.equal(result.action, 'created'); + assert.equal(result.backend, 'kitty'); + assert.equal(result.plan.layout.agents.length, 1); + assert.equal(typeof cockpit.openKittyCockpit, 'function'); + assert.match(stdout.join(''), /Created kitty cockpit window 'guardex'/); }); diff --git a/test/cockpit-terminal-backend.test.js b/test/cockpit-terminal-backend.test.js index 40f7d98..ac54f36 100644 --- a/test/cockpit-terminal-backend.test.js +++ b/test/cockpit-terminal-backend.test.js @@ -41,6 +41,13 @@ function cockpitBackendHarness({ kittyAvailable = true } = {}) { kitty: backend('kitty'), tmux: backend('tmux'), }, + dryRun: true, + readState: () => ({ + repoPath: '/repo/gitguardex', + baseBranch: 'main', + sessions: [], + }), + readSettings: () => ({}), }, }; } @@ -304,14 +311,11 @@ test('cockpit --backend kitty opens through the selected backend', () => { assert.equal(result.backend, 'kitty'); assert.equal(result.sessionName, 'guardex-dev'); - assert.deepEqual(calls.kitty, [ - { - repoRoot: '/repo/gitguardex', - sessionName: 'guardex-dev', - command: "gx cockpit control --target '/repo/gitguardex'", - attach: false, - }, - ]); + assert.equal(calls.kitty.length, 0); + assert.equal(result.plan.repoRoot, '/repo/gitguardex'); + assert.equal(result.plan.sessionName, 'guardex-dev'); + assert.equal(result.plan.layout.control.title, 'gx cockpit'); + assert.equal(result.plan.controlPaneCommand, "gx cockpit control --target '/repo/gitguardex'"); assert.match(stdout.join(''), /Created kitty cockpit window 'guardex-dev'/); assert.match(stdout.join(''), /Control pane: gx cockpit control --target '\/repo\/gitguardex'/); }); @@ -343,7 +347,20 @@ test('cockpit --backend auto prefers kitty through the CLI option', () => { }); assert.equal(result.backend, 'kitty'); - assert.equal(calls.kitty.length, 1); + assert.equal(result.plan.layout.control.title, 'gx cockpit'); + assert.equal(calls.kitty.length, 0); + assert.equal(calls.tmux.length, 0); +}); + +test('cockpit defaults to auto backend and prefers kitty', () => { + const { calls, deps } = cockpitBackendHarness({ kittyAvailable: true }); + const result = cockpit.openCockpit(['--target', '/repo/gitguardex'], { + ...deps, + }); + + assert.equal(result.backend, 'kitty'); + assert.equal(result.plan.layout.control.title, 'gx cockpit'); + assert.equal(calls.kitty.length, 0); assert.equal(calls.tmux.length, 0); });