Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Kitty-backed cockpit terminal backend

## Why

`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.

## What Changes

- Add a `src/terminal` backend abstraction with Kitty and tmux implementations.
- Add dry-run-testable Kitty command builders for cockpit, agent pane, terminal pane, focus, close, and send-text actions.
- Add `gx cockpit --backend kitty|tmux|auto`; auto prefers Kitty when remote control responds and otherwise uses tmux.
- Keep current tmux behavior and cockpit shortcut coverage intact.

## Impact

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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
## ADDED Requirements

### Requirement: Cockpit terminal backend selection

`gx cockpit` SHALL accept `--backend kitty`, `--backend tmux`, and `--backend auto`.

#### Scenario: Auto prefers Kitty when available

- **WHEN** the operator runs `gx cockpit --backend auto`
- **AND** Kitty remote control is available
- **THEN** the cockpit SHALL select the Kitty backend.

#### Scenario: Auto falls back to tmux

- **WHEN** the operator runs `gx cockpit --backend auto`
- **AND** Kitty remote control is unavailable
- **THEN** the cockpit SHALL select the tmux backend.

### Requirement: Kitty cockpit command builders

The Kitty backend SHALL expose stable command builders for cockpit layout, agent pane, terminal pane, focus, close, and send-text operations.

#### Scenario: Cockpit layout command

- **WHEN** a cockpit layout is opened with Kitty
- **THEN** the backend SHALL build `kitty @ launch --type=window --cwd <repoRoot> --title "gx cockpit" ...`.

#### Scenario: Agent pane command

- **WHEN** an agent pane is launched with Kitty
- **THEN** the backend SHALL build `kitty @ launch --type=window --location=vsplit --cwd <worktree> --title <agent>`.

#### Scenario: Remote-control commands

- **WHEN** focus, close, or send-text is requested for a Kitty target id
- **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`.

### Requirement: Tmux compatibility

The tmux cockpit path SHALL remain available through `gx cockpit --backend tmux` and keep existing tmux session behavior.

#### Scenario: Explicit tmux backend

- **WHEN** the operator runs `gx cockpit --backend tmux`
- **THEN** the cockpit SHALL create or attach the configured tmux session using the existing tmux session helpers.
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
## 1. Spec

- [x] Capture Kitty/tmux cockpit backend selection and command-builder requirements.

## 2. Tests

- [x] Cover backend selection preferring Kitty when available and falling back to tmux.
- [x] Cover stable Kitty command construction.
- [x] Keep tmux cockpit command tests passing.
- [x] Verify cockpit shortcut tests remain green.

## 3. Implementation

- [x] Add terminal backend abstraction files under `src/terminal/`.
- [x] Wire `gx cockpit --backend kitty|tmux|auto`.
- [x] Preserve existing cockpit keybinding behavior without editing actively owned keybinding files.
- Note: `src/cockpit/keybindings.js` was actively owned by session `019dde2a`; this change leaves it untouched and verifies existing coverage.

## 4. Verification

- [x] Run focused Node tests for cockpit terminal backends, cockpit command behavior, keybindings, and tmux helpers.
- 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.
- [x] Run OpenSpec validation.
- Evidence: `openspec validate agent-codex-kitty-gx-cockpit-backend-2026-04-30-13-47 --strict` passed.
- Evidence: `openspec validate --specs` passed with no spec items found.

## 5. Cleanup

- [ ] Commit changes.
- [ ] Finish via PR, wait for merge, cleanup, and record `MERGED` evidence.
82 changes: 46 additions & 36 deletions src/cockpit/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,15 @@ const { readCockpitState } = require('./state');
const { renderCockpit } = require('./render');
const control = require('./control');
const actions = require('./actions');
const {
ensureTmuxAvailable,
sessionExists,
createSession,
attachSession,
sendKeys,
} = require('../tmux/session');
const { normalizeBackendName, selectTerminalBackend } = require('../terminal');

const DEFAULT_SESSION_NAME = 'guardex';
const DEFAULT_BACKEND = 'tmux';

function parseCockpitArgs(rawArgs = []) {
const options = {
sessionName: DEFAULT_SESSION_NAME,
backend: process.env.GUARDEX_COCKPIT_BACKEND || DEFAULT_BACKEND,
attach: false,
target: process.cwd(),
};
Expand All @@ -41,6 +37,23 @@ function parseCockpitArgs(rawArgs = []) {
}
continue;
}
if (arg === '--backend') {
const next = rawArgs[index + 1];
if (!next || next.startsWith('-')) {
throw new Error('--backend requires auto, kitty, or tmux');
}
options.backend = normalizeBackendName(next);
index += 1;
continue;
}
if (arg.startsWith('--backend=')) {
const next = arg.slice('--backend='.length);
if (!next) {
throw new Error('--backend requires auto, kitty, or tmux');
}
options.backend = normalizeBackendName(next);
continue;
}
if (arg === '--target' || arg === '-t') {
const next = rawArgs[index + 1];
if (!next || next.startsWith('-')) {
Expand Down Expand Up @@ -133,13 +146,6 @@ function openCockpit(rawArgs = [], deps = {}) {
resolveRepoRoot,
toolName = 'gitguardex',
stdout = process.stdout,
tmux = {
ensureTmuxAvailable,
sessionExists,
createSession,
attachSession,
sendKeys,
},
} = deps;
if (typeof resolveRepoRoot !== 'function') {
throw new Error('openCockpit requires resolveRepoRoot');
Expand All @@ -162,36 +168,39 @@ function openCockpit(rawArgs = [], deps = {}) {
const options = parseCockpitArgs(rawArgs);
const repoRoot = resolveRepoRoot(options.target);
const controlCommand = cockpitControlCommand(repoRoot);
const terminalBackendOptions = { ...(deps.terminalBackendOptions || {}) };
if (deps.terminalBackends && deps.terminalBackends.kitty) {
terminalBackendOptions.kittyBackend = deps.terminalBackends.kitty;
}
if (deps.terminalBackends && deps.terminalBackends.tmux) {
terminalBackendOptions.tmuxBackend = deps.terminalBackends.tmux;
}
if (deps.tmux) {
terminalBackendOptions.tmux = { tmux: deps.tmux };
}
const backend = selectTerminalBackend(options.backend, terminalBackendOptions);

tmux.ensureTmuxAvailable();
const result = backend.openCockpitLayout({
repoRoot,
sessionName: options.sessionName,
command: controlCommand,
attach: options.attach,
});
const action = result && result.action ? result.action : 'created';

if (tmux.sessionExists(options.sessionName)) {
if (backend.name === 'tmux' && action === 'attached') {
stdout.write(`[${toolName}] Attaching tmux session '${options.sessionName}'.\n`);
tmux.attachSession(options.sessionName);
return { action: 'attached', sessionName: options.sessionName, repoRoot };
return { action, backend: backend.name, sessionName: options.sessionName, repoRoot };
}

const createResult = tmux.createSession(options.sessionName, repoRoot);
if (createResult.error) throw createResult.error;
if (createResult.status !== 0) {
const detail = String(createResult.stderr || createResult.stdout || '').trim();
throw new Error(`tmux could not create session '${options.sessionName}'${detail ? `: ${detail}` : '.'}`);
}
const sendResult = tmux.sendKeys(options.sessionName, controlCommand);
if (sendResult.error) throw sendResult.error;
if (sendResult.status !== 0) {
const detail = String(sendResult.stderr || sendResult.stdout || '').trim();
throw new Error(`tmux could not start cockpit control pane${detail ? `: ${detail}` : '.'}`);
if (backend.name === 'tmux') {
stdout.write(`[${toolName}] Created tmux session '${options.sessionName}' in ${repoRoot}.\n`);
} else {
stdout.write(`[${toolName}] Created ${backend.name} cockpit window '${options.sessionName}' in ${repoRoot}.\n`);
}
stdout.write(`[${toolName}] Created tmux session '${options.sessionName}' in ${repoRoot}.\n`);
stdout.write(`[${toolName}] Control pane: ${controlCommand}\n`);

if (options.attach) {
tmux.attachSession(options.sessionName);
return { action: 'created-attached', sessionName: options.sessionName, repoRoot };
}

return { action: 'created', sessionName: options.sessionName, repoRoot };
return { action, backend: backend.name, sessionName: options.sessionName, repoRoot };
}

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

module.exports = {
DEFAULT_SESSION_NAME,
DEFAULT_BACKEND,
cockpitControlCommand,
parseCockpitArgs,
parseCockpitControlArgs,
Expand Down
120 changes: 120 additions & 0 deletions src/terminal/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
'use strict';

const kitty = require('./kitty');
const tmux = require('./tmux');

const BACKEND_NAMES = new Set(['auto', 'kitty', 'tmux']);
const DEFAULT_BACKEND = 'tmux';

function normalizeBackendName(value, fallback = DEFAULT_BACKEND) {
const normalized = String(value || fallback).trim().toLowerCase();
if (!BACKEND_NAMES.has(normalized)) {
throw new Error(`--backend requires auto, kitty, or tmux`);
}
return normalized;
}

function createBackends(options = {}) {
return {
kitty: options.kittyBackend || kitty.createBackend(options.kitty || {}),
tmux: options.tmuxBackend || tmux.createBackend(options.tmux || {}),
};
}

function firstText(...values) {
for (const value of values) {
if (typeof value === 'string' && value.trim().length > 0) return value.trim();
}
return '';
}

function metadataOf(target = {}) {
return target.metadata && typeof target.metadata === 'object' ? target.metadata : {};
}

function terminalOf(target = {}) {
return target.terminal && typeof target.terminal === 'object' ? target.terminal : {};
}

function tmuxOf(target = {}) {
return target.tmux && typeof target.tmux === 'object' ? target.tmux : {};
}

function kittyOf(target = {}) {
return target.kitty && typeof target.kitty === 'object' ? target.kitty : {};
}

function resolveTargetBackendName(target = {}, fallback = '') {
const metadata = metadataOf(target);
const terminal = terminalOf(target);
const explicit = firstText(
target.terminalBackend,
target.backend,
terminal.backend,
metadata.terminalBackend,
metadata['terminal.backend'],
);
if (explicit) return normalizeBackendName(explicit);

const tmux = tmuxOf(target);
if (firstText(target.paneId, target.tmuxPaneId, target.tmuxTarget, tmux.paneId, tmux.target, metadata.tmuxPaneId, metadata['tmux.paneId'])) {
return 'tmux';
}

const kittyTarget = kittyOf(target);
if (firstText(
target.kittyMatch,
target.match,
target.kittyWindowId,
target.windowId,
target.kittyTitle,
target.windowTitle,
terminal.match,
terminal.windowId,
terminal.title,
kittyTarget.match,
kittyTarget.windowId,
kittyTarget.title,
metadata.kittyMatch,
metadata['kitty.match'],
metadata.kittyWindowId,
metadata['kitty.windowId'],
metadata.kittyTitle,
metadata['kitty.title'],
)) {
return 'kitty';
}

return fallback ? normalizeBackendName(fallback) : '';
}

function selectTerminalBackend(value = DEFAULT_BACKEND, options = {}) {
const name = normalizeBackendName(value);
const backends = createBackends(options);

if (name === 'auto') {
if (backends.kitty && typeof backends.kitty.isAvailable === 'function' && backends.kitty.isAvailable()) {
return backends.kitty;
}
return backends.tmux;
}

return backends[name];
}

function selectTerminalBackendForTarget(target = {}, options = {}) {
const name = resolveTargetBackendName(target, options.defaultBackend);
if (!name) return null;
return selectTerminalBackend(name, options);
}

module.exports = {
DEFAULT_BACKEND,
normalizeBackendName,
resolveTargetBackendName,
selectTerminalBackend,
selectTerminalBackendForTarget,
createBackends,
kitty,
tmux,
};
Loading
Loading