diff --git a/src/cockpit/sidebar.js b/src/cockpit/sidebar.js index b65ea70..2cc6f17 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-control.test.js b/test/cockpit-control.test.js index 6bb2dd9..4148714 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 369c8d9..928c1f1 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); +}); diff --git a/test/cockpit-sidebar.test.js b/test/cockpit-sidebar.test.js index d0ef92d..1cb76e9 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/); });