pi-better-btw-plus 1.0.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.
@@ -0,0 +1,1513 @@
1
+ import {
2
+ Agent,
3
+ type AgentEvent,
4
+ type AgentMessage,
5
+ type AgentTool,
6
+ type ThinkingLevel,
7
+ } from "@earendil-works/pi-agent-core";
8
+ import type { Model } from "@earendil-works/pi-ai";
9
+ import { streamSimple } from "@earendil-works/pi-ai/compat";
10
+ import {
11
+ buildSessionContext,
12
+ convertToLlm,
13
+ copyToClipboard,
14
+ createCodingTools,
15
+ createReadOnlyTools,
16
+ getSelectListTheme,
17
+ type ModelRegistry,
18
+ type SessionEntry,
19
+ type Theme,
20
+ type ThemeColor,
21
+ type ScopedModel,
22
+ } from "@earendil-works/pi-coding-agent";
23
+ import { Type } from "@sinclair/typebox";
24
+ import {
25
+ Editor,
26
+ Key,
27
+ matchesKey,
28
+ SelectList,
29
+ truncateToWidth,
30
+ visibleWidth,
31
+ type Component,
32
+ type Focusable,
33
+ type SelectItem,
34
+ type TUI,
35
+ } from "@earendil-works/pi-tui";
36
+ import type { FileActivityTracker } from "./file-activity-tracker.ts";
37
+ import { forkSurgery } from "./fork-surgery.ts";
38
+ import {
39
+ readClipboardTextFromSystem,
40
+ type ClipboardReadOutcome,
41
+ } from "./clipboard-read.ts";
42
+ import { exportChatHistoryToFile } from "./side-chat-export.ts";
43
+ import {
44
+ isLeftDrag,
45
+ isLeftPress,
46
+ isLeftRelease,
47
+ isRightPress,
48
+ isRightRelease,
49
+ isWheelEvent,
50
+ type SgrMouseEvent,
51
+ wheelDirection,
52
+ } from "./side-chat-mouse.ts";
53
+ import { substituteTemplate, type PromptPack } from "./prompt-pack.ts";
54
+ import {
55
+ isFramingMessage,
56
+ markFramingMessage,
57
+ SideChatMessages,
58
+ type CellPos,
59
+ } from "./side-chat-messages.ts";
60
+ import {
61
+ buildModelChoices,
62
+ clampThinkingLevelForModel,
63
+ modelKey,
64
+ type ModelChoice,
65
+ } from "./model-switch.ts";
66
+ import { SIDE_CHAT_SHORTCUT } from "./shortcuts.ts";
67
+ import { wrapToolsWithOverlapDetection } from "./tool-wrapper.ts";
68
+ import type { SideChatFeatures } from "./config.ts";
69
+ import {
70
+ classifyRetryable,
71
+ runWithRetry,
72
+ type RetryableFailure,
73
+ type RetryableInput,
74
+ type RetryAttemptInfo,
75
+ type RetryPolicy,
76
+ } from "./retry.ts";
77
+ export interface ForkContext {
78
+ messages: AgentMessage[];
79
+ model: Model<any>;
80
+ systemPrompt: string;
81
+ thinkingLevel: ThinkingLevel;
82
+ cwd: string;
83
+ extensionTools: AgentTool[];
84
+ }
85
+
86
+ /** Minimal session view used by the side chat (getEntries + getLeafId). */
87
+ type SessionView = { getEntries(): SessionEntry[]; getLeafId(): string | null };
88
+
89
+ interface SideChatOverlayOptions {
90
+ tui: TUI;
91
+ theme: Theme;
92
+ forkContext: ForkContext;
93
+ tracker: FileActivityTracker;
94
+ modelRegistry: ModelRegistry;
95
+ /** Models scoped to this session (--models / enabledModels); empty when unscoped. */
96
+ scopedModels: readonly ScopedModel[];
97
+ sessionManager: SessionView;
98
+ /** Prompt texts resolved from config.json `promptPack` (fresh per fork). */
99
+ promptPack: PromptPack;
100
+ /** Extension tools allowed in read-only mode (config.json, git-untracked). */
101
+ readOnlyExtensionAllowlist: string[];
102
+ /** `settings.retry` budget/backoff read from pi's settings files (D8). */
103
+ retryPolicy: RetryPolicy;
104
+ /** Per-feature kill switches resolved from the layered config (D11). */
105
+ features: SideChatFeatures;
106
+ onOverlapWarning: (path: string) => Promise<boolean>;
107
+ onBackground: () => void;
108
+ onClose: (
109
+ action: "close" | "refork" | "clear",
110
+ messages: AgentMessage[],
111
+ ) => void;
112
+ /** Alt+E export written to $CWD/.agents/eval/ — called with the written path. */
113
+ onExport: (path: string) => void;
114
+ }
115
+
116
+ /** Overlay max-height used for the side chat (adapted for small terminals at render time). */
117
+ export const SIDE_CHAT_OVERLAY_MAX_HEIGHT = "88%";
118
+ export const SIDE_CHAT_OVERLAY_MARGIN_TOP = 1;
119
+ /** Overlay width (percent) and horizontal margins, matching index.ts overlayOptions. */
120
+ const SIDE_CHAT_OVERLAY_WIDTH = "85%";
121
+ const SIDE_CHAT_OVERLAY_MARGIN_LEFT = 2;
122
+ const SIDE_CHAT_OVERLAY_MARGIN_RIGHT = 2;
123
+ /** Two quick presses within this window (same line) count as a double-click → select line. */
124
+ const DOUBLE_CLICK_INTERVAL_MS = 500;
125
+
126
+ /**
127
+ * True when a drag release ended within the double-click tolerance (same
128
+ * line, within a couple of cells). Real terminals report motion even for
129
+ * 1-cell hand shake during a double-click, so a selection this small is a
130
+ * click, not a drag — it must not suppress the next press's double-click
131
+ * classification. Only the same line counts: a real cross-line drag of a
132
+ * couple of cells stays a drag, never a click.
133
+ */
134
+ function selectionWithinClickTolerance(a: CellPos, b: CellPos): boolean {
135
+ return a.line === b.line && Math.abs(a.col - b.col) <= 2;
136
+ }
137
+ /** Wheel scroll step in lines (matches the previous mouse handler). */
138
+ const WHEEL_SCROLL_LINES = 3;
139
+ /**
140
+ * Drag-render coalescing: mouse motion events fire per cell moved, and every
141
+ * render redraws the whole frame (main screen + overlay). Capping drag
142
+ * renders to ~30fps keeps the highlight fluid without saturating the event
143
+ * loop on long drags. The selection state still updates on every event;
144
+ * only the paint is throttled, and the release always paints the final look.
145
+ */
146
+ const DRAG_RENDER_INTERVAL_MS = 32;
147
+ /** Feedback shown after a copy, cleared shortly after. */
148
+ const COPIED_STATUS_PREFIX = "✓ Copied ";
149
+ const COPIED_STATUS_CLEAR_MS = 1200;
150
+ /** Degradation hint when every clipboard read channel fails (C1 unavailable). */
151
+ const PASTE_FAILED_STATUS = "Clipboard read failed";
152
+ /** Hint when the clipboard is readable but holds no text. */
153
+ const PASTE_EMPTY_STATUS = "Clipboard is empty";
154
+ const PASTE_STATUS_CLEAR_MS = 1200;
155
+
156
+ /** Screen geometry of the overlay widgets (0-based terminal coordinates). */
157
+ interface ChatGeometry {
158
+ /** Screen row of the first message line. */
159
+ msgTopRow: number;
160
+ /** Screen column of the first message cell (inside the left border). */
161
+ contentCol: number;
162
+ /** Message area width in cells. */
163
+ innerWidth: number;
164
+ /** Number of visible message lines. */
165
+ msgHeight: number;
166
+ /** Screen row of the input editor widget's top border. */
167
+ editorTopRow: number;
168
+ /** Height of the input editor widget in rows (border + content + border). */
169
+ editorHeight: number;
170
+ }
171
+
172
+ /**
173
+ * Chat area height (message lines): 2.5x the original (~0.35 * rows - 10),
174
+ * adapted to small terminals so the overlay never overflows the screen and
175
+ * always leaves a few rows of the main editor visible.
176
+ */
177
+ export function computeSideChatHeight(rows: number): number {
178
+ const original = Math.max(3, Math.floor(rows * 0.35) - 10);
179
+ const desired = Math.round(original * 2.5);
180
+ // 7 fixed rows (borders, header, editor, hints) around the message area.
181
+ const overlayCap = Math.max(9, Math.min(Math.floor(rows * 0.88), rows - 4));
182
+ return Math.max(3, Math.min(desired, overlayCap - 7));
183
+ }
184
+
185
+ /**
186
+ * Shared-prefix layout (#9, reverses decision #6): the main lane's system
187
+ * prompt stays in the system slot (verbatim, token-identical request head),
188
+ * and the fork snapshot is injected verbatim below it — main and btw share
189
+ * the gateway's cached prefix. The btw identity/instruction texts live in
190
+ * the prompt pack (framing block message + per-turn focus anchor).
191
+ */
192
+
193
+ // --- Lane enforcement (prototype for #8, texts from the prompt pack #13) ---
194
+ // Trigger points only: transformContext (reminder injection) / beforeToolCall
195
+ // (block reason) / afterToolCall (failed-note). UI copy stays in code.
196
+
197
+ const LANE_BLOCKED_STATUS = "🚧 lane blocked";
198
+ const PRE_ABORT_TEXT = "Turn stopped after repeated out-of-lane attempts.";
199
+
200
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
201
+
202
+ export class SideChatOverlay implements Component, Focusable {
203
+ private agent: Agent;
204
+ private messages: SideChatMessages;
205
+ private editor: Editor;
206
+ private isStreaming = false;
207
+ private streamingContent = "";
208
+ private toolMode: "full" | "read-only" = "read-only";
209
+ private _focused = true;
210
+ private disposed = false;
211
+ private forkLeafId: string | null;
212
+ private peekMainTool: AgentTool;
213
+ private spinnerInterval: NodeJS.Timeout | null = null;
214
+ private spinnerFrame = 0;
215
+ private lastRenderHeight = 0;
216
+ /** Geometry of the last render (screen coords), used for mouse hit-testing. */
217
+ private geometry: ChatGeometry | null = null;
218
+ /** Mouse drag state: set while a left-button selection drag is in progress. */
219
+ private mouseDragging = false;
220
+ /** Right-press landed in the chat area; the copy action fires on release there. */
221
+ private rightPressInChat = false;
222
+ /** Right-press landed in the input editor; the paste action fires on release there. */
223
+ private rightPressInEditor = false;
224
+ private mouseAnchor: CellPos = { line: 0, col: 0 };
225
+ private lastPressTime = 0;
226
+ private lastPressPos: CellPos | null = null;
227
+ private pendingDoubleClick = false;
228
+ /** The last release ended a drag; a quick follow-up click must not count as a double-click. */
229
+ private lastReleaseWasDrag = false;
230
+ /** Timestamp of the last render triggered by a drag motion event (coalescing). */
231
+ private lastDragRenderAt = 0;
232
+ /** Clears the current transient tool-status line (copy feedback / read-failed hint). */
233
+ private transientClearTimer: NodeJS.Timeout | null = null;
234
+ /** Leading messages injected from the main lane at fork time (context cite). */
235
+ private forkedMessageCount: number;
236
+ /** Tool names allowed in the read-only lane (builtins + allowlist + peek_main). */
237
+ private readOnlyToolNames = new Set<string>();
238
+ /** Out-of-lane attempts in the current turn (reset on each new user message). */
239
+ private laneViolations = 0;
240
+ /** Reminder queued for injection by transformContext before the next LLM call. */
241
+ private pendingReminder: string | null = null;
242
+ /** When true, the turn is aborted right after the escalated reminder is injected. */
243
+ private abortAfterInject = false;
244
+ /** Open Alt+M model picker modal, or null when closed (modal replaces the chat area). */
245
+ private modelPicker: SelectList | null = null;
246
+ /** Choices backing the open picker (index-aligned with its SelectItems). */
247
+ private modelPickerChoices: ModelChoice[] = [];
248
+ /**
249
+ * Per-turn retry cancellation (D9): Esc aborts this controller so the
250
+ * backoff wait stops and the last error surfaces as the final result. Null
251
+ * while no turn is in flight.
252
+ */
253
+ private retryAbortController: AbortController | null = null;
254
+ /** Countdown ticker for the retry status line (cleared when the wait ends). */
255
+ private retryCountdown: NodeJS.Timeout | null = null;
256
+
257
+ /**
258
+ * Chat area height (message lines): 2.5x the original (~0.35 * rows - 10),
259
+ * adapted to small terminals so the overlay never overflows the screen and
260
+ * always leaves a few rows of the main editor visible.
261
+ */
262
+ private computeChatHeight(): number {
263
+ return computeSideChatHeight(this.options.tui.terminal.rows);
264
+ }
265
+
266
+ /**
267
+ * Screen region occupied by the overlay (0-based rows), used to route mouse
268
+ * wheel events to the chat. Returns null when the overlay is gone.
269
+ */
270
+ getViewport(): { topRow: number; height: number } | null {
271
+ if (this.disposed) return null;
272
+ const rows = this.options.tui.terminal.rows;
273
+ const maxHeight = Math.max(
274
+ 1,
275
+ Math.min(
276
+ parsePercent(SIDE_CHAT_OVERLAY_MAX_HEIGHT, rows),
277
+ Math.max(1, rows - SIDE_CHAT_OVERLAY_MARGIN_TOP),
278
+ ),
279
+ );
280
+ return {
281
+ topRow: SIDE_CHAT_OVERLAY_MARGIN_TOP,
282
+ height: Math.min(this.lastRenderHeight, maxHeight),
283
+ };
284
+ }
285
+
286
+ /** Scroll the message area (positive = toward older content). Mouse wheel handler. */
287
+ scrollByLines(lines: number): boolean {
288
+ const changed = this.messages.scrollBy(lines);
289
+ if (changed) this.options.tui.requestRender();
290
+ return changed;
291
+ }
292
+
293
+ /** True while a left-button drag is captured (events stay consumed even off-overlay). */
294
+ isMouseDragging(): boolean {
295
+ return this.mouseDragging;
296
+ }
297
+
298
+ /**
299
+ * Abort an in-flight drag without waiting for the release (used when the
300
+ * overlay is hidden mid-drag and mouse reporting is turned off).
301
+ */
302
+ cancelMouseDrag(): void {
303
+ this.mouseDragging = false;
304
+ this.rightPressInChat = false;
305
+ this.rightPressInEditor = false;
306
+ this.pendingDoubleClick = false;
307
+ this.messages.clearSelection();
308
+ }
309
+
310
+ /**
311
+ * Handle an SGR mouse event located over the overlay. Screen coordinates
312
+ * are 1-based (as reported by the terminal); the chat area is hit-tested
313
+ * against the geometry of the last render.
314
+ */
315
+ handleMouseEvent(event: SgrMouseEvent): void {
316
+ // Modal model picker: pointer events are ignored until it closes.
317
+ if (this.modelPicker) return;
318
+ if (isWheelEvent(event)) {
319
+ this.scrollByLines(wheelDirection(event) * WHEEL_SCROLL_LINES);
320
+ return;
321
+ }
322
+ if (isLeftPress(event)) {
323
+ const pos = this.screenToChat(event.row - 1, event.col - 1);
324
+ if (!pos) return;
325
+ const now = Date.now();
326
+ const doubleClick =
327
+ this.lastPressPos !== null &&
328
+ !this.lastReleaseWasDrag &&
329
+ now - this.lastPressTime <= DOUBLE_CLICK_INTERVAL_MS &&
330
+ Math.abs(pos.line - this.lastPressPos.line) <= 1 &&
331
+ Math.abs(pos.col - this.lastPressPos.col) <= 2;
332
+ this.mouseDragging = true;
333
+ this.mouseAnchor = pos;
334
+ this.lastPressPos = pos;
335
+ this.lastPressTime = now;
336
+ this.pendingDoubleClick = doubleClick;
337
+ // Seed the selection with the anchor (empty range): the window-shift
338
+ // translation in render() then keeps the anchor aligned with the same
339
+ // content when status/stream lines are appended mid-drag.
340
+ this.messages.setSelection(pos, pos);
341
+ this.options.tui.requestRender();
342
+ return;
343
+ }
344
+ if (isLeftDrag(event)) {
345
+ if (!this.mouseDragging) return;
346
+ const pos = this.clampScreenToChat(event.row - 1, event.col - 1);
347
+ const anchor = this.messages.getSelectionAnchor() ?? this.mouseAnchor;
348
+ this.messages.setSelection(anchor, pos);
349
+ // Coalesce drag paints: the selection state is always current (the next
350
+ // render picks it up), only the number of full-frame redraws is capped.
351
+ const now = Date.now();
352
+ if (now - this.lastDragRenderAt >= DRAG_RENDER_INTERVAL_MS) {
353
+ this.lastDragRenderAt = now;
354
+ this.options.tui.requestRender();
355
+ }
356
+ return;
357
+ }
358
+ if (isLeftRelease(event)) {
359
+ if (!this.mouseDragging) return;
360
+ this.mouseDragging = false;
361
+ if (this.pendingDoubleClick) {
362
+ // Double-click: select the whole rendered line (no auto-copy; the
363
+ // hotkey copies it).
364
+ this.pendingDoubleClick = false;
365
+ this.lastReleaseWasDrag = false;
366
+ const pos = this.clampScreenToChat(event.row - 1, event.col - 1);
367
+ this.messages.setSelection(
368
+ { line: pos.line, col: 0 },
369
+ { line: pos.line, col: this.geometry?.innerWidth ?? 0 },
370
+ );
371
+ this.options.tui.requestRender();
372
+ return;
373
+ }
374
+ this.pendingDoubleClick = false;
375
+ const pos = this.clampScreenToChat(event.row - 1, event.col - 1);
376
+ if (this.messages.hasSelection()) {
377
+ // Drag-release: finalize the selection (the anchor may have been
378
+ // window-shifted by appended status/stream lines mid-drag). The
379
+ // selection stays highlighted so Ctrl+C copies it (hotkey-only copy).
380
+ const anchor = this.messages.getSelectionAnchor() ?? this.mouseAnchor;
381
+ this.messages.setSelection(anchor, pos);
382
+ // A selection that ends within the double-click tolerance is a click
383
+ // with hand shake, not a drag: real terminals report motion (button 32)
384
+ // even for 1-cell moves, so without this the tiniest movement while
385
+ // double-clicking marks the release as a drag and the second press is
386
+ // never classified as a double-click (bug: line-select never fires).
387
+ this.lastReleaseWasDrag = !selectionWithinClickTolerance(anchor, pos);
388
+ } else {
389
+ // Plain click without drag: no selection.
390
+ this.messages.clearSelection();
391
+ this.lastReleaseWasDrag = false;
392
+ }
393
+ this.options.tui.requestRender();
394
+ }
395
+ if (isRightPress(event)) {
396
+ // Track where the right press landed; the actual action fires on
397
+ // release, so a press-then-move-out-then-release does nothing. The
398
+ // feature switch (D11) disables both right-click branches entirely —
399
+ // an unrecorded press leaves the release branch a no-op.
400
+ if (this.options.features.rightClickCopyPaste) {
401
+ this.rightPressInChat =
402
+ this.screenToChat(event.row - 1, event.col - 1) !== null;
403
+ this.rightPressInEditor = this.isOverEditor(event.row - 1);
404
+ }
405
+ return;
406
+ }
407
+ if (isRightRelease(event)) {
408
+ // Right-click (release-triggered), one shared hit branch per D2: a
409
+ // press+release over the chat area with an active mouse selection
410
+ // copies it; a press+release over the input editor pastes the system
411
+ // clipboard. The release position decides — pressing in one area and
412
+ // releasing in the other does nothing, and a release elsewhere
413
+ // (header/border) is ignored.
414
+ const pressInChat = this.rightPressInChat;
415
+ const pressInEditor = this.rightPressInEditor;
416
+ this.rightPressInChat = false;
417
+ this.rightPressInEditor = false;
418
+ if (pressInChat) {
419
+ if (this.screenToChat(event.row - 1, event.col - 1) === null) return;
420
+ if (!this.messages.hasSelection()) return;
421
+ void this.copySelectionToClipboard();
422
+ return;
423
+ }
424
+ if (pressInEditor) {
425
+ if (!this.isOverEditor(event.row - 1)) return;
426
+ void this.pasteFromClipboard();
427
+ return;
428
+ }
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Copy the current mouse selection to the clipboard (native → wl-copy /
434
+ * xclip → OSC 52 cascade via {@link copyToClipboard}, matching the main
435
+ * app's tree selector) and show a transient status. Copying is hotkey-only:
436
+ * `Ctrl+C` / `Ctrl+Shift+C` with an active mouse selection. The selection
437
+ * stays highlighted so a second copy key press re-copies. Returns false when
438
+ * there is nothing to copy.
439
+ */
440
+ async copySelectionToClipboard(): Promise<boolean> {
441
+ const text = this.messages.getSelectedText();
442
+ if (!text) return false;
443
+ try {
444
+ await copyToClipboard(text);
445
+ } catch (error) {
446
+ this.messages.setErrorContent(
447
+ `Copy failed: ${error instanceof Error ? error.message : String(error)}`,
448
+ );
449
+ this.options.tui.requestRender();
450
+ return false;
451
+ }
452
+ const status = `${COPIED_STATUS_PREFIX}${Array.from(text).length} chars`;
453
+ this.showTransientStatus(status, COPIED_STATUS_CLEAR_MS);
454
+ return true;
455
+ }
456
+
457
+ /**
458
+ * Right-click paste (issue #7, D4): read plain text from the system
459
+ * clipboard via the injected platform-channel matrix (clipboard-read.ts,
460
+ * D3 — never throws), then route the text through the Editor's built-in
461
+ * paste entry (bracketed paste), so normalization (\r→\n, \t→4 spaces),
462
+ * large-paste collapse to a `[paste #N …]` marker and the atomic undo
463
+ * snapshot all come from the Editor itself — identical to a native
464
+ * terminal paste. When the read fails or the clipboard holds no text the
465
+ * editor is left untouched and a transient, reason-specific hint is shown.
466
+ */
467
+ private async pasteFromClipboard(): Promise<void> {
468
+ let outcome: ClipboardReadOutcome;
469
+ try {
470
+ outcome = await readClipboardTextFromSystem();
471
+ } catch {
472
+ outcome = { ok: false, reason: "unavailable" };
473
+ }
474
+ if (!outcome.ok) {
475
+ const status =
476
+ outcome.reason === "empty" ? PASTE_EMPTY_STATUS : PASTE_FAILED_STATUS;
477
+ this.showTransientStatus(status, PASTE_STATUS_CLEAR_MS);
478
+ return;
479
+ }
480
+ // Bracketed paste is the only paste entry the Editor exposes (handlePaste
481
+ // is private); the same sequences a native terminal paste produces.
482
+ this.editor.handleInput(`\x1b[200~${outcome.text}\x1b[201~`);
483
+ this.options.tui.requestRender();
484
+ }
485
+
486
+ /**
487
+ * Show a tool-status line that clears itself after `clearMs`. Shared by
488
+ * the copy feedback and the clipboard-read hints.
489
+ */
490
+ private showTransientStatus(status: string, clearMs: number): void {
491
+ this.messages.setToolStatus(status);
492
+ this.options.tui.requestRender();
493
+ if (this.transientClearTimer) clearTimeout(this.transientClearTimer);
494
+ this.transientClearTimer = setTimeout(() => {
495
+ this.messages.clearToolStatusIf(status);
496
+ this.options.tui.requestRender();
497
+ }, clearMs);
498
+ }
499
+
500
+ /** True when a screen row falls inside the input editor widget band. */
501
+ private isOverEditor(row: number): boolean {
502
+ const g = this.geometry;
503
+ if (!g) return false;
504
+ return row >= g.editorTopRow && row < g.editorTopRow + g.editorHeight;
505
+ }
506
+
507
+ /** Map 1-based screen coords to a chat cell position, or null off the chat area. */
508
+ private screenToChat(row: number, col: number): CellPos | null {
509
+ const g = this.geometry;
510
+ if (!g) return null;
511
+ const line = row - g.msgTopRow;
512
+ const c = col - g.contentCol;
513
+ if (line < 0 || line >= g.msgHeight || c < 0 || c >= g.innerWidth)
514
+ return null;
515
+ return { line, col: c };
516
+ }
517
+
518
+ /** Like {@link screenToChat} but clamps into the chat area (drag overshoot). */
519
+ private clampScreenToChat(row: number, col: number): CellPos {
520
+ const g = this.geometry;
521
+ if (!g) return { line: 0, col: 0 };
522
+ const line = Math.max(0, Math.min(row - g.msgTopRow, g.msgHeight - 1));
523
+ const c = Math.max(0, Math.min(col - g.contentCol, g.innerWidth - 1));
524
+ return { line, col: c };
525
+ }
526
+
527
+ get focused() {
528
+ return this._focused;
529
+ }
530
+ set focused(v: boolean) {
531
+ this._focused = v;
532
+ this.editor.focused = v;
533
+ }
534
+
535
+ constructor(private options: SideChatOverlayOptions) {
536
+ const {
537
+ tui,
538
+ theme,
539
+ forkContext,
540
+ modelRegistry,
541
+ sessionManager,
542
+ promptPack,
543
+ } = options;
544
+ // Fork surgery (#12): make the trailing tool exchange gateway-legal on a
545
+ // clone of the fork snapshot (synthesize missing results, drop orphans).
546
+ const forkedMessages = forkSurgery(structuredClone(forkContext.messages));
547
+
548
+ this.forkLeafId = sessionManager.getLeafId();
549
+ this.forkedMessageCount = forkedMessages.length;
550
+ this.peekMainTool = this.createPeekMainTool(sessionManager);
551
+ // Strip philosophy (#7): read-only lane = builtins + allowlisted extension
552
+ // tools + peek_main. Everything else is absent from the list → attempts
553
+ // surface as "Tool X not found" errors (the detection signal).
554
+ this.readOnlyToolNames = new Set(
555
+ this.buildReadOnlyTools().map((t) => t.name),
556
+ );
557
+
558
+ // Framing block (#9): between the cite and the user's first btw message.
559
+ // User-role fallback placement (#11) — the request path keeps only
560
+ // user/assistant/toolResult roles (convertToLlm + openai-completions
561
+ // buildRequest), so trailing-system placement is not reachable through
562
+ // the standard pipeline (ADR-0001 prototype implementation note). The
563
+ // message is marked so the render path never shows it as a chat bubble.
564
+ const framingMessage = markFramingMessage({
565
+ role: "user",
566
+ content: substituteTemplate(promptPack.framing, {
567
+ cwd: forkContext.cwd,
568
+ model: forkContext.model.id,
569
+ }),
570
+ timestamp: Date.now(),
571
+ });
572
+
573
+ this.agent = new Agent({
574
+ streamFn: streamSimple,
575
+ initialState: {
576
+ // Shared-prefix layout (#9): the MAIN persona stays in the system
577
+ // slot so the request head matches the main lane token-for-token.
578
+ systemPrompt: forkContext.systemPrompt,
579
+ model: forkContext.model,
580
+ thinkingLevel: forkContext.thinkingLevel,
581
+ tools: this.buildReadOnlyTools(),
582
+ messages: [...forkedMessages, framingMessage],
583
+ },
584
+ convertToLlm,
585
+ getApiKey: async (provider) => {
586
+ const key = await modelRegistry.getApiKeyForProvider(provider);
587
+ if (!key) throw new Error("No API key available");
588
+ return key;
589
+ },
590
+ // Transient tail injections (present in the LLM request only, never
591
+ // stored in the transcript), texts from the prompt pack:
592
+ // - focus anchor: every turn, both modes (recency position);
593
+ // - lane preamble: read-only lane only (full mode stays untouched);
594
+ // - pending lane reminder: after an out-of-lane attempt; escalated
595
+ // violations abort the turn right after the reminder is queued.
596
+ transformContext: async (messages) => {
597
+ const additions: AgentMessage[] = [
598
+ {
599
+ role: "user",
600
+ content: promptPack.focusAnchor,
601
+ timestamp: Date.now(),
602
+ },
603
+ ];
604
+ if (this.toolMode === "read-only") {
605
+ additions.push({
606
+ role: "user",
607
+ content: promptPack.laneReminders.preamble,
608
+ timestamp: Date.now(),
609
+ });
610
+ }
611
+ if (this.pendingReminder) {
612
+ const reminder = this.pendingReminder;
613
+ this.pendingReminder = null;
614
+ if (this.abortAfterInject) {
615
+ this.abortAfterInject = false;
616
+ this.messages.setErrorContent(PRE_ABORT_TEXT);
617
+ setTimeout(() => this.agent.abort(), 0);
618
+ }
619
+ additions.push({
620
+ role: "user",
621
+ content: reminder,
622
+ timestamp: Date.now(),
623
+ });
624
+ }
625
+ return [...messages, ...additions];
626
+ },
627
+ // Belt-and-braces: block any residual present-but-disallowed tool with
628
+ // the base reminder as the reason (blocked calls never reach afterToolCall).
629
+ beforeToolCall: async (ctx) => {
630
+ if (this.toolMode !== "read-only") return undefined;
631
+ if (this.readOnlyToolNames.has(ctx.toolCall.name)) return undefined;
632
+ return {
633
+ block: true,
634
+ reason: substituteTemplate(promptPack.laneReminders.base, {
635
+ tool: ctx.toolCall.name,
636
+ }),
637
+ };
638
+ },
639
+ // Layer 2: re-ground executed-but-failed read-only calls (never fires
640
+ // for blocked/absent tools). Not a violation — no escalation count.
641
+ afterToolCall: async (ctx) => {
642
+ if (this.toolMode !== "read-only" || !ctx.isError) return undefined;
643
+ const content = [...ctx.result.content];
644
+ if (!content.some((c) => c.type === "text" && c.text.includes("🚧"))) {
645
+ content.push({
646
+ type: "text",
647
+ text: promptPack.laneReminders.failedNote,
648
+ });
649
+ }
650
+ return { content };
651
+ },
652
+ });
653
+
654
+ this.agent.subscribe((e) => this.handleAgentEvent(e));
655
+ this.messages = new SideChatMessages(theme, 20);
656
+ // The whole forked batch (main-session context or reopened history) is
657
+ // injected at open time: render it as one collapsed cite line, not as
658
+ // full history. New messages appended after the fork render normally.
659
+ // The framing block message is marked and skipped by the render path.
660
+ this.messages.setInjectedMessageCount(forkedMessages.length);
661
+ this.messages.setMessages(forkedMessages);
662
+ this.editor = new Editor(
663
+ tui,
664
+ {
665
+ borderColor: (t) => theme.fg("borderMuted", t),
666
+ selectList: getSelectListTheme(),
667
+ },
668
+ { paddingX: 0 },
669
+ );
670
+ this.editor.onSubmit = (text) => this.handleSubmit(text);
671
+ }
672
+
673
+ private createPeekMainTool(sessionManager: SessionView): AgentTool {
674
+ return {
675
+ name: "peek_main",
676
+ label: "peek_main",
677
+ description:
678
+ "View main agent's recent activity. Use when user asks about main's progress or status.",
679
+ parameters: Type.Object({
680
+ lines: Type.Optional(
681
+ Type.Integer({
682
+ description: "Max items (default: 20)",
683
+ minimum: 1,
684
+ maximum: 50,
685
+ }),
686
+ ),
687
+ since_fork: Type.Optional(
688
+ Type.Boolean({
689
+ description: "Only show activity after side chat opened",
690
+ }),
691
+ ),
692
+ }),
693
+ execute: async (_id, params) => {
694
+ const args = (params ?? {}) as { lines?: number; since_fork?: boolean };
695
+ const entries = sessionManager.getEntries();
696
+ const context = buildSessionContext(
697
+ entries,
698
+ sessionManager.getLeafId(),
699
+ );
700
+ let msgs = context.messages;
701
+
702
+ if (args.since_fork && this.forkLeafId) {
703
+ const forkCtx = buildSessionContext(entries, this.forkLeafId);
704
+ msgs = msgs.slice(forkCtx.messages.length);
705
+ }
706
+
707
+ const recent = msgs.slice(-(args.lines ?? 20));
708
+ if (!recent.length) {
709
+ return {
710
+ content: [
711
+ {
712
+ type: "text",
713
+ text: args.since_fork
714
+ ? "No new activity since fork."
715
+ : "No recent activity.",
716
+ },
717
+ ],
718
+ details: undefined,
719
+ };
720
+ }
721
+
722
+ const formatted = recent
723
+ .map((m) => this.formatMessage(m))
724
+ .filter(Boolean)
725
+ .join("\n\n");
726
+ return {
727
+ content: [
728
+ {
729
+ type: "text",
730
+ text: `Main agent activity (${recent.length} items):\n\n${formatted}`,
731
+ },
732
+ ],
733
+ details: undefined,
734
+ };
735
+ },
736
+ };
737
+ }
738
+
739
+ /**
740
+ * Read-only lane tool list (strip philosophy, #7): builtin read tools +
741
+ * allowlisted extension tools (config.json `readOnlyExtensionAllowlist`,
742
+ * git-untracked) + peek_main. Everything else is stripped from the list.
743
+ */
744
+ private buildReadOnlyTools(): AgentTool[] {
745
+ const { forkContext } = this.options;
746
+ const allowlisted = forkContext.extensionTools.filter((t) =>
747
+ this.options.readOnlyExtensionAllowlist.includes(t.name),
748
+ );
749
+ return [
750
+ ...createReadOnlyTools(forkContext.cwd),
751
+ ...allowlisted,
752
+ this.peekMainTool,
753
+ ];
754
+ }
755
+
756
+ /**
757
+ * 1st violation → base reminder; 2nd → escalated wording + turn abort.
758
+ * Texts come from the prompt pack (#13); the reminder is injected by
759
+ * transformContext before the next LLM call.
760
+ */
761
+ private registerLaneViolation(toolName: string) {
762
+ this.laneViolations += 1;
763
+ // Stop the spinner first: the lane-blocked status would otherwise be
764
+ // overwritten by the 80ms spinner tick.
765
+ this.stopSpinner();
766
+ if (this.laneViolations === 1) {
767
+ this.pendingReminder = substituteTemplate(
768
+ this.options.promptPack.laneReminders.base,
769
+ { tool: toolName },
770
+ );
771
+ this.messages.setToolStatus(LANE_BLOCKED_STATUS);
772
+ } else {
773
+ this.pendingReminder = substituteTemplate(
774
+ this.options.promptPack.laneReminders.escalated,
775
+ {
776
+ tool: toolName,
777
+ count: this.laneViolations,
778
+ },
779
+ );
780
+ this.abortAfterInject = true;
781
+ this.messages.setToolStatus(`${LANE_BLOCKED_STATUS} — escalating`);
782
+ }
783
+ this.options.tui.requestRender();
784
+ }
785
+
786
+ private formatMessage(msg: AgentMessage): string {
787
+ if (msg.role === "user") {
788
+ const c =
789
+ typeof msg.content === "string"
790
+ ? msg.content
791
+ : msg.content
792
+ .map((b) => (b.type === "text" ? b.text : "[image]"))
793
+ .join("");
794
+ return `[User]: ${c.slice(0, 300)}${c.length > 300 ? "..." : ""}`;
795
+ }
796
+ if (msg.role === "assistant") {
797
+ const fullText = msg.content
798
+ .filter((b) => b.type === "text")
799
+ .map((b) => b.text)
800
+ .join("\n");
801
+ const text = fullText.slice(0, 500);
802
+ const tools = msg.content
803
+ .filter((b) => b.type === "toolCall")
804
+ .map((t) => t.name);
805
+ const parts = [
806
+ text && text + (fullText.length > 500 ? "..." : ""),
807
+ tools.length && `[Calling: ${tools.join(", ")}]`,
808
+ ].filter(Boolean);
809
+ return parts.length ? `[Assistant]: ${parts.join("\n")}` : "";
810
+ }
811
+ if (msg.role === "toolResult") {
812
+ const fullText =
813
+ msg.content[0]?.type === "text" ? msg.content[0].text : "";
814
+ const preview = fullText.slice(0, 150);
815
+ return `[${msg.toolName}]: ${preview}${fullText.length > 150 ? "..." : ""}`;
816
+ }
817
+ return "";
818
+ }
819
+
820
+ private startSpinner() {
821
+ this.stopSpinner();
822
+ this.spinnerFrame = 0;
823
+ this.messages.setToolStatus(`${SPINNER[0]} Working...`);
824
+ this.options.tui.requestRender();
825
+ this.spinnerInterval = setInterval(() => {
826
+ this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER.length;
827
+ this.messages.setToolStatus(`${SPINNER[this.spinnerFrame]} Working...`);
828
+ this.options.tui.requestRender();
829
+ }, 80);
830
+ }
831
+
832
+ private stopSpinner() {
833
+ if (!this.spinnerInterval) return;
834
+ clearInterval(this.spinnerInterval);
835
+ this.spinnerInterval = null;
836
+ this.messages.setToolStatus("");
837
+ }
838
+
839
+ /**
840
+ * Last assistant message in the fork transcript (pi's _findLastAssistantMessage
841
+ * semantics: includes aborted/error ones). The retry loop classifies this
842
+ * result after each attempt.
843
+ */
844
+ private lastAssistantMessage(): RetryableFailure | undefined {
845
+ const messages = this.agent.state.messages;
846
+ for (let i = messages.length - 1; i >= 0; i--) {
847
+ const msg = messages[i];
848
+ if (msg.role === "assistant") return msg as RetryableFailure;
849
+ }
850
+ return undefined;
851
+ }
852
+
853
+ /**
854
+ * pi's _prepareRetry cleanup, mirrored: before a retry the failed assistant
855
+ * message is stripped from the transcript so the error never re-enters the
856
+ * next request (and agent.continue() can run — it requires a trailing
857
+ * user/toolResult message). Only an error-stop trailing message is removed;
858
+ * a successful prior turn's assistant message is left untouched.
859
+ */
860
+ private removeTrailingAssistantError(): void {
861
+ const messages = this.agent.state.messages;
862
+ const last = messages[messages.length - 1];
863
+ if (last?.role === "assistant" && last.stopReason === "error") {
864
+ this.agent.state.messages = messages.slice(0, -1);
865
+ }
866
+ }
867
+
868
+ private stopRetryCountdown(): void {
869
+ if (this.retryCountdown) {
870
+ clearInterval(this.retryCountdown);
871
+ this.retryCountdown = null;
872
+ }
873
+ }
874
+
875
+ /**
876
+ * Retry status line with a live countdown (D10), mirroring pi's
877
+ * RetryStatusIndicator wording: `Retrying (1/3) in 2s… (Esc to cancel)`.
878
+ * The spinner is stopped first so its 80ms tick cannot overwrite the status.
879
+ */
880
+ private showRetryStatus(info: RetryAttemptInfo): void {
881
+ this.stopSpinner();
882
+ const startedAt = Date.now();
883
+ const renderStatus = () => {
884
+ const remaining = Math.max(0, info.delayMs - (Date.now() - startedAt));
885
+ const seconds = Math.ceil(remaining / 1000);
886
+ this.messages.setToolStatus(
887
+ `Retrying (${info.attempt}/${info.maxAttempts}) in ${seconds}s… (Esc to cancel)`,
888
+ );
889
+ this.options.tui.requestRender();
890
+ };
891
+ renderStatus();
892
+ this.retryCountdown = setInterval(renderStatus, 250);
893
+ }
894
+
895
+ /**
896
+ * Re-substitute the framing block with the fork's CURRENT model (Alt+M may
897
+ * have switched `agent.state.model`, D5). The framing text is built once at
898
+ * open time with the main session's model; the message lives in the
899
+ * transcript (marked, never rendered as a bubble), so refreshing its content
900
+ * keeps the LLM's self-reported model honest without touching the request
901
+ * structure.
902
+ */
903
+ private refreshFramingModel(): void {
904
+ const modelId = this.agent.state.model?.id;
905
+ if (!modelId) return;
906
+ for (const message of this.agent.state.messages) {
907
+ if (isFramingMessage(message) && typeof message.content === "string") {
908
+ message.content = substituteTemplate(this.options.promptPack.framing, {
909
+ cwd: this.options.forkContext.cwd,
910
+ model: modelId,
911
+ });
912
+ return;
913
+ }
914
+ }
915
+ }
916
+ private async handleSubmit(text: string) {
917
+ const trimmed = text.trim();
918
+ if (!trimmed || this.isStreaming || this.disposed) return;
919
+
920
+ // New user message: reset the per-turn lane counter.
921
+ this.laneViolations = 0;
922
+ this.pendingReminder = null;
923
+ this.abortAfterInject = false;
924
+
925
+ // Keep the framing block's `Model:` line in sync with the fork's current
926
+ // model (Alt+M, D5): the text is substituted once at open time with the
927
+ // main session's model, so without this refresh the agent self-reports the
928
+ // old model after a switch (bug #3).
929
+ this.refreshFramingModel();
930
+
931
+ this.editor.setText("");
932
+ this.isStreaming = true;
933
+ this.streamingContent = "";
934
+ // A new user message resumes bottom-following even if the view was frozen.
935
+ this.messages.resumeFollowing();
936
+ this.messages.setStreamingContent("");
937
+ this.messages.setErrorContent("");
938
+ this.startSpinner();
939
+ // Per-turn retry cancellation (D9): Esc aborts this controller so the
940
+ // backoff wait stops and the last error surfaces as the final result.
941
+ this.retryAbortController = new AbortController();
942
+ const signal = this.retryAbortController.signal;
943
+
944
+ try {
945
+ // D9: wrap the turn in the injectable retry loop (issue #8). The first
946
+ // attempt submits the user text; a retryable failure is then stripped
947
+ // from the transcript (pi _prepareRetry semantics) and the agent
948
+ // continues from the same context — never re-submitting the user
949
+ // message and never feeding the failed message back into the request.
950
+ let firstAttempt = true;
951
+ const attempt = async (): Promise<RetryableFailure | undefined> => {
952
+ this.stopRetryCountdown();
953
+ if (!firstAttempt) {
954
+ this.removeTrailingAssistantError();
955
+ // A mid-stream failure may have streamed partial text: drop it so
956
+ // the retry starts clean (the error message itself is re-shown by
957
+ // the render path while we wait).
958
+ this.streamingContent = "";
959
+ this.messages.setStreamingContent("");
960
+ this.startSpinner();
961
+ }
962
+ if (firstAttempt) {
963
+ firstAttempt = false;
964
+ await this.agent.prompt(trimmed);
965
+ } else {
966
+ await this.agent.continue();
967
+ }
968
+ return this.lastAssistantMessage();
969
+ };
970
+ await runWithRetry({
971
+ attempt,
972
+ signal,
973
+ classify: (result) =>
974
+ classifyRetryable(
975
+ result as RetryableInput,
976
+ this.agent.state.model?.contextWindow ?? 0,
977
+ ),
978
+ onAttempt: (info) => this.showRetryStatus(info),
979
+ // D11: the extension feature switch ANDs with pi's own
980
+ // `settings.retry.enabled` — either one off means a single attempt
981
+ // with zero backoff (runWithRetry's enabled=false path).
982
+ policy: {
983
+ ...this.options.retryPolicy,
984
+ enabled:
985
+ this.options.features.retry && this.options.retryPolicy.enabled,
986
+ },
987
+ });
988
+ } catch (e) {
989
+ this.streamingContent = "";
990
+ if (!this.disposed) {
991
+ this.messages.setErrorContent(
992
+ e instanceof Error ? e.message : "Unknown error",
993
+ );
994
+ }
995
+ } finally {
996
+ this.stopRetryCountdown();
997
+ this.retryAbortController = null;
998
+ this.isStreaming = false;
999
+ this.streamingContent = "";
1000
+ this.stopSpinner();
1001
+ this.messages.setStreamingContent("");
1002
+ this.messages.setToolStatus("");
1003
+ this.messages.setMessages([...this.agent.state.messages]);
1004
+ if (!this.disposed) this.options.tui.requestRender();
1005
+ }
1006
+ }
1007
+
1008
+ private handleAgentEvent(event: AgentEvent) {
1009
+ if (this.disposed) return;
1010
+
1011
+ if (
1012
+ event.type === "message_update" &&
1013
+ event.assistantMessageEvent?.type === "text_delta"
1014
+ ) {
1015
+ this.stopSpinner();
1016
+ this.streamingContent += event.assistantMessageEvent.delta;
1017
+ this.messages.setStreamingContent(this.streamingContent);
1018
+ } else if (event.type === "message_end") {
1019
+ this.messages.setMessages([...this.agent.state.messages]);
1020
+ this.messages.setStreamingContent("");
1021
+ this.streamingContent = "";
1022
+ } else if (event.type === "tool_execution_start") {
1023
+ this.stopSpinner();
1024
+ this.messages.setToolStatus(`Running ${event.toolName}...`);
1025
+ } else if (event.type === "tool_execution_end") {
1026
+ this.startSpinner();
1027
+ // Detection signal: an error result for a tool that is not in the
1028
+ // read-only lane (absent tools produce "Tool X not found" errors).
1029
+ if (
1030
+ this.toolMode === "read-only" &&
1031
+ event.isError &&
1032
+ !this.readOnlyToolNames.has(event.toolName)
1033
+ ) {
1034
+ this.registerLaneViolation(event.toolName);
1035
+ }
1036
+ }
1037
+
1038
+ this.options.tui.requestRender();
1039
+ }
1040
+
1041
+ render(width: number): string[] {
1042
+ if (width < 4) {
1043
+ return [" ".repeat(Math.max(0, width))];
1044
+ }
1045
+
1046
+ const { theme, tracker } = this.options;
1047
+ const innerWidth = width - 4;
1048
+ const borderColor: ThemeColor = "border";
1049
+
1050
+ const title = "Side Chat";
1051
+ const mainLabel = tracker.writeCount
1052
+ ? `${tracker.writeCount} file${tracker.writeCount > 1 ? "s" : ""}`
1053
+ : "idle";
1054
+ const modeLabel = this.toolMode === "full" ? "Edit" : "Read-only";
1055
+ const modeColor: ThemeColor = this.toolMode === "full" ? "warning" : "dim";
1056
+ const scrollMark = this.messages.isAtBottom()
1057
+ ? ""
1058
+ : theme.fg("warning", ` [↑${this.messages.getScrollOffset()}]`);
1059
+ // Header status shows the fork's current model (D10), mirroring the main
1060
+ // footer format: thinking level shown only when the model supports it.
1061
+ const model = this.agent.state.model;
1062
+ const modelStatus = model
1063
+ ? model.reasoning
1064
+ ? this.agent.state.thinkingLevel === "off"
1065
+ ? `[Model: ${model.id} • thinking off]`
1066
+ : `[Model: ${model.id} • ${this.agent.state.thinkingLevel}]`
1067
+ : `[Model: ${model.id}]`
1068
+ : "[Model: ?]";
1069
+ const status =
1070
+ theme.fg("dim", `[Main: ${mainLabel}] `) +
1071
+ theme.fg("dim", modelStatus + " ") +
1072
+ theme.fg(modeColor, `[${modeLabel}]`) +
1073
+ scrollMark;
1074
+ const stream = this.isStreaming ? theme.fg("warning", " ●") : "";
1075
+ const left = theme.fg("accent", title) + stream;
1076
+
1077
+ const escHint = this.isStreaming ? "Esc stop" : "Esc close";
1078
+ const modeHint =
1079
+ this.toolMode === "read-only" ? "C+t Edit" : "C+t Readonly";
1080
+ const scrolled = !this.messages.isAtBottom();
1081
+ const scrollHint = scrolled
1082
+ ? theme.fg(
1083
+ "warning",
1084
+ `↑${this.messages.getScrollOffset()} · PgDn/Wheel ↓`,
1085
+ )
1086
+ : "Pg/Scr ↑↓";
1087
+ // Fixed two-row key-hint bar: the rows never collapse onto one line on
1088
+ // wide terminals, so the layout (and the message-area height) is stable
1089
+ // everywhere. Rows longer than the frame are truncated with "…" by
1090
+ // renderSideChatFrame; one message row is traded for the second hint row.
1091
+ const hintLines = this.modelPicker
1092
+ ? buildSideChatModelPickerHints()
1093
+ : buildSideChatHintLines({ scrollHint, escHint, modeHint, features: this.options.features });
1094
+ const maxLines = Math.max(
1095
+ 3,
1096
+ this.computeChatHeight() - (hintLines.length - 1),
1097
+ );
1098
+ this.messages.setMaxVisibleLines(maxLines);
1099
+ const msgLines = this.modelPicker
1100
+ ? this.renderModelPicker(innerWidth, maxLines)
1101
+ : this.messages.render(innerWidth);
1102
+ for (let i = msgLines.length; i < maxLines; i++) msgLines.push("");
1103
+
1104
+ const editorLines = this.editor.render(innerWidth);
1105
+ const lines = renderSideChatFrame({
1106
+ width,
1107
+ theme,
1108
+ borderColor,
1109
+ headerLeft: left,
1110
+ headerRight: status,
1111
+ msgLines,
1112
+ editorLines,
1113
+ hints: hintLines,
1114
+ });
1115
+ this.lastRenderHeight = lines.length;
1116
+ this.geometry = computeChatGeometry(
1117
+ this.options.tui.terminal.columns,
1118
+ msgLines.length,
1119
+ editorLines.length,
1120
+ );
1121
+ return lines;
1122
+ }
1123
+
1124
+ handleInput(data: string): void {
1125
+ // Backgrounding (Alt+W) while the picker is open cancels the modal first.
1126
+ if (matchesKey(data, SIDE_CHAT_SHORTCUT)) {
1127
+ this.closeModelPicker();
1128
+ this.options.onBackground();
1129
+ return;
1130
+ }
1131
+ // Open modal picker: route everything to the list (↑/↓ move, Enter
1132
+ // confirms, Esc/Ctrl+C cancels) until it closes.
1133
+ if (this.modelPicker) {
1134
+ this.modelPicker.handleInput(data);
1135
+ this.options.tui.requestRender();
1136
+ return;
1137
+ }
1138
+ if (matchesKey(data, Key.escape)) {
1139
+ if (this.isStreaming) {
1140
+ // Esc during the retry backoff cancels the wait (D9) so the last
1141
+ // error surfaces immediately; during an active stream it aborts the
1142
+ // run as before. abort() on a waiting (non-running) agent is a no-op.
1143
+ this.retryAbortController?.abort();
1144
+ this.agent.abort();
1145
+ } else {
1146
+ this.dispose();
1147
+ }
1148
+ return;
1149
+ }
1150
+ if (matchesKey(data, Key.alt("r"))) {
1151
+ this.dispose("refork");
1152
+ return;
1153
+ }
1154
+ if (matchesKey(data, Key.alt("n"))) {
1155
+ this.dispose("clear");
1156
+ return;
1157
+ }
1158
+ if (matchesKey(data, Key.alt("e"))) {
1159
+ this.exportChatHistory();
1160
+ return;
1161
+ }
1162
+ if (matchesKey(data, Key.alt("m"))) {
1163
+ this.openModelPicker();
1164
+ return;
1165
+ }
1166
+ if (
1167
+ matchesKey(data, Key.ctrl("c")) ||
1168
+ matchesKey(data, Key.ctrlShift("c"))
1169
+ ) {
1170
+ // Hotkey copy: with an active mouse selection, Ctrl+C / Ctrl+Shift+C
1171
+ // copies it. Without one, fall through so Ctrl+C keeps the editor's
1172
+ // own semantics.
1173
+ if (this.messages.hasSelection()) {
1174
+ void this.copySelectionToClipboard();
1175
+ return;
1176
+ }
1177
+ }
1178
+ if (matchesKey(data, Key.ctrl("t"))) {
1179
+ this.toolMode = this.toolMode === "full" ? "read-only" : "full";
1180
+ // Read-only lane keeps the strip philosophy; edit mode stays untouched
1181
+ // (enforcement out of scope until the crash bug is understood, #4).
1182
+ const { forkContext, tracker, onOverlapWarning } = this.options;
1183
+ this.agent.state.tools =
1184
+ this.toolMode === "read-only"
1185
+ ? this.buildReadOnlyTools()
1186
+ : [
1187
+ ...wrapToolsWithOverlapDetection(
1188
+ createCodingTools(forkContext.cwd),
1189
+ tracker,
1190
+ forkContext.cwd,
1191
+ onOverlapWarning,
1192
+ ),
1193
+ ...forkContext.extensionTools,
1194
+ this.peekMainTool,
1195
+ ];
1196
+ this.options.tui.requestRender();
1197
+ return;
1198
+ }
1199
+ if (this.messages.handleInput(data)) {
1200
+ this.options.tui.requestRender();
1201
+ return;
1202
+ }
1203
+ this.editor.handleInput(data);
1204
+ this.options.tui.requestRender();
1205
+ }
1206
+
1207
+ /**
1208
+ * Alt+M: open the fork model picker as a modal inside the overlay (D7).
1209
+ * The list shows scoped + authenticated models, falling back to the
1210
+ * available catalogue (D6). Rejected while streaming: swapping the model
1211
+ * mid-turn would corrupt the in-flight request.
1212
+ */
1213
+ private openModelPicker(): void {
1214
+ if (this.modelPicker) return;
1215
+ // Feature switch (D11): Alt+M is inert when model switching is off.
1216
+ if (!this.options.features.modelSwitch) return;
1217
+ if (this.isStreaming) {
1218
+ this.messages.setToolStatus("Model switch unavailable while streaming");
1219
+ this.options.tui.requestRender();
1220
+ return;
1221
+ }
1222
+ const { modelRegistry, scopedModels } = this.options;
1223
+ const choices = buildModelChoices(
1224
+ scopedModels,
1225
+ modelRegistry.getAvailable?.() ?? [],
1226
+ (model) => modelRegistry.hasConfiguredAuth?.(model) ?? false,
1227
+ );
1228
+ if (choices.length === 0) {
1229
+ this.messages.setToolStatus("No authenticated models available");
1230
+ this.options.tui.requestRender();
1231
+ return;
1232
+ }
1233
+ const items: SelectItem[] = choices.map((choice) => ({
1234
+ value: modelKey(choice.model),
1235
+ label: choice.model.id,
1236
+ description:
1237
+ modelRegistry.getProviderDisplayName?.(choice.model.provider) ??
1238
+ choice.model.provider,
1239
+ }));
1240
+ this.modelPickerChoices = choices;
1241
+ const list = new SelectList(
1242
+ items,
1243
+ Math.min(items.length, 12),
1244
+ getSelectListTheme(),
1245
+ );
1246
+ // Preselect the current fork model when it is on the list.
1247
+ const currentIndex = choices.findIndex(
1248
+ (c) => modelKey(c.model) === modelKey(this.agent.state.model),
1249
+ );
1250
+ if (currentIndex >= 0) list.setSelectedIndex(currentIndex);
1251
+ list.onSelect = (item) => this.applyModelChoice(item);
1252
+ list.onCancel = () => this.closeModelPicker();
1253
+ this.modelPicker = list;
1254
+ this.options.tui.requestRender();
1255
+ }
1256
+
1257
+ /**
1258
+ * Confirm: swap the fork agent's runtime model (next turn uses it —
1259
+ * `agent.state.model` is re-read per turn, no rebuild needed, D5) and
1260
+ * re-clamp the thinking level for the new model's capabilities. Fork-local
1261
+ * only (ADR 0002): the main session's model is never touched.
1262
+ */
1263
+ private applyModelChoice(item: SelectItem): void {
1264
+ const choice = this.modelPickerChoices.find(
1265
+ (c) => modelKey(c.model) === item.value,
1266
+ );
1267
+ this.closeModelPicker();
1268
+ if (!choice) return;
1269
+ const model = choice.model;
1270
+ // Explicit scoped thinking level ("model:high") overrides; otherwise keep
1271
+ // the current level and clamp it — non-reasoning models clamp to "off"
1272
+ // (pi maps "off" to no reasoning request).
1273
+ const desired = choice.thinkingLevel ?? this.agent.state.thinkingLevel;
1274
+ this.agent.state.model = model;
1275
+ this.agent.state.thinkingLevel = clampThinkingLevelForModel(
1276
+ model,
1277
+ desired,
1278
+ );
1279
+ this.messages.setToolStatus(
1280
+ `✓ Model: ${model.id}${
1281
+ model.reasoning ? ` · ${this.agent.state.thinkingLevel}` : ""
1282
+ }`
1283
+ );
1284
+ this.options.tui.requestRender();
1285
+ }
1286
+
1287
+ private closeModelPicker(): void {
1288
+ this.modelPicker = null;
1289
+ this.modelPickerChoices = [];
1290
+ this.options.tui.requestRender();
1291
+ }
1292
+
1293
+ /**
1294
+ * Render the open picker inside the frame: a one-line title + the list
1295
+ * rows, padded/truncated to exactly `maxLines` so the frame geometry stays
1296
+ * stable (mouse hit-testing and the hint bar depend on it).
1297
+ */
1298
+ private renderModelPicker(width: number, maxLines: number): string[] {
1299
+ const list = this.modelPicker;
1300
+ if (!list) return [];
1301
+ const lines = [
1302
+ this.options.theme.fg("accent", "Select model (↑/↓ · Enter · Esc)"),
1303
+ ];
1304
+ lines.push(...list.render(width));
1305
+ while (lines.length < maxLines) lines.push("");
1306
+ return lines.slice(0, maxLines);
1307
+ }
1308
+
1309
+ /**
1310
+ * Alt+E: export the btw transcript to `$CWD/.agents/eval/pi-better-btw-<ts>.md`
1311
+ * as a markdown diagnostic artifact (feature/debug work). The snapshot is
1312
+ * taken from the agent state at the moment of the keypress.
1313
+ */
1314
+ private exportChatHistory() {
1315
+ try {
1316
+ const path = exportChatHistoryToFile({
1317
+ messages: [...this.agent.state.messages],
1318
+ streamingContent: this.streamingContent,
1319
+ cwd: this.options.forkContext.cwd,
1320
+ modelId: this.options.forkContext.model.id,
1321
+ toolMode: this.toolMode,
1322
+ forkedMessageCount: this.forkedMessageCount,
1323
+ streaming: this.isStreaming,
1324
+ });
1325
+ // Status line feedback inside the overlay + a toast in the main session.
1326
+ this.stopSpinner();
1327
+ this.messages.setToolStatus(`✓ exported → ${path}`);
1328
+ this.options.onExport(path);
1329
+ } catch (error) {
1330
+ this.messages.setErrorContent(
1331
+ `Export failed: ${error instanceof Error ? error.message : String(error)}`,
1332
+ );
1333
+ }
1334
+ this.options.tui.requestRender();
1335
+ }
1336
+
1337
+ dispose(action: "close" | "refork" | "clear" = "close") {
1338
+ if (this.disposed) return;
1339
+ this.disposed = true;
1340
+ this.stopSpinner();
1341
+ if (this.retryAbortController) {
1342
+ // A pending retry wait must not outlive the overlay: abort it so the
1343
+ // turn's handleSubmit settles and the countdown stops.
1344
+ this.retryAbortController.abort();
1345
+ this.stopRetryCountdown();
1346
+ }
1347
+ if (this.transientClearTimer) {
1348
+ clearTimeout(this.transientClearTimer);
1349
+ this.transientClearTimer = null;
1350
+ }
1351
+ const messages = [...this.agent.state.messages];
1352
+ this.agent.abort();
1353
+ this.options.onClose(action, messages);
1354
+ }
1355
+
1356
+ invalidate() {
1357
+ this.messages.invalidate();
1358
+ this.editor.invalidate();
1359
+ }
1360
+ }
1361
+
1362
+ function parsePercent(value: string, reference: number): number {
1363
+ const match = /^(\d+(?:\.\d+)?)%$/.exec(value);
1364
+ if (!match) return reference;
1365
+ return Math.floor((reference * parseFloat(match[1])) / 100);
1366
+ }
1367
+
1368
+ /**
1369
+ * Screen geometry of the chat message area, mirroring the overlay layout
1370
+ * pi-tui computes from the side chat's overlayOptions (width 85%, anchor
1371
+ * top-center, margin { top: 1, left: 2, right: 2 }); see resolveOverlayLayout.
1372
+ * The overlay top row is pinned to marginTop, the message area starts after
1373
+ * the top border, header and separator (3 lines), and content cells begin
1374
+ * after the left border + padding (2 cells).
1375
+ */
1376
+ function computeChatGeometry(
1377
+ termCols: number,
1378
+ msgHeight: number,
1379
+ editorHeight: number,
1380
+ ): ChatGeometry {
1381
+ const availWidth = Math.max(
1382
+ 1,
1383
+ termCols - SIDE_CHAT_OVERLAY_MARGIN_LEFT - SIDE_CHAT_OVERLAY_MARGIN_RIGHT,
1384
+ );
1385
+ const width = Math.max(
1386
+ 1,
1387
+ Math.min(parsePercent(SIDE_CHAT_OVERLAY_WIDTH, termCols), availWidth),
1388
+ );
1389
+ const leftCol =
1390
+ SIDE_CHAT_OVERLAY_MARGIN_LEFT + Math.floor((availWidth - width) / 2);
1391
+ const msgTopRow = SIDE_CHAT_OVERLAY_MARGIN_TOP + 3;
1392
+ return {
1393
+ msgTopRow,
1394
+ contentCol: leftCol + 2,
1395
+ innerWidth: width - 4,
1396
+ msgHeight,
1397
+ // Separator after the messages sits at msgTopRow + msgHeight; the
1398
+ // input editor widget band starts on the next row.
1399
+ editorTopRow: msgTopRow + msgHeight + 1,
1400
+ editorHeight,
1401
+ };
1402
+ }
1403
+
1404
+ /**
1405
+ * Pure side chat frame renderer: borders, header, messages, editor, hints.
1406
+ * Kept separate so previews/tests can render the exact same frame without a TUI.
1407
+ */
1408
+ export interface SideChatFrameOptions {
1409
+ width: number;
1410
+ theme: Theme;
1411
+ borderColor: ThemeColor;
1412
+ headerLeft: string;
1413
+ headerRight: string;
1414
+ msgLines: string[];
1415
+ editorLines: string[];
1416
+ hints: string[];
1417
+ }
1418
+
1419
+ export function renderSideChatFrame(opts: SideChatFrameOptions): string[] {
1420
+ const { theme, width, borderColor } = opts;
1421
+ const innerWidth = width - 4;
1422
+ const lines: string[] = [];
1423
+
1424
+ const headerLeftWidth = Math.max(
1425
+ 1,
1426
+ innerWidth - visibleWidth(opts.headerRight) - 1,
1427
+ );
1428
+ const headerLeft = truncateToWidth(opts.headerLeft, headerLeftWidth);
1429
+ const headerGap = " ".repeat(
1430
+ Math.max(
1431
+ 1,
1432
+ innerWidth - visibleWidth(headerLeft) - visibleWidth(opts.headerRight),
1433
+ ),
1434
+ );
1435
+
1436
+ lines.push(theme.fg(borderColor, "┌" + "─".repeat(width - 2) + "┐"));
1437
+ lines.push(
1438
+ frameLine(
1439
+ theme,
1440
+ borderColor,
1441
+ `${headerLeft}${headerGap}${opts.headerRight}`,
1442
+ innerWidth,
1443
+ ),
1444
+ );
1445
+ lines.push(theme.fg(borderColor, "├" + "─".repeat(width - 2) + "┤"));
1446
+ for (const line of opts.msgLines)
1447
+ lines.push(frameLine(theme, borderColor, line, innerWidth));
1448
+ lines.push(theme.fg(borderColor, "├" + "─".repeat(width - 2) + "┤"));
1449
+ for (const line of opts.editorLines)
1450
+ lines.push(frameLine(theme, borderColor, line, innerWidth));
1451
+ lines.push(theme.fg(borderColor, "├" + "─".repeat(width - 2) + "┤"));
1452
+ for (const line of opts.hints)
1453
+ lines.push(
1454
+ frameLine(theme, borderColor, theme.fg("dim", line), innerWidth),
1455
+ );
1456
+ lines.push(theme.fg(borderColor, "└" + "─".repeat(width - 2) + "┘"));
1457
+
1458
+ return lines.map((l) =>
1459
+ visibleWidth(l) > width ? truncateToWidth(l, width) : l,
1460
+ );
1461
+ }
1462
+
1463
+ function frameLine(
1464
+ theme: Theme,
1465
+ borderColor: ThemeColor,
1466
+ line: string,
1467
+ width: number,
1468
+ ): string {
1469
+ return (
1470
+ theme.fg(borderColor, "│ ") +
1471
+ truncateToWidth(line, width, "...", true) +
1472
+ theme.fg(borderColor, " │")
1473
+ );
1474
+ }
1475
+
1476
+ /** Alt-actions hint row base; the model entry is appended only when the switch is on. */
1477
+ const ALT_ACTIONS_BASE = `A+w bg · A+r fork · A+n new · A+e export`;
1478
+ const ALT_ACTION_HINTS = `${ALT_ACTIONS_BASE} · A+m model`;
1479
+ /**
1480
+ * Build the fixed two-row key-hint bar. Row 1: scrolling, copy, mode toggle,
1481
+ * Esc and send; row 2: the Alt-actions (Alt abbreviated as A, A+w = Alt+W).
1482
+ * Always two rows — the rows are truncated on narrow terminals rather than
1483
+ * collapsing to one line, keeping the message-area height stable.
1484
+ */
1485
+ export function buildSideChatHintLines(options: {
1486
+ scrollHint: string;
1487
+ escHint: string;
1488
+ modeHint: string;
1489
+ /** Feature switches (D11): a disabled behavior is not advertised in the hints. */
1490
+ features: SideChatFeatures;
1491
+ }): string[] {
1492
+ const { scrollHint, escHint, modeHint, features } = options;
1493
+ // Right-click semantics live next to the copy hint: chat-area right-click
1494
+ // copies a retained selection, editor right-click pastes (D10).
1495
+ const rightClickHint = features.rightClickCopyPaste
1496
+ ? " · R-click copy/paste"
1497
+ : "";
1498
+ const primary = `${scrollHint} · C+c copy${rightClickHint} · ${modeHint} · ${escHint} · Enter send`;
1499
+ const secondary = `${ALT_ACTIONS_BASE}${features.modelSwitch ? " · A+m model" : ""}`;
1500
+ return [primary, secondary];
1501
+ }
1502
+
1503
+ /**
1504
+ * Hint bar while the Alt+M model picker modal is open: row 1 switches to
1505
+ * the picker keys, row 2 keeps the Alt-actions (still two rows, so the
1506
+ * message-area height stays stable).
1507
+ */
1508
+ export function buildSideChatModelPickerHints(): string[] {
1509
+ return [
1510
+ "↑/↓ select · Enter confirm · Esc cancel",
1511
+ ALT_ACTION_HINTS,
1512
+ ];
1513
+ }