@herbertgao/pi-subagents 0.16.0 → 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.
@@ -226,18 +226,31 @@ export function getPromptModeLabel(type: SubagentType): string | undefined {
226
226
  /** Mode label is not included — callers add it where they want it. */
227
227
  export function buildInvocationTags(invocation: AgentInvocation | undefined): {
228
228
  modelName?: string
229
+ modelId?: string
229
230
  tags: string[]
230
231
  } {
231
232
  const tags: string[] = []
232
233
  if (!invocation) return { tags }
233
- if (invocation.thinking) tags.push(`thinking: ${invocation.thinking}`)
234
+ const asked = (
235
+ value: string | undefined,
236
+ requested: string | undefined,
237
+ ): string | undefined =>
238
+ value && requested && requested !== value
239
+ ? `${value} (asked ${requested})`
240
+ : value
241
+ const thinking = asked(invocation.thinking, invocation.requestedThinking)
242
+ if (thinking) tags.push(`thinking: ${thinking}`)
234
243
  if (invocation.isolated) tags.push("isolated")
235
244
  if (invocation.isolation === "worktree") tags.push("worktree")
236
245
  if (invocation.inheritContext) tags.push("inherit context")
237
246
  if (invocation.runInBackground) tags.push("background")
238
247
  if (invocation.maxTurns != null)
239
248
  tags.push(`max turns: ${invocation.maxTurns}`)
240
- return { modelName: invocation.modelName, tags }
249
+ return {
250
+ modelName: asked(invocation.modelName, invocation.requestedModel),
251
+ modelId: asked(invocation.modelId, invocation.requestedModel),
252
+ tags,
253
+ }
241
254
  }
242
255
 
243
256
  /** Truncate text to a single line, max `len` chars. */
@@ -317,6 +330,8 @@ export class AgentWidget {
317
330
  * supplies the user's `showCost` setting.
318
331
  */
319
332
  private showCost: () => boolean = () => false,
333
+ /** Whether running rows show the model and thinking level. */
334
+ private showModel: () => boolean = () => false,
320
335
  ) {}
321
336
 
322
337
  /**
@@ -526,6 +541,12 @@ export class AgentWidget {
526
541
  : ""
527
542
 
528
543
  const parts: string[] = []
544
+ if (this.showModel()) {
545
+ const { modelName, tags } = buildInvocationTags(a.invocation)
546
+ if (modelName) parts.push(modelName)
547
+ const thinkingTag = tags.find((tag) => tag.startsWith("thinking: "))
548
+ if (thinkingTag) parts.push(thinkingTag)
549
+ }
529
550
  if (bg) parts.push(formatTurns(bg.turnCount, bg.maxTurns))
530
551
  if (toolUses > 0)
531
552
  parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`)
@@ -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
@@ -94,7 +234,11 @@ export class ConversationViewer implements Component {
94
234
  return
95
235
  }
96
236
 
97
- if (matchesKey(data, "escape") || matchesKey(data, "q")) {
237
+ if (
238
+ matchesKey(data, "escape") ||
239
+ matchesKey(data, "ctrl+c") ||
240
+ matchesKey(data, "q")
241
+ ) {
98
242
  this.closed = true
99
243
  this.done(undefined)
100
244
  return
@@ -123,6 +267,22 @@ export class ConversationViewer implements Component {
123
267
  }
124
268
  return
125
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
+ }
126
286
  if (this.stopArmed) this.stopArmed = false
127
287
 
128
288
  const totalLines = this.buildContentLines(this.lastInnerW).length
@@ -264,6 +424,12 @@ export class ConversationViewer implements Component {
264
424
  : th.fg("dim", "x stop"),
265
425
  )
266
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
+ )
267
433
  const footerRight = th.fg(
268
434
  "dim",
269
435
  "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close",
@@ -301,6 +467,67 @@ export class ConversationViewer implements Component {
301
467
  )
302
468
  }
303
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
+
304
531
  /** Steerable only when a steer handler exists and the agent is still active. */
305
532
  private canSteer(): boolean {
306
533
  return (
@@ -360,8 +587,14 @@ export class ConversationViewer implements Component {
360
587
  }
361
588
 
362
589
  private invocationLine(): string | undefined {
363
- const { modelName, tags } = buildInvocationTags(this.record.invocation)
364
- 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
365
598
  if (parts.length === 0) return undefined
366
599
  return this.theme.fg("dim", ` ↳ ${parts.join(" · ")}`)
367
600
  }
@@ -378,6 +611,7 @@ export class ConversationViewer implements Component {
378
611
  return lines
379
612
  }
380
613
 
614
+ const mode = this.markdownMode()
381
615
  let needsSeparator = false
382
616
  for (const msg of messages) {
383
617
  if (msg.role === "user") {
@@ -403,12 +637,12 @@ export class ConversationViewer implements Component {
403
637
  if (needsSeparator) lines.push(th.fg("dim", "───"))
404
638
  lines.push(th.bold("[Assistant]"))
405
639
  if (textParts.length > 0) {
406
- for (const line of wrapTextWithAnsi(
407
- textParts.join("\n").trim(),
408
- width,
409
- )) {
410
- lines.push(line)
411
- }
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
+ )
412
646
  }
413
647
  for (const name of toolCalls) {
414
648
  lines.push(
@@ -416,15 +650,19 @@ export class ConversationViewer implements Component {
416
650
  )
417
651
  }
418
652
  } else if (msg.role === "toolResult") {
419
- const text = extractText(msg.content)
420
- const truncated =
421
- text.length > 500 ? text.slice(0, 500) + "... (truncated)" : text
422
- if (!truncated.trim()) continue
653
+ const { text, elided } = capResult(extractText(msg.content).trim())
654
+ if (!text) continue
423
655
  if (needsSeparator) lines.push(th.fg("dim", "───"))
424
656
  lines.push(th.fg("dim", "[Result]"))
425
- for (const line of wrapTextWithAnsi(truncated.trim(), width)) {
426
- lines.push(th.fg("dim", line))
427
- }
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
+ )
428
666
  } else if ((msg as any).role === "bashExecution") {
429
667
  const bash = msg as any
430
668
  if (needsSeparator) lines.push(th.fg("dim", "───"))
@@ -432,13 +670,14 @@ export class ConversationViewer implements Component {
432
670
  truncateToWidth(th.fg("muted", ` $ ${bash.command}`), width),
433
671
  )
434
672
  if (bash.output?.trim()) {
435
- const out =
436
- bash.output.length > 500
437
- ? bash.output.slice(0, 500) + "... (truncated)"
438
- : bash.output
439
- for (const line of wrapTextWithAnsi(out.trim(), width)) {
440
- lines.push(th.fg("dim", line))
441
- }
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
+ )
442
681
  }
443
682
  } else {
444
683
  continue