@kushalkhemka/opensec-pi-subagents 0.20.3 → 0.20.4

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.
@@ -2,16 +2,20 @@
2
2
  * conversation-viewer.ts — Live conversation overlay for viewing agent sessions.
3
3
  *
4
4
  * Displays a scrollable, live-updating view of an agent's conversation.
5
- * Subscribes to session events for real-time streaming updates.
5
+ * Subscribes to session events for real-time streaming updates. The
6
+ * transcript is drawn with Pi's own chat components (`viewer-transcript.ts`),
7
+ * so it looks exactly like Pi's main session; the frame around it (header,
8
+ * footer, scrolling, steering) is the viewer's.
6
9
  */
7
10
  import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
8
- import { Input, Markdown, matchesKey, ScrollView, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
11
+ import { Input, matchesKey, ScrollView, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
9
12
  import { renderAgentName } from "../agent-color.js";
10
13
  import { extractText } from "../context.js";
11
14
  import { getCachedSessionContextPercent, getLifetimeCost, getLifetimeTotal } from "../usage.js";
12
15
  import { buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatCost, formatDuration, formatSessionTokens, getPromptModeLabel } from "./agent-widget.js";
13
16
  import { sanitizeTerminalText } from "./terminal-text.js";
14
17
  import { createViewerKeys } from "./viewer-keys.js";
18
+ import { PiTranscript } from "./viewer-transcript.js";
15
19
  /** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
16
20
  const CHROME_LINES_BASE = 6;
17
21
  const MIN_VIEWPORT = 3;
@@ -30,20 +34,10 @@ export function getConversationOverlayOptions(tui) {
30
34
  ? { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 }
31
35
  : { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` };
32
36
  }
37
+ /** Footer click target for the expand toggle, which may be bound to any key. */
38
+ const EXPAND_TARGET = "expand";
33
39
  /** Message roles the fullscreen jump marker counts as "new messages" (not streaming updates). */
34
40
  const COUNTED_ROLES = new Set(["user", "assistant", "toolResult", "bashExecution"]);
35
- /**
36
- * Cap on a single tool result or bash output before the viewer elides the rest.
37
- *
38
- * The cap is not cosmetic — it bounds render cost. A message's lines are
39
- * rebuilt whenever the width or Markdown mode changes, and the streaming tail
40
- * on every frame it changes, so an uncapped 200 KB result costs ~6 ms per
41
- * rebuild to parse as Markdown, against ~0.5 ms once capped (best of 5, width
42
- * 76). 16 KB is roughly a screenful at every terminal size and still ~30x the
43
- * 500 characters this replaces, which was small enough to cut most real
44
- * results mid-sentence.
45
- */
46
- export const RESULT_MAX_CHARS = 16_000;
47
41
  /**
48
42
  * Session events that fire per streamed token or tool-output chunk. They are
49
43
  * coalesced into one redraw per frame; every other event redraws at once.
@@ -51,103 +45,33 @@ export const RESULT_MAX_CHARS = 16_000;
51
45
  const STREAMING_EVENTS = new Set(["message_update", "tool_execution_update"]);
52
46
  /** Redraw cadence while streaming — the widget's spinner frame. */
53
47
  const STREAM_FRAME_MS = 80;
54
- /** Cycle order for the viewer's `m` key. */
55
- const MARKDOWN_MODES = ["off", "assistant", "all"];
56
- /** Footer labels — short, because the idle footer is already full at 80 columns. */
57
- const MARKDOWN_MODE_LABELS = {
58
- off: "raw",
59
- assistant: "md",
60
- all: "md+",
61
- };
62
- /**
63
- * Both options keep the renderer from *rewriting* source that only looks like
64
- * Markdown: without them `3) a / 7) b / 9) c` comes back renumbered `3. 4. 5.`
65
- * and backslash escapes are normalized away. Neither is a safe edit to make to
66
- * a tool's output, and both are cheap to switch off.
67
- */
68
- const MARKDOWN_OPTIONS = {
69
- preserveOrderedListMarkers: true,
70
- preserveBackslashEscapes: true,
71
- };
72
48
  /**
73
- * Pi's own Markdown theme when this process has one, else a theme built from the
74
- * viewer's `Theme`.
75
- *
76
- * Preferring pi's is what buys syntax-highlighted code fences (it carries a
77
- * `highlightCode`), and it keeps this surface consistent with the notification
78
- * renderer, which uses the same source. It has to be *probed* rather than
79
- * try/caught around the call: `getMarkdownTheme()` returns arrow functions that
80
- * read pi's global theme lazily, so an uninitialized theme throws inside
81
- * `render()` — long after this returns — and takes the overlay with it. That is
82
- * the case in tests and any embedded session that never called `initTheme()`.
49
+ * Whether Pi's global theme is initialized. Pi's components read it while they
50
+ * are built and rendered, and throw without it; Pi's interactive mode always
51
+ * has one, tests and embedded sessions may not.
83
52
  */
84
- function resolveMarkdownTheme(th) {
53
+ function piThemeReady() {
85
54
  try {
86
- const piTheme = getMarkdownTheme();
87
- piTheme.heading("probe");
88
- return piTheme;
55
+ getMarkdownTheme().heading("probe");
56
+ return true;
89
57
  }
90
58
  catch {
91
- return fallbackMarkdownTheme(th);
59
+ return false;
92
60
  }
93
61
  }
94
62
  /**
95
- * `Theme` carries only `fg` and `bold`, so the three remaining styles are
96
- * written as raw SGR. Rendering them as plain text instead would silently drop
97
- * `*emphasis*`'s markers with nothing in their place, turning a formatting
98
- * change into a content change.
99
- */
100
- function fallbackMarkdownTheme(th) {
101
- const sgr = (on, off) => (text) => `\x1b[${on}m${text}\x1b[${off}m`;
102
- return {
103
- heading: text => th.bold(th.fg("accent", text)),
104
- link: text => th.fg("accent", text),
105
- linkUrl: text => th.fg("muted", text),
106
- code: text => th.fg("muted", text),
107
- codeBlock: text => th.fg("muted", text),
108
- codeBlockBorder: text => th.fg("dim", text),
109
- quote: text => th.fg("muted", text),
110
- quoteBorder: text => th.fg("dim", text),
111
- hr: text => th.fg("dim", text),
112
- listBullet: text => th.fg("accent", text),
113
- bold: text => th.bold(text),
114
- italic: sgr(3, 23),
115
- underline: sgr(4, 24),
116
- strikethrough: sgr(9, 29),
117
- };
118
- }
119
- /**
120
- * Cap `text` at `RESULT_MAX_CHARS`, reporting the elision separately rather than
121
- * appending it.
122
- *
123
- * Separately because the notice is the viewer's chrome, not the tool's output.
124
- * Appended into the string it becomes content: a cut landing inside a fenced
125
- * code block — likely, on exactly the large `ctx_execute` results this is for —
126
- * renders the notice as a line of source inside the fence.
127
- */
128
- function capResult(text) {
129
- if (text.length <= RESULT_MAX_CHARS)
130
- return { text, elided: 0 };
131
- return {
132
- text: text.slice(0, RESULT_MAX_CHARS),
133
- elided: text.length - RESULT_MAX_CHARS,
134
- };
135
- }
136
- /**
137
- * `999` · `1.5k` · `8.4M` — a magnitude cue, not an exact count, past 1000.
138
- *
139
- * The bracket is chosen against the *rounded* value, so 999,999 reads `1M`
140
- * rather than the `1000.0k` a naive `< 1e6` test produces.
63
+ * The footer's left group, dropping what fits least first: the line-count
64
+ * readout, then the expand hint (Pi's components already say "ctrl+o to
65
+ * expand" beside anything collapsed), never the actions.
141
66
  */
142
- function humanCount(n) {
143
- if (n < 1_000)
144
- return `${n}`;
145
- const thousands = n < 999_950;
146
- const value = thousands ? n / 1_000 : n / 1_000_000;
147
- return `${value.toFixed(1).replace(/\.0$/, "")}${thousands ? "k" : "M"}`;
148
- }
149
- function truncationNote(elided) {
150
- return `... (truncated, ${humanCount(elided)} more character${elided === 1 ? "" : "s"})`;
67
+ function fitFooterLeft(count, actions, expand, sep, room) {
68
+ const tail = expand ? [...actions, expand] : actions;
69
+ for (const parts of [[count, ...tail], tail]) {
70
+ const text = parts.join(sep);
71
+ if (visibleWidth(text) <= room)
72
+ return text;
73
+ }
74
+ return actions.join(sep);
151
75
  }
152
76
  export class ConversationViewer {
153
77
  tui;
@@ -159,8 +83,6 @@ export class ConversationViewer {
159
83
  onStop;
160
84
  onSteer;
161
85
  showCost;
162
- viewerMarkdown;
163
- onMarkdownMode;
164
86
  onBackground;
165
87
  scrollOffset = 0;
166
88
  autoScroll = true;
@@ -203,39 +125,13 @@ export class ConversationViewer {
203
125
  keys;
204
126
  /** Steering composer — present while the user is typing a message to the agent. */
205
127
  composer;
206
- /** Resolved once: pi's Markdown theme is fixed for the life of the process. */
207
- markdownTheme;
208
- /** Set by the `m` key. Wins over the setting so `m` works without a persist hook. */
209
- markdownModeOverride;
210
- /**
211
- * One `Markdown` per message, so its own text/width cache does the work. A
212
- * fresh instance per render would re-parse the whole transcript on every
213
- * keystroke — the component caches, but only across calls to the same object.
214
- * Weak so a compacted-away message doesn't pin its render.
215
- */
216
- markdownCache = new WeakMap();
217
- /**
218
- * The transcript, kept between frames. `blocks[i]` renders `messages[i]`;
219
- * `lines` holds every block's lines (and separators) in order, and
220
- * `blockStart[i]` is where block `i`'s part of it begins. All of it is valid
221
- * for `cacheWidth` and `cacheMode` only.
222
- */
223
- blocks = [];
224
- blockStart = [];
225
- lines = [];
226
- /** `lineWidths[i]` is the visible width of `lines[i]`, measured once when the line is built. */
227
- lineWidths = [];
228
128
  /**
229
- * The messages array the cache was last validated against. Pi only appends
230
- * to that array and replaces it wholesale otherwise, so while it is the same
231
- * object only the unsettled tail needs checking — what keeps a frame's cost
232
- * flat however long the transcript gets.
129
+ * The transcript drawn with Pi's components, its lines cached between
130
+ * frames. Undefined when Pi's theme is not initialized (see `plainLines`).
233
131
  */
234
- cacheMessages;
235
- cacheWidth = -1;
236
- cacheMode;
237
- /** Messages from this index on may still change in place; earlier ones are settled. */
238
- unsettledFrom = 0;
132
+ transcript;
133
+ /** The plain fallback's lines and what they were built for. */
134
+ plain;
239
135
  /** Pending coalesced redraw for streaming events. */
240
136
  streamRenderTimer;
241
137
  constructor(tui, session, record, activity, theme, done,
@@ -251,17 +147,6 @@ export class ConversationViewer {
251
147
  * cannot change while it is on screen.
252
148
  */
253
149
  showCost = false,
254
- /**
255
- * The current `viewerMarkdown` setting. Read live rather than captured,
256
- * unlike `showCost`: `m` changes it while the overlay is on screen.
257
- * Omitted → `assistant`.
258
- */
259
- viewerMarkdown,
260
- /**
261
- * Persist a mode chosen with `m`, so the key and `/agents → Settings` mean
262
- * the same thing. Omitted → `m` still cycles, viewer-locally.
263
- */
264
- onMarkdownMode,
265
150
  /**
266
151
  * Hand this run to the background. `available` is read live — it turns
267
152
  * false the instant the handoff lands or the run ends. Omitted → no `b`
@@ -269,7 +154,9 @@ export class ConversationViewer {
269
154
  */
270
155
  onBackground,
271
156
  /** Pi's `fullscreenScrollbar` preference, captured when the viewer opens. */
272
- scrollbarMode = "auto") {
157
+ scrollbarMode = "auto",
158
+ /** Pi's chat settings (thinking blocks, padding, code indent), read when the viewer opens. */
159
+ renderOptions) {
273
160
  this.tui = tui;
274
161
  this.session = session;
275
162
  this.record = record;
@@ -279,23 +166,23 @@ export class ConversationViewer {
279
166
  this.onStop = onStop;
280
167
  this.onSteer = onSteer;
281
168
  this.showCost = showCost;
282
- this.viewerMarkdown = viewerMarkdown;
283
- this.onMarkdownMode = onMarkdownMode;
284
169
  this.onBackground = onBackground;
285
170
  this.fullscreen = tui.mode === "fullscreen";
286
- this.markdownTheme = resolveMarkdownTheme(theme);
287
171
  this.keys = createViewerKeys(keybindings);
172
+ this.transcript = piThemeReady() ? new PiTranscript(session, this.requestRender, renderOptions) : undefined;
288
173
  this.scrollView = new ScrollView({ render: () => [], invalidate: () => { } }, { follow: "end", scrollbar: this.fullscreen ? scrollbarMode : "hidden" });
289
174
  this.unsubscribe = session.subscribe((event) => {
290
175
  if (this.closed)
291
176
  return;
292
177
  const e = event;
178
+ if (e)
179
+ this.transcript?.handleEvent(e);
293
180
  if (this.fullscreen && e?.type === "message_start" && !this.scrollView.isFollowingEnd && COUNTED_ROLES.has(e.message?.role ?? "")) {
294
181
  this.newMessages++;
295
182
  }
296
183
  if (STREAMING_EVENTS.has(e?.type ?? "")) {
297
- // A token delta changes at most the activity line; one redraw per
298
- // frame is enough, and unthrottled it was one per token.
184
+ // A token delta redraws only the streamed message's block; one redraw
185
+ // per frame is enough, and unthrottled it was one per token.
299
186
  this.streamRenderTimer ??= setTimeout(() => {
300
187
  this.streamRenderTimer = undefined;
301
188
  if (!this.closed)
@@ -357,15 +244,9 @@ export class ConversationViewer {
357
244
  }
358
245
  return;
359
246
  }
360
- // Cycle raw → assistant-only → everything. The escape hatch that makes
361
- // Markdown rendering safe to default on: a result the renderer reshapes
362
- // (a diff, an indented log, a `#`-commented script) is one key from verbatim.
363
- if (matchesKey(data, "m")) {
364
- this.stopArmed = false;
365
- const next = MARKDOWN_MODES[(MARKDOWN_MODES.indexOf(this.markdownMode()) + 1) % MARKDOWN_MODES.length];
366
- this.markdownModeOverride = next;
367
- this.onMarkdownMode?.(next);
368
- this.tui.requestRender();
247
+ // Expand or collapse tool output, as Pi's ctrl+o (`app.tools.expand`).
248
+ if (this.keys.toggleExpand(data)) {
249
+ this.toggleExpand();
369
250
  return;
370
251
  }
371
252
  if (this.stopArmed)
@@ -466,7 +347,10 @@ export class ConversationViewer {
466
347
  if (event.y === this.tui.terminal.rows - 1) {
467
348
  const target = this.footerTargets.find(t => event.x >= t.start && event.x < t.end);
468
349
  if (target) {
469
- this.handleInput(target.key);
350
+ if (target.key === EXPAND_TARGET)
351
+ this.toggleExpand();
352
+ else
353
+ this.handleInput(target.key);
470
354
  return { handled: true, render: true };
471
355
  }
472
356
  }
@@ -543,7 +427,7 @@ export class ConversationViewer {
543
427
  if (at < content.base.length) {
544
428
  const text = content.base[at];
545
429
  // The cached transcript has its widths; the empty-session placeholder does not.
546
- return { text, width: content.base === this.lines ? this.lineWidths[at] : visibleWidth(text) };
430
+ return { text, width: content.widths ? content.widths[at] : visibleWidth(text) };
547
431
  }
548
432
  const text = content.tail[at - content.base.length] ?? "";
549
433
  return { text, width: text ? visibleWidth(text) : 0 };
@@ -600,7 +484,7 @@ export class ConversationViewer {
600
484
  const scrollPct = totalLines <= viewportHeight
601
485
  ? "100%"
602
486
  : `${Math.round(((visibleStart + viewportHeight) / totalLines) * 100)}%`;
603
- const footerKey = `${width}|${this.canSteer()}|${this.canSendToBackground()}|${this.isStoppable()}|${this.stopArmed}|${this.markdownMode()}|${totalLines}|${scrollPct}`;
487
+ const footerKey = `${width}|${this.canSteer()}|${this.canSendToBackground()}|${this.isStoppable()}|${this.stopArmed}|${this.transcript?.isExpanded}|${totalLines}|${scrollPct}`;
604
488
  lines.push(this.chrome("r:footer", footerKey, () => {
605
489
  // Actions on the left, navigation on the right. The scroll hint keeps its
606
490
  // full key list so the less-obvious bindings stay discoverable; it leads
@@ -609,24 +493,18 @@ export class ConversationViewer {
609
493
  const actions = [];
610
494
  if (this.canSteer())
611
495
  actions.push(th.fg("dim", "Enter steer"));
612
- // Abbreviated for the same reason as the Markdown group below.
496
+ // Abbreviated: the idle footer is already full at 80 columns.
613
497
  if (this.canSendToBackground())
614
498
  actions.push(th.fg("dim", "b bg"));
615
499
  if (this.isStoppable()) {
616
500
  actions.push(this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop"));
617
501
  }
618
- // Abbreviated (`raw`/`md`/`md+`) because the idle footer is already full
619
- // at 80 columns with steer + stop present, and this group has no
620
- // degradation step below "drop the line-count readout".
621
- actions.push(th.fg("dim", `m ${MARKDOWN_MODE_LABELS[this.markdownMode()]}`));
502
+ const expand = this.expandHint();
622
503
  const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close");
623
504
  // Prepend the line-count/scroll-% readout only when there's spare width —
624
505
  // it's the first thing dropped so it never crowds out the hints.
625
506
  const count = th.fg("dim", `${totalLines} lines · ${scrollPct}`);
626
- const withCount = [count, ...actions].join(sep);
627
- const footerLeft = visibleWidth(withCount) + visibleWidth(footerRight) + 1 <= innerW
628
- ? withCount
629
- : actions.join(sep);
507
+ const footerLeft = fitFooterLeft(count, actions, expand && th.fg("dim", expand.label), sep, innerW - visibleWidth(footerRight) - 1);
630
508
  const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
631
509
  return row(footerLeft + " ".repeat(footerGap) + footerRight);
632
510
  }));
@@ -719,7 +597,7 @@ export class ConversationViewer {
719
597
  : `${Math.round(((visibleStart + viewportHeight) / totalLines) * 100)}%`;
720
598
  const footerKey = this.composer
721
599
  ? `${width}|composer`
722
- : `${width}|${this.canSteer()}|${this.canSendToBackground()}|${this.isStoppable()}|${this.stopArmed}|${this.markdownMode()}|${totalLines}|${scrollPct}`;
600
+ : `${width}|${this.canSteer()}|${this.canSendToBackground()}|${this.isStoppable()}|${this.stopArmed}|${this.transcript?.isExpanded}|${totalLines}|${scrollPct}`;
723
601
  const { footer, targets } = this.chrome("f:footer", footerKey, () => this.fullscreenFooter(innerW, totalLines, scrollPct, row));
724
602
  this.footerTargets = targets;
725
603
  // The footer stays on screen even in a terminal shorter than the chrome.
@@ -744,13 +622,14 @@ export class ConversationViewer {
744
622
  footerActions.push({ label: "b bg", key: "b" });
745
623
  if (this.isStoppable())
746
624
  footerActions.push({ label: this.stopArmed ? "x again to STOP" : "x stop", key: "x" });
747
- footerActions.push({ label: `m ${MARKDOWN_MODE_LABELS[this.markdownMode()]}`, key: "m" });
748
625
  const actions = footerActions.map(a => th.fg(a.key === "x" && this.stopArmed ? "error" : "dim", a.label));
626
+ const expand = this.expandHint();
627
+ if (expand)
628
+ footerActions.push(expand);
749
629
  footerActions.push({ label: "Esc close", key: "\x1b" });
750
630
  const footerRight = th.fg("dim", "wheel/↑↓ scroll · PgUp/PgDn · Esc close");
751
631
  const count = th.fg("dim", `${totalLines} lines · ${scrollPct}`);
752
- const withCount = [count, ...actions].join(sep);
753
- const footerLeft = visibleWidth(withCount) + visibleWidth(footerRight) + 1 <= innerW ? withCount : actions.join(sep);
632
+ const footerLeft = fitFooterLeft(count, actions, expand && th.fg("dim", expand.label), sep, innerW - visibleWidth(footerRight) - 1);
754
633
  footer = row(footerLeft + " ".repeat(Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight))) + footerRight);
755
634
  }
756
635
  const targets = [];
@@ -776,56 +655,17 @@ export class ConversationViewer {
776
655
  canSendToBackground() {
777
656
  return this.onBackground?.available() === true;
778
657
  }
779
- /** The mode in force: an `m` press, else the setting, else the default. */
780
- markdownMode() {
781
- return this.markdownModeOverride ?? this.viewerMarkdown?.() ?? "assistant";
782
- }
783
- /** Wrap `text` literally — the pre-Markdown path, and the fallback from it. */
784
- rawLines(text, width, dim) {
785
- const lines = wrapTextWithAnsi(sanitizeTerminalText(text), width);
786
- return dim ? lines.map(l => this.theme.fg("dim", l)) : lines;
658
+ /** Expand or collapse every tool, bash and summary block, as Pi's `app.tools.expand`. */
659
+ toggleExpand() {
660
+ this.stopArmed = false;
661
+ this.transcript?.setExpanded(!this.transcript.isExpanded);
662
+ this.tui.requestRender();
787
663
  }
788
- /** Render `text` as Markdown, reusing this message's component instance. */
789
- markdownLines(msg, raw, width, dim) {
790
- const text = sanitizeTerminalText(raw);
791
- let entry = this.markdownCache.get(msg);
792
- if (!entry) {
793
- entry = {
794
- md: new Markdown(text, 0, 0, this.markdownTheme,
795
- // Keeps result prose visually receded, the way the raw path's
796
- // per-line `fg("dim", …)` did. Fenced code is the exception and is
797
- // left alone deliberately: pi's theme highlights it with its own
798
- // colors, which this would otherwise flatten.
799
- dim ? { color: (t) => this.theme.fg("dim", t) } : undefined, MARKDOWN_OPTIONS),
800
- text,
801
- };
802
- this.markdownCache.set(msg, entry);
803
- }
804
- else if (entry.text !== text) {
805
- // Streaming: the message object is stable, its text grows. A failed
806
- // prefix remains unsafe after append-only deltas, so retry only when the
807
- // content was replaced or truncated.
808
- const shouldRetry = !text.startsWith(entry.text);
809
- entry.md.setText(text);
810
- entry.text = text;
811
- if (shouldRetry)
812
- entry.failed = false;
813
- }
814
- if (entry.failed)
815
- return this.rawLines(text, width, dim);
816
- try {
817
- return entry.md.render(width);
818
- }
819
- catch {
820
- // The parser is recursive and this is arbitrary tool output: ~54 nested
821
- // blockquotes overflow the stack, and no amount of fuzzing proves that is
822
- // the only such input. `render()` is on the TUI's critical path, so a
823
- // throw here takes the overlay down for content the literal path shows
824
- // fine — degrade to that instead, and remember, since the throw would
825
- // otherwise repeat on every render and every scroll key.
826
- entry.failed = true;
827
- return this.rawLines(text, width, dim);
828
- }
664
+ /** The expand toggle's footer control: the key bound to it and what it does next. */
665
+ expandHint() {
666
+ if (!this.transcript || !this.keys.expandLabel)
667
+ return undefined;
668
+ return { label: `${this.keys.expandLabel} ${this.transcript.isExpanded ? "collapse" : "expand"}`, key: EXPAND_TARGET };
829
669
  }
830
670
  /** Steerable only when a steer handler exists and the agent is still active. */
831
671
  canSteer() {
@@ -850,7 +690,9 @@ export class ConversationViewer {
850
690
  this.tui.requestRender();
851
691
  }
852
692
  invalidate() {
853
- this.cacheWidth = -1;
693
+ // Pi invalidates the TUI on a theme change: every block re-renders.
694
+ this.transcript?.invalidate();
695
+ this.plain = undefined;
854
696
  this.chromeCache.clear();
855
697
  }
856
698
  dispose() {
@@ -864,6 +706,7 @@ export class ConversationViewer {
864
706
  this.unsubscribe();
865
707
  this.unsubscribe = undefined;
866
708
  }
709
+ this.transcript?.dispose();
867
710
  }
868
711
  // ---- Private ----
869
712
  viewportHeight() {
@@ -898,174 +741,76 @@ export class ConversationViewer {
898
741
  return view.base.length + view.tail.length;
899
742
  }
900
743
  /**
901
- * The content area as cached transcript lines (`base`, owned by the viewer —
902
- * do not mutate) plus the live activity indicator (`tail`).
903
- *
904
- * Only messages that changed are rebuilt. A message is matched to its block
905
- * by identity; messages from `unsettledFrom` on (the last one when the
906
- * previous frame was drawn, and anything after it) are also compared by
907
- * content, because older pi streams into the last message in place and a
908
- * message can be edited and then pushed past before the next frame. Earlier
909
- * messages are settled: pi never rewrites a message once another follows it,
910
- * and compaction replaces the objects, which the identity check catches.
744
+ * The content area as cached transcript lines (`base`, owned by the
745
+ * transcript — do not mutate) with their widths, plus the live activity
746
+ * indicator (`tail`). Only blocks that changed are re-rendered, so a frame
747
+ * where nothing changed costs a few comparisons however long the transcript.
911
748
  */
912
749
  contentView(width) {
913
750
  if (width <= 0)
914
- return { base: [], tail: [] };
751
+ return { base: [], widths: undefined, tail: [] };
915
752
  const th = this.theme;
916
- const messages = this.session.messages;
917
- if (messages.length === 0) {
918
- return { base: [truncateToWidth(th.fg("dim", "(waiting for first message...)"), width)], tail: [] };
753
+ const transcript = this.transcript;
754
+ if (transcript ? !transcript.hasContent : this.session.messages.length === 0) {
755
+ return { base: [truncateToWidth(th.fg("dim", "(waiting for first message...)"), width)], widths: undefined, tail: [] };
919
756
  }
920
- const mode = this.markdownMode();
921
- if (width !== this.cacheWidth || mode !== this.cacheMode) {
922
- this.blocks = [];
923
- this.blockStart = [];
924
- this.lines = [];
925
- this.lineWidths = [];
926
- this.cacheWidth = width;
927
- this.cacheMode = mode;
928
- this.unsettledFrom = 0;
929
- this.cacheMessages = undefined;
757
+ let base;
758
+ let widths;
759
+ if (transcript) {
760
+ transcript.sync(width);
761
+ base = transcript.lines;
762
+ widths = transcript.widths;
930
763
  }
931
- // First block that no longer matches its message. The same array as last
932
- // frame has only grown at its end, so its settled part needs no check.
933
- let stale = Math.min(this.blocks.length, messages.length);
934
- const from = messages === this.cacheMessages && this.blocks.length <= messages.length ? Math.min(this.unsettledFrom, stale) : 0;
935
- this.cacheMessages = messages;
936
- for (let i = Math.max(0, from); i < stale; i++) {
937
- const block = this.blocks[i];
938
- if (block.msg !== messages[i] || (i >= this.unsettledFrom && block.source !== this.messageSource(messages[i]))) {
939
- stale = i;
940
- break;
941
- }
942
- }
943
- if (stale < this.blocks.length || stale < messages.length) {
944
- this.lines.length = stale < this.blockStart.length ? this.blockStart[stale] : this.lines.length;
945
- this.lineWidths.length = this.lines.length;
946
- this.blocks.length = stale;
947
- this.blockStart.length = stale;
948
- // Any line before the first rebuilt block means a block rendered there.
949
- let needsSeparator = this.lines.length > 0;
950
- const separator = truncateToWidth(th.fg("dim", "───"), width);
951
- const separatorWidth = visibleWidth(separator);
952
- for (let i = stale; i < messages.length; i++) {
953
- const msg = messages[i];
954
- const source = this.messageSource(msg);
955
- const body = this.messageLines(msg, width, mode);
956
- this.blocks.push({ msg, source, lines: body });
957
- this.blockStart.push(this.lines.length);
958
- if (!body)
959
- continue;
960
- if (needsSeparator) {
961
- this.lines.push(separator);
962
- this.lineWidths.push(separatorWidth);
963
- }
964
- for (const line of body) {
965
- this.lines.push(line);
966
- this.lineWidths.push(visibleWidth(line));
967
- }
968
- needsSeparator = true;
969
- }
764
+ else {
765
+ ({ lines: base, widths } = this.plainLines(width));
970
766
  }
971
- this.unsettledFrom = messages.length - 1;
972
767
  // Streaming indicator for running agents
973
768
  const tail = [];
974
769
  if (this.record.status === "running" && this.activity) {
975
770
  const act = describeActivity(this.activity.activeTools, this.activity.responseText);
976
771
  tail.push("", truncateToWidth(th.fg("accent", "▍ ") + th.fg("dim", sanitizeTerminalText(act)), width));
977
772
  }
978
- return { base: this.lines, tail };
773
+ return { base, widths, tail };
979
774
  }
980
775
  /**
981
- * Everything a message's rendered lines depend on, besides width and mode.
982
- * Cheap next to rendering: string joins, no wrapping or parsing.
776
+ * The transcript as plain wrapped text, for a process whose Pi theme is not
777
+ * initialized (Pi's components cannot render there). Never the case in Pi's
778
+ * interactive mode; it keeps the viewer usable rather than throwing.
983
779
  */
984
- messageSource(msg) {
985
- const m = msg;
986
- switch (m.role) {
987
- case "user":
988
- return typeof m.content === "string" ? m.content : extractText(m.content);
989
- case "assistant": {
990
- let out = "";
991
- for (const c of m.content) {
992
- if (c.type === "text" && c.text)
993
- out += `t${c.text.length}:${c.text}`;
994
- else if (c.type === "toolCall")
995
- out += `c${c.name ?? c.toolName ?? "unknown"}\n`;
996
- }
997
- return out;
998
- }
999
- case "toolResult":
1000
- return extractText(m.content);
1001
- case "bashExecution":
1002
- return `${String(m.command)}\n${m.output ?? ""}`;
1003
- default:
1004
- return "";
1005
- }
1006
- }
1007
- /** One message's lines without its separator, clamped to `width`; undefined when it renders nothing. */
1008
- messageLines(msg, width, mode) {
1009
- const th = this.theme;
780
+ plainLines(width) {
781
+ const messages = this.session.messages;
782
+ const cached = this.plain;
783
+ if (cached && cached.messages === messages && cached.count === messages.length && cached.width === width)
784
+ return cached;
1010
785
  const lines = [];
1011
- if (msg.role === "user") {
1012
- const text = typeof msg.content === "string"
1013
- ? msg.content
1014
- : extractText(msg.content);
1015
- if (!text.trim())
1016
- return undefined;
1017
- lines.push(th.fg("accent", "[User]"));
1018
- for (const line of wrapTextWithAnsi(sanitizeTerminalText(text.trim()), width)) {
1019
- lines.push(line);
786
+ for (const raw of messages) {
787
+ const msg = raw;
788
+ let text = "";
789
+ if (msg.role === "user" || msg.role === "toolResult" || msg.role === "custom") {
790
+ text = typeof msg.content === "string" ? msg.content : extractText(msg.content);
1020
791
  }
1021
- }
1022
- else if (msg.role === "assistant") {
1023
- const textParts = [];
1024
- const toolCalls = [];
1025
- for (const c of msg.content) {
1026
- if (c.type === "text" && c.text)
1027
- textParts.push(c.text);
1028
- else if (c.type === "toolCall") {
1029
- toolCalls.push(c.name ?? c.toolName ?? "unknown");
1030
- }
792
+ else if (msg.role === "assistant" && Array.isArray(msg.content)) {
793
+ text = msg.content
794
+ .map(c => c.type === "text" ? c.text ?? "" : c.type === "toolCall" ? `[${c.name ?? "tool"}]` : "")
795
+ .filter(Boolean)
796
+ .join("\n");
1031
797
  }
1032
- lines.push(th.bold("[Assistant]"));
1033
- if (textParts.length > 0) {
1034
- const text = textParts.join("\n").trim();
1035
- lines.push(...(mode === "off"
1036
- ? this.rawLines(text, width, false)
1037
- : this.markdownLines(msg, text, width, false)));
798
+ else if (msg.role === "bashExecution") {
799
+ text = `$ ${String(msg.command)}\n${String(msg.output ?? "")}`;
1038
800
  }
1039
- for (const name of toolCalls) {
1040
- lines.push(truncateToWidth(th.fg("muted", ` [Tool: ${sanitizeTerminalText(name)}]`), width));
801
+ else if (msg.role === "compactionSummary" || msg.role === "branchSummary") {
802
+ text = String(msg.summary ?? "");
1041
803
  }
1042
- }
1043
- else if (msg.role === "toolResult") {
1044
- const { text, elided } = capResult(extractText(msg.content).trim());
804
+ text = sanitizeTerminalText(text).trim();
1045
805
  if (!text)
1046
- return undefined;
1047
- lines.push(th.fg("dim", "[Result]"));
1048
- lines.push(...(mode === "all"
1049
- ? this.markdownLines(msg, text, width, true)
1050
- : this.rawLines(text, width, true)));
1051
- if (elided)
1052
- lines.push(truncateToWidth(th.fg("dim", truncationNote(elided)), width));
1053
- }
1054
- else if (msg.role === "bashExecution") {
1055
- const bash = msg;
1056
- lines.push(truncateToWidth(th.fg("muted", ` $ ${sanitizeTerminalText(String(bash.command))}`), width));
1057
- if (bash.output?.trim()) {
1058
- // Same cap as a tool result, never Markdown: command output is the one
1059
- // thing here that is definitionally not authored as Markdown.
1060
- const { text, elided } = capResult(bash.output.trim());
1061
- lines.push(...this.rawLines(text, width, true));
1062
- if (elided)
1063
- lines.push(truncateToWidth(th.fg("dim", truncationNote(elided)), width));
1064
- }
1065
- }
1066
- else {
1067
- return undefined;
1068
- }
1069
- return lines.map(l => truncateToWidth(l, width));
806
+ continue;
807
+ if (lines.length > 0)
808
+ lines.push("");
809
+ for (const line of wrapTextWithAnsi(text, width))
810
+ lines.push(truncateToWidth(line, width));
811
+ }
812
+ const plain = { messages, count: messages.length, width, lines, widths: lines.map(l => visibleWidth(l)) };
813
+ this.plain = plain;
814
+ return plain;
1070
815
  }
1071
816
  }