From 8e8da15a26e4b32e4a685ae6767b33c4a8c8c700 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 4 May 2026 06:35:13 +0200 Subject: [PATCH] Make cockpit default to interactive Kitty control Bare gx cockpit previously opened the tmux layout, which kept the control UI inside the caller's current terminal and left the empty cockpit state with very little actionable navigation. The command now defaults to the auto backend so available Kitty remote control opens a separate control window, gives that window the stable gx cockpit title, and focuses it after launch. The control frame also gains the empty-state welcome/actions and dmux-style key handling needed to navigate without an existing agent lane. Constraint: Existing --backend tmux behavior must remain available for tmux users and tests. Rejected: Force Kitty with no fallback | tmux fallback is still needed when Kitty or remote control is unavailable. Confidence: high Scope-risk: moderate Tested: /home/deadpool/.local/bin/rtk test node --test test/cockpit-command.test.js test/cockpit-control.test.js test/cockpit-keybindings.test.js test/cockpit-kitty-layout.test.js test/cockpit-kitty-integration.test.js test/cockpit-terminal-backend.test.js test/kitty-runtime.test.js Tested: /home/deadpool/.local/bin/rtk test node --test test/cockpit*.test.js test/default-gx-cockpit.test.js test/terminal-kitty.test.js Tested: git diff --check Tested: openspec validate --specs Not-tested: Full /home/deadpool/.local/bin/rtk test npm test hung in unrelated test/sandbox.test.js and was stopped after focused cockpit coverage passed. Co-authored-by: OmX --- src/cockpit/control.js | 204 +++++++++++++++++--- src/cockpit/index.js | 31 ++- src/cockpit/keybindings.js | 100 +++++++++- src/cockpit/kitty-layout.js | 257 +++++++++++++++++++++++++ src/cockpit/shortcuts.js | 2 +- src/kitty/runtime.js | 63 ++++++ test/cockpit-command.test.js | 60 ++++-- test/cockpit-control.test.js | 19 +- test/cockpit-kitty-integration.test.js | 8 +- test/cockpit-kitty-layout.test.js | 213 ++++++++++++-------- test/cockpit-terminal-backend.test.js | 35 +++- 11 files changed, 853 insertions(+), 139 deletions(-) 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); });