diff --git a/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase7-kitty-tre-2026-05-05-10-00/proposal.md b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase7-kitty-tre-2026-05-05-10-00/proposal.md new file mode 100644 index 0000000..089bd53 --- /dev/null +++ b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase7-kitty-tre-2026-05-05-10-00/proposal.md @@ -0,0 +1,52 @@ +# dmux-style cockpit — Phase 7: kitty window tree in the sidebar + +## Why + +The user wants the cockpit sidebar to mirror dmux's session tree: +` > > · · ...`. Today the +sidebar shows the agent lanes plus the dmux-style shortcut block, but +nothing tracks the actual Kitty windows the user has open inside the +spawned cockpit OS-window. So when they split with Ctrl+Shift+Enter +or Ctrl+Shift+\ in Kitty, the new pane is invisible to the cockpit. + +Phase 7 adds a live Kitty-window tree to the sidebar — populated from +`kitty @ ls` — so every pane (control, agent lanes, shells) is listed +under the user/session header with a focus marker. + +## What changes + +- New `src/cockpit/kitty-tree.js`: + - `readKittyTree({ env, socket, runner, osWindowId })` runs + `kitty @ ls --to=`, parses the JSON, and returns + `{ user, sessionLabel, osWindowId, windows, error }`. + - `flattenOsWindow` extracts windows from nested `tabs[].windows[]`. + - `classifyWindow` heuristically tags each window as `control`, + `agent`, or `shell` (used by the sidebar to print short tags). + - `pickOsWindow` defaults to the focused entry but accepts an + `osWindowId` override. +- `src/cockpit/sidebar.js` gains `renderKittyTreeRows(state, width, + options)` and calls it inside `renderSidebar` between the agent + lanes and the shortcut block. The tree renders as: + ``` + deadpool + gitguardex + > gx cockpit [gx] + codex codex [cx] + shell-1 [ba] + ``` +- The cockpit sidebar gracefully omits the tree section when no + `state.kittyTree` is set, and prints `(kitty: )` when the + reader returned a non-empty `error` field. + +## Impact + +- Reader is fully runner-injectable for unit tests (no real Kitty + required in CI). +- Sidebar tests assert the new rows render only when the tree is + populated; legacy tests with no tree state continue to render the + pre-phase-7 sidebar layout. +- Future PRs can populate `state.kittyTree` in the cockpit-control + refresh loop (call `readKittyTree` on every tick); this PR ships + the data + render plumbing only. +- No safety-model change. +- ASCII-only renderer. diff --git a/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase7-kitty-tre-2026-05-05-10-00/specs/cockpit-kitty-tree/spec.md b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase7-kitty-tre-2026-05-05-10-00/specs/cockpit-kitty-tree/spec.md new file mode 100644 index 0000000..c6f0965 --- /dev/null +++ b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase7-kitty-tre-2026-05-05-10-00/specs/cockpit-kitty-tree/spec.md @@ -0,0 +1,44 @@ +## ADDED Requirements + +### Requirement: Cockpit ships a kitty-tree reader module +The cockpit SHALL expose a `kitty-tree` module that runs `kitty @ ls` +against the configured remote-control socket and returns a normalized +tree containing the current user, session label, focused OS-window id, +and a flat list of windows with classified kinds (`control`, +`agent`, `shell`). + +#### Scenario: readKittyTree parses the kitty @ ls JSON output +- **WHEN** `readKittyTree({ env: { KITTY_LISTEN_ON, USER }, runner })` + is called with a runner that returns `status: 0` and the kitty + `@ ls` JSON payload for one OS-window with three windows + (`gx cockpit`, a codex agent, a bash shell) +- **THEN** the result has `error === ''` +- **AND** `result.user` equals the `USER` env var +- **AND** `result.windows` has length 3 with kinds + `['control', 'agent', 'shell']`. + +#### Scenario: Missing socket falls back to an empty tree +- **WHEN** `readKittyTree({ env: {} })` is called with no + `KITTY_LISTEN_ON` set +- **THEN** the result has `windows: []` and `error` matches `/no + KITTY_LISTEN_ON/`. + +### Requirement: Sidebar renders the kitty tree above the shortcut block +The cockpit sidebar SHALL render the kitty window tree (when present +on `state.kittyTree`) between the agent lanes block and the dmux-style +shortcut block. The tree SHALL list the user, the session label, and +each window with a `>` cursor on the focused row plus a short kind +tag (`[gx]`, `[cx]`, `[ba]`, `[sh]`). + +#### Scenario: Sidebar surfaces the tree when populated +- **WHEN** `renderSidebar` is called with `state.kittyTree` populated + (user `deadpool`, session `gitguardex`, three windows with the + first focused) +- **THEN** the rendered output contains a line `^deadpool$` +- **AND** the focused row matches `>\s+gx cockpit` +- **AND** every other window appears in the output with a kind tag. + +#### Scenario: Sidebar omits the tree when no state +- **WHEN** `renderSidebar` is called with no `kittyTree` field on the + state +- **THEN** the rendered output does NOT contain a `^deadpool$` line. diff --git a/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase7-kitty-tre-2026-05-05-10-00/tasks.md b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase7-kitty-tre-2026-05-05-10-00/tasks.md new file mode 100644 index 0000000..13ab11e --- /dev/null +++ b/openspec/changes/agent-claude-gitguardex-dmux-cockpit-phase7-kitty-tre-2026-05-05-10-00/tasks.md @@ -0,0 +1,27 @@ +# Tasks + +## 1. Spec +- [x] 1.1 Capture proposal in `proposal.md` +- [x] 1.2 Capture spec delta in `specs/cockpit-kitty-tree/spec.md` + +## 2. Tests +- [x] 2.1 Add `test/cockpit-kitty-tree.test.js` covering + `buildLsArgs`, `classifyWindow`, `flattenOsWindow`, + `pickOsWindow`, `readKittyTree` (with and without + `KITTY_LISTEN_ON`), and the rendered sidebar tree (with and + without state). + +## 3. Implementation +- [x] 3.1 Add `src/cockpit/kitty-tree.js` with `readKittyTree`, + `flattenOsWindow`, `classifyWindow`, `pickOsWindow`, + `buildLsArgs`, `userLabel`, `buildSessionLabel`, and + `emptyTree`. +- [x] 3.2 Add `renderKittyTreeRows` to `src/cockpit/sidebar.js` and + insert it into `renderSidebar` between the agent lanes block + and the shortcut block. + +## 4. Cleanup +- [ ] 4.1 Commit changes on the agent branch. +- [ ] 4.2 Push branch and open a PR. +- [ ] 4.3 Run `gx branch finish ... --via-pr --wait-for-merge --cleanup`. +- [ ] 4.4 Record PR URL and `MERGED` evidence. diff --git a/src/cockpit/kitty-tree.js b/src/cockpit/kitty-tree.js new file mode 100644 index 0000000..7d9dd45 --- /dev/null +++ b/src/cockpit/kitty-tree.js @@ -0,0 +1,144 @@ +'use strict'; + +const cp = require('node:child_process'); +const os = require('node:os'); +const path = require('node:path'); + +const DEFAULT_BIN = 'kitty'; +const DEFAULT_TIMEOUT_MS = 1500; + +function text(value, fallback = '') { + if (typeof value === 'string') return value.trim() || fallback; + if (value === null || value === undefined) return fallback; + return String(value).trim() || fallback; +} + +function defaultRunner(cmd, args, options = {}) { + return cp.spawnSync(cmd, args, { + cwd: options.cwd, + env: options.env ? { ...process.env, ...options.env } : process.env, + encoding: 'utf8', + stdio: 'pipe', + timeout: options.timeout || DEFAULT_TIMEOUT_MS, + }); +} + +function buildLsArgs(socket) { + const sock = text(socket); + const args = ['@']; + if (sock) args.push(`--to=${sock}`); + args.push('ls'); + return args; +} + +function classifyWindow(window = {}) { + const title = String(window.title || '').toLowerCase(); + const cmdline = Array.isArray(window.cmdline) ? window.cmdline.join(' ').toLowerCase() : ''; + if (/^gx cockpit/.test(title) || /gx cockpit/.test(cmdline)) return 'control'; + if (title.startsWith('agent ') || /agent\//.test(cmdline)) return 'agent'; + if (title === 'terminal' || cmdline.endsWith('bash') || cmdline.endsWith('zsh') || cmdline.endsWith('sh')) return 'shell'; + if (/codex|claude|gemini|cursor|opencode/.test(title) || /codex|claude|gemini|cursor|opencode/.test(cmdline)) return 'agent'; + return 'shell'; +} + +function flattenOsWindow(osWindow = {}) { + const tabs = Array.isArray(osWindow.tabs) ? osWindow.tabs : []; + const windows = []; + for (const tab of tabs) { + const tabWindows = Array.isArray(tab.windows) ? tab.windows : []; + for (const window of tabWindows) { + windows.push({ + id: Number.isFinite(window.id) ? window.id : null, + title: text(window.title), + cwd: text(window.cwd), + cmdline: Array.isArray(window.cmdline) ? window.cmdline : [], + pid: Number.isFinite(window.pid) ? window.pid : null, + isFocused: Boolean(window.is_focused || window.focused), + isActive: Boolean(window.is_active || window.active), + kind: classifyWindow(window), + tabId: Number.isFinite(tab.id) ? tab.id : null, + tabTitle: text(tab.title), + }); + } + } + return windows; +} + +function pickOsWindow(payload, options = {}) { + if (!Array.isArray(payload) || payload.length === 0) return null; + const targetId = Number.parseInt(options.osWindowId, 10); + if (Number.isFinite(targetId)) { + return payload.find((entry) => entry && entry.id === targetId) || payload[0]; + } + return payload.find((entry) => entry && (entry.is_focused || entry.focused)) || payload[0]; +} + +function buildSessionLabel(options = {}) { + if (text(options.sessionLabel)) return text(options.sessionLabel); + const env = options.env || process.env; + const fromEnv = text(env.GUARDEX_SESSION_LABEL); + if (fromEnv) return fromEnv; + const repoRoot = text(options.repoRoot); + if (repoRoot) return path.basename(repoRoot); + return 'session'; +} + +function userLabel(options = {}) { + const env = options.env || process.env; + return text(env.USER) || text(env.LOGNAME) || (typeof os.userInfo === 'function' ? text(os.userInfo().username) : '') || 'user'; +} + +function emptyTree(options = {}) { + return { + user: userLabel(options), + sessionLabel: buildSessionLabel(options), + osWindowId: null, + windows: [], + error: '', + }; +} + +function readKittyTree(options = {}) { + const env = options.env || process.env; + const socket = text(options.socket || env.KITTY_LISTEN_ON); + if (!socket) { + return { ...emptyTree(options), error: 'no KITTY_LISTEN_ON socket' }; + } + const bin = text(options.bin || env.GUARDEX_KITTY_BIN, DEFAULT_BIN); + const runner = typeof options.runner === 'function' ? options.runner : defaultRunner; + const result = runner(bin, buildLsArgs(socket), { env, timeout: options.timeoutMs }); + if (!result || result.error || result.status !== 0) { + const msg = result && (result.stderr || result.error || '').toString().trim(); + return { ...emptyTree(options), error: msg || 'kitty @ ls failed' }; + } + let payload; + try { + payload = JSON.parse(String(result.stdout || '')); + } catch (error) { + return { ...emptyTree(options), error: `parse error: ${error.message}` }; + } + const osWindow = pickOsWindow(payload, options); + if (!osWindow) { + return { ...emptyTree(options), error: 'no os-window in kitty tree' }; + } + return { + user: userLabel(options), + sessionLabel: buildSessionLabel(options), + osWindowId: Number.isFinite(osWindow.id) ? osWindow.id : null, + windows: flattenOsWindow(osWindow), + error: '', + }; +} + +module.exports = { + DEFAULT_BIN, + DEFAULT_TIMEOUT_MS, + buildLsArgs, + classifyWindow, + emptyTree, + flattenOsWindow, + pickOsWindow, + readKittyTree, + userLabel, + buildSessionLabel, +}; diff --git a/src/cockpit/sidebar.js b/src/cockpit/sidebar.js index ebba35b..0c4ab3a 100644 --- a/src/cockpit/sidebar.js +++ b/src/cockpit/sidebar.js @@ -199,6 +199,54 @@ function fitRow(left, right, width) { return `${truncate(left, leftWidth).padEnd(leftWidth, ' ')}${right}`; } +function kittyTreeOf(state = {}) { + const tree = state && typeof state === 'object' ? state.kittyTree : null; + return tree && typeof tree === 'object' ? tree : null; +} + +function classifyTag(window = {}) { + if (window.kind === 'control') return 'gx'; + if (window.kind === 'agent') return 'cx'; + if (window.kind === 'shell') return 'ba'; + return 'sh'; +} + +function windowLabel(window = {}, fallback) { + const explicit = text(window.title); + if (explicit) return explicit; + if (window.kind === 'control') return 'gx cockpit'; + if (Array.isArray(window.cmdline) && window.cmdline.length > 0) { + return path.basename(text(window.cmdline[0])) || fallback; + } + return fallback; +} + +function renderKittyTreeRows(state, width, options = {}) { + const tree = kittyTreeOf(state); + if (!tree) return []; + const theme = getCockpitTheme(options.theme || (state.settings && state.settings.theme), options); + const windows = Array.isArray(tree.windows) ? tree.windows : []; + const lines = []; + lines.push(colorize(boundLine(text(tree.user, 'user'), width), 'title', theme)); + lines.push(colorize(boundLine(` ${text(tree.sessionLabel, 'session')}`, width), 'secondary', theme)); + if (windows.length === 0) { + lines.push(colorize(boundLine(' no kitty panes detected', width), 'secondary', theme)); + } else { + windows.forEach((window, index) => { + const cursor = window.isFocused ? '>' : ' '; + const label = windowLabel(window, `pane-${index + 1}`); + const tag = classifyTag(window); + const row = ` ${cursor} ${label}`.padEnd(Math.max(width - 6, 6), ' ') + ` [${tag}]`; + const token = window.isFocused ? 'selected' : 'secondary'; + lines.push(colorize(boundLine(row, width), token, theme)); + }); + } + if (tree.error) { + lines.push(colorize(boundLine(` (kitty: ${tree.error})`, width), 'secondary', theme)); + } + return lines; +} + function renderShortcutRows(width, options) { const theme = getCockpitTheme(options.theme, options); const rows = [ @@ -243,6 +291,12 @@ function renderSidebar(state = {}, options = {}) { }); } + const treeRows = renderKittyTreeRows(state, width, options); + if (treeRows.length > 0) { + lines.push(''); + lines.push(...treeRows); + } + lines.push(...renderShortcutRows(width, options)); return `${lines.join('\n')}\n`; diff --git a/test/cockpit-kitty-tree.test.js b/test/cockpit-kitty-tree.test.js new file mode 100644 index 0000000..ea6b245 --- /dev/null +++ b/test/cockpit-kitty-tree.test.js @@ -0,0 +1,125 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + buildLsArgs, + classifyWindow, + emptyTree, + flattenOsWindow, + pickOsWindow, + readKittyTree, +} = require('../src/cockpit/kitty-tree'); +const { renderSidebar } = require('../src/cockpit/sidebar'); + +function stripAnsi(value) { + return String(value).replace(/\x1b\[[0-9;]*m/g, ''); +} + +test('buildLsArgs targets a custom socket when provided', () => { + assert.deepEqual(buildLsArgs('unix:/tmp/x.sock'), ['@', '--to=unix:/tmp/x.sock', 'ls']); + assert.deepEqual(buildLsArgs(''), ['@', 'ls']); +}); + +test('classifyWindow recognizes control, agents, and shells', () => { + assert.equal(classifyWindow({ title: 'gx cockpit' }), 'control'); + assert.equal(classifyWindow({ title: 'agent codex/foo' }), 'agent'); + assert.equal(classifyWindow({ title: 'codex login' }), 'agent'); + assert.equal(classifyWindow({ title: 'terminal' }), 'shell'); + assert.equal(classifyWindow({ cmdline: ['/bin/bash'] }), 'shell'); + assert.equal(classifyWindow({ title: 'random' }), 'shell'); +}); + +test('flattenOsWindow extracts windows from nested tabs', () => { + const osWindow = { + id: 1, + tabs: [ + { + id: 10, + title: 'main', + windows: [ + { id: 100, title: 'gx cockpit', cwd: '/repo', is_focused: true, cmdline: ['gx', 'cockpit', 'control'] }, + { id: 101, title: 'shell-1', cwd: '/repo', cmdline: ['/bin/bash'] }, + ], + }, + ], + }; + const windows = flattenOsWindow(osWindow); + assert.equal(windows.length, 2); + assert.equal(windows[0].kind, 'control'); + assert.equal(windows[0].isFocused, true); + assert.equal(windows[1].kind, 'shell'); +}); + +test('pickOsWindow defaults to the focused entry', () => { + const payload = [ + { id: 1, is_focused: false, tabs: [] }, + { id: 2, is_focused: true, tabs: [] }, + ]; + assert.equal(pickOsWindow(payload).id, 2); + assert.equal(pickOsWindow(payload, { osWindowId: 1 }).id, 1); + assert.equal(pickOsWindow([]), null); +}); + +test('readKittyTree returns empty tree when KITTY_LISTEN_ON is unset', () => { + const result = readKittyTree({ env: {} }); + assert.deepEqual(result.windows, []); + assert.match(result.error, /no KITTY_LISTEN_ON/); +}); + +test('readKittyTree parses kitty @ ls JSON output', () => { + const stdout = JSON.stringify([{ + id: 7, + is_focused: true, + tabs: [{ + id: 1, + windows: [ + { id: 11, title: 'gx cockpit', cwd: '/repo', is_focused: true, cmdline: ['gx', 'cockpit'] }, + { id: 12, title: 'codex codex', cwd: '/repo', cmdline: ['codex'] }, + { id: 13, title: 'shell-1', cwd: '/repo', cmdline: ['/bin/bash'] }, + ], + }], + }]); + const result = readKittyTree({ + env: { KITTY_LISTEN_ON: 'unix:/tmp/test.sock', USER: 'deadpool' }, + runner: () => ({ status: 0, stdout }), + }); + assert.equal(result.error, ''); + assert.equal(result.user, 'deadpool'); + assert.equal(result.osWindowId, 7); + assert.equal(result.windows.length, 3); + assert.equal(result.windows[0].kind, 'control'); + assert.equal(result.windows[1].kind, 'agent'); + assert.equal(result.windows[2].kind, 'shell'); +}); + +test('renderSidebar surfaces the kitty tree above the shortcut block when populated', () => { + const state = { + repoPath: '/work/gitguardex', + sessions: [], + kittyTree: { + user: 'deadpool', + sessionLabel: 'gitguardex', + osWindowId: 7, + windows: [ + { id: 11, title: 'gx cockpit', kind: 'control', isFocused: true }, + { id: 12, title: 'codex codex', kind: 'agent', isFocused: false }, + { id: 13, title: 'shell-1', kind: 'shell', isFocused: false }, + ], + error: '', + }, + }; + const output = stripAnsi(renderSidebar(state, { width: 38, noColor: true })); + assert.match(output, /^deadpool$/m); + assert.match(output, /^ gitguardex/m); + assert.match(output, />\s+gx cockpit/); + assert.match(output, /codex codex/); + assert.match(output, /shell-1/); + assert.match(output, /\[n\]ew agent/); +}); + +test('renderSidebar omits the kitty tree section when none is present', () => { + const output = stripAnsi(renderSidebar({ repoPath: '/work/gitguardex', sessions: [] }, { width: 32, noColor: true })); + assert.doesNotMatch(output, /^deadpool$/m); +});