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 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.13.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.11.0",
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(label: string, active: boolean, color: ThemeColor, theme: Theme, unread = 0): string {
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(tab.label, tab.id === selectedId, tab.color, theme, tab.unread));
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) => renderAgentChipLine(tabs, selectedId, availableWidth, theme),
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,
@@ -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
- /** Plain letters reserved for command keys stay out of the draft. */
41
- function isDraftInput(data: string): boolean {
42
- return data.length === 1 && 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.compose = null;
239
+ this.searching = false;
240
+ this.search = "";
69
241
  } else if (data === "\r" || data === "\n") {
70
- const targetId = this.compose.targetId;
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.compose.draft = this.compose.draft.slice(0, -1);
84
- } else if (isDraftInput(data)) {
85
- this.compose.draft += data;
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
- this.params.close();
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 (data === "i" && this.selectedId) {
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 (data === "s" && this.selectedId) {
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.entries().find((entry) => entry.row.correlationId === correlationId)?.row;
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) return [this.params.theme.fg("dim", fit(tuiT("overlay.agents.noAgents"), width))];
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
- return this.params.theme.fg("dim", fit(tuiT("overlay.agents.help"), width));
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 {