@herbertgao/pi-subagents 0.19.0 → 0.22.0-local.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.
@@ -11,12 +11,19 @@ import {
11
11
  } from "@earendil-works/pi-coding-agent"
12
12
  import {
13
13
  type Component,
14
+ type Focusable,
14
15
  Input,
15
16
  Markdown,
16
17
  type MarkdownOptions,
17
18
  type MarkdownTheme,
18
19
  matchesKey,
20
+ type OverlayOptions,
21
+ ScrollView,
22
+ type ScrollViewScrollbar,
23
+ stripTerminalSequences,
19
24
  type TUI,
25
+ type TuiMouseEvent,
26
+ type TuiMouseEventResult,
20
27
  truncateToWidth,
21
28
  visibleWidth,
22
29
  wrapTextWithAnsi,
@@ -51,6 +58,16 @@ const CHROME_LINES_BASE = 6
51
58
  const MIN_VIEWPORT = 3
52
59
  /** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
53
60
  export const VIEWPORT_HEIGHT_PCT = 70
61
+ const SCROLLBAR_WIDTH = 1
62
+
63
+ /** Follow the active renderer, not a setting that may require a restart. */
64
+ export function getConversationOverlayOptions(
65
+ tui: Pick<TUI, "mode">,
66
+ ): OverlayOptions {
67
+ return tui.mode === "fullscreen"
68
+ ? { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 }
69
+ : { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` }
70
+ }
54
71
 
55
72
  /**
56
73
  * Cap on a single tool result or bash output before the viewer elides the rest.
@@ -65,6 +82,12 @@ export const VIEWPORT_HEIGHT_PCT = 70
65
82
  */
66
83
  export const RESULT_MAX_CHARS = 16_000
67
84
 
85
+ /** The `b` affordance: whether this run can be handed to the background now, and the handoff. */
86
+ export interface ViewerBackgroundAction {
87
+ available(): boolean
88
+ send(): void
89
+ }
90
+
68
91
  /** Cycle order for the viewer's `m` key. */
69
92
  const MARKDOWN_MODES: readonly ViewerMarkdownMode[] = [
70
93
  "off",
@@ -173,9 +196,26 @@ function truncationNote(elided: number): string {
173
196
  return `... (truncated, ${humanCount(elided)} more character${elided === 1 ? "" : "s"})`
174
197
  }
175
198
 
176
- export class ConversationViewer implements Component {
199
+ export class ConversationViewer implements Component, Focusable {
177
200
  private scrollOffset = 0
178
201
  private autoScroll = true
202
+ focused = false
203
+ private readonly fullscreen: boolean
204
+ private viewport:
205
+ | {
206
+ width: number
207
+ top: number
208
+ height: number
209
+ maxScroll: number
210
+ thumbTop: number
211
+ thumbHeight: number
212
+ }
213
+ | undefined
214
+ private scrollbarGrabOffset: number | undefined
215
+ private footerTargets: { start: number; end: number; key: string }[] = []
216
+ private latestTarget: { row: number; start: number; end: number } | undefined
217
+ private newMessages = 0
218
+ private readonly scrollView: ScrollView
179
219
  private unsubscribe: (() => void) | undefined
180
220
  private lastInnerW = 0
181
221
  private closed = false
@@ -229,16 +269,51 @@ export class ConversationViewer implements Component {
229
269
  * the same thing. Omitted → `m` still cycles, viewer-locally.
230
270
  */
231
271
  private onMarkdownMode?: (mode: ViewerMarkdownMode) => void,
272
+ /** Pi's fullscreen scrollbar preference, captured when the observer opens. */
273
+ scrollbarMode: ScrollViewScrollbar = "auto",
274
+ /**
275
+ * Hand this run to the background. `available` is read live — it turns
276
+ * false the instant the handoff lands or the run ends. Omitted → no `b`
277
+ * affordance (history views).
278
+ */
279
+ private onBackground?: ViewerBackgroundAction,
232
280
  ) {
281
+ this.fullscreen = tui.mode === "fullscreen"
233
282
  this.markdownTheme = resolveMarkdownTheme(theme)
234
283
  this.keys = createViewerKeys(keybindings)
235
- this.unsubscribe = session.subscribe(() => {
284
+ this.scrollView = new ScrollView(
285
+ {
286
+ render: (width) => this.buildContentLines(width),
287
+ invalidate: () => {},
288
+ },
289
+ { follow: "end", scrollbar: this.fullscreen ? scrollbarMode : "hidden" },
290
+ )
291
+ this.unsubscribe = session.subscribe((event) => {
236
292
  if (this.closed) return
293
+ if (
294
+ this.fullscreen &&
295
+ !this.scrollView.isFollowingEnd &&
296
+ event.type === "message_start" &&
297
+ ["user", "assistant", "toolResult", "bashExecution"].includes(
298
+ event.message.role,
299
+ )
300
+ ) {
301
+ this.newMessages++
302
+ }
237
303
  this.tui.requestRender()
238
304
  })
239
305
  }
240
306
 
241
307
  handleInput(data: string): void {
308
+ if (this.closed) return
309
+ if (
310
+ this.fullscreen &&
311
+ (matchesKey(data, "ctrl+end") ||
312
+ (!this.composer && matchesKey(data, "end")))
313
+ ) {
314
+ this.jumpToLatest()
315
+ return
316
+ }
242
317
  // While composing a steer message, the input owns all keys (Enter sends,
243
318
  // Esc cancels — both wired in openComposer()). Editing keys flow through.
244
319
  if (this.composer) {
@@ -266,6 +341,16 @@ export class ConversationViewer implements Component {
266
341
  return
267
342
  }
268
343
 
344
+ // Hand a live foreground run to the background. Single press, unlike stop:
345
+ // the transition is one-way but keeps the agent working, so a stray key
346
+ // costs a notification rather than the run.
347
+ if (matchesKey(data, "b") && this.canSendToBackground()) {
348
+ this.stopArmed = false
349
+ this.onBackground?.send()
350
+ this.tui.requestRender()
351
+ return
352
+ }
353
+
269
354
  // Stop/abort the agent (only while it can still be stopped). Two-press:
270
355
  // first "x" arms, second confirms — any other key disarms.
271
356
  if (matchesKey(data, "x")) {
@@ -300,6 +385,18 @@ export class ConversationViewer implements Component {
300
385
 
301
386
  const totalLines = this.buildContentLines(this.lastInnerW).length
302
387
  const viewportHeight = this.viewportHeight()
388
+ if (this.fullscreen) {
389
+ this.scrollView.updateLayout(totalLines, viewportHeight, () => {
390
+ if (!this.closed) this.tui.requestRender()
391
+ })
392
+ if (this.keys.scrollUp(data)) this.scrollView.scrollBy(-1)
393
+ else if (this.keys.scrollDown(data)) this.scrollView.scrollBy(1)
394
+ else if (this.keys.pageUp(data)) this.scrollView.scrollBy(-viewportHeight)
395
+ else if (this.keys.pageDown(data))
396
+ this.scrollView.scrollBy(viewportHeight)
397
+ else if (matchesKey(data, "home")) this.scrollView.scrollToStart()
398
+ return
399
+ }
303
400
  const maxScroll = Math.max(0, totalLines - viewportHeight)
304
401
 
305
402
  if (this.keys.scrollUp(data)) {
@@ -326,29 +423,141 @@ export class ConversationViewer implements Component {
326
423
  }
327
424
  }
328
425
 
426
+ private jumpToLatest(): void {
427
+ this.scrollView.scrollToEnd()
428
+ this.newMessages = 0
429
+ this.stopArmed = false
430
+ this.tui.requestRender()
431
+ }
432
+
433
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult {
434
+ if (!this.fullscreen) return { handled: false }
435
+ // Every cell belongs to this observer. Even unused clicks/drags must not
436
+ // fall through to Pi's transcript selection, scrolling, or editor.
437
+ if (this.closed || !this.viewport) return { handled: true, render: false }
438
+ const view = this.viewport
439
+ const onScrollbar =
440
+ this.scrollView.scrollbar !== "hidden" &&
441
+ event.x >= view.width - SCROLLBAR_WIDTH &&
442
+ event.x < view.width &&
443
+ event.y >= view.top &&
444
+ event.y < view.top + view.height
445
+ if (event.type === "move") {
446
+ this.scrollView.setScrollbarActive(onScrollbar)
447
+ return { handled: true, render: false }
448
+ }
449
+ if (event.type === "wheel") {
450
+ this.stopArmed = false
451
+ this.scrollView.scrollBy(event.wheelDelta ?? 0)
452
+ return { handled: true, render: true }
453
+ }
454
+ if (event.type === "release") {
455
+ const wasDragging = this.scrollbarGrabOffset !== undefined
456
+ this.scrollbarGrabOffset = undefined
457
+ this.scrollView.setScrollbarActive(onScrollbar)
458
+ return { handled: true, render: wasDragging }
459
+ }
460
+ if (event.type === "drag" && this.scrollbarGrabOffset !== undefined) {
461
+ const travel = view.height - view.thumbHeight
462
+ const position = Math.max(
463
+ 0,
464
+ Math.min(travel, event.y - view.top - this.scrollbarGrabOffset),
465
+ )
466
+ this.scrollView.scrollTo(
467
+ travel > 0 ? Math.round((position / travel) * view.maxScroll) : 0,
468
+ )
469
+ return { handled: true, render: true }
470
+ }
471
+ if (event.type === "press" && event.button === "left") {
472
+ this.scrollbarGrabOffset = undefined
473
+ this.scrollView.setScrollbarActive(onScrollbar)
474
+ if (
475
+ this.latestTarget &&
476
+ event.y === this.latestTarget.row &&
477
+ event.x >= this.latestTarget.start &&
478
+ event.x < this.latestTarget.end
479
+ ) {
480
+ this.jumpToLatest()
481
+ return { handled: true, render: true }
482
+ }
483
+ if (event.y === this.tui.terminal.rows - 1) {
484
+ const target = this.footerTargets.find(
485
+ (t) => event.x >= t.start && event.x < t.end,
486
+ )
487
+ if (target) {
488
+ this.handleInput(target.key)
489
+ return { handled: true, render: true }
490
+ }
491
+ }
492
+ this.stopArmed = false
493
+ if (this.composer && event.y === this.tui.terminal.rows - 2) {
494
+ this.composer.handleMouse({
495
+ ...event,
496
+ x: event.x - 1,
497
+ y: 0,
498
+ width: this.lastInnerW,
499
+ height: 1,
500
+ })
501
+ return { handled: true, render: true }
502
+ }
503
+ if (onScrollbar && view.maxScroll > 0) {
504
+ const y = event.y - view.top
505
+ if (y >= view.thumbTop && y < view.thumbTop + view.thumbHeight) {
506
+ this.scrollbarGrabOffset = y - view.thumbTop
507
+ return { handled: true, capture: true, render: true }
508
+ }
509
+ this.scrollView.scrollBy(y < view.thumbTop ? -view.height : view.height)
510
+ // A track click still pages, but holding it can grab the thumb too.
511
+ this.scrollbarGrabOffset = Math.floor(view.thumbHeight / 2)
512
+ return { handled: true, capture: true, render: true }
513
+ }
514
+ return { handled: true, render: true }
515
+ }
516
+ return { handled: true, render: false }
517
+ }
518
+
329
519
  render(width: number): string[] {
330
- if (width < 6) return [] // too narrow for any meaningful rendering
520
+ this.footerTargets = []
521
+ this.latestTarget = undefined
522
+ this.viewport = undefined
523
+ const rows = Math.max(0, this.tui.terminal.rows)
524
+ if (!this.fullscreen && width < 6) return [] // too narrow for any meaningful rendering
525
+ if (width <= SCROLLBAR_WIDTH + 1)
526
+ return Array.from({ length: rows }, () => " ".repeat(Math.max(0, width)))
331
527
  const th = this.theme
332
- const innerW = width - 4 // border + padding
528
+ const innerW = this.fullscreen
529
+ ? this.scrollView.getContentWidth(width - 1)
530
+ : width - 4
333
531
  this.lastInnerW = innerW
334
532
  const lines: string[] = []
335
-
533
+ const footerActions: { label: string; key: string }[] = []
336
534
  const pad = (s: string, len: number) => {
337
535
  const vis = visibleWidth(s)
338
536
  return s + " ".repeat(Math.max(0, len - vis))
339
537
  }
340
- const row = (content: string) =>
341
- th.fg("border", "│") +
342
- " " +
343
- truncateToWidth(pad(content, innerW), innerW, "...", true) +
344
- " " +
345
- th.fg("border", "│")
538
+ const row = (content: string, scrollbar?: string): string => {
539
+ if (this.fullscreen) {
540
+ const contentWidth = width - 1 - (scrollbar ? SCROLLBAR_WIDTH : 0)
541
+ return (
542
+ " " +
543
+ truncateToWidth(content, contentWidth, scrollbar ? "" : "...", true) +
544
+ (scrollbar ?? "")
545
+ )
546
+ }
547
+ return (
548
+ th.fg("border", "│") +
549
+ " " +
550
+ truncateToWidth(pad(content, innerW), innerW, "...", true) +
551
+ " " +
552
+ th.fg("border", "│")
553
+ )
554
+ }
346
555
  const hrTop = th.fg("border", `╭${"─".repeat(width - 2)}╮`)
347
556
  const hrBot = th.fg("border", `╰${"─".repeat(width - 2)}╯`)
348
557
  const hrMid = row(th.fg("dim", "─".repeat(innerW)))
349
558
 
350
559
  // Header
351
- lines.push(hrTop)
560
+ if (!this.fullscreen) lines.push(hrTop)
352
561
  const modeLabel = getPromptModeLabel(this.record.type)
353
562
  const modeTag = modeLabel ? ` ${th.fg("dim", `(${modeLabel})`)}` : ""
354
563
  const statusIcon =
@@ -393,29 +602,106 @@ export class ConversationViewer implements Component {
393
602
  lines.push(hrMid)
394
603
 
395
604
  // Content area — rebuild every render (live data, no cache needed)
396
- const contentLines = this.buildContentLines(innerW)
605
+ const contentLines = this.fullscreen
606
+ ? this.scrollView.render(width - 1)
607
+ : this.buildContentLines(innerW)
397
608
  const viewportHeight = this.viewportHeight()
398
609
  const maxScroll = Math.max(0, contentLines.length - viewportHeight)
399
-
400
- if (this.autoScroll) {
610
+ if (this.fullscreen) {
611
+ this.scrollView.updateLayout(contentLines.length, viewportHeight, () => {
612
+ if (!this.closed) this.tui.requestRender()
613
+ })
614
+ if (this.scrollView.isFollowingEnd) this.newMessages = 0
615
+ } else if (this.autoScroll) {
401
616
  this.scrollOffset = maxScroll
402
617
  }
403
618
 
404
- const visibleStart = Math.min(this.scrollOffset, maxScroll)
619
+ const visibleStart = this.fullscreen
620
+ ? this.scrollView.scrollTop
621
+ : Math.min(this.scrollOffset, maxScroll)
405
622
  const visible = contentLines.slice(
406
623
  visibleStart,
407
624
  visibleStart + viewportHeight,
408
625
  )
626
+ // Match Pi's scrollbar geometry: rounded size, at least two rows when available.
627
+ const thumbHeight = Math.min(
628
+ viewportHeight,
629
+ Math.max(
630
+ 2,
631
+ Math.round(
632
+ (viewportHeight * viewportHeight) / Math.max(1, contentLines.length),
633
+ ),
634
+ ),
635
+ )
636
+ const thumbTop =
637
+ maxScroll > 0
638
+ ? Math.round(
639
+ (visibleStart / maxScroll) * (viewportHeight - thumbHeight),
640
+ )
641
+ : 0
642
+ this.viewport = {
643
+ width,
644
+ top: lines.length,
645
+ height: viewportHeight,
646
+ maxScroll,
647
+ thumbTop,
648
+ thumbHeight,
649
+ }
409
650
 
410
651
  for (let i = 0; i < viewportHeight; i++) {
411
- lines.push(row(visible[i] ?? ""))
652
+ let scrollbar: string | undefined
653
+ if (this.scrollView.isScrollbarVisible) {
654
+ const onThumb = i >= thumbTop && i < thumbTop + thumbHeight
655
+ if (onThumb) {
656
+ scrollbar = th.fg(
657
+ "scrollbarThumb",
658
+ this.scrollView.isScrollbarActive ? "█" : "┃",
659
+ )
660
+ } else {
661
+ scrollbar = th.fg("scrollbarTrack", "│")
662
+ }
663
+ }
664
+ lines.push(row(visible[i] ?? "", scrollbar))
412
665
  }
413
666
 
414
- // Footer
415
- lines.push(hrMid)
667
+ // Keep the divider pinned above the footer, with the jump control centered
668
+ // inside it. Neither scrolling nor new messages change its position.
669
+ if (
670
+ this.fullscreen &&
671
+ !this.scrollView.isFollowingEnd &&
672
+ maxScroll > 0 &&
673
+ rows >= this.chromeLines()
674
+ ) {
675
+ const text =
676
+ this.newMessages > 0
677
+ ? `${this.newMessages} new message${this.newMessages === 1 ? "" : "s"}`
678
+ : "Jump to latest message"
679
+ const label = truncateToWidth(`[ ↓ ${text} · Ctrl+End ]`, innerW, "…")
680
+ const labelWidth = visibleWidth(label)
681
+ const padding = Math.floor((innerW - labelWidth) / 2)
682
+ this.latestTarget = {
683
+ row: lines.length,
684
+ start: padding + 1,
685
+ end: padding + 1 + labelWidth,
686
+ }
687
+ lines.push(
688
+ row(
689
+ th.fg("dim", "─".repeat(padding)) +
690
+ th.fg("accent", label) +
691
+ th.fg("dim", "─".repeat(innerW - padding - labelWidth)),
692
+ ),
693
+ )
694
+ } else {
695
+ lines.push(hrMid)
696
+ }
416
697
  if (this.composer) {
417
698
  // Composer row: the Input renders its own `> ` prompt and cursor.
699
+ this.composer.focused = this.focused
418
700
  lines.push(row(this.composer.render(innerW)[0] ?? ""))
701
+ footerActions.push(
702
+ { label: "Enter send", key: "\r" },
703
+ { label: "Esc cancel", key: "\x1b" },
704
+ )
419
705
  const composeHint = th.fg("dim", "Enter send · Esc cancel")
420
706
  const composeLeft = th.fg("accent", "✎ steer")
421
707
  const composeGap = Math.max(
@@ -428,21 +714,28 @@ export class ConversationViewer implements Component {
428
714
  // full key list so the less-obvious bindings stay discoverable; it leads
429
715
  // the right group so "Esc close" is the only part that truncates first.
430
716
  const sep = th.fg("dim", " · ")
431
- const actions: string[] = []
432
- if (this.canSteer()) actions.push(th.fg("dim", "Enter steer"))
717
+ if (this.canSteer())
718
+ footerActions.push({ label: "Enter steer", key: "\r" })
719
+ // Abbreviated for the same reason as the Markdown group below.
720
+ if (this.canSendToBackground())
721
+ footerActions.push({ label: "b bg", key: "b" })
433
722
  if (this.isStoppable()) {
434
- actions.push(
435
- this.stopArmed
436
- ? th.fg("error", "x again to STOP")
437
- : th.fg("dim", "x stop"),
438
- )
723
+ footerActions.push({
724
+ label: this.stopArmed ? "x again to STOP" : "x stop",
725
+ key: "x",
726
+ })
439
727
  }
440
728
  // Abbreviated (`raw`/`md`/`md+`) because the idle footer is already full
441
729
  // at 80 columns with steer + stop present, and this group has no
442
730
  // degradation step below "drop the line-count readout".
443
- actions.push(
444
- th.fg("dim", `m ${MARKDOWN_MODE_LABELS[this.markdownMode()]}`),
731
+ footerActions.push({
732
+ label: `m ${MARKDOWN_MODE_LABELS[this.markdownMode()]}`,
733
+ key: "m",
734
+ })
735
+ const actions = footerActions.map((a) =>
736
+ th.fg(a.key === "x" && this.stopArmed ? "error" : "dim", a.label),
445
737
  )
738
+ footerActions.push({ label: "Esc close", key: "\x1b" })
446
739
  const footerRight = th.fg(
447
740
  "dim",
448
741
  "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close",
@@ -467,9 +760,25 @@ export class ConversationViewer implements Component {
467
760
  )
468
761
  lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight))
469
762
  }
470
- lines.push(hrBot)
471
-
472
- return lines
763
+ if (!this.fullscreen) {
764
+ lines.push(hrBot)
765
+ return lines
766
+ }
767
+ // Keep the footer on screen even in a terminal shorter than the chrome.
768
+ const footer = lines.pop() ?? row("")
769
+ const output = rows > 0 ? [...lines.slice(0, rows - 1), footer] : []
770
+ const footerText = stripTerminalSequences(footer)
771
+ for (const action of footerActions) {
772
+ const index = footerText.indexOf(action.label)
773
+ if (index < 0) continue // a clipped label is not a clickable control
774
+ const start = visibleWidth(footerText.slice(0, index))
775
+ this.footerTargets.push({
776
+ start,
777
+ end: start + visibleWidth(action.label),
778
+ key: action.key,
779
+ })
780
+ }
781
+ return output
473
782
  }
474
783
 
475
784
  /** Stoppable only when a stop handler exists and the agent is still active. */
@@ -549,6 +858,15 @@ export class ConversationViewer implements Component {
549
858
  )
550
859
  }
551
860
 
861
+ /**
862
+ * Asked of the owner rather than derived from the record: only the caller
863
+ * blocked on this run knows it can let go of it, and `blocking` alone also
864
+ * matches spawns nobody can hand off (the agent generator).
865
+ */
866
+ private canSendToBackground(): boolean {
867
+ return this.onBackground?.available() === true
868
+ }
869
+
552
870
  /** Open the inline steering composer and route subsequent input to it. */
553
871
  private openComposer(): void {
554
872
  const input = new Input()
@@ -573,6 +891,7 @@ export class ConversationViewer implements Component {
573
891
 
574
892
  dispose(): void {
575
893
  this.closed = true
894
+ this.scrollView.setScrollbar("hidden") // clears Pi's auto-hide timer
576
895
  if (this.unsubscribe) {
577
896
  this.unsubscribe()
578
897
  this.unsubscribe = undefined
@@ -582,6 +901,8 @@ export class ConversationViewer implements Component {
582
901
  // ---- Private ----
583
902
 
584
903
  private viewportHeight(): number {
904
+ if (this.fullscreen)
905
+ return Math.max(0, this.tui.terminal.rows - this.chromeLines())
585
906
  // Cap mirrors the overlay's maxHeight — otherwise the viewer would render
586
907
  // more lines than the overlay shows and clip the footer.
587
908
  const maxRows = Math.floor(
@@ -593,7 +914,8 @@ export class ConversationViewer implements Component {
593
914
  private chromeLines(): number {
594
915
  // The composer adds one row above the footer hint while it's open.
595
916
  return (
596
- CHROME_LINES_BASE +
917
+ CHROME_LINES_BASE -
918
+ (this.fullscreen ? 2 : 0) +
597
919
  (this.invocationLine() ? 1 : 0) +
598
920
  (this.composer ? 1 : 0)
599
921
  )
@@ -12,10 +12,10 @@
12
12
  */
13
13
 
14
14
  import {
15
- Editor,
16
15
  isKeyRelease,
17
16
  Key,
18
17
  matchesKey,
18
+ type ScrollViewScrollbar,
19
19
  truncateToWidth,
20
20
  visibleWidth,
21
21
  } from "@earendil-works/pi-tui"
@@ -26,7 +26,8 @@ import { getLifetimeCost, getLifetimeTotal } from "../usage.js"
26
26
  import { type AgentActivity, formatCost, type Theme } from "./agent-widget.js"
27
27
  import {
28
28
  ConversationViewer,
29
- VIEWPORT_HEIGHT_PCT,
29
+ getConversationOverlayOptions,
30
+ type ViewerBackgroundAction,
30
31
  } from "./conversation-viewer.js"
31
32
 
32
33
  /** Widget key for the below-editor fleet list. */
@@ -178,6 +179,16 @@ export class FleetList {
178
179
  * point. Omitted → `m` still cycles, viewer-locally.
179
180
  */
180
181
  private onViewerMarkdown?: (mode: ViewerMarkdownMode) => void,
182
+ private scrollbarMode: () => ScrollViewScrollbar = () => "auto",
183
+ /**
184
+ * The viewer's `b` for this row's run, supplied by the extension: only it
185
+ * knows which runs have a tool call waiting to be released, and it owns the
186
+ * widget this list shares with. Omitted → no `b` affordance.
187
+ */
188
+ private backgroundAction?: (
189
+ record: AgentRecord,
190
+ notify: (message: string) => void,
191
+ ) => ViewerBackgroundAction,
181
192
  ) {}
182
193
 
183
194
  // ---- Lifecycle ----
@@ -439,17 +450,20 @@ export class FleetList {
439
450
  }
440
451
 
441
452
  /**
442
- * True when pi's prompt editor owns the keyboard. pi's editor is an `Editor`
443
- * subclass (CustomEditor) while every dialog/selector is not, and the loader
444
- * aliases pi-tui to pi's own copy, so `instanceof` is a reliable identity
445
- * check. `focusedComponent` is TUI-private (no public accessor), hence the
446
- * best-effort peek: unknowable focus (no tui seen yet, nothing focused)
447
- * counts as the editor so activation keeps working.
453
+ * True when pi's prompt editor owns the keyboard. The editor is pluggable
454
+ * (ui.setEditorComponent) and need not extend `Editor` (#374), so match pi-tui's
455
+ * `EditorComponent` contract instead: `getText` + `setText`, which pi calls on
456
+ * every editor and none of pi's dialogs, selectors or overlays implement.
457
+ * `focusedComponent` is TUI-private (no public accessor), hence the best-effort
458
+ * peek: unknowable focus (no tui seen yet, nothing focused) counts as the
459
+ * editor so activation keeps working.
448
460
  */
449
- private editorHasFocus(): boolean {
461
+ editorHasFocus(): boolean {
450
462
  const focused = (this.tui as { focusedComponent?: unknown } | undefined)
451
463
  ?.focusedComponent
452
- return focused == null || focused instanceof Editor
464
+ if (focused == null) return true
465
+ const c = focused as { getText?: unknown; setText?: unknown }
466
+ return typeof c.getText === "function" && typeof c.setText === "function"
453
467
  }
454
468
 
455
469
  private deactivate(): void {
@@ -488,10 +502,12 @@ export class FleetList {
488
502
  const session = record.session
489
503
  const activity = this.agentActivity.get(record.id)
490
504
  this.viewingAgentId = record.id
505
+ let overlayOptions = getConversationOverlayOptions({ mode: "regular" })
491
506
 
492
507
  void this.ui
493
508
  .custom<undefined>(
494
509
  (tui, theme, keybindings, done) => {
510
+ overlayOptions = getConversationOverlayOptions(tui)
495
511
  this.viewerClose = () => done(undefined)
496
512
  return new ConversationViewer(
497
513
  tui,
@@ -509,15 +525,15 @@ export class FleetList {
509
525
  this.showCost(),
510
526
  this.viewerMarkdown,
511
527
  this.onViewerMarkdown,
528
+ this.scrollbarMode(),
529
+ this.backgroundAction?.(record, (message) =>
530
+ this.ui?.notify(message, "info"),
531
+ ),
512
532
  )
513
533
  },
514
534
  {
515
535
  overlay: true,
516
- overlayOptions: {
517
- anchor: "center",
518
- width: "90%",
519
- maxHeight: `${VIEWPORT_HEIGHT_PCT}%`,
520
- },
536
+ overlayOptions: () => overlayOptions,
521
537
  },
522
538
  )
523
539
  .then(
@@ -586,6 +602,8 @@ export class FleetList {
586
602
  lines.push(
587
603
  rightAlign("", theme.fg("dim", `↓ ${hiddenBelow} more`), width),
588
604
  )
605
+ // pi stacks below-editor widgets directly on its footer with no spacer (#351).
606
+ lines.push("")
589
607
 
590
608
  return lines
591
609
  }
@@ -332,6 +332,12 @@ export function createWorkflowHost(deps: WorkflowHostOptions): WorkflowHost {
332
332
  request.prompt,
333
333
  {
334
334
  description: request.label,
335
+ // Same as the Agent tool: `isolated` comes off the agent definition.
336
+ // Left out, a hermetic agent's script-spawned children loaded every
337
+ // host extension and picked up its MCP and web tools.
338
+ ...(config?.isolated !== undefined
339
+ ? { isolated: config.isolated }
340
+ : {}),
335
341
  // The stamp is what keeps this child out of the session's
336
342
  // `maxConcurrent` pool — see `occupiesPoolSlot`. The run already
337
343
  // bounds how many of its agents run at once, and counting them