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.
- package/LICENSE +23 -0
- package/README.md +251 -0
- package/README.zh-CN.md +252 -0
- package/banner.png +0 -0
- package/config.json +23 -0
- package/package.json +69 -0
- package/prompts/btw-focus-anchor.md +1 -0
- package/prompts/btw-framing.md +8 -0
- package/prompts/lane-failed-note.md +1 -0
- package/prompts/lane-preamble.md +1 -0
- package/prompts/lane-reminder-base.md +1 -0
- package/prompts/lane-reminder-escalated.md +1 -0
- package/srcs/clipboard-read.ts +339 -0
- package/srcs/config.ts +341 -0
- package/srcs/file-activity-tracker.ts +21 -0
- package/srcs/fork-surgery.ts +106 -0
- package/srcs/index.ts +381 -0
- package/srcs/model-switch.ts +60 -0
- package/srcs/prompt-pack.ts +145 -0
- package/srcs/retry.ts +360 -0
- package/srcs/shortcuts.ts +4 -0
- package/srcs/side-chat-export.ts +302 -0
- package/srcs/side-chat-messages.ts +479 -0
- package/srcs/side-chat-mouse.ts +108 -0
- package/srcs/side-chat-overlay.ts +1513 -0
- package/srcs/tool-wrapper.ts +225 -0
|
@@ -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
|
+
}
|