@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.
@@ -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 { Theme } from "./agent-display.js";
15
- import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatDuration, formatSessionTokens, getPromptModeLabel } from "./agent-display.js";
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
- /** Base lines consumed by chrome: top border + header + two separators + footer + bottom border. */
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
- type BashExecutionMessage = {
27
- role: "bashExecution";
28
- command: string;
29
- output: string;
30
- };
31
-
32
- /** Characters of a tool result or command output the transcript shows inline. */
33
- const PREVIEW_LINE_LIMIT = 500;
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
- * Reduce an untrusted blob to a bounded, inert preview.
37
- *
38
- * Scrub before truncating — a cut through a live escape sequence would leave a
39
- * dangling introducer, and only the scrubber decides where a sequence ends. The
40
- * raw prefix bound keeps a megabyte-sized tool result from being scrubbed in
41
- * full just to show 500 characters of it, and is large enough that even
42
- * escape-dense output still yields a full preview.
43
- */
44
- function previewText(raw: string): string {
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
- function isBashExecutionMessage(message: { role: string }): message is BashExecutionMessage {
52
- return message.role === "bashExecution"
53
- && "command" in message
54
- && typeof message.command === "string"
55
- && "output" in message
56
- && typeof message.output === "string";
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
- /** Send a steering message to the agent. Omitted → no compose affordance. */
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) return;
90
- this.tui.requestRender();
161
+ if (!this.closed) this.tui.requestRender();
91
162
  });
92
163
  }
93
164
 
94
165
  private refreshRecord(): AgentRecordSnapshot {
95
- const current = this.getRecord?.();
96
- if (current) this.record = current;
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
- // While composing a steer message, the input owns all keys (Enter sends,
102
- // Esc cancels — both wired in openComposer()). Editing keys flow through.
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.closed = true;
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
- // Enter opens the steering composer (only while the agent can still be
116
- // steered) — then type + Enter sends, Esc or an empty submit returns. When
117
- // not steerable, fall through so the key still disarms a pending stop.
118
- if (matchesKey(data, "enter") && this.canSteer()) {
119
- this.stopArmed = false;
120
- this.openComposer();
121
- return;
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
- // Stop/abort the agent (only while it can still be stopped). Two-press:
125
- // first "x" arms, second confirms — any other key disarms.
126
- if (matchesKey(data, "x")) {
127
- if (this.isStoppable()) {
128
- if (this.stopArmed) {
129
- this.stopArmed = false;
130
- this.onStop?.();
131
- } else {
132
- this.stopArmed = true;
133
- }
134
- this.tui.requestRender();
135
- }
136
- return;
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
- if (this.stopArmed) this.stopArmed = false;
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
- const totalLines = this.buildContentLines(this.lastInnerW).length;
141
- const viewportHeight = this.viewportHeight();
142
- const maxScroll = Math.max(0, totalLines - viewportHeight);
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
- if (this.keys.scrollUp(data)) {
145
- this.scrollOffset = Math.max(0, this.scrollOffset - 1);
146
- this.autoScroll = this.scrollOffset >= maxScroll;
147
- } else if (this.keys.scrollDown(data)) {
148
- this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
149
- this.autoScroll = this.scrollOffset >= maxScroll;
150
- } else if (this.keys.pageUp(data)) {
151
- this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
152
- this.autoScroll = false;
153
- } else if (this.keys.pageDown(data)) {
154
- this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
155
- this.autoScroll = this.scrollOffset >= maxScroll;
156
- } else if (matchesKey(data, "home")) {
157
- this.scrollOffset = 0;
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
- } else if (matchesKey(data, "end")) {
160
- this.scrollOffset = maxScroll;
161
- this.autoScroll = true;
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
- if (width < 6) return [];
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
- // A custom overlay is clipped by pi after rendering. At very low heights
169
- // there is no room for the complete header/footer chrome, so render nothing
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
- // Match pi's overlay style: a complete box with two columns reserved for
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 lines: string[] = [];
178
-
179
- const pad = (s: string, len: number) => {
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
- // Keep the border in the viewer rather than relying on the host overlay's
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(topRule);
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 modeTag = modeLabel ? th.fg("dim", `mode ${modeLabel}`) : undefined;
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) headerStats.unshift(`tools ${toolUses}`);
209
- const tokens = getLifetimeTotal(this.activity?.lifetimeUsage);
210
- if (tokens > 0) {
211
- const percent = getSessionContextPercent(this.activity?.session);
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(statusColor, `${getAgentStatusMark(this.record.status)} ${statusText}`),
462
+ th.fg(getAgentStatusColor(this.record.status), `${getAgentStatusMark(this.record.status)} ${getAgentStatusLabel(this.record.status)}`),
217
463
  name,
218
- modeTag,
219
- th.fg("muted", sanitizeDisplayText(this.record.description)),
220
- fgPreservingNestedStyles(th, "dim", headerStats.join(" · ")),
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
- const invocationLine = this.invocationLine();
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 viewportHeight = this.viewportHeight();
230
- const maxScroll = Math.max(0, contentLines.length - viewportHeight);
231
-
232
- if (this.autoScroll) {
233
- this.scrollOffset = maxScroll;
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 visibleStart = Math.min(this.scrollOffset, maxScroll);
237
- const visible = contentLines.slice(visibleStart, visibleStart + viewportHeight);
238
-
239
- for (let i = 0; i < viewportHeight; i++) {
240
- lines.push(row(visible[i] ?? ""));
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
- lines.push(hrMid);
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
- const renderedComposer = this.composer.render(innerW)[0] ?? "";
246
- const composerLine = renderedComposer.startsWith("> ") ? renderedComposer.slice(2) : renderedComposer;
247
- lines.push(row(composerLine));
248
- const composeHint = th.fg("dim", "Enter send · Esc cancel");
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
- const sep = th.fg("dim", " · ");
254
- const actions: string[] = [];
255
- if (this.canSteer()) actions.push(th.fg("dim", "Enter chat"));
256
- if (this.isStoppable()) {
257
- actions.push(this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop"));
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 footerRight = th.fg("dim", "up/down scroll · page up/page down or shift up/down · esc close");
260
-
261
- const scrollPct = contentLines.length <= viewportHeight
262
- ? "100%"
263
- : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
264
- const count = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
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.push(bottomRule);
274
-
275
- return lines;
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
- /** Stoppable only when a stop handler exists and the agent is still active. */
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
- input.focused = true;
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 (message) this.onSteer?.(message);
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 { /* no cached state to clear */ }
309
-
597
+ invalidate(): void { this.layouts.clear(); this.composer?.invalidate(); }
310
598
  dispose(): void {
311
599
  this.closed = true;
312
- if (this.unsubscribe) {
313
- this.unsubscribe();
314
- this.unsubscribe = undefined;
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
- return Math.floor((this.tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100);
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 buildContentLines(width: number): string[] {
343
- if (width <= 0) return [];
344
-
345
- const th = this.theme;
346
- const messages = this.session.messages;
347
- const lines: string[] = [];
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
- if (messages.length === 0) {
350
- lines.push(th.fg("dim", "waiting for first message"));
351
- return lines;
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
- let needsSeparator = false;
355
- for (const msg of messages) {
356
- if (msg.role === "user") {
357
- const text = safeTerminalText(typeof msg.content === "string"
358
- ? msg.content
359
- : extractText(msg.content));
360
- if (!text.trim()) continue;
361
- if (needsSeparator) lines.push("");
362
- lines.push(th.fg("accent", "User"));
363
- for (const line of wrapTextWithAnsi(text.trim(), width)) {
364
- lines.push(line);
365
- }
366
- } else if (msg.role === "assistant") {
367
- const textParts: string[] = [];
368
- const toolCalls: string[] = [];
369
- for (const c of msg.content) {
370
- if (c.type === "text" && c.text) textParts.push(c.text);
371
- else if (c.type === "toolCall") toolCalls.push(c.name);
372
- }
373
- if (needsSeparator) lines.push("");
374
- lines.push(th.bold("Assistant"));
375
- if (textParts.length > 0) {
376
- for (const line of wrapTextWithAnsi(safeTerminalText(textParts.join("\n")).trim(), width)) {
377
- lines.push(line);
378
- }
379
- }
380
- for (const name of toolCalls) {
381
- lines.push(truncateToWidth(th.fg("muted", `Tool: ${sanitizeDisplayText(name)}`), width));
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
- if (this.record.status === "running" && this.activity) {
407
- const act = describeActivity(this.activity.activeTools, this.activity.responseText);
408
- lines.push("");
409
- lines.push(truncateToWidth(`${th.fg("accent", "Thinking...")}${th.fg("dim", " · ")}${th.fg("dim", act)}`, width));
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
- return lines.map(l => truncateToWidth(l, width));
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
  }