@signalridge/pi-subagents 1.12.0 → 1.14.0

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