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.
- package/README.md +43 -3
- package/package.json +80 -75
- package/src/agent-overlay.ts +337 -0
- package/src/agent-scroll.ts +69 -0
- package/src/agents-store.ts +112 -56
- package/src/ambient.ts +3 -0
- package/src/autocomplete-probe.ts +26 -0
- package/src/bash-bg-overlay.ts +412 -387
- package/src/bash-bg-store.ts +90 -90
- package/src/capturing-overlay.ts +77 -0
- package/src/claude-editor.ts +158 -0
- package/src/config.ts +6 -0
- package/src/edit-guard.ts +402 -0
- package/src/extension-status.ts +25 -25
- package/src/footer.ts +18 -9
- package/src/fullscreen-controller.ts +359 -0
- package/src/index.ts +711 -67
- package/src/input-routing.ts +63 -0
- package/src/maestro-store.ts +273 -273
- package/src/model-picker.ts +149 -0
- package/src/mouse-reporting.ts +65 -65
- package/src/public/v1/events.ts +187 -156
- package/src/quiet-tools.ts +23 -75
- package/src/render.ts +18 -10
- package/src/session-bar.ts +170 -0
- package/src/session-detail.ts +169 -0
- package/src/settings/cockpit-provider.ts +52 -19
- package/src/settings/i18n.ts +28 -4
- package/src/settings/settings-shell.ts +499 -42
- package/src/settings/ui-primitives.ts +59 -0
- package/src/settings-view.ts +36 -22
- package/src/sidebar-controller.ts +55 -6
- package/src/sidebar-render.ts +43 -17
- package/src/split-pane.ts +18 -2
- package/src/stack-widget.ts +53 -8
- package/src/supervision-store.ts +109 -0
- package/src/terminal-capability.ts +25 -0
- package/src/terminal-cleanup.ts +120 -0
- package/src/theme-picker.ts +255 -255
- package/src/thinking-fold.ts +142 -86
- package/src/thinking-timer.ts +32 -9
- package/src/tick-policy.ts +56 -56
- package/src/title-gen.ts +60 -60
- package/src/title-llm.ts +115 -115
- package/src/todo-store.ts +14 -1
- package/src/transcript-selection.ts +247 -0
- package/src/types.ts +36 -9
- package/src/viewport-stability.ts +101 -101
- 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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
"@
|
|
66
|
-
"pi-
|
|
67
|
-
"
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
"
|
|
72
|
-
"
|
|
73
|
-
|
|
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
|
+
}
|