@kushalkhemka/opensec-pi-subagents 0.20.3 → 0.20.4
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 +36 -0
- package/README.md +26 -3
- package/dist/index.js +22 -52
- package/dist/session-handoff.d.ts +1 -1
- package/dist/session-handoff.js +1 -1
- package/dist/settings.d.ts +1 -15
- package/dist/settings.js +1 -6
- package/dist/types.d.ts +0 -14
- package/dist/ui/conversation-viewer.d.ts +27 -89
- package/dist/ui/conversation-viewer.js +119 -374
- package/dist/ui/fleet-list.d.ts +8 -22
- package/dist/ui/fleet-list.js +6 -15
- package/dist/ui/viewer-keys.d.ts +10 -1
- package/dist/ui/viewer-keys.js +5 -0
- package/dist/ui/viewer-transcript.d.ts +152 -0
- package/dist/ui/viewer-transcript.js +721 -0
- package/package.json +1 -1
- package/src/index.ts +25 -60
- package/src/session-handoff.ts +1 -1
- package/src/settings.ts +2 -20
- package/src/types.ts +0 -15
- package/src/ui/conversation-viewer.ts +116 -386
- package/src/ui/fleet-list.ts +6 -14
- package/src/ui/viewer-keys.ts +13 -1
- package/src/ui/viewer-transcript.ts +779 -0
|
@@ -2,16 +2,20 @@
|
|
|
2
2
|
* conversation-viewer.ts — Live conversation overlay for viewing agent sessions.
|
|
3
3
|
*
|
|
4
4
|
* Displays a scrollable, live-updating view of an agent's conversation.
|
|
5
|
-
* Subscribes to session events for real-time streaming updates.
|
|
5
|
+
* Subscribes to session events for real-time streaming updates. The
|
|
6
|
+
* transcript is drawn with Pi's own chat components (`viewer-transcript.ts`),
|
|
7
|
+
* so it looks exactly like Pi's main session; the frame around it (header,
|
|
8
|
+
* footer, scrolling, steering) is the viewer's.
|
|
6
9
|
*/
|
|
7
10
|
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import { Input,
|
|
11
|
+
import { Input, matchesKey, ScrollView, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
9
12
|
import { renderAgentName } from "../agent-color.js";
|
|
10
13
|
import { extractText } from "../context.js";
|
|
11
14
|
import { getCachedSessionContextPercent, getLifetimeCost, getLifetimeTotal } from "../usage.js";
|
|
12
15
|
import { buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatCost, formatDuration, formatSessionTokens, getPromptModeLabel } from "./agent-widget.js";
|
|
13
16
|
import { sanitizeTerminalText } from "./terminal-text.js";
|
|
14
17
|
import { createViewerKeys } from "./viewer-keys.js";
|
|
18
|
+
import { PiTranscript } from "./viewer-transcript.js";
|
|
15
19
|
/** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
|
|
16
20
|
const CHROME_LINES_BASE = 6;
|
|
17
21
|
const MIN_VIEWPORT = 3;
|
|
@@ -30,20 +34,10 @@ export function getConversationOverlayOptions(tui) {
|
|
|
30
34
|
? { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 }
|
|
31
35
|
: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` };
|
|
32
36
|
}
|
|
37
|
+
/** Footer click target for the expand toggle, which may be bound to any key. */
|
|
38
|
+
const EXPAND_TARGET = "expand";
|
|
33
39
|
/** Message roles the fullscreen jump marker counts as "new messages" (not streaming updates). */
|
|
34
40
|
const COUNTED_ROLES = new Set(["user", "assistant", "toolResult", "bashExecution"]);
|
|
35
|
-
/**
|
|
36
|
-
* Cap on a single tool result or bash output before the viewer elides the rest.
|
|
37
|
-
*
|
|
38
|
-
* The cap is not cosmetic — it bounds render cost. A message's lines are
|
|
39
|
-
* rebuilt whenever the width or Markdown mode changes, and the streaming tail
|
|
40
|
-
* on every frame it changes, so an uncapped 200 KB result costs ~6 ms per
|
|
41
|
-
* rebuild to parse as Markdown, against ~0.5 ms once capped (best of 5, width
|
|
42
|
-
* 76). 16 KB is roughly a screenful at every terminal size and still ~30x the
|
|
43
|
-
* 500 characters this replaces, which was small enough to cut most real
|
|
44
|
-
* results mid-sentence.
|
|
45
|
-
*/
|
|
46
|
-
export const RESULT_MAX_CHARS = 16_000;
|
|
47
41
|
/**
|
|
48
42
|
* Session events that fire per streamed token or tool-output chunk. They are
|
|
49
43
|
* coalesced into one redraw per frame; every other event redraws at once.
|
|
@@ -51,103 +45,33 @@ export const RESULT_MAX_CHARS = 16_000;
|
|
|
51
45
|
const STREAMING_EVENTS = new Set(["message_update", "tool_execution_update"]);
|
|
52
46
|
/** Redraw cadence while streaming — the widget's spinner frame. */
|
|
53
47
|
const STREAM_FRAME_MS = 80;
|
|
54
|
-
/** Cycle order for the viewer's `m` key. */
|
|
55
|
-
const MARKDOWN_MODES = ["off", "assistant", "all"];
|
|
56
|
-
/** Footer labels — short, because the idle footer is already full at 80 columns. */
|
|
57
|
-
const MARKDOWN_MODE_LABELS = {
|
|
58
|
-
off: "raw",
|
|
59
|
-
assistant: "md",
|
|
60
|
-
all: "md+",
|
|
61
|
-
};
|
|
62
|
-
/**
|
|
63
|
-
* Both options keep the renderer from *rewriting* source that only looks like
|
|
64
|
-
* Markdown: without them `3) a / 7) b / 9) c` comes back renumbered `3. 4. 5.`
|
|
65
|
-
* and backslash escapes are normalized away. Neither is a safe edit to make to
|
|
66
|
-
* a tool's output, and both are cheap to switch off.
|
|
67
|
-
*/
|
|
68
|
-
const MARKDOWN_OPTIONS = {
|
|
69
|
-
preserveOrderedListMarkers: true,
|
|
70
|
-
preserveBackslashEscapes: true,
|
|
71
|
-
};
|
|
72
48
|
/**
|
|
73
|
-
* Pi's
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* Preferring pi's is what buys syntax-highlighted code fences (it carries a
|
|
77
|
-
* `highlightCode`), and it keeps this surface consistent with the notification
|
|
78
|
-
* renderer, which uses the same source. It has to be *probed* rather than
|
|
79
|
-
* try/caught around the call: `getMarkdownTheme()` returns arrow functions that
|
|
80
|
-
* read pi's global theme lazily, so an uninitialized theme throws inside
|
|
81
|
-
* `render()` — long after this returns — and takes the overlay with it. That is
|
|
82
|
-
* the case in tests and any embedded session that never called `initTheme()`.
|
|
49
|
+
* Whether Pi's global theme is initialized. Pi's components read it while they
|
|
50
|
+
* are built and rendered, and throw without it; Pi's interactive mode always
|
|
51
|
+
* has one, tests and embedded sessions may not.
|
|
83
52
|
*/
|
|
84
|
-
function
|
|
53
|
+
function piThemeReady() {
|
|
85
54
|
try {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
return piTheme;
|
|
55
|
+
getMarkdownTheme().heading("probe");
|
|
56
|
+
return true;
|
|
89
57
|
}
|
|
90
58
|
catch {
|
|
91
|
-
return
|
|
59
|
+
return false;
|
|
92
60
|
}
|
|
93
61
|
}
|
|
94
62
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* change into a content change.
|
|
99
|
-
*/
|
|
100
|
-
function fallbackMarkdownTheme(th) {
|
|
101
|
-
const sgr = (on, off) => (text) => `\x1b[${on}m${text}\x1b[${off}m`;
|
|
102
|
-
return {
|
|
103
|
-
heading: text => th.bold(th.fg("accent", text)),
|
|
104
|
-
link: text => th.fg("accent", text),
|
|
105
|
-
linkUrl: text => th.fg("muted", text),
|
|
106
|
-
code: text => th.fg("muted", text),
|
|
107
|
-
codeBlock: text => th.fg("muted", text),
|
|
108
|
-
codeBlockBorder: text => th.fg("dim", text),
|
|
109
|
-
quote: text => th.fg("muted", text),
|
|
110
|
-
quoteBorder: text => th.fg("dim", text),
|
|
111
|
-
hr: text => th.fg("dim", text),
|
|
112
|
-
listBullet: text => th.fg("accent", text),
|
|
113
|
-
bold: text => th.bold(text),
|
|
114
|
-
italic: sgr(3, 23),
|
|
115
|
-
underline: sgr(4, 24),
|
|
116
|
-
strikethrough: sgr(9, 29),
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
/**
|
|
120
|
-
* Cap `text` at `RESULT_MAX_CHARS`, reporting the elision separately rather than
|
|
121
|
-
* appending it.
|
|
122
|
-
*
|
|
123
|
-
* Separately because the notice is the viewer's chrome, not the tool's output.
|
|
124
|
-
* Appended into the string it becomes content: a cut landing inside a fenced
|
|
125
|
-
* code block — likely, on exactly the large `ctx_execute` results this is for —
|
|
126
|
-
* renders the notice as a line of source inside the fence.
|
|
127
|
-
*/
|
|
128
|
-
function capResult(text) {
|
|
129
|
-
if (text.length <= RESULT_MAX_CHARS)
|
|
130
|
-
return { text, elided: 0 };
|
|
131
|
-
return {
|
|
132
|
-
text: text.slice(0, RESULT_MAX_CHARS),
|
|
133
|
-
elided: text.length - RESULT_MAX_CHARS,
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
/**
|
|
137
|
-
* `999` · `1.5k` · `8.4M` — a magnitude cue, not an exact count, past 1000.
|
|
138
|
-
*
|
|
139
|
-
* The bracket is chosen against the *rounded* value, so 999,999 reads `1M`
|
|
140
|
-
* rather than the `1000.0k` a naive `< 1e6` test produces.
|
|
63
|
+
* The footer's left group, dropping what fits least first: the line-count
|
|
64
|
+
* readout, then the expand hint (Pi's components already say "ctrl+o to
|
|
65
|
+
* expand" beside anything collapsed), never the actions.
|
|
141
66
|
*/
|
|
142
|
-
function
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
return `... (truncated, ${humanCount(elided)} more character${elided === 1 ? "" : "s"})`;
|
|
67
|
+
function fitFooterLeft(count, actions, expand, sep, room) {
|
|
68
|
+
const tail = expand ? [...actions, expand] : actions;
|
|
69
|
+
for (const parts of [[count, ...tail], tail]) {
|
|
70
|
+
const text = parts.join(sep);
|
|
71
|
+
if (visibleWidth(text) <= room)
|
|
72
|
+
return text;
|
|
73
|
+
}
|
|
74
|
+
return actions.join(sep);
|
|
151
75
|
}
|
|
152
76
|
export class ConversationViewer {
|
|
153
77
|
tui;
|
|
@@ -159,8 +83,6 @@ export class ConversationViewer {
|
|
|
159
83
|
onStop;
|
|
160
84
|
onSteer;
|
|
161
85
|
showCost;
|
|
162
|
-
viewerMarkdown;
|
|
163
|
-
onMarkdownMode;
|
|
164
86
|
onBackground;
|
|
165
87
|
scrollOffset = 0;
|
|
166
88
|
autoScroll = true;
|
|
@@ -203,39 +125,13 @@ export class ConversationViewer {
|
|
|
203
125
|
keys;
|
|
204
126
|
/** Steering composer — present while the user is typing a message to the agent. */
|
|
205
127
|
composer;
|
|
206
|
-
/** Resolved once: pi's Markdown theme is fixed for the life of the process. */
|
|
207
|
-
markdownTheme;
|
|
208
|
-
/** Set by the `m` key. Wins over the setting so `m` works without a persist hook. */
|
|
209
|
-
markdownModeOverride;
|
|
210
|
-
/**
|
|
211
|
-
* One `Markdown` per message, so its own text/width cache does the work. A
|
|
212
|
-
* fresh instance per render would re-parse the whole transcript on every
|
|
213
|
-
* keystroke — the component caches, but only across calls to the same object.
|
|
214
|
-
* Weak so a compacted-away message doesn't pin its render.
|
|
215
|
-
*/
|
|
216
|
-
markdownCache = new WeakMap();
|
|
217
|
-
/**
|
|
218
|
-
* The transcript, kept between frames. `blocks[i]` renders `messages[i]`;
|
|
219
|
-
* `lines` holds every block's lines (and separators) in order, and
|
|
220
|
-
* `blockStart[i]` is where block `i`'s part of it begins. All of it is valid
|
|
221
|
-
* for `cacheWidth` and `cacheMode` only.
|
|
222
|
-
*/
|
|
223
|
-
blocks = [];
|
|
224
|
-
blockStart = [];
|
|
225
|
-
lines = [];
|
|
226
|
-
/** `lineWidths[i]` is the visible width of `lines[i]`, measured once when the line is built. */
|
|
227
|
-
lineWidths = [];
|
|
228
128
|
/**
|
|
229
|
-
* The
|
|
230
|
-
*
|
|
231
|
-
* object only the unsettled tail needs checking — what keeps a frame's cost
|
|
232
|
-
* flat however long the transcript gets.
|
|
129
|
+
* The transcript drawn with Pi's components, its lines cached between
|
|
130
|
+
* frames. Undefined when Pi's theme is not initialized (see `plainLines`).
|
|
233
131
|
*/
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
/** Messages from this index on may still change in place; earlier ones are settled. */
|
|
238
|
-
unsettledFrom = 0;
|
|
132
|
+
transcript;
|
|
133
|
+
/** The plain fallback's lines and what they were built for. */
|
|
134
|
+
plain;
|
|
239
135
|
/** Pending coalesced redraw for streaming events. */
|
|
240
136
|
streamRenderTimer;
|
|
241
137
|
constructor(tui, session, record, activity, theme, done,
|
|
@@ -251,17 +147,6 @@ export class ConversationViewer {
|
|
|
251
147
|
* cannot change while it is on screen.
|
|
252
148
|
*/
|
|
253
149
|
showCost = false,
|
|
254
|
-
/**
|
|
255
|
-
* The current `viewerMarkdown` setting. Read live rather than captured,
|
|
256
|
-
* unlike `showCost`: `m` changes it while the overlay is on screen.
|
|
257
|
-
* Omitted → `assistant`.
|
|
258
|
-
*/
|
|
259
|
-
viewerMarkdown,
|
|
260
|
-
/**
|
|
261
|
-
* Persist a mode chosen with `m`, so the key and `/agents → Settings` mean
|
|
262
|
-
* the same thing. Omitted → `m` still cycles, viewer-locally.
|
|
263
|
-
*/
|
|
264
|
-
onMarkdownMode,
|
|
265
150
|
/**
|
|
266
151
|
* Hand this run to the background. `available` is read live — it turns
|
|
267
152
|
* false the instant the handoff lands or the run ends. Omitted → no `b`
|
|
@@ -269,7 +154,9 @@ export class ConversationViewer {
|
|
|
269
154
|
*/
|
|
270
155
|
onBackground,
|
|
271
156
|
/** Pi's `fullscreenScrollbar` preference, captured when the viewer opens. */
|
|
272
|
-
scrollbarMode = "auto"
|
|
157
|
+
scrollbarMode = "auto",
|
|
158
|
+
/** Pi's chat settings (thinking blocks, padding, code indent), read when the viewer opens. */
|
|
159
|
+
renderOptions) {
|
|
273
160
|
this.tui = tui;
|
|
274
161
|
this.session = session;
|
|
275
162
|
this.record = record;
|
|
@@ -279,23 +166,23 @@ export class ConversationViewer {
|
|
|
279
166
|
this.onStop = onStop;
|
|
280
167
|
this.onSteer = onSteer;
|
|
281
168
|
this.showCost = showCost;
|
|
282
|
-
this.viewerMarkdown = viewerMarkdown;
|
|
283
|
-
this.onMarkdownMode = onMarkdownMode;
|
|
284
169
|
this.onBackground = onBackground;
|
|
285
170
|
this.fullscreen = tui.mode === "fullscreen";
|
|
286
|
-
this.markdownTheme = resolveMarkdownTheme(theme);
|
|
287
171
|
this.keys = createViewerKeys(keybindings);
|
|
172
|
+
this.transcript = piThemeReady() ? new PiTranscript(session, this.requestRender, renderOptions) : undefined;
|
|
288
173
|
this.scrollView = new ScrollView({ render: () => [], invalidate: () => { } }, { follow: "end", scrollbar: this.fullscreen ? scrollbarMode : "hidden" });
|
|
289
174
|
this.unsubscribe = session.subscribe((event) => {
|
|
290
175
|
if (this.closed)
|
|
291
176
|
return;
|
|
292
177
|
const e = event;
|
|
178
|
+
if (e)
|
|
179
|
+
this.transcript?.handleEvent(e);
|
|
293
180
|
if (this.fullscreen && e?.type === "message_start" && !this.scrollView.isFollowingEnd && COUNTED_ROLES.has(e.message?.role ?? "")) {
|
|
294
181
|
this.newMessages++;
|
|
295
182
|
}
|
|
296
183
|
if (STREAMING_EVENTS.has(e?.type ?? "")) {
|
|
297
|
-
// A token delta
|
|
298
|
-
// frame is enough, and unthrottled it was one per token.
|
|
184
|
+
// A token delta redraws only the streamed message's block; one redraw
|
|
185
|
+
// per frame is enough, and unthrottled it was one per token.
|
|
299
186
|
this.streamRenderTimer ??= setTimeout(() => {
|
|
300
187
|
this.streamRenderTimer = undefined;
|
|
301
188
|
if (!this.closed)
|
|
@@ -357,15 +244,9 @@ export class ConversationViewer {
|
|
|
357
244
|
}
|
|
358
245
|
return;
|
|
359
246
|
}
|
|
360
|
-
//
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
if (matchesKey(data, "m")) {
|
|
364
|
-
this.stopArmed = false;
|
|
365
|
-
const next = MARKDOWN_MODES[(MARKDOWN_MODES.indexOf(this.markdownMode()) + 1) % MARKDOWN_MODES.length];
|
|
366
|
-
this.markdownModeOverride = next;
|
|
367
|
-
this.onMarkdownMode?.(next);
|
|
368
|
-
this.tui.requestRender();
|
|
247
|
+
// Expand or collapse tool output, as Pi's ctrl+o (`app.tools.expand`).
|
|
248
|
+
if (this.keys.toggleExpand(data)) {
|
|
249
|
+
this.toggleExpand();
|
|
369
250
|
return;
|
|
370
251
|
}
|
|
371
252
|
if (this.stopArmed)
|
|
@@ -466,7 +347,10 @@ export class ConversationViewer {
|
|
|
466
347
|
if (event.y === this.tui.terminal.rows - 1) {
|
|
467
348
|
const target = this.footerTargets.find(t => event.x >= t.start && event.x < t.end);
|
|
468
349
|
if (target) {
|
|
469
|
-
|
|
350
|
+
if (target.key === EXPAND_TARGET)
|
|
351
|
+
this.toggleExpand();
|
|
352
|
+
else
|
|
353
|
+
this.handleInput(target.key);
|
|
470
354
|
return { handled: true, render: true };
|
|
471
355
|
}
|
|
472
356
|
}
|
|
@@ -543,7 +427,7 @@ export class ConversationViewer {
|
|
|
543
427
|
if (at < content.base.length) {
|
|
544
428
|
const text = content.base[at];
|
|
545
429
|
// The cached transcript has its widths; the empty-session placeholder does not.
|
|
546
|
-
return { text, width: content.
|
|
430
|
+
return { text, width: content.widths ? content.widths[at] : visibleWidth(text) };
|
|
547
431
|
}
|
|
548
432
|
const text = content.tail[at - content.base.length] ?? "";
|
|
549
433
|
return { text, width: text ? visibleWidth(text) : 0 };
|
|
@@ -600,7 +484,7 @@ export class ConversationViewer {
|
|
|
600
484
|
const scrollPct = totalLines <= viewportHeight
|
|
601
485
|
? "100%"
|
|
602
486
|
: `${Math.round(((visibleStart + viewportHeight) / totalLines) * 100)}%`;
|
|
603
|
-
const footerKey = `${width}|${this.canSteer()}|${this.canSendToBackground()}|${this.isStoppable()}|${this.stopArmed}|${this.
|
|
487
|
+
const footerKey = `${width}|${this.canSteer()}|${this.canSendToBackground()}|${this.isStoppable()}|${this.stopArmed}|${this.transcript?.isExpanded}|${totalLines}|${scrollPct}`;
|
|
604
488
|
lines.push(this.chrome("r:footer", footerKey, () => {
|
|
605
489
|
// Actions on the left, navigation on the right. The scroll hint keeps its
|
|
606
490
|
// full key list so the less-obvious bindings stay discoverable; it leads
|
|
@@ -609,24 +493,18 @@ export class ConversationViewer {
|
|
|
609
493
|
const actions = [];
|
|
610
494
|
if (this.canSteer())
|
|
611
495
|
actions.push(th.fg("dim", "Enter steer"));
|
|
612
|
-
// Abbreviated
|
|
496
|
+
// Abbreviated: the idle footer is already full at 80 columns.
|
|
613
497
|
if (this.canSendToBackground())
|
|
614
498
|
actions.push(th.fg("dim", "b bg"));
|
|
615
499
|
if (this.isStoppable()) {
|
|
616
500
|
actions.push(this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop"));
|
|
617
501
|
}
|
|
618
|
-
|
|
619
|
-
// at 80 columns with steer + stop present, and this group has no
|
|
620
|
-
// degradation step below "drop the line-count readout".
|
|
621
|
-
actions.push(th.fg("dim", `m ${MARKDOWN_MODE_LABELS[this.markdownMode()]}`));
|
|
502
|
+
const expand = this.expandHint();
|
|
622
503
|
const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close");
|
|
623
504
|
// Prepend the line-count/scroll-% readout only when there's spare width —
|
|
624
505
|
// it's the first thing dropped so it never crowds out the hints.
|
|
625
506
|
const count = th.fg("dim", `${totalLines} lines · ${scrollPct}`);
|
|
626
|
-
const
|
|
627
|
-
const footerLeft = visibleWidth(withCount) + visibleWidth(footerRight) + 1 <= innerW
|
|
628
|
-
? withCount
|
|
629
|
-
: actions.join(sep);
|
|
507
|
+
const footerLeft = fitFooterLeft(count, actions, expand && th.fg("dim", expand.label), sep, innerW - visibleWidth(footerRight) - 1);
|
|
630
508
|
const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
|
|
631
509
|
return row(footerLeft + " ".repeat(footerGap) + footerRight);
|
|
632
510
|
}));
|
|
@@ -719,7 +597,7 @@ export class ConversationViewer {
|
|
|
719
597
|
: `${Math.round(((visibleStart + viewportHeight) / totalLines) * 100)}%`;
|
|
720
598
|
const footerKey = this.composer
|
|
721
599
|
? `${width}|composer`
|
|
722
|
-
: `${width}|${this.canSteer()}|${this.canSendToBackground()}|${this.isStoppable()}|${this.stopArmed}|${this.
|
|
600
|
+
: `${width}|${this.canSteer()}|${this.canSendToBackground()}|${this.isStoppable()}|${this.stopArmed}|${this.transcript?.isExpanded}|${totalLines}|${scrollPct}`;
|
|
723
601
|
const { footer, targets } = this.chrome("f:footer", footerKey, () => this.fullscreenFooter(innerW, totalLines, scrollPct, row));
|
|
724
602
|
this.footerTargets = targets;
|
|
725
603
|
// The footer stays on screen even in a terminal shorter than the chrome.
|
|
@@ -744,13 +622,14 @@ export class ConversationViewer {
|
|
|
744
622
|
footerActions.push({ label: "b bg", key: "b" });
|
|
745
623
|
if (this.isStoppable())
|
|
746
624
|
footerActions.push({ label: this.stopArmed ? "x again to STOP" : "x stop", key: "x" });
|
|
747
|
-
footerActions.push({ label: `m ${MARKDOWN_MODE_LABELS[this.markdownMode()]}`, key: "m" });
|
|
748
625
|
const actions = footerActions.map(a => th.fg(a.key === "x" && this.stopArmed ? "error" : "dim", a.label));
|
|
626
|
+
const expand = this.expandHint();
|
|
627
|
+
if (expand)
|
|
628
|
+
footerActions.push(expand);
|
|
749
629
|
footerActions.push({ label: "Esc close", key: "\x1b" });
|
|
750
630
|
const footerRight = th.fg("dim", "wheel/↑↓ scroll · PgUp/PgDn · Esc close");
|
|
751
631
|
const count = th.fg("dim", `${totalLines} lines · ${scrollPct}`);
|
|
752
|
-
const
|
|
753
|
-
const footerLeft = visibleWidth(withCount) + visibleWidth(footerRight) + 1 <= innerW ? withCount : actions.join(sep);
|
|
632
|
+
const footerLeft = fitFooterLeft(count, actions, expand && th.fg("dim", expand.label), sep, innerW - visibleWidth(footerRight) - 1);
|
|
754
633
|
footer = row(footerLeft + " ".repeat(Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight))) + footerRight);
|
|
755
634
|
}
|
|
756
635
|
const targets = [];
|
|
@@ -776,56 +655,17 @@ export class ConversationViewer {
|
|
|
776
655
|
canSendToBackground() {
|
|
777
656
|
return this.onBackground?.available() === true;
|
|
778
657
|
}
|
|
779
|
-
/**
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
rawLines(text, width, dim) {
|
|
785
|
-
const lines = wrapTextWithAnsi(sanitizeTerminalText(text), width);
|
|
786
|
-
return dim ? lines.map(l => this.theme.fg("dim", l)) : lines;
|
|
658
|
+
/** Expand or collapse every tool, bash and summary block, as Pi's `app.tools.expand`. */
|
|
659
|
+
toggleExpand() {
|
|
660
|
+
this.stopArmed = false;
|
|
661
|
+
this.transcript?.setExpanded(!this.transcript.isExpanded);
|
|
662
|
+
this.tui.requestRender();
|
|
787
663
|
}
|
|
788
|
-
/**
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
entry = {
|
|
794
|
-
md: new Markdown(text, 0, 0, this.markdownTheme,
|
|
795
|
-
// Keeps result prose visually receded, the way the raw path's
|
|
796
|
-
// per-line `fg("dim", …)` did. Fenced code is the exception and is
|
|
797
|
-
// left alone deliberately: pi's theme highlights it with its own
|
|
798
|
-
// colors, which this would otherwise flatten.
|
|
799
|
-
dim ? { color: (t) => this.theme.fg("dim", t) } : undefined, MARKDOWN_OPTIONS),
|
|
800
|
-
text,
|
|
801
|
-
};
|
|
802
|
-
this.markdownCache.set(msg, entry);
|
|
803
|
-
}
|
|
804
|
-
else if (entry.text !== text) {
|
|
805
|
-
// Streaming: the message object is stable, its text grows. A failed
|
|
806
|
-
// prefix remains unsafe after append-only deltas, so retry only when the
|
|
807
|
-
// content was replaced or truncated.
|
|
808
|
-
const shouldRetry = !text.startsWith(entry.text);
|
|
809
|
-
entry.md.setText(text);
|
|
810
|
-
entry.text = text;
|
|
811
|
-
if (shouldRetry)
|
|
812
|
-
entry.failed = false;
|
|
813
|
-
}
|
|
814
|
-
if (entry.failed)
|
|
815
|
-
return this.rawLines(text, width, dim);
|
|
816
|
-
try {
|
|
817
|
-
return entry.md.render(width);
|
|
818
|
-
}
|
|
819
|
-
catch {
|
|
820
|
-
// The parser is recursive and this is arbitrary tool output: ~54 nested
|
|
821
|
-
// blockquotes overflow the stack, and no amount of fuzzing proves that is
|
|
822
|
-
// the only such input. `render()` is on the TUI's critical path, so a
|
|
823
|
-
// throw here takes the overlay down for content the literal path shows
|
|
824
|
-
// fine — degrade to that instead, and remember, since the throw would
|
|
825
|
-
// otherwise repeat on every render and every scroll key.
|
|
826
|
-
entry.failed = true;
|
|
827
|
-
return this.rawLines(text, width, dim);
|
|
828
|
-
}
|
|
664
|
+
/** The expand toggle's footer control: the key bound to it and what it does next. */
|
|
665
|
+
expandHint() {
|
|
666
|
+
if (!this.transcript || !this.keys.expandLabel)
|
|
667
|
+
return undefined;
|
|
668
|
+
return { label: `${this.keys.expandLabel} ${this.transcript.isExpanded ? "collapse" : "expand"}`, key: EXPAND_TARGET };
|
|
829
669
|
}
|
|
830
670
|
/** Steerable only when a steer handler exists and the agent is still active. */
|
|
831
671
|
canSteer() {
|
|
@@ -850,7 +690,9 @@ export class ConversationViewer {
|
|
|
850
690
|
this.tui.requestRender();
|
|
851
691
|
}
|
|
852
692
|
invalidate() {
|
|
853
|
-
|
|
693
|
+
// Pi invalidates the TUI on a theme change: every block re-renders.
|
|
694
|
+
this.transcript?.invalidate();
|
|
695
|
+
this.plain = undefined;
|
|
854
696
|
this.chromeCache.clear();
|
|
855
697
|
}
|
|
856
698
|
dispose() {
|
|
@@ -864,6 +706,7 @@ export class ConversationViewer {
|
|
|
864
706
|
this.unsubscribe();
|
|
865
707
|
this.unsubscribe = undefined;
|
|
866
708
|
}
|
|
709
|
+
this.transcript?.dispose();
|
|
867
710
|
}
|
|
868
711
|
// ---- Private ----
|
|
869
712
|
viewportHeight() {
|
|
@@ -898,174 +741,76 @@ export class ConversationViewer {
|
|
|
898
741
|
return view.base.length + view.tail.length;
|
|
899
742
|
}
|
|
900
743
|
/**
|
|
901
|
-
* The content area as cached transcript lines (`base`, owned by the
|
|
902
|
-
* do not mutate) plus the live activity
|
|
903
|
-
*
|
|
904
|
-
*
|
|
905
|
-
* by identity; messages from `unsettledFrom` on (the last one when the
|
|
906
|
-
* previous frame was drawn, and anything after it) are also compared by
|
|
907
|
-
* content, because older pi streams into the last message in place and a
|
|
908
|
-
* message can be edited and then pushed past before the next frame. Earlier
|
|
909
|
-
* messages are settled: pi never rewrites a message once another follows it,
|
|
910
|
-
* and compaction replaces the objects, which the identity check catches.
|
|
744
|
+
* The content area as cached transcript lines (`base`, owned by the
|
|
745
|
+
* transcript — do not mutate) with their widths, plus the live activity
|
|
746
|
+
* indicator (`tail`). Only blocks that changed are re-rendered, so a frame
|
|
747
|
+
* where nothing changed costs a few comparisons however long the transcript.
|
|
911
748
|
*/
|
|
912
749
|
contentView(width) {
|
|
913
750
|
if (width <= 0)
|
|
914
|
-
return { base: [], tail: [] };
|
|
751
|
+
return { base: [], widths: undefined, tail: [] };
|
|
915
752
|
const th = this.theme;
|
|
916
|
-
const
|
|
917
|
-
if (messages.length === 0) {
|
|
918
|
-
return { base: [truncateToWidth(th.fg("dim", "(waiting for first message...)"), width)], tail: [] };
|
|
753
|
+
const transcript = this.transcript;
|
|
754
|
+
if (transcript ? !transcript.hasContent : this.session.messages.length === 0) {
|
|
755
|
+
return { base: [truncateToWidth(th.fg("dim", "(waiting for first message...)"), width)], widths: undefined, tail: [] };
|
|
919
756
|
}
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
this.cacheWidth = width;
|
|
927
|
-
this.cacheMode = mode;
|
|
928
|
-
this.unsettledFrom = 0;
|
|
929
|
-
this.cacheMessages = undefined;
|
|
757
|
+
let base;
|
|
758
|
+
let widths;
|
|
759
|
+
if (transcript) {
|
|
760
|
+
transcript.sync(width);
|
|
761
|
+
base = transcript.lines;
|
|
762
|
+
widths = transcript.widths;
|
|
930
763
|
}
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
let stale = Math.min(this.blocks.length, messages.length);
|
|
934
|
-
const from = messages === this.cacheMessages && this.blocks.length <= messages.length ? Math.min(this.unsettledFrom, stale) : 0;
|
|
935
|
-
this.cacheMessages = messages;
|
|
936
|
-
for (let i = Math.max(0, from); i < stale; i++) {
|
|
937
|
-
const block = this.blocks[i];
|
|
938
|
-
if (block.msg !== messages[i] || (i >= this.unsettledFrom && block.source !== this.messageSource(messages[i]))) {
|
|
939
|
-
stale = i;
|
|
940
|
-
break;
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
if (stale < this.blocks.length || stale < messages.length) {
|
|
944
|
-
this.lines.length = stale < this.blockStart.length ? this.blockStart[stale] : this.lines.length;
|
|
945
|
-
this.lineWidths.length = this.lines.length;
|
|
946
|
-
this.blocks.length = stale;
|
|
947
|
-
this.blockStart.length = stale;
|
|
948
|
-
// Any line before the first rebuilt block means a block rendered there.
|
|
949
|
-
let needsSeparator = this.lines.length > 0;
|
|
950
|
-
const separator = truncateToWidth(th.fg("dim", "───"), width);
|
|
951
|
-
const separatorWidth = visibleWidth(separator);
|
|
952
|
-
for (let i = stale; i < messages.length; i++) {
|
|
953
|
-
const msg = messages[i];
|
|
954
|
-
const source = this.messageSource(msg);
|
|
955
|
-
const body = this.messageLines(msg, width, mode);
|
|
956
|
-
this.blocks.push({ msg, source, lines: body });
|
|
957
|
-
this.blockStart.push(this.lines.length);
|
|
958
|
-
if (!body)
|
|
959
|
-
continue;
|
|
960
|
-
if (needsSeparator) {
|
|
961
|
-
this.lines.push(separator);
|
|
962
|
-
this.lineWidths.push(separatorWidth);
|
|
963
|
-
}
|
|
964
|
-
for (const line of body) {
|
|
965
|
-
this.lines.push(line);
|
|
966
|
-
this.lineWidths.push(visibleWidth(line));
|
|
967
|
-
}
|
|
968
|
-
needsSeparator = true;
|
|
969
|
-
}
|
|
764
|
+
else {
|
|
765
|
+
({ lines: base, widths } = this.plainLines(width));
|
|
970
766
|
}
|
|
971
|
-
this.unsettledFrom = messages.length - 1;
|
|
972
767
|
// Streaming indicator for running agents
|
|
973
768
|
const tail = [];
|
|
974
769
|
if (this.record.status === "running" && this.activity) {
|
|
975
770
|
const act = describeActivity(this.activity.activeTools, this.activity.responseText);
|
|
976
771
|
tail.push("", truncateToWidth(th.fg("accent", "▍ ") + th.fg("dim", sanitizeTerminalText(act)), width));
|
|
977
772
|
}
|
|
978
|
-
return { base
|
|
773
|
+
return { base, widths, tail };
|
|
979
774
|
}
|
|
980
775
|
/**
|
|
981
|
-
*
|
|
982
|
-
*
|
|
776
|
+
* The transcript as plain wrapped text, for a process whose Pi theme is not
|
|
777
|
+
* initialized (Pi's components cannot render there). Never the case in Pi's
|
|
778
|
+
* interactive mode; it keeps the viewer usable rather than throwing.
|
|
983
779
|
*/
|
|
984
|
-
|
|
985
|
-
const
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
case "assistant": {
|
|
990
|
-
let out = "";
|
|
991
|
-
for (const c of m.content) {
|
|
992
|
-
if (c.type === "text" && c.text)
|
|
993
|
-
out += `t${c.text.length}:${c.text}`;
|
|
994
|
-
else if (c.type === "toolCall")
|
|
995
|
-
out += `c${c.name ?? c.toolName ?? "unknown"}\n`;
|
|
996
|
-
}
|
|
997
|
-
return out;
|
|
998
|
-
}
|
|
999
|
-
case "toolResult":
|
|
1000
|
-
return extractText(m.content);
|
|
1001
|
-
case "bashExecution":
|
|
1002
|
-
return `${String(m.command)}\n${m.output ?? ""}`;
|
|
1003
|
-
default:
|
|
1004
|
-
return "";
|
|
1005
|
-
}
|
|
1006
|
-
}
|
|
1007
|
-
/** One message's lines without its separator, clamped to `width`; undefined when it renders nothing. */
|
|
1008
|
-
messageLines(msg, width, mode) {
|
|
1009
|
-
const th = this.theme;
|
|
780
|
+
plainLines(width) {
|
|
781
|
+
const messages = this.session.messages;
|
|
782
|
+
const cached = this.plain;
|
|
783
|
+
if (cached && cached.messages === messages && cached.count === messages.length && cached.width === width)
|
|
784
|
+
return cached;
|
|
1010
785
|
const lines = [];
|
|
1011
|
-
|
|
1012
|
-
const
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
return undefined;
|
|
1017
|
-
lines.push(th.fg("accent", "[User]"));
|
|
1018
|
-
for (const line of wrapTextWithAnsi(sanitizeTerminalText(text.trim()), width)) {
|
|
1019
|
-
lines.push(line);
|
|
786
|
+
for (const raw of messages) {
|
|
787
|
+
const msg = raw;
|
|
788
|
+
let text = "";
|
|
789
|
+
if (msg.role === "user" || msg.role === "toolResult" || msg.role === "custom") {
|
|
790
|
+
text = typeof msg.content === "string" ? msg.content : extractText(msg.content);
|
|
1020
791
|
}
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
if (c.type === "text" && c.text)
|
|
1027
|
-
textParts.push(c.text);
|
|
1028
|
-
else if (c.type === "toolCall") {
|
|
1029
|
-
toolCalls.push(c.name ?? c.toolName ?? "unknown");
|
|
1030
|
-
}
|
|
792
|
+
else if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
|
793
|
+
text = msg.content
|
|
794
|
+
.map(c => c.type === "text" ? c.text ?? "" : c.type === "toolCall" ? `[${c.name ?? "tool"}]` : "")
|
|
795
|
+
.filter(Boolean)
|
|
796
|
+
.join("\n");
|
|
1031
797
|
}
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
const text = textParts.join("\n").trim();
|
|
1035
|
-
lines.push(...(mode === "off"
|
|
1036
|
-
? this.rawLines(text, width, false)
|
|
1037
|
-
: this.markdownLines(msg, text, width, false)));
|
|
798
|
+
else if (msg.role === "bashExecution") {
|
|
799
|
+
text = `$ ${String(msg.command)}\n${String(msg.output ?? "")}`;
|
|
1038
800
|
}
|
|
1039
|
-
|
|
1040
|
-
|
|
801
|
+
else if (msg.role === "compactionSummary" || msg.role === "branchSummary") {
|
|
802
|
+
text = String(msg.summary ?? "");
|
|
1041
803
|
}
|
|
1042
|
-
|
|
1043
|
-
else if (msg.role === "toolResult") {
|
|
1044
|
-
const { text, elided } = capResult(extractText(msg.content).trim());
|
|
804
|
+
text = sanitizeTerminalText(text).trim();
|
|
1045
805
|
if (!text)
|
|
1046
|
-
|
|
1047
|
-
lines.
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
const bash = msg;
|
|
1056
|
-
lines.push(truncateToWidth(th.fg("muted", ` $ ${sanitizeTerminalText(String(bash.command))}`), width));
|
|
1057
|
-
if (bash.output?.trim()) {
|
|
1058
|
-
// Same cap as a tool result, never Markdown: command output is the one
|
|
1059
|
-
// thing here that is definitionally not authored as Markdown.
|
|
1060
|
-
const { text, elided } = capResult(bash.output.trim());
|
|
1061
|
-
lines.push(...this.rawLines(text, width, true));
|
|
1062
|
-
if (elided)
|
|
1063
|
-
lines.push(truncateToWidth(th.fg("dim", truncationNote(elided)), width));
|
|
1064
|
-
}
|
|
1065
|
-
}
|
|
1066
|
-
else {
|
|
1067
|
-
return undefined;
|
|
1068
|
-
}
|
|
1069
|
-
return lines.map(l => truncateToWidth(l, width));
|
|
806
|
+
continue;
|
|
807
|
+
if (lines.length > 0)
|
|
808
|
+
lines.push("");
|
|
809
|
+
for (const line of wrapTextWithAnsi(text, width))
|
|
810
|
+
lines.push(truncateToWidth(line, width));
|
|
811
|
+
}
|
|
812
|
+
const plain = { messages, count: messages.length, width, lines, widths: lines.map(l => visibleWidth(l)) };
|
|
813
|
+
this.plain = plain;
|
|
814
|
+
return plain;
|
|
1070
815
|
}
|
|
1071
816
|
}
|