@herbertgao/pi-subagents 0.16.1 → 0.17.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.
@@ -5,10 +5,16 @@
5
5
  * Subscribes to session events for real-time streaming updates.
6
6
  */
7
7
 
8
- import type { AgentSession } from "@earendil-works/pi-coding-agent"
8
+ import {
9
+ type AgentSession,
10
+ getMarkdownTheme,
11
+ } from "@earendil-works/pi-coding-agent"
9
12
  import {
10
13
  type Component,
11
14
  Input,
15
+ Markdown,
16
+ type MarkdownOptions,
17
+ type MarkdownTheme,
12
18
  matchesKey,
13
19
  type TUI,
14
20
  truncateToWidth,
@@ -17,7 +23,7 @@ import {
17
23
  } from "@earendil-works/pi-tui"
18
24
  import { renderAgentName } from "../agent-color.js"
19
25
  import { extractText } from "../context.js"
20
- import type { AgentRecord } from "../types.js"
26
+ import type { AgentRecord, ViewerMarkdownMode } from "../types.js"
21
27
  import {
22
28
  getLifetimeCost,
23
29
  getLifetimeTotal,
@@ -46,6 +52,114 @@ const MIN_VIEWPORT = 3
46
52
  /** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
47
53
  export const VIEWPORT_HEIGHT_PCT = 70
48
54
 
55
+ /**
56
+ * Cap on a single tool result or bash output before the viewer elides the rest.
57
+ *
58
+ * The cap is not cosmetic — it bounds render cost. `buildContentLines()` runs on
59
+ * every render *and* on every scroll key (`handleInput` calls it to compute
60
+ * `maxScroll`), so an uncapped 200 KB result costs ~6 ms per keystroke to parse
61
+ * as Markdown, against ~0.5 ms once capped and effectively nothing on a cache
62
+ * hit (best of 5, width 76). 16 KB is roughly a screenful at every terminal size
63
+ * and still ~30x the 500 characters this replaces, which was small enough to cut
64
+ * most real results mid-sentence.
65
+ */
66
+ export const RESULT_MAX_CHARS = 16_000
67
+
68
+ /** Cycle order for the viewer's `m` key. */
69
+ const MARKDOWN_MODES: readonly ViewerMarkdownMode[] = [
70
+ "off",
71
+ "assistant",
72
+ "all",
73
+ ]
74
+
75
+ /** Footer labels — short, because the idle footer is already full at 80 columns. */
76
+ const MARKDOWN_MODE_LABELS: Record<ViewerMarkdownMode, string> = {
77
+ off: "raw",
78
+ assistant: "md",
79
+ all: "md+",
80
+ }
81
+
82
+ /**
83
+ * Both options keep the renderer from *rewriting* source that only looks like
84
+ * Markdown: without them `3) a / 7) b / 9) c` comes back renumbered `3. 4. 5.`
85
+ * and backslash escapes are normalized away. Neither is a safe edit to make to
86
+ * a tool's output, and both are cheap to switch off.
87
+ */
88
+ const MARKDOWN_OPTIONS: MarkdownOptions = {
89
+ preserveOrderedListMarkers: true,
90
+ preserveBackslashEscapes: true,
91
+ }
92
+
93
+ /**
94
+ * Pi's own Markdown theme when this process has one, else a theme built from the
95
+ * viewer's `Theme`.
96
+ *
97
+ * Preferring pi's is what buys syntax-highlighted code fences (it carries a
98
+ * `highlightCode`), and it keeps this surface consistent with the notification
99
+ * renderer, which uses the same source. It has to be *probed* rather than
100
+ * try/caught around the call: `getMarkdownTheme()` returns arrow functions that
101
+ * read pi's global theme lazily, so an uninitialized theme throws inside
102
+ * `render()` — long after this returns — and takes the overlay with it. That is
103
+ * the case in tests and any embedded session that never called `initTheme()`.
104
+ */
105
+ function resolveMarkdownTheme(th: Theme): MarkdownTheme {
106
+ try {
107
+ const piTheme = getMarkdownTheme()
108
+ piTheme.heading("probe")
109
+ return piTheme
110
+ } catch {
111
+ return fallbackMarkdownTheme(th)
112
+ }
113
+ }
114
+
115
+ /**
116
+ * `Theme` carries only `fg` and `bold`, so the three remaining styles are
117
+ * written as raw SGR. Rendering them as plain text instead would silently drop
118
+ * `*emphasis*`'s markers with nothing in their place, turning a formatting
119
+ * change into a content change.
120
+ */
121
+ function fallbackMarkdownTheme(th: Theme): MarkdownTheme {
122
+ const sgr = (on: number, off: number) => (text: string) =>
123
+ `\x1b[${on}m${text}\x1b[${off}m`
124
+ return {
125
+ heading: (text) => th.bold(th.fg("accent", text)),
126
+ link: (text) => th.fg("accent", text),
127
+ linkUrl: (text) => th.fg("muted", text),
128
+ code: (text) => th.fg("muted", text),
129
+ codeBlock: (text) => th.fg("muted", text),
130
+ codeBlockBorder: (text) => th.fg("dim", text),
131
+ quote: (text) => th.fg("muted", text),
132
+ quoteBorder: (text) => th.fg("dim", text),
133
+ hr: (text) => th.fg("dim", text),
134
+ listBullet: (text) => th.fg("accent", text),
135
+ bold: (text) => th.bold(text),
136
+ italic: sgr(3, 23),
137
+ underline: sgr(4, 24),
138
+ strikethrough: sgr(9, 29),
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Cap `text` at `RESULT_MAX_CHARS`, reporting the elision separately rather than
144
+ * appending it.
145
+ *
146
+ * Separately because the notice is the viewer's chrome, not the tool's output.
147
+ * Appended into the string it becomes content: a cut landing inside a fenced
148
+ * code block — likely, on exactly the large `ctx_execute` results this is for —
149
+ * renders the notice as a line of source inside the fence.
150
+ */
151
+ function capResult(text: string): { text: string; elided: number } {
152
+ if (text.length <= RESULT_MAX_CHARS) return { text, elided: 0 }
153
+ return {
154
+ text: text.slice(0, RESULT_MAX_CHARS),
155
+ elided: text.length - RESULT_MAX_CHARS,
156
+ }
157
+ }
158
+
159
+ function truncationNote(elided: number): string {
160
+ return `... (truncated, ${elided} more character${elided === 1 ? "" : "s"})`
161
+ }
162
+
49
163
  export class ConversationViewer implements Component {
50
164
  private scrollOffset = 0
51
165
  private autoScroll = true
@@ -57,6 +171,20 @@ export class ConversationViewer implements Component {
57
171
  private keys: ViewerKeys
58
172
  /** Steering composer — present while the user is typing a message to the agent. */
59
173
  private composer: Input | undefined
174
+ /** Resolved once: pi's Markdown theme is fixed for the life of the process. */
175
+ private readonly markdownTheme: MarkdownTheme
176
+ /** Set by the `m` key. Wins over the setting so `m` works without a persist hook. */
177
+ private markdownModeOverride: ViewerMarkdownMode | undefined
178
+ /**
179
+ * One `Markdown` per message, so its own text/width cache does the work. A
180
+ * fresh instance per render would re-parse the whole transcript on every
181
+ * keystroke — the component caches, but only across calls to the same object.
182
+ * Weak so a compacted-away message doesn't pin its render.
183
+ */
184
+ private readonly markdownCache = new WeakMap<
185
+ object,
186
+ { md: Markdown; text: string; failed?: boolean }
187
+ >()
60
188
 
61
189
  constructor(
62
190
  private tui: TUI,
@@ -77,7 +205,19 @@ export class ConversationViewer implements Component {
77
205
  * cannot change while it is on screen.
78
206
  */
79
207
  private showCost = false,
208
+ /**
209
+ * The current `viewerMarkdown` setting. Read live rather than captured,
210
+ * unlike `showCost`: `m` changes it while the overlay is on screen.
211
+ * Omitted → `assistant`.
212
+ */
213
+ private viewerMarkdown?: () => ViewerMarkdownMode,
214
+ /**
215
+ * Persist a mode chosen with `m`, so the key and `/agents → Settings` mean
216
+ * the same thing. Omitted → `m` still cycles, viewer-locally.
217
+ */
218
+ private onMarkdownMode?: (mode: ViewerMarkdownMode) => void,
80
219
  ) {
220
+ this.markdownTheme = resolveMarkdownTheme(theme)
81
221
  this.keys = createViewerKeys(keybindings)
82
222
  this.unsubscribe = session.subscribe(() => {
83
223
  if (this.closed) return
@@ -127,6 +267,22 @@ export class ConversationViewer implements Component {
127
267
  }
128
268
  return
129
269
  }
270
+
271
+ // Cycle raw → assistant-only → everything. The escape hatch that makes
272
+ // Markdown rendering safe to default on: a result the renderer reshapes
273
+ // (a diff, an indented log, a `#`-commented script) is one key from verbatim.
274
+ if (matchesKey(data, "m")) {
275
+ this.stopArmed = false
276
+ const next =
277
+ MARKDOWN_MODES[
278
+ (MARKDOWN_MODES.indexOf(this.markdownMode()) + 1) %
279
+ MARKDOWN_MODES.length
280
+ ]
281
+ this.markdownModeOverride = next
282
+ this.onMarkdownMode?.(next)
283
+ this.tui.requestRender()
284
+ return
285
+ }
130
286
  if (this.stopArmed) this.stopArmed = false
131
287
 
132
288
  const totalLines = this.buildContentLines(this.lastInnerW).length
@@ -268,6 +424,12 @@ export class ConversationViewer implements Component {
268
424
  : th.fg("dim", "x stop"),
269
425
  )
270
426
  }
427
+ // Abbreviated (`raw`/`md`/`md+`) because the idle footer is already full
428
+ // at 80 columns with steer + stop present, and this group has no
429
+ // degradation step below "drop the line-count readout".
430
+ actions.push(
431
+ th.fg("dim", `m ${MARKDOWN_MODE_LABELS[this.markdownMode()]}`),
432
+ )
271
433
  const footerRight = th.fg(
272
434
  "dim",
273
435
  "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close",
@@ -305,6 +467,67 @@ export class ConversationViewer implements Component {
305
467
  )
306
468
  }
307
469
 
470
+ /** The mode in force: an `m` press, else the setting, else the default. */
471
+ private markdownMode(): ViewerMarkdownMode {
472
+ return this.markdownModeOverride ?? this.viewerMarkdown?.() ?? "assistant"
473
+ }
474
+
475
+ /** Wrap `text` literally — the pre-Markdown path, and the fallback from it. */
476
+ private rawLines(text: string, width: number, dim: boolean): string[] {
477
+ const lines = wrapTextWithAnsi(text, width)
478
+ return dim ? lines.map((l) => this.theme.fg("dim", l)) : lines
479
+ }
480
+
481
+ /** Render `text` as Markdown, reusing this message's component instance. */
482
+ private markdownLines(
483
+ msg: AgentSession["messages"][number],
484
+ text: string,
485
+ width: number,
486
+ dim: boolean,
487
+ ): string[] {
488
+ let entry = this.markdownCache.get(msg)
489
+ if (!entry) {
490
+ entry = {
491
+ md: new Markdown(
492
+ text,
493
+ 0,
494
+ 0,
495
+ this.markdownTheme,
496
+ // Keeps result prose visually receded, the way the raw path's
497
+ // per-line `fg("dim", …)` did. Fenced code is the exception and is
498
+ // left alone deliberately: pi's theme highlights it with its own
499
+ // colors, which this would otherwise flatten.
500
+ dim ? { color: (t: string) => this.theme.fg("dim", t) } : undefined,
501
+ MARKDOWN_OPTIONS,
502
+ ),
503
+ text,
504
+ }
505
+ this.markdownCache.set(msg, entry)
506
+ } else if (entry.text !== text) {
507
+ // Streaming: the message object is stable, its text grows. A failed
508
+ // prefix remains unsafe after append-only deltas, so retry only when the
509
+ // content was replaced or truncated.
510
+ const shouldRetry = !text.startsWith(entry.text)
511
+ entry.md.setText(text)
512
+ entry.text = text
513
+ if (shouldRetry) entry.failed = false
514
+ }
515
+ if (entry.failed) return this.rawLines(text, width, dim)
516
+
517
+ try {
518
+ return entry.md.render(width)
519
+ } catch {
520
+ // The parser is recursive and this is arbitrary tool output: ~54 nested
521
+ // blockquotes overflow the stack, and no amount of fuzzing proves that is
522
+ // the only such input. `render()` is on the TUI's critical path, so a
523
+ // throw here takes the overlay down for content the literal path shows
524
+ // fine — degrade to that instead, and remember, since the throw would
525
+ // otherwise repeat on every render and every scroll key.
526
+ entry.failed = true
527
+ return this.rawLines(text, width, dim)
528
+ }
529
+ }
530
+
308
531
  /** Steerable only when a steer handler exists and the agent is still active. */
309
532
  private canSteer(): boolean {
310
533
  return (
@@ -364,8 +587,14 @@ export class ConversationViewer implements Component {
364
587
  }
365
588
 
366
589
  private invocationLine(): string | undefined {
367
- const { modelName, tags } = buildInvocationTags(this.record.invocation)
368
- const parts = modelName ? [modelName, ...tags] : tags
590
+ // Canonical id here, short label everywhere else: this overlay is opened to
591
+ // inspect one agent and has the width for it, and two providers can serve
592
+ // models whose short names read alike.
593
+ const { modelName, modelId, tags } = buildInvocationTags(
594
+ this.record.invocation,
595
+ )
596
+ const model = modelId ?? modelName
597
+ const parts = model ? [model, ...tags] : tags
369
598
  if (parts.length === 0) return undefined
370
599
  return this.theme.fg("dim", ` ↳ ${parts.join(" · ")}`)
371
600
  }
@@ -382,6 +611,7 @@ export class ConversationViewer implements Component {
382
611
  return lines
383
612
  }
384
613
 
614
+ const mode = this.markdownMode()
385
615
  let needsSeparator = false
386
616
  for (const msg of messages) {
387
617
  if (msg.role === "user") {
@@ -407,12 +637,12 @@ export class ConversationViewer implements Component {
407
637
  if (needsSeparator) lines.push(th.fg("dim", "───"))
408
638
  lines.push(th.bold("[Assistant]"))
409
639
  if (textParts.length > 0) {
410
- for (const line of wrapTextWithAnsi(
411
- textParts.join("\n").trim(),
412
- width,
413
- )) {
414
- lines.push(line)
415
- }
640
+ const text = textParts.join("\n").trim()
641
+ lines.push(
642
+ ...(mode === "off"
643
+ ? this.rawLines(text, width, false)
644
+ : this.markdownLines(msg, text, width, false)),
645
+ )
416
646
  }
417
647
  for (const name of toolCalls) {
418
648
  lines.push(
@@ -420,15 +650,19 @@ export class ConversationViewer implements Component {
420
650
  )
421
651
  }
422
652
  } else if (msg.role === "toolResult") {
423
- const text = extractText(msg.content)
424
- const truncated =
425
- text.length > 500 ? text.slice(0, 500) + "... (truncated)" : text
426
- if (!truncated.trim()) continue
653
+ const { text, elided } = capResult(extractText(msg.content).trim())
654
+ if (!text) continue
427
655
  if (needsSeparator) lines.push(th.fg("dim", "───"))
428
656
  lines.push(th.fg("dim", "[Result]"))
429
- for (const line of wrapTextWithAnsi(truncated.trim(), width)) {
430
- lines.push(th.fg("dim", line))
431
- }
657
+ lines.push(
658
+ ...(mode === "all"
659
+ ? this.markdownLines(msg, text, width, true)
660
+ : this.rawLines(text, width, true)),
661
+ )
662
+ if (elided)
663
+ lines.push(
664
+ truncateToWidth(th.fg("dim", truncationNote(elided)), width),
665
+ )
432
666
  } else if ((msg as any).role === "bashExecution") {
433
667
  const bash = msg as any
434
668
  if (needsSeparator) lines.push(th.fg("dim", "───"))
@@ -436,13 +670,14 @@ export class ConversationViewer implements Component {
436
670
  truncateToWidth(th.fg("muted", ` $ ${bash.command}`), width),
437
671
  )
438
672
  if (bash.output?.trim()) {
439
- const out =
440
- bash.output.length > 500
441
- ? bash.output.slice(0, 500) + "... (truncated)"
442
- : bash.output
443
- for (const line of wrapTextWithAnsi(out.trim(), width)) {
444
- lines.push(th.fg("dim", line))
445
- }
673
+ // Same cap as a tool result, never Markdown: command output is the one
674
+ // thing here that is definitionally not authored as Markdown.
675
+ const { text, elided } = capResult(bash.output.trim())
676
+ lines.push(...this.rawLines(text, width, true))
677
+ if (elided)
678
+ lines.push(
679
+ truncateToWidth(th.fg("dim", truncationNote(elided)), width),
680
+ )
446
681
  }
447
682
  } else {
448
683
  continue