pi-extended-teams 2.1.16

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.
Files changed (130) hide show
  1. package/README.md +105 -0
  2. package/assets/pi-extended-teams-agent-navigation.png +0 -0
  3. package/assets/pi-extended-teams-in-action.png +0 -0
  4. package/extensions/agents/read-agent-report.ts +181 -0
  5. package/extensions/agents/read-agent-session-lifecycle.test.ts +605 -0
  6. package/extensions/agents/read-agent-session-lifecycle.ts +676 -0
  7. package/extensions/agents/read-agent.test.ts +3077 -0
  8. package/extensions/agents/read-agent.ts +1119 -0
  9. package/extensions/agents/write-agent.test.ts +513 -0
  10. package/extensions/agents/write-agent.ts +392 -0
  11. package/extensions/events/register-events.test.ts +465 -0
  12. package/extensions/events/register-events.ts +409 -0
  13. package/extensions/index.test.ts +1659 -0
  14. package/extensions/index.ts +1228 -0
  15. package/extensions/internal/agent-session-files.test.ts +164 -0
  16. package/extensions/internal/agent-session-files.ts +320 -0
  17. package/extensions/internal/debug.ts +44 -0
  18. package/extensions/internal/model-selection.ts +82 -0
  19. package/extensions/internal/pi-command.test.ts +191 -0
  20. package/extensions/internal/pi-command.ts +224 -0
  21. package/extensions/internal/pi-runtime-api.test.ts +43 -0
  22. package/extensions/internal/pi-runtime-api.ts +78 -0
  23. package/extensions/internal/schema.ts +19 -0
  24. package/extensions/internal/session-context-reference.test.ts +290 -0
  25. package/extensions/internal/session-context-reference.ts +430 -0
  26. package/extensions/internal/session-files.test.ts +225 -0
  27. package/extensions/internal/session-files.ts +273 -0
  28. package/extensions/internal/session-usage.ts +59 -0
  29. package/extensions/resources/spawn-resource-plan.test.ts +233 -0
  30. package/extensions/resources/spawn-resource-plan.ts +247 -0
  31. package/extensions/runtime/active-agent-sleep.test.ts +157 -0
  32. package/extensions/runtime/active-agent-sleep.ts +117 -0
  33. package/extensions/runtime/nested-read-agents.ts +22 -0
  34. package/extensions/runtime/pending-child-controller.test.ts +169 -0
  35. package/extensions/runtime/pending-child-controller.ts +280 -0
  36. package/extensions/runtime/types.ts +68 -0
  37. package/extensions/team/contracts.test.ts +111 -0
  38. package/extensions/team/lifecycle.test.ts +1095 -0
  39. package/extensions/team/lifecycle.ts +502 -0
  40. package/extensions/team/recipient-closure.test.ts +97 -0
  41. package/extensions/team/recipient-closure.ts +120 -0
  42. package/extensions/team/roster.test.ts +129 -0
  43. package/extensions/team/roster.ts +165 -0
  44. package/extensions/team/team-contracts.json +43 -0
  45. package/extensions/team/writer-screens.test.ts +50 -0
  46. package/extensions/team/writer-screens.ts +156 -0
  47. package/extensions/tools/agent-communication-tools.test.ts +306 -0
  48. package/extensions/tools/agent-communication-tools.ts +183 -0
  49. package/extensions/tools/coordination-tools.test.ts +670 -0
  50. package/extensions/tools/coordination-tools.ts +304 -0
  51. package/extensions/tools/delegation-guard.test.ts +95 -0
  52. package/extensions/tools/delegation-guard.ts +65 -0
  53. package/extensions/tools/file-claim-tools.test.ts +104 -0
  54. package/extensions/tools/file-claim-tools.ts +68 -0
  55. package/extensions/tools/model-tools.ts +53 -0
  56. package/extensions/tools/predefined-tools.test.ts +647 -0
  57. package/extensions/tools/predefined-tools.ts +331 -0
  58. package/extensions/tools/read-helper.test.ts +35 -0
  59. package/extensions/tools/task-runtime-tools.test.ts +334 -0
  60. package/extensions/tools/task-runtime-tools.ts +227 -0
  61. package/extensions/tools/team-tools.read-agent.test.ts +1924 -0
  62. package/extensions/tools/team-tools.ts +1270 -0
  63. package/extensions/ui/agent-follow-view.test.ts +520 -0
  64. package/extensions/ui/agent-follow-view.ts +779 -0
  65. package/extensions/ui/agent-navigation.test.ts +52 -0
  66. package/extensions/ui/agent-navigation.ts +55 -0
  67. package/extensions/ui/ansi.ts +16 -0
  68. package/extensions/ui/extensions-command.test.ts +336 -0
  69. package/extensions/ui/extensions-command.ts +282 -0
  70. package/extensions/ui/favorite-models-command.test.ts +280 -0
  71. package/extensions/ui/favorite-models-command.ts +423 -0
  72. package/extensions/ui/frame.ts +77 -0
  73. package/extensions/ui/input.ts +17 -0
  74. package/extensions/ui/read-agent-status.test.ts +117 -0
  75. package/extensions/ui/read-agent-status.ts +125 -0
  76. package/extensions/ui/renderers.ts +243 -0
  77. package/extensions/ui/status-widget.test.ts +143 -0
  78. package/extensions/ui/status-widget.ts +440 -0
  79. package/extensions/ui-frame.test.ts +69 -0
  80. package/package.json +75 -0
  81. package/skills/teams.md +256 -0
  82. package/src/adapters/terminal-registry.ts +78 -0
  83. package/src/adapters/tmux-adapter.test.ts +276 -0
  84. package/src/adapters/tmux-adapter.ts +200 -0
  85. package/src/orchestration/index.ts +437 -0
  86. package/src/orchestration/orchestrator.test.ts +396 -0
  87. package/src/orchestration/types.ts +125 -0
  88. package/src/utils/atomic-json.ts +21 -0
  89. package/src/utils/claims.test.ts +137 -0
  90. package/src/utils/claims.ts +169 -0
  91. package/src/utils/hooks.test.ts +75 -0
  92. package/src/utils/hooks.ts +35 -0
  93. package/src/utils/lifecycle-tombstone.test.ts +105 -0
  94. package/src/utils/lifecycle-tombstone.ts +268 -0
  95. package/src/utils/lock.race.child.ts +44 -0
  96. package/src/utils/lock.race.test.ts +198 -0
  97. package/src/utils/lock.test.ts +90 -0
  98. package/src/utils/lock.ts +186 -0
  99. package/src/utils/messaging.test.ts +337 -0
  100. package/src/utils/messaging.ts +441 -0
  101. package/src/utils/model-resolution.test.ts +231 -0
  102. package/src/utils/model-resolution.ts +322 -0
  103. package/src/utils/models.test.ts +8 -0
  104. package/src/utils/models.ts +115 -0
  105. package/src/utils/paths.ts +81 -0
  106. package/src/utils/predefined-teams/types.ts +48 -0
  107. package/src/utils/predefined-teams.save-template.test.ts +74 -0
  108. package/src/utils/predefined-teams.test.ts +441 -0
  109. package/src/utils/predefined-teams.ts +471 -0
  110. package/src/utils/read-helper-queue.ts +99 -0
  111. package/src/utils/report-events.test.ts +154 -0
  112. package/src/utils/report-events.ts +222 -0
  113. package/src/utils/runtime.test.ts +314 -0
  114. package/src/utils/runtime.ts +261 -0
  115. package/src/utils/security.test.ts +43 -0
  116. package/src/utils/settings.test.ts +480 -0
  117. package/src/utils/settings.ts +645 -0
  118. package/src/utils/shared-memory.test.ts +80 -0
  119. package/src/utils/shared-memory.ts +81 -0
  120. package/src/utils/tasks.race.test.ts +44 -0
  121. package/src/utils/tasks.test.ts +229 -0
  122. package/src/utils/tasks.ts +396 -0
  123. package/src/utils/teams.ts +231 -0
  124. package/src/utils/terminal-adapter.ts +103 -0
  125. package/src/utils/thinking-levels.test.ts +56 -0
  126. package/src/utils/thinking-levels.ts +47 -0
  127. package/src/utils/workflow-metadata.test.ts +11 -0
  128. package/src/utils/workflow-metadata.ts +88 -0
  129. package/src/utils/write-queue.test.ts +251 -0
  130. package/src/utils/write-queue.ts +202 -0
@@ -0,0 +1,779 @@
1
+ import { Input, Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
2
+ import type { RunningReadAgent } from "../runtime/types";
3
+ import { initialContextUsage } from "../../src/utils/runtime";
4
+ import { dimAnsi, pink, purple } from "./ansi";
5
+ import { createFramePanelRowRenderer, framePanel } from "./frame";
6
+ import { extractTextParts, formatAnimatedProgress, formatContextUsage, formatElapsed, formatModelLabel, sanitizePlainTuiLine, sanitizeTuiLine, sanitizeTuiText } from "./renderers";
7
+
8
+ const REFRESH_INTERVAL_MS = 250;
9
+ const MAX_NAVIGATION_AGENTS = 6;
10
+ const AGENT_FOLLOW_BACKGROUND = "\x1b[48;2;22;23;32m";
11
+ const ACTION_FOREGROUND = "\x1b[38;5;117m";
12
+ const PATH_FOREGROUND = "\x1b[38;5;213m";
13
+ const SUCCESS_FOREGROUND = "\x1b[38;5;114m";
14
+ const FAILURE_FOREGROUND = "\x1b[38;5;210m";
15
+ const PENDING_FOREGROUND = "\x1b[38;5;222m";
16
+ const BODY_FOREGROUND = "\x1b[38;5;253m";
17
+ const MUTED_FOREGROUND = "\x1b[38;5;247m";
18
+ const STRUCTURAL_FOREGROUND = "\x1b[38;5;141m";
19
+ const ANSI_FOREGROUND_RESET = "\x1b[39m";
20
+ const ANSI_RESET = "\x1b[0m";
21
+ const COLLAPSED_TOOL_RESULT_LINE_LIMIT = 14;
22
+ const COLLAPSED_TOOL_RESULT_HEAD_LINES = 8;
23
+ const COLLAPSED_TOOL_RESULT_TAIL_LINES = 3;
24
+
25
+ export interface AgentFollowViewOptions {
26
+ getAgents(): RunningReadAgent[];
27
+ initialAgentName?: string;
28
+ stopAgent?(name: string): void | Promise<void>;
29
+ sendMessage?(name: string, content: string): void | Promise<void>;
30
+ }
31
+
32
+ export interface AgentFollowTranscriptOptions {
33
+ expandLargeToolResults?: boolean;
34
+ width?: number;
35
+ }
36
+
37
+ type TranscriptBlock =
38
+ | { kind: "section"; label: "user" | "thinking" | "assistant"; text: string }
39
+ | { kind: "tool"; id?: string; name: string; args: unknown; result?: string; details?: unknown; isError?: boolean };
40
+
41
+ function stringifyToolArgs(args: unknown): string {
42
+ if (args === undefined) return "";
43
+ try {
44
+ return JSON.stringify(args, null, 2);
45
+ } catch {
46
+ return String(args);
47
+ }
48
+ }
49
+
50
+ function compactToolArgs(name: string, args: unknown): string {
51
+ if (!args || typeof args !== "object") return sanitizeTuiLine(stringifyToolArgs(args));
52
+ const values = args as Record<string, unknown>;
53
+ const primary = name === "bash"
54
+ ? values.command
55
+ : name === "read"
56
+ ? values.path
57
+ : name === "agentic_search"
58
+ ? values.query
59
+ : undefined;
60
+ const raw = primary === undefined ? JSON.stringify(values) : String(primary);
61
+ const compact = sanitizeTuiLine(raw).replace(/\s+/g, " ").trim();
62
+ return compact.length > 240 ? `${compact.slice(0, 239)}…` : compact;
63
+ }
64
+
65
+ function formatResultSize(text: string): string {
66
+ const size = Buffer.byteLength(text, "utf8");
67
+ if (size < 1_024) return `${size} B`;
68
+ return `${(size / 1_024).toFixed(size < 10_240 ? 1 : 0)} KB`;
69
+ }
70
+
71
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
72
+ return value && typeof value === "object" && !Array.isArray(value)
73
+ ? value as Record<string, unknown>
74
+ : undefined;
75
+ }
76
+
77
+ function compactTranscriptLine(text: string): string {
78
+ return sanitizePlainTuiLine(text).replace(/\s+/g, " ").trim();
79
+ }
80
+
81
+ function semanticAnsi(text: string, foreground: string): string {
82
+ return `${foreground}${text}${ANSI_FOREGROUND_RESET}`;
83
+ }
84
+
85
+ function actionAnsi(text: string): string {
86
+ return semanticAnsi(text, ACTION_FOREGROUND);
87
+ }
88
+
89
+ function pathAnsi(text: string): string {
90
+ return semanticAnsi(text, PATH_FOREGROUND);
91
+ }
92
+
93
+ function successAnsi(text: string): string {
94
+ return semanticAnsi(text, SUCCESS_FOREGROUND);
95
+ }
96
+
97
+ function failureAnsi(text: string): string {
98
+ return semanticAnsi(text, FAILURE_FOREGROUND);
99
+ }
100
+
101
+ function pendingAnsi(text: string): string {
102
+ return semanticAnsi(text, PENDING_FOREGROUND);
103
+ }
104
+
105
+ function bodyAnsi(text: string): string {
106
+ return semanticAnsi(text, BODY_FOREGROUND);
107
+ }
108
+
109
+ function mutedAnsi(text: string): string {
110
+ return semanticAnsi(text, MUTED_FOREGROUND);
111
+ }
112
+
113
+ function structuralAnsi(text: string): string {
114
+ return semanticAnsi(text, STRUCTURAL_FOREGROUND);
115
+ }
116
+
117
+ function boundTranscriptLine(line: string, width?: number): string {
118
+ return width === undefined ? line : truncateToWidth(line, Math.max(1, width), "…");
119
+ }
120
+
121
+ function toolPath(args: unknown): string {
122
+ const path = asRecord(args)?.path;
123
+ return compactTranscriptLine(typeof path === "string" ? path : "(unknown file)");
124
+ }
125
+
126
+ function toolPaths(args: unknown): string {
127
+ const paths = asRecord(args)?.paths;
128
+ if (!Array.isArray(paths)) return toolPath(args);
129
+ const compactPaths = paths
130
+ .filter((path): path is string => typeof path === "string")
131
+ .map(compactTranscriptLine)
132
+ .filter(Boolean);
133
+ return compactPaths.length > 0 ? compactPaths.join(", ") : "(unknown file)";
134
+ }
135
+
136
+ function editDiffCounts(details: unknown): { added: number; removed: number } | undefined {
137
+ const diff = asRecord(details)?.diff;
138
+ if (typeof diff !== "string") return undefined;
139
+
140
+ let added = 0;
141
+ let removed = 0;
142
+ for (const line of diff.split("\n")) {
143
+ if (/^\+\s*\d+(?:\s|$)/.test(line)) added += 1;
144
+ else if (/^-\s*\d+(?:\s|$)/.test(line)) removed += 1;
145
+ }
146
+ return { added, removed };
147
+ }
148
+
149
+ function renderState(state: string): string {
150
+ if (state === "failed") return failureAnsi(state);
151
+ if (state === "working" || state === "submitting") return pendingAnsi(state);
152
+ if (state === "duplicate") return mutedAnsi(state);
153
+ return successAnsi(state);
154
+ }
155
+
156
+ function renderActionPathState(action: string, path: string, state: string, width?: number): string {
157
+ const renderedState = state === "failed"
158
+ ? failureAnsi("✗")
159
+ : state === "succeeded"
160
+ ? successAnsi("✓")
161
+ : renderState(state);
162
+ return boundTranscriptLine(
163
+ `${actionAnsi(action)}${mutedAnsi(" · ")}${pathAnsi(path)}${mutedAnsi(" · ")}${renderedState}`,
164
+ width,
165
+ );
166
+ }
167
+
168
+ function renderCompactToolBlock(block: Extract<TranscriptBlock, { kind: "tool" }>, width?: number): string[] | undefined {
169
+ if (block.name === "report_progress") {
170
+ const details = asRecord(block.details);
171
+ const args = asRecord(block.args);
172
+ const resultStatus = block.result?.replace(/^Progress (?:updated|update skipped):\s*/i, "");
173
+ const rawStatus = details?.status ?? args?.status ?? resultStatus ?? "updating";
174
+ const status = compactTranscriptLine(String(rawStatus)) || "updating";
175
+ const failureSuffix = block.isError ? " (failed)" : "";
176
+ return [boundTranscriptLine(`${status}${failureSuffix}`, width), ""];
177
+ }
178
+
179
+ if (block.name === "edit") {
180
+ const path = toolPath(block.args);
181
+ if (block.result === undefined) return [renderActionPathState("edit", path, "working", width)];
182
+ if (block.isError) return [renderActionPathState("edit", path, "failed", width)];
183
+ const counts = editDiffCounts(block.details);
184
+ const added = counts ? `+${counts.added}` : "+?";
185
+ const removed = counts ? `−${counts.removed}` : "−?";
186
+ return [boundTranscriptLine(
187
+ `${actionAnsi("edit")}${mutedAnsi(" · ")}${pathAnsi(path)}${mutedAnsi(" · ")}${successAnsi(added)} ${failureAnsi(removed)}${mutedAnsi(" · ")}${successAnsi("✓")}`,
188
+ width,
189
+ )];
190
+ }
191
+
192
+ if (block.name === "write") {
193
+ const state = block.result === undefined ? "working" : block.isError ? "failed" : "succeeded";
194
+ return [renderActionPathState("write", toolPath(block.args), state, width)];
195
+ }
196
+
197
+ if (block.name === "claim_file" || block.name === "release_file") {
198
+ const details = asRecord(block.details);
199
+ const conflicts = Array.isArray(details?.conflicts) ? details.conflicts : [];
200
+ const state = block.result === undefined
201
+ ? "working"
202
+ : block.isError || conflicts.length > 0
203
+ ? "failed"
204
+ : "succeeded";
205
+ const action = block.name === "claim_file" ? "claim" : "release";
206
+ return [renderActionPathState(action, toolPaths(block.args), state, width)];
207
+ }
208
+
209
+ if (block.name === "report_and_exit") {
210
+ const accepted = asRecord(block.details)?.accepted;
211
+ const state = block.result === undefined
212
+ ? "submitting"
213
+ : block.isError
214
+ ? "failed"
215
+ : accepted === false
216
+ ? "duplicate"
217
+ : "accepted";
218
+ return [boundTranscriptLine(`${actionAnsi("final report")}${mutedAnsi(" · ")}${renderState(state)}`, width)];
219
+ }
220
+
221
+ return undefined;
222
+ }
223
+
224
+ function renderToolHeader(block: Extract<TranscriptBlock, { kind: "tool" }>): string {
225
+ const detail = compactToolArgs(block.name, block.args);
226
+ if (!detail) return actionAnsi(block.name);
227
+ const isPath = typeof asRecord(block.args)?.path === "string";
228
+ const renderedDetail = isPath ? pathAnsi(detail) : bodyAnsi(`${block.name === "bash" ? "$ " : ""}${detail}`);
229
+ return `${actionAnsi(block.name)}${mutedAnsi(" · ")}${renderedDetail}`;
230
+ }
231
+
232
+ function renderToolBlock(block: Extract<TranscriptBlock, { kind: "tool" }>, expandLargeToolResults: boolean, width?: number): string[] {
233
+ const compactBlock = renderCompactToolBlock(block, width);
234
+ if (compactBlock) return compactBlock;
235
+
236
+ const header = renderToolHeader(block);
237
+ if (block.result === undefined) {
238
+ return [header, `${structuralAnsi("│")} ${pendingAnsi("waiting for result…")}`, `${structuralAnsi("╰─")} ${pendingAnsi("running")}`, ""];
239
+ }
240
+
241
+ const result = block.result || "(no output)";
242
+ const resultLines = result.split("\n");
243
+ const isCollapsed = !expandLargeToolResults && resultLines.length > COLLAPSED_TOOL_RESULT_LINE_LIMIT;
244
+ const visibleLines = isCollapsed
245
+ ? [
246
+ ...resultLines.slice(0, COLLAPSED_TOOL_RESULT_HEAD_LINES),
247
+ `… ${resultLines.length - COLLAPSED_TOOL_RESULT_HEAD_LINES - COLLAPSED_TOOL_RESULT_TAIL_LINES} lines hidden · press l to expand logs`,
248
+ ...resultLines.slice(-COLLAPSED_TOOL_RESULT_TAIL_LINES),
249
+ ]
250
+ : resultLines;
251
+ const resultLineWidth = width === undefined ? undefined : Math.max(1, width - visibleWidth("│ "));
252
+ const boundedLines = visibleLines.map((line) => resultLineWidth === undefined
253
+ ? line
254
+ : truncateToWidth(line, resultLineWidth, "…"));
255
+ const body = boundedLines.map((line, index) => isCollapsed && index === COLLAPSED_TOOL_RESULT_HEAD_LINES
256
+ ? `${structuralAnsi("│")} ${mutedAnsi(line)}`
257
+ : `${structuralAnsi("│")} ${line}`);
258
+ const summary = `${resultLines.length} line${resultLines.length === 1 ? "" : "s"} · ${formatResultSize(result)}${isCollapsed ? " · collapsed" : ""}`;
259
+ const renderedSummary = block.isError ? failureAnsi(summary) : successAnsi(summary);
260
+ return [header, ...body, `${structuralAnsi("╰─")} ${renderedSummary}`, ""];
261
+ }
262
+
263
+ export function formatAgentFollowTranscript(messages: any[], options: AgentFollowTranscriptOptions = {}): string[] {
264
+ const blocks: TranscriptBlock[] = [];
265
+ const toolsById = new Map<string, Extract<TranscriptBlock, { kind: "tool" }>>();
266
+
267
+ for (const message of messages || []) {
268
+ if (message?.role === "user") {
269
+ const text = sanitizeTuiText(extractTextParts(message.content));
270
+ if (text) blocks.push({ kind: "section", label: "user", text });
271
+ continue;
272
+ }
273
+
274
+ if (message?.role === "assistant") {
275
+ for (const part of Array.isArray(message.content) ? message.content : []) {
276
+ if (part?.type === "thinking" && typeof part.thinking === "string" && part.thinking.trim()) {
277
+ blocks.push({ kind: "section", label: "thinking", text: sanitizeTuiText(part.thinking.trim()) });
278
+ } else if (part?.type === "text" && typeof part.text === "string" && part.text.trim()) {
279
+ blocks.push({ kind: "section", label: "assistant", text: sanitizeTuiText(part.text.trim()) });
280
+ } else if (part?.type === "toolCall") {
281
+ const id = typeof part.id === "string" ? part.id : typeof part.toolCallId === "string" ? part.toolCallId : undefined;
282
+ const tool: Extract<TranscriptBlock, { kind: "tool" }> = {
283
+ kind: "tool",
284
+ id,
285
+ name: sanitizeTuiLine(String(part.name || "unknown")),
286
+ args: part.arguments ?? part.args,
287
+ };
288
+ blocks.push(tool);
289
+ if (id) toolsById.set(id, tool);
290
+ }
291
+ }
292
+ continue;
293
+ }
294
+
295
+ if (message?.role === "toolResult") {
296
+ const id = typeof message.toolCallId === "string" ? message.toolCallId : undefined;
297
+ const name = sanitizeTuiLine(String(message.toolName || "tool"));
298
+ const matchingTool = (id ? toolsById.get(id) : undefined)
299
+ ?? blocks.slice().reverse().find((block): block is Extract<TranscriptBlock, { kind: "tool" }> => block.kind === "tool" && block.result === undefined && block.name === name);
300
+ const result = sanitizeTuiText(extractTextParts(message.content));
301
+ const isError = typeof message.isError === "boolean" ? message.isError : undefined;
302
+ if (matchingTool) {
303
+ matchingTool.result = result;
304
+ matchingTool.details = message.details;
305
+ matchingTool.isError = isError;
306
+ } else {
307
+ blocks.push({ kind: "tool", id, name, args: undefined, result, details: message.details, isError });
308
+ }
309
+ }
310
+ }
311
+
312
+ const lines = blocks.flatMap(block => {
313
+ if (block.kind === "tool") {
314
+ return renderToolBlock(block, options.expandLargeToolResults === true, options.width);
315
+ }
316
+ if (block.label === "thinking") {
317
+ return [purple(block.label), block.text.replace(/\*\*/g, ""), ""];
318
+ }
319
+ return [block.label === "user" ? pink(block.label) : purple(block.label), block.text, ""];
320
+ });
321
+ return lines.length > 0 ? lines : [dimAnsi("Waiting for the agent's first transcript event…")];
322
+ }
323
+
324
+ function currentAgent(agents: RunningReadAgent[], selectedName: string | undefined): RunningReadAgent | undefined {
325
+ return agents.find(agent => agent.name === selectedName) ?? agents[0];
326
+ }
327
+
328
+ export function createAgentFollowComponent(
329
+ tui: any,
330
+ done: () => void,
331
+ options: AgentFollowViewOptions
332
+ ) {
333
+ let selectedName = options.initialAgentName;
334
+ let offsetFromBottom = 0;
335
+ let lastBodyHeight = 10;
336
+ let lastTranscriptRows = 0;
337
+ let expandLargeToolResults = false;
338
+ let composingMessage = false;
339
+ let sendingMessage = false;
340
+ let messageStatus = "";
341
+ let focused = false;
342
+ const messageInput = new Input();
343
+ const stoppingAgents = new Set<string>();
344
+ let transcriptAgent: RunningReadAgent | undefined;
345
+ let transcriptMessages: unknown[] | undefined;
346
+ let transcriptMessageCount = -1;
347
+ let transcriptLastMessage: unknown;
348
+ let transcriptWidth = -1;
349
+ let transcriptExpanded = false;
350
+ let cachedTranscriptLines: string[] = [];
351
+ let cachedFrameWidth = -1;
352
+ let cachedFrameRowRenderer: ((line: string) => string) | undefined;
353
+ let cachedFrameContent: string[] | null = null;
354
+ let cachedFrameLines: string[] | null = null;
355
+ let fastFrameKey = "";
356
+ let fastFrameMessages: unknown[] | undefined;
357
+ let fastFrameMessageCount = -1;
358
+ let fastFrameLastMessage: unknown;
359
+ let refreshStateKey = "";
360
+ let refreshMessages: unknown[] | undefined;
361
+ let refreshMessageCount = -1;
362
+ let refreshLastMessage: unknown;
363
+ const refreshTimer = setInterval(() => {
364
+ const agents = options.getAgents().slice().sort((a, b) => a.name.localeCompare(b.name));
365
+ const agent = currentAgent(agents, selectedName);
366
+ if (!agent) {
367
+ const emptyKey = agents.map((item) => item.name).join("\0");
368
+ if (emptyKey !== refreshStateKey) {
369
+ refreshStateKey = emptyKey;
370
+ tui.requestRender();
371
+ }
372
+ return;
373
+ }
374
+
375
+ try {
376
+ const session = agent.session;
377
+ const stats = session?.getSessionStats();
378
+ if (stats) {
379
+ agent.tokensUsed = stats.tokens.total;
380
+ const contextUsage = stats.contextUsage ?? session?.getContextUsage?.();
381
+ agent.contextUsage = stats.tokens.total > 0
382
+ ? contextUsage
383
+ : initialContextUsage(contextUsage?.contextWindow);
384
+ }
385
+ } catch {
386
+ // The nested session may be shutting down while this view refreshes.
387
+ }
388
+ const messages = agent.session?.messages || [];
389
+ const lastMessage = messages[messages.length - 1];
390
+ const stateKey = [
391
+ Math.floor(Date.now() / 1000),
392
+ agents.map((item) => item.name).join(","),
393
+ agent.name,
394
+ agent.model,
395
+ agent.thinking,
396
+ agent.modelSlot,
397
+ agent.startedAt,
398
+ agent.tokensUsed,
399
+ formatContextUsage(agent.contextUsage),
400
+ agent.status,
401
+ agent.latestProgress,
402
+ stoppingAgents.has(agent.name),
403
+ expandLargeToolResults,
404
+ offsetFromBottom,
405
+ composingMessage,
406
+ messageStatus,
407
+ tui.terminal?.rows ?? 24,
408
+ ].join("\0");
409
+ if (stateKey === refreshStateKey
410
+ && refreshMessages === messages
411
+ && refreshMessageCount === messages.length
412
+ && refreshLastMessage === lastMessage) {
413
+ return;
414
+ }
415
+ refreshStateKey = stateKey;
416
+ refreshMessages = messages;
417
+ refreshMessageCount = messages.length;
418
+ refreshLastMessage = lastMessage;
419
+ tui.requestRender();
420
+ }, REFRESH_INTERVAL_MS);
421
+
422
+ const syncInputFocus = () => {
423
+ messageInput.focused = focused && composingMessage;
424
+ };
425
+
426
+ const stopComposingMessage = () => {
427
+ composingMessage = false;
428
+ messageInput.setValue("");
429
+ syncInputFocus();
430
+ tui.requestRender();
431
+ };
432
+
433
+ messageInput.onEscape = stopComposingMessage;
434
+ messageInput.onSubmit = (value: string) => {
435
+ const content = value.trim();
436
+ const agent = currentAgent(sortedAgents(), selectedName);
437
+ if (!content || !agent || !options.sendMessage || sendingMessage) {
438
+ if (!content) messageStatus = "Write a message before sending.";
439
+ tui.requestRender();
440
+ return;
441
+ }
442
+
443
+ const recipient = agent.name;
444
+ sendingMessage = true;
445
+ messageStatus = `Sending to ${recipient}…`;
446
+ tui.requestRender();
447
+ void Promise.resolve()
448
+ .then(() => options.sendMessage?.(recipient, content))
449
+ .then(() => {
450
+ messageStatus = `Message sent to ${recipient}.`;
451
+ stopComposingMessage();
452
+ })
453
+ .catch((error: unknown) => {
454
+ messageStatus = error instanceof Error ? error.message : `Could not message ${recipient}.`;
455
+ tui.requestRender();
456
+ })
457
+ .finally(() => {
458
+ sendingMessage = false;
459
+ tui.requestRender();
460
+ });
461
+ };
462
+
463
+ const sortedAgents = () => options.getAgents().slice().sort((a, b) => a.name.localeCompare(b.name));
464
+
465
+ const selectRelative = (delta: number) => {
466
+ const agents = sortedAgents();
467
+ if (agents.length === 0) return;
468
+ const selected = currentAgent(agents, selectedName);
469
+ const currentIndex = Math.max(0, agents.findIndex(agent => agent.name === selected?.name));
470
+ selectedName = agents[(currentIndex + delta + agents.length) % agents.length]?.name;
471
+ offsetFromBottom = 0;
472
+ };
473
+
474
+ const selectPreviousOrMain = () => {
475
+ const agents = sortedAgents();
476
+ const selected = currentAgent(agents, selectedName);
477
+ const currentIndex = agents.findIndex(agent => agent.name === selected?.name);
478
+ if (currentIndex <= 0) {
479
+ done();
480
+ return;
481
+ }
482
+ selectedName = agents[currentIndex - 1]?.name;
483
+ offsetFromBottom = 0;
484
+ tui.requestRender();
485
+ };
486
+
487
+ return {
488
+ get focused() {
489
+ return focused;
490
+ },
491
+ set focused(value: boolean) {
492
+ focused = value;
493
+ syncInputFocus();
494
+ },
495
+ render(width: number): string[] {
496
+ const agents = sortedAgents();
497
+ const agent = currentAgent(agents, selectedName);
498
+ const innerWidth = Math.max(40, width - 4);
499
+ const terminalRows = Math.max(12, tui.terminal?.rows ?? 24);
500
+
501
+ if (!agent) {
502
+ const emptyBodyHeight = Math.max(4, terminalRows - 6);
503
+ return framePanel([
504
+ pink("agent navigation"),
505
+ purple("↑ main agent"),
506
+ dimAnsi("No active agents. Press ↑ or esc to return to main."),
507
+ ...Array.from({ length: emptyBodyHeight }, () => ""),
508
+ ], innerWidth, AGENT_FOLLOW_BACKGROUND);
509
+ }
510
+
511
+ const selectedIndex = Math.max(0, agents.findIndex(item => item.name === agent.name));
512
+ const navigationStart = Math.max(0, Math.min(selectedIndex - 2, agents.length - MAX_NAVIGATION_AGENTS));
513
+ const visibleAgents = agents.slice(navigationStart, navigationStart + MAX_NAVIGATION_AGENTS);
514
+ const navigationLines = [pink("agent navigation"), purple("↑ main agent")];
515
+ if (navigationStart > 0) navigationLines.push(dimAnsi(` … ${navigationStart} agent${navigationStart === 1 ? "" : "s"} above`));
516
+ for (const item of visibleAgents) {
517
+ const selected = item.name === agent.name;
518
+ navigationLines.push(`${selected ? pink("->") : " "} ${item.name}`);
519
+ }
520
+ const remainingAgents = agents.length - navigationStart - visibleAgents.length;
521
+ if (remainingAgents > 0) navigationLines.push(dimAnsi(`↓ … ${remainingAgents} more agent${remainingAgents === 1 ? "" : "s"}`));
522
+
523
+ const messageLines = options.sendMessage ? [
524
+ purple("─".repeat(innerWidth)),
525
+ composingMessage ? pink(`message ${agent.name}`) : dimAnsi(`message ${agent.name}`),
526
+ ...(composingMessage ? messageInput.render(innerWidth) : [dimAnsi("> Press m to start typing")]),
527
+ ...(messageStatus
528
+ ? [dimAnsi(messageStatus)]
529
+ : composingMessage ? [dimAnsi("enter send · esc cancel")] : []),
530
+ ] : [];
531
+ const bodyHeight = Math.max(4, terminalRows - navigationLines.length - 6 - messageLines.length);
532
+ lastBodyHeight = bodyHeight;
533
+
534
+ selectedName = agent.name;
535
+ try {
536
+ const session = agent.session;
537
+ const stats = session?.getSessionStats();
538
+ if (stats) {
539
+ agent.tokensUsed = stats.tokens.total;
540
+ const contextUsage = stats.contextUsage ?? session?.getContextUsage?.();
541
+ agent.contextUsage = stats.tokens.total > 0
542
+ ? contextUsage
543
+ : initialContextUsage(contextUsage?.contextWindow);
544
+ }
545
+ } catch {
546
+ // The nested session may be shutting down while this view renders.
547
+ }
548
+
549
+ const currentMessages = agent.session?.messages || [];
550
+ const lastMessage = currentMessages[currentMessages.length - 1];
551
+ const renderNow = Date.now();
552
+ const isStopping = stoppingAgents.has(agent.name);
553
+ const currentFastFrameKey = composingMessage ? "" : [
554
+ innerWidth,
555
+ terminalRows,
556
+ Math.floor(renderNow / 1000),
557
+ agents.map((item) => item.name).join(","),
558
+ selectedName,
559
+ agent.model,
560
+ agent.thinking,
561
+ agent.modelSlot,
562
+ agent.startedAt,
563
+ agent.tokensUsed,
564
+ formatContextUsage(agent.contextUsage),
565
+ agent.status,
566
+ agent.latestProgress,
567
+ isStopping,
568
+ expandLargeToolResults,
569
+ offsetFromBottom,
570
+ messageStatus,
571
+ options.sendMessage ? 1 : 0,
572
+ ].join("\0");
573
+ if (currentFastFrameKey
574
+ && currentFastFrameKey === fastFrameKey
575
+ && fastFrameMessages === currentMessages
576
+ && fastFrameMessageCount === currentMessages.length
577
+ && fastFrameLastMessage === lastMessage
578
+ && cachedFrameLines) {
579
+ return cachedFrameLines.slice();
580
+ }
581
+ const model = formatModelLabel(agent.model, agent.thinking).replace(" · ", "/");
582
+ const slot = agent.modelSlot || "level inherited";
583
+ const elapsed = formatElapsed(renderNow - agent.startedAt);
584
+ const activity = isStopping
585
+ ? "stopping"
586
+ : agent.latestProgress
587
+ ? formatAnimatedProgress(agent.latestProgress, renderNow)
588
+ : agent.status;
589
+ const headline = `(${agent.name}) ${model} · ${slot} · ${elapsed} · ${formatContextUsage(agent.contextUsage)} · ${activity}`;
590
+ const logAction = expandLargeToolResults ? "l collapse logs" : "l expand logs";
591
+ const messageAction = options.sendMessage ? " · m message" : "";
592
+ const help = composingMessage
593
+ ? `message ${agent.name} · enter send · esc cancel`
594
+ : agents.length > 1
595
+ ? `↑ previous/main · ↓ next agent · ←/→ agent · ${logAction}${messageAction} · x stop · pgup/pgdn scroll · esc main`
596
+ : `↑/esc main · ${logAction}${messageAction} · x stop · pgup/pgdn scroll · end follow`;
597
+
598
+ const currentTranscriptWidth = Math.max(20, innerWidth);
599
+ if (transcriptAgent !== agent
600
+ || transcriptMessages !== currentMessages
601
+ || transcriptMessageCount !== currentMessages.length
602
+ || transcriptLastMessage !== lastMessage
603
+ || transcriptWidth !== currentTranscriptWidth
604
+ || transcriptExpanded !== expandLargeToolResults) {
605
+ transcriptAgent = agent;
606
+ transcriptMessages = currentMessages;
607
+ transcriptMessageCount = currentMessages.length;
608
+ transcriptLastMessage = lastMessage;
609
+ transcriptWidth = currentTranscriptWidth;
610
+ transcriptExpanded = expandLargeToolResults;
611
+ cachedTranscriptLines = formatAgentFollowTranscript(currentMessages, {
612
+ expandLargeToolResults,
613
+ width: currentTranscriptWidth,
614
+ }).flatMap(line => wrapTextWithAnsi(line, currentTranscriptWidth));
615
+ }
616
+ const transcriptLines = cachedTranscriptLines;
617
+ lastTranscriptRows = transcriptLines.length;
618
+ const maxOffset = Math.max(0, transcriptLines.length - bodyHeight);
619
+ offsetFromBottom = Math.min(offsetFromBottom, maxOffset);
620
+ const start = Math.max(0, maxOffset - offsetFromBottom);
621
+ const visible = transcriptLines.slice(start, start + bodyHeight);
622
+ while (visible.length < bodyHeight) visible.push("");
623
+
624
+ const frameContent = [
625
+ ...navigationLines,
626
+ purple("─".repeat(innerWidth)),
627
+ headline,
628
+ dimAnsi(help),
629
+ purple("─".repeat(innerWidth)),
630
+ ...visible,
631
+ ...messageLines,
632
+ ];
633
+ let renderedFrame: string[] | undefined;
634
+ if (cachedFrameWidth === innerWidth
635
+ && cachedFrameContent
636
+ && cachedFrameContent.length === frameContent.length
637
+ && cachedFrameLines) {
638
+ let changedCount = 0;
639
+ let changed0 = -1;
640
+ let changed1 = -1;
641
+ let changed2 = -1;
642
+ let changed3 = -1;
643
+ for (let index = 0; index < frameContent.length; index++) {
644
+ if (frameContent[index] === cachedFrameContent[index]) continue;
645
+ if (changedCount === 0) changed0 = index;
646
+ else if (changedCount === 1) changed1 = index;
647
+ else if (changedCount === 2) changed2 = index;
648
+ else if (changedCount === 3) changed3 = index;
649
+ changedCount++;
650
+ if (changedCount > 4) break;
651
+ }
652
+ if (changedCount === 0) {
653
+ fastFrameKey = currentFastFrameKey;
654
+ fastFrameMessages = currentMessages;
655
+ fastFrameMessageCount = currentMessages.length;
656
+ fastFrameLastMessage = lastMessage;
657
+ return cachedFrameLines.slice();
658
+ }
659
+ if (changedCount <= 4) {
660
+ const renderFrameRow = cachedFrameRowRenderer!;
661
+ renderedFrame = cachedFrameLines.slice();
662
+ renderedFrame[changed0 + 1] = renderFrameRow(frameContent[changed0]);
663
+ if (changedCount > 1) {
664
+ renderedFrame[changed1 + 1] = renderFrameRow(frameContent[changed1]);
665
+ if (changedCount > 2) {
666
+ renderedFrame[changed2 + 1] = renderFrameRow(frameContent[changed2]);
667
+ if (changedCount > 3) renderedFrame[changed3 + 1] = renderFrameRow(frameContent[changed3]);
668
+ }
669
+ }
670
+ }
671
+ }
672
+ if (!renderedFrame) {
673
+ renderedFrame = framePanel(frameContent, innerWidth, AGENT_FOLLOW_BACKGROUND);
674
+ cachedFrameRowRenderer = createFramePanelRowRenderer(innerWidth, AGENT_FOLLOW_BACKGROUND);
675
+ }
676
+ cachedFrameWidth = innerWidth;
677
+ cachedFrameContent = frameContent;
678
+ cachedFrameLines = renderedFrame;
679
+ fastFrameKey = currentFastFrameKey;
680
+ fastFrameMessages = currentMessages;
681
+ fastFrameMessageCount = currentMessages.length;
682
+ fastFrameLastMessage = lastMessage;
683
+ return renderedFrame;
684
+ },
685
+ invalidate() {
686
+ messageInput.invalidate();
687
+ },
688
+ dispose() {
689
+ clearInterval(refreshTimer);
690
+ },
691
+ handleInput(data: string) {
692
+ if (composingMessage) {
693
+ if (matchesKey(data, Key.ctrl("c"))) {
694
+ done();
695
+ return;
696
+ }
697
+ messageInput.handleInput(data);
698
+ tui.requestRender();
699
+ return;
700
+ }
701
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
702
+ done();
703
+ return;
704
+ }
705
+ if (matchesKey(data, Key.up)) {
706
+ selectPreviousOrMain();
707
+ return;
708
+ }
709
+ if (data.toLowerCase() === "l") {
710
+ expandLargeToolResults = !expandLargeToolResults;
711
+ offsetFromBottom = 0;
712
+ tui.requestRender();
713
+ return;
714
+ }
715
+ if (data.toLowerCase() === "m" && options.sendMessage) {
716
+ composingMessage = true;
717
+ messageStatus = "";
718
+ syncInputFocus();
719
+ tui.requestRender();
720
+ return;
721
+ }
722
+ if (data.toLowerCase() === "x" && options.stopAgent) {
723
+ const agent = currentAgent(sortedAgents(), selectedName);
724
+ if (!agent || stoppingAgents.has(agent.name)) return;
725
+ stoppingAgents.add(agent.name);
726
+ tui.requestRender();
727
+ try {
728
+ const result = options.stopAgent(agent.name);
729
+ if (result && typeof result.then === "function") {
730
+ void result.finally(() => {
731
+ stoppingAgents.delete(agent.name);
732
+ tui.requestRender();
733
+ });
734
+ } else {
735
+ stoppingAgents.delete(agent.name);
736
+ tui.requestRender();
737
+ }
738
+ } catch {
739
+ stoppingAgents.delete(agent.name);
740
+ tui.requestRender();
741
+ }
742
+ return;
743
+ }
744
+ if (matchesKey(data, Key.left)) {
745
+ selectRelative(-1);
746
+ tui.requestRender();
747
+ return;
748
+ }
749
+ if (matchesKey(data, Key.right) || matchesKey(data, Key.down)) {
750
+ selectRelative(1);
751
+ tui.requestRender();
752
+ return;
753
+ }
754
+ const maxOffset = Math.max(0, lastTranscriptRows - lastBodyHeight);
755
+ if (matchesKey(data, Key.pageUp)) offsetFromBottom = Math.min(maxOffset, offsetFromBottom + lastBodyHeight);
756
+ else if (matchesKey(data, Key.pageDown)) offsetFromBottom = Math.max(0, offsetFromBottom - lastBodyHeight);
757
+ else if (matchesKey(data, Key.home)) offsetFromBottom = maxOffset;
758
+ else if (matchesKey(data, Key.end)) offsetFromBottom = 0;
759
+ else return;
760
+ tui.requestRender();
761
+ },
762
+ };
763
+ }
764
+
765
+ export async function openAgentFollowView(ctx: any, options: AgentFollowViewOptions): Promise<void> {
766
+ if (ctx.mode && ctx.mode !== "tui") return;
767
+ await ctx.ui.custom(
768
+ (tui: any, _theme: any, _keybindings: any, done: () => void) => createAgentFollowComponent(tui, done, options),
769
+ {
770
+ overlay: true,
771
+ overlayOptions: {
772
+ width: "100%",
773
+ maxHeight: "100%",
774
+ anchor: "center",
775
+ margin: 0,
776
+ },
777
+ }
778
+ );
779
+ }