Skip to content

Commit ba382dd

Browse files
NagyViktNagyViktOmX
authored
Enable keyboard-first cockpit navigation (#510)
* 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 <omx@oh-my-codex.dev> * 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 <omx@oh-my-codex.dev> --------- Co-authored-by: NagyVikt <nagy.viktordp@gmail.com> Co-authored-by: OmX <omx@oh-my-codex.dev>
1 parent 2f66705 commit ba382dd

4 files changed

Lines changed: 405 additions & 104 deletions

File tree

src/cockpit/sidebar.js

Lines changed: 132 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,23 @@ const path = require('node:path');
33
const DEFAULT_WIDTH = 36;
44
const MIN_WIDTH = 12;
55

6-
const STATUS_DOTS = new Map([
7-
['active', '*'],
8-
['running', '*'],
9-
['working', '*'],
10-
['thinking', 'o'],
11-
['idle', 'o'],
12-
['ready', 'o'],
13-
['done', '+'],
14-
['complete', '+'],
15-
['completed', '+'],
16-
['merged', '+'],
17-
['blocked', '!'],
18-
['error', '!'],
19-
['failed', '!'],
20-
['stalled', '!'],
21-
['dead', '!'],
6+
const STATUS_STATES = new Map([
7+
['active', 'active'],
8+
['running', 'active'],
9+
['working', 'active'],
10+
['thinking', 'waiting'],
11+
['idle', 'waiting'],
12+
['ready', 'waiting'],
13+
['waiting', 'waiting'],
14+
['done', 'done'],
15+
['complete', 'done'],
16+
['completed', 'done'],
17+
['merged', 'done'],
18+
['blocked', 'blocked'],
19+
['error', 'failed'],
20+
['failed', 'failed'],
21+
['stalled', 'stalled'],
22+
['dead', 'stalled'],
2223
]);
2324

2425
const ANSI = {
@@ -31,6 +32,15 @@ const ANSI = {
3132
inverse: '\x1b[7m',
3233
};
3334

35+
const AGENT_LABELS = new Map([
36+
['codex', 'cx'],
37+
['claude', 'cc'],
38+
['claude-code', 'cc'],
39+
['claudecode', 'cc'],
40+
['cursor', 'cu'],
41+
['gemini', 'gm'],
42+
]);
43+
3444
function text(value, fallback = '') {
3545
if (typeof value === 'string') {
3646
return value.trim() || fallback;
@@ -81,24 +91,60 @@ function repoName(state = {}, options = {}) {
8191
}
8292

8393
function agentLabel(agentName) {
84-
const compact = text(agentName, 'agent').replace(/[^a-z0-9]/gi, '').toUpperCase();
85-
return truncate(compact || 'AGENT', 3).padEnd(3, ' ');
94+
const raw = text(agentName, 'agent').toLowerCase();
95+
const compact = raw.replace(/[^a-z0-9]/g, '');
96+
if (AGENT_LABELS.has(raw)) {
97+
return AGENT_LABELS.get(raw);
98+
}
99+
if (AGENT_LABELS.has(compact)) {
100+
return AGENT_LABELS.get(compact);
101+
}
102+
if (compact.includes('codex')) {
103+
return 'cx';
104+
}
105+
if (compact.includes('claude')) {
106+
return 'cc';
107+
}
108+
109+
const parts = raw.match(/[a-z0-9]+/g) || [];
110+
if (parts.length >= 2) {
111+
return `${parts[0][0]}${parts[1][0]}`;
112+
}
113+
return truncate(parts[0] || compact || 'ag', 2).padEnd(2, 'g');
86114
}
87115

88116
function statusDot(session = {}) {
89-
if (session.worktreeExists === false) {
117+
const status = laneState(session);
118+
if (status === 'active') {
119+
return '*';
120+
}
121+
if (status === 'waiting') {
122+
return 'o';
123+
}
124+
if (status === 'done') {
125+
return '+';
126+
}
127+
if (status === 'missing') {
90128
return 'x';
91129
}
92-
const status = text(session.status, 'unknown').toLowerCase();
93-
return STATUS_DOTS.get(status) || '.';
130+
if (status === 'blocked' || status === 'failed' || status === 'stalled') {
131+
return '!';
132+
}
133+
return '.';
94134
}
95135

96-
function lockCount(session = {}) {
97-
if (Array.isArray(session.locks)) {
98-
return session.locks.length;
136+
function laneState(session = {}) {
137+
const status = text(session.status, 'unknown').toLowerCase();
138+
if (session.hidden === true || session.visible === false || status === 'hidden') {
139+
return 'hidden';
99140
}
100-
const count = Number(session.lockCount);
101-
return Number.isFinite(count) && count >= 0 ? count : 0;
141+
if (session.closed === true || session.closedAt || status === 'closed') {
142+
return 'closed';
143+
}
144+
if (session.worktreeExists === false || session.worktreeMissing === true || status === 'missing' || status === 'missing-worktree') {
145+
return 'missing';
146+
}
147+
return STATUS_STATES.get(status) || status || 'unknown';
102148
}
103149

104150
function sessionId(session = {}) {
@@ -120,51 +166,80 @@ function isSelected(session, index, state = {}, options = {}) {
120166
return Number.isInteger(selectedIndex) && selectedIndex === index;
121167
}
122168

169+
function colorEnabled(options = {}) {
170+
const env = options.env && typeof options.env === 'object' ? options.env : process.env;
171+
return options.color === true && !options.noColor && !env.NO_COLOR;
172+
}
173+
123174
function colorize(value, color, options = {}) {
124-
if (options.noColor || options.color !== true) {
175+
if (!colorEnabled(options)) {
125176
return value;
126177
}
127178
const code = ANSI[color];
128179
return code ? `${code}${value}${ANSI.reset}` : value;
129180
}
130181

131-
function statusColor(dot) {
132-
if (dot === '*') {
182+
function statusColor(status) {
183+
if (status === 'active' || status === 'done') {
133184
return 'green';
134185
}
135-
if (dot === '!') {
186+
if (status === 'waiting') {
136187
return 'yellow';
137188
}
138-
if (dot === 'x') {
189+
if (status === 'blocked' || status === 'failed' || status === 'stalled' || status === 'missing') {
139190
return 'red';
140191
}
141-
if (dot === '+') {
142-
return 'cyan';
192+
if (status === 'hidden' || status === 'closed') {
193+
return 'dim';
143194
}
144-
return 'dim';
195+
return 'cyan';
196+
}
197+
198+
function laneName(session = {}) {
199+
const task = text(session.task || session.name || session.title);
200+
if (task) {
201+
return task;
202+
}
203+
204+
const branch = text(session.branch);
205+
if (!branch) {
206+
return '(no task)';
207+
}
208+
return path.basename(branch);
209+
}
210+
211+
function fitRow(left, right, width) {
212+
if (width <= 0) {
213+
return '';
214+
}
215+
216+
if (right.length >= width - 2) {
217+
return truncate(`${left}${right}`, width);
218+
}
219+
220+
const leftWidth = width - right.length;
221+
return `${truncate(left, leftWidth).padEnd(leftWidth, ' ')}${right}`;
222+
}
223+
224+
function renderShortcutRows(width, options) {
225+
const rows = [
226+
' [n]ew agent [t]erminal',
227+
' [s]ettings [?] shortcuts',
228+
];
229+
return rows.map((row) => colorize(boundLine(row, width), 'dim', options));
145230
}
146231

147232
function renderSessionRow(session, index, state, options) {
148233
const width = sidebarWidth(options);
149234
const selected = isSelected(session, index, state, options);
150235
const marker = selected ? '>' : ' ';
151-
const dot = statusDot(session);
152-
const label = agentLabel(session.agentName);
153-
const branch = text(session.branch, '(no branch)');
154-
const task = text(session.task, '(no task)');
155-
const missing = session.worktreeExists === false ? ' missing worktree' : '';
156-
157-
const firstPrefix = `${marker} ${dot} ${label} `;
158-
const first = `${firstPrefix}${truncate(branch, width - firstPrefix.length)}`;
159-
const taskPrefix = ' ';
160-
const taskLine = `${taskPrefix}${truncate(task, width - taskPrefix.length)}`;
161-
const meta = ` locks: ${lockCount(session)}${missing}`;
162-
163-
return [
164-
selected ? colorize(boundLine(first, width), 'inverse', options) : boundLine(first, width),
165-
boundLine(taskLine, width),
166-
colorize(boundLine(meta, width), statusColor(dot), options),
167-
];
236+
const status = laneState(session);
237+
const badge = `[${agentLabel(session.agentName || session.agent || session.owner)}] (${status})`;
238+
const row = fitRow(`${marker} ${laneName(session)}`, ` ${badge}`, width);
239+
240+
return selected
241+
? colorize(row, 'inverse', options)
242+
: colorize(row, statusColor(status), options);
168243
}
169244

170245
function renderSidebar(state = {}, options = {}) {
@@ -174,33 +249,27 @@ function renderSidebar(state = {}, options = {}) {
174249
: text(options.title || state.title, 'gx cockpit');
175250
const sessions = Array.isArray(state.sessions) ? state.sessions : [];
176251
const lines = [
177-
boundLine(title, width),
178-
boundLine(`repo ${repoName(state, options)}`, width),
179-
boundLine('-'.repeat(width), width),
180-
boundLine('lanes', width),
252+
colorize(boundLine(title, width), 'cyan', options),
253+
colorize(boundLine(repoName(state, options), width), 'dim', options),
181254
];
182255

183256
if (sessions.length === 0) {
184-
lines.push(boundLine(' no active lanes', width));
257+
lines.push(boundLine(' no agent lanes', width));
185258
} else {
186259
sessions.forEach((session, index) => {
187-
lines.push(...renderSessionRow(session, index, state, options));
260+
lines.push(renderSessionRow(session, index, state, options));
188261
});
189262
}
190263

191-
lines.push(
192-
boundLine('-'.repeat(width), width),
193-
boundLine('[n] new agent', width),
194-
boundLine('[t] terminal', width),
195-
boundLine('[s] settings', width),
196-
);
264+
lines.push(...renderShortcutRows(width, options));
197265

198266
return `${lines.join('\n')}\n`;
199267
}
200268

201269
module.exports = {
202270
renderSidebar,
203271
agentLabel,
272+
laneState,
204273
statusDot,
205274
truncate,
206275
};

test/cockpit-control.test.js

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const test = require('node:test');
77
const cockpit = require('../src/cockpit');
88
const {
99
applyCockpitAction,
10+
applyCockpitKey,
1011
renderControlFrame,
1112
startCockpitControl,
1213
} = require('../src/cockpit/control');
@@ -71,6 +72,14 @@ test('applyCockpitAction selects sessions and preserves selection across refresh
7172
assert.equal(state.selectedIndex, 1);
7273
assert.equal(state.selectedSessionId, 'two');
7374

75+
state = applyCockpitAction(state, { type: 'key', key: 'j' });
76+
assert.equal(state.selectedIndex, 0);
77+
assert.equal(state.selectedSessionId, 'one');
78+
79+
state = applyCockpitAction(state, { type: 'key', key: 'up' });
80+
assert.equal(state.selectedIndex, 1);
81+
assert.equal(state.selectedSessionId, 'two');
82+
7483
state = applyCockpitAction(state, {
7584
type: 'refresh',
7685
cockpitState: snapshot([session('two'), session('one')]),
@@ -122,10 +131,64 @@ test('applyCockpitAction closes pane menu with Escape', () => {
122131
const closedState = applyCockpitAction(menuState, { type: 'key', key: '\u001b' });
123132

124133
assert.equal(menuState.mode, 'menu');
125-
assert.equal(closedState.mode, 'details');
134+
assert.equal(closedState.mode, 'main');
126135
assert.equal(closedState.lastIntent, null);
127136
});
128137

138+
test('applyCockpitAction handles dmux shortcut modes without launching agents', () => {
139+
const baseState = applyCockpitAction({}, {
140+
type: 'refresh',
141+
cockpitState: snapshot([session('one')]),
142+
});
143+
144+
const newAgent = applyCockpitAction(baseState, { type: 'key', key: 'n' });
145+
assert.equal(newAgent.mode, 'new-agent');
146+
assert.equal(newAgent.lastIntent, null);
147+
148+
const terminal = applyCockpitAction(baseState, { type: 'key', key: 't' });
149+
assert.equal(terminal.mode, 'terminal');
150+
assert.equal(terminal.lastIntent, null);
151+
152+
assert.equal(applyCockpitAction(baseState, { type: 'key', key: '?' }).mode, 'shortcuts');
153+
assert.equal(applyCockpitAction(newAgent, { type: 'key', key: 'esc' }).mode, 'main');
154+
assert.equal(applyCockpitAction(terminal, { type: 'key', key: 'escape' }).mode, 'main');
155+
assert.equal(applyCockpitAction(baseState, { type: 'key', key: 'q' }).shouldExit, true);
156+
});
157+
158+
test('applyCockpitAction maps enter to view selected lane', () => {
159+
const baseState = applyCockpitAction({}, {
160+
type: 'refresh',
161+
cockpitState: snapshot([session('one')]),
162+
});
163+
164+
const state = applyCockpitAction(baseState, { type: 'key', key: 'enter' });
165+
assert.deepEqual(state.lastIntent, {
166+
type: 'view',
167+
sessionId: 'one',
168+
branch: 'agent/codex/one',
169+
worktreePath: '/tmp/one',
170+
});
171+
assert.equal(state.mode, 'main');
172+
});
173+
174+
test('applyCockpitAction keeps empty-lane navigation on action rows', () => {
175+
let state = applyCockpitAction({}, {
176+
type: 'refresh',
177+
cockpitState: snapshot([]),
178+
});
179+
180+
assert.equal(state.selectedScope, 'action');
181+
assert.equal(state.actionIndex, 0);
182+
183+
state = applyCockpitAction(state, { type: 'key', key: 'k' });
184+
assert.equal(state.selectedScope, 'action');
185+
assert.equal(state.selectedIndex, 0);
186+
assert.equal(state.actionIndex, 3);
187+
188+
state = applyCockpitAction(state, { type: 'key', key: 'j' });
189+
assert.equal(state.actionIndex, 0);
190+
});
191+
129192
test('applyCockpitAction routes pane menu hotkeys to pane action intents', () => {
130193
let state = applyCockpitAction({}, {
131194
type: 'refresh',
@@ -157,7 +220,7 @@ test('renderControlFrame renders sidebar with details, menu, and settings modes'
157220

158221
const details = renderControlFrame(baseState);
159222
assert.match(details, /gx cockpit/);
160-
assert.match(details, /details/);
223+
assert.match(details, /main/);
161224
assert.match(details, /session: one/);
162225

163226
const menu = renderControlFrame(applyCockpitAction(baseState, { type: 'key', key: 'm' }));
@@ -168,6 +231,14 @@ test('renderControlFrame renders sidebar with details, menu, and settings modes'
168231
const settings = renderControlFrame(applyCockpitAction(baseState, { type: 'key', key: 's' }));
169232
assert.match(settings, /gx cockpit settings/);
170233
assert.match(settings, /> Theme: dim/);
234+
235+
const shortcuts = renderControlFrame(applyCockpitAction(baseState, { type: 'key', key: '?' }));
236+
assert.match(shortcuts, /shortcuts/);
237+
assert.match(shortcuts, /j\/down: next lane/);
238+
});
239+
240+
test('control re-exports pure applyCockpitKey helper', () => {
241+
assert.equal(applyCockpitKey({ mode: 'main' }, 's').mode, 'settings');
171242
});
172243

173244
test('startCockpitControl reads state/settings, refreshes, and handles TTY keys', () => {

0 commit comments

Comments
 (0)