pi-cockpit 0.13.0 → 0.15.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 +4 -1
- package/package.json +3 -3
- package/src/agent-bar.ts +66 -5
- package/src/agent-overlay.ts +272 -29
- package/src/agents-store.ts +40 -9
- package/src/bash-bg-overlay.ts +3 -0
- package/src/config.ts +4 -1
- package/src/index.ts +167 -15
- package/src/render.ts +9 -2
- package/src/session-detail.ts +4 -1
- package/src/settings/cockpit-provider.ts +7 -0
- package/src/settings-view.ts +13 -0
- package/src/sidebar-render.ts +5 -2
- package/src/tui-i18n.ts +118 -2
- package/src/types.ts +22 -1
- package/src/viewport-stability.ts +173 -99
- package/src/zen-browse.ts +178 -0
- package/src/zen-render.ts +499 -0
- package/src/zen-sheet.ts +254 -0
- package/themes/cockpit-zen.json +24 -0
package/README.md
CHANGED
|
@@ -66,6 +66,7 @@ Cockpit has no package dependency on `pi-maestro-flow`. It observes optional pro
|
|
|
66
66
|
"agentsMode": "list",
|
|
67
67
|
"todoMode": "list",
|
|
68
68
|
"todoExpanded": false,
|
|
69
|
+
"stackStyle": "classic",
|
|
69
70
|
"hideNativeAgents": true,
|
|
70
71
|
"icons": { "mode": "auto" },
|
|
71
72
|
"sidebar": {
|
|
@@ -91,6 +92,7 @@ Cockpit has no package dependency on `pi-maestro-flow`. It observes optional pro
|
|
|
91
92
|
- `quietSymbols`: `"check"` (✓/✗/⋯) or `"dot"` (●/○/◌) lifecycle glyphs for quiet tool rows.
|
|
92
93
|
- `agentsMode` / `todoMode`: `"list"` or `"compact"`.
|
|
93
94
|
- `todoExpanded`: when `true`, expands the todo widget by default.
|
|
95
|
+
- `stackStyle`: `"classic"` keeps the separate Todo/Agent widgets; `"zen"` projects MISSION, WORK, and ACTORS into one borderless stack. The default remains `"classic"`.
|
|
94
96
|
- `hideNativeAgents`: when `true`, clears the teammate extension's own `teammate-agents` widget (it draws a similar list *below* the editor) so the two don't duplicate. On by default.
|
|
95
97
|
- `sidebar.mode`: `"auto"` or `"on"` enables the dock when at least 72 main columns plus 32 sidebar columns fit; `"off"` always uses widgets.
|
|
96
98
|
- `sidebar.width`: persisted dock width, rounded and clamped to `32..56`; default `40`.
|
|
@@ -114,11 +116,12 @@ The tab title is `frame + pi - <session> - <working state>`. The session part fo
|
|
|
114
116
|
- `/cockpit` — opens the settings overlay.
|
|
115
117
|
- `/maestro-settings` — opens the unified settings shell (Cockpit, Flow, Teammate and integrations).
|
|
116
118
|
- `Alt+J` — opens the background Bash jobs overlay (live status, command, cwd, duration, output tail).
|
|
119
|
+
- `Alt+L` — browses the visible Cockpit surface. In Zen widget mode, arrows or `j/k` select rows, first Enter expands inline details, second Enter opens the entity sheet/overlay, and Esc steps back. In the Agent overlay, `m` makes the selected agent the editor input target.
|
|
117
120
|
- `/cockpit sidebar` — reports the current sidebar mode, width, and density.
|
|
118
121
|
- `/cockpit sidebar auto|on|off` — selects dock behavior.
|
|
119
122
|
- `/cockpit sidebar resize` or `Ctrl+Shift+R` — enters temporary Resize mode. Left/Right adjusts one column, Shift+Left/Shift+Right adjusts four, Enter accepts, and Escape rolls back. Mouse reporting is active only during Resize mode.
|
|
120
123
|
- `/cockpit quiet` — toggles quiet mode; `/cockpit bg` shows background jobs.
|
|
121
|
-
- `/theme` — switch theme with live preview; `/theme <name>` applies directly. Pi ships no standalone theme command; cockpit provides one.
|
|
124
|
+
- `/theme` — switch theme with live preview; `/theme <name>` applies directly. `cockpit-zen` is the restrained warm-gold theme designed for the Zen stack; selecting it does not change `stackStyle`. Pi ships no standalone theme command; cockpit provides one.
|
|
122
125
|
|
|
123
126
|
## Sidebar compatibility
|
|
124
127
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-cockpit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Responsive Maestro operations sidebar, fallback status widgets, and footer for Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"README.md"
|
|
19
19
|
],
|
|
20
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/endpoint-store.test.ts tests/session-ui-state.test.ts tests/session-tabs.test.ts tests/agent-bar.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/tui-i18n.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",
|
|
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/endpoint-store.test.ts tests/session-ui-state.test.ts tests/session-tabs.test.ts tests/agent-bar.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/zen-render.test.ts tests/zen-browse.test.ts tests/zen-sheet.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/tui-i18n.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
22
|
"typecheck": "tsc --noEmit",
|
|
23
23
|
"prepack": "node ../../scripts/bundle-settings-core.mjs",
|
|
24
24
|
"postpack": "node ../../scripts/bundle-settings-core.mjs --clean"
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"@earendil-works/pi-coding-agent": "0.83.0",
|
|
73
73
|
"@earendil-works/pi-tui": "0.83.0",
|
|
74
74
|
"@types/node": "^22.0.0",
|
|
75
|
-
"pi-maestro-teammate": "1.
|
|
75
|
+
"pi-maestro-teammate": "1.13.0",
|
|
76
76
|
"typebox": "1.3.7",
|
|
77
77
|
"typescript": "^5.5.0"
|
|
78
78
|
},
|
package/src/agent-bar.ts
CHANGED
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { truncateToWidth, visibleWidth, type TUI } from "@earendil-works/pi-tui";
|
|
10
10
|
import type { CockpitEndpoint } from "./endpoint-store.ts";
|
|
11
|
+
import { formatAgentMetric } from "./render.ts";
|
|
11
12
|
import type { SessionUiState } from "./session-ui-state.ts";
|
|
12
13
|
import { formatUnreadCount, type SessionTab } from "./session-tabs.ts";
|
|
13
14
|
import type { AgentRow } from "./types.ts";
|
|
14
15
|
import { effectiveAgentStatus, type AgentDisplayStatus } from "./agents-store.ts";
|
|
15
16
|
import { visibleAgentRows } from "./stack-widget.ts";
|
|
17
|
+
import { tuiT } from "./tui-i18n.ts";
|
|
16
18
|
|
|
17
19
|
export const SESSION_BAR_WIDGET_KEY = "cockpit-session-bar";
|
|
18
20
|
export const AGENT_BAR_WIDGET_KEY = SESSION_BAR_WIDGET_KEY;
|
|
@@ -88,12 +90,30 @@ export function statusColor(status: AgentDisplayStatus): ThemeColor {
|
|
|
88
90
|
}
|
|
89
91
|
}
|
|
90
92
|
|
|
91
|
-
function chip(
|
|
93
|
+
function chip(
|
|
94
|
+
label: string,
|
|
95
|
+
active: boolean,
|
|
96
|
+
color: ThemeColor,
|
|
97
|
+
theme: Theme,
|
|
98
|
+
unread = 0,
|
|
99
|
+
activity?: string,
|
|
100
|
+
attention = false,
|
|
101
|
+
outcome?: { status: "completed" | "failed" | "terminated"; message?: string },
|
|
102
|
+
): string {
|
|
92
103
|
const text = `@${label}`;
|
|
93
104
|
const badge = unread > 0 ? theme.fg("warning", ` •${formatUnreadCount(unread)}`) : "";
|
|
105
|
+
const activitySuffix = activity ? theme.fg("dim", ` · ${activity}`) : "";
|
|
106
|
+
const attentionPrefix = attention ? theme.fg("error", "!") : "";
|
|
107
|
+
const outcomeSuffix = outcome
|
|
108
|
+
? outcome.status === "failed"
|
|
109
|
+
? theme.fg("error", ` ✗${outcome.message ? ` ${outcome.message}` : ""}`)
|
|
110
|
+
: outcome.status === "terminated"
|
|
111
|
+
? theme.fg("warning", " ✗")
|
|
112
|
+
: theme.fg("success", " ✓")
|
|
113
|
+
: "";
|
|
94
114
|
return active
|
|
95
|
-
? `${theme.fg(color, "▸")} ${theme.fg(color, theme.bold(text))}${badge}`
|
|
96
|
-
: `${theme.fg(color, text)}${badge}`;
|
|
115
|
+
? `${attentionPrefix}${theme.fg(color, "▸")} ${theme.fg(color, theme.bold(text))}${outcomeSuffix}${activitySuffix}${badge}`
|
|
116
|
+
: `${attentionPrefix}${theme.fg(color, text)}${outcomeSuffix}${activitySuffix}${badge}`;
|
|
97
117
|
}
|
|
98
118
|
|
|
99
119
|
export type AgentBarStatus = AgentDisplayStatus | "idle";
|
|
@@ -102,6 +122,12 @@ interface AgentBarTab extends SessionTab {
|
|
|
102
122
|
endpoint: CockpitEndpoint;
|
|
103
123
|
status: AgentBarStatus;
|
|
104
124
|
color: ThemeColor;
|
|
125
|
+
/** Current tool name while the agent is live; shown after the chip label. */
|
|
126
|
+
activity?: string;
|
|
127
|
+
/** Stalled state beyond the color channel: a leading error `!`. */
|
|
128
|
+
attention?: boolean;
|
|
129
|
+
/** Terminal outcome badge: ✓ completed, ✗ failed (with reason) or terminated. */
|
|
130
|
+
outcome?: { status: "completed" | "failed" | "terminated"; message?: string };
|
|
105
131
|
}
|
|
106
132
|
|
|
107
133
|
function endpointStatus(endpoint: CockpitEndpoint, now: number, mainRunning: boolean): AgentBarStatus {
|
|
@@ -140,7 +166,18 @@ const CHIP_SEPARATOR = " ";
|
|
|
140
166
|
function renderAgentChipLine(tabs: AgentBarTab[], selectedId: string, width: number, theme: Theme): string {
|
|
141
167
|
const w = Math.max(1, width);
|
|
142
168
|
const selectedIndex = Math.max(0, tabs.findIndex((tab) => tab.id === selectedId));
|
|
143
|
-
const chips = tabs.map((tab) => chip(
|
|
169
|
+
const chips = tabs.map((tab) => chip(
|
|
170
|
+
tab.label,
|
|
171
|
+
tab.id === selectedId,
|
|
172
|
+
tab.color,
|
|
173
|
+
theme,
|
|
174
|
+
tab.unread,
|
|
175
|
+
tab.activity,
|
|
176
|
+
tab.attention,
|
|
177
|
+
tab.outcome
|
|
178
|
+
? { status: tab.outcome.status, message: tab.outcome.message ? truncateToWidth(tab.outcome.message, 24, "…") : undefined }
|
|
179
|
+
: undefined,
|
|
180
|
+
));
|
|
144
181
|
const chipWidths = chips.map((text) => visibleWidth(text));
|
|
145
182
|
const sepWidth = visibleWidth(CHIP_SEPARATOR);
|
|
146
183
|
const totalWidth = chipWidths.reduce((sum, chipWidth) => sum + chipWidth + sepWidth, 0) - sepWidth;
|
|
@@ -223,6 +260,10 @@ export function renderAgentBar(
|
|
|
223
260
|
const tabs: AgentBarTab[] = endpoints.map((endpoint) => {
|
|
224
261
|
const selected = endpoint.id === selectedId;
|
|
225
262
|
const status = endpointStatus(endpoint, now, options.mainRunning === true);
|
|
263
|
+
const live = status === "running" || status === "retrying";
|
|
264
|
+
const outcome = status === "done" || status === "failed" || status === "terminated" || status === "sleeping"
|
|
265
|
+
? endpoint.agentRow?.lastOutcome
|
|
266
|
+
: undefined;
|
|
226
267
|
return {
|
|
227
268
|
id: endpoint.id,
|
|
228
269
|
label: endpoint.label,
|
|
@@ -231,10 +272,30 @@ export function renderAgentBar(
|
|
|
231
272
|
endpoint,
|
|
232
273
|
status,
|
|
233
274
|
color: endpointColor(endpoint, status, now, selected),
|
|
275
|
+
...(live && endpoint.agentRow?.activeTool
|
|
276
|
+
? {
|
|
277
|
+
activity: endpoint.agentRow.activeToolArgs
|
|
278
|
+
? `${endpoint.agentRow.activeTool} ${endpoint.agentRow.activeToolArgs}`
|
|
279
|
+
: endpoint.agentRow.activeTool,
|
|
280
|
+
}
|
|
281
|
+
: {}),
|
|
282
|
+
...(status === "stalled" ? { attention: true } : {}),
|
|
283
|
+
...(outcome ? { outcome } : {}),
|
|
234
284
|
};
|
|
235
285
|
});
|
|
286
|
+
// Selected-session metrics summary, appended only when the chip line leaves
|
|
287
|
+
// room for it: telemetry must never squeeze the chips that carry identity.
|
|
288
|
+
const selectedRow = tabs.find((tab) => tab.id === selectedId)?.endpoint.agentRow;
|
|
289
|
+
const metricParts: string[] = [];
|
|
290
|
+
if (selectedRow?.toolCount !== undefined) metricParts.push(tuiT("common.tools", { count: selectedRow.toolCount }));
|
|
291
|
+
if (selectedRow?.tokens !== undefined) metricParts.push(tuiT("widget.agent.tokens", { count: formatAgentMetric(selectedRow.tokens) }));
|
|
292
|
+
const metrics = metricParts.length > 0 ? theme.fg("muted", ` · ${metricParts.join(" · ")}`) : "";
|
|
236
293
|
return [renderSessionBarLine(
|
|
237
|
-
(availableWidth) =>
|
|
294
|
+
(availableWidth) => {
|
|
295
|
+
const chipsLine = renderAgentChipLine(tabs, selectedId, availableWidth, theme);
|
|
296
|
+
if (!metrics || visibleWidth(chipsLine) + visibleWidth(metrics) > availableWidth) return chipsLine;
|
|
297
|
+
return chipsLine + metrics;
|
|
298
|
+
},
|
|
238
299
|
width,
|
|
239
300
|
theme,
|
|
240
301
|
options.shortcutHint,
|
package/src/agent-overlay.ts
CHANGED
|
@@ -3,11 +3,13 @@ import {
|
|
|
3
3
|
Key,
|
|
4
4
|
type Component,
|
|
5
5
|
type Focusable,
|
|
6
|
+
decodeKittyPrintable,
|
|
7
|
+
fuzzyFilter,
|
|
6
8
|
matchesKey,
|
|
7
9
|
truncateToWidth,
|
|
8
10
|
visibleWidth,
|
|
9
11
|
} from "@earendil-works/pi-tui";
|
|
10
|
-
import { effectiveAgentStatus, type AgentDisplayStatus } from "./agents-store.ts";
|
|
12
|
+
import { effectiveAgentStatus, isExpertLeader, type AgentDisplayStatus } from "./agents-store.ts";
|
|
11
13
|
import { scrollBy, type AgentScrollState } from "./agent-scroll.ts";
|
|
12
14
|
import type { IconGlyphs } from "./icons.ts";
|
|
13
15
|
import { visibleStart } from "./layout.ts";
|
|
@@ -26,6 +28,8 @@ export interface AgentOverlayParams {
|
|
|
26
28
|
getAgents: () => readonly AgentRow[];
|
|
27
29
|
getViewingId: () => string | undefined;
|
|
28
30
|
onSelect: (correlationId: string) => void;
|
|
31
|
+
/** Set the selected agent as the editor input target without closing the overlay. */
|
|
32
|
+
onTarget?: (correlationId: string) => void;
|
|
29
33
|
onCommand: (correlationId: string, action: "interrupt" | "steer", message?: string) => void;
|
|
30
34
|
requestRender: () => void;
|
|
31
35
|
close: () => void;
|
|
@@ -36,10 +40,148 @@ export interface AgentOverlayParams {
|
|
|
36
40
|
|
|
37
41
|
const CARD_CHROME_ROWS = 5;
|
|
38
42
|
const COMMAND_ACK_MS = 1_500;
|
|
43
|
+
const MAX_STEER_DRAFT_CHARS = 4_096;
|
|
44
|
+
const BRACKETED_PASTE_START = "\x1b[200~";
|
|
45
|
+
const BRACKETED_PASTE_END = "\x1b[201~";
|
|
46
|
+
const MAX_BUFFERED_PASTE_CHARS = 1_048_576;
|
|
47
|
+
const graphemeSegmenter = typeof Intl.Segmenter === "function"
|
|
48
|
+
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
49
|
+
: undefined;
|
|
39
50
|
|
|
40
|
-
/**
|
|
41
|
-
function
|
|
42
|
-
return data.
|
|
51
|
+
/** Match the repository's single-line input cleanup without importing across packages. */
|
|
52
|
+
function sanitizeDraftInput(data: string): string {
|
|
53
|
+
return data.normalize("NFC").replace(/\r\n?|\n|\t/g, " ").replace(/[\x00-\x1f\x7f-\x9f]/g, "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function printableDraftInput(data: string): string {
|
|
57
|
+
const kittyPrintable = decodeKittyPrintable(data);
|
|
58
|
+
if (kittyPrintable !== undefined) return kittyPrintable;
|
|
59
|
+
// Escape/CSI-prefixed terminal input is a key sequence, not pasted text.
|
|
60
|
+
if (data.includes("\x1b") || data.includes("\x9b")) return "";
|
|
61
|
+
return sanitizeDraftInput(data);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function appendDraftInput(draft: string, data: string, paste = false): string {
|
|
65
|
+
const printable = paste ? sanitizeDraftInput(data) : printableDraftInput(data);
|
|
66
|
+
const remaining = MAX_STEER_DRAFT_CHARS - draft.length;
|
|
67
|
+
if (!printable || remaining <= 0) return draft;
|
|
68
|
+
if (printable.length <= remaining) return draft + printable;
|
|
69
|
+
|
|
70
|
+
let end = 0;
|
|
71
|
+
for (const segment of draftSegments(printable)) {
|
|
72
|
+
if (segment.end > remaining) break;
|
|
73
|
+
end = segment.end;
|
|
74
|
+
}
|
|
75
|
+
return draft + printable.slice(0, end);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface DraftInputToken {
|
|
79
|
+
kind: "input" | "paste";
|
|
80
|
+
text: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
class BracketedPasteDecoder {
|
|
84
|
+
private pasting = false;
|
|
85
|
+
private buffer = "";
|
|
86
|
+
private pending = "";
|
|
87
|
+
|
|
88
|
+
isPasting(): boolean {
|
|
89
|
+
return this.pasting;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
hasPending(): boolean {
|
|
93
|
+
return this.pending.length > 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
flushPending(): DraftInputToken[] {
|
|
97
|
+
if (!this.pending) return [];
|
|
98
|
+
const pending = this.pending;
|
|
99
|
+
this.pending = "";
|
|
100
|
+
if (this.pasting) {
|
|
101
|
+
this.appendPaste(pending);
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
return [{ kind: "input", text: pending }];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
feed(data: string): DraftInputToken[] {
|
|
108
|
+
const tokens: DraftInputToken[] = [];
|
|
109
|
+
let rest = this.pending + data;
|
|
110
|
+
this.pending = "";
|
|
111
|
+
while (rest) {
|
|
112
|
+
if (!this.pasting) {
|
|
113
|
+
const start = rest.indexOf(BRACKETED_PASTE_START);
|
|
114
|
+
if (start < 0) {
|
|
115
|
+
const partial = partialMarkerSuffix(rest, BRACKETED_PASTE_START);
|
|
116
|
+
const input = rest.slice(0, rest.length - partial.length);
|
|
117
|
+
if (input) tokens.push({ kind: "input", text: input });
|
|
118
|
+
this.pending = partial;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
if (start > 0) tokens.push({ kind: "input", text: rest.slice(0, start) });
|
|
122
|
+
this.pasting = true;
|
|
123
|
+
rest = rest.slice(start + BRACKETED_PASTE_START.length);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const end = rest.indexOf(BRACKETED_PASTE_END);
|
|
128
|
+
if (end < 0) {
|
|
129
|
+
const partial = partialMarkerSuffix(rest, BRACKETED_PASTE_END);
|
|
130
|
+
this.appendPaste(rest.slice(0, rest.length - partial.length));
|
|
131
|
+
this.pending = partial;
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
this.appendPaste(rest.slice(0, end));
|
|
135
|
+
tokens.push({ kind: "paste", text: this.buffer });
|
|
136
|
+
this.buffer = "";
|
|
137
|
+
this.pasting = false;
|
|
138
|
+
rest = rest.slice(end + BRACKETED_PASTE_END.length);
|
|
139
|
+
}
|
|
140
|
+
return tokens;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private appendPaste(value: string): void {
|
|
144
|
+
const remaining = MAX_BUFFERED_PASTE_CHARS - this.buffer.length;
|
|
145
|
+
if (remaining > 0) this.buffer += value.slice(0, remaining);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function partialMarkerSuffix(value: string, marker: string): string {
|
|
150
|
+
const limit = Math.min(value.length, marker.length - 1);
|
|
151
|
+
for (let length = limit; length >= 1; length--) {
|
|
152
|
+
const suffix = value.slice(-length);
|
|
153
|
+
if (marker.startsWith(suffix)) return suffix;
|
|
154
|
+
}
|
|
155
|
+
return "";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function removeLastDraftGrapheme(draft: string): string {
|
|
159
|
+
const segments = draftSegments(draft);
|
|
160
|
+
return segments.length === 0 ? draft : draft.slice(0, segments[segments.length - 1].start);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function draftSegments(value: string): Array<{ start: number; end: number }> {
|
|
164
|
+
if (graphemeSegmenter) {
|
|
165
|
+
const parts = [...graphemeSegmenter.segment(value)];
|
|
166
|
+
return parts.map((part, index) => ({ start: part.index, end: parts[index + 1]?.index ?? value.length }));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const segments: Array<{ start: number; end: number }> = [];
|
|
170
|
+
let start = 0;
|
|
171
|
+
for (const char of value) {
|
|
172
|
+
const end = start + char.length;
|
|
173
|
+
segments.push({ start, end });
|
|
174
|
+
start = end;
|
|
175
|
+
}
|
|
176
|
+
return segments;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Printable text for the search query: single chars, paste and CJK alike. */
|
|
180
|
+
function isSearchInput(data: string): boolean {
|
|
181
|
+
return data.length > 0 && [...data].every((ch) => {
|
|
182
|
+
const code = ch.codePointAt(0)!;
|
|
183
|
+
return code >= 0x20 && code !== 0x7f && !(code >= 0x80 && code <= 0x9f);
|
|
184
|
+
});
|
|
43
185
|
}
|
|
44
186
|
|
|
45
187
|
export class AgentOverlay implements Component, Focusable {
|
|
@@ -48,9 +190,14 @@ export class AgentOverlay implements Component, Focusable {
|
|
|
48
190
|
private outputScroll: AgentScrollState = { offset: 0, following: true };
|
|
49
191
|
private detailWidth = 80;
|
|
50
192
|
private detailRows = 8;
|
|
51
|
-
private compose: { targetId: string; draft: string } | null = null;
|
|
193
|
+
private compose: { targetId: string; draft: string; input: BracketedPasteDecoder } | null = null;
|
|
194
|
+
/** Active `/` search mode; the query string may be empty while searching. */
|
|
195
|
+
private searching = false;
|
|
196
|
+
/** Current search query; empty string means no filtering. */
|
|
197
|
+
private search = "";
|
|
52
198
|
private lastAck: { text: string; at: number; error?: boolean } | null = null;
|
|
53
199
|
private ackTimer: ReturnType<typeof setTimeout> | undefined;
|
|
200
|
+
private pasteFlushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
54
201
|
|
|
55
202
|
constructor(private readonly params: AgentOverlayParams) {
|
|
56
203
|
this.selectedId = params.getViewingId();
|
|
@@ -59,36 +206,65 @@ export class AgentOverlay implements Component, Focusable {
|
|
|
59
206
|
invalidate(): void {}
|
|
60
207
|
dispose(): void {
|
|
61
208
|
if (this.ackTimer) clearTimeout(this.ackTimer);
|
|
209
|
+
if (this.pasteFlushTimer) clearTimeout(this.pasteFlushTimer);
|
|
62
210
|
this.ackTimer = undefined;
|
|
211
|
+
this.pasteFlushTimer = undefined;
|
|
63
212
|
}
|
|
64
213
|
|
|
65
214
|
handleInput(data: string): void {
|
|
66
215
|
if (this.compose) {
|
|
216
|
+
const input = this.compose.input;
|
|
217
|
+
if (this.pasteFlushTimer) clearTimeout(this.pasteFlushTimer);
|
|
218
|
+
this.pasteFlushTimer = undefined;
|
|
219
|
+
for (const token of input.feed(data)) this.handleComposeToken(token);
|
|
220
|
+
if (this.compose?.input === input && input.hasPending() && !input.isPasting()) {
|
|
221
|
+
this.pasteFlushTimer = setTimeout(() => {
|
|
222
|
+
this.pasteFlushTimer = undefined;
|
|
223
|
+
if (this.compose?.input !== input) return;
|
|
224
|
+
for (const token of input.flushPending()) this.handleComposeToken(token);
|
|
225
|
+
this.params.requestRender();
|
|
226
|
+
}, 16);
|
|
227
|
+
this.pasteFlushTimer.unref?.();
|
|
228
|
+
}
|
|
229
|
+
this.params.requestRender();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const printableInput = decodeKittyPrintable(data);
|
|
233
|
+
const commandInput = printableInput ?? data;
|
|
234
|
+
if (this.searching) {
|
|
235
|
+
// Search mode: printable characters extend the query, backspace trims
|
|
236
|
+
// it. Esc exits and clears; Enter locks the filter so the roster
|
|
237
|
+
// commands (↑↓ / i / s) apply to the narrowed set.
|
|
67
238
|
if (matchesKey(data, Key.escape)) {
|
|
68
|
-
this.
|
|
239
|
+
this.searching = false;
|
|
240
|
+
this.search = "";
|
|
69
241
|
} else if (data === "\r" || data === "\n") {
|
|
70
|
-
|
|
71
|
-
const draft = this.compose.draft.trim();
|
|
72
|
-
this.compose = null;
|
|
73
|
-
// The compose target is frozen: the steer must never silently
|
|
74
|
-
// re-target another agent because the roster selection moved.
|
|
75
|
-
const target = this.entries().find((entry) => entry.row.correlationId === targetId)?.row;
|
|
76
|
-
if (!target) {
|
|
77
|
-
this.ack(tuiT("overlay.agents.aborted", { agent: this.agentName(targetId) }), true);
|
|
78
|
-
} else if (draft) {
|
|
79
|
-
this.params.onCommand(targetId, "steer", draft);
|
|
80
|
-
this.ack(`steer @${target.name || target.role || target.agent || targetId}: ${draft}`);
|
|
81
|
-
}
|
|
242
|
+
this.searching = false;
|
|
82
243
|
} else if (data === "\x7f" || data === "\x08") {
|
|
83
|
-
this.
|
|
84
|
-
} else if (
|
|
85
|
-
this.
|
|
244
|
+
this.search = this.search.slice(0, -1);
|
|
245
|
+
} else if (isSearchInput(commandInput)) {
|
|
246
|
+
this.search += commandInput;
|
|
86
247
|
}
|
|
248
|
+
this.reconcileSelection(this.entries().map((entry) => entry.row));
|
|
249
|
+
this.params.requestRender();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (commandInput === "/") {
|
|
253
|
+
this.searching = true;
|
|
254
|
+
this.search = "";
|
|
87
255
|
this.params.requestRender();
|
|
88
256
|
return;
|
|
89
257
|
}
|
|
90
258
|
if (matchesKey(data, Key.escape)) {
|
|
91
|
-
|
|
259
|
+
// A locked filter (Enter left a non-empty query) is cleared by the
|
|
260
|
+
// first Esc, the second one closes the overlay.
|
|
261
|
+
if (this.search !== "") {
|
|
262
|
+
this.search = "";
|
|
263
|
+
this.reconcileSelection(this.entries().map((entry) => entry.row));
|
|
264
|
+
} else {
|
|
265
|
+
this.params.close();
|
|
266
|
+
}
|
|
267
|
+
this.params.requestRender();
|
|
92
268
|
return;
|
|
93
269
|
}
|
|
94
270
|
if (matchesKey(data, Key.up)) {
|
|
@@ -115,21 +291,58 @@ export class AgentOverlay implements Component, Focusable {
|
|
|
115
291
|
this.scrollOutput(Math.max(1, this.detailRows - 2));
|
|
116
292
|
return;
|
|
117
293
|
}
|
|
118
|
-
if (
|
|
294
|
+
if (commandInput === "m" && this.selectedId && this.params.onTarget) {
|
|
295
|
+
this.params.onTarget(this.selectedId);
|
|
296
|
+
this.ack(tuiT("overlay.agents.targetAck", { agent: this.agentName(this.selectedId) }));
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (commandInput === "i" && this.selectedId) {
|
|
119
300
|
// Interrupt (打断): abort the current turn/tool; the agent stays alive
|
|
120
301
|
// and is told to report and continue.
|
|
121
302
|
this.params.onCommand(this.selectedId, "interrupt");
|
|
122
303
|
this.ack(`interrupt @${this.agentName(this.selectedId)}`);
|
|
123
304
|
return;
|
|
124
305
|
}
|
|
125
|
-
if (
|
|
306
|
+
if (commandInput === "s" && this.selectedId) {
|
|
126
307
|
// Steer (引导): interrupt + inject the drafted message. The target is
|
|
127
308
|
// frozen at compose time so later roster changes cannot re-target it.
|
|
128
|
-
this.compose = { targetId: this.selectedId, draft: "" };
|
|
309
|
+
this.compose = { targetId: this.selectedId, draft: "", input: new BracketedPasteDecoder() };
|
|
129
310
|
this.params.requestRender();
|
|
130
311
|
}
|
|
131
312
|
}
|
|
132
313
|
|
|
314
|
+
private handleComposeToken(token: DraftInputToken): void {
|
|
315
|
+
if (!this.compose) return;
|
|
316
|
+
if (token.kind === "paste") {
|
|
317
|
+
this.compose.draft = appendDraftInput(this.compose.draft, token.text, true);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (matchesKey(token.text, Key.escape)) {
|
|
321
|
+
this.compose = null;
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (token.text === "\r" || token.text === "\n") {
|
|
325
|
+
const targetId = this.compose.targetId;
|
|
326
|
+
const draft = this.compose.draft.trim();
|
|
327
|
+
this.compose = null;
|
|
328
|
+
// The compose target is frozen: the steer must never silently
|
|
329
|
+
// re-target another agent because the roster selection moved.
|
|
330
|
+
const target = this.unfilteredEntries().find((entry) => entry.row.correlationId === targetId)?.row;
|
|
331
|
+
if (!target) {
|
|
332
|
+
this.ack(tuiT("overlay.agents.aborted", { agent: this.agentName(targetId) }), true);
|
|
333
|
+
} else if (draft) {
|
|
334
|
+
this.params.onCommand(targetId, "steer", draft);
|
|
335
|
+
this.ack(`steer @${target.name || target.role || target.agent || targetId}: ${draft}`);
|
|
336
|
+
}
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (token.text === "\x7f" || token.text === "\x08") {
|
|
340
|
+
this.compose.draft = removeLastDraftGrapheme(this.compose.draft);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
this.compose.draft = appendDraftInput(this.compose.draft, token.text);
|
|
344
|
+
}
|
|
345
|
+
|
|
133
346
|
private ack(text: string, error = false): void {
|
|
134
347
|
this.lastAck = { text, at: Date.now(), ...(error ? { error: true } : {}) };
|
|
135
348
|
if (this.ackTimer) clearTimeout(this.ackTimer);
|
|
@@ -143,7 +356,7 @@ export class AgentOverlay implements Component, Focusable {
|
|
|
143
356
|
}
|
|
144
357
|
|
|
145
358
|
private agentName(correlationId: string): string {
|
|
146
|
-
const row = this.
|
|
359
|
+
const row = this.unfilteredEntries().find((entry) => entry.row.correlationId === correlationId)?.row;
|
|
147
360
|
return row?.name || row?.role || row?.agent || correlationId.slice(0, 8);
|
|
148
361
|
}
|
|
149
362
|
|
|
@@ -158,6 +371,9 @@ export class AgentOverlay implements Component, Focusable {
|
|
|
158
371
|
const target = this.agentName(this.compose.targetId);
|
|
159
372
|
return [fit(`steer @${target}: ${this.compose.draft}_ · Enter · Esc`, safeWidth)];
|
|
160
373
|
}
|
|
374
|
+
if (this.searching || this.search.trim() !== "") {
|
|
375
|
+
return [fit(`${tuiT("overlay.agents.searchPrompt")} ${this.search}_ (${entries.length}) · Esc`, safeWidth)];
|
|
376
|
+
}
|
|
161
377
|
const selected = entries.find((entry) => entry.row.correlationId === this.selectedId)?.row;
|
|
162
378
|
const title = tuiT("overlay.agents.title");
|
|
163
379
|
return [fit(
|
|
@@ -206,12 +422,28 @@ export class AgentOverlay implements Component, Focusable {
|
|
|
206
422
|
}
|
|
207
423
|
|
|
208
424
|
private entries() {
|
|
425
|
+
const entries = this.unfilteredEntries();
|
|
426
|
+
const query = this.search.trim();
|
|
427
|
+
if (!query) return entries;
|
|
428
|
+
// A search flattens the tree: the query already broke the hierarchy, so
|
|
429
|
+
// keeping dangling branch glyphs would only add noise to the results.
|
|
430
|
+
return fuzzyFilter(entries, query, (entry) =>
|
|
431
|
+
`${entry.row.name ?? ""} ${entry.row.role} ${entry.row.agent} ${entry.row.task}`,
|
|
432
|
+
).map((entry) => ({ ...entry, prefix: "" }));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private unfilteredEntries() {
|
|
209
436
|
return buildAgentTree(visibleAgentRows([...this.params.getAgents()]), this.params.glyphs);
|
|
210
437
|
}
|
|
211
438
|
|
|
212
439
|
private agentLines(width: number, maxRows: number): string[] {
|
|
213
440
|
const entries = this.entries();
|
|
214
|
-
if (entries.length === 0)
|
|
441
|
+
if (entries.length === 0) {
|
|
442
|
+
return [this.params.theme.fg("dim", fit(
|
|
443
|
+
this.searching || this.search.trim() !== "" ? tuiT("overlay.agents.noMatches") : tuiT("overlay.agents.noAgents"),
|
|
444
|
+
width,
|
|
445
|
+
))];
|
|
446
|
+
}
|
|
215
447
|
const selected = Math.max(0, entries.findIndex((entry) => entry.row.correlationId === this.selectedId));
|
|
216
448
|
const start = visibleStart(selected, entries.length, maxRows);
|
|
217
449
|
return entries.slice(start, start + maxRows).map(({ row, prefix }) => {
|
|
@@ -219,8 +451,11 @@ export class AgentOverlay implements Component, Focusable {
|
|
|
219
451
|
const visual = agentVisual(status, this.params.glyphs);
|
|
220
452
|
const marker = row.correlationId === this.selectedId ? this.params.glyphs.selectMarker : " ";
|
|
221
453
|
const task = row.task ? ` · ${row.task}` : "";
|
|
454
|
+
const expertTag = isExpertLeader(row)
|
|
455
|
+
? `${this.params.theme.fg("accent", tuiT("widget.agent.expert"))} `
|
|
456
|
+
: "";
|
|
222
457
|
return fit(
|
|
223
|
-
`${prefix}${marker} ${this.params.theme.fg(visual.color, visual.glyph)} ${this.params.theme.bold(agentLabel(row))}${task}`,
|
|
458
|
+
`${prefix}${marker} ${this.params.theme.fg(visual.color, visual.glyph)} ${expertTag}${this.params.theme.bold(agentLabel(row))}${task}`,
|
|
224
459
|
width,
|
|
225
460
|
);
|
|
226
461
|
});
|
|
@@ -246,6 +481,11 @@ export class AgentOverlay implements Component, Focusable {
|
|
|
246
481
|
const target = this.agentName(this.compose.targetId);
|
|
247
482
|
return fit(`steer @${target}: ${this.compose.draft}_`, width);
|
|
248
483
|
}
|
|
484
|
+
if (this.searching || this.search.trim() !== "") {
|
|
485
|
+
const entries = this.entries();
|
|
486
|
+
const total = buildAgentTree(visibleAgentRows([...this.params.getAgents()]), this.params.glyphs).length;
|
|
487
|
+
return fit(`${this.params.theme.fg("accent", tuiT("overlay.agents.searchPrompt"))} ${this.search}_ (${entries.length}/${total})`, width);
|
|
488
|
+
}
|
|
249
489
|
const rows = this.entries().map((entry) => entry.row);
|
|
250
490
|
const statuses = rows.map((row) => effectiveAgentStatus(row));
|
|
251
491
|
const active = statuses.filter((status) => status === "running" || status === "retrying").length;
|
|
@@ -266,7 +506,10 @@ export class AgentOverlay implements Component, Focusable {
|
|
|
266
506
|
if (this.compose) {
|
|
267
507
|
return this.params.theme.fg("dim", fit(tuiT("overlay.agents.composeHelp"), width));
|
|
268
508
|
}
|
|
269
|
-
|
|
509
|
+
if (this.searching || this.search.trim() !== "") {
|
|
510
|
+
return this.params.theme.fg("dim", fit(tuiT("overlay.agents.searchHelp"), width));
|
|
511
|
+
}
|
|
512
|
+
return this.params.theme.fg("dim", fit(`${tuiT("overlay.agents.help")} · ${tuiT("overlay.agents.searchHint")}`, width));
|
|
270
513
|
}
|
|
271
514
|
|
|
272
515
|
private rule(width: number): string {
|