Skip to content

Commit 76306a5

Browse files
NagyViktNagyViktOmX
authored
Make cockpit default to interactive Kitty control (#517)
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: NagyVikt <nagy.viktordp@gmail.com> Co-authored-by: OmX <omx@oh-my-codex.dev>
1 parent 1bdeec4 commit 76306a5

11 files changed

Lines changed: 853 additions & 139 deletions

src/cockpit/control.js

Lines changed: 176 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const { renderSidebar } = require('./sidebar');
55
const { renderSettingsScreen } = require('./settings-render');
66
const { CONTROL_KEY_HELP } = require('./shortcuts');
77
const { stripAnsi } = require('./theme');
8+
const { renderWelcomePage } = require('./welcome');
89
const { runCockpitAction } = require('./action-runner');
910
const {
1011
PANE_MENU_ITEMS,
@@ -22,7 +23,8 @@ const DEFAULT_SETTINGS = {
2223
defaultBase: 'main',
2324
};
2425

25-
const MODES = new Set(['details', 'menu', 'settings']);
26+
const MODES = new Set(['main', 'menu', 'settings', 'shortcuts', 'new-agent', 'terminal']);
27+
const EMPTY_ACTION_ROWS = Object.freeze(['new-agent', 'terminal', 'settings', 'shortcuts']);
2628
const SETTINGS_FIELDS = [
2729
'theme',
2830
'sidebarWidth',
@@ -150,8 +152,17 @@ function normalizeSettings(settings) {
150152
};
151153
}
152154

155+
function normalizeActionRows(rows) {
156+
if (!Array.isArray(rows) || rows.length === 0) {
157+
return [...EMPTY_ACTION_ROWS];
158+
}
159+
const normalized = rows.map((row) => text(row)).filter(Boolean);
160+
return normalized.length > 0 ? normalized : [...EMPTY_ACTION_ROWS];
161+
}
162+
153163
function normalizeMode(mode) {
154-
return MODES.has(mode) ? mode : 'details';
164+
if (mode === 'details') return 'main';
165+
return MODES.has(mode) ? mode : 'main';
155166
}
156167

157168
function normalizeControlState(state = {}) {
@@ -163,8 +174,10 @@ function normalizeControlState(state = {}) {
163174
: Array.isArray(cockpitState.sessions)
164175
? cockpitState.sessions
165176
: [];
177+
const actionRows = normalizeActionRows(state.actionRows);
166178
const selectedIndex = clampIndex(number(state.selectedIndex, 0), sessions.length);
167179
const selected = sessions[selectedIndex] || null;
180+
const selectedScope = sessions.length > 0 ? 'lane' : 'action';
168181

169182
return {
170183
...state,
@@ -174,6 +187,9 @@ function normalizeControlState(state = {}) {
174187
sessions,
175188
selectedIndex,
176189
selectedSessionId: text(state.selectedSessionId || (selected && sessionId(selected))),
190+
selectedScope,
191+
actionRows,
192+
actionIndex: wrapIndex(number(state.actionIndex, 0), actionRows.length),
177193
mode: normalizeMode(state.mode),
178194
menuIndex: wrapIndex(number(state.menuIndex, 0), MENU_ITEMS.length),
179195
settingsIndex: wrapIndex(number(state.settingsIndex, 0), SETTINGS_FIELDS.length),
@@ -266,7 +282,7 @@ function chooseMenuItem(state) {
266282
const intent = buildIntent(current, result.actionId);
267283
return normalizeControlState({
268284
...current,
269-
mode: 'details',
285+
mode: 'main',
270286
paneMenuMessage: '',
271287
shouldExit: intent.type === 'quit',
272288
lastIntent: intent,
@@ -290,9 +306,54 @@ function normalizeKey(value) {
290306
if (raw === '\u001b[B') return 'down';
291307
if (raw === '\t') return 'tab';
292308
if (/^alt(?:\+|-)?shift(?:\+|-)?m$/i.test(raw)) return 'alt-shift-m';
309+
if (/^(esc|escape)$/i.test(raw)) return 'escape';
293310
return raw.toLowerCase();
294311
}
295312

313+
function moveSelection(state, direction) {
314+
const current = normalizeControlState(state);
315+
if (current.sessions.length > 0) {
316+
return normalizeControlState({
317+
...current,
318+
selectedScope: 'lane',
319+
selectedIndex: wrapIndex(current.selectedIndex + direction, current.sessions.length),
320+
selectedSessionId: '',
321+
lastIntent: null,
322+
});
323+
}
324+
325+
return normalizeControlState({
326+
...current,
327+
selectedScope: 'action',
328+
selectedIndex: 0,
329+
actionIndex: wrapIndex(current.actionIndex + direction, current.actionRows.length),
330+
selectedSessionId: '',
331+
lastIntent: null,
332+
});
333+
}
334+
335+
function openActionRow(state, actionId) {
336+
const current = normalizeControlState(state);
337+
if (actionId === 'new-agent') {
338+
return normalizeControlState({ ...current, mode: 'new-agent', lastIntent: null });
339+
}
340+
if (actionId === 'terminal') {
341+
return normalizeControlState({ ...current, mode: 'terminal', lastIntent: null });
342+
}
343+
if (actionId === 'settings') {
344+
return normalizeControlState({ ...current, mode: 'settings', lastIntent: null });
345+
}
346+
if (actionId === 'shortcuts') {
347+
return normalizeControlState({ ...current, mode: 'shortcuts', lastIntent: null });
348+
}
349+
return normalizeControlState({ ...current, lastIntent: null });
350+
}
351+
352+
function openSelectedActionRow(state) {
353+
const current = normalizeControlState(state);
354+
return openActionRow(current, current.actionRows[current.actionIndex] || current.actionRows[0]);
355+
}
356+
296357
function applyKey(state, rawKey) {
297358
const current = normalizeControlState(state);
298359
const key = normalizeKey(rawKey);
@@ -303,15 +364,15 @@ function applyKey(state, rawKey) {
303364
if (result.action === 'cancel') {
304365
return normalizeControlState({
305366
...current,
306-
mode: 'details',
367+
mode: 'main',
307368
paneMenuMessage: '',
308369
lastIntent: null,
309370
});
310371
}
311372
if (result.action === 'select') {
312373
return normalizeControlState({
313374
...current,
314-
mode: 'details',
375+
mode: 'main',
315376
menuIndex: result.state.selectedIndex,
316377
paneMenuMessage: '',
317378
lastIntent: buildIntent(current, result.actionId),
@@ -335,26 +396,11 @@ function applyKey(state, rawKey) {
335396
if (key === 'escape') {
336397
return normalizeControlState({
337398
...current,
338-
mode: 'details',
339-
lastIntent: null,
340-
});
341-
}
342-
if (key === 's') {
343-
return normalizeControlState({
344-
...current,
345-
mode: 'settings',
346-
lastIntent: null,
347-
});
348-
}
349-
if (key === 'm' || key === 'tab' || key === 'alt-shift-m') {
350-
return normalizeControlState({
351-
...current,
352-
mode: 'menu',
353-
paneMenuMessage: '',
399+
mode: 'main',
354400
lastIntent: null,
355401
});
356402
}
357-
if (DIRECT_DETAIL_PANE_KEYS.has(normalizePaneMenuKey(rawKey))) {
403+
if (mode === 'main' && DIRECT_DETAIL_PANE_KEYS.has(normalizePaneMenuKey(rawKey))) {
358404
const result = applyPaneMenuKey(paneMenuStateFromControl(current), rawKey);
359405
if (result.action === 'select') {
360406
return normalizeControlState({
@@ -369,6 +415,26 @@ function applyKey(state, rawKey) {
369415
lastIntent: null,
370416
});
371417
}
418+
if (key === 'n') {
419+
return openActionRow(current, 'new-agent');
420+
}
421+
if (key === 't') {
422+
return openActionRow(current, 'terminal');
423+
}
424+
if (key === '?') {
425+
return openActionRow(current, 'shortcuts');
426+
}
427+
if (key === 's') {
428+
return openActionRow(current, 'settings');
429+
}
430+
if (key === 'm' || key === 'tab' || key === 'alt-shift-m') {
431+
return normalizeControlState({
432+
...current,
433+
mode: 'menu',
434+
paneMenuMessage: '',
435+
lastIntent: null,
436+
});
437+
}
372438
if (key === 'enter') {
373439
if (mode === 'menu') return chooseMenuItem(current);
374440
if (mode === 'settings') {
@@ -377,10 +443,27 @@ function applyKey(state, rawKey) {
377443
lastIntent: buildIntent(current, 'settings:edit'),
378444
});
379445
}
446+
if (mode === 'new-agent') {
447+
return normalizeControlState({
448+
...current,
449+
mode: 'main',
450+
lastIntent: buildIntent(current, 'agent:start'),
451+
});
452+
}
453+
if (mode === 'terminal') {
454+
return normalizeControlState({
455+
...current,
456+
mode: 'main',
457+
lastIntent: buildIntent(current, 'terminal:open'),
458+
});
459+
}
460+
if (current.sessions.length === 0 && current.selectedScope === 'action') {
461+
return openSelectedActionRow(current);
462+
}
380463
return normalizeControlState({
381464
...current,
382-
mode: 'menu',
383-
lastIntent: null,
465+
mode: 'main',
466+
lastIntent: buildIntent(current, 'view'),
384467
});
385468
}
386469
if (key === 'down' || key === 'j') {
@@ -390,7 +473,7 @@ function applyKey(state, rawKey) {
390473
if (mode === 'settings') {
391474
return normalizeControlState({ ...current, settingsIndex: current.settingsIndex + 1, lastIntent: null });
392475
}
393-
return normalizeControlState({ ...current, selectedIndex: current.selectedIndex + 1, selectedSessionId: '', lastIntent: null });
476+
return moveSelection(current, 1);
394477
}
395478
if (key === 'up' || key === 'k') {
396479
if (mode === 'menu') {
@@ -399,7 +482,7 @@ function applyKey(state, rawKey) {
399482
if (mode === 'settings') {
400483
return normalizeControlState({ ...current, settingsIndex: current.settingsIndex - 1, lastIntent: null });
401484
}
402-
return normalizeControlState({ ...current, selectedIndex: current.selectedIndex - 1, selectedSessionId: '', lastIntent: null });
485+
return moveSelection(current, -1);
403486
}
404487

405488
return current;
@@ -481,11 +564,21 @@ function selectedField(state) {
481564
return SETTINGS_FIELDS[current.settingsIndex] || SETTINGS_FIELDS[0];
482565
}
483566

567+
function welcomeState(state) {
568+
const current = normalizeControlState(state);
569+
return {
570+
...current.cockpitState,
571+
repoPath: current.repoPath,
572+
baseBranch: current.baseBranch,
573+
sessions: current.sessions,
574+
};
575+
}
576+
484577
function renderDetailsPanel(state) {
485578
const current = normalizeControlState(state);
486579
const session = selectedSession(current);
487580
const lines = [
488-
'details',
581+
'main',
489582
`repo: ${current.repoPath || '-'}`,
490583
`base: ${current.baseBranch || '-'}`,
491584
`mode: ${current.mode}`,
@@ -517,6 +610,54 @@ function renderDetailsPanel(state) {
517610
return `${lines.join('\n')}\n`;
518611
}
519612

613+
function renderShortcutsPanel() {
614+
return [
615+
'shortcuts',
616+
'',
617+
'j/down: next lane',
618+
'k/up: previous lane',
619+
'enter: view selected lane / open selected action',
620+
'n: new agent',
621+
't: terminal',
622+
'm or Alt+Shift+M: pane menu',
623+
's: settings',
624+
'v/h/x/p/r/c/o/a/b/f/T/A: pane actions',
625+
'esc: back to main',
626+
'q: quit',
627+
'',
628+
].join('\n');
629+
}
630+
631+
function renderNewAgentPanel(state) {
632+
const current = normalizeControlState(state);
633+
return [
634+
'new agent',
635+
'',
636+
`agent: ${current.settings.defaultAgent}`,
637+
`base: ${current.settings.defaultBase}`,
638+
'',
639+
'Enter: open a guarded agent lane in Kitty',
640+
'Esc: back to main',
641+
'',
642+
].join('\n');
643+
}
644+
645+
function renderTerminalPanel(state) {
646+
const current = normalizeControlState(state);
647+
const session = selectedSession(current);
648+
return [
649+
'terminal',
650+
'',
651+
session
652+
? `target: ${sessionId(session) || text(session.branch, 'selected lane')}`
653+
: `target: ${current.repoPath || 'repo'}`,
654+
'',
655+
'Enter: open Kitty terminal',
656+
'Esc: back to main',
657+
'',
658+
].join('\n');
659+
}
660+
520661
function renderMenuPanel(state) {
521662
const current = normalizeControlState(state);
522663
return renderPaneMenu(paneMenuStateFromControl(current), { width: 72, theme: current.settings.theme });
@@ -534,6 +675,12 @@ function renderPanel(state) {
534675
const current = normalizeControlState(state);
535676
if (current.mode === 'menu') return renderMenuPanel(current);
536677
if (current.mode === 'settings') return renderSettingsPanel(current);
678+
if (current.mode === 'shortcuts') return renderShortcutsPanel(current);
679+
if (current.mode === 'new-agent') return renderNewAgentPanel(current);
680+
if (current.mode === 'terminal') return renderTerminalPanel(current);
681+
if (current.sessions.length === 0) {
682+
return renderWelcomePage(welcomeState(current), current.settings);
683+
}
537684
return renderDetailsPanel(current);
538685
}
539686

@@ -542,7 +689,7 @@ function renderControlFrame(state) {
542689
const width = number(current.settings.sidebarWidth, DEFAULT_SETTINGS.sidebarWidth);
543690
const sidebar = splitLines(renderSidebar(current, { width, theme: current.settings.theme }));
544691
const framePanelState = current.mode === 'menu'
545-
? normalizeControlState({ ...current, mode: 'details' })
692+
? normalizeControlState({ ...current, mode: 'main' })
546693
: current;
547694
const panel = splitLines(renderPanel(framePanelState));
548695
const leftWidth = Math.max(width, ...sidebar.map((line) => stripAnsi(line).length));
@@ -704,6 +851,7 @@ module.exports = {
704851
MENU_ITEMS,
705852
SETTINGS_FIELDS,
706853
applyCockpitAction,
854+
applyCockpitKey: applyKey,
707855
buildCockpitActionContext,
708856
normalizeControlState,
709857
normalizeKey,

0 commit comments

Comments
 (0)