From 7540b791499b4e5cde0b8f6e17ac15b755fd9b6e Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Fri, 1 May 2026 00:31:55 +0200 Subject: [PATCH 1/2] Make cockpit lanes scan like dmux The cockpit sidebar now prioritizes task rows over branch metadata so users can scan active lanes, statuses, and shortcuts in the same compact shape as dmux. Constraint: User requested edits only in src/cockpit/sidebar.js and test/cockpit-sidebar.test.js Rejected: Keep branch-first multi-line rows | too noisy for the requested dmux-like cockpit sidebar Confidence: high Scope-risk: narrow Tested: node --check src/cockpit/sidebar.js Tested: node --test test/cockpit-sidebar.test.js test/cockpit-control.test.js Tested: node --test test/cockpit-*.test.js Not-tested: interactive Kitty cockpit rendering Co-authored-by: OmX --- src/cockpit/sidebar.js | 195 ++++++++++++++++++++++++----------- test/cockpit-sidebar.test.js | 132 +++++++++++++++++------- 2 files changed, 225 insertions(+), 102 deletions(-) diff --git a/src/cockpit/sidebar.js b/src/cockpit/sidebar.js index b65ea70c..2cc6f171 100644 --- a/src/cockpit/sidebar.js +++ b/src/cockpit/sidebar.js @@ -3,22 +3,23 @@ const path = require('node:path'); const DEFAULT_WIDTH = 36; const MIN_WIDTH = 12; -const STATUS_DOTS = new Map([ - ['active', '*'], - ['running', '*'], - ['working', '*'], - ['thinking', 'o'], - ['idle', 'o'], - ['ready', 'o'], - ['done', '+'], - ['complete', '+'], - ['completed', '+'], - ['merged', '+'], - ['blocked', '!'], - ['error', '!'], - ['failed', '!'], - ['stalled', '!'], - ['dead', '!'], +const STATUS_STATES = new Map([ + ['active', 'active'], + ['running', 'active'], + ['working', 'active'], + ['thinking', 'waiting'], + ['idle', 'waiting'], + ['ready', 'waiting'], + ['waiting', 'waiting'], + ['done', 'done'], + ['complete', 'done'], + ['completed', 'done'], + ['merged', 'done'], + ['blocked', 'blocked'], + ['error', 'failed'], + ['failed', 'failed'], + ['stalled', 'stalled'], + ['dead', 'stalled'], ]); const ANSI = { @@ -31,6 +32,15 @@ const ANSI = { inverse: '\x1b[7m', }; +const AGENT_LABELS = new Map([ + ['codex', 'cx'], + ['claude', 'cc'], + ['claude-code', 'cc'], + ['claudecode', 'cc'], + ['cursor', 'cu'], + ['gemini', 'gm'], +]); + function text(value, fallback = '') { if (typeof value === 'string') { return value.trim() || fallback; @@ -81,24 +91,60 @@ function repoName(state = {}, options = {}) { } function agentLabel(agentName) { - const compact = text(agentName, 'agent').replace(/[^a-z0-9]/gi, '').toUpperCase(); - return truncate(compact || 'AGENT', 3).padEnd(3, ' '); + const raw = text(agentName, 'agent').toLowerCase(); + const compact = raw.replace(/[^a-z0-9]/g, ''); + if (AGENT_LABELS.has(raw)) { + return AGENT_LABELS.get(raw); + } + if (AGENT_LABELS.has(compact)) { + return AGENT_LABELS.get(compact); + } + if (compact.includes('codex')) { + return 'cx'; + } + if (compact.includes('claude')) { + return 'cc'; + } + + const parts = raw.match(/[a-z0-9]+/g) || []; + if (parts.length >= 2) { + return `${parts[0][0]}${parts[1][0]}`; + } + return truncate(parts[0] || compact || 'ag', 2).padEnd(2, 'g'); } function statusDot(session = {}) { - if (session.worktreeExists === false) { + const status = laneState(session); + if (status === 'active') { + return '*'; + } + if (status === 'waiting') { + return 'o'; + } + if (status === 'done') { + return '+'; + } + if (status === 'missing') { return 'x'; } - const status = text(session.status, 'unknown').toLowerCase(); - return STATUS_DOTS.get(status) || '.'; + if (status === 'blocked' || status === 'failed' || status === 'stalled') { + return '!'; + } + return '.'; } -function lockCount(session = {}) { - if (Array.isArray(session.locks)) { - return session.locks.length; +function laneState(session = {}) { + const status = text(session.status, 'unknown').toLowerCase(); + if (session.hidden === true || session.visible === false || status === 'hidden') { + return 'hidden'; } - const count = Number(session.lockCount); - return Number.isFinite(count) && count >= 0 ? count : 0; + if (session.closed === true || session.closedAt || status === 'closed') { + return 'closed'; + } + if (session.worktreeExists === false || session.worktreeMissing === true || status === 'missing' || status === 'missing-worktree') { + return 'missing'; + } + return STATUS_STATES.get(status) || status || 'unknown'; } function sessionId(session = {}) { @@ -120,51 +166,80 @@ function isSelected(session, index, state = {}, options = {}) { return Number.isInteger(selectedIndex) && selectedIndex === index; } +function colorEnabled(options = {}) { + const env = options.env && typeof options.env === 'object' ? options.env : process.env; + return options.color === true && !options.noColor && !env.NO_COLOR; +} + function colorize(value, color, options = {}) { - if (options.noColor || options.color !== true) { + if (!colorEnabled(options)) { return value; } const code = ANSI[color]; return code ? `${code}${value}${ANSI.reset}` : value; } -function statusColor(dot) { - if (dot === '*') { +function statusColor(status) { + if (status === 'active' || status === 'done') { return 'green'; } - if (dot === '!') { + if (status === 'waiting') { return 'yellow'; } - if (dot === 'x') { + if (status === 'blocked' || status === 'failed' || status === 'stalled' || status === 'missing') { return 'red'; } - if (dot === '+') { - return 'cyan'; + if (status === 'hidden' || status === 'closed') { + return 'dim'; } - return 'dim'; + return 'cyan'; +} + +function laneName(session = {}) { + const task = text(session.task || session.name || session.title); + if (task) { + return task; + } + + const branch = text(session.branch); + if (!branch) { + return '(no task)'; + } + return path.basename(branch); +} + +function fitRow(left, right, width) { + if (width <= 0) { + return ''; + } + + if (right.length >= width - 2) { + return truncate(`${left}${right}`, width); + } + + const leftWidth = width - right.length; + return `${truncate(left, leftWidth).padEnd(leftWidth, ' ')}${right}`; +} + +function renderShortcutRows(width, options) { + const rows = [ + ' [n]ew agent [t]erminal', + ' [s]ettings [?] shortcuts', + ]; + return rows.map((row) => colorize(boundLine(row, width), 'dim', options)); } function renderSessionRow(session, index, state, options) { const width = sidebarWidth(options); const selected = isSelected(session, index, state, options); const marker = selected ? '>' : ' '; - const dot = statusDot(session); - const label = agentLabel(session.agentName); - const branch = text(session.branch, '(no branch)'); - const task = text(session.task, '(no task)'); - const missing = session.worktreeExists === false ? ' missing worktree' : ''; - - const firstPrefix = `${marker} ${dot} ${label} `; - const first = `${firstPrefix}${truncate(branch, width - firstPrefix.length)}`; - const taskPrefix = ' '; - const taskLine = `${taskPrefix}${truncate(task, width - taskPrefix.length)}`; - const meta = ` locks: ${lockCount(session)}${missing}`; - - return [ - selected ? colorize(boundLine(first, width), 'inverse', options) : boundLine(first, width), - boundLine(taskLine, width), - colorize(boundLine(meta, width), statusColor(dot), options), - ]; + const status = laneState(session); + const badge = `[${agentLabel(session.agentName || session.agent || session.owner)}] (${status})`; + const row = fitRow(`${marker} ${laneName(session)}`, ` ${badge}`, width); + + return selected + ? colorize(row, 'inverse', options) + : colorize(row, statusColor(status), options); } function renderSidebar(state = {}, options = {}) { @@ -174,26 +249,19 @@ function renderSidebar(state = {}, options = {}) { : text(options.title || state.title, 'gx cockpit'); const sessions = Array.isArray(state.sessions) ? state.sessions : []; const lines = [ - boundLine(title, width), - boundLine(`repo ${repoName(state, options)}`, width), - boundLine('-'.repeat(width), width), - boundLine('lanes', width), + colorize(boundLine(title, width), 'cyan', options), + colorize(boundLine(repoName(state, options), width), 'dim', options), ]; if (sessions.length === 0) { - lines.push(boundLine(' no active lanes', width)); + lines.push(boundLine(' no agent lanes', width)); } else { sessions.forEach((session, index) => { - lines.push(...renderSessionRow(session, index, state, options)); + lines.push(renderSessionRow(session, index, state, options)); }); } - lines.push( - boundLine('-'.repeat(width), width), - boundLine('[n] new agent', width), - boundLine('[t] terminal', width), - boundLine('[s] settings', width), - ); + lines.push(...renderShortcutRows(width, options)); return `${lines.join('\n')}\n`; } @@ -201,6 +269,7 @@ function renderSidebar(state = {}, options = {}) { module.exports = { renderSidebar, agentLabel, + laneState, statusDot, truncate, }; diff --git a/test/cockpit-sidebar.test.js b/test/cockpit-sidebar.test.js index d0ef92dd..1cb76e94 100644 --- a/test/cockpit-sidebar.test.js +++ b/test/cockpit-sidebar.test.js @@ -7,22 +7,53 @@ function lines(output) { return output.trimEnd().split('\n'); } -test('renderSidebar renders an empty sidebar', () => { +function stripAnsi(output) { + return output.replace(/\x1b\[[0-9;]*m/g, ''); +} + +test('renderSidebar renders an empty repo sidebar', () => { const output = renderSidebar({ repoPath: '/work/gitguardex', sessions: [], }, { noColor: true }); - assert.match(output, /gx cockpit/); - assert.match(output, /repo gitguardex/); - assert.match(output, /lanes/); - assert.match(output, /no active lanes/); - assert.match(output, /\[n\] new agent/); - assert.match(output, /\[t\] terminal/); - assert.match(output, /\[s\] settings/); + assert.deepEqual(lines(output), [ + 'gx cockpit', + 'gitguardex', + ' no agent lanes', + ' [n]ew agent [t]erminal', + ' [s]ettings [?] shortcuts', + ]); +}); + +test('renderSidebar renders multiple dmux-style agent rows', () => { + const output = renderSidebar({ + repoName: 'gitguardex', + sessions: [ + { + id: 's1', + agentName: 'codex', + branch: 'agent/codex/first', + task: 'build sidebar', + status: 'working', + worktreeExists: true, + }, + { + id: 's2', + agentName: 'claude-code', + branch: 'agent/claude/second', + task: 'review cockpit', + status: 'idle', + worktreeExists: true, + }, + ], + }, { width: 42, noColor: true }); + + assert.match(output, /^ build sidebar\s+\[cx\] \(active\)$/m); + assert.match(output, /^ review cockpit\s+\[cc\] \(waiting\)$/m); }); -test('renderSidebar marks the selected session', () => { +test('renderSidebar marks the selected row', () => { const output = renderSidebar({ repoName: 'gitguardex', selectedSessionId: 's2', @@ -33,81 +64,104 @@ test('renderSidebar marks the selected session', () => { branch: 'agent/codex/first', task: 'first lane', status: 'idle', - lockCount: 0, worktreeExists: true, }, { id: 's2', agentName: 'claude', - branch: 'agent/claude/second', + branch: 'agent/claude/selected', task: 'selected lane', status: 'working', - lockCount: 2, worktreeExists: true, }, ], - }, { noColor: true }); + }, { width: 40, noColor: true }); - assert.match(output, /^ o COD agent\/codex\/first$/m); - assert.match(output, /^> \* CLA agent\/claude\/second$/m); + assert.match(output, /^ first lane\s+\[cx\] \(waiting\)$/m); + assert.match(output, /^> selected lane\s+\[cc\] \(active\)$/m); }); -test('renderSidebar marks a missing worktree', () => { +test('renderSidebar exposes hidden closed and missing worktree states', () => { const output = renderSidebar({ repoName: 'gitguardex', sessions: [ { - id: 'missing', + id: 'hidden', agentName: 'codex', - branch: 'agent/codex/missing', - task: 'repair missing lane', + task: 'quiet lane', + status: 'working', + hidden: true, + worktreeExists: true, + }, + { + id: 'closed', + agentName: 'claude', + task: 'closed lane', + status: 'idle', + closed: true, + worktreeExists: true, + }, + { + id: 'missing', + agentName: 'cursor', + task: 'missing lane', status: 'stalled', - lockCount: 1, worktreeExists: false, }, ], - }, { noColor: true }); + }, { width: 40, noColor: true }); - assert.match(output, /^ x COD agent\/codex\/missing$/m); - assert.match(output, /locks: 1 missing worktree/); + assert.match(output, /^ quiet lane\s+\[cx\] \(hidden\)$/m); + assert.match(output, /^ closed lane\s+\[cc\] \(closed\)$/m); + assert.match(output, /^ missing lane\s+\[cu\] \(missing\)$/m); }); -test('renderSidebar truncates long branch and task text', () => { +test('renderSidebar truncates long names cleanly', () => { const output = renderSidebar({ repoName: 'gitguardex', sessions: [ { id: 'long', agentName: 'codex', - branch: 'agent/codex/this-branch-name-is-too-long-for-the-sidebar', - task: 'this task description is also too long for the bounded dmux-style sidebar', + branch: 'agent/codex/long', + task: 'this lane name is much too long for the dmux sidebar', status: 'working', - lockCount: 0, worktreeExists: true, }, ], - }, { width: 30, noColor: true }); + }, { width: 34, noColor: true }); - assert.ok(lines(output).every((line) => line.length <= 30)); - assert.match(output, /agent\/codex\/this-br\.\.\./); - assert.match(output, /this task description i\.\.\./); + assert.ok(lines(output).every((line) => line.length <= 34)); + assert.match(output, /\.\.\. \[cx\] \(active\)/); }); -test('renderSidebar displays lock counts', () => { - const output = renderSidebar({ +test('renderSidebar disables ANSI color for no-color modes', () => { + const state = { repoName: 'gitguardex', sessions: [ { - id: 'locks', + id: 'color', agentName: 'codex', - branch: 'agent/codex/locks', - task: 'lock count lane', + task: 'colored lane', status: 'working', - lockCount: 7, worktreeExists: true, }, ], - }, { noColor: true }); + }; + + assert.match(renderSidebar(state, { color: true, env: {} }), /\x1b\[/); + assert.doesNotMatch(renderSidebar(state, { color: true, noColor: true, env: {} }), /\x1b\[/); + assert.doesNotMatch(renderSidebar(state, { color: true, env: { NO_COLOR: '1' } }), /\x1b\[/); +}); + +test('renderSidebar keeps shortcuts visible', () => { + const output = stripAnsi(renderSidebar({ + repoName: 'gitguardex', + sessions: [], + }, { color: true, env: {} })); - assert.match(output, /locks: 7/); + assert.match(output, /\[n\]ew agent/); + assert.match(output, /\[t\]erminal/); + assert.match(output, /\[s\]ettings/); + assert.match(output, /\[\?\] shortcuts/); }); From 8ea4c1a0222338830b0f95b5d69ed4d1bb135d77 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Fri, 1 May 2026 00:34:22 +0200 Subject: [PATCH 2/2] Enable keyboard-first cockpit navigation The cockpit control loop needed one pure keybinding path so dmux-style shortcuts behave consistently across the main list, popups, settings, and empty-lane action rows. Constraint: Do not launch agents from the shortcut handler in this change Constraint: Keep edits limited to cockpit keybindings, control, and related tests Rejected: Keep enter opening the pane menu | requested behavior is enter views the selected lane Confidence: high Scope-risk: narrow Directive: Keep key resolution in src/cockpit/keybindings.js; control should translate resolved actions into intents or display modes Tested: node --test test/cockpit-keybindings.test.js test/cockpit-control.test.js Tested: node --test test/cockpit-kitty-integration.test.js test/cockpit-pane-menu.test.js test/cockpit-settings.test.js test/cockpit-settings-render.test.js Not-tested: interactive terminal rendering in a real Kitty/tmux cockpit session Co-authored-by: OmX --- test/cockpit-control.test.js | 75 +++++++++++++++++++++- test/cockpit-keybindings.test.js | 107 +++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 2 deletions(-) diff --git a/test/cockpit-control.test.js b/test/cockpit-control.test.js index 6bb2dd96..41487148 100644 --- a/test/cockpit-control.test.js +++ b/test/cockpit-control.test.js @@ -7,6 +7,7 @@ const test = require('node:test'); const cockpit = require('../src/cockpit'); const { applyCockpitAction, + applyCockpitKey, renderControlFrame, startCockpitControl, } = require('../src/cockpit/control'); @@ -71,6 +72,14 @@ test('applyCockpitAction selects sessions and preserves selection across refresh assert.equal(state.selectedIndex, 1); assert.equal(state.selectedSessionId, 'two'); + state = applyCockpitAction(state, { type: 'key', key: 'j' }); + assert.equal(state.selectedIndex, 0); + assert.equal(state.selectedSessionId, 'one'); + + state = applyCockpitAction(state, { type: 'key', key: 'up' }); + assert.equal(state.selectedIndex, 1); + assert.equal(state.selectedSessionId, 'two'); + state = applyCockpitAction(state, { type: 'refresh', cockpitState: snapshot([session('two'), session('one')]), @@ -122,10 +131,64 @@ test('applyCockpitAction closes pane menu with Escape', () => { const closedState = applyCockpitAction(menuState, { type: 'key', key: '\u001b' }); assert.equal(menuState.mode, 'menu'); - assert.equal(closedState.mode, 'details'); + assert.equal(closedState.mode, 'main'); assert.equal(closedState.lastIntent, null); }); +test('applyCockpitAction handles dmux shortcut modes without launching agents', () => { + const baseState = applyCockpitAction({}, { + type: 'refresh', + cockpitState: snapshot([session('one')]), + }); + + const newAgent = applyCockpitAction(baseState, { type: 'key', key: 'n' }); + assert.equal(newAgent.mode, 'new-agent'); + assert.equal(newAgent.lastIntent, null); + + const terminal = applyCockpitAction(baseState, { type: 'key', key: 't' }); + assert.equal(terminal.mode, 'terminal'); + assert.equal(terminal.lastIntent, null); + + assert.equal(applyCockpitAction(baseState, { type: 'key', key: '?' }).mode, 'shortcuts'); + assert.equal(applyCockpitAction(newAgent, { type: 'key', key: 'esc' }).mode, 'main'); + assert.equal(applyCockpitAction(terminal, { type: 'key', key: 'escape' }).mode, 'main'); + assert.equal(applyCockpitAction(baseState, { type: 'key', key: 'q' }).shouldExit, true); +}); + +test('applyCockpitAction maps enter to view selected lane', () => { + const baseState = applyCockpitAction({}, { + type: 'refresh', + cockpitState: snapshot([session('one')]), + }); + + const state = applyCockpitAction(baseState, { type: 'key', key: 'enter' }); + assert.deepEqual(state.lastIntent, { + type: 'view', + sessionId: 'one', + branch: 'agent/codex/one', + worktreePath: '/tmp/one', + }); + assert.equal(state.mode, 'main'); +}); + +test('applyCockpitAction keeps empty-lane navigation on action rows', () => { + let state = applyCockpitAction({}, { + type: 'refresh', + cockpitState: snapshot([]), + }); + + assert.equal(state.selectedScope, 'action'); + assert.equal(state.actionIndex, 0); + + state = applyCockpitAction(state, { type: 'key', key: 'k' }); + assert.equal(state.selectedScope, 'action'); + assert.equal(state.selectedIndex, 0); + assert.equal(state.actionIndex, 3); + + state = applyCockpitAction(state, { type: 'key', key: 'j' }); + assert.equal(state.actionIndex, 0); +}); + test('applyCockpitAction routes pane menu hotkeys to pane action intents', () => { let state = applyCockpitAction({}, { type: 'refresh', @@ -157,7 +220,7 @@ test('renderControlFrame renders sidebar with details, menu, and settings modes' const details = renderControlFrame(baseState); assert.match(details, /gx cockpit/); - assert.match(details, /details/); + assert.match(details, /main/); assert.match(details, /session: one/); const menu = renderControlFrame(applyCockpitAction(baseState, { type: 'key', key: 'm' })); @@ -168,6 +231,14 @@ test('renderControlFrame renders sidebar with details, menu, and settings modes' const settings = renderControlFrame(applyCockpitAction(baseState, { type: 'key', key: 's' })); assert.match(settings, /gx cockpit settings/); assert.match(settings, /> Theme: dim/); + + const shortcuts = renderControlFrame(applyCockpitAction(baseState, { type: 'key', key: '?' })); + assert.match(shortcuts, /shortcuts/); + assert.match(shortcuts, /j\/down: next lane/); +}); + +test('control re-exports pure applyCockpitKey helper', () => { + assert.equal(applyCockpitKey({ mode: 'main' }, 's').mode, 'settings'); }); test('startCockpitControl reads state/settings, refreshes, and handles TTY keys', () => { diff --git a/test/cockpit-keybindings.test.js b/test/cockpit-keybindings.test.js index 369c8d97..928c1f1b 100644 --- a/test/cockpit-keybindings.test.js +++ b/test/cockpit-keybindings.test.js @@ -4,6 +4,7 @@ const assert = require('node:assert/strict'); const test = require('node:test'); const { + applyCockpitKey, defaultKeybindings, resolveKeyAction, } = require('../src/cockpit/keybindings'); @@ -11,11 +12,17 @@ const { test('defaultKeybindings exposes dmux-style cockpit commands for main mode', () => { const bindings = defaultKeybindings(); + assert.equal(bindings.main.j.type, 'next'); + assert.equal(bindings.main.down.type, 'next'); + assert.equal(bindings.main.k.type, 'previous'); + assert.equal(bindings.main.up.type, 'previous'); + assert.equal(bindings.main.enter.type, 'view-selected'); assert.equal(bindings.main.n.type, 'new-agent'); assert.equal(bindings.main.t.type, 'terminal'); assert.equal(bindings.main.m.type, 'menu'); assert.equal(bindings.main['alt-shift-m'].type, 'menu'); assert.equal(bindings.main.s.type, 'settings'); + assert.equal(bindings.main['?'].type, 'shortcuts'); assert.equal(bindings.main.x.type, 'close'); assert.equal(bindings.main.b.type, 'create-child-worktree'); assert.equal(bindings.main.f.type, 'browse-files'); @@ -30,13 +37,44 @@ test('defaultKeybindings exposes dmux-style cockpit commands for main mode', () assert.equal(bindings.main.c.type, 'cleanup-sessions'); assert.equal(bindings.main.r.type, 'reopen-closed-worktree'); assert.equal(bindings.main.q.type, 'quit'); + assert.equal(bindings.shortcuts.esc.type, 'close-popup'); + assert.equal(bindings['new-agent'].esc.type, 'close-popup'); + assert.equal(bindings.terminal.esc.type, 'close-popup'); }); test('resolveKeyAction maps main mode keys to structured actions', () => { + assert.deepEqual(resolveKeyAction('j', { mode: 'main' }), { + type: 'next', + payload: { key: 'j', mode: 'main' }, + }); + assert.deepEqual(resolveKeyAction('ArrowUp', { mode: 'main' }), { + type: 'previous', + payload: { key: 'up', mode: 'main' }, + }); assert.deepEqual(resolveKeyAction('n', { mode: 'main' }), { type: 'new-agent', payload: { key: 'n', mode: 'main' }, }); + assert.deepEqual(resolveKeyAction('t', { mode: 'main' }), { + type: 'terminal', + payload: { key: 't', mode: 'main' }, + }); + assert.deepEqual(resolveKeyAction('m', { mode: 'main' }), { + type: 'menu', + payload: { key: 'm', mode: 'main' }, + }); + assert.deepEqual(resolveKeyAction('s', { mode: 'main' }), { + type: 'settings', + payload: { key: 's', mode: 'main' }, + }); + assert.deepEqual(resolveKeyAction('?', { mode: 'main' }), { + type: 'shortcuts', + payload: { key: '?', mode: 'main' }, + }); + assert.deepEqual(resolveKeyAction('q', { mode: 'main' }), { + type: 'quit', + payload: { key: 'q', mode: 'main' }, + }); assert.deepEqual(resolveKeyAction('F', { mode: 'main' }), { type: 'finish', payload: { key: 'F', mode: 'main' }, @@ -108,6 +146,10 @@ test('resolveKeyAction keeps menu mode focused on navigation and closing', () => type: 'noop', payload: { key: 'n', mode: 'menu' }, }); + assert.deepEqual(resolveKeyAction('q', { mode: 'menu' }), { + type: 'quit', + payload: { key: 'q', mode: 'menu' }, + }); }); test('resolveKeyAction keeps settings mode focused on navigation and closing', () => { @@ -131,6 +173,23 @@ test('resolveKeyAction keeps settings mode focused on navigation and closing', ( type: 'noop', payload: { key: 'f', mode: 'settings' }, }); + assert.deepEqual(resolveKeyAction('q', { mode: 'settings' }), { + type: 'quit', + payload: { key: 'q', mode: 'settings' }, + }); +}); + +test('resolveKeyAction closes secondary modes with escape', () => { + for (const mode of ['shortcuts', 'new-agent', 'terminal']) { + assert.deepEqual(resolveKeyAction('esc', { mode }), { + type: 'close-popup', + payload: { key: 'esc', mode }, + }); + assert.deepEqual(resolveKeyAction('q', { mode }), { + type: 'quit', + payload: { key: 'q', mode }, + }); + } }); test('resolveKeyAction defaults unknown modes to main and unknown keys to noop', () => { @@ -143,3 +202,51 @@ test('resolveKeyAction defaults unknown modes to main and unknown keys to noop', payload: { key: 'z', mode: 'main' }, }); }); + +test('applyCockpitKey wraps lane navigation', () => { + const state = { + mode: 'main', + sessions: [{ id: 'one' }, { id: 'two' }], + selectedIndex: 1, + selectedSessionId: 'two', + }; + + const next = applyCockpitKey(state, 'j'); + assert.equal(next.selectedIndex, 0); + assert.equal(next.selectedSessionId, ''); + assert.equal(next.selectedScope, 'lane'); + + const previous = applyCockpitKey(next, 'k'); + assert.equal(previous.selectedIndex, 1); + assert.equal(previous.selectedScope, 'lane'); +}); + +test('applyCockpitKey keeps empty-lane navigation on action rows', () => { + const state = { + mode: 'main', + sessions: [], + actionRows: ['new-agent', 'terminal', 'settings', 'shortcuts'], + actionIndex: 0, + }; + + const next = applyCockpitKey(state, 'down'); + assert.equal(next.selectedScope, 'action'); + assert.equal(next.selectedIndex, 0); + assert.equal(next.actionIndex, 1); + + const previous = applyCockpitKey(state, 'up'); + assert.equal(previous.selectedScope, 'action'); + assert.equal(previous.actionIndex, 3); +}); + +test('applyCockpitKey opens modes without launching actions', () => { + assert.equal(applyCockpitKey({ mode: 'main' }, 'n').mode, 'new-agent'); + assert.equal(applyCockpitKey({ mode: 'main' }, 't').mode, 'terminal'); + assert.equal(applyCockpitKey({ mode: 'main' }, 'm').mode, 'menu'); + assert.equal(applyCockpitKey({ mode: 'main' }, 's').mode, 'settings'); + assert.equal(applyCockpitKey({ mode: 'main' }, '?').mode, 'shortcuts'); + assert.equal(applyCockpitKey({ mode: 'settings' }, 'esc').mode, 'main'); + assert.equal(applyCockpitKey({ mode: 'menu' }, '\u001b').mode, 'main'); + assert.equal(applyCockpitKey({ mode: 'shortcuts' }, 'escape').mode, 'main'); + assert.equal(applyCockpitKey({ mode: 'main' }, 'q').shouldExit, true); +});