@ymy88/pi-subagents 0.0.0-stage → 0.24.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.
- package/CHANGELOG.md +904 -0
- package/CONTRIBUTING.md +72 -0
- package/LICENSE +21 -0
- package/README.md +1170 -2
- package/SECURITY.md +95 -0
- package/dist/abortable.d.ts +12 -0
- package/dist/abortable.js +42 -0
- package/dist/agent-color.d.ts +35 -0
- package/dist/agent-color.js +123 -0
- package/dist/agent-file-toggle.d.ts +133 -0
- package/dist/agent-file-toggle.js +287 -0
- package/dist/agent-manager.d.ts +552 -0
- package/dist/agent-manager.js +1466 -0
- package/dist/agent-record-entry.d.ts +96 -0
- package/dist/agent-record-entry.js +121 -0
- package/dist/agent-runner.d.ts +310 -0
- package/dist/agent-runner.js +1195 -0
- package/dist/agent-source-badge.d.ts +50 -0
- package/dist/agent-source-badge.js +74 -0
- package/dist/agent-types.d.ts +119 -0
- package/dist/agent-types.js +286 -0
- package/dist/child-context.d.ts +2 -0
- package/dist/child-context.js +12 -0
- package/dist/completion-outbox.d.ts +63 -0
- package/dist/completion-outbox.js +104 -0
- package/dist/context.d.ts +12 -0
- package/dist/context.js +56 -0
- package/dist/cross-extension-rpc.d.ts +72 -0
- package/dist/cross-extension-rpc.js +153 -0
- package/dist/custom-agents.d.ts +77 -0
- package/dist/custom-agents.js +396 -0
- package/dist/default-agents.d.ts +7 -0
- package/dist/default-agents.js +121 -0
- package/dist/enabled-models.d.ts +49 -0
- package/dist/enabled-models.js +145 -0
- package/dist/env.d.ts +6 -0
- package/dist/env.js +28 -0
- package/dist/group-join.d.ts +32 -0
- package/dist/group-join.js +116 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +4111 -0
- package/dist/invocation-config.d.ts +108 -0
- package/dist/invocation-config.js +83 -0
- package/dist/memory.d.ts +53 -0
- package/dist/memory.js +165 -0
- package/dist/mention-clone.d.ts +79 -0
- package/dist/mention-clone.js +155 -0
- package/dist/mention.d.ts +81 -0
- package/dist/mention.js +131 -0
- package/dist/model-resolver.d.ts +36 -0
- package/dist/model-resolver.js +95 -0
- package/dist/model-scope.d.ts +49 -0
- package/dist/model-scope.js +48 -0
- package/dist/nested-tools.d.ts +57 -0
- package/dist/nested-tools.js +303 -0
- package/dist/output-file.d.ts +43 -0
- package/dist/output-file.js +153 -0
- package/dist/package-resources.d.ts +240 -0
- package/dist/package-resources.js +519 -0
- package/dist/prompts.d.ts +76 -0
- package/dist/prompts.js +121 -0
- package/dist/schedule-store.d.ts +38 -0
- package/dist/schedule-store.js +155 -0
- package/dist/schedule.d.ts +109 -0
- package/dist/schedule.js +359 -0
- package/dist/settings.d.ts +398 -0
- package/dist/settings.js +282 -0
- package/dist/skill-loader.d.ts +24 -0
- package/dist/skill-loader.js +93 -0
- package/dist/status-note.d.ts +61 -0
- package/dist/status-note.js +85 -0
- package/dist/structured-output.d.ts +61 -0
- package/dist/structured-output.js +112 -0
- package/dist/types.d.ts +391 -0
- package/dist/types.js +5 -0
- package/dist/ui/agent-mention.d.ts +82 -0
- package/dist/ui/agent-mention.js +187 -0
- package/dist/ui/agent-widget.d.ts +219 -0
- package/dist/ui/agent-widget.js +585 -0
- package/dist/ui/conversation-viewer.d.ts +177 -0
- package/dist/ui/conversation-viewer.js +905 -0
- package/dist/ui/fleet-list.d.ts +211 -0
- package/dist/ui/fleet-list.js +489 -0
- package/dist/ui/schedule-menu.d.ts +16 -0
- package/dist/ui/schedule-menu.js +94 -0
- package/dist/ui/select-item.d.ts +27 -0
- package/dist/ui/select-item.js +34 -0
- package/dist/ui/viewer-keys.d.ts +22 -0
- package/dist/ui/viewer-keys.js +18 -0
- package/dist/ui/workflow-card.d.ts +175 -0
- package/dist/ui/workflow-card.js +332 -0
- package/dist/ui/workflow-dialog.d.ts +305 -0
- package/dist/ui/workflow-dialog.js +843 -0
- package/dist/ui/workflow-menu.d.ts +60 -0
- package/dist/ui/workflow-menu.js +147 -0
- package/dist/usage.d.ts +135 -0
- package/dist/usage.js +120 -0
- package/dist/workflow/collisions.d.ts +95 -0
- package/dist/workflow/collisions.js +88 -0
- package/dist/workflow/entry.d.ts +32 -0
- package/dist/workflow/entry.js +29 -0
- package/dist/workflow/host.d.ts +62 -0
- package/dist/workflow/host.js +419 -0
- package/dist/workflow/journal.d.ts +97 -0
- package/dist/workflow/journal.js +120 -0
- package/dist/workflow/json-schema.d.ts +52 -0
- package/dist/workflow/json-schema.js +112 -0
- package/dist/workflow/meta.d.ts +67 -0
- package/dist/workflow/meta.js +317 -0
- package/dist/workflow/progress.d.ts +224 -0
- package/dist/workflow/progress.js +361 -0
- package/dist/workflow/runtime.d.ts +334 -0
- package/dist/workflow/runtime.js +830 -0
- package/dist/workflow/saved.d.ts +102 -0
- package/dist/workflow/saved.js +247 -0
- package/dist/workflow/task.d.ts +136 -0
- package/dist/workflow/task.js +207 -0
- package/dist/workflow/tool-description.d.ts +38 -0
- package/dist/workflow/tool-description.js +199 -0
- package/dist/workflow/worker-source.d.ts +47 -0
- package/dist/workflow/worker-source.js +778 -0
- package/dist/worktree.d.ts +52 -0
- package/dist/worktree.js +164 -0
- package/dist/xml.d.ts +10 -0
- package/dist/xml.js +12 -0
- package/docs/packages.md +211 -0
- package/docs/rpc.md +192 -0
- package/docs/workflows.md +447 -0
- package/examples/agent-tool-description.md +42 -0
- package/examples/workflows/compose.js +51 -0
- package/examples/workflows/fan-out-audit.js +47 -0
- package/examples/workflows/gated-fix.js +60 -0
- package/examples/workflows/lib/count-child.js +27 -0
- package/examples/workflows/review-panel.js +63 -0
- package/examples/workflows/structured-findings.js +78 -0
- package/package.json +74 -4
- package/src/abortable.ts +43 -0
- package/src/agent-color.ts +161 -0
- package/src/agent-file-toggle.ts +297 -0
- package/src/agent-manager.ts +1735 -0
- package/src/agent-record-entry.ts +153 -0
- package/src/agent-runner.ts +1468 -0
- package/src/agent-source-badge.ts +75 -0
- package/src/agent-types.ts +346 -0
- package/src/child-context.ts +15 -0
- package/src/completion-outbox.ts +116 -0
- package/src/context.ts +58 -0
- package/src/cross-extension-rpc.ts +221 -0
- package/src/custom-agents.ts +452 -0
- package/src/default-agents.ts +125 -0
- package/src/enabled-models.ts +180 -0
- package/src/env.ts +33 -0
- package/src/group-join.ts +141 -0
- package/src/index.ts +4433 -0
- package/src/invocation-config.ts +155 -0
- package/src/memory.ts +179 -0
- package/src/mention-clone.ts +195 -0
- package/src/mention.ts +141 -0
- package/src/model-resolver.ts +118 -0
- package/src/model-scope.ts +70 -0
- package/src/nested-tools.ts +426 -0
- package/src/output-file.ts +164 -0
- package/src/package-resources.ts +652 -0
- package/src/prompts.ts +171 -0
- package/src/schedule-store.ts +153 -0
- package/src/schedule.ts +386 -0
- package/src/settings.ts +650 -0
- package/src/skill-loader.ts +102 -0
- package/src/status-note.ts +90 -0
- package/src/structured-output.ts +130 -0
- package/src/types.ts +404 -0
- package/src/ui/agent-mention.ts +216 -0
- package/src/ui/agent-widget.ts +661 -0
- package/src/ui/conversation-viewer.ts +928 -0
- package/src/ui/fleet-list.ts +559 -0
- package/src/ui/schedule-menu.ts +105 -0
- package/src/ui/select-item.ts +45 -0
- package/src/ui/viewer-keys.ts +43 -0
- package/src/ui/workflow-card.ts +470 -0
- package/src/ui/workflow-dialog.ts +1115 -0
- package/src/ui/workflow-menu.ts +193 -0
- package/src/usage.ts +167 -0
- package/src/workflow/collisions.ts +123 -0
- package/src/workflow/entry.ts +47 -0
- package/src/workflow/host.ts +467 -0
- package/src/workflow/journal.ts +164 -0
- package/src/workflow/json-schema.ts +129 -0
- package/src/workflow/meta.ts +325 -0
- package/src/workflow/progress.ts +551 -0
- package/src/workflow/runtime.ts +1219 -0
- package/src/workflow/saved.ts +259 -0
- package/src/workflow/task.ts +302 -0
- package/src/workflow/tool-description.ts +200 -0
- package/src/workflow/worker-source.ts +781 -0
- package/src/worktree.ts +205 -0
- package/src/xml.ts +13 -0
|
@@ -0,0 +1,905 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* conversation-viewer.ts — Live conversation overlay for viewing agent sessions.
|
|
3
|
+
*
|
|
4
|
+
* Displays a scrollable, live-updating view of an agent's conversation.
|
|
5
|
+
* Subscribes to session events for real-time streaming updates.
|
|
6
|
+
*/
|
|
7
|
+
import { getMarkdownTheme, ToolExecutionComponent } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { Input, Markdown, matchesKey, ScrollView, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
9
|
+
import { renderAgentName } from "../agent-color.js";
|
|
10
|
+
import { extractText } from "../context.js";
|
|
11
|
+
import { getLifetimeCost, getLifetimeTotal, getSessionContextPercent } from "../usage.js";
|
|
12
|
+
import { buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatCost, formatDuration, formatSessionTokens, getPromptModeLabel } from "./agent-widget.js";
|
|
13
|
+
import { createViewerKeys } from "./viewer-keys.js";
|
|
14
|
+
/**
|
|
15
|
+
* Whether pi's global theme is initialized.
|
|
16
|
+
*
|
|
17
|
+
* `ToolExecutionComponent` draws with pi's theme rather than the instance this
|
|
18
|
+
* viewer is handed, and pi throws on the first color lookup when nothing called
|
|
19
|
+
* `initTheme()`. This viewer is constructed outside pi too — its own unit tests,
|
|
20
|
+
* an embedded session — so the tool path is gated on a probe, the same way
|
|
21
|
+
* `resolveMarkdownTheme` already treats pi's Markdown theme.
|
|
22
|
+
*/
|
|
23
|
+
function piThemeAvailable() {
|
|
24
|
+
try {
|
|
25
|
+
getMarkdownTheme().heading("probe");
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
|
|
33
|
+
const CHROME_LINES_BASE = 6;
|
|
34
|
+
const MIN_VIEWPORT = 3;
|
|
35
|
+
/** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
|
|
36
|
+
export const VIEWPORT_HEIGHT_PCT = 70;
|
|
37
|
+
const SCROLLBAR_WIDTH = 1;
|
|
38
|
+
/** Follow the active renderer, not a setting that may require a restart. */
|
|
39
|
+
export function getConversationOverlayOptions(tui) {
|
|
40
|
+
return tui.mode === "fullscreen"
|
|
41
|
+
? { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 }
|
|
42
|
+
: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Cap on a single tool result or bash output before the viewer elides the rest.
|
|
46
|
+
*
|
|
47
|
+
* The cap is not cosmetic — it bounds render cost. `buildContentLines()` runs on
|
|
48
|
+
* every render *and* on every scroll key (`handleInput` calls it to compute
|
|
49
|
+
* `maxScroll`), so an uncapped 200 KB result costs ~6 ms per keystroke to parse
|
|
50
|
+
* as Markdown, against ~0.5 ms once capped and effectively nothing on a cache
|
|
51
|
+
* hit (best of 5, width 76). 16 KB is roughly a screenful at every terminal size
|
|
52
|
+
* and still ~30x the 500 characters this replaces, which was small enough to cut
|
|
53
|
+
* most real results mid-sentence.
|
|
54
|
+
*/
|
|
55
|
+
export const RESULT_MAX_CHARS = 16_000;
|
|
56
|
+
/** Cycle order for the viewer's `m` key. */
|
|
57
|
+
const MARKDOWN_MODES = ["off", "assistant", "all"];
|
|
58
|
+
/** Footer labels — short, because the idle footer is already full at 80 columns. */
|
|
59
|
+
const MARKDOWN_MODE_LABELS = {
|
|
60
|
+
off: "raw",
|
|
61
|
+
assistant: "md",
|
|
62
|
+
all: "md+",
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Both options keep the renderer from *rewriting* source that only looks like
|
|
66
|
+
* Markdown: without them `3) a / 7) b / 9) c` comes back renumbered `3. 4. 5.`
|
|
67
|
+
* and backslash escapes are normalized away. Neither is a safe edit to make to
|
|
68
|
+
* a tool's output, and both are cheap to switch off.
|
|
69
|
+
*/
|
|
70
|
+
const MARKDOWN_OPTIONS = {
|
|
71
|
+
preserveOrderedListMarkers: true,
|
|
72
|
+
preserveBackslashEscapes: true,
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Pi's own Markdown theme when this process has one, else a theme built from the
|
|
76
|
+
* viewer's `Theme`.
|
|
77
|
+
*
|
|
78
|
+
* Preferring pi's is what buys syntax-highlighted code fences (it carries a
|
|
79
|
+
* `highlightCode`), and it keeps this surface consistent with the notification
|
|
80
|
+
* renderer, which uses the same source. It has to be *probed* rather than
|
|
81
|
+
* try/caught around the call: `getMarkdownTheme()` returns arrow functions that
|
|
82
|
+
* read pi's global theme lazily, so an uninitialized theme throws inside
|
|
83
|
+
* `render()` — long after this returns — and takes the overlay with it. That is
|
|
84
|
+
* the case in tests and any embedded session that never called `initTheme()`.
|
|
85
|
+
*/
|
|
86
|
+
function resolveMarkdownTheme(th) {
|
|
87
|
+
try {
|
|
88
|
+
const piTheme = getMarkdownTheme();
|
|
89
|
+
piTheme.heading("probe");
|
|
90
|
+
return piTheme;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return fallbackMarkdownTheme(th);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* `Theme` carries only `fg` and `bold`, so the three remaining styles are
|
|
98
|
+
* written as raw SGR. Rendering them as plain text instead would silently drop
|
|
99
|
+
* `*emphasis*`'s markers with nothing in their place, turning a formatting
|
|
100
|
+
* change into a content change.
|
|
101
|
+
*/
|
|
102
|
+
function fallbackMarkdownTheme(th) {
|
|
103
|
+
const sgr = (on, off) => (text) => `\x1b[${on}m${text}\x1b[${off}m`;
|
|
104
|
+
return {
|
|
105
|
+
heading: text => th.bold(th.fg("accent", text)),
|
|
106
|
+
link: text => th.fg("accent", text),
|
|
107
|
+
linkUrl: text => th.fg("muted", text),
|
|
108
|
+
code: text => th.fg("muted", text),
|
|
109
|
+
codeBlock: text => th.fg("muted", text),
|
|
110
|
+
codeBlockBorder: text => th.fg("dim", text),
|
|
111
|
+
quote: text => th.fg("muted", text),
|
|
112
|
+
quoteBorder: text => th.fg("dim", text),
|
|
113
|
+
hr: text => th.fg("dim", text),
|
|
114
|
+
listBullet: text => th.fg("accent", text),
|
|
115
|
+
bold: text => th.bold(text),
|
|
116
|
+
italic: sgr(3, 23),
|
|
117
|
+
underline: sgr(4, 24),
|
|
118
|
+
strikethrough: sgr(9, 29),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Cap `text` at `RESULT_MAX_CHARS`, reporting the elision separately rather than
|
|
123
|
+
* appending it.
|
|
124
|
+
*
|
|
125
|
+
* Separately because the notice is the viewer's chrome, not the tool's output.
|
|
126
|
+
* Appended into the string it becomes content: a cut landing inside a fenced
|
|
127
|
+
* code block — likely, on exactly the large `ctx_execute` results this is for —
|
|
128
|
+
* renders the notice as a line of source inside the fence.
|
|
129
|
+
*/
|
|
130
|
+
function capResult(text) {
|
|
131
|
+
if (text.length <= RESULT_MAX_CHARS)
|
|
132
|
+
return { text, elided: 0 };
|
|
133
|
+
return {
|
|
134
|
+
text: text.slice(0, RESULT_MAX_CHARS),
|
|
135
|
+
elided: text.length - RESULT_MAX_CHARS,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* `999` · `1.5k` · `8.4M` — a magnitude cue, not an exact count, past 1000.
|
|
140
|
+
*
|
|
141
|
+
* The bracket is chosen against the *rounded* value, so 999,999 reads `1M`
|
|
142
|
+
* rather than the `1000.0k` a naive `< 1e6` test produces.
|
|
143
|
+
*/
|
|
144
|
+
function humanCount(n) {
|
|
145
|
+
if (n < 1_000)
|
|
146
|
+
return `${n}`;
|
|
147
|
+
const thousands = n < 999_950;
|
|
148
|
+
const value = thousands ? n / 1_000 : n / 1_000_000;
|
|
149
|
+
return `${value.toFixed(1).replace(/\.0$/, "")}${thousands ? "k" : "M"}`;
|
|
150
|
+
}
|
|
151
|
+
function truncationNote(elided) {
|
|
152
|
+
return `... (truncated, ${humanCount(elided)} more character${elided === 1 ? "" : "s"})`;
|
|
153
|
+
}
|
|
154
|
+
export class ConversationViewer {
|
|
155
|
+
tui;
|
|
156
|
+
session;
|
|
157
|
+
record;
|
|
158
|
+
activity;
|
|
159
|
+
theme;
|
|
160
|
+
done;
|
|
161
|
+
onStop;
|
|
162
|
+
onSteer;
|
|
163
|
+
showCost;
|
|
164
|
+
viewerMarkdown;
|
|
165
|
+
onMarkdownMode;
|
|
166
|
+
onBackground;
|
|
167
|
+
scrollOffset = 0;
|
|
168
|
+
autoScroll = true;
|
|
169
|
+
focused = false;
|
|
170
|
+
fullscreen;
|
|
171
|
+
viewport;
|
|
172
|
+
scrollbarGrabOffset;
|
|
173
|
+
footerTargets = [];
|
|
174
|
+
latestTarget;
|
|
175
|
+
newMessages = 0;
|
|
176
|
+
scrollView;
|
|
177
|
+
unsubscribe;
|
|
178
|
+
lastInnerW = 0;
|
|
179
|
+
closed = false;
|
|
180
|
+
/** Two-press confirm guard for the stop key, so a stray key can't kill the agent. */
|
|
181
|
+
stopArmed = false;
|
|
182
|
+
keys;
|
|
183
|
+
/** Steering composer — present while the user is typing a message to the agent. */
|
|
184
|
+
composer;
|
|
185
|
+
/** Resolved once: pi's Markdown theme is fixed for the life of the process. */
|
|
186
|
+
markdownTheme;
|
|
187
|
+
/** Set by the `m` key. Wins over the setting so `m` works without a persist hook. */
|
|
188
|
+
markdownModeOverride;
|
|
189
|
+
/**
|
|
190
|
+
* One `Markdown` per message, so its own text/width cache does the work. A
|
|
191
|
+
* fresh instance per render would re-parse the whole transcript on every
|
|
192
|
+
* keystroke — the component caches, but only across calls to the same object.
|
|
193
|
+
* Weak so a compacted-away message doesn't pin its render.
|
|
194
|
+
*/
|
|
195
|
+
markdownCache = new WeakMap();
|
|
196
|
+
/** One block per tool call id, so a redraw reuses the component's own caches. */
|
|
197
|
+
toolBlocks = new Map();
|
|
198
|
+
/**
|
|
199
|
+
* Whether calls render through pi's components. Probe-gated: without pi's
|
|
200
|
+
* theme the viewer keeps its own plain rendering rather than throwing.
|
|
201
|
+
*/
|
|
202
|
+
toolRendering = piThemeAvailable();
|
|
203
|
+
/** Whether tool blocks show their full output; toggled with pi's expand key. */
|
|
204
|
+
toolsExpanded = false;
|
|
205
|
+
constructor(tui, session, record, activity, theme, done,
|
|
206
|
+
/** Abort the agent shown here. Omitted → no stop affordance (e.g. read-only history). */
|
|
207
|
+
onStop,
|
|
208
|
+
/** User keybindings from `ctx.ui.custom()`. Omitted → hardcoded defaults. */
|
|
209
|
+
keybindings,
|
|
210
|
+
/** Send a steering message to the agent. Omitted → no compose affordance. */
|
|
211
|
+
onSteer,
|
|
212
|
+
/**
|
|
213
|
+
* Whether the header shows an estimated cost after the token count. Read
|
|
214
|
+
* once, at construction: the overlay is opened from a menu, so the setting
|
|
215
|
+
* cannot change while it is on screen.
|
|
216
|
+
*/
|
|
217
|
+
showCost = false,
|
|
218
|
+
/**
|
|
219
|
+
* The current `viewerMarkdown` setting. Read live rather than captured,
|
|
220
|
+
* unlike `showCost`: `m` changes it while the overlay is on screen.
|
|
221
|
+
* Omitted → `assistant`.
|
|
222
|
+
*/
|
|
223
|
+
viewerMarkdown,
|
|
224
|
+
/**
|
|
225
|
+
* Persist a mode chosen with `m`, so the key and `/agents → Settings` mean
|
|
226
|
+
* the same thing. Omitted → `m` still cycles, viewer-locally.
|
|
227
|
+
*/
|
|
228
|
+
onMarkdownMode,
|
|
229
|
+
/** Pi's fullscreen scrollbar preference, captured when the observer opens. */
|
|
230
|
+
scrollbarMode = "auto",
|
|
231
|
+
/**
|
|
232
|
+
* Hand this run to the background. `available` is read live — it turns
|
|
233
|
+
* false the instant the handoff lands or the run ends. Omitted → no `b`
|
|
234
|
+
* affordance (history views).
|
|
235
|
+
*/
|
|
236
|
+
onBackground) {
|
|
237
|
+
this.tui = tui;
|
|
238
|
+
this.session = session;
|
|
239
|
+
this.record = record;
|
|
240
|
+
this.activity = activity;
|
|
241
|
+
this.theme = theme;
|
|
242
|
+
this.done = done;
|
|
243
|
+
this.onStop = onStop;
|
|
244
|
+
this.onSteer = onSteer;
|
|
245
|
+
this.showCost = showCost;
|
|
246
|
+
this.viewerMarkdown = viewerMarkdown;
|
|
247
|
+
this.onMarkdownMode = onMarkdownMode;
|
|
248
|
+
this.onBackground = onBackground;
|
|
249
|
+
this.fullscreen = tui.mode === "fullscreen";
|
|
250
|
+
this.markdownTheme = resolveMarkdownTheme(theme);
|
|
251
|
+
this.keys = createViewerKeys(keybindings);
|
|
252
|
+
this.scrollView = new ScrollView({
|
|
253
|
+
render: width => this.buildContentLines(width),
|
|
254
|
+
invalidate: () => { },
|
|
255
|
+
}, { follow: "end", scrollbar: this.fullscreen ? scrollbarMode : "hidden" });
|
|
256
|
+
this.unsubscribe = session.subscribe(event => {
|
|
257
|
+
if (this.closed)
|
|
258
|
+
return;
|
|
259
|
+
if (this.fullscreen && !this.scrollView.isFollowingEnd && event.type === "message_start" && ["user", "assistant", "toolResult", "bashExecution"].includes(event.message.role)) {
|
|
260
|
+
this.newMessages++;
|
|
261
|
+
}
|
|
262
|
+
this.tui.requestRender();
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
handleInput(data) {
|
|
266
|
+
if (this.closed)
|
|
267
|
+
return;
|
|
268
|
+
if (this.fullscreen && (matchesKey(data, "ctrl+end") || (!this.composer && matchesKey(data, "end")))) {
|
|
269
|
+
this.jumpToLatest();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
// While composing a steer message, the input owns all keys (Enter sends,
|
|
273
|
+
// Esc cancels — both wired in openComposer()). Editing keys flow through.
|
|
274
|
+
if (this.composer) {
|
|
275
|
+
this.composer.handleInput(data);
|
|
276
|
+
this.tui.requestRender();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c") || matchesKey(data, "q")) {
|
|
280
|
+
this.closed = true;
|
|
281
|
+
this.done(undefined);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
// Enter opens the steering composer (only while the agent can still be
|
|
285
|
+
// steered) — then type + Enter sends, Esc or an empty submit returns. When
|
|
286
|
+
// not steerable, fall through so the key still disarms a pending stop.
|
|
287
|
+
if (matchesKey(data, "enter") && this.canSteer()) {
|
|
288
|
+
this.stopArmed = false;
|
|
289
|
+
this.openComposer();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
// Hand a live foreground run to the background. Single press, unlike stop:
|
|
293
|
+
// the transition is one-way but keeps the agent working, so a stray key
|
|
294
|
+
// costs a notification rather than the run.
|
|
295
|
+
if (matchesKey(data, "b") && this.canSendToBackground()) {
|
|
296
|
+
this.stopArmed = false;
|
|
297
|
+
this.onBackground?.send();
|
|
298
|
+
this.tui.requestRender();
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
// Stop/abort the agent (only while it can still be stopped). Two-press:
|
|
302
|
+
// first "x" arms, second confirms — any other key disarms.
|
|
303
|
+
if (matchesKey(data, "x")) {
|
|
304
|
+
if (this.isStoppable()) {
|
|
305
|
+
if (this.stopArmed) {
|
|
306
|
+
this.stopArmed = false;
|
|
307
|
+
this.onStop?.();
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
this.stopArmed = true;
|
|
311
|
+
}
|
|
312
|
+
this.tui.requestRender();
|
|
313
|
+
}
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
// Cycle raw → assistant-only → everything. The escape hatch that makes
|
|
317
|
+
// Markdown rendering safe to default on: a result the renderer reshapes
|
|
318
|
+
// (a diff, an indented log, a `#`-commented script) is one key from verbatim.
|
|
319
|
+
if (matchesKey(data, "m")) {
|
|
320
|
+
this.stopArmed = false;
|
|
321
|
+
const next = MARKDOWN_MODES[(MARKDOWN_MODES.indexOf(this.markdownMode()) + 1) % MARKDOWN_MODES.length];
|
|
322
|
+
this.markdownModeOverride = next;
|
|
323
|
+
this.onMarkdownMode?.(next);
|
|
324
|
+
this.tui.requestRender();
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (this.stopArmed)
|
|
328
|
+
this.stopArmed = false;
|
|
329
|
+
// Unfold tool output — the same key, and the same meaning, as in the main
|
|
330
|
+
// transcript. It is what the renderers' own elision notices refer to.
|
|
331
|
+
if (this.keys.expand(data)) {
|
|
332
|
+
this.toolsExpanded = !this.toolsExpanded;
|
|
333
|
+
for (const block of this.toolBlocks.values()) {
|
|
334
|
+
block.component.setExpanded(this.toolsExpanded);
|
|
335
|
+
block.rows = undefined;
|
|
336
|
+
}
|
|
337
|
+
this.tui.requestRender();
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const totalLines = this.buildContentLines(this.lastInnerW).length;
|
|
341
|
+
const viewportHeight = this.viewportHeight();
|
|
342
|
+
if (this.fullscreen) {
|
|
343
|
+
this.scrollView.updateLayout(totalLines, viewportHeight, () => {
|
|
344
|
+
if (!this.closed)
|
|
345
|
+
this.tui.requestRender();
|
|
346
|
+
});
|
|
347
|
+
if (this.keys.scrollUp(data))
|
|
348
|
+
this.scrollView.scrollBy(-1);
|
|
349
|
+
else if (this.keys.scrollDown(data))
|
|
350
|
+
this.scrollView.scrollBy(1);
|
|
351
|
+
else if (this.keys.pageUp(data))
|
|
352
|
+
this.scrollView.scrollBy(-viewportHeight);
|
|
353
|
+
else if (this.keys.pageDown(data))
|
|
354
|
+
this.scrollView.scrollBy(viewportHeight);
|
|
355
|
+
else if (matchesKey(data, "home"))
|
|
356
|
+
this.scrollView.scrollToStart();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const maxScroll = Math.max(0, totalLines - viewportHeight);
|
|
360
|
+
if (this.keys.scrollUp(data)) {
|
|
361
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
|
|
362
|
+
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
363
|
+
}
|
|
364
|
+
else if (this.keys.scrollDown(data)) {
|
|
365
|
+
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
|
|
366
|
+
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
367
|
+
}
|
|
368
|
+
else if (this.keys.pageUp(data)) {
|
|
369
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
|
|
370
|
+
this.autoScroll = false;
|
|
371
|
+
}
|
|
372
|
+
else if (this.keys.pageDown(data)) {
|
|
373
|
+
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
|
|
374
|
+
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
375
|
+
}
|
|
376
|
+
else if (matchesKey(data, "home")) {
|
|
377
|
+
this.scrollOffset = 0;
|
|
378
|
+
this.autoScroll = false;
|
|
379
|
+
}
|
|
380
|
+
else if (matchesKey(data, "end")) {
|
|
381
|
+
this.scrollOffset = maxScroll;
|
|
382
|
+
this.autoScroll = true;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
jumpToLatest() {
|
|
386
|
+
this.scrollView.scrollToEnd();
|
|
387
|
+
this.newMessages = 0;
|
|
388
|
+
this.stopArmed = false;
|
|
389
|
+
this.tui.requestRender();
|
|
390
|
+
}
|
|
391
|
+
handleMouse(event) {
|
|
392
|
+
if (!this.fullscreen)
|
|
393
|
+
return { handled: false };
|
|
394
|
+
// Every cell belongs to this observer. Even unused clicks/drags must not
|
|
395
|
+
// fall through to Pi's transcript selection, scrolling, or editor.
|
|
396
|
+
if (this.closed || !this.viewport)
|
|
397
|
+
return { handled: true, render: false };
|
|
398
|
+
const view = this.viewport;
|
|
399
|
+
const onScrollbar = this.scrollView.scrollbar !== "hidden" && event.x >= view.width - SCROLLBAR_WIDTH && event.x < view.width && event.y >= view.top && event.y < view.top + view.height;
|
|
400
|
+
if (event.type === "move") {
|
|
401
|
+
this.scrollView.setScrollbarActive(onScrollbar);
|
|
402
|
+
return { handled: true, render: false };
|
|
403
|
+
}
|
|
404
|
+
if (event.type === "wheel") {
|
|
405
|
+
this.stopArmed = false;
|
|
406
|
+
this.scrollView.scrollBy(event.wheelDelta ?? 0);
|
|
407
|
+
return { handled: true, render: true };
|
|
408
|
+
}
|
|
409
|
+
if (event.type === "release") {
|
|
410
|
+
const wasDragging = this.scrollbarGrabOffset !== undefined;
|
|
411
|
+
this.scrollbarGrabOffset = undefined;
|
|
412
|
+
this.scrollView.setScrollbarActive(onScrollbar);
|
|
413
|
+
return { handled: true, render: wasDragging };
|
|
414
|
+
}
|
|
415
|
+
if (event.type === "drag" && this.scrollbarGrabOffset !== undefined) {
|
|
416
|
+
const travel = view.height - view.thumbHeight;
|
|
417
|
+
const position = Math.max(0, Math.min(travel, event.y - view.top - this.scrollbarGrabOffset));
|
|
418
|
+
this.scrollView.scrollTo(travel > 0 ? Math.round(position / travel * view.maxScroll) : 0);
|
|
419
|
+
return { handled: true, render: true };
|
|
420
|
+
}
|
|
421
|
+
if (event.type === "press" && event.button === "left") {
|
|
422
|
+
this.scrollbarGrabOffset = undefined;
|
|
423
|
+
this.scrollView.setScrollbarActive(onScrollbar);
|
|
424
|
+
if (this.latestTarget && event.y === this.latestTarget.row && event.x >= this.latestTarget.start && event.x < this.latestTarget.end) {
|
|
425
|
+
this.jumpToLatest();
|
|
426
|
+
return { handled: true, render: true };
|
|
427
|
+
}
|
|
428
|
+
if (event.y === this.tui.terminal.rows - 1) {
|
|
429
|
+
const target = this.footerTargets.find(t => event.x >= t.start && event.x < t.end);
|
|
430
|
+
if (target) {
|
|
431
|
+
this.handleInput(target.key);
|
|
432
|
+
return { handled: true, render: true };
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
this.stopArmed = false;
|
|
436
|
+
if (this.composer && event.y === this.tui.terminal.rows - 2) {
|
|
437
|
+
this.composer.handleMouse({ ...event, x: event.x - 1, y: 0, width: this.lastInnerW, height: 1 });
|
|
438
|
+
return { handled: true, render: true };
|
|
439
|
+
}
|
|
440
|
+
if (onScrollbar && view.maxScroll > 0) {
|
|
441
|
+
const y = event.y - view.top;
|
|
442
|
+
if (y >= view.thumbTop && y < view.thumbTop + view.thumbHeight) {
|
|
443
|
+
this.scrollbarGrabOffset = y - view.thumbTop;
|
|
444
|
+
return { handled: true, capture: true, render: true };
|
|
445
|
+
}
|
|
446
|
+
this.scrollView.scrollBy(y < view.thumbTop ? -view.height : view.height);
|
|
447
|
+
// A track click still pages, but holding it can grab the thumb too.
|
|
448
|
+
this.scrollbarGrabOffset = Math.floor(view.thumbHeight / 2);
|
|
449
|
+
return { handled: true, capture: true, render: true };
|
|
450
|
+
}
|
|
451
|
+
return { handled: true, render: true };
|
|
452
|
+
}
|
|
453
|
+
return { handled: true, render: false };
|
|
454
|
+
}
|
|
455
|
+
render(width) {
|
|
456
|
+
this.footerTargets = [];
|
|
457
|
+
this.latestTarget = undefined;
|
|
458
|
+
this.viewport = undefined;
|
|
459
|
+
const rows = Math.max(0, this.tui.terminal.rows);
|
|
460
|
+
if (!this.fullscreen && width < 6)
|
|
461
|
+
return []; // too narrow for any meaningful rendering
|
|
462
|
+
if (width <= SCROLLBAR_WIDTH + 1)
|
|
463
|
+
return Array.from({ length: rows }, () => " ".repeat(Math.max(0, width)));
|
|
464
|
+
const th = this.theme;
|
|
465
|
+
const innerW = this.fullscreen ? this.scrollView.getContentWidth(width - 1) : width - 4;
|
|
466
|
+
this.lastInnerW = innerW;
|
|
467
|
+
const lines = [];
|
|
468
|
+
const footerActions = [];
|
|
469
|
+
const pad = (s, len) => {
|
|
470
|
+
const vis = visibleWidth(s);
|
|
471
|
+
return s + " ".repeat(Math.max(0, len - vis));
|
|
472
|
+
};
|
|
473
|
+
const row = (content, scrollbar) => {
|
|
474
|
+
if (this.fullscreen) {
|
|
475
|
+
const contentWidth = width - 1 - (scrollbar ? SCROLLBAR_WIDTH : 0);
|
|
476
|
+
return " " + truncateToWidth(content, contentWidth, scrollbar ? "" : "...", true) + (scrollbar ?? "");
|
|
477
|
+
}
|
|
478
|
+
return th.fg("border", "│") + " " + truncateToWidth(pad(content, innerW), innerW, "...", true) + " " + th.fg("border", "│");
|
|
479
|
+
};
|
|
480
|
+
const hrTop = th.fg("border", `╭${"─".repeat(width - 2)}╮`);
|
|
481
|
+
const hrBot = th.fg("border", `╰${"─".repeat(width - 2)}╯`);
|
|
482
|
+
const hrMid = row(th.fg("dim", "─".repeat(innerW)));
|
|
483
|
+
// Header
|
|
484
|
+
if (!this.fullscreen)
|
|
485
|
+
lines.push(hrTop);
|
|
486
|
+
const modeLabel = getPromptModeLabel(this.record.type);
|
|
487
|
+
const modeTag = modeLabel ? ` ${th.fg("dim", `(${modeLabel})`)}` : "";
|
|
488
|
+
const statusIcon = this.record.status === "running"
|
|
489
|
+
? th.fg("accent", "●")
|
|
490
|
+
: this.record.status === "completed"
|
|
491
|
+
? th.fg("success", "✓")
|
|
492
|
+
: this.record.status === "error"
|
|
493
|
+
? th.fg("error", "✗")
|
|
494
|
+
: th.fg("dim", "○");
|
|
495
|
+
const duration = formatDuration(this.record.startedAt, this.record.completedAt);
|
|
496
|
+
const headerParts = [duration];
|
|
497
|
+
const toolUses = this.activity?.toolUses ?? this.record.toolUses;
|
|
498
|
+
if (toolUses > 0)
|
|
499
|
+
headerParts.unshift(`${toolUses} tool${toolUses === 1 ? "" : "s"}`);
|
|
500
|
+
// Spend from the record, context from the live session: the record is the
|
|
501
|
+
// only total that survives the agent finishing and the only one carrying a
|
|
502
|
+
// nested child's spend.
|
|
503
|
+
const tokens = getLifetimeTotal(this.record.lifetimeUsage);
|
|
504
|
+
if (tokens > 0) {
|
|
505
|
+
const percent = getSessionContextPercent(this.activity?.session);
|
|
506
|
+
headerParts.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount));
|
|
507
|
+
}
|
|
508
|
+
const cost = this.showCost ? formatCost(getLifetimeCost(this.record.lifetimeUsage)) : "";
|
|
509
|
+
if (cost)
|
|
510
|
+
headerParts.push(cost);
|
|
511
|
+
lines.push(row(`${statusIcon} ${renderAgentName(this.record.type, th, { bold: true })}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${fgPreservingNestedStyles(th, "dim", headerParts.join(" · "))}`));
|
|
512
|
+
const invocationLine = this.invocationLine();
|
|
513
|
+
if (invocationLine)
|
|
514
|
+
lines.push(row(invocationLine));
|
|
515
|
+
lines.push(hrMid);
|
|
516
|
+
// Content area — rebuild every render (live data, no cache needed)
|
|
517
|
+
const contentLines = this.fullscreen ? this.scrollView.render(width - 1) : this.buildContentLines(innerW);
|
|
518
|
+
const viewportHeight = this.viewportHeight();
|
|
519
|
+
const maxScroll = Math.max(0, contentLines.length - viewportHeight);
|
|
520
|
+
if (this.fullscreen) {
|
|
521
|
+
this.scrollView.updateLayout(contentLines.length, viewportHeight, () => {
|
|
522
|
+
if (!this.closed)
|
|
523
|
+
this.tui.requestRender();
|
|
524
|
+
});
|
|
525
|
+
if (this.scrollView.isFollowingEnd)
|
|
526
|
+
this.newMessages = 0;
|
|
527
|
+
}
|
|
528
|
+
else if (this.autoScroll) {
|
|
529
|
+
this.scrollOffset = maxScroll;
|
|
530
|
+
}
|
|
531
|
+
const visibleStart = this.fullscreen ? this.scrollView.scrollTop : Math.min(this.scrollOffset, maxScroll);
|
|
532
|
+
const visible = contentLines.slice(visibleStart, visibleStart + viewportHeight);
|
|
533
|
+
// Match Pi's scrollbar geometry: rounded size, at least two rows when available.
|
|
534
|
+
const thumbHeight = Math.min(viewportHeight, Math.max(2, Math.round(viewportHeight * viewportHeight / Math.max(1, contentLines.length))));
|
|
535
|
+
const thumbTop = maxScroll > 0 ? Math.round(visibleStart / maxScroll * (viewportHeight - thumbHeight)) : 0;
|
|
536
|
+
this.viewport = { width, top: lines.length, height: viewportHeight, maxScroll, thumbTop, thumbHeight };
|
|
537
|
+
for (let i = 0; i < viewportHeight; i++) {
|
|
538
|
+
let scrollbar;
|
|
539
|
+
if (this.scrollView.isScrollbarVisible) {
|
|
540
|
+
const onThumb = i >= thumbTop && i < thumbTop + thumbHeight;
|
|
541
|
+
if (onThumb) {
|
|
542
|
+
scrollbar = th.fg("scrollbarThumb", this.scrollView.isScrollbarActive ? "█" : "┃");
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
scrollbar = th.fg("scrollbarTrack", "│");
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
lines.push(row(visible[i] ?? "", scrollbar));
|
|
549
|
+
}
|
|
550
|
+
// Keep the divider pinned above the footer, with the jump control centered
|
|
551
|
+
// inside it. Neither scrolling nor new messages change its position.
|
|
552
|
+
if (this.fullscreen && !this.scrollView.isFollowingEnd && maxScroll > 0 && rows >= this.chromeLines()) {
|
|
553
|
+
const text = this.newMessages > 0
|
|
554
|
+
? `${this.newMessages} new message${this.newMessages === 1 ? "" : "s"}`
|
|
555
|
+
: "Jump to latest message";
|
|
556
|
+
const label = truncateToWidth(`[ ↓ ${text} · Ctrl+End ]`, innerW, "…");
|
|
557
|
+
const labelWidth = visibleWidth(label);
|
|
558
|
+
const padding = Math.floor((innerW - labelWidth) / 2);
|
|
559
|
+
this.latestTarget = { row: lines.length, start: padding + 1, end: padding + 1 + labelWidth };
|
|
560
|
+
lines.push(row(th.fg("dim", "─".repeat(padding)) + th.fg("accent", label) + th.fg("dim", "─".repeat(innerW - padding - labelWidth))));
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
563
|
+
lines.push(hrMid);
|
|
564
|
+
}
|
|
565
|
+
if (this.composer) {
|
|
566
|
+
// Composer row: the Input renders its own `> ` prompt and cursor.
|
|
567
|
+
this.composer.focused = this.focused;
|
|
568
|
+
lines.push(row(this.composer.render(innerW)[0] ?? ""));
|
|
569
|
+
footerActions.push({ label: "Enter send", key: "\r" }, { label: "Esc cancel", key: "\x1b" });
|
|
570
|
+
const composeHint = th.fg("dim", "Enter send · Esc cancel");
|
|
571
|
+
const composeLeft = th.fg("accent", "✎ steer");
|
|
572
|
+
const composeGap = Math.max(1, innerW - visibleWidth(composeLeft) - visibleWidth(composeHint));
|
|
573
|
+
lines.push(row(composeLeft + " ".repeat(composeGap) + composeHint));
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
// Actions on the left, navigation on the right. The scroll hint keeps its
|
|
577
|
+
// full key list so the less-obvious bindings stay discoverable; it leads
|
|
578
|
+
// the right group so "Esc close" is the only part that truncates first.
|
|
579
|
+
const sep = th.fg("dim", " · ");
|
|
580
|
+
if (this.canSteer())
|
|
581
|
+
footerActions.push({ label: "Enter steer", key: "\r" });
|
|
582
|
+
// Abbreviated for the same reason as the Markdown group below.
|
|
583
|
+
if (this.canSendToBackground())
|
|
584
|
+
footerActions.push({ label: "b bg", key: "b" });
|
|
585
|
+
if (this.isStoppable()) {
|
|
586
|
+
footerActions.push({ label: this.stopArmed ? "x again to STOP" : "x stop", key: "x" });
|
|
587
|
+
}
|
|
588
|
+
// Abbreviated (`raw`/`md`/`md+`) because the idle footer is already full
|
|
589
|
+
// at 80 columns with steer + stop present, and this group has no
|
|
590
|
+
// degradation step below "drop the line-count readout".
|
|
591
|
+
footerActions.push({ label: `m ${MARKDOWN_MODE_LABELS[this.markdownMode()]}`, key: "m" });
|
|
592
|
+
const actions = footerActions.map(a => th.fg(a.key === "x" && this.stopArmed ? "error" : "dim", a.label));
|
|
593
|
+
footerActions.push({ label: "Esc close", key: "\x1b" });
|
|
594
|
+
const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close");
|
|
595
|
+
// Prepend the line-count/scroll-% readout only when there's spare width —
|
|
596
|
+
// it's the first thing dropped so it never crowds out the hints.
|
|
597
|
+
const scrollPct = contentLines.length <= viewportHeight
|
|
598
|
+
? "100%"
|
|
599
|
+
: `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
|
|
600
|
+
const count = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
|
|
601
|
+
const withCount = [count, ...actions].join(sep);
|
|
602
|
+
const footerLeft = visibleWidth(withCount) + visibleWidth(footerRight) + 1 <= innerW
|
|
603
|
+
? withCount
|
|
604
|
+
: actions.join(sep);
|
|
605
|
+
const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
|
|
606
|
+
lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
|
|
607
|
+
}
|
|
608
|
+
if (!this.fullscreen) {
|
|
609
|
+
lines.push(hrBot);
|
|
610
|
+
return lines;
|
|
611
|
+
}
|
|
612
|
+
// Keep the footer on screen even in a terminal shorter than the chrome.
|
|
613
|
+
const footer = lines.pop() ?? row("");
|
|
614
|
+
const output = rows > 0 ? [...lines.slice(0, rows - 1), footer] : [];
|
|
615
|
+
const footerText = stripTerminalSequences(footer);
|
|
616
|
+
for (const action of footerActions) {
|
|
617
|
+
const index = footerText.indexOf(action.label);
|
|
618
|
+
if (index < 0)
|
|
619
|
+
continue; // a clipped label is not a clickable control
|
|
620
|
+
const start = visibleWidth(footerText.slice(0, index));
|
|
621
|
+
this.footerTargets.push({ start, end: start + visibleWidth(action.label), key: action.key });
|
|
622
|
+
}
|
|
623
|
+
return output;
|
|
624
|
+
}
|
|
625
|
+
/** Stoppable only when a stop handler exists and the agent is still active. */
|
|
626
|
+
isStoppable() {
|
|
627
|
+
return !!this.onStop && (this.record.status === "running" || this.record.status === "queued");
|
|
628
|
+
}
|
|
629
|
+
/** The mode in force: an `m` press, else the setting, else the default. */
|
|
630
|
+
markdownMode() {
|
|
631
|
+
return this.markdownModeOverride ?? this.viewerMarkdown?.() ?? "assistant";
|
|
632
|
+
}
|
|
633
|
+
/** Wrap `text` literally — the pre-Markdown path, and the fallback from it. */
|
|
634
|
+
rawLines(text, width, dim) {
|
|
635
|
+
const lines = wrapTextWithAnsi(text, width);
|
|
636
|
+
return dim ? lines.map(l => this.theme.fg("dim", l)) : lines;
|
|
637
|
+
}
|
|
638
|
+
/** Render `text` as Markdown, reusing this message's component instance. */
|
|
639
|
+
markdownLines(msg, text, width, dim) {
|
|
640
|
+
let entry = this.markdownCache.get(msg);
|
|
641
|
+
if (!entry) {
|
|
642
|
+
entry = {
|
|
643
|
+
md: new Markdown(text, 0, 0, this.markdownTheme,
|
|
644
|
+
// Keeps result prose visually receded, the way the raw path's
|
|
645
|
+
// per-line `fg("dim", …)` did. Fenced code is the exception and is
|
|
646
|
+
// left alone deliberately: pi's theme highlights it with its own
|
|
647
|
+
// colors, which this would otherwise flatten.
|
|
648
|
+
dim ? { color: (t) => this.theme.fg("dim", t) } : undefined, MARKDOWN_OPTIONS),
|
|
649
|
+
text,
|
|
650
|
+
};
|
|
651
|
+
this.markdownCache.set(msg, entry);
|
|
652
|
+
}
|
|
653
|
+
else if (entry.text !== text) {
|
|
654
|
+
// Streaming: the message object is stable, its text grows. A failed
|
|
655
|
+
// prefix remains unsafe after append-only deltas, so retry only when the
|
|
656
|
+
// content was replaced or truncated.
|
|
657
|
+
const shouldRetry = !text.startsWith(entry.text);
|
|
658
|
+
entry.md.setText(text);
|
|
659
|
+
entry.text = text;
|
|
660
|
+
if (shouldRetry)
|
|
661
|
+
entry.failed = false;
|
|
662
|
+
}
|
|
663
|
+
if (entry.failed)
|
|
664
|
+
return this.rawLines(text, width, dim);
|
|
665
|
+
try {
|
|
666
|
+
return entry.md.render(width);
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
// The parser is recursive and this is arbitrary tool output: ~54 nested
|
|
670
|
+
// blockquotes overflow the stack, and no amount of fuzzing proves that is
|
|
671
|
+
// the only such input. `render()` is on the TUI's critical path, so a
|
|
672
|
+
// throw here takes the overlay down for content the literal path shows
|
|
673
|
+
// fine — degrade to that instead, and remember, since the throw would
|
|
674
|
+
// otherwise repeat on every render and every scroll key.
|
|
675
|
+
entry.failed = true;
|
|
676
|
+
return this.rawLines(text, width, dim);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
/** Steerable only when a steer handler exists and the agent is still active. */
|
|
680
|
+
canSteer() {
|
|
681
|
+
return !!this.onSteer && (this.record.status === "running" || this.record.status === "queued");
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Asked of the owner rather than derived from the record: only the caller
|
|
685
|
+
* blocked on this run knows it can let go of it, and `blocking` alone also
|
|
686
|
+
* matches spawns nobody can hand off (the agent generator).
|
|
687
|
+
*/
|
|
688
|
+
canSendToBackground() {
|
|
689
|
+
return this.onBackground?.available() === true;
|
|
690
|
+
}
|
|
691
|
+
/** Open the inline steering composer and route subsequent input to it. */
|
|
692
|
+
openComposer() {
|
|
693
|
+
const input = new Input();
|
|
694
|
+
input.focused = true;
|
|
695
|
+
input.onSubmit = (value) => {
|
|
696
|
+
const message = value.trim();
|
|
697
|
+
this.composer = undefined;
|
|
698
|
+
if (message)
|
|
699
|
+
this.onSteer?.(message);
|
|
700
|
+
this.tui.requestRender();
|
|
701
|
+
};
|
|
702
|
+
input.onEscape = () => {
|
|
703
|
+
this.composer = undefined;
|
|
704
|
+
this.tui.requestRender();
|
|
705
|
+
};
|
|
706
|
+
this.composer = input;
|
|
707
|
+
this.tui.requestRender();
|
|
708
|
+
}
|
|
709
|
+
invalidate() { }
|
|
710
|
+
dispose() {
|
|
711
|
+
this.closed = true;
|
|
712
|
+
this.scrollView.setScrollbar("hidden"); // clears Pi's auto-hide timer
|
|
713
|
+
if (this.unsubscribe) {
|
|
714
|
+
this.unsubscribe();
|
|
715
|
+
this.unsubscribe = undefined;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
// ---- Private ----
|
|
719
|
+
viewportHeight() {
|
|
720
|
+
if (this.fullscreen)
|
|
721
|
+
return Math.max(0, this.tui.terminal.rows - this.chromeLines());
|
|
722
|
+
// Cap mirrors the overlay's maxHeight — otherwise the viewer would render
|
|
723
|
+
// more lines than the overlay shows and clip the footer.
|
|
724
|
+
const maxRows = Math.floor((this.tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100);
|
|
725
|
+
return Math.max(MIN_VIEWPORT, maxRows - this.chromeLines());
|
|
726
|
+
}
|
|
727
|
+
chromeLines() {
|
|
728
|
+
// The composer adds one row above the footer hint while it's open.
|
|
729
|
+
return CHROME_LINES_BASE - (this.fullscreen ? 2 : 0) + (this.invocationLine() ? 1 : 0) + (this.composer ? 1 : 0);
|
|
730
|
+
}
|
|
731
|
+
invocationLine() {
|
|
732
|
+
// Canonical id here, short label everywhere else: this overlay is opened to
|
|
733
|
+
// inspect one agent and has the width for it, and two providers can serve
|
|
734
|
+
// models whose short names read alike.
|
|
735
|
+
const { modelName, modelId, tags } = buildInvocationTags(this.record.invocation);
|
|
736
|
+
const model = modelId ?? modelName;
|
|
737
|
+
const parts = model ? [model, ...tags] : tags;
|
|
738
|
+
if (parts.length === 0)
|
|
739
|
+
return undefined;
|
|
740
|
+
return this.theme.fg("dim", ` ↳ ${parts.join(" · ")}`);
|
|
741
|
+
}
|
|
742
|
+
buildContentLines(width) {
|
|
743
|
+
if (width <= 0)
|
|
744
|
+
return [];
|
|
745
|
+
const th = this.theme;
|
|
746
|
+
const messages = this.session.messages;
|
|
747
|
+
const lines = [];
|
|
748
|
+
if (messages.length === 0) {
|
|
749
|
+
lines.push(th.fg("dim", "(waiting for first message...)"));
|
|
750
|
+
return lines;
|
|
751
|
+
}
|
|
752
|
+
// A result is folded into the block of the call it answers, so every call
|
|
753
|
+
// has to find its result before the loop reaches it.
|
|
754
|
+
const results = new Map();
|
|
755
|
+
for (const msg of messages) {
|
|
756
|
+
if (msg.role === "toolResult")
|
|
757
|
+
results.set(msg.toolCallId, msg);
|
|
758
|
+
}
|
|
759
|
+
const foldedCalls = new Set();
|
|
760
|
+
const mode = this.markdownMode();
|
|
761
|
+
let needsSeparator = false;
|
|
762
|
+
for (const msg of messages) {
|
|
763
|
+
if (msg.role === "user") {
|
|
764
|
+
const text = typeof msg.content === "string"
|
|
765
|
+
? msg.content
|
|
766
|
+
: extractText(msg.content);
|
|
767
|
+
if (!text.trim())
|
|
768
|
+
continue;
|
|
769
|
+
if (needsSeparator)
|
|
770
|
+
lines.push(th.fg("dim", "───"));
|
|
771
|
+
lines.push(th.fg("accent", "[User]"));
|
|
772
|
+
for (const line of wrapTextWithAnsi(text.trim(), width)) {
|
|
773
|
+
lines.push(line);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
else if (msg.role === "assistant") {
|
|
777
|
+
const textParts = [];
|
|
778
|
+
const toolCalls = [];
|
|
779
|
+
for (const c of msg.content) {
|
|
780
|
+
if (c.type === "text" && c.text)
|
|
781
|
+
textParts.push(c.text);
|
|
782
|
+
else if (c.type === "toolCall")
|
|
783
|
+
toolCalls.push(c);
|
|
784
|
+
}
|
|
785
|
+
if (needsSeparator)
|
|
786
|
+
lines.push(th.fg("dim", "───"));
|
|
787
|
+
lines.push(th.bold("[Assistant]"));
|
|
788
|
+
if (textParts.length > 0) {
|
|
789
|
+
const text = textParts.join("\n").trim();
|
|
790
|
+
lines.push(...(mode === "off"
|
|
791
|
+
? this.rawLines(text, width, false)
|
|
792
|
+
: this.markdownLines(msg, text, width, false)));
|
|
793
|
+
}
|
|
794
|
+
for (const call of toolCalls) {
|
|
795
|
+
if (!this.toolRendering) {
|
|
796
|
+
lines.push(truncateToWidth(th.fg("muted", ` [Tool: ${call.name}]`), width));
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
foldedCalls.add(call.id);
|
|
800
|
+
// An aborted or failed response never runs its calls, so no result
|
|
801
|
+
// will arrive: say so, as pi's session replay does.
|
|
802
|
+
const notRun = msg.stopReason === "aborted" ? "Operation aborted"
|
|
803
|
+
: msg.stopReason === "error" ? msg.errorMessage || "Error"
|
|
804
|
+
: undefined;
|
|
805
|
+
lines.push(...this.toolBlockLines(call, results.get(call.id), notRun, width));
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
else if (msg.role === "toolResult") {
|
|
809
|
+
// Rendered under its call above. A result whose call is not in the
|
|
810
|
+
// transcript — a compacted prefix, a reopened session — keeps the plain
|
|
811
|
+
// block, because there is no call row to fold it into.
|
|
812
|
+
if (foldedCalls.has(msg.toolCallId))
|
|
813
|
+
continue;
|
|
814
|
+
const { text, elided } = capResult(extractText(msg.content).trim());
|
|
815
|
+
if (!text)
|
|
816
|
+
continue;
|
|
817
|
+
if (needsSeparator)
|
|
818
|
+
lines.push(th.fg("dim", "───"));
|
|
819
|
+
lines.push(th.fg("dim", "[Result]"));
|
|
820
|
+
lines.push(...(mode === "all"
|
|
821
|
+
? this.markdownLines(msg, text, width, true)
|
|
822
|
+
: this.rawLines(text, width, true)));
|
|
823
|
+
if (elided)
|
|
824
|
+
lines.push(truncateToWidth(th.fg("dim", truncationNote(elided)), width));
|
|
825
|
+
}
|
|
826
|
+
else if (msg.role === "bashExecution") {
|
|
827
|
+
const bash = msg;
|
|
828
|
+
if (needsSeparator)
|
|
829
|
+
lines.push(th.fg("dim", "───"));
|
|
830
|
+
lines.push(truncateToWidth(th.fg("muted", ` $ ${bash.command}`), width));
|
|
831
|
+
if (bash.output?.trim()) {
|
|
832
|
+
// Same cap as a tool result, never Markdown: command output is the one
|
|
833
|
+
// thing here that is definitionally not authored as Markdown.
|
|
834
|
+
const { text, elided } = capResult(bash.output.trim());
|
|
835
|
+
lines.push(...this.rawLines(text, width, true));
|
|
836
|
+
if (elided)
|
|
837
|
+
lines.push(truncateToWidth(th.fg("dim", truncationNote(elided)), width));
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
else {
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
needsSeparator = true;
|
|
844
|
+
}
|
|
845
|
+
// Streaming indicator for running agents
|
|
846
|
+
if (this.record.status === "running" && this.activity) {
|
|
847
|
+
const act = describeActivity(this.activity.activeTools, this.activity.responseText);
|
|
848
|
+
lines.push("");
|
|
849
|
+
lines.push(truncateToWidth(th.fg("accent", "▍ ") + th.fg("dim", act), width));
|
|
850
|
+
}
|
|
851
|
+
// Only clamp what actually overflows: `truncateToWidth` takes a slow,
|
|
852
|
+
// character-by-character path for any line carrying an escape sequence, and
|
|
853
|
+
// a tool block's rows are already rendered at this width by its component.
|
|
854
|
+
return lines.map(l => (visibleWidth(l) > width ? truncateToWidth(l, width) : l));
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* One tool call as pi draws it in the main transcript: the arguments on the
|
|
858
|
+
* call, then the result in that tool's own format — a bash box, read line
|
|
859
|
+
* numbers, an edit diff. Blocks are cached per call id because
|
|
860
|
+
* `ToolExecutionComponent` rebuilds its renderer tree on every update.
|
|
861
|
+
*
|
|
862
|
+
* A tool without a renderer still renders: the component falls back to a
|
|
863
|
+
* generic `name key=value` header plus a bounded preview that the expand key
|
|
864
|
+
* unfolds.
|
|
865
|
+
*/
|
|
866
|
+
toolBlockLines(call, result, notRun, width) {
|
|
867
|
+
let block = this.toolBlocks.get(call.id);
|
|
868
|
+
let changed = false;
|
|
869
|
+
if (!block) {
|
|
870
|
+
// Built the way pi replays a saved session: `session.messages` only holds
|
|
871
|
+
// finished messages, so the arguments are final. No setArgsComplete() or
|
|
872
|
+
// markExecutionStarted() — both request a render, and this runs inside
|
|
873
|
+
// one; the former also makes the edit renderer re-read the file.
|
|
874
|
+
const component = new ToolExecutionComponent(call.name, call.id, call.arguments,
|
|
875
|
+
// Text only: an image needs rows this line-based renderer cannot reserve.
|
|
876
|
+
{ showImages: false },
|
|
877
|
+
// pi's resolution for the agent's own session: builtin and extension
|
|
878
|
+
// renderers, and extension overrides. `{}` for a tool no registry knows,
|
|
879
|
+
// so it gets the bounded, expandable fallback rather than a raw dump.
|
|
880
|
+
this.session.extensionRunner.resolveToolRenderers(call.name, () => this.session.getToolDefinition(call.name)) ?? {}, this.tui, this.session.sessionManager.getCwd());
|
|
881
|
+
component.setExpanded(this.toolsExpanded);
|
|
882
|
+
// The message is final, so this is decided once, when the block is built.
|
|
883
|
+
if (notRun && !result)
|
|
884
|
+
component.updateResult({ content: [{ type: "text", text: notRun }], isError: true });
|
|
885
|
+
block = { component };
|
|
886
|
+
this.toolBlocks.set(call.id, block);
|
|
887
|
+
changed = true;
|
|
888
|
+
}
|
|
889
|
+
if (result && block.result !== result) {
|
|
890
|
+
block.result = result;
|
|
891
|
+
block.component.updateResult({
|
|
892
|
+
content: result.content,
|
|
893
|
+
details: result.details,
|
|
894
|
+
isError: result.isError,
|
|
895
|
+
durationMs: result.durationMs,
|
|
896
|
+
});
|
|
897
|
+
changed = true;
|
|
898
|
+
}
|
|
899
|
+
if (!changed && block.rows && block.width === width)
|
|
900
|
+
return block.rows;
|
|
901
|
+
block.rows = block.component.render(width);
|
|
902
|
+
block.width = width;
|
|
903
|
+
return block.rows;
|
|
904
|
+
}
|
|
905
|
+
}
|