@diousk/pi-subagents-fast 0.20.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.
Files changed (183) hide show
  1. package/CHANGELOG.md +808 -0
  2. package/CONTRIBUTING.md +72 -0
  3. package/LICENSE +21 -0
  4. package/README.md +1034 -0
  5. package/SECURITY.md +95 -0
  6. package/dist/abortable.d.ts +12 -0
  7. package/dist/abortable.js +42 -0
  8. package/dist/agent-color.d.ts +35 -0
  9. package/dist/agent-color.js +123 -0
  10. package/dist/agent-file-toggle.d.ts +125 -0
  11. package/dist/agent-file-toggle.js +260 -0
  12. package/dist/agent-manager.d.ts +472 -0
  13. package/dist/agent-manager.js +1338 -0
  14. package/dist/agent-runner.d.ts +312 -0
  15. package/dist/agent-runner.js +1034 -0
  16. package/dist/agent-types.d.ts +119 -0
  17. package/dist/agent-types.js +286 -0
  18. package/dist/child-context.d.ts +2 -0
  19. package/dist/child-context.js +12 -0
  20. package/dist/context.d.ts +12 -0
  21. package/dist/context.js +56 -0
  22. package/dist/cross-extension-rpc.d.ts +66 -0
  23. package/dist/cross-extension-rpc.js +138 -0
  24. package/dist/custom-agents.d.ts +54 -0
  25. package/dist/custom-agents.js +316 -0
  26. package/dist/default-agents.d.ts +7 -0
  27. package/dist/default-agents.js +122 -0
  28. package/dist/enabled-models.d.ts +49 -0
  29. package/dist/enabled-models.js +145 -0
  30. package/dist/env.d.ts +6 -0
  31. package/dist/env.js +28 -0
  32. package/dist/group-join.d.ts +32 -0
  33. package/dist/group-join.js +116 -0
  34. package/dist/index.d.ts +50 -0
  35. package/dist/index.js +3682 -0
  36. package/dist/invocation-config.d.ts +107 -0
  37. package/dist/invocation-config.js +83 -0
  38. package/dist/memory.d.ts +53 -0
  39. package/dist/memory.js +165 -0
  40. package/dist/mention-clone.d.ts +87 -0
  41. package/dist/mention-clone.js +153 -0
  42. package/dist/mention.d.ts +81 -0
  43. package/dist/mention.js +131 -0
  44. package/dist/model-resolver.d.ts +36 -0
  45. package/dist/model-resolver.js +95 -0
  46. package/dist/model-scope.d.ts +49 -0
  47. package/dist/model-scope.js +48 -0
  48. package/dist/nested-tools.d.ts +55 -0
  49. package/dist/nested-tools.js +299 -0
  50. package/dist/output-file.d.ts +43 -0
  51. package/dist/output-file.js +142 -0
  52. package/dist/prompts.d.ts +55 -0
  53. package/dist/prompts.js +91 -0
  54. package/dist/schedule-store.d.ts +38 -0
  55. package/dist/schedule-store.js +155 -0
  56. package/dist/schedule.d.ts +109 -0
  57. package/dist/schedule.js +359 -0
  58. package/dist/settings.d.ts +360 -0
  59. package/dist/settings.js +251 -0
  60. package/dist/skill-loader.d.ts +24 -0
  61. package/dist/skill-loader.js +93 -0
  62. package/dist/status-note.d.ts +61 -0
  63. package/dist/status-note.js +85 -0
  64. package/dist/structured-output.d.ts +61 -0
  65. package/dist/structured-output.js +112 -0
  66. package/dist/types.d.ts +371 -0
  67. package/dist/types.js +5 -0
  68. package/dist/ui/agent-mention.d.ts +82 -0
  69. package/dist/ui/agent-mention.js +187 -0
  70. package/dist/ui/agent-widget.d.ts +219 -0
  71. package/dist/ui/agent-widget.js +592 -0
  72. package/dist/ui/conversation-viewer.d.ts +120 -0
  73. package/dist/ui/conversation-viewer.js +578 -0
  74. package/dist/ui/fleet-list.d.ts +195 -0
  75. package/dist/ui/fleet-list.js +471 -0
  76. package/dist/ui/schedule-menu.d.ts +16 -0
  77. package/dist/ui/schedule-menu.js +94 -0
  78. package/dist/ui/select-item.d.ts +27 -0
  79. package/dist/ui/select-item.js +34 -0
  80. package/dist/ui/viewer-keys.d.ts +20 -0
  81. package/dist/ui/viewer-keys.js +17 -0
  82. package/dist/ui/workflow-card.d.ts +175 -0
  83. package/dist/ui/workflow-card.js +332 -0
  84. package/dist/ui/workflow-dialog.d.ts +305 -0
  85. package/dist/ui/workflow-dialog.js +843 -0
  86. package/dist/ui/workflow-menu.d.ts +60 -0
  87. package/dist/ui/workflow-menu.js +147 -0
  88. package/dist/usage.d.ts +135 -0
  89. package/dist/usage.js +120 -0
  90. package/dist/workflow/collisions.d.ts +95 -0
  91. package/dist/workflow/collisions.js +88 -0
  92. package/dist/workflow/entry.d.ts +32 -0
  93. package/dist/workflow/entry.js +29 -0
  94. package/dist/workflow/host.d.ts +62 -0
  95. package/dist/workflow/host.js +362 -0
  96. package/dist/workflow/journal.d.ts +97 -0
  97. package/dist/workflow/journal.js +120 -0
  98. package/dist/workflow/json-schema.d.ts +51 -0
  99. package/dist/workflow/json-schema.js +111 -0
  100. package/dist/workflow/meta.d.ts +67 -0
  101. package/dist/workflow/meta.js +317 -0
  102. package/dist/workflow/progress.d.ts +224 -0
  103. package/dist/workflow/progress.js +361 -0
  104. package/dist/workflow/runtime.d.ts +334 -0
  105. package/dist/workflow/runtime.js +830 -0
  106. package/dist/workflow/saved.d.ts +90 -0
  107. package/dist/workflow/saved.js +203 -0
  108. package/dist/workflow/task.d.ts +136 -0
  109. package/dist/workflow/task.js +207 -0
  110. package/dist/workflow/tool-description.d.ts +38 -0
  111. package/dist/workflow/tool-description.js +199 -0
  112. package/dist/workflow/worker-source.d.ts +47 -0
  113. package/dist/workflow/worker-source.js +778 -0
  114. package/dist/worktree.d.ts +52 -0
  115. package/dist/worktree.js +164 -0
  116. package/dist/xml.d.ts +10 -0
  117. package/dist/xml.js +12 -0
  118. package/docs/rpc.md +183 -0
  119. package/docs/workflows.md +437 -0
  120. package/examples/agent-tool-description.md +42 -0
  121. package/examples/workflows/compose.js +51 -0
  122. package/examples/workflows/fan-out-audit.js +47 -0
  123. package/examples/workflows/gated-fix.js +60 -0
  124. package/examples/workflows/lib/count-child.js +27 -0
  125. package/examples/workflows/review-panel.js +63 -0
  126. package/examples/workflows/structured-findings.js +78 -0
  127. package/package.json +68 -0
  128. package/src/abortable.ts +43 -0
  129. package/src/agent-color.ts +161 -0
  130. package/src/agent-file-toggle.ts +270 -0
  131. package/src/agent-manager.ts +1581 -0
  132. package/src/agent-runner.ts +1286 -0
  133. package/src/agent-types.ts +346 -0
  134. package/src/child-context.ts +15 -0
  135. package/src/context.ts +58 -0
  136. package/src/cross-extension-rpc.ts +198 -0
  137. package/src/custom-agents.ts +333 -0
  138. package/src/default-agents.ts +126 -0
  139. package/src/enabled-models.ts +180 -0
  140. package/src/env.ts +33 -0
  141. package/src/group-join.ts +141 -0
  142. package/src/index.ts +3991 -0
  143. package/src/invocation-config.ts +155 -0
  144. package/src/memory.ts +179 -0
  145. package/src/mention-clone.ts +196 -0
  146. package/src/mention.ts +141 -0
  147. package/src/model-resolver.ts +118 -0
  148. package/src/model-scope.ts +70 -0
  149. package/src/nested-tools.ts +422 -0
  150. package/src/output-file.ts +155 -0
  151. package/src/prompts.ts +142 -0
  152. package/src/schedule-store.ts +153 -0
  153. package/src/schedule.ts +386 -0
  154. package/src/settings.ts +587 -0
  155. package/src/skill-loader.ts +102 -0
  156. package/src/status-note.ts +90 -0
  157. package/src/structured-output.ts +130 -0
  158. package/src/types.ts +384 -0
  159. package/src/ui/agent-mention.ts +216 -0
  160. package/src/ui/agent-widget.ts +664 -0
  161. package/src/ui/conversation-viewer.ts +589 -0
  162. package/src/ui/fleet-list.ts +543 -0
  163. package/src/ui/schedule-menu.ts +105 -0
  164. package/src/ui/select-item.ts +45 -0
  165. package/src/ui/viewer-keys.ts +39 -0
  166. package/src/ui/workflow-card.ts +470 -0
  167. package/src/ui/workflow-dialog.ts +1115 -0
  168. package/src/ui/workflow-menu.ts +193 -0
  169. package/src/usage.ts +167 -0
  170. package/src/workflow/collisions.ts +123 -0
  171. package/src/workflow/entry.ts +47 -0
  172. package/src/workflow/host.ts +403 -0
  173. package/src/workflow/journal.ts +164 -0
  174. package/src/workflow/json-schema.ts +128 -0
  175. package/src/workflow/meta.ts +325 -0
  176. package/src/workflow/progress.ts +550 -0
  177. package/src/workflow/runtime.ts +1219 -0
  178. package/src/workflow/saved.ts +217 -0
  179. package/src/workflow/task.ts +302 -0
  180. package/src/workflow/tool-description.ts +200 -0
  181. package/src/workflow/worker-source.ts +781 -0
  182. package/src/worktree.ts +205 -0
  183. package/src/xml.ts +13 -0
@@ -0,0 +1,589 @@
1
+ /**
2
+ * conversation-viewer.ts — Live conversation overlay for viewing agent sessions.
3
+ *
4
+ * Displays a scrollable, live-updating view of an agent's conversation.
5
+ * Subscribes to session events for real-time streaming updates.
6
+ */
7
+
8
+ import { type AgentSession, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
9
+ import { type Component, Input, Markdown, type MarkdownOptions, type MarkdownTheme, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
10
+ import { renderAgentName } from "../agent-color.js";
11
+ import { extractText } from "../context.js";
12
+ import type { AgentRecord, ViewerMarkdownMode } from "../types.js";
13
+ import { getLifetimeCost, getLifetimeTotal, getSessionContextPercent } from "../usage.js";
14
+ import type { Theme } from "./agent-widget.js";
15
+ import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatCost, formatDuration, formatSessionTokens, getPromptModeLabel } from "./agent-widget.js";
16
+ import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
17
+
18
+ /** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
19
+ const CHROME_LINES_BASE = 6;
20
+ const MIN_VIEWPORT = 3;
21
+ /** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
22
+ export const VIEWPORT_HEIGHT_PCT = 70;
23
+
24
+ /**
25
+ * Cap on a single tool result or bash output before the viewer elides the rest.
26
+ *
27
+ * The cap is not cosmetic — it bounds render cost. `buildContentLines()` runs on
28
+ * every render *and* on every scroll key (`handleInput` calls it to compute
29
+ * `maxScroll`), so an uncapped 200 KB result costs ~6 ms per keystroke to parse
30
+ * as Markdown, against ~0.5 ms once capped and effectively nothing on a cache
31
+ * hit (best of 5, width 76). 16 KB is roughly a screenful at every terminal size
32
+ * and still ~30x the 500 characters this replaces, which was small enough to cut
33
+ * most real results mid-sentence.
34
+ */
35
+ export const RESULT_MAX_CHARS = 16_000;
36
+
37
+ /** Cycle order for the viewer's `m` key. */
38
+ const MARKDOWN_MODES: readonly ViewerMarkdownMode[] = ["off", "assistant", "all"];
39
+
40
+ /** Footer labels — short, because the idle footer is already full at 80 columns. */
41
+ const MARKDOWN_MODE_LABELS: Record<ViewerMarkdownMode, string> = {
42
+ off: "raw",
43
+ assistant: "md",
44
+ all: "md+",
45
+ };
46
+
47
+ /**
48
+ * Both options keep the renderer from *rewriting* source that only looks like
49
+ * Markdown: without them `3) a / 7) b / 9) c` comes back renumbered `3. 4. 5.`
50
+ * and backslash escapes are normalized away. Neither is a safe edit to make to
51
+ * a tool's output, and both are cheap to switch off.
52
+ */
53
+ const MARKDOWN_OPTIONS: MarkdownOptions = {
54
+ preserveOrderedListMarkers: true,
55
+ preserveBackslashEscapes: true,
56
+ };
57
+
58
+ /**
59
+ * Pi's own Markdown theme when this process has one, else a theme built from the
60
+ * viewer's `Theme`.
61
+ *
62
+ * Preferring pi's is what buys syntax-highlighted code fences (it carries a
63
+ * `highlightCode`), and it keeps this surface consistent with the notification
64
+ * renderer, which uses the same source. It has to be *probed* rather than
65
+ * try/caught around the call: `getMarkdownTheme()` returns arrow functions that
66
+ * read pi's global theme lazily, so an uninitialized theme throws inside
67
+ * `render()` — long after this returns — and takes the overlay with it. That is
68
+ * the case in tests and any embedded session that never called `initTheme()`.
69
+ */
70
+ function resolveMarkdownTheme(th: Theme): MarkdownTheme {
71
+ try {
72
+ const piTheme = getMarkdownTheme();
73
+ piTheme.heading("probe");
74
+ return piTheme;
75
+ } catch {
76
+ return fallbackMarkdownTheme(th);
77
+ }
78
+ }
79
+
80
+ /**
81
+ * `Theme` carries only `fg` and `bold`, so the three remaining styles are
82
+ * written as raw SGR. Rendering them as plain text instead would silently drop
83
+ * `*emphasis*`'s markers with nothing in their place, turning a formatting
84
+ * change into a content change.
85
+ */
86
+ function fallbackMarkdownTheme(th: Theme): MarkdownTheme {
87
+ const sgr = (on: number, off: number) => (text: string) => `\x1b[${on}m${text}\x1b[${off}m`;
88
+ return {
89
+ heading: text => th.bold(th.fg("accent", text)),
90
+ link: text => th.fg("accent", text),
91
+ linkUrl: text => th.fg("muted", text),
92
+ code: text => th.fg("muted", text),
93
+ codeBlock: text => th.fg("muted", text),
94
+ codeBlockBorder: text => th.fg("dim", text),
95
+ quote: text => th.fg("muted", text),
96
+ quoteBorder: text => th.fg("dim", text),
97
+ hr: text => th.fg("dim", text),
98
+ listBullet: text => th.fg("accent", text),
99
+ bold: text => th.bold(text),
100
+ italic: sgr(3, 23),
101
+ underline: sgr(4, 24),
102
+ strikethrough: sgr(9, 29),
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Cap `text` at `RESULT_MAX_CHARS`, reporting the elision separately rather than
108
+ * appending it.
109
+ *
110
+ * Separately because the notice is the viewer's chrome, not the tool's output.
111
+ * Appended into the string it becomes content: a cut landing inside a fenced
112
+ * code block — likely, on exactly the large `ctx_execute` results this is for —
113
+ * renders the notice as a line of source inside the fence.
114
+ */
115
+ function capResult(text: string): { text: string; elided: number } {
116
+ if (text.length <= RESULT_MAX_CHARS) return { text, elided: 0 };
117
+ return {
118
+ text: text.slice(0, RESULT_MAX_CHARS),
119
+ elided: text.length - RESULT_MAX_CHARS,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * `999` · `1.5k` · `8.4M` — a magnitude cue, not an exact count, past 1000.
125
+ *
126
+ * The bracket is chosen against the *rounded* value, so 999,999 reads `1M`
127
+ * rather than the `1000.0k` a naive `< 1e6` test produces.
128
+ */
129
+ function humanCount(n: number): string {
130
+ if (n < 1_000) return `${n}`;
131
+ const thousands = n < 999_950;
132
+ const value = thousands ? n / 1_000 : n / 1_000_000;
133
+ return `${value.toFixed(1).replace(/\.0$/, "")}${thousands ? "k" : "M"}`;
134
+ }
135
+
136
+ function truncationNote(elided: number): string {
137
+ return `... (truncated, ${humanCount(elided)} more character${elided === 1 ? "" : "s"})`;
138
+ }
139
+
140
+ export class ConversationViewer implements Component {
141
+ private scrollOffset = 0;
142
+ private autoScroll = true;
143
+ private unsubscribe: (() => void) | undefined;
144
+ private lastInnerW = 0;
145
+ private closed = false;
146
+ /** Two-press confirm guard for the stop key, so a stray key can't kill the agent. */
147
+ private stopArmed = false;
148
+ private keys: ViewerKeys;
149
+ /** Steering composer — present while the user is typing a message to the agent. */
150
+ private composer: Input | undefined;
151
+ /** Resolved once: pi's Markdown theme is fixed for the life of the process. */
152
+ private readonly markdownTheme: MarkdownTheme;
153
+ /** Set by the `m` key. Wins over the setting so `m` works without a persist hook. */
154
+ private markdownModeOverride: ViewerMarkdownMode | undefined;
155
+ /**
156
+ * One `Markdown` per message, so its own text/width cache does the work. A
157
+ * fresh instance per render would re-parse the whole transcript on every
158
+ * keystroke — the component caches, but only across calls to the same object.
159
+ * Weak so a compacted-away message doesn't pin its render.
160
+ */
161
+ private readonly markdownCache = new WeakMap<object, { md: Markdown; text: string; failed?: boolean }>();
162
+
163
+ constructor(
164
+ private tui: TUI,
165
+ private session: AgentSession,
166
+ private record: AgentRecord,
167
+ private activity: AgentActivity | undefined,
168
+ private theme: Theme,
169
+ private done: (result: undefined) => void,
170
+ /** Abort the agent shown here. Omitted → no stop affordance (e.g. read-only history). */
171
+ private onStop?: () => void,
172
+ /** User keybindings from `ctx.ui.custom()`. Omitted → hardcoded defaults. */
173
+ keybindings?: ViewerKeybindings,
174
+ /** Send a steering message to the agent. Omitted → no compose affordance. */
175
+ private onSteer?: (message: string) => void,
176
+ /**
177
+ * Whether the header shows an estimated cost after the token count. Read
178
+ * once, at construction: the overlay is opened from a menu, so the setting
179
+ * cannot change while it is on screen.
180
+ */
181
+ private showCost = false,
182
+ /**
183
+ * The current `viewerMarkdown` setting. Read live rather than captured,
184
+ * unlike `showCost`: `m` changes it while the overlay is on screen.
185
+ * Omitted → `assistant`.
186
+ */
187
+ private viewerMarkdown?: () => ViewerMarkdownMode,
188
+ /**
189
+ * Persist a mode chosen with `m`, so the key and `/agents → Settings` mean
190
+ * the same thing. Omitted → `m` still cycles, viewer-locally.
191
+ */
192
+ private onMarkdownMode?: (mode: ViewerMarkdownMode) => void,
193
+ ) {
194
+ this.markdownTheme = resolveMarkdownTheme(theme);
195
+ this.keys = createViewerKeys(keybindings);
196
+ this.unsubscribe = session.subscribe(() => {
197
+ if (this.closed) return;
198
+ this.tui.requestRender();
199
+ });
200
+ }
201
+
202
+ handleInput(data: string): void {
203
+ // While composing a steer message, the input owns all keys (Enter sends,
204
+ // Esc cancels — both wired in openComposer()). Editing keys flow through.
205
+ if (this.composer) {
206
+ this.composer.handleInput(data);
207
+ this.tui.requestRender();
208
+ return;
209
+ }
210
+
211
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c") || matchesKey(data, "q")) {
212
+ this.closed = true;
213
+ this.done(undefined);
214
+ return;
215
+ }
216
+
217
+ // Enter opens the steering composer (only while the agent can still be
218
+ // steered) — then type + Enter sends, Esc or an empty submit returns. When
219
+ // not steerable, fall through so the key still disarms a pending stop.
220
+ if (matchesKey(data, "enter") && this.canSteer()) {
221
+ this.stopArmed = false;
222
+ this.openComposer();
223
+ return;
224
+ }
225
+
226
+ // Stop/abort the agent (only while it can still be stopped). Two-press:
227
+ // first "x" arms, second confirms — any other key disarms.
228
+ if (matchesKey(data, "x")) {
229
+ if (this.isStoppable()) {
230
+ if (this.stopArmed) {
231
+ this.stopArmed = false;
232
+ this.onStop?.();
233
+ } else {
234
+ this.stopArmed = true;
235
+ }
236
+ this.tui.requestRender();
237
+ }
238
+ return;
239
+ }
240
+
241
+ // Cycle raw → assistant-only → everything. The escape hatch that makes
242
+ // Markdown rendering safe to default on: a result the renderer reshapes
243
+ // (a diff, an indented log, a `#`-commented script) is one key from verbatim.
244
+ if (matchesKey(data, "m")) {
245
+ this.stopArmed = false;
246
+ const next = MARKDOWN_MODES[(MARKDOWN_MODES.indexOf(this.markdownMode()) + 1) % MARKDOWN_MODES.length];
247
+ this.markdownModeOverride = next;
248
+ this.onMarkdownMode?.(next);
249
+ this.tui.requestRender();
250
+ return;
251
+ }
252
+ if (this.stopArmed) this.stopArmed = false;
253
+
254
+ const totalLines = this.buildContentLines(this.lastInnerW).length;
255
+ const viewportHeight = this.viewportHeight();
256
+ const maxScroll = Math.max(0, totalLines - viewportHeight);
257
+
258
+ if (this.keys.scrollUp(data)) {
259
+ this.scrollOffset = Math.max(0, this.scrollOffset - 1);
260
+ this.autoScroll = this.scrollOffset >= maxScroll;
261
+ } else if (this.keys.scrollDown(data)) {
262
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
263
+ this.autoScroll = this.scrollOffset >= maxScroll;
264
+ } else if (this.keys.pageUp(data)) {
265
+ this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
266
+ this.autoScroll = false;
267
+ } else if (this.keys.pageDown(data)) {
268
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
269
+ this.autoScroll = this.scrollOffset >= maxScroll;
270
+ } else if (matchesKey(data, "home")) {
271
+ this.scrollOffset = 0;
272
+ this.autoScroll = false;
273
+ } else if (matchesKey(data, "end")) {
274
+ this.scrollOffset = maxScroll;
275
+ this.autoScroll = true;
276
+ }
277
+ }
278
+
279
+ render(width: number): string[] {
280
+ if (width < 6) return []; // too narrow for any meaningful rendering
281
+ const th = this.theme;
282
+ const innerW = width - 4; // border + padding
283
+ this.lastInnerW = innerW;
284
+ const lines: string[] = [];
285
+
286
+ const pad = (s: string, len: number) => {
287
+ const vis = visibleWidth(s);
288
+ return s + " ".repeat(Math.max(0, len - vis));
289
+ };
290
+ const row = (content: string) =>
291
+ th.fg("border", "│") + " " + truncateToWidth(pad(content, innerW), innerW, "...", true) + " " + th.fg("border", "│");
292
+ const hrTop = th.fg("border", `╭${"─".repeat(width - 2)}╮`);
293
+ const hrBot = th.fg("border", `╰${"─".repeat(width - 2)}╯`);
294
+ const hrMid = row(th.fg("dim", "─".repeat(innerW)));
295
+
296
+ // Header
297
+ lines.push(hrTop);
298
+ const modeLabel = getPromptModeLabel(this.record.type);
299
+ const modeTag = modeLabel ? ` ${th.fg("dim", `(${modeLabel})`)}` : "";
300
+ const statusIcon = this.record.status === "running"
301
+ ? th.fg("accent", "●")
302
+ : this.record.status === "completed"
303
+ ? th.fg("success", "✓")
304
+ : this.record.status === "error"
305
+ ? th.fg("error", "✗")
306
+ : th.fg("dim", "○");
307
+ const duration = formatDuration(this.record.startedAt, this.record.completedAt);
308
+
309
+ const headerParts: string[] = [duration];
310
+ const toolUses = this.activity?.toolUses ?? this.record.toolUses;
311
+ if (toolUses > 0) headerParts.unshift(`${toolUses} tool${toolUses === 1 ? "" : "s"}`);
312
+ // Spend from the record, context from the live session: the record is the
313
+ // only total that survives the agent finishing and the only one carrying a
314
+ // nested child's spend.
315
+ const tokens = getLifetimeTotal(this.record.lifetimeUsage);
316
+ if (tokens > 0) {
317
+ const percent = getSessionContextPercent(this.activity?.session);
318
+ headerParts.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount));
319
+ }
320
+ const cost = this.showCost ? formatCost(getLifetimeCost(this.record.lifetimeUsage)) : "";
321
+ if (cost) headerParts.push(cost);
322
+
323
+ lines.push(row(
324
+ `${statusIcon} ${renderAgentName(this.record.type, th, { bold: true })}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${fgPreservingNestedStyles(th, "dim", headerParts.join(" · "))}`,
325
+ ));
326
+ const invocationLine = this.invocationLine();
327
+ if (invocationLine) lines.push(row(invocationLine));
328
+ lines.push(hrMid);
329
+
330
+ // Content area — rebuild every render (live data, no cache needed)
331
+ const contentLines = this.buildContentLines(innerW);
332
+ const viewportHeight = this.viewportHeight();
333
+ const maxScroll = Math.max(0, contentLines.length - viewportHeight);
334
+
335
+ if (this.autoScroll) {
336
+ this.scrollOffset = maxScroll;
337
+ }
338
+
339
+ const visibleStart = Math.min(this.scrollOffset, maxScroll);
340
+ const visible = contentLines.slice(visibleStart, visibleStart + viewportHeight);
341
+
342
+ for (let i = 0; i < viewportHeight; i++) {
343
+ lines.push(row(visible[i] ?? ""));
344
+ }
345
+
346
+ // Footer
347
+ lines.push(hrMid);
348
+ if (this.composer) {
349
+ // Composer row: the Input renders its own `> ` prompt and cursor.
350
+ lines.push(row(this.composer.render(innerW)[0] ?? ""));
351
+ const composeHint = th.fg("dim", "Enter send · Esc cancel");
352
+ const composeLeft = th.fg("accent", "✎ steer");
353
+ const composeGap = Math.max(1, innerW - visibleWidth(composeLeft) - visibleWidth(composeHint));
354
+ lines.push(row(composeLeft + " ".repeat(composeGap) + composeHint));
355
+ } else {
356
+ // Actions on the left, navigation on the right. The scroll hint keeps its
357
+ // full key list so the less-obvious bindings stay discoverable; it leads
358
+ // the right group so "Esc close" is the only part that truncates first.
359
+ const sep = th.fg("dim", " · ");
360
+ const actions: string[] = [];
361
+ if (this.canSteer()) actions.push(th.fg("dim", "Enter steer"));
362
+ if (this.isStoppable()) {
363
+ actions.push(this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop"));
364
+ }
365
+ // Abbreviated (`raw`/`md`/`md+`) because the idle footer is already full
366
+ // at 80 columns with steer + stop present, and this group has no
367
+ // degradation step below "drop the line-count readout".
368
+ actions.push(th.fg("dim", `m ${MARKDOWN_MODE_LABELS[this.markdownMode()]}`));
369
+ const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close");
370
+
371
+ // Prepend the line-count/scroll-% readout only when there's spare width —
372
+ // it's the first thing dropped so it never crowds out the hints.
373
+ const scrollPct = contentLines.length <= viewportHeight
374
+ ? "100%"
375
+ : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
376
+ const count = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
377
+ const withCount = [count, ...actions].join(sep);
378
+ const footerLeft = visibleWidth(withCount) + visibleWidth(footerRight) + 1 <= innerW
379
+ ? withCount
380
+ : actions.join(sep);
381
+
382
+ const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
383
+ lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
384
+ }
385
+ lines.push(hrBot);
386
+
387
+ return lines;
388
+ }
389
+
390
+ /** Stoppable only when a stop handler exists and the agent is still active. */
391
+ private isStoppable(): boolean {
392
+ return !!this.onStop && (this.record.status === "running" || this.record.status === "queued");
393
+ }
394
+
395
+ /** The mode in force: an `m` press, else the setting, else the default. */
396
+ private markdownMode(): ViewerMarkdownMode {
397
+ return this.markdownModeOverride ?? this.viewerMarkdown?.() ?? "assistant";
398
+ }
399
+
400
+ /** Wrap `text` literally — the pre-Markdown path, and the fallback from it. */
401
+ private rawLines(text: string, width: number, dim: boolean): string[] {
402
+ const lines = wrapTextWithAnsi(text, width);
403
+ return dim ? lines.map(l => this.theme.fg("dim", l)) : lines;
404
+ }
405
+
406
+ /** Render `text` as Markdown, reusing this message's component instance. */
407
+ private markdownLines(msg: AgentSession["messages"][number], text: string, width: number, dim: boolean): string[] {
408
+ let entry = this.markdownCache.get(msg);
409
+ if (!entry) {
410
+ entry = {
411
+ md: new Markdown(
412
+ text,
413
+ 0,
414
+ 0,
415
+ this.markdownTheme,
416
+ // Keeps result prose visually receded, the way the raw path's
417
+ // per-line `fg("dim", …)` did. Fenced code is the exception and is
418
+ // left alone deliberately: pi's theme highlights it with its own
419
+ // colors, which this would otherwise flatten.
420
+ dim ? { color: (t: string) => this.theme.fg("dim", t) } : undefined,
421
+ MARKDOWN_OPTIONS,
422
+ ),
423
+ text,
424
+ };
425
+ this.markdownCache.set(msg, entry);
426
+ } else if (entry.text !== text) {
427
+ // Streaming: the message object is stable, its text grows. A failed
428
+ // prefix remains unsafe after append-only deltas, so retry only when the
429
+ // content was replaced or truncated.
430
+ const shouldRetry = !text.startsWith(entry.text);
431
+ entry.md.setText(text);
432
+ entry.text = text;
433
+ if (shouldRetry) entry.failed = false;
434
+ }
435
+ if (entry.failed) return this.rawLines(text, width, dim);
436
+
437
+ try {
438
+ return entry.md.render(width);
439
+ } catch {
440
+ // The parser is recursive and this is arbitrary tool output: ~54 nested
441
+ // blockquotes overflow the stack, and no amount of fuzzing proves that is
442
+ // the only such input. `render()` is on the TUI's critical path, so a
443
+ // throw here takes the overlay down for content the literal path shows
444
+ // fine — degrade to that instead, and remember, since the throw would
445
+ // otherwise repeat on every render and every scroll key.
446
+ entry.failed = true;
447
+ return this.rawLines(text, width, dim);
448
+ }
449
+ }
450
+
451
+ /** Steerable only when a steer handler exists and the agent is still active. */
452
+ private canSteer(): boolean {
453
+ return !!this.onSteer && (this.record.status === "running" || this.record.status === "queued");
454
+ }
455
+
456
+ /** Open the inline steering composer and route subsequent input to it. */
457
+ private openComposer(): void {
458
+ const input = new Input();
459
+ input.focused = true;
460
+ input.onSubmit = (value: string) => {
461
+ const message = value.trim();
462
+ this.composer = undefined;
463
+ if (message) this.onSteer?.(message);
464
+ this.tui.requestRender();
465
+ };
466
+ input.onEscape = () => {
467
+ this.composer = undefined;
468
+ this.tui.requestRender();
469
+ };
470
+ this.composer = input;
471
+ this.tui.requestRender();
472
+ }
473
+
474
+ invalidate(): void { /* no cached state to clear */ }
475
+
476
+ dispose(): void {
477
+ this.closed = true;
478
+ if (this.unsubscribe) {
479
+ this.unsubscribe();
480
+ this.unsubscribe = undefined;
481
+ }
482
+ }
483
+
484
+ // ---- Private ----
485
+
486
+ private viewportHeight(): number {
487
+ // Cap mirrors the overlay's maxHeight — otherwise the viewer would render
488
+ // more lines than the overlay shows and clip the footer.
489
+ const maxRows = Math.floor((this.tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100);
490
+ return Math.max(MIN_VIEWPORT, maxRows - this.chromeLines());
491
+ }
492
+
493
+ private chromeLines(): number {
494
+ // The composer adds one row above the footer hint while it's open.
495
+ return CHROME_LINES_BASE + (this.invocationLine() ? 1 : 0) + (this.composer ? 1 : 0);
496
+ }
497
+
498
+ private invocationLine(): string | undefined {
499
+ // Canonical id here, short label everywhere else: this overlay is opened to
500
+ // inspect one agent and has the width for it, and two providers can serve
501
+ // models whose short names read alike.
502
+ const { modelName, modelId, tags } = buildInvocationTags(this.record.invocation);
503
+ const model = modelId ?? modelName;
504
+ const parts = model ? [model, ...tags] : tags;
505
+ if (parts.length === 0) return undefined;
506
+ return this.theme.fg("dim", ` ↳ ${parts.join(" · ")}`);
507
+ }
508
+
509
+ private buildContentLines(width: number): string[] {
510
+ if (width <= 0) return [];
511
+
512
+ const th = this.theme;
513
+ const messages = this.session.messages;
514
+ const lines: string[] = [];
515
+
516
+ if (messages.length === 0) {
517
+ lines.push(th.fg("dim", "(waiting for first message...)"));
518
+ return lines;
519
+ }
520
+
521
+ const mode = this.markdownMode();
522
+ let needsSeparator = false;
523
+ for (const msg of messages) {
524
+ if (msg.role === "user") {
525
+ const text = typeof msg.content === "string"
526
+ ? msg.content
527
+ : extractText(msg.content);
528
+ if (!text.trim()) continue;
529
+ if (needsSeparator) lines.push(th.fg("dim", "───"));
530
+ lines.push(th.fg("accent", "[User]"));
531
+ for (const line of wrapTextWithAnsi(text.trim(), width)) {
532
+ lines.push(line);
533
+ }
534
+ } else if (msg.role === "assistant") {
535
+ const textParts: string[] = [];
536
+ const toolCalls: string[] = [];
537
+ for (const c of msg.content) {
538
+ if (c.type === "text" && c.text) textParts.push(c.text);
539
+ else if (c.type === "toolCall") {
540
+ toolCalls.push((c as any).name ?? (c as any).toolName ?? "unknown");
541
+ }
542
+ }
543
+ if (needsSeparator) lines.push(th.fg("dim", "───"));
544
+ lines.push(th.bold("[Assistant]"));
545
+ if (textParts.length > 0) {
546
+ const text = textParts.join("\n").trim();
547
+ lines.push(...(mode === "off"
548
+ ? this.rawLines(text, width, false)
549
+ : this.markdownLines(msg, text, width, false)));
550
+ }
551
+ for (const name of toolCalls) {
552
+ lines.push(truncateToWidth(th.fg("muted", ` [Tool: ${name}]`), width));
553
+ }
554
+ } else if (msg.role === "toolResult") {
555
+ const { text, elided } = capResult(extractText(msg.content).trim());
556
+ if (!text) continue;
557
+ if (needsSeparator) lines.push(th.fg("dim", "───"));
558
+ lines.push(th.fg("dim", "[Result]"));
559
+ lines.push(...(mode === "all"
560
+ ? this.markdownLines(msg, text, width, true)
561
+ : this.rawLines(text, width, true)));
562
+ if (elided) lines.push(truncateToWidth(th.fg("dim", truncationNote(elided)), width));
563
+ } else if ((msg as any).role === "bashExecution") {
564
+ const bash = msg as any;
565
+ if (needsSeparator) lines.push(th.fg("dim", "───"));
566
+ lines.push(truncateToWidth(th.fg("muted", ` $ ${bash.command}`), width));
567
+ if (bash.output?.trim()) {
568
+ // Same cap as a tool result, never Markdown: command output is the one
569
+ // thing here that is definitionally not authored as Markdown.
570
+ const { text, elided } = capResult(bash.output.trim());
571
+ lines.push(...this.rawLines(text, width, true));
572
+ if (elided) lines.push(truncateToWidth(th.fg("dim", truncationNote(elided)), width));
573
+ }
574
+ } else {
575
+ continue;
576
+ }
577
+ needsSeparator = true;
578
+ }
579
+
580
+ // Streaming indicator for running agents
581
+ if (this.record.status === "running" && this.activity) {
582
+ const act = describeActivity(this.activity.activeTools, this.activity.responseText);
583
+ lines.push("");
584
+ lines.push(truncateToWidth(th.fg("accent", "▍ ") + th.fg("dim", act), width));
585
+ }
586
+
587
+ return lines.map(l => truncateToWidth(l, width));
588
+ }
589
+ }