pi-cockpit 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +43 -3
  2. package/package.json +80 -75
  3. package/src/agent-overlay.ts +337 -0
  4. package/src/agent-scroll.ts +69 -0
  5. package/src/agents-store.ts +112 -56
  6. package/src/ambient.ts +3 -0
  7. package/src/autocomplete-probe.ts +26 -0
  8. package/src/bash-bg-overlay.ts +412 -387
  9. package/src/bash-bg-store.ts +90 -90
  10. package/src/capturing-overlay.ts +77 -0
  11. package/src/claude-editor.ts +158 -0
  12. package/src/config.ts +6 -0
  13. package/src/edit-guard.ts +402 -0
  14. package/src/extension-status.ts +25 -25
  15. package/src/footer.ts +18 -9
  16. package/src/fullscreen-controller.ts +359 -0
  17. package/src/index.ts +711 -67
  18. package/src/input-routing.ts +63 -0
  19. package/src/maestro-store.ts +273 -273
  20. package/src/model-picker.ts +149 -0
  21. package/src/mouse-reporting.ts +65 -65
  22. package/src/public/v1/events.ts +187 -156
  23. package/src/quiet-tools.ts +23 -75
  24. package/src/render.ts +18 -10
  25. package/src/session-bar.ts +170 -0
  26. package/src/session-detail.ts +169 -0
  27. package/src/settings/cockpit-provider.ts +52 -19
  28. package/src/settings/i18n.ts +28 -4
  29. package/src/settings/settings-shell.ts +499 -42
  30. package/src/settings/ui-primitives.ts +59 -0
  31. package/src/settings-view.ts +36 -22
  32. package/src/sidebar-controller.ts +55 -6
  33. package/src/sidebar-render.ts +43 -17
  34. package/src/split-pane.ts +18 -2
  35. package/src/stack-widget.ts +53 -8
  36. package/src/supervision-store.ts +109 -0
  37. package/src/terminal-capability.ts +25 -0
  38. package/src/terminal-cleanup.ts +120 -0
  39. package/src/theme-picker.ts +255 -255
  40. package/src/thinking-fold.ts +142 -86
  41. package/src/thinking-timer.ts +32 -9
  42. package/src/tick-policy.ts +56 -56
  43. package/src/title-gen.ts +60 -60
  44. package/src/title-llm.ts +115 -115
  45. package/src/todo-store.ts +14 -1
  46. package/src/transcript-selection.ts +247 -0
  47. package/src/types.ts +36 -9
  48. package/src/viewport-stability.ts +101 -101
  49. package/src/viewport.ts +90 -62
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A responsive **agent cockpit** for [Pi](https://pi.dev): wide terminals get a docked Maestro operations sidebar, narrow terminals automatically fall back to the existing Todo and Agent widgets, and every layout keeps the Starship-style footer.
4
4
 
5
- It is the third plugin of the `pi-maestro-flow` project (alongside `pi-maestro-flow` and `pi-maestro-teammate`). It is installed and registered with `pi-maestro-flow`, but it also runs standalone. Current version: **0.8.0**.
5
+ It is the third plugin of the `pi-maestro-flow` project (alongside `pi-maestro-flow` and `pi-maestro-teammate`). It is installed and registered with `pi-maestro-flow`, but it also runs standalone. Current version: **0.9.1**.
6
6
 
7
7
  The dock is a non-capturing top-right overlay. Cockpit reserves its columns by wrapping the active TUI renderer at runtime, so the Pi workspace reflows instead of rendering underneath it. No Pi source files are modified.
8
8
 
@@ -25,7 +25,7 @@ The dock is a non-capturing top-right overlay. Cockpit reserves its columns by w
25
25
  `pi-cockpit` comes automatically with the orchestration layer — installing `pi-maestro-flow` pulls `pi-cockpit` and registers it into `settings.packages` on postinstall, no manual setup required. To use it on its own:
26
26
 
27
27
  ```bash
28
- pi install npm:pi-cockpit # standalone from npm
28
+ pi install npm:pi-cockpit@0.9.1 # standalone from npm
29
29
  # or, for one run:
30
30
  pi -e ./packages/pi-cockpit
31
31
  ```
@@ -122,7 +122,7 @@ The tab title is `frame + pi - <session> - <working state>`. The session part fo
122
122
 
123
123
  ## Sidebar compatibility
124
124
 
125
- The split-pane wrapper depends on Pi's current TUI renderer shape and is verified against Pi `0.82.1`. A render integration failure disables the split and retries the original renderer at full width.
125
+ The split-pane wrapper depends on Pi's current TUI renderer shape and is verified against Pi `0.83.0`. A render integration failure disables the split and retries the original renderer at full width.
126
126
 
127
127
  Do not enable `pi-cockpit`'s dock and `pi-atelier@0.7.0`'s sidebar together. Both reserve columns by wrapping the same renderer, and `pi-atelier@0.7.0` does not participate in Cockpit's split-owner marker protocol. Use `"sidebar": { "mode": "off" }` when running Atelier.
128
128
 
@@ -137,6 +137,46 @@ The original mockup was a browser page; a terminal is an ANSI stream with no DOM
137
137
  | Scanlines / logo light-up animation | no overlay/animation layer | a braille spinner frame, advanced on each redraw |
138
138
  | In-stream thinking-collapse / edit progress bar / colored bash stdout | built-in message & built-in tool rendering is **not** replaceable by extensions (`renderCall`/`renderResult` only apply to tools *you* register) | the conversation stream is left to Pi's native renderer — it is context, not a cockpit deliverable |
139
139
 
140
+ ## Claude Code-style interactions (all opt-in, all default off)
141
+
142
+ Three independent settings, each stored in `cockpit.json` and exposed bilingually in `/cockpit` and `/maestro-settings`:
143
+
144
+ | Setting | Default | Applies | What it does |
145
+ |---|---|---|---|
146
+ | `doubleEscapeClearInput` | off | after `/reload` | Press Escape twice quickly to clear a non-empty input draft. The first Escape keeps its native meaning; an empty-draft double-Escape stays pi's rewind/tree action. Requires the Cockpit custom editor (`/reload`). |
147
+ | `fullscreenInput` | off | after `/reload` | Alternate screen with the editor fixed at the bottom and an application-scrolled transcript. Wheel scrolls history while the editor stays put; `↑ n new · click to bottom` appears when new output arrives while you are scrolled up. Replaces terminal-native scrollback/search inside fullscreen. Requires the Cockpit custom editor (`/reload`). |
148
+ | `copyOnSelect` | off | live | Drag inside the fullscreen transcript to select; the visible text is copied to the clipboard on release. Effective only while `fullscreenInput` is active. |
149
+
150
+ Interaction rules:
151
+
152
+ - The three settings are independent; `copyOnSelect` is inert without `fullscreenInput`.
153
+ - The legacy `pinEditorBottom` keeps working in normal mode and is ignored inside fullscreen.
154
+ - If another extension already owns a custom editor, `doubleEscapeClearInput` and `fullscreenInput` fail closed with one warning (they share the Cockpit custom editor) and never overwrite it.
155
+ - A clipboard failure shows a warning and keeps the selection so you can retry.
156
+
157
+ ### Terminal capability matrix
158
+
159
+ Fullscreen needs an alternate screen (`?1049`) and SGR mouse reporting (`1006`/`1002`); copy-on-select additionally needs drag selection to be application-owned. No universal support is claimed — this is best effort, opt-in, and `TERM=dumb`/unset is refused with a warning.
160
+
161
+ | Terminal | Alternate screen | SGR mouse | Copy-on-select | Verified |
162
+ |---|---|---|---|---|
163
+ | iTerm2 | ✓ | ✓ | ✓ | expected |
164
+ | Ghostty | ✓ | ✓ | ✓ | expected |
165
+ | WezTerm | ✓ | ✓ | ✓ | expected |
166
+ | kitty | ✓ | ✓ | ✓ | expected |
167
+ | Windows Terminal | ✓ | ✓ | ✓ | expected |
168
+ | VS Code integrated terminal | ✓ | ✓ | ✓ | expected |
169
+ | tmux (no `-T` config) | depends | depends | depends | not claimed |
170
+ | `TERM=dumb` | ✗ | ✗ | ✗ | refused with a warning |
171
+
172
+ ### Manual smoke checklist
173
+
174
+ 1. `/maestro-settings` → Cockpit → enable `doubleEscapeClearInput`, then `/reload`; type a draft and press Escape twice — the draft clears; a single Escape does nothing; with an empty draft two Escapes still open the tree selector.
175
+ 2. Enable `fullscreenInput`, `/reload`; scroll the transcript with the mouse wheel — the editor stays fixed at the bottom; while scrolled up, new output shows the `↑ n new` hint; clicking it returns to the bottom.
176
+ 3. Enable `copyOnSelect` (live, no reload); drag across transcript lines — the visible text lands on the clipboard (verify with `pbpaste` on macOS); a plain click does not copy; a copy failure (e.g. headless) shows a warning and keeps the selection.
177
+ 4. Toggle everything off and `/reload` — behavior returns to stock pi.
178
+ 5. If a terminal ever gets stuck in a blank alternate screen (crash while fullscreen), run `reset` to restore the normal screen.
179
+
140
180
  ## Local development
141
181
 
142
182
  ```bash
package/package.json CHANGED
@@ -1,75 +1,80 @@
1
- {
2
- "name": "pi-cockpit",
3
- "version": "0.9.0",
4
- "description": "Responsive Maestro operations sidebar, fallback status widgets, and footer for Pi.",
5
- "type": "module",
6
- "keywords": [
7
- "pi-package",
8
- "pi",
9
- "pi-extension",
10
- "tui",
11
- "teammate",
12
- "todo"
13
- ],
14
- "license": "MIT",
15
- "files": [
16
- "src/",
17
- "themes/",
18
- "README.md"
19
- ],
20
- "scripts": {
21
- "test": "node --test --experimental-transform-types tests/config.test.ts tests/public-events.test.ts tests/maestro-store.test.ts tests/split-pane.test.ts tests/editor-bottom.test.ts tests/sidebar-render.test.ts tests/sidebar-controller.test.ts tests/viewport-stability.test.ts tests/quiet-tools.test.ts tests/agents-store.test.ts tests/bash-bg-store.test.ts tests/bash-bg-widget.test.ts tests/bash-bg-overlay.test.ts tests/todo-store.test.ts tests/render.test.ts tests/footer.test.ts tests/extension-status.test.ts tests/integration-contract.test.ts tests/stack-widget.test.ts tests/settings-view.test.ts tests/thinking-fold.test.ts tests/thinking-timer.test.ts tests/tick-policy.test.ts tests/ambient.test.ts tests/viewport.test.ts tests/theme-picker.test.ts tests/title-gen.test.ts tests/title-llm.test.ts tests/settings-registry.test.ts tests/settings-coordinator.test.ts tests/settings-locale.test.ts tests/settings-shell.test.ts tests/settings-cockpit-provider.test.ts tests/settings-cross-package.test.ts",
22
- "typecheck": "tsc --noEmit"
23
- },
24
- "pi": {
25
- "extensions": [
26
- "./src/extension/index.ts"
27
- ],
28
- "themes": [
29
- "./themes"
30
- ]
31
- },
32
- "dependencies": {
33
- "pi-maestro-settings-core": "0.1.0",
34
- "proper-lockfile": "4.1.2"
35
- },
36
- "peerDependencies": {
37
- "@earendil-works/pi-agent-core": "*",
38
- "@earendil-works/pi-ai": "*",
39
- "@earendil-works/pi-coding-agent": "*",
40
- "@earendil-works/pi-tui": "*",
41
- "pi-maestro-teammate": "^1.6.0"
42
- },
43
- "peerDependenciesMeta": {
44
- "@earendil-works/pi-agent-core": {
45
- "optional": true
46
- },
47
- "@earendil-works/pi-ai": {
48
- "optional": true
49
- },
50
- "@earendil-works/pi-coding-agent": {
51
- "optional": true
52
- },
53
- "@earendil-works/pi-tui": {
54
- "optional": true
55
- },
56
- "pi-maestro-teammate": {
57
- "optional": true
58
- }
59
- },
60
- "devDependencies": {
61
- "@earendil-works/pi-agent-core": "0.83.0",
62
- "@earendil-works/pi-ai": "0.83.0",
63
- "@earendil-works/pi-coding-agent": "0.83.0",
64
- "@earendil-works/pi-tui": "0.83.0",
65
- "@types/node": "^22.0.0",
66
- "pi-maestro-teammate": "1.7.0",
67
- "typescript": "^5.5.0"
68
- },
69
- "main": "./src/index.ts",
70
- "exports": {
71
- ".": "./src/index.ts",
72
- "./v1/events": "./src/public/v1/events.ts",
73
- "./src/*": "./src/*"
74
- }
75
- }
1
+ {
2
+ "name": "pi-cockpit",
3
+ "version": "0.10.0",
4
+ "description": "Responsive Maestro operations sidebar, fallback status widgets, and footer for Pi.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi",
9
+ "pi-extension",
10
+ "tui",
11
+ "teammate",
12
+ "todo"
13
+ ],
14
+ "license": "MIT",
15
+ "files": [
16
+ "src/",
17
+ "themes/",
18
+ "README.md"
19
+ ],
20
+ "scripts": {
21
+ "test": "node --test --experimental-transform-types tests/config.test.ts tests/public-events.test.ts tests/maestro-store.test.ts tests/split-pane.test.ts tests/editor-bottom.test.ts tests/sidebar-render.test.ts tests/sidebar-controller.test.ts tests/viewport-stability.test.ts tests/quiet-tools.test.ts tests/edit-guard.test.ts tests/agents-store.test.ts tests/supervision-store.test.ts tests/bash-bg-store.test.ts tests/bash-bg-widget.test.ts tests/bash-bg-overlay.test.ts tests/agent-overlay.test.ts tests/todo-store.test.ts tests/render.test.ts tests/agent-scroll.test.ts tests/session-bar.test.ts tests/session-detail.test.ts tests/footer.test.ts tests/extension-status.test.ts tests/input-routing.test.ts tests/integration-contract.test.ts tests/stack-widget.test.ts tests/settings-view.test.ts tests/thinking-fold.test.ts tests/thinking-timer.test.ts tests/tick-policy.test.ts tests/ambient.test.ts tests/viewport.test.ts tests/theme-picker.test.ts tests/model-picker.test.ts tests/title-gen.test.ts tests/title-llm.test.ts tests/settings-registry.test.ts tests/settings-coordinator.test.ts tests/settings-locale.test.ts tests/settings-shell.test.ts tests/settings-cockpit-provider.test.ts tests/settings-cross-package.test.ts tests/ui-primitives.test.ts tests/settings-shell-secret.test.ts tests/settings-shell-listcrud.test.ts tests/capturing-overlay.test.ts tests/claude-editor.test.ts tests/fullscreen-controller.test.ts tests/transcript-selection.test.ts tests/terminal-cleanup.test.ts tests/terminal-capability.test.ts",
22
+ "typecheck": "tsc --noEmit"
23
+ },
24
+ "pi": {
25
+ "extensions": [
26
+ "./src/extension/index.ts"
27
+ ],
28
+ "themes": [
29
+ "./themes"
30
+ ]
31
+ },
32
+ "dependencies": {
33
+ "pi-maestro-settings-core": "0.1.0",
34
+ "proper-lockfile": "4.1.2"
35
+ },
36
+ "peerDependencies": {
37
+ "@earendil-works/pi-agent-core": "*",
38
+ "@earendil-works/pi-ai": "*",
39
+ "@earendil-works/pi-coding-agent": "*",
40
+ "@earendil-works/pi-tui": "*",
41
+ "pi-maestro-teammate": "^1.6.0",
42
+ "typebox": "*"
43
+ },
44
+ "peerDependenciesMeta": {
45
+ "@earendil-works/pi-agent-core": {
46
+ "optional": true
47
+ },
48
+ "@earendil-works/pi-ai": {
49
+ "optional": true
50
+ },
51
+ "@earendil-works/pi-coding-agent": {
52
+ "optional": true
53
+ },
54
+ "@earendil-works/pi-tui": {
55
+ "optional": true
56
+ },
57
+ "pi-maestro-teammate": {
58
+ "optional": true
59
+ },
60
+ "typebox": {
61
+ "optional": true
62
+ }
63
+ },
64
+ "devDependencies": {
65
+ "@earendil-works/pi-agent-core": "0.83.0",
66
+ "@earendil-works/pi-ai": "0.83.0",
67
+ "@earendil-works/pi-coding-agent": "0.83.0",
68
+ "@earendil-works/pi-tui": "0.83.0",
69
+ "@types/node": "^22.0.0",
70
+ "pi-maestro-teammate": "1.8.0",
71
+ "typebox": "1.3.7",
72
+ "typescript": "^5.5.0"
73
+ },
74
+ "main": "./src/index.ts",
75
+ "exports": {
76
+ ".": "./src/index.ts",
77
+ "./v1/events": "./src/public/v1/events.ts",
78
+ "./src/*": "./src/*"
79
+ }
80
+ }
@@ -0,0 +1,337 @@
1
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ Key,
4
+ type Component,
5
+ type Focusable,
6
+ matchesKey,
7
+ truncateToWidth,
8
+ visibleWidth,
9
+ } from "@earendil-works/pi-tui";
10
+ import { effectiveAgentStatus, type AgentDisplayStatus } from "./agents-store.ts";
11
+ import { scrollBy, type AgentScrollState } from "./agent-scroll.ts";
12
+ import type { IconGlyphs } from "./icons.ts";
13
+ import { visibleStart } from "./layout.ts";
14
+ import { buildAgentTree } from "./render.ts";
15
+ import {
16
+ renderSessionDetail,
17
+ sessionDetailBodyLength,
18
+ sessionDetailWindowRows,
19
+ } from "./session-detail.ts";
20
+ import { visibleAgentRows } from "./stack-widget.ts";
21
+ import type { AgentRow } from "./types.ts";
22
+ import { overlayListRows } from "./viewport.ts";
23
+
24
+ export interface AgentOverlayParams {
25
+ getAgents: () => readonly AgentRow[];
26
+ getViewingId: () => string | undefined;
27
+ onSelect: (correlationId: string) => void;
28
+ onCommand: (correlationId: string, action: "interrupt" | "steer", message?: string) => void;
29
+ requestRender: () => void;
30
+ close: () => void;
31
+ theme: Theme;
32
+ glyphs: IconGlyphs;
33
+ getTerminalRows?: () => number | undefined;
34
+ }
35
+
36
+ const CARD_CHROME_ROWS = 5;
37
+ const COMMAND_ACK_MS = 1_500;
38
+
39
+ /** Plain letters reserved for command keys stay out of the draft. */
40
+ function isDraftInput(data: string): boolean {
41
+ return data.length === 1 && data >= " ";
42
+ }
43
+
44
+ export class AgentOverlay implements Component, Focusable {
45
+ focused = false;
46
+ private selectedId: string | undefined;
47
+ private outputScroll: AgentScrollState = { offset: 0, following: true };
48
+ private detailWidth = 80;
49
+ private detailRows = 8;
50
+ private compose: { targetId: string; draft: string } | null = null;
51
+ private lastAck: { text: string; at: number; error?: boolean } | null = null;
52
+ private ackTimer: ReturnType<typeof setTimeout> | undefined;
53
+
54
+ constructor(private readonly params: AgentOverlayParams) {
55
+ this.selectedId = params.getViewingId();
56
+ }
57
+
58
+ invalidate(): void {}
59
+ dispose(): void {
60
+ if (this.ackTimer) clearTimeout(this.ackTimer);
61
+ this.ackTimer = undefined;
62
+ }
63
+
64
+ handleInput(data: string): void {
65
+ if (this.compose) {
66
+ if (matchesKey(data, Key.escape)) {
67
+ this.compose = null;
68
+ } else if (data === "\r" || data === "\n") {
69
+ const targetId = this.compose.targetId;
70
+ const draft = this.compose.draft.trim();
71
+ this.compose = null;
72
+ // The compose target is frozen: the steer must never silently
73
+ // re-target another agent because the roster selection moved.
74
+ const target = this.entries().find((entry) => entry.row.correlationId === targetId)?.row;
75
+ if (!target) {
76
+ this.ack(`steer aborted: @${this.agentName(targetId)} is gone`, true);
77
+ } else if (draft) {
78
+ this.params.onCommand(targetId, "steer", draft);
79
+ this.ack(`steer @${target.name || target.role || target.agent || targetId}: ${draft}`);
80
+ }
81
+ } else if (data === "\x7f" || data === "\x08") {
82
+ this.compose.draft = this.compose.draft.slice(0, -1);
83
+ } else if (isDraftInput(data)) {
84
+ this.compose.draft += data;
85
+ }
86
+ this.params.requestRender();
87
+ return;
88
+ }
89
+ if (matchesKey(data, Key.escape)) {
90
+ this.params.close();
91
+ return;
92
+ }
93
+ if (matchesKey(data, Key.up)) {
94
+ this.moveSelection(-1);
95
+ return;
96
+ }
97
+ if (matchesKey(data, Key.down)) {
98
+ this.moveSelection(1);
99
+ return;
100
+ }
101
+ if (matchesKey(data, Key.home)) {
102
+ this.selectIndex(0);
103
+ return;
104
+ }
105
+ if (matchesKey(data, Key.end)) {
106
+ this.selectIndex(this.entries().length - 1);
107
+ return;
108
+ }
109
+ if (matchesKey(data, Key.pageUp)) {
110
+ this.scrollOutput(-Math.max(1, this.detailRows - 2));
111
+ return;
112
+ }
113
+ if (matchesKey(data, Key.pageDown)) {
114
+ this.scrollOutput(Math.max(1, this.detailRows - 2));
115
+ return;
116
+ }
117
+ if (data === "i" && this.selectedId) {
118
+ // Interrupt (打断): abort the current turn/tool; the agent stays alive
119
+ // and is told to report and continue.
120
+ this.params.onCommand(this.selectedId, "interrupt");
121
+ this.ack(`interrupt @${this.agentName(this.selectedId)}`);
122
+ return;
123
+ }
124
+ if (data === "s" && this.selectedId) {
125
+ // Steer (引导): interrupt + inject the drafted message. The target is
126
+ // frozen at compose time so later roster changes cannot re-target it.
127
+ this.compose = { targetId: this.selectedId, draft: "" };
128
+ this.params.requestRender();
129
+ }
130
+ }
131
+
132
+ private ack(text: string, error = false): void {
133
+ this.lastAck = { text, at: Date.now(), ...(error ? { error: true } : {}) };
134
+ if (this.ackTimer) clearTimeout(this.ackTimer);
135
+ this.ackTimer = setTimeout(() => {
136
+ this.ackTimer = undefined;
137
+ this.lastAck = null;
138
+ this.params.requestRender();
139
+ }, COMMAND_ACK_MS);
140
+ this.ackTimer.unref?.();
141
+ this.params.requestRender();
142
+ }
143
+
144
+ private agentName(correlationId: string): string {
145
+ const row = this.entries().find((entry) => entry.row.correlationId === correlationId)?.row;
146
+ return row?.name || row?.role || row?.agent || correlationId.slice(0, 8);
147
+ }
148
+
149
+ render(width: number): string[] {
150
+ const safeWidth = Math.max(1, Math.min(width, 150));
151
+ const entries = this.entries();
152
+ this.reconcileSelection(entries.map((entry) => entry.row));
153
+ const terminalHeight = this.params.getTerminalRows?.();
154
+ const heightIsCompact = terminalHeight !== undefined && Math.floor(terminalHeight * 0.9) < CARD_CHROME_ROWS + 1;
155
+ if (safeWidth < 20 || heightIsCompact) {
156
+ if (this.compose) {
157
+ const target = this.agentName(this.compose.targetId);
158
+ return [fit(`steer @${target}: ${this.compose.draft}_ · Enter · Esc`, safeWidth)];
159
+ }
160
+ const selected = entries.find((entry) => entry.row.correlationId === this.selectedId)?.row;
161
+ return [fit(selected ? `Agents ${entries.length} · ${agentLabel(selected)} · Esc` : "Agents · none · Esc", safeWidth)];
162
+ }
163
+ return safeWidth < 88
164
+ ? this.renderNarrow(safeWidth, entries.map((entry) => entry.row))
165
+ : this.renderWide(safeWidth, entries.map((entry) => entry.row));
166
+ }
167
+
168
+ private renderWide(width: number, rows: AgentRow[]): string[] {
169
+ const inner = width - 2;
170
+ const leftWidth = Math.max(30, Math.floor((inner - 3) * 0.38));
171
+ const rightWidth = inner - leftWidth - 3;
172
+ const contentRows = overlayListRows(this.params.getTerminalRows?.(), CARD_CHROME_ROWS);
173
+ const list = this.agentLines(leftWidth, contentRows);
174
+ const detail = this.detailLines(rows, rightWidth, contentRows);
175
+ const bodyRows = Math.max(list.length, detail.length, 1);
176
+ const body: string[] = [this.header(inner), this.rule(inner)];
177
+ for (let index = 0; index < bodyRows; index++) {
178
+ body.push(`${pad(list[index] ?? "", leftWidth)} ${this.params.glyphs.box.vertical} ${pad(detail[index] ?? "", rightWidth)}`);
179
+ }
180
+ body.push(this.help(inner));
181
+ return this.card(body, width);
182
+ }
183
+
184
+ private renderNarrow(width: number, rows: AgentRow[]): string[] {
185
+ const inner = width - 2;
186
+ const contentRows = overlayListRows(this.params.getTerminalRows?.(), CARD_CHROME_ROWS);
187
+ const listRows = Math.max(1, Math.min(4, Math.floor(contentRows * 0.35)));
188
+ const list = this.agentLines(inner, listRows);
189
+ const detailRows = Math.max(2, contentRows - list.length - 1);
190
+ const detail = this.detailLines(rows, inner, detailRows);
191
+ return this.card([
192
+ this.header(inner),
193
+ this.rule(inner),
194
+ ...list,
195
+ this.rule(inner),
196
+ ...detail,
197
+ this.help(inner),
198
+ ], width);
199
+ }
200
+
201
+ private entries() {
202
+ return buildAgentTree(visibleAgentRows([...this.params.getAgents()]), this.params.glyphs);
203
+ }
204
+
205
+ private agentLines(width: number, maxRows: number): string[] {
206
+ const entries = this.entries();
207
+ if (entries.length === 0) return [this.params.theme.fg("dim", fit("No agents", width))];
208
+ const selected = Math.max(0, entries.findIndex((entry) => entry.row.correlationId === this.selectedId));
209
+ const start = visibleStart(selected, entries.length, maxRows);
210
+ return entries.slice(start, start + maxRows).map(({ row, prefix }) => {
211
+ const status = effectiveAgentStatus(row);
212
+ const visual = agentVisual(status, this.params.glyphs);
213
+ const marker = row.correlationId === this.selectedId ? this.params.glyphs.selectMarker : " ";
214
+ const task = row.task ? ` · ${row.task}` : "";
215
+ return fit(
216
+ `${prefix}${marker} ${this.params.theme.fg(visual.color, visual.glyph)} ${this.params.theme.bold(agentLabel(row))}${task}`,
217
+ width,
218
+ );
219
+ });
220
+ }
221
+
222
+ private detailLines(rows: AgentRow[], width: number, maxRows: number): string[] {
223
+ this.detailWidth = width;
224
+ this.detailRows = maxRows;
225
+ if (!this.selectedId) return [this.params.theme.fg("dim", fit("No agent selected", width))];
226
+ return renderSessionDetail(
227
+ rows,
228
+ this.selectedId,
229
+ width,
230
+ this.params.theme,
231
+ maxRows,
232
+ this.outputScroll,
233
+ "PgUp/PgDn scroll output",
234
+ );
235
+ }
236
+
237
+ private header(width: number): string {
238
+ if (this.compose) {
239
+ const target = this.agentName(this.compose.targetId);
240
+ return fit(`steer @${target}: ${this.compose.draft}_`, width);
241
+ }
242
+ const rows = this.entries().map((entry) => entry.row);
243
+ const statuses = rows.map((row) => effectiveAgentStatus(row));
244
+ const active = statuses.filter((status) => status === "running" || status === "retrying").length;
245
+ const failed = statuses.filter((status) => status === "failed" || status === "stalled").length;
246
+ const ack = this.lastAck && Date.now() - this.lastAck.at < COMMAND_ACK_MS ? this.lastAck : null;
247
+ return fit([
248
+ this.params.theme.bold("Agents"),
249
+ `${rows.length} total`,
250
+ active ? `${active} active` : "",
251
+ failed ? `${failed} failed` : "",
252
+ ack
253
+ ? this.params.theme.fg(ack.error ? "error" : "success", ack.error ? `✗ ${ack.text}` : `${this.params.glyphs.check} ${ack.text}`)
254
+ : "",
255
+ ].filter(Boolean).join(" · "), width);
256
+ }
257
+
258
+ private help(width: number): string {
259
+ if (this.compose) {
260
+ return this.params.theme.fg("dim", fit("Enter send · Esc cancel", width));
261
+ }
262
+ return this.params.theme.fg("dim", fit("Esc close · ↑↓ agent · Home/End jump · PgUp/PgDn output · i interrupt · s steer", width));
263
+ }
264
+
265
+ private rule(width: number): string {
266
+ return this.params.theme.fg("borderMuted", this.params.glyphs.box.horizontal.repeat(Math.max(1, width)));
267
+ }
268
+
269
+ private card(rows: string[], width: number): string[] {
270
+ const box = this.params.glyphs.box;
271
+ const edge = box.horizontal.repeat(Math.max(0, width - 2));
272
+ const background = (value: string) => this.params.theme.bg("customMessageBg", value);
273
+ return [
274
+ background(this.params.theme.fg("borderMuted", `${box.topLeft}${edge}${box.topRight}`)),
275
+ ...rows.map((row) => background(pad(` ${row}`, width))),
276
+ background(this.params.theme.fg("borderMuted", `${box.bottomLeft}${edge}${box.bottomRight}`)),
277
+ ];
278
+ }
279
+
280
+ private moveSelection(delta: number): void {
281
+ const rows = this.entries().map((entry) => entry.row);
282
+ if (rows.length === 0) return;
283
+ const current = Math.max(0, rows.findIndex((row) => row.correlationId === this.selectedId));
284
+ this.selectRow(rows[(current + delta + rows.length) % rows.length]);
285
+ }
286
+
287
+ private selectIndex(index: number): void {
288
+ const rows = this.entries().map((entry) => entry.row);
289
+ if (rows.length === 0) return;
290
+ this.selectRow(rows[Math.max(0, Math.min(index, rows.length - 1))]);
291
+ }
292
+
293
+ private selectRow(row: AgentRow): void {
294
+ this.selectedId = row.correlationId;
295
+ this.outputScroll = { offset: 0, following: true };
296
+ this.params.onSelect(row.correlationId);
297
+ this.params.requestRender();
298
+ }
299
+
300
+ private reconcileSelection(rows: AgentRow[]): void {
301
+ if (rows.some((row) => row.correlationId === this.selectedId)) return;
302
+ this.selectedId = rows[0]?.correlationId;
303
+ this.outputScroll = { offset: 0, following: true };
304
+ if (this.selectedId) this.params.onSelect(this.selectedId);
305
+ }
306
+
307
+ private scrollOutput(delta: number): void {
308
+ if (!this.selectedId) return;
309
+ const rows = this.entries().map((entry) => entry.row);
310
+ const total = sessionDetailBodyLength(rows, this.selectedId, this.detailWidth);
311
+ const budget = Math.max(1, sessionDetailWindowRows(total, this.detailRows));
312
+ this.outputScroll = scrollBy(this.outputScroll, delta, total, budget);
313
+ this.params.requestRender();
314
+ }
315
+ }
316
+
317
+ function agentLabel(row: AgentRow): string {
318
+ return `@${row.name || row.role || row.agent || "agent"}`;
319
+ }
320
+
321
+ function agentVisual(status: AgentDisplayStatus, glyphs: IconGlyphs): { glyph: string; color: ThemeColor } {
322
+ if (status === "failed" || status === "stalled") return { glyph: glyphs.cross, color: "error" };
323
+ if (status === "terminated") return { glyph: glyphs.cross, color: "warning" };
324
+ if (status === "done" || status === "result-ready") return { glyph: glyphs.check, color: "success" };
325
+ if (status === "pending") return { glyph: glyphs.pending, color: "dim" };
326
+ if (status === "sleeping") return { glyph: glyphs.dotIdle, color: "warning" };
327
+ return { glyph: glyphs.dotRunning, color: "accent" };
328
+ }
329
+
330
+ function fit(value: string, width: number): string {
331
+ return truncateToWidth(value, Math.max(1, width), "…");
332
+ }
333
+
334
+ function pad(value: string, width: number): string {
335
+ const fitted = fit(value, width);
336
+ return `${fitted}${" ".repeat(Math.max(0, width - visibleWidth(fitted)))}`;
337
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Agent-list scroll window.
3
+ *
4
+ * The below-input agent list is bounded by the terminal height. It follows the
5
+ * newest activity by default (auto-scroll as agents stream); Shift+↑/Shift+↓
6
+ * (with an empty composer, via a cockpit terminal-input hook) scroll up/down
7
+ * through the whole roster. Scrolling up pauses follow; reaching the bottom
8
+ * resumes it. Pure state math, no UI dependencies.
9
+ */
10
+
11
+ import {
12
+ AGENT_NARROW_ROW_CAP,
13
+ AGENT_WIDE_ROW_CAP,
14
+ agentPanelRows,
15
+ } from "./viewport.ts";
16
+
17
+ export interface AgentScrollState {
18
+ /** Window start over the activity-ordered roster. */
19
+ offset: number;
20
+ /** When true the window tracks the newest rows (auto-scroll). */
21
+ following: boolean;
22
+ }
23
+
24
+ /** Window start for a state; follow pins the window to the newest rows. */
25
+ export function scrollWindowStart(total: number, budget: number, state: AgentScrollState): number {
26
+ if (total <= budget) return 0;
27
+ if (state.following) return total - budget;
28
+ return Math.max(0, Math.min(state.offset, total - budget));
29
+ }
30
+
31
+ /** Move the window by `delta` rows; reaching the bottom resumes follow. */
32
+ export function scrollBy(
33
+ state: AgentScrollState,
34
+ delta: number,
35
+ total: number,
36
+ budget: number,
37
+ ): AgentScrollState {
38
+ if (total <= budget) return { offset: 0, following: true };
39
+ const start = scrollWindowStart(total, budget, state);
40
+ const next = Math.max(0, Math.min(start + delta, total - budget));
41
+ return { offset: next, following: next >= total - budget };
42
+ }
43
+
44
+ /** Tail-following window: newest rows first. */
45
+ export function followWindow(total: number, budget: number): AgentScrollState {
46
+ return { offset: Math.max(0, total - budget), following: true };
47
+ }
48
+
49
+ /**
50
+ * How many agent rows the panel may render. Mirrors the roster widget's own
51
+ * budget: eight wide / four narrow rows plus one marker row, bounded by the
52
+ * active-agent share unless the caller supplies a shared-surface budget.
53
+ */
54
+ export function agentListWindowRows(
55
+ terminalColumns: number | undefined,
56
+ terminalRows: number | undefined,
57
+ total?: number,
58
+ panelOverride?: number,
59
+ ): number {
60
+ const widthCap = (terminalColumns ?? 80) < 40 ? AGENT_NARROW_ROW_CAP : AGENT_WIDE_ROW_CAP;
61
+ const panel = panelOverride ?? agentPanelRows(terminalRows);
62
+ const panelBudget = Math.min(widthCap + 1, panel ?? widthCap + 1);
63
+ const bodyBudget = Math.max(1, panelBudget - 1); // roster header owns one row
64
+ // One combined overflow marker is paid from the body budget, preserving at
65
+ // least one actual agent row even in the minimum three-row panel.
66
+ return total !== undefined && total > bodyBudget
67
+ ? Math.max(1, bodyBudget - 1)
68
+ : bodyBudget;
69
+ }