@signalridge/pi-subagents 1.12.0 → 1.14.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 +26 -0
- package/README.md +24 -5
- package/package.json +3 -2
- package/src/agent-manager.ts +368 -64
- package/src/agent-runner.ts +150 -35
- package/src/index.ts +177 -67
- package/src/operation-approvals.ts +49 -0
- package/src/output-file.ts +28 -9
- package/src/types.ts +4 -3
- package/src/ui/conversation-viewer.ts +607 -287
- package/src/ui/fleet-list.ts +154 -29
- package/src/ui/owner-group.ts +12 -4
- package/src/ui/workflow-ui-client.ts +189 -0
|
@@ -1,76 +1,143 @@
|
|
|
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
|
-
|
|
1
|
+
/** Live, session-owned conversation observer. Controls remain runtime-owned callbacks. */
|
|
8
2
|
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import { type Component, Input, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
+
import { type Component, type Focusable, Input, isKeyRelease, isKeyRepeat, Markdown, type MarkdownTheme, matchesKey, type OverlayOptions, stripTerminalSequences, type TUI, type TuiMouseEvent, type TuiMouseEventResult, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
10
4
|
import { sanitizeDisplayText } from "@signalridge/pi-ui";
|
|
11
5
|
import { renderAgentName } from "../agent-color.js";
|
|
12
|
-
import { extractText } from "../context.js";
|
|
13
6
|
import type { AgentRecordSnapshot } from "../types.js";
|
|
14
7
|
import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
|
|
15
|
-
import type
|
|
16
|
-
import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatDuration, formatSessionTokens, getPromptModeLabel } from "./agent-display.js";
|
|
8
|
+
import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatDuration, formatSessionTokens, getPromptModeLabel, type Theme } from "./agent-display.js";
|
|
17
9
|
import { PREVIEW_SCAN_LIMIT, safeTerminalText, truncateCodePoints } from "./safe-text.js";
|
|
18
10
|
import { getAgentStatusColor, getAgentStatusLabel, getAgentStatusMark } from "./status-label.js";
|
|
19
11
|
import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
|
|
20
12
|
|
|
21
|
-
/**
|
|
22
|
-
const CHROME_LINES_BASE = 6;
|
|
23
|
-
const MIN_VIEWPORT = 1;
|
|
24
|
-
/** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
|
|
13
|
+
/** Height ceiling shared by the floating overlay and its internal viewport. */
|
|
25
14
|
export const VIEWPORT_HEIGHT_PCT = 70;
|
|
26
15
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
/** Characters of a tool result or command output the transcript shows inline. */
|
|
34
|
-
const PREVIEW_LINE_LIMIT = 500;
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Reduce an untrusted blob to a bounded, inert preview.
|
|
38
|
-
*
|
|
39
|
-
* Scrub before truncating — a cut through a live escape sequence would leave a
|
|
40
|
-
* dangling introducer, and only the scrubber decides where a sequence ends. The
|
|
41
|
-
* raw prefix bound keeps a megabyte-sized tool result from being scrubbed in
|
|
42
|
-
* full just to show 500 characters of it, and is large enough that even
|
|
43
|
-
* escape-dense output still yields a full preview.
|
|
44
|
-
*/
|
|
45
|
-
function previewText(raw: string): string {
|
|
46
|
-
const scanned = raw.length > PREVIEW_SCAN_LIMIT ? raw.slice(0, PREVIEW_SCAN_LIMIT) : raw;
|
|
47
|
-
const safe = safeTerminalText(scanned);
|
|
48
|
-
if (scanned.length === raw.length && safe.length <= PREVIEW_LINE_LIMIT) return safe;
|
|
49
|
-
return `${truncateCodePoints(safe, PREVIEW_LINE_LIMIT, "")}... (truncated)`;
|
|
16
|
+
/** Only the public renderer mode selects native fullscreen; old hosts float. */
|
|
17
|
+
export type ConversationViewerHost = { readonly mode?: string };
|
|
18
|
+
export function getConversationViewerMode(host: ConversationViewerHost = {}, options: Pick<ViewerOptions, "mode"> = {}): "fullscreen" | "overlay" {
|
|
19
|
+
return options.mode !== "overlay" && host.mode === "fullscreen" ? "fullscreen" : "overlay";
|
|
50
20
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
21
|
+
export function getConversationOverlayOptions(host: ConversationViewerHost = {}, options: Pick<ViewerOptions, "mode"> = {}): OverlayOptions {
|
|
22
|
+
return getConversationViewerMode(host, options) === "fullscreen"
|
|
23
|
+
? { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 }
|
|
24
|
+
: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` };
|
|
25
|
+
}
|
|
26
|
+
export interface ViewerOptions {
|
|
27
|
+
/** Runtime-owned eligibility for an exact running, unmanaged foreground run. */
|
|
28
|
+
canHandoff?: () => boolean;
|
|
29
|
+
// biome-ignore lint/suspicious/noConfusingVoidType: preserve void-returning control callbacks.
|
|
30
|
+
onHandoff?: () => boolean | void;
|
|
31
|
+
/** Fullscreen still requires a public fullscreen renderer. */
|
|
32
|
+
mode?: "fullscreen" | "overlay";
|
|
58
33
|
}
|
|
59
|
-
|
|
60
34
|
// biome-ignore lint/suspicious/noConfusingVoidType: preserve existing void-returning steering callbacks.
|
|
61
35
|
export type SteerAcceptance = boolean | void;
|
|
62
36
|
export type SteerHandler = (message: string) => SteerAcceptance | Promise<SteerAcceptance>;
|
|
63
37
|
|
|
64
|
-
|
|
38
|
+
const MAX_MESSAGES = 64;
|
|
39
|
+
const MAX_MESSAGE_CHARS = 8_192;
|
|
40
|
+
const MAX_MESSAGE_ROWS = 256;
|
|
41
|
+
const MAX_CONTENT_ROWS = 4_096;
|
|
42
|
+
const MAX_BLOCKS = 64;
|
|
43
|
+
const PREVIEW_CHARS = 500;
|
|
44
|
+
type Message = AgentSession["messages"][number];
|
|
45
|
+
type TextCursor = { block: number; offset: number };
|
|
46
|
+
type MessagePage = { cursor: TextCursor; row: number };
|
|
47
|
+
type LayoutEntry = { text: string; signature: string; sourceLength: number; lines: string[]; failed: boolean; next?: TextCursor; moreRows: boolean; markdown?: Markdown };
|
|
48
|
+
type RowAnchor = { message: Message; index: number; row: number };
|
|
49
|
+
|
|
50
|
+
/** Reject invalid geometry before allocation; terminal-sized work has a hard ceiling. */
|
|
51
|
+
function dimension(value: number): number { return Number.isFinite(value) ? Math.max(0, Math.min(4_096, Math.floor(value))) : 0; }
|
|
52
|
+
type Viewport = { top: number; height: number; width: number; maxScroll: number; thumbTop: number; thumbHeight: number };
|
|
53
|
+
type BashExecutionMessage = { role: "bashExecution"; command: string; output: string };
|
|
54
|
+
function isBashExecutionMessage(message: { role: string }): message is BashExecutionMessage {
|
|
55
|
+
return message.role === "bashExecution" && "command" in message && typeof message.command === "string"
|
|
56
|
+
&& "output" in message && typeof message.output === "string";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Cut only the already bounded prefix; source-row omissions remain reachable on the next page. */
|
|
60
|
+
function sourceEnd(text: string, offset: number, limit: number): number {
|
|
61
|
+
let end = Math.min(text.length, offset + limit);
|
|
62
|
+
const prefix = text.slice(offset, end);
|
|
63
|
+
let rows = 0;
|
|
64
|
+
for (let i = 0; i < prefix.length; i++) {
|
|
65
|
+
if (prefix[i] === "\n" && ++rows === 128) { end = offset + i + 1; break; }
|
|
66
|
+
}
|
|
67
|
+
if (end < text.length && /[\uD800-\uDBFF]/.test(text[end - 1] ?? "")) end--;
|
|
68
|
+
return end;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Read a bounded source page without joining/scanning earlier or omitted text. */
|
|
72
|
+
function boundedMessageText(message: Message, limit: number, cursor: TextCursor): { text: string; calls: string[]; next?: TextCursor; sourceLength: number } {
|
|
73
|
+
const content = isBashExecutionMessage(message) ? message.output : "content" in message ? message.content : "";
|
|
74
|
+
if (typeof content === "string") {
|
|
75
|
+
const end = sourceEnd(content, cursor.offset, limit);
|
|
76
|
+
return { text: content.slice(cursor.offset, end), calls: [], sourceLength: content.length,
|
|
77
|
+
next: end < content.length ? { block: 0, offset: end } : undefined };
|
|
78
|
+
}
|
|
79
|
+
if (!Array.isArray(content)) return { text: "", calls: [], sourceLength: 0 };
|
|
80
|
+
let text = "";
|
|
81
|
+
const calls: string[] = [];
|
|
82
|
+
let sourceLength = 0;
|
|
83
|
+
let spent = 0;
|
|
84
|
+
let blockIndex = cursor.block;
|
|
85
|
+
let offset = cursor.offset;
|
|
86
|
+
for (let visited = 0; blockIndex < content.length && visited < MAX_BLOCKS; visited++, blockIndex++) {
|
|
87
|
+
const block = content[blockIndex];
|
|
88
|
+
const part = block.type === "text" ? block.text : block.type === "toolCall" && message.role === "assistant" ? block.name : undefined;
|
|
89
|
+
if (part === undefined) { offset = 0; continue; }
|
|
90
|
+
sourceLength += part.length;
|
|
91
|
+
const remaining = limit - spent - 1;
|
|
92
|
+
if (remaining <= 0) break;
|
|
93
|
+
const end = sourceEnd(part, offset, remaining);
|
|
94
|
+
const chunk = part.slice(offset, end);
|
|
95
|
+
if (block.type === "text") text += (text ? "\n" : "") + chunk;
|
|
96
|
+
else calls.push(sanitizeDisplayText(chunk));
|
|
97
|
+
spent += chunk.length + 1;
|
|
98
|
+
if (end < part.length) { offset = end; break; }
|
|
99
|
+
offset = 0;
|
|
100
|
+
}
|
|
101
|
+
return { text, calls, sourceLength, next: blockIndex < content.length ? { block: blockIndex, offset } : undefined };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export class ConversationViewer implements Component, Focusable {
|
|
105
|
+
private hasFocus = false;
|
|
106
|
+
get focused(): boolean { return this.hasFocus; }
|
|
107
|
+
set focused(value: boolean) {
|
|
108
|
+
this.hasFocus = value;
|
|
109
|
+
if (this.composer) this.composer.focused = value;
|
|
110
|
+
}
|
|
111
|
+
private readonly fullscreen: boolean;
|
|
112
|
+
private layouts = new Map<Message, LayoutEntry>();
|
|
113
|
+
private pages = new Map<Message, MessagePage>();
|
|
114
|
+
private rowAnchors: (RowAnchor | undefined)[] = [];
|
|
115
|
+
private anchor: RowAnchor | undefined;
|
|
116
|
+
/** Explicit text selection is independent of the viewport's first-row anchor. */
|
|
117
|
+
private textTarget: Message | undefined;
|
|
118
|
+
private windowStart = 0;
|
|
119
|
+
private windowEnd = 0;
|
|
120
|
+
private windowBackwards = true;
|
|
121
|
+
private composerRow = -1;
|
|
122
|
+
private layoutWidth = 0;
|
|
123
|
+
private compactionCount = 0;
|
|
124
|
+
private firstMessage: Message | undefined;
|
|
125
|
+
private messageCount = 0;
|
|
126
|
+
private newMessages = 0;
|
|
127
|
+
private controlError: string | undefined;
|
|
128
|
+
private handoffArmed = false;
|
|
129
|
+
private viewport: Viewport | undefined;
|
|
130
|
+
private scrollbarGrabOffset: number | undefined;
|
|
131
|
+
private footerTargets: { start: number; end: number; key: string }[] = [];
|
|
132
|
+
private footerRow = -1;
|
|
133
|
+
private latestTarget: { row: number; start: number; end: number } | undefined;
|
|
65
134
|
private scrollOffset = 0;
|
|
66
135
|
private autoScroll = true;
|
|
67
136
|
private unsubscribe: (() => void) | undefined;
|
|
68
137
|
private lastInnerW = 0;
|
|
69
138
|
private closed = false;
|
|
70
|
-
/** Two-press confirm guard for the stop key, so a stray key can't kill the agent. */
|
|
71
139
|
private stopArmed = false;
|
|
72
140
|
private keys: ViewerKeys;
|
|
73
|
-
/** Steering composer — present while the user is typing a message to the agent. */
|
|
74
141
|
private composer: Input | undefined;
|
|
75
142
|
private steerError: string | undefined;
|
|
76
143
|
private sending = false;
|
|
@@ -82,223 +149,418 @@ export class ConversationViewer implements Component {
|
|
|
82
149
|
private activity: AgentActivity | undefined,
|
|
83
150
|
private theme: Theme,
|
|
84
151
|
private done: (result: undefined) => void,
|
|
85
|
-
/** Abort the agent shown here. Omitted → no stop affordance (e.g. read-only history). */
|
|
86
152
|
private onStop?: () => void,
|
|
87
|
-
/** User keybindings from `ctx.ui.custom()`. Omitted → hardcoded defaults. */
|
|
88
153
|
keybindings?: ViewerKeybindings,
|
|
89
|
-
/** Send a steering message to the agent. Omitted → no compose affordance. */
|
|
90
154
|
private onSteer?: SteerHandler,
|
|
91
|
-
/** Retrieve a fresh public snapshot so the viewer reflects status changes. */
|
|
92
155
|
private getRecord?: () => AgentRecordSnapshot | undefined,
|
|
156
|
+
private options: ViewerOptions = {},
|
|
93
157
|
) {
|
|
158
|
+
this.fullscreen = getConversationViewerMode(tui, options) === "fullscreen";
|
|
94
159
|
this.keys = createViewerKeys(keybindings);
|
|
95
160
|
this.unsubscribe = session.subscribe(() => {
|
|
96
|
-
if (this.closed)
|
|
97
|
-
this.tui.requestRender();
|
|
161
|
+
if (!this.closed) this.tui.requestRender();
|
|
98
162
|
});
|
|
99
163
|
}
|
|
100
164
|
|
|
101
165
|
private refreshRecord(): AgentRecordSnapshot {
|
|
102
166
|
try {
|
|
103
167
|
const current = this.getRecord?.();
|
|
104
|
-
if
|
|
168
|
+
// Never bind controls/status to a different agent if the owner changes selection.
|
|
169
|
+
if (current?.id === this.record.id) this.record = current;
|
|
105
170
|
} catch { /* a disposed owner cannot erase the retained snapshot */ }
|
|
106
171
|
return this.record;
|
|
107
172
|
}
|
|
108
173
|
|
|
109
174
|
handleInput(data: string): void {
|
|
110
|
-
if (this.closed) return;
|
|
111
|
-
//
|
|
112
|
-
|
|
175
|
+
if (this.closed || isKeyRelease(data)) return;
|
|
176
|
+
// Ctrl+End is observer-owned even while composing; plain End stays an editing key.
|
|
177
|
+
if (matchesKey(data, "ctrl+end")) { this.jumpToLatest(); return; }
|
|
113
178
|
if (this.composer) {
|
|
114
179
|
if (!this.sending || matchesKey(data, "escape")) this.composer.handleInput(data);
|
|
115
180
|
this.tui.requestRender();
|
|
116
181
|
return;
|
|
117
182
|
}
|
|
118
|
-
|
|
183
|
+
if (matchesKey(data, "escape") && (this.stopArmed || this.handoffArmed)) {
|
|
184
|
+
this.stopArmed = false; this.handoffArmed = false; this.controlError = undefined;
|
|
185
|
+
this.tui.requestRender(); return;
|
|
186
|
+
}
|
|
119
187
|
if (matchesKey(data, "escape") || matchesKey(data, "q")) {
|
|
120
|
-
this.
|
|
121
|
-
|
|
122
|
-
|
|
188
|
+
this.dispose(); this.done(undefined); return;
|
|
189
|
+
}
|
|
190
|
+
// A DIFFERENT key confirms. Legacy raw typematic b/b, x/x and Enter cannot execute controls.
|
|
191
|
+
if (matchesKey(data, "y") && (this.stopArmed || this.handoffArmed)) {
|
|
192
|
+
if (isKeyRepeat(data)) return;
|
|
193
|
+
if (this.handoffArmed) {
|
|
194
|
+
if (!this.canHandoff()) this.controlError = "Handoff unavailable; still foreground · y retry · Esc cancel";
|
|
195
|
+
else {
|
|
196
|
+
try {
|
|
197
|
+
if (this.options.onHandoff?.() === false) this.controlError = "Handoff failed; still foreground · y retry · Esc cancel";
|
|
198
|
+
else { this.handoffArmed = false; this.controlError = undefined; }
|
|
199
|
+
} catch { this.controlError = "Handoff failed; still foreground · y retry · Esc cancel"; }
|
|
200
|
+
}
|
|
201
|
+
} else if (!this.isStoppable()) this.controlError = "Stop unavailable · y retry · Esc cancel";
|
|
202
|
+
else {
|
|
203
|
+
try { this.onStop?.(); this.stopArmed = false; this.controlError = undefined; }
|
|
204
|
+
catch { this.controlError = "Stop failed · y retry · Esc cancel"; }
|
|
205
|
+
}
|
|
206
|
+
this.tui.requestRender(); return;
|
|
207
|
+
}
|
|
208
|
+
if (matchesKey(data, "b") && this.canHandoff()) {
|
|
209
|
+
if (!isKeyRepeat(data)) { this.stopArmed = false; this.handoffArmed = true; this.controlError = undefined; }
|
|
210
|
+
this.tui.requestRender(); return;
|
|
211
|
+
}
|
|
212
|
+
// An unavailable b is an ordinary navigation key and disarms BOTH controls.
|
|
213
|
+
if (matchesKey(data, "x") && this.isStoppable()) {
|
|
214
|
+
if (!isKeyRepeat(data)) { this.handoffArmed = false; this.stopArmed = true; this.controlError = undefined; }
|
|
215
|
+
this.tui.requestRender(); return;
|
|
216
|
+
}
|
|
217
|
+
if (matchesKey(data, "enter") && (this.stopArmed || this.handoffArmed)) return;
|
|
218
|
+
this.stopArmed = false; this.handoffArmed = false; this.controlError = undefined;
|
|
219
|
+
if (matchesKey(data, "enter") && this.canSteer()) { this.openComposer(); return; }
|
|
220
|
+
if (matchesKey(data, "]") || matchesKey(data, "[")) {
|
|
221
|
+
this.pageMessage(matchesKey(data, "]")); return;
|
|
123
222
|
}
|
|
223
|
+
if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) {
|
|
224
|
+
this.buildContentLines(this.lastInnerW);
|
|
225
|
+
const candidates = this.pagingCandidates();
|
|
226
|
+
const target = this.pagingAnchor();
|
|
227
|
+
const current = candidates.findIndex(row => row.message === target?.message);
|
|
228
|
+
const step = matchesKey(data, "shift+tab") ? -1 : 1;
|
|
229
|
+
const index = current < 0 ? step > 0 ? 0 : candidates.length - 1 : (current + step + candidates.length) % candidates.length;
|
|
230
|
+
const selected = candidates[index];
|
|
231
|
+
if (selected) { this.textTarget = selected.message; this.autoScroll = false; this.rememberAnchor(); }
|
|
232
|
+
this.tui.requestRender(); return;
|
|
233
|
+
}
|
|
234
|
+
const height = Math.max(1, this.viewportHeight());
|
|
235
|
+
if (this.keys.scrollUp(data)) this.moveScroll(-1);
|
|
236
|
+
else if (this.keys.scrollDown(data)) this.moveScroll(1);
|
|
237
|
+
else if (this.keys.pageUp(data)) this.moveScroll(-height);
|
|
238
|
+
else if (this.keys.pageDown(data)) this.moveScroll(height);
|
|
239
|
+
else if (matchesKey(data, "home")) {
|
|
240
|
+
this.autoScroll = false; this.windowBackwards = false; this.windowStart = 0;
|
|
241
|
+
this.anchor = undefined; this.textTarget = undefined; this.pages.clear(); this.scrollOffset = 0;
|
|
242
|
+
this.buildContentLines(this.lastInnerW); this.rememberAnchor();
|
|
243
|
+
} else if (matchesKey(data, "end")) { this.jumpToLatest(); return; }
|
|
244
|
+
this.tui.requestRender();
|
|
245
|
+
}
|
|
124
246
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
247
|
+
private jumpToLatest(): void {
|
|
248
|
+
this.autoScroll = true;
|
|
249
|
+
this.anchor = undefined;
|
|
250
|
+
this.textTarget = undefined;
|
|
251
|
+
this.windowBackwards = true;
|
|
252
|
+
this.scrollOffset = Math.max(0, this.buildContentLines(this.lastInnerW).length - this.viewportHeight());
|
|
253
|
+
this.newMessages = 0;
|
|
254
|
+
this.stopArmed = false;
|
|
255
|
+
this.handoffArmed = false;
|
|
256
|
+
this.controlError = undefined;
|
|
257
|
+
this.tui.requestRender();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private rememberAnchor(): void {
|
|
261
|
+
let index = this.scrollOffset;
|
|
262
|
+
while (index < this.rowAnchors.length && !this.rowAnchors[index]) index++;
|
|
263
|
+
if (index === this.rowAnchors.length) {
|
|
264
|
+
index = Math.min(this.scrollOffset, this.rowAnchors.length - 1);
|
|
265
|
+
while (index >= 0 && !this.rowAnchors[index]) index--;
|
|
132
266
|
}
|
|
267
|
+
const anchor = this.rowAnchors[index];
|
|
268
|
+
this.anchor = anchor ? { ...anchor, row: anchor.row + this.scrollOffset - index } : undefined;
|
|
269
|
+
}
|
|
133
270
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
271
|
+
private moveScroll(delta: number): void {
|
|
272
|
+
let lines = this.buildContentLines(this.lastInnerW);
|
|
273
|
+
const height = Math.max(1, this.viewportHeight());
|
|
274
|
+
let max = Math.max(0, lines.length - height);
|
|
275
|
+
let target = this.scrollOffset + delta;
|
|
276
|
+
this.autoScroll = false;
|
|
277
|
+
if (target < 0 && this.windowStart > 0) {
|
|
278
|
+
const overlap = this.windowStart;
|
|
279
|
+
this.windowEnd = overlap + 1;
|
|
280
|
+
this.windowBackwards = true; this.anchor = undefined;
|
|
281
|
+
lines = this.buildContentLines(this.lastInnerW);
|
|
282
|
+
max = Math.max(0, lines.length - height);
|
|
283
|
+
const start = this.rowAnchors.findIndex(row => row?.index === overlap);
|
|
284
|
+
target = Math.max(0, start) + target;
|
|
285
|
+
} else if (target > max && this.windowEnd < this.messageCount) {
|
|
286
|
+
this.windowStart = this.windowEnd;
|
|
287
|
+
this.windowBackwards = false; this.anchor = undefined;
|
|
288
|
+
lines = this.buildContentLines(this.lastInnerW);
|
|
289
|
+
target = Math.max(0, target - max - 1);
|
|
290
|
+
max = Math.max(0, lines.length - height);
|
|
147
291
|
}
|
|
148
|
-
|
|
292
|
+
this.scrollOffset = Math.max(0, Math.min(max, target));
|
|
293
|
+
this.autoScroll = delta > 0 && this.scrollOffset === max && this.windowEnd === this.messageCount;
|
|
294
|
+
if (this.autoScroll) this.newMessages = 0;
|
|
295
|
+
this.rememberAnchor();
|
|
296
|
+
}
|
|
149
297
|
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
298
|
+
private canPageMessage(message: Message): boolean {
|
|
299
|
+
const entry = this.layouts.get(message);
|
|
300
|
+
return !!(entry?.moreRows || entry?.next || this.pages.has(message));
|
|
301
|
+
}
|
|
153
302
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
303
|
+
/** Only visible messages with source/render continuation or a retained page are selectable. */
|
|
304
|
+
private pagingCandidates(): RowAnchor[] {
|
|
305
|
+
const seen = new Set<Message>();
|
|
306
|
+
return this.rowAnchors.slice(this.scrollOffset, this.scrollOffset + this.viewportHeight()).filter((row): row is RowAnchor => {
|
|
307
|
+
if (!row || seen.has(row.message) || !this.canPageMessage(row.message)) return false;
|
|
308
|
+
seen.add(row.message); return true;
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private pagingAnchor(): RowAnchor | undefined {
|
|
313
|
+
// Never silently switch an explicit selection, even at its final page.
|
|
314
|
+
if (this.textTarget) {
|
|
315
|
+
const visible = this.rowAnchors.find(row => row?.message === this.textTarget);
|
|
316
|
+
if (visible) return visible;
|
|
317
|
+
const index = this.session.messages.indexOf(this.textTarget);
|
|
318
|
+
return index >= 0 ? { message: this.textTarget, index, row: 0 } : undefined;
|
|
319
|
+
}
|
|
320
|
+
const entry = this.anchor && this.layouts.get(this.anchor.message);
|
|
321
|
+
if (entry?.moreRows || entry?.next) return this.anchor;
|
|
322
|
+
const candidates = this.pagingCandidates();
|
|
323
|
+
return candidates.find(row => {
|
|
324
|
+
const layout = this.layouts.get(row.message);
|
|
325
|
+
return layout?.moreRows || layout?.next;
|
|
326
|
+
}) ?? candidates[0];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** ] advances the selected bounded row/source page; [ returns to its first page. */
|
|
330
|
+
private pageMessage(next: boolean): void {
|
|
331
|
+
this.buildContentLines(this.lastInnerW);
|
|
332
|
+
this.rememberAnchor();
|
|
333
|
+
const anchor = this.pagingAnchor();
|
|
334
|
+
if (!anchor) return;
|
|
335
|
+
if (!this.rowAnchors.some(row => row?.message === anchor.message)) {
|
|
336
|
+
// Restore an evicted selection without laying out any intervening history.
|
|
168
337
|
this.autoScroll = false;
|
|
169
|
-
|
|
170
|
-
this.
|
|
171
|
-
this.
|
|
338
|
+
this.anchor = { ...anchor, row: 0 };
|
|
339
|
+
this.windowStart = anchor.index; this.windowBackwards = false;
|
|
340
|
+
this.buildContentLines(this.lastInnerW);
|
|
341
|
+
this.tui.requestRender();
|
|
342
|
+
}
|
|
343
|
+
const entry = this.layouts.get(anchor.message);
|
|
344
|
+
const page = this.pages.get(anchor.message) ?? { cursor: { block: 0, offset: 0 }, row: 0 };
|
|
345
|
+
if (!next) this.pages.delete(anchor.message);
|
|
346
|
+
else if (entry?.moreRows) this.pages.set(anchor.message, { ...page, row: page.row + MAX_MESSAGE_ROWS - 2 });
|
|
347
|
+
else if (entry?.next) this.pages.set(anchor.message, { cursor: entry.next, row: 0 });
|
|
348
|
+
else return;
|
|
349
|
+
this.autoScroll = false;
|
|
350
|
+
this.textTarget = anchor.message;
|
|
351
|
+
this.anchor = { ...anchor, row: 0 };
|
|
352
|
+
this.layouts.delete(anchor.message);
|
|
353
|
+
this.buildContentLines(this.lastInnerW);
|
|
354
|
+
this.tui.requestRender();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Public normalized events only; regular terminals retain terminal-owned mouse input. */
|
|
358
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult {
|
|
359
|
+
// Floating viewers on native fullscreen hosts still receive normalized overlay-local events.
|
|
360
|
+
if (getConversationViewerMode(this.tui) !== "fullscreen") return { handled: false };
|
|
361
|
+
if (this.closed) return { handled: true, render: false };
|
|
362
|
+
if (event.type === "press" && event.button === "left") {
|
|
363
|
+
if (event.y === this.footerRow) {
|
|
364
|
+
const target = this.footerTargets.find(t => event.x >= t.start && event.x < t.end);
|
|
365
|
+
if (target) this.handleInput(target.key);
|
|
366
|
+
return { handled: true, focus: true, render: true };
|
|
367
|
+
}
|
|
368
|
+
if (this.composer && event.y === this.composerRow && !this.sending) {
|
|
369
|
+
// Input's public mouse contract currently reserves two prompt columns even for prompt:"".
|
|
370
|
+
const left = this.fullscreen ? event.width < 3 ? 0 : 1 : 2;
|
|
371
|
+
const result = this.composer.handleMouse({ ...event, x: event.x - left + 2, y: 0, width: this.lastInnerW, height: 1 });
|
|
372
|
+
return { ...result, handled: true, render: result?.render ?? true };
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
// Footer/composer remain interactive even when there is no transcript viewport.
|
|
376
|
+
if (!this.viewport) return { handled: true, render: false };
|
|
377
|
+
const view = this.viewport;
|
|
378
|
+
const onRail = event.x === view.width - 1 && event.y >= view.top && event.y < view.top + view.height && view.maxScroll > 0;
|
|
379
|
+
const scrollTo = (offset: number, up = false): void => {
|
|
380
|
+
this.moveScroll(offset - this.scrollOffset);
|
|
381
|
+
if (up) this.autoScroll = false;
|
|
382
|
+
this.rememberAnchor();
|
|
383
|
+
this.stopArmed = false;
|
|
384
|
+
this.handoffArmed = false;
|
|
385
|
+
this.controlError = undefined;
|
|
386
|
+
this.tui.requestRender();
|
|
387
|
+
};
|
|
388
|
+
if (event.type === "wheel") {
|
|
389
|
+
const delta = event.wheelDelta ?? 0;
|
|
390
|
+
scrollTo(this.scrollOffset + delta, delta < 0);
|
|
391
|
+
return { handled: true, render: true };
|
|
392
|
+
}
|
|
393
|
+
if (event.type === "release") {
|
|
394
|
+
this.scrollbarGrabOffset = undefined;
|
|
395
|
+
return { handled: true, render: true };
|
|
396
|
+
}
|
|
397
|
+
if (event.type === "drag" && this.scrollbarGrabOffset !== undefined) {
|
|
398
|
+
const travel = view.height - view.thumbHeight;
|
|
399
|
+
const position = Math.max(0, Math.min(travel, event.y - view.top - this.scrollbarGrabOffset));
|
|
400
|
+
scrollTo(travel > 0 ? Math.round(position / travel * view.maxScroll) : 0);
|
|
401
|
+
return { handled: true, render: true };
|
|
172
402
|
}
|
|
403
|
+
// Act on press, never again on the renderer's synthesized click/release.
|
|
404
|
+
if (event.type === "press" && event.button === "left") {
|
|
405
|
+
const latest = this.latestTarget;
|
|
406
|
+
if (latest && event.y === latest.row && event.x >= latest.start && event.x < latest.end) this.jumpToLatest();
|
|
407
|
+
else if (onRail) {
|
|
408
|
+
const y = event.y - view.top;
|
|
409
|
+
if (y >= view.thumbTop && y < view.thumbTop + view.thumbHeight) this.scrollbarGrabOffset = y - view.thumbTop;
|
|
410
|
+
else {
|
|
411
|
+
scrollTo(this.scrollOffset + (y < view.thumbTop ? -view.height : view.height), y < view.thumbTop);
|
|
412
|
+
this.scrollbarGrabOffset = Math.floor(view.thumbHeight / 2);
|
|
413
|
+
}
|
|
414
|
+
return { handled: true, capture: true, render: true };
|
|
415
|
+
} else if (!this.composer && event.y >= view.top && event.y < view.top + view.height) {
|
|
416
|
+
const selected = this.rowAnchors[this.scrollOffset + event.y - view.top];
|
|
417
|
+
if (selected && this.canPageMessage(selected.message)) {
|
|
418
|
+
this.textTarget = selected.message; this.autoScroll = false; this.rememberAnchor();
|
|
419
|
+
this.stopArmed = false; this.handoffArmed = false; this.controlError = undefined;
|
|
420
|
+
this.tui.requestRender();
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return { handled: true, render: true };
|
|
424
|
+
}
|
|
425
|
+
return { handled: true, render: false };
|
|
173
426
|
}
|
|
174
427
|
|
|
175
428
|
render(width: number): string[] {
|
|
176
|
-
|
|
429
|
+
this.footerTargets = [];
|
|
430
|
+
this.latestTarget = undefined;
|
|
431
|
+
this.viewport = undefined;
|
|
432
|
+
this.footerRow = -1; this.composerRow = -1;
|
|
433
|
+
if (this.closed) return [];
|
|
434
|
+
width = dimension(width);
|
|
435
|
+
if (!this.fullscreen && width < 6) return [];
|
|
436
|
+
const rows = this.maxOverlayRows();
|
|
437
|
+
if (width === 0) return this.fullscreen ? Array.from({ length: rows }, () => "") : [];
|
|
177
438
|
this.refreshRecord();
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
// rather than showing a misleading half-box with a missing bottom border.
|
|
181
|
-
if (this.maxOverlayRows() < this.chromeLines() + MIN_VIEWPORT) return [];
|
|
439
|
+
if (!this.fullscreen && rows < this.chromeLines() + 1) return [];
|
|
440
|
+
if (rows === 0) return [];
|
|
182
441
|
const th = this.theme;
|
|
183
|
-
|
|
184
|
-
// the side borders and one padding column on each side of the content.
|
|
185
|
-
const innerW = width - 4;
|
|
442
|
+
const innerW = this.fullscreen ? width < 3 ? width : width - 2 : width - 4;
|
|
186
443
|
this.lastInnerW = innerW;
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const vis = visibleWidth(s);
|
|
191
|
-
return s + " ".repeat(Math.max(0, len - vis));
|
|
444
|
+
const row = (content: string, rail = " ") => {
|
|
445
|
+
const fitted = truncateToWidth(content, innerW, "…", true);
|
|
446
|
+
return this.fullscreen ? width < 3 ? fitted : ` ${fitted}${rail}` : `${th.fg("borderAccent", "│")} ${fitted} ${th.fg("borderAccent", "│")}`;
|
|
192
447
|
};
|
|
193
|
-
const
|
|
194
|
-
const fitted = truncateToWidth(pad(content, innerW), innerW, "...", true);
|
|
195
|
-
return `${th.fg("borderAccent", "│")} ${fitted} ${th.fg("borderAccent", "│")}`;
|
|
196
|
-
};
|
|
197
|
-
// Keep the border in the viewer rather than relying on the host overlay's
|
|
198
|
-
// transparent background. This makes the dialog boundary obvious and
|
|
199
|
-
// matches the reference pi-subagents viewer (╭─╮ / │ / ╰─╯).
|
|
200
|
-
const rule = (left: string, right: string) =>
|
|
201
|
-
th.fg("borderAccent", `${left}${"─".repeat(Math.max(0, width - 2))}${right}`);
|
|
202
|
-
const topRule = rule("╭", "╮");
|
|
203
|
-
const bottomRule = rule("╰", "╯");
|
|
204
|
-
// Match the reference viewer's internal separator: it keeps the header,
|
|
205
|
-
// transcript, and controls visually distinct without adding another row.
|
|
448
|
+
const rule = (left: string, right: string) => th.fg("borderAccent", `${left}${"─".repeat(Math.max(0, width - 2))}${right}`);
|
|
206
449
|
const hrMid = row(th.fg("dim", "─".repeat(innerW)));
|
|
207
|
-
|
|
208
|
-
lines.push(
|
|
450
|
+
const lines: string[] = [];
|
|
451
|
+
if (!this.fullscreen) lines.push(rule("╭", "╮"));
|
|
209
452
|
const name = renderAgentName(this.record.type, th, { bold: true });
|
|
210
453
|
const modeLabel = getPromptModeLabel(this.record.type);
|
|
211
|
-
const
|
|
212
|
-
const statusText = getAgentStatusLabel(this.record.status);
|
|
213
|
-
const statusColor = getAgentStatusColor(this.record.status);
|
|
214
|
-
const duration = formatDuration(this.record.startedAt, this.record.completedAt);
|
|
215
|
-
|
|
216
|
-
const headerStats: string[] = [duration];
|
|
454
|
+
const stats: string[] = [formatDuration(this.record.startedAt, this.record.completedAt)];
|
|
217
455
|
const toolUses = this.activity?.toolUses ?? this.record.toolUses;
|
|
218
|
-
if (toolUses > 0)
|
|
456
|
+
if (toolUses > 0) stats.unshift(`tools ${toolUses}`);
|
|
219
457
|
const tokens = getLifetimeTotal(this.activity?.lifetimeUsage ?? this.record.lifetimeUsage);
|
|
220
458
|
if (tokens > 0 || this.record.compactionCount > 0) {
|
|
221
|
-
|
|
222
|
-
headerStats.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount));
|
|
459
|
+
stats.push(formatSessionTokens(tokens, getSessionContextPercent(this.activity?.session ?? this.session), th, this.record.compactionCount));
|
|
223
460
|
}
|
|
224
|
-
|
|
225
461
|
const headerParts = [
|
|
226
|
-
th.fg(
|
|
462
|
+
th.fg(getAgentStatusColor(this.record.status), `${getAgentStatusMark(this.record.status)} ${getAgentStatusLabel(this.record.status)}`),
|
|
227
463
|
name,
|
|
228
|
-
|
|
229
|
-
th.fg("muted", sanitizeDisplayText(this.record.description)),
|
|
230
|
-
fgPreservingNestedStyles(th, "dim",
|
|
464
|
+
modeLabel ? th.fg("dim", `mode ${modeLabel}`) : undefined,
|
|
465
|
+
th.fg("muted", sanitizeDisplayText(this.record.description.slice(0, MAX_MESSAGE_CHARS))),
|
|
466
|
+
fgPreservingNestedStyles(th, "dim", stats.join(" · ")),
|
|
231
467
|
].filter((part): part is string => part !== undefined);
|
|
232
468
|
lines.push(row(headerParts.join(th.fg("dim", " · "))));
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (invocationLine) lines.push(row(invocationLine));
|
|
469
|
+
const invocation = this.invocationLine();
|
|
470
|
+
if (invocation) lines.push(row(invocation));
|
|
236
471
|
lines.push(hrMid);
|
|
237
|
-
|
|
238
472
|
const contentLines = this.buildContentLines(innerW);
|
|
239
|
-
const
|
|
240
|
-
const maxScroll = Math.max(0, contentLines.length -
|
|
241
|
-
|
|
242
|
-
if (this.autoScroll)
|
|
243
|
-
|
|
473
|
+
const height = this.viewportHeight();
|
|
474
|
+
const maxScroll = Math.max(0, contentLines.length - height);
|
|
475
|
+
this.scrollOffset = this.autoScroll ? maxScroll : Math.min(this.scrollOffset, maxScroll);
|
|
476
|
+
if (this.autoScroll) this.newMessages = 0;
|
|
477
|
+
this.rememberAnchor();
|
|
478
|
+
const textTarget = this.pagingAnchor();
|
|
479
|
+
if (textTarget && !this.composer) {
|
|
480
|
+
const entry = this.layouts.get(textTarget.message);
|
|
481
|
+
const continuation = entry?.moreRows || entry?.next;
|
|
482
|
+
lines[lines.length - 1] = row(th.fg("dim", `Text #${textTarget.index + 1} ${textTarget.message.role} · ${continuation ? "] next · " : ""}[ first · Tab/Shift+Tab target · click message`));
|
|
244
483
|
}
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
for (let i = 0; i <
|
|
250
|
-
|
|
484
|
+
const visible = contentLines.slice(this.scrollOffset, this.scrollOffset + height);
|
|
485
|
+
const thumbHeight = Math.min(height, Math.max(2, Math.round(height * height / Math.max(1, contentLines.length))));
|
|
486
|
+
const thumbTop = maxScroll > 0 ? Math.round(this.scrollOffset / maxScroll * (height - thumbHeight)) : 0;
|
|
487
|
+
this.viewport = { width, top: lines.length, height, maxScroll, thumbTop, thumbHeight };
|
|
488
|
+
for (let i = 0; i < height; i++) {
|
|
489
|
+
const thumb = i >= thumbTop && i < thumbTop + thumbHeight;
|
|
490
|
+
const rail = this.fullscreen && maxScroll > 0 ? th.fg(thumb ? "accent" : "dim", thumb ? "┃" : "│") : " ";
|
|
491
|
+
lines.push(row(visible[i] ?? "", rail));
|
|
251
492
|
}
|
|
252
|
-
|
|
253
|
-
|
|
493
|
+
if (!this.autoScroll && (maxScroll > 0 || this.windowEnd < this.messageCount)) {
|
|
494
|
+
const text = this.newMessages > 0 ? `${this.newMessages} new message${this.newMessages === 1 ? "" : "s"}` : "Jump to latest";
|
|
495
|
+
const label = truncateToWidth(`[ ↓ ${text} · Ctrl+End ]`, innerW, "…");
|
|
496
|
+
const labelWidth = visibleWidth(label);
|
|
497
|
+
const padding = Math.floor((innerW - labelWidth) / 2);
|
|
498
|
+
this.latestTarget = { row: lines.length, start: padding + 1, end: padding + 1 + labelWidth };
|
|
499
|
+
lines.push(row(th.fg("dim", "─".repeat(padding)) + th.fg("accent", label) + th.fg("dim", "─".repeat(innerW - padding - labelWidth))));
|
|
500
|
+
} else lines.push(hrMid);
|
|
501
|
+
const footerActions: { label: string; key: string }[] = [];
|
|
254
502
|
if (this.composer) {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
lines.push(row(th.fg(this.steerError ? "error" : "dim", composeHint)));
|
|
503
|
+
this.composer.focused = this.focused;
|
|
504
|
+
lines.push(row(this.composer.render(innerW)[0] ?? ""));
|
|
505
|
+
footerActions.push({ label: "Enter send", key: "\r" }, { label: "Esc cancel", key: "\x1b" });
|
|
506
|
+
lines.push(row(th.fg(this.steerError ? "error" : "dim", this.sending ? "Sending... · Esc cancel" : this.steerError ?? "Enter send · Esc cancel")));
|
|
260
507
|
} else {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
508
|
+
if (this.canSteer()) footerActions.push({ label: "Enter chat", key: "\r" });
|
|
509
|
+
if (this.stopArmed || this.handoffArmed) {
|
|
510
|
+
footerActions.length = 0;
|
|
511
|
+
footerActions.push({ label: this.controlError ? "y retry" : this.stopArmed ? "y confirm STOP" : "y confirm background", key: "y" }, { label: "Esc cancel", key: "\x1b" });
|
|
512
|
+
} else {
|
|
513
|
+
if (this.isStoppable()) footerActions.push({ label: "x stop", key: "x" });
|
|
514
|
+
if (this.canHandoff()) footerActions.push({ label: "b background", key: "b" });
|
|
515
|
+
footerActions.push({ label: "Esc close", key: "\x1b" });
|
|
266
516
|
}
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const withCount = [count, ...actions].join(sep);
|
|
274
|
-
const footerLeft = visibleWidth(withCount) + visibleWidth(footerRight) + 1 <= innerW
|
|
275
|
-
? withCount
|
|
276
|
-
: actions.join(sep);
|
|
277
|
-
|
|
278
|
-
const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
|
|
279
|
-
lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
|
|
517
|
+
const actions = footerActions.map(action => th.fg(action.key === "y" ? "warning" : "dim", action.label));
|
|
518
|
+
const percent = contentLines.length <= height ? 100 : Math.round((this.scrollOffset + height) / contentLines.length * 100);
|
|
519
|
+
const controls = actions.join(th.fg("dim", " · "));
|
|
520
|
+
const navigation = th.fg("dim", `↑↓ scroll · PgUp/PgDn · Home first · End latest · ] next text · [ first text · ${percent}% window`);
|
|
521
|
+
const gap = Math.max(1, innerW - visibleWidth(controls) - visibleWidth(navigation));
|
|
522
|
+
lines.push(row(this.controlError ? th.fg("error", this.controlError) : controls + " ".repeat(gap) + navigation));
|
|
280
523
|
}
|
|
281
|
-
lines.
|
|
282
|
-
|
|
283
|
-
|
|
524
|
+
const footer = lines.pop() ?? row("");
|
|
525
|
+
const composerLine = this.composer ? lines.pop() : undefined;
|
|
526
|
+
// At heights 1–5 chrome yields to the footer and, at >=2 rows, the actual draft/cursor.
|
|
527
|
+
const reserved = composerLine !== undefined && rows >= 2 ? 2 : 1;
|
|
528
|
+
const output = this.fullscreen
|
|
529
|
+
? [...lines.slice(0, Math.max(0, rows - reserved)), ...(reserved === 2 ? [composerLine ?? row("")] : []), footer]
|
|
530
|
+
: [...lines, ...(composerLine !== undefined ? [composerLine] : []), footer, rule("╰", "╯")];
|
|
531
|
+
this.footerRow = output.length - (this.fullscreen ? 1 : 2);
|
|
532
|
+
this.composerRow = composerLine !== undefined && (!this.fullscreen || reserved === 2) ? this.footerRow - 1 : -1;
|
|
533
|
+
if (this.viewport.top + this.viewport.height > (this.composerRow >= 0 ? this.composerRow : this.footerRow) || this.viewport.height === 0) this.viewport = undefined;
|
|
534
|
+
const footerText = stripTerminalSequences(footer);
|
|
535
|
+
for (const action of footerActions) {
|
|
536
|
+
const index = footerText.indexOf(action.label);
|
|
537
|
+
if (index < 0) continue;
|
|
538
|
+
const start = visibleWidth(footerText.slice(0, index));
|
|
539
|
+
this.footerTargets.push({ start, end: start + visibleWidth(action.label), key: action.key });
|
|
540
|
+
}
|
|
541
|
+
return output;
|
|
284
542
|
}
|
|
285
543
|
|
|
286
|
-
|
|
544
|
+
private canHandoff(): boolean {
|
|
545
|
+
const record = this.refreshRecord();
|
|
546
|
+
if (record.status !== "running" || record.detached || !this.options.onHandoff) return false;
|
|
547
|
+
try { return this.options.canHandoff?.() === true; } catch { return false; }
|
|
548
|
+
}
|
|
287
549
|
private isStoppable(): boolean {
|
|
288
550
|
const record = this.refreshRecord();
|
|
289
551
|
return !record.detached && !!this.onStop && (record.status === "running" || record.status === "queued");
|
|
290
552
|
}
|
|
291
|
-
|
|
292
|
-
/** Steerable only when a steer handler exists and the agent is still active. */
|
|
293
553
|
private canSteer(): boolean {
|
|
294
554
|
const record = this.refreshRecord();
|
|
295
555
|
return !record.detached && !!this.onSteer && (record.status === "running" || record.status === "queued");
|
|
296
556
|
}
|
|
297
557
|
|
|
298
|
-
/** Open the inline steering composer and route subsequent input to it. */
|
|
299
558
|
private openComposer(): void {
|
|
300
|
-
const input = new Input();
|
|
301
|
-
|
|
559
|
+
const input = new Input({ prompt: "" });
|
|
560
|
+
this.controlError = undefined;
|
|
561
|
+
input.focused = this.focused;
|
|
562
|
+
this.steerError = undefined;
|
|
563
|
+
this.handoffArmed = false;
|
|
302
564
|
input.onSubmit = (value: string) => {
|
|
303
565
|
const message = value.trim();
|
|
304
566
|
if (!message) { this.composer = undefined; this.tui.requestRender(); return; }
|
|
@@ -315,14 +577,11 @@ export class ConversationViewer implements Component {
|
|
|
315
577
|
else this.composer = undefined;
|
|
316
578
|
this.tui.requestRender();
|
|
317
579
|
};
|
|
318
|
-
const reject = (): void => settle(false);
|
|
319
580
|
try {
|
|
320
581
|
const result = this.onSteer?.(message);
|
|
321
|
-
if (result && typeof result === "object") {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
} else settle(result);
|
|
325
|
-
} catch { reject(); }
|
|
582
|
+
if (result && typeof result === "object") { this.sending = true; void result.then(settle, () => settle(false)); }
|
|
583
|
+
else settle(result);
|
|
584
|
+
} catch { settle(false); }
|
|
326
585
|
this.tui.requestRender();
|
|
327
586
|
};
|
|
328
587
|
input.onEscape = () => {
|
|
@@ -335,111 +594,172 @@ export class ConversationViewer implements Component {
|
|
|
335
594
|
this.tui.requestRender();
|
|
336
595
|
}
|
|
337
596
|
|
|
338
|
-
invalidate(): void {
|
|
339
|
-
|
|
597
|
+
invalidate(): void { this.layouts.clear(); this.composer?.invalidate(); }
|
|
340
598
|
dispose(): void {
|
|
341
599
|
this.closed = true;
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
600
|
+
this.layouts.clear();
|
|
601
|
+
this.pages.clear(); this.rowAnchors = []; this.anchor = undefined; this.textTarget = undefined;
|
|
602
|
+
this.composer = undefined;
|
|
603
|
+
this.viewport = undefined;
|
|
604
|
+
this.footerTargets = [];
|
|
605
|
+
this.unsubscribe?.();
|
|
606
|
+
this.unsubscribe = undefined;
|
|
346
607
|
}
|
|
347
|
-
|
|
348
|
-
// ---- Private ----
|
|
349
|
-
|
|
350
608
|
private maxOverlayRows(): number {
|
|
351
|
-
|
|
609
|
+
const rows = dimension(this.tui.terminal.rows);
|
|
610
|
+
return this.fullscreen ? rows : Math.floor(rows * VIEWPORT_HEIGHT_PCT / 100);
|
|
352
611
|
}
|
|
353
|
-
|
|
354
|
-
private
|
|
355
|
-
// Cap mirrors the overlay's maxHeight — otherwise the viewer would render
|
|
356
|
-
// more lines than the overlay shows and clip the footer.
|
|
357
|
-
return Math.max(MIN_VIEWPORT, this.maxOverlayRows() - this.chromeLines());
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
private chromeLines(): number {
|
|
361
|
-
// The composer adds one row above the footer hint while it's open.
|
|
362
|
-
return CHROME_LINES_BASE + (this.invocationLine() ? 1 : 0) + (this.composer ? 1 : 0);
|
|
363
|
-
}
|
|
364
|
-
|
|
612
|
+
private viewportHeight(): number { return Math.max(this.fullscreen ? 0 : 1, this.maxOverlayRows() - this.chromeLines()); }
|
|
613
|
+
private chromeLines(): number { return (this.fullscreen ? 4 : 6) + (this.invocationLine() ? 1 : 0) + (this.composer ? 1 : 0); }
|
|
365
614
|
private invocationLine(): string | undefined {
|
|
366
615
|
const { modelName, tags } = buildInvocationTags(this.record.invocation);
|
|
367
616
|
const parts = modelName ? [modelName, ...tags] : tags;
|
|
368
617
|
if (parts.length === 0) return undefined;
|
|
369
|
-
return this.theme.fg("dim", ` invocation · ${parts.map(part => sanitizeDisplayText(part)).join(" · ")}`);
|
|
618
|
+
return this.theme.fg("dim", ` invocation · ${parts.map(part => sanitizeDisplayText(part.slice(0, MAX_MESSAGE_CHARS))).join(" · ")}`);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
private markdownTheme(): MarkdownTheme {
|
|
622
|
+
const fg = (color: string) => (text: string) => this.theme.fg(color, text);
|
|
623
|
+
return { heading: this.theme.bold.bind(this.theme), link: fg("accent"), linkUrl: fg("dim"), code: fg("accent"),
|
|
624
|
+
codeBlock: fg("text"), codeBlockBorder: fg("dim"), quote: fg("muted"), quoteBorder: fg("dim"), hr: fg("dim"),
|
|
625
|
+
listBullet: fg("accent"), bold: this.theme.bold.bind(this.theme), italic: fg("text"), strikethrough: fg("dim"), underline: fg("text") };
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
private messageLines(msg: Message, width: number): string[] {
|
|
629
|
+
const preview = msg.role === "toolResult" || isBashExecutionMessage(msg);
|
|
630
|
+
let page = this.pages.get(msg) ?? { cursor: { block: 0, offset: 0 }, row: 0 };
|
|
631
|
+
const content = isBashExecutionMessage(msg) ? msg.output : "content" in msg ? msg.content : "";
|
|
632
|
+
const block = Array.isArray(content) ? content[page.cursor.block] : undefined;
|
|
633
|
+
const part = typeof content === "string" ? content : block?.type === "text" ? block.text : block?.type === "toolCall" ? block.name : undefined;
|
|
634
|
+
// A shortened/replaced source must not leave a retained cursor beyond all available text.
|
|
635
|
+
if (page.cursor.offset > 0 && (part === undefined || page.cursor.offset >= part.length) || Array.isArray(content) && page.cursor.block >= content.length) {
|
|
636
|
+
this.pages.delete(msg);
|
|
637
|
+
page = { cursor: { block: 0, offset: 0 }, row: 0 };
|
|
638
|
+
}
|
|
639
|
+
// Narrow widths reduce the raw input bound, BEFORE Markdown/wrapping can expand it.
|
|
640
|
+
const source = boundedMessageText(msg, preview ? PREVIEW_SCAN_LIMIT : Math.min(MAX_MESSAGE_CHARS, Math.max(128, width * 16)), preview ? { block: 0, offset: 0 } : page.cursor);
|
|
641
|
+
const safe = safeTerminalText(source.text);
|
|
642
|
+
const capped = preview ? truncateCodePoints(safe, PREVIEW_CHARS, "") : safe;
|
|
643
|
+
const text = capped.trim();
|
|
644
|
+
const omitted = !!source.next || capped.length < safe.length;
|
|
645
|
+
const calls = source.calls;
|
|
646
|
+
const command = isBashExecutionMessage(msg) ? sanitizeDisplayText(msg.command.slice(0, MAX_MESSAGE_CHARS)) : "";
|
|
647
|
+
const signature = `${msg.role}\0${text}\0${omitted}\0${command}\0${calls.join("\0")}\0${page.row}\0${page.cursor.block}:${page.cursor.offset}`;
|
|
648
|
+
let entry = this.layouts.get(msg);
|
|
649
|
+
const shortened = !!entry && source.sourceLength < entry.sourceLength;
|
|
650
|
+
if (entry?.signature === signature && !shortened) { entry.sourceLength = source.sourceLength; entry.next = preview ? undefined : source.next; return entry.lines; }
|
|
651
|
+
const failed = !!entry?.failed && !shortened && text.startsWith(entry.text);
|
|
652
|
+
entry = { text, signature, sourceLength: source.sourceLength, lines: [], failed, next: preview ? undefined : source.next, moreRows: false, markdown: entry?.markdown };
|
|
653
|
+
if (!this.layouts.has(msg) && this.layouts.size >= MAX_MESSAGES) this.layouts.delete(this.layouts.keys().next().value as Message);
|
|
654
|
+
this.layouts.set(msg, entry);
|
|
655
|
+
if (!text && calls.length === 0 && !command && !source.next) return [];
|
|
656
|
+
const heading = msg.role === "user" ? this.theme.fg("accent", "User") : msg.role === "assistant" ? this.theme.bold("Assistant")
|
|
657
|
+
: msg.role === "toolResult" ? this.theme.fg("dim", "Result") : this.theme.fg("muted", `Command: ${command}`);
|
|
658
|
+
let body: string[] = [];
|
|
659
|
+
if (text) {
|
|
660
|
+
if (msg.role === "assistant" && !entry.failed) {
|
|
661
|
+
try {
|
|
662
|
+
entry.markdown ??= new Markdown(text, 0, 0, this.markdownTheme());
|
|
663
|
+
entry.markdown.setText(text);
|
|
664
|
+
body = entry.markdown.render(width);
|
|
665
|
+
} catch { entry.failed = true; }
|
|
666
|
+
}
|
|
667
|
+
if (msg.role !== "assistant" || entry.failed) body = wrapTextWithAnsi(text, width);
|
|
668
|
+
}
|
|
669
|
+
if (preview) body = body.map(line => this.theme.fg("dim", line));
|
|
670
|
+
const rendered = [...body, ...calls.flatMap(name => wrapTextWithAnsi(`Tool: ${name}`, width).map(line => this.theme.fg("muted", line)))];
|
|
671
|
+
const capacity = MAX_MESSAGE_ROWS - 2; // ALWAYS reserve heading and an accurate paging/omission marker.
|
|
672
|
+
const pageStart = Math.min(page.row, Math.max(0, rendered.length - 1));
|
|
673
|
+
entry.moreRows = pageStart + capacity < rendered.length;
|
|
674
|
+
const more = entry.moreRows || (!preview && omitted);
|
|
675
|
+
const marker = more ? "] next text · [ first text (more available)" : preview && omitted ? "... (truncated preview)" : page.cursor.block || page.cursor.offset || page.row ? "[ first text" : "";
|
|
676
|
+
// Even at one content column, > denotes available continuation, ~ a truncated preview.
|
|
677
|
+
const markerLine = width < 8 ? more ? ">" : preview && omitted ? "~" : "[" : marker;
|
|
678
|
+
entry.lines = [heading, ...rendered.slice(pageStart, pageStart + capacity), ...(marker ? [this.theme.fg("dim", markerLine)] : [])]
|
|
679
|
+
.map(line => truncateToWidth(line, width));
|
|
680
|
+
return entry.lines;
|
|
370
681
|
}
|
|
371
682
|
|
|
372
683
|
private buildContentLines(width: number): string[] {
|
|
684
|
+
width = dimension(width);
|
|
373
685
|
if (width <= 0) return [];
|
|
374
|
-
|
|
375
|
-
const th = this.theme;
|
|
376
686
|
let messages: AgentSession["messages"];
|
|
377
687
|
try { messages = this.session.messages; } catch { messages = []; }
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
if (
|
|
381
|
-
|
|
382
|
-
|
|
688
|
+
const compacted = messages.length < this.messageCount || messages[0] !== this.firstMessage || this.compactionCount !== this.record.compactionCount;
|
|
689
|
+
const resized = width !== this.layoutWidth;
|
|
690
|
+
if (resized || compacted) this.layouts.clear();
|
|
691
|
+
if (resized) {
|
|
692
|
+
// Wrapping rows belong to the old width/source bound, unlike raw cursors.
|
|
693
|
+
// Repeating the current source chunk is safer than skipping unread text.
|
|
694
|
+
for (const [message, page] of this.pages) if (page.row) this.pages.set(message, { ...page, row: 0 });
|
|
383
695
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
696
|
+
if (this.textTarget && !messages.includes(this.textTarget)) this.textTarget = undefined;
|
|
697
|
+
if (!this.autoScroll && !compacted) this.newMessages += Math.max(0, messages.length - this.messageCount);
|
|
698
|
+
if (compacted) this.newMessages = 0;
|
|
699
|
+
this.messageCount = messages.length;
|
|
700
|
+
this.firstMessage = messages[0];
|
|
701
|
+
this.compactionCount = this.record.compactionCount;
|
|
702
|
+
this.layoutWidth = width;
|
|
703
|
+
// Only references are searched; no global text layout, joining or length scans.
|
|
704
|
+
if (!this.autoScroll && this.anchor) {
|
|
705
|
+
let index = messages.indexOf(this.anchor.message);
|
|
706
|
+
if (index < 0) index = Math.min(this.anchor.index, Math.max(0, messages.length - 1));
|
|
707
|
+
const message = messages[index];
|
|
708
|
+
if (message) {
|
|
709
|
+
this.anchor = { ...this.anchor, message, index };
|
|
710
|
+
if (compacted || index < this.windowStart || index >= this.windowEnd) {
|
|
711
|
+
this.windowStart = index; this.windowBackwards = false;
|
|
396
712
|
}
|
|
397
|
-
} else if (msg.role === "assistant") {
|
|
398
|
-
const textParts: string[] = [];
|
|
399
|
-
const toolCalls: string[] = [];
|
|
400
|
-
for (const c of msg.content) {
|
|
401
|
-
if (c.type === "text" && c.text) textParts.push(c.text);
|
|
402
|
-
else if (c.type === "toolCall") toolCalls.push(c.name);
|
|
403
|
-
}
|
|
404
|
-
if (needsSeparator) lines.push("");
|
|
405
|
-
lines.push(th.bold("Assistant"));
|
|
406
|
-
if (textParts.length > 0) {
|
|
407
|
-
for (const line of wrapTextWithAnsi(safeTerminalText(textParts.join("\n")).trim(), width)) {
|
|
408
|
-
lines.push(line);
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
for (const name of toolCalls) {
|
|
412
|
-
lines.push(truncateToWidth(th.fg("muted", `Tool: ${sanitizeDisplayText(name)}`), width));
|
|
413
|
-
}
|
|
414
|
-
} else if (msg.role === "toolResult") {
|
|
415
|
-
const truncated = previewText(extractText(msg.content));
|
|
416
|
-
if (!truncated.trim()) continue;
|
|
417
|
-
if (needsSeparator) lines.push("");
|
|
418
|
-
lines.push(th.fg("dim", "Result"));
|
|
419
|
-
for (const line of wrapTextWithAnsi(truncated.trim(), width)) {
|
|
420
|
-
lines.push(th.fg("dim", line));
|
|
421
|
-
}
|
|
422
|
-
} else if (isBashExecutionMessage(msg)) {
|
|
423
|
-
if (needsSeparator) lines.push("");
|
|
424
|
-
lines.push(truncateToWidth(th.fg("muted", `Command: ${sanitizeDisplayText(msg.command)}`), width));
|
|
425
|
-
const out = previewText(msg.output);
|
|
426
|
-
if (out.trim()) {
|
|
427
|
-
for (const line of wrapTextWithAnsi(out.trim(), width)) {
|
|
428
|
-
lines.push(th.fg("dim", line));
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
} else {
|
|
432
|
-
continue;
|
|
433
713
|
}
|
|
434
|
-
needsSeparator = true;
|
|
435
714
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
715
|
+
if (this.autoScroll) { this.windowEnd = messages.length; this.windowBackwards = true; }
|
|
716
|
+
this.windowStart = Math.max(0, Math.min(this.windowStart, Math.max(0, messages.length - 1)));
|
|
717
|
+
this.windowEnd = Math.max(0, Math.min(this.windowEnd, messages.length));
|
|
718
|
+
const groups: { message: Message; index: number; lines: string[] }[] = [];
|
|
719
|
+
let count = 0;
|
|
720
|
+
const backwards = this.windowBackwards;
|
|
721
|
+
let index = backwards ? this.windowEnd - 1 : this.windowStart;
|
|
722
|
+
let visited = 0;
|
|
723
|
+
while (index >= 0 && index < messages.length && visited < MAX_MESSAGES) {
|
|
724
|
+
const message = messages[index];
|
|
725
|
+
visited++;
|
|
726
|
+
if (["user", "assistant", "toolResult", "bashExecution"].includes(message.role)) {
|
|
727
|
+
const group = this.messageLines(message, width);
|
|
728
|
+
if (count + group.length + 1 > MAX_CONTENT_ROWS - 4) { this.layouts.delete(message); break; }
|
|
729
|
+
groups.push({ message, index, lines: group });
|
|
730
|
+
count += group.length + 1;
|
|
731
|
+
}
|
|
732
|
+
index += backwards ? -1 : 1;
|
|
441
733
|
}
|
|
442
|
-
|
|
443
|
-
|
|
734
|
+
if (backwards) { this.windowStart = Math.max(0, index + 1); groups.reverse(); }
|
|
735
|
+
else this.windowEnd = Math.min(messages.length, index);
|
|
736
|
+
const current = new Set(groups.map(group => group.message));
|
|
737
|
+
for (const message of this.layouts.keys()) if (!current.has(message)) this.layouts.delete(message);
|
|
738
|
+
// At most the current window's pages plus one explicitly selected cursor.
|
|
739
|
+
for (const message of this.pages.keys()) if (!current.has(message) && message !== this.textTarget) this.pages.delete(message);
|
|
740
|
+
const lines: string[] = [];
|
|
741
|
+
this.rowAnchors = [];
|
|
742
|
+
const append = (line: string, anchor?: RowAnchor): void => { lines.push(truncateToWidth(line, width)); this.rowAnchors.push(anchor); };
|
|
743
|
+
if (this.windowStart > 0) append(width < 8 ? "↑" : "↑ older messages available · scroll up / Home");
|
|
744
|
+
for (const group of groups) {
|
|
745
|
+
if (!group.lines.length) continue;
|
|
746
|
+
if (lines.length) append("");
|
|
747
|
+
group.lines.forEach((line, row) => { append(line, { message: group.message, index: group.index, row }); });
|
|
748
|
+
}
|
|
749
|
+
if (this.windowEnd < messages.length) append(width < 8 ? "↓" : "↓ newer messages available · scroll down / End");
|
|
750
|
+
if (!messages.length) append(this.theme.fg("dim", "waiting for first message"));
|
|
751
|
+
if (this.windowEnd === messages.length && this.record.status === "running" && this.activity) {
|
|
752
|
+
const act = describeActivity(this.activity.activeTools, this.activity.responseText.slice(0, MAX_MESSAGE_CHARS));
|
|
753
|
+
append(""); append(`${this.theme.fg("accent", "Thinking...")}${this.theme.fg("dim", ` · ${act}`)}`);
|
|
754
|
+
}
|
|
755
|
+
if (!this.autoScroll && this.anchor) {
|
|
756
|
+
const anchor = this.anchor;
|
|
757
|
+
const start = this.rowAnchors.findIndex(row => row?.message === anchor.message);
|
|
758
|
+
if (start >= 0) {
|
|
759
|
+
const length = groups.find(group => group.message === anchor.message)?.lines.length ?? 1;
|
|
760
|
+
this.scrollOffset = Math.max(0, start + Math.min(anchor.row, Math.max(0, length - 1)));
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return lines;
|
|
444
764
|
}
|
|
445
765
|
}
|