@signalridge/pi-subagents 1.11.1 → 1.13.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 +27 -0
- package/README.md +77 -10
- package/package.json +3 -3
- package/src/agent-color.ts +1 -1
- package/src/agent-manager.ts +395 -66
- package/src/agent-runner.ts +83 -35
- package/src/extension-resource-names.ts +105 -0
- package/src/index.ts +221 -69
- package/src/output-file.ts +28 -9
- package/src/parent-questions.ts +453 -0
- package/src/question-origin.ts +24 -0
- package/src/supervisor.ts +28 -8
- package/src/types.ts +4 -3
- package/src/ui/agent-display.ts +2 -1
- package/src/ui/conversation-viewer.ts +641 -290
- package/src/ui/fleet-list.ts +258 -66
- package/src/ui/owner-group.ts +110 -0
- package/src/ui/schedule-menu.ts +14 -7
- package/src/ui/workflow-ui-client.ts +189 -0
|
@@ -1,72 +1,146 @@
|
|
|
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";
|
|
4
|
+
import { sanitizeDisplayText } from "@signalridge/pi-ui";
|
|
10
5
|
import { renderAgentName } from "../agent-color.js";
|
|
11
|
-
import { extractText } from "../context.js";
|
|
12
6
|
import type { AgentRecordSnapshot } from "../types.js";
|
|
13
7
|
import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
|
|
14
|
-
import type
|
|
15
|
-
import {
|
|
16
|
-
import { PREVIEW_SCAN_LIMIT, safeTerminalText, sanitizeDisplayText, truncateCodePoints } from "./safe-text.js";
|
|
8
|
+
import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatDuration, formatSessionTokens, getPromptModeLabel, type Theme } from "./agent-display.js";
|
|
9
|
+
import { PREVIEW_SCAN_LIMIT, safeTerminalText, truncateCodePoints } from "./safe-text.js";
|
|
17
10
|
import { getAgentStatusColor, getAgentStatusLabel, getAgentStatusMark } from "./status-label.js";
|
|
18
11
|
import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
|
|
19
12
|
|
|
20
|
-
/**
|
|
21
|
-
const CHROME_LINES_BASE = 6;
|
|
22
|
-
const MIN_VIEWPORT = 1;
|
|
23
|
-
/** 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. */
|
|
24
14
|
export const VIEWPORT_HEIGHT_PCT = 70;
|
|
25
15
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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";
|
|
20
|
+
}
|
|
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";
|
|
33
|
+
}
|
|
34
|
+
// biome-ignore lint/suspicious/noConfusingVoidType: preserve existing void-returning steering callbacks.
|
|
35
|
+
export type SteerAcceptance = boolean | void;
|
|
36
|
+
export type SteerHandler = (message: string) => SteerAcceptance | Promise<SteerAcceptance>;
|
|
37
|
+
|
|
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
|
+
}
|
|
34
58
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const scanned = raw.length > PREVIEW_SCAN_LIMIT ? raw.slice(0, PREVIEW_SCAN_LIMIT) : raw;
|
|
46
|
-
const safe = safeTerminalText(scanned);
|
|
47
|
-
if (scanned.length === raw.length && safe.length <= PREVIEW_LINE_LIMIT) return safe;
|
|
48
|
-
return `${truncateCodePoints(safe, PREVIEW_LINE_LIMIT, "")}... (truncated)`;
|
|
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;
|
|
49
69
|
}
|
|
50
70
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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 };
|
|
57
102
|
}
|
|
58
103
|
|
|
59
|
-
export class ConversationViewer implements Component {
|
|
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;
|
|
60
134
|
private scrollOffset = 0;
|
|
61
135
|
private autoScroll = true;
|
|
62
136
|
private unsubscribe: (() => void) | undefined;
|
|
63
137
|
private lastInnerW = 0;
|
|
64
138
|
private closed = false;
|
|
65
|
-
/** Two-press confirm guard for the stop key, so a stray key can't kill the agent. */
|
|
66
139
|
private stopArmed = false;
|
|
67
140
|
private keys: ViewerKeys;
|
|
68
|
-
/** Steering composer — present while the user is typing a message to the agent. */
|
|
69
141
|
private composer: Input | undefined;
|
|
142
|
+
private steerError: string | undefined;
|
|
143
|
+
private sending = false;
|
|
70
144
|
|
|
71
145
|
constructor(
|
|
72
146
|
private tui: TUI,
|
|
@@ -75,340 +149,617 @@ export class ConversationViewer implements Component {
|
|
|
75
149
|
private activity: AgentActivity | undefined,
|
|
76
150
|
private theme: Theme,
|
|
77
151
|
private done: (result: undefined) => void,
|
|
78
|
-
/** Abort the agent shown here. Omitted → no stop affordance (e.g. read-only history). */
|
|
79
152
|
private onStop?: () => void,
|
|
80
|
-
/** User keybindings from `ctx.ui.custom()`. Omitted → hardcoded defaults. */
|
|
81
153
|
keybindings?: ViewerKeybindings,
|
|
82
|
-
|
|
83
|
-
private onSteer?: (message: string) => void,
|
|
84
|
-
/** Retrieve a fresh public snapshot so the viewer reflects status changes. */
|
|
154
|
+
private onSteer?: SteerHandler,
|
|
85
155
|
private getRecord?: () => AgentRecordSnapshot | undefined,
|
|
156
|
+
private options: ViewerOptions = {},
|
|
86
157
|
) {
|
|
158
|
+
this.fullscreen = getConversationViewerMode(tui, options) === "fullscreen";
|
|
87
159
|
this.keys = createViewerKeys(keybindings);
|
|
88
160
|
this.unsubscribe = session.subscribe(() => {
|
|
89
|
-
if (this.closed)
|
|
90
|
-
this.tui.requestRender();
|
|
161
|
+
if (!this.closed) this.tui.requestRender();
|
|
91
162
|
});
|
|
92
163
|
}
|
|
93
164
|
|
|
94
165
|
private refreshRecord(): AgentRecordSnapshot {
|
|
95
|
-
|
|
96
|
-
|
|
166
|
+
try {
|
|
167
|
+
const current = this.getRecord?.();
|
|
168
|
+
// Never bind controls/status to a different agent if the owner changes selection.
|
|
169
|
+
if (current?.id === this.record.id) this.record = current;
|
|
170
|
+
} catch { /* a disposed owner cannot erase the retained snapshot */ }
|
|
97
171
|
return this.record;
|
|
98
172
|
}
|
|
99
173
|
|
|
100
174
|
handleInput(data: string): void {
|
|
101
|
-
|
|
102
|
-
//
|
|
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; }
|
|
103
178
|
if (this.composer) {
|
|
104
|
-
this.composer.handleInput(data);
|
|
179
|
+
if (!this.sending || matchesKey(data, "escape")) this.composer.handleInput(data);
|
|
105
180
|
this.tui.requestRender();
|
|
106
181
|
return;
|
|
107
182
|
}
|
|
108
|
-
|
|
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
|
+
}
|
|
109
187
|
if (matchesKey(data, "escape") || matchesKey(data, "q")) {
|
|
110
|
-
this.
|
|
111
|
-
this.done(undefined);
|
|
112
|
-
return;
|
|
188
|
+
this.dispose(); this.done(undefined); return;
|
|
113
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;
|
|
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
|
+
}
|
|
114
246
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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--;
|
|
122
266
|
}
|
|
267
|
+
const anchor = this.rowAnchors[index];
|
|
268
|
+
this.anchor = anchor ? { ...anchor, row: anchor.row + this.scrollOffset - index } : undefined;
|
|
269
|
+
}
|
|
123
270
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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);
|
|
137
291
|
}
|
|
138
|
-
|
|
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
|
+
}
|
|
139
297
|
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
298
|
+
private canPageMessage(message: Message): boolean {
|
|
299
|
+
const entry = this.layouts.get(message);
|
|
300
|
+
return !!(entry?.moreRows || entry?.next || this.pages.has(message));
|
|
301
|
+
}
|
|
143
302
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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.
|
|
158
337
|
this.autoScroll = false;
|
|
159
|
-
|
|
160
|
-
this.
|
|
161
|
-
this.
|
|
338
|
+
this.anchor = { ...anchor, row: 0 };
|
|
339
|
+
this.windowStart = anchor.index; this.windowBackwards = false;
|
|
340
|
+
this.buildContentLines(this.lastInnerW);
|
|
341
|
+
this.tui.requestRender();
|
|
162
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 };
|
|
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 };
|
|
163
426
|
}
|
|
164
427
|
|
|
165
428
|
render(width: number): string[] {
|
|
166
|
-
|
|
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 }, () => "") : [];
|
|
167
438
|
this.refreshRecord();
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
// rather than showing a misleading half-box with a missing bottom border.
|
|
171
|
-
if (this.maxOverlayRows() < this.chromeLines() + MIN_VIEWPORT) return [];
|
|
439
|
+
if (!this.fullscreen && rows < this.chromeLines() + 1) return [];
|
|
440
|
+
if (rows === 0) return [];
|
|
172
441
|
const th = this.theme;
|
|
173
|
-
|
|
174
|
-
// the side borders and one padding column on each side of the content.
|
|
175
|
-
const innerW = width - 4;
|
|
442
|
+
const innerW = this.fullscreen ? width < 3 ? width : width - 2 : width - 4;
|
|
176
443
|
this.lastInnerW = innerW;
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const vis = visibleWidth(s);
|
|
181
|
-
return s + " ".repeat(Math.max(0, len - vis));
|
|
182
|
-
};
|
|
183
|
-
const row = (content: string) => {
|
|
184
|
-
const fitted = truncateToWidth(pad(content, innerW), innerW, "...", true);
|
|
185
|
-
return `${th.fg("borderAccent", "│")} ${fitted} ${th.fg("borderAccent", "│")}`;
|
|
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", "│")}`;
|
|
186
447
|
};
|
|
187
|
-
|
|
188
|
-
// transparent background. This makes the dialog boundary obvious and
|
|
189
|
-
// matches the reference pi-subagents viewer (╭─╮ / │ / ╰─╯).
|
|
190
|
-
const rule = (left: string, right: string) =>
|
|
191
|
-
th.fg("borderAccent", `${left}${"─".repeat(Math.max(0, width - 2))}${right}`);
|
|
192
|
-
const topRule = rule("╭", "╮");
|
|
193
|
-
const bottomRule = rule("╰", "╯");
|
|
194
|
-
// Match the reference viewer's internal separator: it keeps the header,
|
|
195
|
-
// 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}`);
|
|
196
449
|
const hrMid = row(th.fg("dim", "─".repeat(innerW)));
|
|
197
|
-
|
|
198
|
-
lines.push(
|
|
450
|
+
const lines: string[] = [];
|
|
451
|
+
if (!this.fullscreen) lines.push(rule("╭", "╮"));
|
|
199
452
|
const name = renderAgentName(this.record.type, th, { bold: true });
|
|
200
453
|
const modeLabel = getPromptModeLabel(this.record.type);
|
|
201
|
-
const
|
|
202
|
-
const statusText = getAgentStatusLabel(this.record.status);
|
|
203
|
-
const statusColor = getAgentStatusColor(this.record.status);
|
|
204
|
-
const duration = formatDuration(this.record.startedAt, this.record.completedAt);
|
|
205
|
-
|
|
206
|
-
const headerStats: string[] = [duration];
|
|
454
|
+
const stats: string[] = [formatDuration(this.record.startedAt, this.record.completedAt)];
|
|
207
455
|
const toolUses = this.activity?.toolUses ?? this.record.toolUses;
|
|
208
|
-
if (toolUses > 0)
|
|
209
|
-
const tokens = getLifetimeTotal(this.activity?.lifetimeUsage);
|
|
210
|
-
if (tokens > 0) {
|
|
211
|
-
|
|
212
|
-
headerStats.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount));
|
|
456
|
+
if (toolUses > 0) stats.unshift(`tools ${toolUses}`);
|
|
457
|
+
const tokens = getLifetimeTotal(this.activity?.lifetimeUsage ?? this.record.lifetimeUsage);
|
|
458
|
+
if (tokens > 0 || this.record.compactionCount > 0) {
|
|
459
|
+
stats.push(formatSessionTokens(tokens, getSessionContextPercent(this.activity?.session ?? this.session), th, this.record.compactionCount));
|
|
213
460
|
}
|
|
214
|
-
|
|
215
461
|
const headerParts = [
|
|
216
|
-
th.fg(
|
|
462
|
+
th.fg(getAgentStatusColor(this.record.status), `${getAgentStatusMark(this.record.status)} ${getAgentStatusLabel(this.record.status)}`),
|
|
217
463
|
name,
|
|
218
|
-
|
|
219
|
-
th.fg("muted", sanitizeDisplayText(this.record.description)),
|
|
220
|
-
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(" · ")),
|
|
221
467
|
].filter((part): part is string => part !== undefined);
|
|
222
468
|
lines.push(row(headerParts.join(th.fg("dim", " · "))));
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
if (invocationLine) lines.push(row(invocationLine));
|
|
469
|
+
const invocation = this.invocationLine();
|
|
470
|
+
if (invocation) lines.push(row(invocation));
|
|
226
471
|
lines.push(hrMid);
|
|
227
|
-
|
|
228
472
|
const contentLines = this.buildContentLines(innerW);
|
|
229
|
-
const
|
|
230
|
-
const maxScroll = Math.max(0, contentLines.length -
|
|
231
|
-
|
|
232
|
-
if (this.autoScroll)
|
|
233
|
-
|
|
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`));
|
|
234
483
|
}
|
|
235
|
-
|
|
236
|
-
const
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
for (let i = 0; i <
|
|
240
|
-
|
|
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));
|
|
241
492
|
}
|
|
242
|
-
|
|
243
|
-
|
|
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 }[] = [];
|
|
244
502
|
if (this.composer) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
const composeLeft = th.fg("accent", "chat");
|
|
250
|
-
const composeGap = Math.max(1, innerW - visibleWidth(composeLeft) - visibleWidth(composeHint));
|
|
251
|
-
lines.push(row(composeLeft + " ".repeat(composeGap) + 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")));
|
|
252
507
|
} else {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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" });
|
|
258
516
|
}
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const withCount = [count, ...actions].join(sep);
|
|
266
|
-
const footerLeft = visibleWidth(withCount) + visibleWidth(footerRight) + 1 <= innerW
|
|
267
|
-
? withCount
|
|
268
|
-
: actions.join(sep);
|
|
269
|
-
|
|
270
|
-
const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
|
|
271
|
-
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));
|
|
272
523
|
}
|
|
273
|
-
lines.
|
|
274
|
-
|
|
275
|
-
|
|
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;
|
|
276
542
|
}
|
|
277
543
|
|
|
278
|
-
|
|
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
|
+
}
|
|
279
549
|
private isStoppable(): boolean {
|
|
280
550
|
const record = this.refreshRecord();
|
|
281
|
-
return !!this.onStop && (record.status === "running" || record.status === "queued");
|
|
551
|
+
return !record.detached && !!this.onStop && (record.status === "running" || record.status === "queued");
|
|
282
552
|
}
|
|
283
|
-
|
|
284
|
-
/** Steerable only when a steer handler exists and the agent is still active. */
|
|
285
553
|
private canSteer(): boolean {
|
|
286
554
|
const record = this.refreshRecord();
|
|
287
|
-
return !!this.onSteer && (record.status === "running" || record.status === "queued");
|
|
555
|
+
return !record.detached && !!this.onSteer && (record.status === "running" || record.status === "queued");
|
|
288
556
|
}
|
|
289
557
|
|
|
290
|
-
/** Open the inline steering composer and route subsequent input to it. */
|
|
291
558
|
private openComposer(): void {
|
|
292
|
-
const input = new Input();
|
|
293
|
-
|
|
559
|
+
const input = new Input({ prompt: "" });
|
|
560
|
+
this.controlError = undefined;
|
|
561
|
+
input.focused = this.focused;
|
|
562
|
+
this.steerError = undefined;
|
|
563
|
+
this.handoffArmed = false;
|
|
294
564
|
input.onSubmit = (value: string) => {
|
|
295
565
|
const message = value.trim();
|
|
296
|
-
this.composer = undefined;
|
|
297
|
-
if (
|
|
566
|
+
if (!message) { this.composer = undefined; this.tui.requestRender(); return; }
|
|
567
|
+
if (!this.canSteer()) {
|
|
568
|
+
this.steerError = "Agent finished; draft retained · Esc cancel";
|
|
569
|
+
this.tui.requestRender();
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
this.steerError = undefined;
|
|
573
|
+
const settle = (accepted: SteerAcceptance): void => {
|
|
574
|
+
if (this.closed || this.composer !== input) return;
|
|
575
|
+
this.sending = false;
|
|
576
|
+
if (accepted === false) this.steerError = "Steering rejected; draft retained · Esc cancel";
|
|
577
|
+
else this.composer = undefined;
|
|
578
|
+
this.tui.requestRender();
|
|
579
|
+
};
|
|
580
|
+
try {
|
|
581
|
+
const result = this.onSteer?.(message);
|
|
582
|
+
if (result && typeof result === "object") { this.sending = true; void result.then(settle, () => settle(false)); }
|
|
583
|
+
else settle(result);
|
|
584
|
+
} catch { settle(false); }
|
|
298
585
|
this.tui.requestRender();
|
|
299
586
|
};
|
|
300
587
|
input.onEscape = () => {
|
|
301
588
|
this.composer = undefined;
|
|
589
|
+
this.steerError = undefined;
|
|
590
|
+
this.sending = false;
|
|
302
591
|
this.tui.requestRender();
|
|
303
592
|
};
|
|
304
593
|
this.composer = input;
|
|
305
594
|
this.tui.requestRender();
|
|
306
595
|
}
|
|
307
596
|
|
|
308
|
-
invalidate(): void {
|
|
309
|
-
|
|
597
|
+
invalidate(): void { this.layouts.clear(); this.composer?.invalidate(); }
|
|
310
598
|
dispose(): void {
|
|
311
599
|
this.closed = true;
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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;
|
|
316
607
|
}
|
|
317
|
-
|
|
318
|
-
// ---- Private ----
|
|
319
|
-
|
|
320
608
|
private maxOverlayRows(): number {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
private viewportHeight(): number {
|
|
325
|
-
// Cap mirrors the overlay's maxHeight — otherwise the viewer would render
|
|
326
|
-
// more lines than the overlay shows and clip the footer.
|
|
327
|
-
return Math.max(MIN_VIEWPORT, this.maxOverlayRows() - this.chromeLines());
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
private chromeLines(): number {
|
|
331
|
-
// The composer adds one row above the footer hint while it's open.
|
|
332
|
-
return CHROME_LINES_BASE + (this.invocationLine() ? 1 : 0) + (this.composer ? 1 : 0);
|
|
609
|
+
const rows = dimension(this.tui.terminal.rows);
|
|
610
|
+
return this.fullscreen ? rows : Math.floor(rows * VIEWPORT_HEIGHT_PCT / 100);
|
|
333
611
|
}
|
|
334
|
-
|
|
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); }
|
|
335
614
|
private invocationLine(): string | undefined {
|
|
336
615
|
const { modelName, tags } = buildInvocationTags(this.record.invocation);
|
|
337
616
|
const parts = modelName ? [modelName, ...tags] : tags;
|
|
338
617
|
if (parts.length === 0) return undefined;
|
|
339
|
-
return this.theme.fg("dim", ` invocation · ${parts.join(" · ")}`);
|
|
618
|
+
return this.theme.fg("dim", ` invocation · ${parts.map(part => sanitizeDisplayText(part.slice(0, MAX_MESSAGE_CHARS))).join(" · ")}`);
|
|
340
619
|
}
|
|
341
620
|
|
|
342
|
-
private
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
+
}
|
|
348
627
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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);
|
|
352
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;
|
|
681
|
+
}
|
|
353
682
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
} else if (msg.role === "toolResult") {
|
|
384
|
-
const truncated = previewText(extractText(msg.content));
|
|
385
|
-
if (!truncated.trim()) continue;
|
|
386
|
-
if (needsSeparator) lines.push("");
|
|
387
|
-
lines.push(th.fg("dim", "Result"));
|
|
388
|
-
for (const line of wrapTextWithAnsi(truncated.trim(), width)) {
|
|
389
|
-
lines.push(th.fg("dim", line));
|
|
390
|
-
}
|
|
391
|
-
} else if (isBashExecutionMessage(msg)) {
|
|
392
|
-
if (needsSeparator) lines.push("");
|
|
393
|
-
lines.push(truncateToWidth(th.fg("muted", `Command: ${sanitizeDisplayText(msg.command)}`), width));
|
|
394
|
-
const out = previewText(msg.output);
|
|
395
|
-
if (out.trim()) {
|
|
396
|
-
for (const line of wrapTextWithAnsi(out.trim(), width)) {
|
|
397
|
-
lines.push(th.fg("dim", line));
|
|
398
|
-
}
|
|
683
|
+
private buildContentLines(width: number): string[] {
|
|
684
|
+
width = dimension(width);
|
|
685
|
+
if (width <= 0) return [];
|
|
686
|
+
let messages: AgentSession["messages"];
|
|
687
|
+
try { messages = this.session.messages; } catch { messages = []; }
|
|
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 });
|
|
695
|
+
}
|
|
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;
|
|
399
712
|
}
|
|
400
|
-
} else {
|
|
401
|
-
continue;
|
|
402
713
|
}
|
|
403
|
-
needsSeparator = true;
|
|
404
714
|
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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;
|
|
410
733
|
}
|
|
411
|
-
|
|
412
|
-
|
|
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;
|
|
413
764
|
}
|
|
414
765
|
}
|