pi-cockpit 0.12.0 → 0.14.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.
@@ -1,32 +1,32 @@
1
- {
2
- "name": "pi-maestro-settings-core",
3
- "version": "0.1.2",
4
- "description": "Versioned Settings and i18n contracts shared by Pi Maestro plugins.",
5
- "type": "module",
6
- "engines": {
7
- "node": ">=22.19.0"
8
- },
9
- "scripts": {
10
- "test": "node --experimental-transform-types --test \"test/*.test.ts\"",
11
- "typecheck": "tsc --noEmit -p tsconfig.json"
12
- },
13
- "main": "./src/index.ts",
14
- "exports": {
15
- ".": "./src/index.ts",
16
- "./v1": "./src/public/v1/index.ts",
17
- "./v1/events": "./src/public/v1/events.ts",
18
- "./v1/provider": "./src/public/v1/provider.ts",
19
- "./v1/schema": "./src/public/v1/schema.ts",
20
- "./v1/i18n": "./src/public/v1/i18n.ts"
21
- },
22
- "files": [
23
- "src/**/*.ts"
24
- ],
25
- "keywords": [
26
- "pi",
27
- "maestro",
28
- "settings",
29
- "i18n"
30
- ],
31
- "license": "ISC"
32
- }
1
+ {
2
+ "name": "pi-maestro-settings-core",
3
+ "version": "0.1.3",
4
+ "description": "Versioned Settings and i18n contracts shared by Pi Maestro plugins.",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22.19.0"
8
+ },
9
+ "scripts": {
10
+ "test": "node --experimental-transform-types --test \"test/*.test.ts\"",
11
+ "typecheck": "tsc --noEmit -p tsconfig.json"
12
+ },
13
+ "main": "./src/index.ts",
14
+ "exports": {
15
+ ".": "./src/index.ts",
16
+ "./v1": "./src/public/v1/index.ts",
17
+ "./v1/events": "./src/public/v1/events.ts",
18
+ "./v1/provider": "./src/public/v1/provider.ts",
19
+ "./v1/schema": "./src/public/v1/schema.ts",
20
+ "./v1/i18n": "./src/public/v1/i18n.ts"
21
+ },
22
+ "files": [
23
+ "src/**/*.ts"
24
+ ],
25
+ "keywords": [
26
+ "pi",
27
+ "maestro",
28
+ "settings",
29
+ "i18n"
30
+ ],
31
+ "license": "ISC"
32
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cockpit",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Responsive Maestro operations sidebar, fallback status widgets, and footer for Pi.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -32,7 +32,7 @@
32
32
  ]
33
33
  },
34
34
  "dependencies": {
35
- "pi-maestro-settings-core": "0.1.2",
35
+ "pi-maestro-settings-core": "0.1.3",
36
36
  "proper-lockfile": "4.1.2"
37
37
  },
38
38
  "bundledDependencies": [
@@ -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.10.0",
75
+ "pi-maestro-teammate": "1.12.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,6 +3,8 @@ 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,
@@ -36,10 +38,148 @@ export interface AgentOverlayParams {
36
38
 
37
39
  const CARD_CHROME_ROWS = 5;
38
40
  const COMMAND_ACK_MS = 1_500;
41
+ const MAX_STEER_DRAFT_CHARS = 4_096;
42
+ const BRACKETED_PASTE_START = "\x1b[200~";
43
+ const BRACKETED_PASTE_END = "\x1b[201~";
44
+ const MAX_BUFFERED_PASTE_CHARS = 1_048_576;
45
+ const graphemeSegmenter = typeof Intl.Segmenter === "function"
46
+ ? new Intl.Segmenter(undefined, { granularity: "grapheme" })
47
+ : undefined;
39
48
 
40
- /** Plain letters reserved for command keys stay out of the draft. */
41
- function isDraftInput(data: string): boolean {
42
- return data.length === 1 && data >= " ";
49
+ /** Match the repository's single-line input cleanup without importing across packages. */
50
+ function sanitizeDraftInput(data: string): string {
51
+ return data.normalize("NFC").replace(/\r\n?|\n|\t/g, " ").replace(/[\x00-\x1f\x7f-\x9f]/g, "");
52
+ }
53
+
54
+ function printableDraftInput(data: string): string {
55
+ const kittyPrintable = decodeKittyPrintable(data);
56
+ if (kittyPrintable !== undefined) return kittyPrintable;
57
+ // Escape/CSI-prefixed terminal input is a key sequence, not pasted text.
58
+ if (data.includes("\x1b") || data.includes("\x9b")) return "";
59
+ return sanitizeDraftInput(data);
60
+ }
61
+
62
+ function appendDraftInput(draft: string, data: string, paste = false): string {
63
+ const printable = paste ? sanitizeDraftInput(data) : printableDraftInput(data);
64
+ const remaining = MAX_STEER_DRAFT_CHARS - draft.length;
65
+ if (!printable || remaining <= 0) return draft;
66
+ if (printable.length <= remaining) return draft + printable;
67
+
68
+ let end = 0;
69
+ for (const segment of draftSegments(printable)) {
70
+ if (segment.end > remaining) break;
71
+ end = segment.end;
72
+ }
73
+ return draft + printable.slice(0, end);
74
+ }
75
+
76
+ interface DraftInputToken {
77
+ kind: "input" | "paste";
78
+ text: string;
79
+ }
80
+
81
+ class BracketedPasteDecoder {
82
+ private pasting = false;
83
+ private buffer = "";
84
+ private pending = "";
85
+
86
+ isPasting(): boolean {
87
+ return this.pasting;
88
+ }
89
+
90
+ hasPending(): boolean {
91
+ return this.pending.length > 0;
92
+ }
93
+
94
+ flushPending(): DraftInputToken[] {
95
+ if (!this.pending) return [];
96
+ const pending = this.pending;
97
+ this.pending = "";
98
+ if (this.pasting) {
99
+ this.appendPaste(pending);
100
+ return [];
101
+ }
102
+ return [{ kind: "input", text: pending }];
103
+ }
104
+
105
+ feed(data: string): DraftInputToken[] {
106
+ const tokens: DraftInputToken[] = [];
107
+ let rest = this.pending + data;
108
+ this.pending = "";
109
+ while (rest) {
110
+ if (!this.pasting) {
111
+ const start = rest.indexOf(BRACKETED_PASTE_START);
112
+ if (start < 0) {
113
+ const partial = partialMarkerSuffix(rest, BRACKETED_PASTE_START);
114
+ const input = rest.slice(0, rest.length - partial.length);
115
+ if (input) tokens.push({ kind: "input", text: input });
116
+ this.pending = partial;
117
+ break;
118
+ }
119
+ if (start > 0) tokens.push({ kind: "input", text: rest.slice(0, start) });
120
+ this.pasting = true;
121
+ rest = rest.slice(start + BRACKETED_PASTE_START.length);
122
+ continue;
123
+ }
124
+
125
+ const end = rest.indexOf(BRACKETED_PASTE_END);
126
+ if (end < 0) {
127
+ const partial = partialMarkerSuffix(rest, BRACKETED_PASTE_END);
128
+ this.appendPaste(rest.slice(0, rest.length - partial.length));
129
+ this.pending = partial;
130
+ break;
131
+ }
132
+ this.appendPaste(rest.slice(0, end));
133
+ tokens.push({ kind: "paste", text: this.buffer });
134
+ this.buffer = "";
135
+ this.pasting = false;
136
+ rest = rest.slice(end + BRACKETED_PASTE_END.length);
137
+ }
138
+ return tokens;
139
+ }
140
+
141
+ private appendPaste(value: string): void {
142
+ const remaining = MAX_BUFFERED_PASTE_CHARS - this.buffer.length;
143
+ if (remaining > 0) this.buffer += value.slice(0, remaining);
144
+ }
145
+ }
146
+
147
+ function partialMarkerSuffix(value: string, marker: string): string {
148
+ const limit = Math.min(value.length, marker.length - 1);
149
+ for (let length = limit; length >= 1; length--) {
150
+ const suffix = value.slice(-length);
151
+ if (marker.startsWith(suffix)) return suffix;
152
+ }
153
+ return "";
154
+ }
155
+
156
+ function removeLastDraftGrapheme(draft: string): string {
157
+ const segments = draftSegments(draft);
158
+ return segments.length === 0 ? draft : draft.slice(0, segments[segments.length - 1].start);
159
+ }
160
+
161
+ function draftSegments(value: string): Array<{ start: number; end: number }> {
162
+ if (graphemeSegmenter) {
163
+ const parts = [...graphemeSegmenter.segment(value)];
164
+ return parts.map((part, index) => ({ start: part.index, end: parts[index + 1]?.index ?? value.length }));
165
+ }
166
+
167
+ const segments: Array<{ start: number; end: number }> = [];
168
+ let start = 0;
169
+ for (const char of value) {
170
+ const end = start + char.length;
171
+ segments.push({ start, end });
172
+ start = end;
173
+ }
174
+ return segments;
175
+ }
176
+
177
+ /** Printable text for the search query: single chars, paste and CJK alike. */
178
+ function isSearchInput(data: string): boolean {
179
+ return data.length > 0 && [...data].every((ch) => {
180
+ const code = ch.codePointAt(0)!;
181
+ return code >= 0x20 && code !== 0x7f && !(code >= 0x80 && code <= 0x9f);
182
+ });
43
183
  }
44
184
 
45
185
  export class AgentOverlay implements Component, Focusable {
@@ -48,9 +188,14 @@ export class AgentOverlay implements Component, Focusable {
48
188
  private outputScroll: AgentScrollState = { offset: 0, following: true };
49
189
  private detailWidth = 80;
50
190
  private detailRows = 8;
51
- private compose: { targetId: string; draft: string } | null = null;
191
+ private compose: { targetId: string; draft: string; input: BracketedPasteDecoder } | null = null;
192
+ /** Active `/` search mode; the query string may be empty while searching. */
193
+ private searching = false;
194
+ /** Current search query; empty string means no filtering. */
195
+ private search = "";
52
196
  private lastAck: { text: string; at: number; error?: boolean } | null = null;
53
197
  private ackTimer: ReturnType<typeof setTimeout> | undefined;
198
+ private pasteFlushTimer: ReturnType<typeof setTimeout> | undefined;
54
199
 
55
200
  constructor(private readonly params: AgentOverlayParams) {
56
201
  this.selectedId = params.getViewingId();
@@ -59,36 +204,65 @@ export class AgentOverlay implements Component, Focusable {
59
204
  invalidate(): void {}
60
205
  dispose(): void {
61
206
  if (this.ackTimer) clearTimeout(this.ackTimer);
207
+ if (this.pasteFlushTimer) clearTimeout(this.pasteFlushTimer);
62
208
  this.ackTimer = undefined;
209
+ this.pasteFlushTimer = undefined;
63
210
  }
64
211
 
65
212
  handleInput(data: string): void {
66
213
  if (this.compose) {
214
+ const input = this.compose.input;
215
+ if (this.pasteFlushTimer) clearTimeout(this.pasteFlushTimer);
216
+ this.pasteFlushTimer = undefined;
217
+ for (const token of input.feed(data)) this.handleComposeToken(token);
218
+ if (this.compose?.input === input && input.hasPending() && !input.isPasting()) {
219
+ this.pasteFlushTimer = setTimeout(() => {
220
+ this.pasteFlushTimer = undefined;
221
+ if (this.compose?.input !== input) return;
222
+ for (const token of input.flushPending()) this.handleComposeToken(token);
223
+ this.params.requestRender();
224
+ }, 16);
225
+ this.pasteFlushTimer.unref?.();
226
+ }
227
+ this.params.requestRender();
228
+ return;
229
+ }
230
+ const printableInput = decodeKittyPrintable(data);
231
+ const commandInput = printableInput ?? data;
232
+ if (this.searching) {
233
+ // Search mode: printable characters extend the query, backspace trims
234
+ // it. Esc exits and clears; Enter locks the filter so the roster
235
+ // commands (↑↓ / i / s) apply to the narrowed set.
67
236
  if (matchesKey(data, Key.escape)) {
68
- this.compose = null;
237
+ this.searching = false;
238
+ this.search = "";
69
239
  } 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
- }
240
+ this.searching = false;
82
241
  } 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;
242
+ this.search = this.search.slice(0, -1);
243
+ } else if (isSearchInput(commandInput)) {
244
+ this.search += commandInput;
86
245
  }
246
+ this.reconcileSelection(this.entries().map((entry) => entry.row));
247
+ this.params.requestRender();
248
+ return;
249
+ }
250
+ if (commandInput === "/") {
251
+ this.searching = true;
252
+ this.search = "";
87
253
  this.params.requestRender();
88
254
  return;
89
255
  }
90
256
  if (matchesKey(data, Key.escape)) {
91
- this.params.close();
257
+ // A locked filter (Enter left a non-empty query) is cleared by the
258
+ // first Esc, the second one closes the overlay.
259
+ if (this.search !== "") {
260
+ this.search = "";
261
+ this.reconcileSelection(this.entries().map((entry) => entry.row));
262
+ } else {
263
+ this.params.close();
264
+ }
265
+ this.params.requestRender();
92
266
  return;
93
267
  }
94
268
  if (matchesKey(data, Key.up)) {
@@ -115,21 +289,53 @@ export class AgentOverlay implements Component, Focusable {
115
289
  this.scrollOutput(Math.max(1, this.detailRows - 2));
116
290
  return;
117
291
  }
118
- if (data === "i" && this.selectedId) {
292
+ if (commandInput === "i" && this.selectedId) {
119
293
  // Interrupt (打断): abort the current turn/tool; the agent stays alive
120
294
  // and is told to report and continue.
121
295
  this.params.onCommand(this.selectedId, "interrupt");
122
296
  this.ack(`interrupt @${this.agentName(this.selectedId)}`);
123
297
  return;
124
298
  }
125
- if (data === "s" && this.selectedId) {
299
+ if (commandInput === "s" && this.selectedId) {
126
300
  // Steer (引导): interrupt + inject the drafted message. The target is
127
301
  // frozen at compose time so later roster changes cannot re-target it.
128
- this.compose = { targetId: this.selectedId, draft: "" };
302
+ this.compose = { targetId: this.selectedId, draft: "", input: new BracketedPasteDecoder() };
129
303
  this.params.requestRender();
130
304
  }
131
305
  }
132
306
 
307
+ private handleComposeToken(token: DraftInputToken): void {
308
+ if (!this.compose) return;
309
+ if (token.kind === "paste") {
310
+ this.compose.draft = appendDraftInput(this.compose.draft, token.text, true);
311
+ return;
312
+ }
313
+ if (matchesKey(token.text, Key.escape)) {
314
+ this.compose = null;
315
+ return;
316
+ }
317
+ if (token.text === "\r" || token.text === "\n") {
318
+ const targetId = this.compose.targetId;
319
+ const draft = this.compose.draft.trim();
320
+ this.compose = null;
321
+ // The compose target is frozen: the steer must never silently
322
+ // re-target another agent because the roster selection moved.
323
+ const target = this.unfilteredEntries().find((entry) => entry.row.correlationId === targetId)?.row;
324
+ if (!target) {
325
+ this.ack(tuiT("overlay.agents.aborted", { agent: this.agentName(targetId) }), true);
326
+ } else if (draft) {
327
+ this.params.onCommand(targetId, "steer", draft);
328
+ this.ack(`steer @${target.name || target.role || target.agent || targetId}: ${draft}`);
329
+ }
330
+ return;
331
+ }
332
+ if (token.text === "\x7f" || token.text === "\x08") {
333
+ this.compose.draft = removeLastDraftGrapheme(this.compose.draft);
334
+ return;
335
+ }
336
+ this.compose.draft = appendDraftInput(this.compose.draft, token.text);
337
+ }
338
+
133
339
  private ack(text: string, error = false): void {
134
340
  this.lastAck = { text, at: Date.now(), ...(error ? { error: true } : {}) };
135
341
  if (this.ackTimer) clearTimeout(this.ackTimer);
@@ -143,7 +349,7 @@ export class AgentOverlay implements Component, Focusable {
143
349
  }
144
350
 
145
351
  private agentName(correlationId: string): string {
146
- const row = this.entries().find((entry) => entry.row.correlationId === correlationId)?.row;
352
+ const row = this.unfilteredEntries().find((entry) => entry.row.correlationId === correlationId)?.row;
147
353
  return row?.name || row?.role || row?.agent || correlationId.slice(0, 8);
148
354
  }
149
355
 
@@ -158,6 +364,9 @@ export class AgentOverlay implements Component, Focusable {
158
364
  const target = this.agentName(this.compose.targetId);
159
365
  return [fit(`steer @${target}: ${this.compose.draft}_ · Enter · Esc`, safeWidth)];
160
366
  }
367
+ if (this.searching || this.search.trim() !== "") {
368
+ return [fit(`${tuiT("overlay.agents.searchPrompt")} ${this.search}_ (${entries.length}) · Esc`, safeWidth)];
369
+ }
161
370
  const selected = entries.find((entry) => entry.row.correlationId === this.selectedId)?.row;
162
371
  const title = tuiT("overlay.agents.title");
163
372
  return [fit(
@@ -206,12 +415,28 @@ export class AgentOverlay implements Component, Focusable {
206
415
  }
207
416
 
208
417
  private entries() {
418
+ const entries = this.unfilteredEntries();
419
+ const query = this.search.trim();
420
+ if (!query) return entries;
421
+ // A search flattens the tree: the query already broke the hierarchy, so
422
+ // keeping dangling branch glyphs would only add noise to the results.
423
+ return fuzzyFilter(entries, query, (entry) =>
424
+ `${entry.row.name ?? ""} ${entry.row.role} ${entry.row.agent} ${entry.row.task}`,
425
+ ).map((entry) => ({ ...entry, prefix: "" }));
426
+ }
427
+
428
+ private unfilteredEntries() {
209
429
  return buildAgentTree(visibleAgentRows([...this.params.getAgents()]), this.params.glyphs);
210
430
  }
211
431
 
212
432
  private agentLines(width: number, maxRows: number): string[] {
213
433
  const entries = this.entries();
214
- if (entries.length === 0) return [this.params.theme.fg("dim", fit(tuiT("overlay.agents.noAgents"), width))];
434
+ if (entries.length === 0) {
435
+ return [this.params.theme.fg("dim", fit(
436
+ this.searching || this.search.trim() !== "" ? tuiT("overlay.agents.noMatches") : tuiT("overlay.agents.noAgents"),
437
+ width,
438
+ ))];
439
+ }
215
440
  const selected = Math.max(0, entries.findIndex((entry) => entry.row.correlationId === this.selectedId));
216
441
  const start = visibleStart(selected, entries.length, maxRows);
217
442
  return entries.slice(start, start + maxRows).map(({ row, prefix }) => {
@@ -246,6 +471,11 @@ export class AgentOverlay implements Component, Focusable {
246
471
  const target = this.agentName(this.compose.targetId);
247
472
  return fit(`steer @${target}: ${this.compose.draft}_`, width);
248
473
  }
474
+ if (this.searching || this.search.trim() !== "") {
475
+ const entries = this.entries();
476
+ const total = buildAgentTree(visibleAgentRows([...this.params.getAgents()]), this.params.glyphs).length;
477
+ return fit(`${this.params.theme.fg("accent", tuiT("overlay.agents.searchPrompt"))} ${this.search}_ (${entries.length}/${total})`, width);
478
+ }
249
479
  const rows = this.entries().map((entry) => entry.row);
250
480
  const statuses = rows.map((row) => effectiveAgentStatus(row));
251
481
  const active = statuses.filter((status) => status === "running" || status === "retrying").length;
@@ -266,7 +496,10 @@ export class AgentOverlay implements Component, Focusable {
266
496
  if (this.compose) {
267
497
  return this.params.theme.fg("dim", fit(tuiT("overlay.agents.composeHelp"), width));
268
498
  }
269
- return this.params.theme.fg("dim", fit(tuiT("overlay.agents.help"), width));
499
+ if (this.searching || this.search.trim() !== "") {
500
+ return this.params.theme.fg("dim", fit(tuiT("overlay.agents.searchHelp"), width));
501
+ }
502
+ return this.params.theme.fg("dim", fit(`${tuiT("overlay.agents.help")} · ${tuiT("overlay.agents.searchHint")}`, width));
270
503
  }
271
504
 
272
505
  private rule(width: number): string {
@@ -63,7 +63,7 @@ export type MessagePayload = Partial<Omit<
63
63
  "progress" | "recentTools" | "isSend" | "isInteraction"
64
64
  >> & {
65
65
  progress?: ProgressPayload[];
66
- recentTools?: Array<string | { name?: string; status?: string }>;
66
+ recentTools?: Array<string | { name?: string; status?: string; argsPreview?: string }>;
67
67
  isSend?: boolean;
68
68
  isInteraction?: boolean;
69
69
  /** Compatibility with pre-v1 progress deltas; discriminated send events are ignored. */
@@ -164,21 +164,30 @@ function terminalTime(
164
164
  return row.finishedAt ?? now;
165
165
  }
166
166
 
167
- function latestTool(tools: Array<string | { name?: string; status?: string }> | undefined): string | undefined {
167
+ function latestTool(tools: Array<string | { name?: string; status?: string; argsPreview?: string }> | undefined): { name: string; argsPreview?: string } | undefined {
168
168
  if (!tools?.length) return undefined;
169
169
  const tool = tools.find((candidate) => typeof candidate === "object" && candidate?.status === "running")
170
170
  ?? tools.at(-1);
171
171
  if (!tool) return undefined;
172
- if (typeof tool === "string") return truncateStatusText(tool);
172
+ if (typeof tool === "string") {
173
+ const name = truncateStatusText(tool);
174
+ return name ? { name } : undefined;
175
+ }
173
176
  const name = tool?.name?.trim();
174
177
  if (!name) return undefined;
175
178
  const status = tool.status?.trim();
176
179
  // The active tool is by definition the one in flight, so "bash (running)"
177
180
  // only repeats the row's own live state. Keep the suffix only for
178
181
  // anomalies (failed/error) that the row state does not already convey.
179
- return truncateStatusText(
182
+ const displayName = truncateStatusText(
180
183
  status && status !== "completed" && status !== "running" ? `${name} (${status})` : name,
181
184
  );
185
+ return {
186
+ name: displayName,
187
+ ...(typeof tool.argsPreview === "string" && tool.argsPreview.trim()
188
+ ? { argsPreview: truncateText(tool.argsPreview, 140) }
189
+ : {}),
190
+ };
182
191
  }
183
192
 
184
193
  // Self-accumulating roster. The teammate extension only broadcasts deltas
@@ -326,8 +335,14 @@ export class AgentsStore {
326
335
  if (typeof tail === "string" && tail.length > 0) row.tail = truncateSessionContent(tail);
327
336
  if (p.recentTools) {
328
337
  const tool = latestTool(p.recentTools);
329
- if (tool) row.activeTool = tool;
330
- else delete row.activeTool;
338
+ if (tool) {
339
+ row.activeTool = tool.name;
340
+ if (tool.argsPreview) row.activeToolArgs = tool.argsPreview;
341
+ else delete row.activeToolArgs;
342
+ } else {
343
+ delete row.activeTool;
344
+ delete row.activeToolArgs;
345
+ }
331
346
  }
332
347
  if (typeof p.toolCount === "number") row.toolCount = p.toolCount;
333
348
  if (typeof p.tokens === "number") row.tokens = p.tokens;
@@ -524,8 +539,14 @@ export class AgentsStore {
524
539
  row.lastActivityAt = normalizeStartedAt(p.lastActivityAt, now);
525
540
  if (p.recentTools) {
526
541
  const tool = latestTool(p.recentTools);
527
- if (tool) row.activeTool = tool;
528
- else delete row.activeTool;
542
+ if (tool) {
543
+ row.activeTool = tool.name;
544
+ if (tool.argsPreview) row.activeToolArgs = tool.argsPreview;
545
+ else delete row.activeToolArgs;
546
+ } else {
547
+ delete row.activeTool;
548
+ delete row.activeToolArgs;
549
+ }
529
550
  }
530
551
  if (typeof p.toolCount === "number") row.toolCount = p.toolCount;
531
552
  if (typeof p.tokens === "number") row.tokens = p.tokens;
@@ -143,7 +143,10 @@ export function renderSessionDetail(
143
143
  })}`));
144
144
  }
145
145
  if (row.activeTool) {
146
- body.push(truncateToWidth(theme.fg("dim", ` ${theme.fg("warning", "→")} ${row.activeTool}`), w, "…"));
146
+ const toolLine = row.activeToolArgs
147
+ ? ` ${theme.fg("warning", "→")} ${row.activeTool} ${theme.fg("dim", truncateToWidth(row.activeToolArgs, Math.max(1, w - 12), "…"))}`
148
+ : ` ${theme.fg("warning", "→")} ${row.activeTool}`;
149
+ body.push(truncateToWidth(toolLine, w, "…"));
147
150
  }
148
151
  if (row.error) {
149
152
  body.push(theme.fg("error", ` ✗ ${truncateToWidth(row.error, Math.max(1, w - 4), "…")}`));
package/src/tui-i18n.ts CHANGED
@@ -87,6 +87,10 @@ export const COCKPIT_TUI_CATALOGS: TranslationCatalogs = {
87
87
  "overlay.agents.aborted": "steer aborted: @{agent} is gone",
88
88
  "overlay.agents.help": "Esc close · ↑↓ agent · Home/End jump · PgUp/PgDn output · i interrupt · s steer",
89
89
  "overlay.agents.composeHelp": "Enter send · Esc cancel",
90
+ "overlay.agents.searchHint": "/ search",
91
+ "overlay.agents.searchPrompt": "search:",
92
+ "overlay.agents.noMatches": "No matching agents",
93
+ "overlay.agents.searchHelp": "Esc clear · ↑↓ agent",
90
94
  "overlay.jobs.title": "Background jobs",
91
95
  "overlay.jobs.empty": "{mark} no background jobs{separator}use bash_bg action=start",
92
96
  "overlay.jobs.refreshing": "refreshing…",
@@ -332,6 +336,10 @@ export const COCKPIT_TUI_CATALOGS: TranslationCatalogs = {
332
336
  "overlay.agents.aborted": "引导已取消:@{agent} 已离开",
333
337
  "overlay.agents.help": "Esc 关闭 · ↑↓ Agent · Home/End 跳转 · PgUp/PgDn 输出 · i 打断 · s 引导",
334
338
  "overlay.agents.composeHelp": "Enter 发送 · Esc 取消",
339
+ "overlay.agents.searchHint": "/ 搜索",
340
+ "overlay.agents.searchPrompt": "搜索:",
341
+ "overlay.agents.noMatches": "没有匹配的 Agent",
342
+ "overlay.agents.searchHelp": "Esc 清除 · ↑↓ Agent",
335
343
  "overlay.jobs.title": "后台任务",
336
344
  "overlay.jobs.empty": "{mark} 没有后台任务{separator}使用 bash_bg action=start",
337
345
  "overlay.jobs.refreshing": "刷新中…",
package/src/types.ts CHANGED
@@ -63,11 +63,14 @@ export interface AgentRow {
63
63
  cacheWriteTokens?: number;
64
64
  /** Latest provider/runtime diagnostic from the authoritative progress snapshot. */
65
65
  error?: string;
66
+ /** Current tool name while running, from the progress snapshot's recent tools. */
67
+ activeTool?: string;
68
+ /** Redacted one-line argument summary of the current tool call. */
69
+ activeToolArgs?: string;
66
70
  requestedModel?: string;
67
71
  resolvedModel?: string;
68
72
  attemptedModels?: string[];
69
73
  taskStatus?: string;
70
- activeTool?: string;
71
74
  taskIndex?: number;
72
75
  dependencies?: number[];
73
76
  /** Set only on a failed row that outlived its completion, so it can be pruned. */