@herbertgao/pi-subagents 0.16.1 → 0.17.1

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,121 @@ 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 humanCount(count: number): string {
160
+ if (count < 1_000) return `${count}`
161
+ const thousands = count < 999_950
162
+ const value = thousands ? count / 1_000 : count / 1_000_000
163
+ return `${value.toFixed(1).replace(/\.0$/, "")}${thousands ? "k" : "M"}`
164
+ }
165
+
166
+ function truncationNote(elided: number): string {
167
+ return `... (truncated, ${humanCount(elided)} more character${elided === 1 ? "" : "s"})`
168
+ }
169
+
49
170
  export class ConversationViewer implements Component {
50
171
  private scrollOffset = 0
51
172
  private autoScroll = true
@@ -57,6 +178,20 @@ export class ConversationViewer implements Component {
57
178
  private keys: ViewerKeys
58
179
  /** Steering composer — present while the user is typing a message to the agent. */
59
180
  private composer: Input | undefined
181
+ /** Resolved once: pi's Markdown theme is fixed for the life of the process. */
182
+ private readonly markdownTheme: MarkdownTheme
183
+ /** Set by the `m` key. Wins over the setting so `m` works without a persist hook. */
184
+ private markdownModeOverride: ViewerMarkdownMode | undefined
185
+ /**
186
+ * One `Markdown` per message, so its own text/width cache does the work. A
187
+ * fresh instance per render would re-parse the whole transcript on every
188
+ * keystroke — the component caches, but only across calls to the same object.
189
+ * Weak so a compacted-away message doesn't pin its render.
190
+ */
191
+ private readonly markdownCache = new WeakMap<
192
+ object,
193
+ { md: Markdown; text: string; failed?: boolean }
194
+ >()
60
195
 
61
196
  constructor(
62
197
  private tui: TUI,
@@ -77,7 +212,19 @@ export class ConversationViewer implements Component {
77
212
  * cannot change while it is on screen.
78
213
  */
79
214
  private showCost = false,
215
+ /**
216
+ * The current `viewerMarkdown` setting. Read live rather than captured,
217
+ * unlike `showCost`: `m` changes it while the overlay is on screen.
218
+ * Omitted → `assistant`.
219
+ */
220
+ private viewerMarkdown?: () => ViewerMarkdownMode,
221
+ /**
222
+ * Persist a mode chosen with `m`, so the key and `/agents → Settings` mean
223
+ * the same thing. Omitted → `m` still cycles, viewer-locally.
224
+ */
225
+ private onMarkdownMode?: (mode: ViewerMarkdownMode) => void,
80
226
  ) {
227
+ this.markdownTheme = resolveMarkdownTheme(theme)
81
228
  this.keys = createViewerKeys(keybindings)
82
229
  this.unsubscribe = session.subscribe(() => {
83
230
  if (this.closed) return
@@ -127,6 +274,22 @@ export class ConversationViewer implements Component {
127
274
  }
128
275
  return
129
276
  }
277
+
278
+ // Cycle raw → assistant-only → everything. The escape hatch that makes
279
+ // Markdown rendering safe to default on: a result the renderer reshapes
280
+ // (a diff, an indented log, a `#`-commented script) is one key from verbatim.
281
+ if (matchesKey(data, "m")) {
282
+ this.stopArmed = false
283
+ const next =
284
+ MARKDOWN_MODES[
285
+ (MARKDOWN_MODES.indexOf(this.markdownMode()) + 1) %
286
+ MARKDOWN_MODES.length
287
+ ]
288
+ this.markdownModeOverride = next
289
+ this.onMarkdownMode?.(next)
290
+ this.tui.requestRender()
291
+ return
292
+ }
130
293
  if (this.stopArmed) this.stopArmed = false
131
294
 
132
295
  const totalLines = this.buildContentLines(this.lastInnerW).length
@@ -268,6 +431,12 @@ export class ConversationViewer implements Component {
268
431
  : th.fg("dim", "x stop"),
269
432
  )
270
433
  }
434
+ // Abbreviated (`raw`/`md`/`md+`) because the idle footer is already full
435
+ // at 80 columns with steer + stop present, and this group has no
436
+ // degradation step below "drop the line-count readout".
437
+ actions.push(
438
+ th.fg("dim", `m ${MARKDOWN_MODE_LABELS[this.markdownMode()]}`),
439
+ )
271
440
  const footerRight = th.fg(
272
441
  "dim",
273
442
  "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close",
@@ -305,6 +474,67 @@ export class ConversationViewer implements Component {
305
474
  )
306
475
  }
307
476
 
477
+ /** The mode in force: an `m` press, else the setting, else the default. */
478
+ private markdownMode(): ViewerMarkdownMode {
479
+ return this.markdownModeOverride ?? this.viewerMarkdown?.() ?? "assistant"
480
+ }
481
+
482
+ /** Wrap `text` literally — the pre-Markdown path, and the fallback from it. */
483
+ private rawLines(text: string, width: number, dim: boolean): string[] {
484
+ const lines = wrapTextWithAnsi(text, width)
485
+ return dim ? lines.map((l) => this.theme.fg("dim", l)) : lines
486
+ }
487
+
488
+ /** Render `text` as Markdown, reusing this message's component instance. */
489
+ private markdownLines(
490
+ msg: AgentSession["messages"][number],
491
+ text: string,
492
+ width: number,
493
+ dim: boolean,
494
+ ): string[] {
495
+ let entry = this.markdownCache.get(msg)
496
+ if (!entry) {
497
+ entry = {
498
+ md: new Markdown(
499
+ text,
500
+ 0,
501
+ 0,
502
+ this.markdownTheme,
503
+ // Keeps result prose visually receded, the way the raw path's
504
+ // per-line `fg("dim", …)` did. Fenced code is the exception and is
505
+ // left alone deliberately: pi's theme highlights it with its own
506
+ // colors, which this would otherwise flatten.
507
+ dim ? { color: (t: string) => this.theme.fg("dim", t) } : undefined,
508
+ MARKDOWN_OPTIONS,
509
+ ),
510
+ text,
511
+ }
512
+ this.markdownCache.set(msg, entry)
513
+ } else if (entry.text !== text) {
514
+ // Streaming: the message object is stable, its text grows. A failed
515
+ // prefix remains unsafe after append-only deltas, so retry only when the
516
+ // content was replaced or truncated.
517
+ const shouldRetry = !text.startsWith(entry.text)
518
+ entry.md.setText(text)
519
+ entry.text = text
520
+ if (shouldRetry) entry.failed = false
521
+ }
522
+ if (entry.failed) return this.rawLines(text, width, dim)
523
+
524
+ try {
525
+ return entry.md.render(width)
526
+ } catch {
527
+ // The parser is recursive and this is arbitrary tool output: ~54 nested
528
+ // blockquotes overflow the stack, and no amount of fuzzing proves that is
529
+ // the only such input. `render()` is on the TUI's critical path, so a
530
+ // throw here takes the overlay down for content the literal path shows
531
+ // fine — degrade to that instead, and remember, since the throw would
532
+ // otherwise repeat on every render and every scroll key.
533
+ entry.failed = true
534
+ return this.rawLines(text, width, dim)
535
+ }
536
+ }
537
+
308
538
  /** Steerable only when a steer handler exists and the agent is still active. */
309
539
  private canSteer(): boolean {
310
540
  return (
@@ -364,8 +594,14 @@ export class ConversationViewer implements Component {
364
594
  }
365
595
 
366
596
  private invocationLine(): string | undefined {
367
- const { modelName, tags } = buildInvocationTags(this.record.invocation)
368
- const parts = modelName ? [modelName, ...tags] : tags
597
+ // Canonical id here, short label everywhere else: this overlay is opened to
598
+ // inspect one agent and has the width for it, and two providers can serve
599
+ // models whose short names read alike.
600
+ const { modelName, modelId, tags } = buildInvocationTags(
601
+ this.record.invocation,
602
+ )
603
+ const model = modelId ?? modelName
604
+ const parts = model ? [model, ...tags] : tags
369
605
  if (parts.length === 0) return undefined
370
606
  return this.theme.fg("dim", ` ↳ ${parts.join(" · ")}`)
371
607
  }
@@ -382,6 +618,7 @@ export class ConversationViewer implements Component {
382
618
  return lines
383
619
  }
384
620
 
621
+ const mode = this.markdownMode()
385
622
  let needsSeparator = false
386
623
  for (const msg of messages) {
387
624
  if (msg.role === "user") {
@@ -407,12 +644,12 @@ export class ConversationViewer implements Component {
407
644
  if (needsSeparator) lines.push(th.fg("dim", "───"))
408
645
  lines.push(th.bold("[Assistant]"))
409
646
  if (textParts.length > 0) {
410
- for (const line of wrapTextWithAnsi(
411
- textParts.join("\n").trim(),
412
- width,
413
- )) {
414
- lines.push(line)
415
- }
647
+ const text = textParts.join("\n").trim()
648
+ lines.push(
649
+ ...(mode === "off"
650
+ ? this.rawLines(text, width, false)
651
+ : this.markdownLines(msg, text, width, false)),
652
+ )
416
653
  }
417
654
  for (const name of toolCalls) {
418
655
  lines.push(
@@ -420,15 +657,19 @@ export class ConversationViewer implements Component {
420
657
  )
421
658
  }
422
659
  } 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
660
+ const { text, elided } = capResult(extractText(msg.content).trim())
661
+ if (!text) continue
427
662
  if (needsSeparator) lines.push(th.fg("dim", "───"))
428
663
  lines.push(th.fg("dim", "[Result]"))
429
- for (const line of wrapTextWithAnsi(truncated.trim(), width)) {
430
- lines.push(th.fg("dim", line))
431
- }
664
+ lines.push(
665
+ ...(mode === "all"
666
+ ? this.markdownLines(msg, text, width, true)
667
+ : this.rawLines(text, width, true)),
668
+ )
669
+ if (elided)
670
+ lines.push(
671
+ truncateToWidth(th.fg("dim", truncationNote(elided)), width),
672
+ )
432
673
  } else if ((msg as any).role === "bashExecution") {
433
674
  const bash = msg as any
434
675
  if (needsSeparator) lines.push(th.fg("dim", "───"))
@@ -436,13 +677,14 @@ export class ConversationViewer implements Component {
436
677
  truncateToWidth(th.fg("muted", ` $ ${bash.command}`), width),
437
678
  )
438
679
  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
- }
680
+ // Same cap as a tool result, never Markdown: command output is the one
681
+ // thing here that is definitionally not authored as Markdown.
682
+ const { text, elided } = capResult(bash.output.trim())
683
+ lines.push(...this.rawLines(text, width, true))
684
+ if (elided)
685
+ lines.push(
686
+ truncateToWidth(th.fg("dim", truncationNote(elided)), width),
687
+ )
446
688
  }
447
689
  } else {
448
690
  continue