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,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:
`<user> > <session-name> > <pane-1> · <pane-2> · ...`. 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=<sock>`, 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: <error>)` 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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
144 changes: 144 additions & 0 deletions src/cockpit/kitty-tree.js
Original file line number Diff line number Diff line change
@@ -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,
};
54 changes: 54 additions & 0 deletions src/cockpit/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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`;
Expand Down
Loading
Loading