pi-cockpit 0.13.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.
- package/package.json +2 -2
- package/src/agent-bar.ts +66 -5
- package/src/agent-overlay.ts +260 -27
- package/src/agents-store.ts +29 -8
- package/src/session-detail.ts +4 -1
- package/src/tui-i18n.ts +8 -0
- package/src/types.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-cockpit",
|
|
3
|
-
"version": "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": [
|
|
@@ -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.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(
|
|
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,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
|
-
/**
|
|
41
|
-
function
|
|
42
|
-
return 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.
|
|
237
|
+
this.searching = false;
|
|
238
|
+
this.search = "";
|
|
69
239
|
} 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
|
-
}
|
|
240
|
+
this.searching = false;
|
|
82
241
|
} else if (data === "\x7f" || data === "\x08") {
|
|
83
|
-
this.
|
|
84
|
-
} else if (
|
|
85
|
-
this.
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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.
|
|
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)
|
|
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
|
-
|
|
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 {
|
package/src/agents-store.ts
CHANGED
|
@@ -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")
|
|
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
|
-
|
|
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)
|
|
330
|
-
|
|
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)
|
|
528
|
-
|
|
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;
|
package/src/session-detail.ts
CHANGED
|
@@ -143,7 +143,10 @@ export function renderSessionDetail(
|
|
|
143
143
|
})}`));
|
|
144
144
|
}
|
|
145
145
|
if (row.activeTool) {
|
|
146
|
-
|
|
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. */
|