Skip to content

Commit 8454c25

Browse files
NagyViktNagyViktclaude
authored
Show a live kitty-window tree in the gx cockpit sidebar (#531)
Phase 7 of the dmux-style cockpit plan: ship a kitty-tree reader that runs kitty @ ls against the cockpit's remote-control socket, parses the OS-window/tabs/windows JSON, classifies each window as control, agent, or shell, and exposes a flat list keyed by user and session. The sidebar renders the tree between the agent lanes block and the shortcut block, mirroring dmux's user > session > pane layout with a > cursor on the focused row plus short kind tags. Tree state is optional on the cockpit state — legacy callers without a kittyTree field continue to render the pre-phase-7 sidebar. Co-authored-by: NagyVikt <nagy.viktordp@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5d425d8 commit 8454c25

6 files changed

Lines changed: 446 additions & 0 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# dmux-style cockpit — Phase 7: kitty window tree in the sidebar
2+
3+
## Why
4+
5+
The user wants the cockpit sidebar to mirror dmux's session tree:
6+
`<user> > <session-name> > <pane-1> · <pane-2> · ...`. Today the
7+
sidebar shows the agent lanes plus the dmux-style shortcut block, but
8+
nothing tracks the actual Kitty windows the user has open inside the
9+
spawned cockpit OS-window. So when they split with Ctrl+Shift+Enter
10+
or Ctrl+Shift+\ in Kitty, the new pane is invisible to the cockpit.
11+
12+
Phase 7 adds a live Kitty-window tree to the sidebar — populated from
13+
`kitty @ ls` — so every pane (control, agent lanes, shells) is listed
14+
under the user/session header with a focus marker.
15+
16+
## What changes
17+
18+
- New `src/cockpit/kitty-tree.js`:
19+
- `readKittyTree({ env, socket, runner, osWindowId })` runs
20+
`kitty @ ls --to=<sock>`, parses the JSON, and returns
21+
`{ user, sessionLabel, osWindowId, windows, error }`.
22+
- `flattenOsWindow` extracts windows from nested `tabs[].windows[]`.
23+
- `classifyWindow` heuristically tags each window as `control`,
24+
`agent`, or `shell` (used by the sidebar to print short tags).
25+
- `pickOsWindow` defaults to the focused entry but accepts an
26+
`osWindowId` override.
27+
- `src/cockpit/sidebar.js` gains `renderKittyTreeRows(state, width,
28+
options)` and calls it inside `renderSidebar` between the agent
29+
lanes and the shortcut block. The tree renders as:
30+
```
31+
deadpool
32+
gitguardex
33+
> gx cockpit [gx]
34+
codex codex [cx]
35+
shell-1 [ba]
36+
```
37+
- The cockpit sidebar gracefully omits the tree section when no
38+
`state.kittyTree` is set, and prints `(kitty: <error>)` when the
39+
reader returned a non-empty `error` field.
40+
41+
## Impact
42+
43+
- Reader is fully runner-injectable for unit tests (no real Kitty
44+
required in CI).
45+
- Sidebar tests assert the new rows render only when the tree is
46+
populated; legacy tests with no tree state continue to render the
47+
pre-phase-7 sidebar layout.
48+
- Future PRs can populate `state.kittyTree` in the cockpit-control
49+
refresh loop (call `readKittyTree` on every tick); this PR ships
50+
the data + render plumbing only.
51+
- No safety-model change.
52+
- ASCII-only renderer.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Cockpit ships a kitty-tree reader module
4+
The cockpit SHALL expose a `kitty-tree` module that runs `kitty @ ls`
5+
against the configured remote-control socket and returns a normalized
6+
tree containing the current user, session label, focused OS-window id,
7+
and a flat list of windows with classified kinds (`control`,
8+
`agent`, `shell`).
9+
10+
#### Scenario: readKittyTree parses the kitty @ ls JSON output
11+
- **WHEN** `readKittyTree({ env: { KITTY_LISTEN_ON, USER }, runner })`
12+
is called with a runner that returns `status: 0` and the kitty
13+
`@ ls` JSON payload for one OS-window with three windows
14+
(`gx cockpit`, a codex agent, a bash shell)
15+
- **THEN** the result has `error === ''`
16+
- **AND** `result.user` equals the `USER` env var
17+
- **AND** `result.windows` has length 3 with kinds
18+
`['control', 'agent', 'shell']`.
19+
20+
#### Scenario: Missing socket falls back to an empty tree
21+
- **WHEN** `readKittyTree({ env: {} })` is called with no
22+
`KITTY_LISTEN_ON` set
23+
- **THEN** the result has `windows: []` and `error` matches `/no
24+
KITTY_LISTEN_ON/`.
25+
26+
### Requirement: Sidebar renders the kitty tree above the shortcut block
27+
The cockpit sidebar SHALL render the kitty window tree (when present
28+
on `state.kittyTree`) between the agent lanes block and the dmux-style
29+
shortcut block. The tree SHALL list the user, the session label, and
30+
each window with a `>` cursor on the focused row plus a short kind
31+
tag (`[gx]`, `[cx]`, `[ba]`, `[sh]`).
32+
33+
#### Scenario: Sidebar surfaces the tree when populated
34+
- **WHEN** `renderSidebar` is called with `state.kittyTree` populated
35+
(user `deadpool`, session `gitguardex`, three windows with the
36+
first focused)
37+
- **THEN** the rendered output contains a line `^deadpool$`
38+
- **AND** the focused row matches `>\s+gx cockpit`
39+
- **AND** every other window appears in the output with a kind tag.
40+
41+
#### Scenario: Sidebar omits the tree when no state
42+
- **WHEN** `renderSidebar` is called with no `kittyTree` field on the
43+
state
44+
- **THEN** the rendered output does NOT contain a `^deadpool$` line.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Tasks
2+
3+
## 1. Spec
4+
- [x] 1.1 Capture proposal in `proposal.md`
5+
- [x] 1.2 Capture spec delta in `specs/cockpit-kitty-tree/spec.md`
6+
7+
## 2. Tests
8+
- [x] 2.1 Add `test/cockpit-kitty-tree.test.js` covering
9+
`buildLsArgs`, `classifyWindow`, `flattenOsWindow`,
10+
`pickOsWindow`, `readKittyTree` (with and without
11+
`KITTY_LISTEN_ON`), and the rendered sidebar tree (with and
12+
without state).
13+
14+
## 3. Implementation
15+
- [x] 3.1 Add `src/cockpit/kitty-tree.js` with `readKittyTree`,
16+
`flattenOsWindow`, `classifyWindow`, `pickOsWindow`,
17+
`buildLsArgs`, `userLabel`, `buildSessionLabel`, and
18+
`emptyTree`.
19+
- [x] 3.2 Add `renderKittyTreeRows` to `src/cockpit/sidebar.js` and
20+
insert it into `renderSidebar` between the agent lanes block
21+
and the shortcut block.
22+
23+
## 4. Cleanup
24+
- [ ] 4.1 Commit changes on the agent branch.
25+
- [ ] 4.2 Push branch and open a PR.
26+
- [ ] 4.3 Run `gx branch finish ... --via-pr --wait-for-merge --cleanup`.
27+
- [ ] 4.4 Record PR URL and `MERGED` evidence.

src/cockpit/kitty-tree.js

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
'use strict';
2+
3+
const cp = require('node:child_process');
4+
const os = require('node:os');
5+
const path = require('node:path');
6+
7+
const DEFAULT_BIN = 'kitty';
8+
const DEFAULT_TIMEOUT_MS = 1500;
9+
10+
function text(value, fallback = '') {
11+
if (typeof value === 'string') return value.trim() || fallback;
12+
if (value === null || value === undefined) return fallback;
13+
return String(value).trim() || fallback;
14+
}
15+
16+
function defaultRunner(cmd, args, options = {}) {
17+
return cp.spawnSync(cmd, args, {
18+
cwd: options.cwd,
19+
env: options.env ? { ...process.env, ...options.env } : process.env,
20+
encoding: 'utf8',
21+
stdio: 'pipe',
22+
timeout: options.timeout || DEFAULT_TIMEOUT_MS,
23+
});
24+
}
25+
26+
function buildLsArgs(socket) {
27+
const sock = text(socket);
28+
const args = ['@'];
29+
if (sock) args.push(`--to=${sock}`);
30+
args.push('ls');
31+
return args;
32+
}
33+
34+
function classifyWindow(window = {}) {
35+
const title = String(window.title || '').toLowerCase();
36+
const cmdline = Array.isArray(window.cmdline) ? window.cmdline.join(' ').toLowerCase() : '';
37+
if (/^gx cockpit/.test(title) || /gx cockpit/.test(cmdline)) return 'control';
38+
if (title.startsWith('agent ') || /agent\//.test(cmdline)) return 'agent';
39+
if (title === 'terminal' || cmdline.endsWith('bash') || cmdline.endsWith('zsh') || cmdline.endsWith('sh')) return 'shell';
40+
if (/codex|claude|gemini|cursor|opencode/.test(title) || /codex|claude|gemini|cursor|opencode/.test(cmdline)) return 'agent';
41+
return 'shell';
42+
}
43+
44+
function flattenOsWindow(osWindow = {}) {
45+
const tabs = Array.isArray(osWindow.tabs) ? osWindow.tabs : [];
46+
const windows = [];
47+
for (const tab of tabs) {
48+
const tabWindows = Array.isArray(tab.windows) ? tab.windows : [];
49+
for (const window of tabWindows) {
50+
windows.push({
51+
id: Number.isFinite(window.id) ? window.id : null,
52+
title: text(window.title),
53+
cwd: text(window.cwd),
54+
cmdline: Array.isArray(window.cmdline) ? window.cmdline : [],
55+
pid: Number.isFinite(window.pid) ? window.pid : null,
56+
isFocused: Boolean(window.is_focused || window.focused),
57+
isActive: Boolean(window.is_active || window.active),
58+
kind: classifyWindow(window),
59+
tabId: Number.isFinite(tab.id) ? tab.id : null,
60+
tabTitle: text(tab.title),
61+
});
62+
}
63+
}
64+
return windows;
65+
}
66+
67+
function pickOsWindow(payload, options = {}) {
68+
if (!Array.isArray(payload) || payload.length === 0) return null;
69+
const targetId = Number.parseInt(options.osWindowId, 10);
70+
if (Number.isFinite(targetId)) {
71+
return payload.find((entry) => entry && entry.id === targetId) || payload[0];
72+
}
73+
return payload.find((entry) => entry && (entry.is_focused || entry.focused)) || payload[0];
74+
}
75+
76+
function buildSessionLabel(options = {}) {
77+
if (text(options.sessionLabel)) return text(options.sessionLabel);
78+
const env = options.env || process.env;
79+
const fromEnv = text(env.GUARDEX_SESSION_LABEL);
80+
if (fromEnv) return fromEnv;
81+
const repoRoot = text(options.repoRoot);
82+
if (repoRoot) return path.basename(repoRoot);
83+
return 'session';
84+
}
85+
86+
function userLabel(options = {}) {
87+
const env = options.env || process.env;
88+
return text(env.USER) || text(env.LOGNAME) || (typeof os.userInfo === 'function' ? text(os.userInfo().username) : '') || 'user';
89+
}
90+
91+
function emptyTree(options = {}) {
92+
return {
93+
user: userLabel(options),
94+
sessionLabel: buildSessionLabel(options),
95+
osWindowId: null,
96+
windows: [],
97+
error: '',
98+
};
99+
}
100+
101+
function readKittyTree(options = {}) {
102+
const env = options.env || process.env;
103+
const socket = text(options.socket || env.KITTY_LISTEN_ON);
104+
if (!socket) {
105+
return { ...emptyTree(options), error: 'no KITTY_LISTEN_ON socket' };
106+
}
107+
const bin = text(options.bin || env.GUARDEX_KITTY_BIN, DEFAULT_BIN);
108+
const runner = typeof options.runner === 'function' ? options.runner : defaultRunner;
109+
const result = runner(bin, buildLsArgs(socket), { env, timeout: options.timeoutMs });
110+
if (!result || result.error || result.status !== 0) {
111+
const msg = result && (result.stderr || result.error || '').toString().trim();
112+
return { ...emptyTree(options), error: msg || 'kitty @ ls failed' };
113+
}
114+
let payload;
115+
try {
116+
payload = JSON.parse(String(result.stdout || ''));
117+
} catch (error) {
118+
return { ...emptyTree(options), error: `parse error: ${error.message}` };
119+
}
120+
const osWindow = pickOsWindow(payload, options);
121+
if (!osWindow) {
122+
return { ...emptyTree(options), error: 'no os-window in kitty tree' };
123+
}
124+
return {
125+
user: userLabel(options),
126+
sessionLabel: buildSessionLabel(options),
127+
osWindowId: Number.isFinite(osWindow.id) ? osWindow.id : null,
128+
windows: flattenOsWindow(osWindow),
129+
error: '',
130+
};
131+
}
132+
133+
module.exports = {
134+
DEFAULT_BIN,
135+
DEFAULT_TIMEOUT_MS,
136+
buildLsArgs,
137+
classifyWindow,
138+
emptyTree,
139+
flattenOsWindow,
140+
pickOsWindow,
141+
readKittyTree,
142+
userLabel,
143+
buildSessionLabel,
144+
};

src/cockpit/sidebar.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,54 @@ function fitRow(left, right, width) {
199199
return `${truncate(left, leftWidth).padEnd(leftWidth, ' ')}${right}`;
200200
}
201201

202+
function kittyTreeOf(state = {}) {
203+
const tree = state && typeof state === 'object' ? state.kittyTree : null;
204+
return tree && typeof tree === 'object' ? tree : null;
205+
}
206+
207+
function classifyTag(window = {}) {
208+
if (window.kind === 'control') return 'gx';
209+
if (window.kind === 'agent') return 'cx';
210+
if (window.kind === 'shell') return 'ba';
211+
return 'sh';
212+
}
213+
214+
function windowLabel(window = {}, fallback) {
215+
const explicit = text(window.title);
216+
if (explicit) return explicit;
217+
if (window.kind === 'control') return 'gx cockpit';
218+
if (Array.isArray(window.cmdline) && window.cmdline.length > 0) {
219+
return path.basename(text(window.cmdline[0])) || fallback;
220+
}
221+
return fallback;
222+
}
223+
224+
function renderKittyTreeRows(state, width, options = {}) {
225+
const tree = kittyTreeOf(state);
226+
if (!tree) return [];
227+
const theme = getCockpitTheme(options.theme || (state.settings && state.settings.theme), options);
228+
const windows = Array.isArray(tree.windows) ? tree.windows : [];
229+
const lines = [];
230+
lines.push(colorize(boundLine(text(tree.user, 'user'), width), 'title', theme));
231+
lines.push(colorize(boundLine(` ${text(tree.sessionLabel, 'session')}`, width), 'secondary', theme));
232+
if (windows.length === 0) {
233+
lines.push(colorize(boundLine(' no kitty panes detected', width), 'secondary', theme));
234+
} else {
235+
windows.forEach((window, index) => {
236+
const cursor = window.isFocused ? '>' : ' ';
237+
const label = windowLabel(window, `pane-${index + 1}`);
238+
const tag = classifyTag(window);
239+
const row = ` ${cursor} ${label}`.padEnd(Math.max(width - 6, 6), ' ') + ` [${tag}]`;
240+
const token = window.isFocused ? 'selected' : 'secondary';
241+
lines.push(colorize(boundLine(row, width), token, theme));
242+
});
243+
}
244+
if (tree.error) {
245+
lines.push(colorize(boundLine(` (kitty: ${tree.error})`, width), 'secondary', theme));
246+
}
247+
return lines;
248+
}
249+
202250
function renderShortcutRows(width, options) {
203251
const theme = getCockpitTheme(options.theme, options);
204252
const rows = [
@@ -243,6 +291,12 @@ function renderSidebar(state = {}, options = {}) {
243291
});
244292
}
245293

294+
const treeRows = renderKittyTreeRows(state, width, options);
295+
if (treeRows.length > 0) {
296+
lines.push('');
297+
lines.push(...treeRows);
298+
}
299+
246300
lines.push(...renderShortcutRows(width, options));
247301

248302
return `${lines.join('\n')}\n`;

0 commit comments

Comments
 (0)