@herbertgao/pi-subagents 0.18.5 → 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.
- package/CHANGELOG.md +16 -0
- package/README.md +1 -1
- package/docs/packages.md +211 -0
- package/docs/rpc.md +61 -63
- package/docs/workflows.md +24 -16
- package/package.json +6 -6
- package/src/agent-file-toggle.ts +51 -14
- package/src/agent-manager.ts +123 -55
- package/src/agent-runner.ts +144 -13
- package/src/agent-source-badge.ts +78 -0
- package/src/completion-outbox.ts +122 -0
- package/src/custom-agents.ts +171 -79
- package/src/index.ts +598 -297
- package/src/invocation-config.ts +1 -1
- package/src/mention-clone.ts +48 -72
- package/src/nested-tools.ts +1 -1
- package/src/output-file.ts +15 -6
- package/src/package-resources.ts +724 -0
- package/src/schedule.ts +1 -1
- package/src/settings.ts +66 -0
- package/src/types.ts +26 -4
- package/src/ui/agent-widget.ts +2 -0
- package/src/ui/conversation-viewer.ts +354 -32
- package/src/ui/fleet-list.ts +33 -15
- package/src/workflow/collisions.ts +4 -1
- package/src/workflow/host.ts +6 -0
- package/src/workflow/json-schema.ts +9 -8
- package/src/workflow/saved.ts +48 -2
|
@@ -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.
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
415
|
-
|
|
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
|
-
|
|
432
|
-
|
|
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
|
-
|
|
435
|
-
this.stopArmed
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
444
|
-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
)
|
package/src/ui/fleet-list.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
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
|
-
|
|
461
|
+
editorHasFocus(): boolean {
|
|
450
462
|
const focused = (this.tui as { focusedComponent?: unknown } | undefined)
|
|
451
463
|
?.focusedComponent
|
|
452
|
-
|
|
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
|
}
|
|
@@ -46,11 +46,14 @@ import { SUBAGENT_TOOL_NAMES } from "../agent-runner.js"
|
|
|
46
46
|
*
|
|
47
47
|
* Our own name, because pi resolves a duplicate registration silently, and
|
|
48
48
|
* Claude Code's bare `Workflow`, because a port of that tool is what a second
|
|
49
|
-
* workflow extension most likely calls itself.
|
|
49
|
+
* workflow extension most likely calls itself. Lowercase `workflow` is the same
|
|
50
|
+
* tool by pi convention: it is what `@quintinshaw/pi-dynamic-workflows`
|
|
51
|
+
* registers, and the match here is exact, so the case has to be listed by hand.
|
|
50
52
|
*/
|
|
51
53
|
export const FOREIGN_WORKFLOW_TOOL_NAMES: ReadonlySet<string> = new Set([
|
|
52
54
|
SUBAGENT_TOOL_NAMES.WORKFLOW,
|
|
53
55
|
"Workflow",
|
|
56
|
+
"workflow",
|
|
54
57
|
])
|
|
55
58
|
|
|
56
59
|
/** The fields of a registered tool this decision reads. */
|
package/src/workflow/host.ts
CHANGED
|
@@ -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
|