Skip to content

Commit 05630d7

Browse files
NagyViktNagyVikt
andauthored
Let gx cockpit choose Kitty without losing tmux (#499)
The cockpit launcher now goes through a terminal backend boundary so Kitty remote-control windows can be selected for the new cockpit surface while the default tmux path stays compatible. Constraint: Must preserve existing tmux cockpit behavior and avoid actively owned keybinding files Rejected: Replace tmux session helpers | existing tests and users still depend on the tmux fallback Confidence: high Scope-risk: moderate Directive: Keep Kitty command builders argv-based and covered by tests before adding deeper remote-control behavior Tested: node --test test/cockpit-terminal-backend.test.js test/cockpit-command.test.js test/tmux-session.test.js test/cockpit-keybindings.test.js passed 21/21 Tested: openspec validate agent-codex-kitty-gx-cockpit-backend-2026-04-30-13-47 --strict Tested: openspec validate --specs Not-tested: Live Kitty remote-control window management; this slice uses dry-run-testable command builders and stubbed backends Co-authored-by: NagyVikt <nagy.viktordp@gmail.com>
1 parent eb5f08e commit 05630d7

9 files changed

Lines changed: 749 additions & 39 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Kitty-backed cockpit terminal backend
2+
3+
## Why
4+
5+
`gx cockpit` is currently coupled to tmux session creation. The cockpit needs a terminal backend boundary so Kitty remote-control windows can drive the new cockpit surface while tmux remains available and compatible.
6+
7+
## What Changes
8+
9+
- Add a `src/terminal` backend abstraction with Kitty and tmux implementations.
10+
- Add dry-run-testable Kitty command builders for cockpit, agent pane, terminal pane, focus, close, and send-text actions.
11+
- Add `gx cockpit --backend kitty|tmux|auto`; auto prefers Kitty when remote control responds and otherwise uses tmux.
12+
- Keep current tmux behavior and cockpit shortcut coverage intact.
13+
14+
## Impact
15+
16+
The change is limited to cockpit terminal launching and command construction. Agent worktree creation, locks, PR finish flow, and existing tmux session helpers remain unchanged.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Cockpit terminal backend selection
4+
5+
`gx cockpit` SHALL accept `--backend kitty`, `--backend tmux`, and `--backend auto`.
6+
7+
#### Scenario: Auto prefers Kitty when available
8+
9+
- **WHEN** the operator runs `gx cockpit --backend auto`
10+
- **AND** Kitty remote control is available
11+
- **THEN** the cockpit SHALL select the Kitty backend.
12+
13+
#### Scenario: Auto falls back to tmux
14+
15+
- **WHEN** the operator runs `gx cockpit --backend auto`
16+
- **AND** Kitty remote control is unavailable
17+
- **THEN** the cockpit SHALL select the tmux backend.
18+
19+
### Requirement: Kitty cockpit command builders
20+
21+
The Kitty backend SHALL expose stable command builders for cockpit layout, agent pane, terminal pane, focus, close, and send-text operations.
22+
23+
#### Scenario: Cockpit layout command
24+
25+
- **WHEN** a cockpit layout is opened with Kitty
26+
- **THEN** the backend SHALL build `kitty @ launch --type=window --cwd <repoRoot> --title "gx cockpit" ...`.
27+
28+
#### Scenario: Agent pane command
29+
30+
- **WHEN** an agent pane is launched with Kitty
31+
- **THEN** the backend SHALL build `kitty @ launch --type=window --location=vsplit --cwd <worktree> --title <agent>`.
32+
33+
#### Scenario: Remote-control commands
34+
35+
- **WHEN** focus, close, or send-text is requested for a Kitty target id
36+
- **THEN** the backend SHALL build `kitty @ focus-window --match id:<id>`, `kitty @ close-window --match id:<id>`, and `kitty @ send-text --match id:<id> --stdin`.
37+
38+
### Requirement: Tmux compatibility
39+
40+
The tmux cockpit path SHALL remain available through `gx cockpit --backend tmux` and keep existing tmux session behavior.
41+
42+
#### Scenario: Explicit tmux backend
43+
44+
- **WHEN** the operator runs `gx cockpit --backend tmux`
45+
- **THEN** the cockpit SHALL create or attach the configured tmux session using the existing tmux session helpers.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
## 1. Spec
2+
3+
- [x] Capture Kitty/tmux cockpit backend selection and command-builder requirements.
4+
5+
## 2. Tests
6+
7+
- [x] Cover backend selection preferring Kitty when available and falling back to tmux.
8+
- [x] Cover stable Kitty command construction.
9+
- [x] Keep tmux cockpit command tests passing.
10+
- [x] Verify cockpit shortcut tests remain green.
11+
12+
## 3. Implementation
13+
14+
- [x] Add terminal backend abstraction files under `src/terminal/`.
15+
- [x] Wire `gx cockpit --backend kitty|tmux|auto`.
16+
- [x] Preserve existing cockpit keybinding behavior without editing actively owned keybinding files.
17+
- Note: `src/cockpit/keybindings.js` was actively owned by session `019dde2a`; this change leaves it untouched and verifies existing coverage.
18+
19+
## 4. Verification
20+
21+
- [x] Run focused Node tests for cockpit terminal backends, cockpit command behavior, keybindings, and tmux helpers.
22+
- Evidence: `node --test test/cockpit-terminal-backend.test.js test/cockpit-command.test.js test/tmux-session.test.js test/cockpit-keybindings.test.js` passed 21/21 after stashing unrelated inherited dirty files.
23+
- [x] Run OpenSpec validation.
24+
- Evidence: `openspec validate agent-codex-kitty-gx-cockpit-backend-2026-04-30-13-47 --strict` passed.
25+
- Evidence: `openspec validate --specs` passed with no spec items found.
26+
27+
## 5. Cleanup
28+
29+
- [ ] Commit changes.
30+
- [ ] Finish via PR, wait for merge, cleanup, and record `MERGED` evidence.

src/cockpit/index.js

Lines changed: 46 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,15 @@ const { readCockpitState } = require('./state');
22
const { renderCockpit } = require('./render');
33
const control = require('./control');
44
const actions = require('./actions');
5-
const {
6-
ensureTmuxAvailable,
7-
sessionExists,
8-
createSession,
9-
attachSession,
10-
sendKeys,
11-
} = require('../tmux/session');
5+
const { normalizeBackendName, selectTerminalBackend } = require('../terminal');
126

137
const DEFAULT_SESSION_NAME = 'guardex';
8+
const DEFAULT_BACKEND = 'tmux';
149

1510
function parseCockpitArgs(rawArgs = []) {
1611
const options = {
1712
sessionName: DEFAULT_SESSION_NAME,
13+
backend: process.env.GUARDEX_COCKPIT_BACKEND || DEFAULT_BACKEND,
1814
attach: false,
1915
target: process.cwd(),
2016
};
@@ -41,6 +37,23 @@ function parseCockpitArgs(rawArgs = []) {
4137
}
4238
continue;
4339
}
40+
if (arg === '--backend') {
41+
const next = rawArgs[index + 1];
42+
if (!next || next.startsWith('-')) {
43+
throw new Error('--backend requires auto, kitty, or tmux');
44+
}
45+
options.backend = normalizeBackendName(next);
46+
index += 1;
47+
continue;
48+
}
49+
if (arg.startsWith('--backend=')) {
50+
const next = arg.slice('--backend='.length);
51+
if (!next) {
52+
throw new Error('--backend requires auto, kitty, or tmux');
53+
}
54+
options.backend = normalizeBackendName(next);
55+
continue;
56+
}
4457
if (arg === '--target' || arg === '-t') {
4558
const next = rawArgs[index + 1];
4659
if (!next || next.startsWith('-')) {
@@ -133,13 +146,6 @@ function openCockpit(rawArgs = [], deps = {}) {
133146
resolveRepoRoot,
134147
toolName = 'gitguardex',
135148
stdout = process.stdout,
136-
tmux = {
137-
ensureTmuxAvailable,
138-
sessionExists,
139-
createSession,
140-
attachSession,
141-
sendKeys,
142-
},
143149
} = deps;
144150
if (typeof resolveRepoRoot !== 'function') {
145151
throw new Error('openCockpit requires resolveRepoRoot');
@@ -162,36 +168,39 @@ function openCockpit(rawArgs = [], deps = {}) {
162168
const options = parseCockpitArgs(rawArgs);
163169
const repoRoot = resolveRepoRoot(options.target);
164170
const controlCommand = cockpitControlCommand(repoRoot);
171+
const terminalBackendOptions = { ...(deps.terminalBackendOptions || {}) };
172+
if (deps.terminalBackends && deps.terminalBackends.kitty) {
173+
terminalBackendOptions.kittyBackend = deps.terminalBackends.kitty;
174+
}
175+
if (deps.terminalBackends && deps.terminalBackends.tmux) {
176+
terminalBackendOptions.tmuxBackend = deps.terminalBackends.tmux;
177+
}
178+
if (deps.tmux) {
179+
terminalBackendOptions.tmux = { tmux: deps.tmux };
180+
}
181+
const backend = selectTerminalBackend(options.backend, terminalBackendOptions);
165182

166-
tmux.ensureTmuxAvailable();
183+
const result = backend.openCockpitLayout({
184+
repoRoot,
185+
sessionName: options.sessionName,
186+
command: controlCommand,
187+
attach: options.attach,
188+
});
189+
const action = result && result.action ? result.action : 'created';
167190

168-
if (tmux.sessionExists(options.sessionName)) {
191+
if (backend.name === 'tmux' && action === 'attached') {
169192
stdout.write(`[${toolName}] Attaching tmux session '${options.sessionName}'.\n`);
170-
tmux.attachSession(options.sessionName);
171-
return { action: 'attached', sessionName: options.sessionName, repoRoot };
193+
return { action, backend: backend.name, sessionName: options.sessionName, repoRoot };
172194
}
173195

174-
const createResult = tmux.createSession(options.sessionName, repoRoot);
175-
if (createResult.error) throw createResult.error;
176-
if (createResult.status !== 0) {
177-
const detail = String(createResult.stderr || createResult.stdout || '').trim();
178-
throw new Error(`tmux could not create session '${options.sessionName}'${detail ? `: ${detail}` : '.'}`);
179-
}
180-
const sendResult = tmux.sendKeys(options.sessionName, controlCommand);
181-
if (sendResult.error) throw sendResult.error;
182-
if (sendResult.status !== 0) {
183-
const detail = String(sendResult.stderr || sendResult.stdout || '').trim();
184-
throw new Error(`tmux could not start cockpit control pane${detail ? `: ${detail}` : '.'}`);
196+
if (backend.name === 'tmux') {
197+
stdout.write(`[${toolName}] Created tmux session '${options.sessionName}' in ${repoRoot}.\n`);
198+
} else {
199+
stdout.write(`[${toolName}] Created ${backend.name} cockpit window '${options.sessionName}' in ${repoRoot}.\n`);
185200
}
186-
stdout.write(`[${toolName}] Created tmux session '${options.sessionName}' in ${repoRoot}.\n`);
187201
stdout.write(`[${toolName}] Control pane: ${controlCommand}\n`);
188202

189-
if (options.attach) {
190-
tmux.attachSession(options.sessionName);
191-
return { action: 'created-attached', sessionName: options.sessionName, repoRoot };
192-
}
193-
194-
return { action: 'created', sessionName: options.sessionName, repoRoot };
203+
return { action, backend: backend.name, sessionName: options.sessionName, repoRoot };
195204
}
196205

197206
if (require.main === module) {
@@ -203,6 +212,7 @@ if (require.main === module) {
203212

204213
module.exports = {
205214
DEFAULT_SESSION_NAME,
215+
DEFAULT_BACKEND,
206216
cockpitControlCommand,
207217
parseCockpitArgs,
208218
parseCockpitControlArgs,

src/terminal/index.js

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
'use strict';
2+
3+
const kitty = require('./kitty');
4+
const tmux = require('./tmux');
5+
6+
const BACKEND_NAMES = new Set(['auto', 'kitty', 'tmux']);
7+
const DEFAULT_BACKEND = 'tmux';
8+
9+
function normalizeBackendName(value, fallback = DEFAULT_BACKEND) {
10+
const normalized = String(value || fallback).trim().toLowerCase();
11+
if (!BACKEND_NAMES.has(normalized)) {
12+
throw new Error(`--backend requires auto, kitty, or tmux`);
13+
}
14+
return normalized;
15+
}
16+
17+
function createBackends(options = {}) {
18+
return {
19+
kitty: options.kittyBackend || kitty.createBackend(options.kitty || {}),
20+
tmux: options.tmuxBackend || tmux.createBackend(options.tmux || {}),
21+
};
22+
}
23+
24+
function firstText(...values) {
25+
for (const value of values) {
26+
if (typeof value === 'string' && value.trim().length > 0) return value.trim();
27+
}
28+
return '';
29+
}
30+
31+
function metadataOf(target = {}) {
32+
return target.metadata && typeof target.metadata === 'object' ? target.metadata : {};
33+
}
34+
35+
function terminalOf(target = {}) {
36+
return target.terminal && typeof target.terminal === 'object' ? target.terminal : {};
37+
}
38+
39+
function tmuxOf(target = {}) {
40+
return target.tmux && typeof target.tmux === 'object' ? target.tmux : {};
41+
}
42+
43+
function kittyOf(target = {}) {
44+
return target.kitty && typeof target.kitty === 'object' ? target.kitty : {};
45+
}
46+
47+
function resolveTargetBackendName(target = {}, fallback = '') {
48+
const metadata = metadataOf(target);
49+
const terminal = terminalOf(target);
50+
const explicit = firstText(
51+
target.terminalBackend,
52+
target.backend,
53+
terminal.backend,
54+
metadata.terminalBackend,
55+
metadata['terminal.backend'],
56+
);
57+
if (explicit) return normalizeBackendName(explicit);
58+
59+
const tmux = tmuxOf(target);
60+
if (firstText(target.paneId, target.tmuxPaneId, target.tmuxTarget, tmux.paneId, tmux.target, metadata.tmuxPaneId, metadata['tmux.paneId'])) {
61+
return 'tmux';
62+
}
63+
64+
const kittyTarget = kittyOf(target);
65+
if (firstText(
66+
target.kittyMatch,
67+
target.match,
68+
target.kittyWindowId,
69+
target.windowId,
70+
target.kittyTitle,
71+
target.windowTitle,
72+
terminal.match,
73+
terminal.windowId,
74+
terminal.title,
75+
kittyTarget.match,
76+
kittyTarget.windowId,
77+
kittyTarget.title,
78+
metadata.kittyMatch,
79+
metadata['kitty.match'],
80+
metadata.kittyWindowId,
81+
metadata['kitty.windowId'],
82+
metadata.kittyTitle,
83+
metadata['kitty.title'],
84+
)) {
85+
return 'kitty';
86+
}
87+
88+
return fallback ? normalizeBackendName(fallback) : '';
89+
}
90+
91+
function selectTerminalBackend(value = DEFAULT_BACKEND, options = {}) {
92+
const name = normalizeBackendName(value);
93+
const backends = createBackends(options);
94+
95+
if (name === 'auto') {
96+
if (backends.kitty && typeof backends.kitty.isAvailable === 'function' && backends.kitty.isAvailable()) {
97+
return backends.kitty;
98+
}
99+
return backends.tmux;
100+
}
101+
102+
return backends[name];
103+
}
104+
105+
function selectTerminalBackendForTarget(target = {}, options = {}) {
106+
const name = resolveTargetBackendName(target, options.defaultBackend);
107+
if (!name) return null;
108+
return selectTerminalBackend(name, options);
109+
}
110+
111+
module.exports = {
112+
DEFAULT_BACKEND,
113+
normalizeBackendName,
114+
resolveTargetBackendName,
115+
selectTerminalBackend,
116+
selectTerminalBackendForTarget,
117+
createBackends,
118+
kitty,
119+
tmux,
120+
};

0 commit comments

Comments
 (0)