pum-agent 0.1.0-beta.3

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/src/app.tsx ADDED
@@ -0,0 +1,1953 @@
1
+ import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core";
2
+ import { randomUUID } from "node:crypto";
3
+ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
4
+ import type { Model } from "@earendil-works/pi-ai";
5
+ import type { AgentSession, ModelRuntime, SessionInfo } from "@earendil-works/pi-coding-agent";
6
+ import { Fragment, useEffect, useMemo, useRef, useState } from "react";
7
+ import { AnimationProvider, supportsTrueColor, useWorkingRule, type WorkingRuleRole } from "./animation";
8
+ import {
9
+ filterModels,
10
+ filterSettingsRows,
11
+ isModelSearchShortcut,
12
+ isSettingsSearchShortcut,
13
+ moveSettingSelection,
14
+ SettingsPopup,
15
+ SETTINGS_ROWS,
16
+ THINKING_LEVELS,
17
+ type SettingRowId,
18
+ type ThinkingLevel,
19
+ } from "./settings-popup";
20
+ import {
21
+ saveSettings,
22
+ WORKING_RULE_ANIMATION_MODES,
23
+ type PumSettings,
24
+ type WorkingRuleAnimationMode,
25
+ } from "./settings";
26
+ import { StatusBar } from "./status-bar";
27
+ import {
28
+ AgentMessageLine,
29
+ needsTranscriptGap,
30
+ PendingMessageLine,
31
+ resolvePendingDelivery,
32
+ settleTranscriptMessage,
33
+ StreamLine,
34
+ TextLine,
35
+ ToolLine,
36
+ type Line,
37
+ type PendingLine,
38
+ type Role,
39
+ } from "./transcript";
40
+ import { editCounts, toolArg, type ToolCall } from "./tool-line";
41
+ import { readBranch, watchBranch } from "./git-branch";
42
+ import { HelpPopup, maxHelpScrollOffset } from "./help-popup";
43
+ import { appendHistory, loadHistory, removeHistory } from "./history";
44
+ import {
45
+ appendPromptStash,
46
+ loadPromptStash,
47
+ markPromptStashExecuted,
48
+ markPromptStashExecutedMany,
49
+ removePromptStash,
50
+ replacePromptStash,
51
+ type StashedPrompt,
52
+ } from "./prompt-stash";
53
+ import { replayEntries } from "./replay";
54
+ import { loadTheme, PRESET_NAMES, type Theme } from "./theme";
55
+ import { buildSyntaxStyle } from "./syntax";
56
+ import {
57
+ observeSearchCalls,
58
+ persistSearchCall,
59
+ webSearch,
60
+ withSearchRoute,
61
+ } from "./web-search";
62
+ import { matchingCommands, moveCommandSelection } from "./commands";
63
+ import { isRejectedToolResult } from "./check-mode";
64
+ import { SessionHistoryPopup } from "./session-history-popup";
65
+ import { setWritingStyle, WRITING_STYLES } from "./writing-style";
66
+ import {
67
+ EXPLANATION_STRENGTHS,
68
+ setExplanationStrength,
69
+ } from "./explanation-strength";
70
+ import { setCheckModeConfig } from "./check-mode";
71
+ import {
72
+ captureClipboardImage,
73
+ cleanupPendingImages,
74
+ imageContent,
75
+ removePendingImage,
76
+ type PendingImage,
77
+ } from "./image-paste";
78
+ import type { SubagentManager } from "./subagents/manager";
79
+ import { runWorktreeCommand } from "./worktree-command";
80
+ import { CANCEL_WINDOW_MS, confirmsCancellation } from "./cancel-confirmation";
81
+ import { buildStashBatchPrompt, selectedRange } from "./stash-batch";
82
+ import { addTurnUsage, usageFromEntries } from "./agent-usage";
83
+ import { AgentSelectorPopup, buildAgentTree, moveAgentSelection } from "./agent-selector";
84
+ import { LoginPopup, type LoginPage } from "./login-popup";
85
+ import { LoginController } from "./login-controller";
86
+ import { providerLoginMethods, refreshAndSelectModel } from "./login-flow";
87
+
88
+ type Stream = { kind: "assistant" | "thinking"; text: string } | null;
89
+ type Transcript = { lines: Line[]; stream: Stream; pending: PendingLine[] };
90
+
91
+ const QUIT_WINDOW_MS = 2000;
92
+ const MAX_INPUT_ROWS = 8;
93
+ /** Keys that move around without changing the text. */
94
+ const NAV_KEYS = new Set(["up", "down", "left", "right", "home", "end", "pageup", "pagedown"]);
95
+
96
+ export function promptPlaceholder(options: {
97
+ activeAgentName?: string;
98
+ busy: boolean;
99
+ stashOpen: boolean;
100
+ }): string {
101
+ if (options.activeAgentName) {
102
+ return options.busy ? `Steer ${options.activeAgentName}…` : `Message ${options.activeAgentName}…`;
103
+ }
104
+ if (options.stashOpen) return "Cache…";
105
+ return options.busy ? "Steer…" : "Ask something…";
106
+ }
107
+
108
+ /** A blank row. An empty <text> measures to nothing, so this needs a height. */
109
+ const Gap = () => <box style={{ height: 1, flexShrink: 0 }} />;
110
+
111
+ function WorkingRule({
112
+ theme,
113
+ width,
114
+ busy,
115
+ dimmed = false,
116
+ mode,
117
+ role,
118
+ }: {
119
+ theme: Theme;
120
+ width: number;
121
+ busy: boolean;
122
+ dimmed?: boolean;
123
+ mode: WorkingRuleAnimationMode;
124
+ role: WorkingRuleRole;
125
+ }) {
126
+ const ref = useWorkingRule({
127
+ width,
128
+ color: dimmed ? theme.dim : theme.border,
129
+ highlight: theme.highlight,
130
+ active: busy,
131
+ mode,
132
+ role,
133
+ });
134
+ return <text ref={ref} style={{ flexShrink: 0 }} />;
135
+ }
136
+
137
+ export function PromptStashRow({
138
+ theme,
139
+ prompt,
140
+ index,
141
+ selected,
142
+ }: {
143
+ theme: Theme;
144
+ prompt: StashedPrompt;
145
+ index: number;
146
+ selected: boolean;
147
+ }) {
148
+ const color = prompt.executed ? theme.dim : theme.fg;
149
+
150
+ return (
151
+ <box
152
+ id={`stash-prompt-${index}`}
153
+ style={{
154
+ flexDirection: "row",
155
+ width: "100%",
156
+ flexShrink: 0,
157
+ backgroundColor: selected ? theme.selectionBg : "transparent",
158
+ }}
159
+ >
160
+ <box style={{ width: 2, flexShrink: 0 }}>
161
+ <text content={prompt.executed ? "✓ " : "○ "} fg={prompt.executed ? theme.success : theme.dim} />
162
+ </box>
163
+ <text
164
+ content={prompt.text}
165
+ fg={color}
166
+ wrapMode="word"
167
+ style={{ flexGrow: 1, minWidth: 0 }}
168
+ />
169
+ </box>
170
+ );
171
+ }
172
+
173
+ export function PromptStash({
174
+ theme,
175
+ prompts,
176
+ cursor,
177
+ selectedIndices,
178
+ height,
179
+ }: {
180
+ theme: Theme;
181
+ prompts: StashedPrompt[];
182
+ cursor: number;
183
+ selectedIndices: ReadonlySet<number>;
184
+ height: number;
185
+ }) {
186
+ const scrollRef = useRef<ScrollBoxRenderable>(null);
187
+
188
+ useEffect(() => {
189
+ // Reconcile after OpenTUI has measured appended or wrapped rows.
190
+ const timer = setTimeout(() => {
191
+ if (!scrollRef.current) return;
192
+ const target = cursor < 0 ? prompts.length - 1 : cursor;
193
+ if (target >= 0) scrollRef.current.scrollChildIntoView(`stash-prompt-${target}`);
194
+ }, 0);
195
+ return () => clearTimeout(timer);
196
+ }, [cursor, prompts.length]);
197
+
198
+ return (
199
+ <scrollbox
200
+ ref={scrollRef}
201
+ style={{
202
+ height: Math.min(15, Math.max(1, height - 7)),
203
+ flexShrink: 0,
204
+ }}
205
+ verticalScrollbarOptions={{ visible: true }}
206
+ stickyScroll
207
+ stickyStart="bottom"
208
+ >
209
+ <box style={{ flexDirection: "column", width: "100%", flexShrink: 0 }}>
210
+ {prompts.map((prompt, i) => (
211
+ <PromptStashRow
212
+ key={`${i}:${prompt.text}`}
213
+ theme={theme}
214
+ prompt={prompt}
215
+ index={i}
216
+ selected={selectedIndices.size > 0 ? selectedIndices.has(i) : i === cursor}
217
+ />
218
+ ))}
219
+ </box>
220
+ </scrollbox>
221
+ );
222
+ }
223
+
224
+ function sessionUsage(session: AgentSession) {
225
+ const manager = session.sessionManager as any;
226
+ const entries = typeof manager.getEntries === "function"
227
+ ? manager.getEntries()
228
+ : manager.buildContextEntries();
229
+ return usageFromEntries(entries, session.agent.state.model.contextWindow);
230
+ }
231
+
232
+ function messageText(message: any): string {
233
+ if (typeof message?.content === "string") return message.content.trim();
234
+ if (!Array.isArray(message?.content)) return "";
235
+ return message.content
236
+ .filter((block: any) => block?.type === "text" && typeof block.text === "string")
237
+ .map((block: any) => block.text)
238
+ .join("")
239
+ .trim();
240
+ }
241
+
242
+ export type PromptHistoryStore = {
243
+ load: (cwd: string) => string[];
244
+ append: (cwd: string, prompt: string) => string[];
245
+ remove: (cwd: string, prompt: string) => string[];
246
+ };
247
+
248
+ const DEFAULT_PROMPT_HISTORY_STORE: PromptHistoryStore = {
249
+ load: loadHistory,
250
+ append: appendHistory,
251
+ remove: removeHistory,
252
+ };
253
+
254
+ export type PromptStashStore = {
255
+ load: (cwd: string) => StashedPrompt[];
256
+ append: (cwd: string, prompt: string, executed?: boolean) => StashedPrompt[];
257
+ markExecuted: (cwd: string, index: number) => StashedPrompt[];
258
+ markExecutedMany: (cwd: string, indices: Iterable<number>) => StashedPrompt[];
259
+ replace: (cwd: string, index: number, prompt: string, executed: boolean) => StashedPrompt[];
260
+ remove: (cwd: string, index: number) => StashedPrompt[];
261
+ };
262
+
263
+ const DEFAULT_PROMPT_STASH_STORE: PromptStashStore = {
264
+ load: loadPromptStash,
265
+ append: appendPromptStash,
266
+ markExecuted: markPromptStashExecuted,
267
+ markExecutedMany: markPromptStashExecutedMany,
268
+ replace: replacePromptStash,
269
+ remove: removePromptStash,
270
+ };
271
+
272
+ /** Move any buffered stream into the transcript so later lines land in order. */
273
+ function flushed(t: Transcript): Transcript {
274
+ if (t.stream && t.stream.text.trim()) {
275
+ const line: Line = { kind: "text", role: t.stream.kind, text: t.stream.text.trim() };
276
+ return { lines: [...t.lines, line], stream: null, pending: t.pending };
277
+ }
278
+ return { ...t, stream: null };
279
+ }
280
+
281
+ export function App({
282
+ session: initialSession,
283
+ modelRuntime,
284
+ onNewSession,
285
+ loadSessions,
286
+ onSwitchSession,
287
+ settings: initial,
288
+ searchProviders,
289
+ subagentManager,
290
+ loginRequired = false,
291
+ promptHistoryStore = DEFAULT_PROMPT_HISTORY_STORE,
292
+ promptStashStore = DEFAULT_PROMPT_STASH_STORE,
293
+ onExit = () => process.exit(0),
294
+ }: {
295
+ session: AgentSession;
296
+ modelRuntime: ModelRuntime;
297
+ onNewSession: () => Promise<AgentSession>;
298
+ loadSessions: () => Promise<SessionInfo[]>;
299
+ onSwitchSession: (path: string) => Promise<AgentSession>;
300
+ settings: PumSettings;
301
+ /** Provider ids that carry the hosted web-search tool; empty means none. */
302
+ searchProviders: string[];
303
+ subagentManager: SubagentManager;
304
+ loginRequired?: boolean;
305
+ promptHistoryStore?: PromptHistoryStore;
306
+ promptStashStore?: PromptStashStore;
307
+ onExit?: () => void | Promise<void>;
308
+ }) {
309
+ const cwd = process.cwd();
310
+ const [session, setSession] = useState(initialSession);
311
+ const [tx, setTx] = useState<Transcript>(() => ({
312
+ // A resumed session already holds messages; show them instead of a blank pane.
313
+ lines: replayEntries(
314
+ initialSession.sessionManager.buildContextEntries(),
315
+ cwd,
316
+ initial.showThinking,
317
+ ),
318
+ stream: null,
319
+ pending: [],
320
+ }));
321
+ const [busy, setBusy] = useState(false);
322
+ const [quitArmed, setQuitArmed] = useState(false);
323
+ const [cancelArmed, setCancelArmed] = useState(false);
324
+ const [settingsOpen, setSettingsOpen] = useState(false);
325
+ const [helpOpen, setHelpOpen] = useState(false);
326
+ const [helpScrollOffset, setHelpScrollOffset] = useState(0);
327
+ const [historyOpen, setHistoryOpen] = useState(false);
328
+ const [historySessions, setHistorySessions] = useState<SessionInfo[]>([]);
329
+ const [page, setPage] = useState<"main" | "models" | "checkModels">("main");
330
+ const [settingsQuery, setSettingsQuery] = useState("");
331
+ const [settingsSearchFocused, setSettingsSearchFocused] = useState(true);
332
+ const [selectedSettingId, setSelectedSettingId] = useState<SettingRowId | null>(SETTINGS_ROWS[0]!.id);
333
+ const [settings, setSettings] = useState(initial);
334
+ const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel>(
335
+ session.agent.state.thinkingLevel as ThinkingLevel,
336
+ );
337
+ const [modelId, setModelId] = useState(session.agent.state.model.id);
338
+ const [branch, setBranch] = useState<string | null>(null);
339
+ const [usage, setUsage] = useState(() => sessionUsage(initialSession));
340
+ const [elapsedSec, setElapsedSec] = useState(0);
341
+ const [stash, setStash] = useState<StashedPrompt[]>(() => promptStashStore.load(cwd));
342
+ /** -1 means the input is selected; non-negative values select stash rows. */
343
+ const [stashCursor, setStashCursor] = useState(-1);
344
+ const [stashSelection, setStashSelection] = useState<Set<number>>(() => new Set());
345
+ const [stashOpen, setStashOpen] = useState(false);
346
+ const [commandInput, setCommandInput] = useState("");
347
+ const [commandCursor, setCommandCursor] = useState(0);
348
+ const [inputRows, setInputRows] = useState(1);
349
+ const [inputCursorRow, setInputCursorRow] = useState(0);
350
+ const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
351
+ const [agentSelectorOpen, setAgentSelectorOpen] = useState(false);
352
+ const [agentSelectorCursor, setAgentSelectorCursor] = useState(0);
353
+ const [agentElapsedSec, setAgentElapsedSec] = useState(0);
354
+ const [loginOpen, setLoginOpen] = useState(loginRequired);
355
+ const [loginPage, setLoginPage] = useState<LoginPage>(() => ({
356
+ kind: "providers",
357
+ methods: providerLoginMethods((modelRuntime as any).getProviders?.() ?? []),
358
+ cursor: 0,
359
+ }));
360
+ const [modelQuery, setModelQuery] = useState("");
361
+ const [modelSearchFocused, setModelSearchFocused] = useState(false);
362
+ const [, setAgentRevision] = useState(0);
363
+
364
+ const theme = useMemo(() => loadTheme(settings.theme), [settings.theme]);
365
+ const { width, height } = useTerminalDimensions();
366
+ const syntaxStyle = useMemo(() => buildSyntaxStyle(theme), [theme]);
367
+ const animations = settings.animations && supportsTrueColor();
368
+ const agents = subagentManager.getAgents();
369
+ const activeAgent = activeAgentId
370
+ ? agents.find((agent) => agent.id === activeAgentId)
371
+ : undefined;
372
+ const visibleTx = activeAgent?.transcript ?? tx;
373
+ const visibleBusy = activeAgent
374
+ ? activeAgent.status === "starting" || activeAgent.status === "running"
375
+ : busy;
376
+ const visibleModelId = activeAgent?.modelId.split("/").slice(1).join("/") || modelId;
377
+ const visibleThinkingLevel = activeAgent?.thinkingLevel ?? thinkingLevel;
378
+ const visibleBranch = activeAgent?.worktree.branch ?? branch;
379
+ const visibleElapsedSec = activeAgent ? agentElapsedSec : elapsedSec;
380
+ const visibleUsage = activeAgent?.usage ?? usage;
381
+ const agentTreeRows = buildAgentTree(agents);
382
+ const inputHint = cancelArmed
383
+ ? " esc again to cancel "
384
+ : quitArmed
385
+ ? " ctrl+c again to quit "
386
+ : "";
387
+ const promptRightColumns = width >= 12 ? 6 : Math.max(2, width - 3);
388
+ const promptInputColumns = Math.max(
389
+ 1,
390
+ width - 2 - promptRightColumns - inputHint.length,
391
+ );
392
+ const commandSuggestions = stashOpen ? [] : matchingCommands(commandInput).slice(0, 5);
393
+ const visibleSettingRows = filterSettingsRows(settingsQuery);
394
+ const visibleModels = useMemo(() => filterModels(
395
+ modelRuntime.getAvailableSnapshot(),
396
+ modelQuery,
397
+ (providerId) => (modelRuntime as any).getProvider?.(providerId)?.name ?? "",
398
+ ), [modelRuntime, modelId, modelQuery, loginPage]);
399
+
400
+ const inputRef = useRef<TextareaRenderable>(null);
401
+ const focusInputAfterSwitch = useRef(false);
402
+ const activeAgentIdRef = useRef<string | null>(null);
403
+ const agentSelectorCursorRef = useRef(0);
404
+ const commandCursorRef = useRef(0);
405
+ const stashRef = useRef(stash);
406
+ const stashOpenRef = useRef(false);
407
+ const stashCursorRef = useRef(-1);
408
+ const stashSelectionRef = useRef<Set<number>>(new Set());
409
+ const stashSelectionAnchor = useRef<number | null>(null);
410
+ const pendingImages = useRef<PendingImage[]>([]);
411
+ const nextImageId = useRef(1);
412
+ const lastInputValue = useRef("");
413
+ const imagePasteBusy = useRef(false);
414
+ const viewDrafts = useRef(new Map<string, string>());
415
+ const viewEditingStashIndices = useRef(new Map<string, number | null>());
416
+ /** Cache row currently checked out into the selected transcript input. */
417
+ const editingStashIndex = useRef<number | null>(null);
418
+ const quitTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
419
+ const lastQuitPress = useRef(0);
420
+ const cancelTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
421
+ const lastCancelPress = useRef<number | null>(null);
422
+ const cancelTarget = useRef<string | null>(null);
423
+ // Prompt history. `cursor` is null while editing a fresh line; `draft` holds
424
+ // that line so walking back down restores it.
425
+ const history = useRef<string[]>(promptHistoryStore.load(cwd));
426
+ const histCursor = useRef<number | null>(null);
427
+ const draft = useRef("");
428
+ /** The prompt in flight, so Esc can hand it back for editing. */
429
+ const inFlight = useRef("");
430
+ // Mirrors `busy` for the keyboard handler: a keypress can land before React
431
+ // has re-rendered, and the handler's closure would still read the old value.
432
+ const busyRef = useRef(false);
433
+ const resetCancelArm = () => {
434
+ lastCancelPress.current = null;
435
+ cancelTarget.current = null;
436
+ clearTimeout(cancelTimer.current);
437
+ setCancelArmed(false);
438
+ };
439
+ const setWorking = (value: boolean) => {
440
+ busyRef.current = value;
441
+ setBusy(value);
442
+ };
443
+ // The event subscription is set up once, so it reads the toggle via a ref.
444
+ const showThinkingRef = useRef(initial.showThinking);
445
+ const sessionRef = useRef(session);
446
+ sessionRef.current = session;
447
+ const loginControllerRef = useRef<LoginController | null>(null);
448
+
449
+ if (!loginControllerRef.current) {
450
+ loginControllerRef.current = new LoginController(modelRuntime, () => sessionRef.current, setLoginPage, (id) => id && setModelId(id), () => setLoginOpen(false));
451
+ }
452
+
453
+ const setSelectedStashRange = (indices: Set<number>, anchor: number | null) => {
454
+ stashSelectionRef.current = indices;
455
+ stashSelectionAnchor.current = anchor;
456
+ setStashSelection(indices);
457
+ };
458
+
459
+ const clearStashSelection = () => setSelectedStashRange(new Set(), null);
460
+
461
+ const setStashMode = (open: boolean) => {
462
+ stashOpenRef.current = open;
463
+ stashCursorRef.current = -1;
464
+ clearStashSelection();
465
+ setStashOpen(open);
466
+ setStashCursor(-1);
467
+ };
468
+
469
+ const setSelectedStash = (index: number) => {
470
+ stashCursorRef.current = index;
471
+ setStashCursor(index);
472
+ };
473
+
474
+ const addToStash = (prompt: string, executed = false) => {
475
+ const next = promptStashStore.append(cwd, prompt, executed);
476
+ stashRef.current = next;
477
+ setStash(next);
478
+ if (stashOpenRef.current) setSelectedStash(-1);
479
+ };
480
+
481
+ const executeStashedPrompt = (index: number) => {
482
+ const next = promptStashStore.markExecuted(cwd, index);
483
+ stashRef.current = next;
484
+ setStash(next);
485
+ };
486
+
487
+ const replaceStashedPrompt = (index: number, prompt: string, executed: boolean) => {
488
+ const next = promptStashStore.replace(cwd, index, prompt, executed);
489
+ stashRef.current = next;
490
+ setStash(next);
491
+ };
492
+
493
+ const deleteStashedPrompt = (index: number) => {
494
+ clearStashSelection();
495
+ const prompt = stashRef.current[index];
496
+ if (!prompt) return;
497
+ const next = promptStashStore.remove(cwd, index);
498
+ stashRef.current = next;
499
+ setStash(next);
500
+ history.current = promptHistoryStore.remove(cwd, prompt.text);
501
+ histCursor.current = null;
502
+
503
+ const editingIndex = editingStashIndex.current;
504
+ if (editingIndex === index) editingStashIndex.current = null;
505
+ else if (editingIndex !== null && editingIndex > index) {
506
+ editingStashIndex.current = editingIndex - 1;
507
+ }
508
+
509
+ if (next.length === 0) setStashMode(false);
510
+ else setSelectedStash(Math.min(index, next.length - 1));
511
+ };
512
+
513
+ const clearPendingImages = () => {
514
+ for (const image of pendingImages.current) removePendingImage(image);
515
+ pendingImages.current = [];
516
+ nextImageId.current = 1;
517
+ };
518
+
519
+ const syncInputMetrics = () => {
520
+ const input = inputRef.current;
521
+ if (!input) return;
522
+ const rows = Math.min(
523
+ MAX_INPUT_ROWS,
524
+ Math.max(1, input.editorView.getTotalVirtualLineCount()),
525
+ );
526
+ const cursorRow = input.cursorOffset >= input.plainText.length
527
+ ? rows - 1
528
+ : Math.max(0, Math.min(rows - 1, input.visualCursor.visualRow));
529
+ setInputRows(rows);
530
+ setInputCursorRow(cursorRow);
531
+ };
532
+
533
+ const scheduleInputMetrics = () => queueMicrotask(syncInputMetrics);
534
+
535
+ const setEditorText = (
536
+ value: string,
537
+ cursorOffset = value.length,
538
+ preserveImages = false,
539
+ ) => {
540
+ if (!preserveImages && pendingImages.current.length > 0) clearPendingImages();
541
+ const input = inputRef.current;
542
+ if (input) {
543
+ input.setText(value);
544
+ input.cursorOffset = Math.max(0, Math.min(cursorOffset, value.length));
545
+ }
546
+ lastInputValue.current = value;
547
+ commandCursorRef.current = 0;
548
+ setCommandCursor(0);
549
+ setCommandInput(value);
550
+ scheduleInputMetrics();
551
+ };
552
+
553
+ const handleInput = (nextValue: string) => {
554
+ const previous = lastInputValue.current;
555
+ let value = nextValue;
556
+ let cleanupCursor: number | null = null;
557
+ const kept: PendingImage[] = [];
558
+
559
+ for (const image of pendingImages.current) {
560
+ const exactStart = value.indexOf(image.marker);
561
+ if (exactStart >= 0) {
562
+ kept.push({ ...image, start: exactStart, end: exactStart + image.marker.length });
563
+ continue;
564
+ }
565
+
566
+ // The marker changed. Remove its remaining fragment from the new value
567
+ // and delete the corresponding temporary image immediately.
568
+ let prefix = 0;
569
+ while (
570
+ prefix < previous.length &&
571
+ prefix < nextValue.length &&
572
+ previous[prefix] === nextValue[prefix]
573
+ ) prefix++;
574
+ const delta = nextValue.length - previous.length;
575
+ const start = Math.max(0, Math.min(value.length, Math.min(image.start, prefix)));
576
+ const end = Math.max(start, Math.min(value.length, image.end + delta));
577
+ value = value.slice(0, start) + value.slice(end);
578
+ cleanupCursor = cleanupCursor === null ? start : Math.min(cleanupCursor, start);
579
+ removePendingImage(image);
580
+ }
581
+
582
+ // Recalculate marker positions after any atomic marker removal.
583
+ pendingImages.current = kept.flatMap((image) => {
584
+ const start = value.indexOf(image.marker);
585
+ if (start < 0) {
586
+ removePendingImage(image);
587
+ return [];
588
+ }
589
+ return [{ ...image, start, end: start + image.marker.length }];
590
+ });
591
+
592
+ if (value !== nextValue && inputRef.current) {
593
+ inputRef.current.setText(value);
594
+ inputRef.current.cursorOffset = Math.min(cleanupCursor ?? value.length, value.length);
595
+ }
596
+ lastInputValue.current = value;
597
+ commandCursorRef.current = 0;
598
+ setCommandCursor(0);
599
+ setCommandInput(value);
600
+ scheduleInputMetrics();
601
+ };
602
+
603
+ const handleTextareaChange = () => handleInput(inputRef.current?.plainText ?? "");
604
+
605
+ const pasteClipboardImage = async () => {
606
+ if (imagePasteBusy.current) return;
607
+ if (!session.agent.state.model.input.includes("image")) {
608
+ append({ kind: "text", role: "error", text: "the current model does not support image input" });
609
+ return;
610
+ }
611
+
612
+ imagePasteBusy.current = true;
613
+ try {
614
+ const captured = await captureClipboardImage();
615
+ const input = inputRef.current;
616
+ if (!input) {
617
+ removePendingImage({ ...captured, id: 0, marker: "", start: 0, end: 0 });
618
+ return;
619
+ }
620
+
621
+ const id = nextImageId.current++;
622
+ const marker = `[Image #${id}]`;
623
+ const current = input.plainText;
624
+ const leadingSpace = current && !/\s$/.test(current) ? " " : "";
625
+ const start = current.length + leadingSpace.length;
626
+ const value = `${current}${leadingSpace}${marker}`;
627
+ const image: PendingImage = {
628
+ ...captured,
629
+ id,
630
+ marker,
631
+ start,
632
+ end: start + marker.length,
633
+ };
634
+ pendingImages.current.push(image);
635
+ setEditorText(value, value.length, true);
636
+ } catch (error) {
637
+ append({ kind: "text", role: "error", text: `image paste failed: ${String(error)}` });
638
+ } finally {
639
+ imagePasteBusy.current = false;
640
+ }
641
+ };
642
+
643
+ const append = (line: Line) =>
644
+ setTx((t) => {
645
+ const f = flushed(t);
646
+ return { ...f, lines: [...f.lines, line] };
647
+ });
648
+
649
+ const addPending = (pending: PendingLine) =>
650
+ setTx((value) => ({ ...value, pending: [...value.pending, pending] }));
651
+
652
+ const resolvePending = (id: string) =>
653
+ setTx((value) => resolvePendingDelivery(value, id));
654
+
655
+ const dropPending = (id: string) =>
656
+ setTx((value) => ({
657
+ ...value,
658
+ pending: value.pending.filter((item) => item.id !== id),
659
+ }));
660
+
661
+ const resolvePendingText = (text: string) =>
662
+ setTx((value) => {
663
+ const pending = value.pending.find((item) => item.deliveryText === text);
664
+ return pending ? resolvePendingDelivery(value, pending.id) : value;
665
+ });
666
+
667
+ useEffect(
668
+ () => subagentManager.subscribe((event) => {
669
+ if (event.type === "main-line") append(event.line);
670
+ else if (event.type === "main-pending-add") addPending(event.pending);
671
+ else if (event.type === "main-pending-resolve") resolvePending(event.id);
672
+ else if (event.type === "main-pending-drop") dropPending(event.id);
673
+ setAgentRevision((revision) => revision + 1);
674
+ }),
675
+ [subagentManager],
676
+ );
677
+
678
+ useEffect(() => {
679
+ if (activeAgentId && !agents.some((agent) => agent.id === activeAgentId)) {
680
+ activeAgentIdRef.current = null;
681
+ setActiveAgentId(null);
682
+ }
683
+ }, [activeAgentId, agents.map((agent) => agent.id).join(":")]);
684
+
685
+ const delta = (kind: "assistant" | "thinking", text: string) =>
686
+ setTx((t) => {
687
+ if (t.stream?.kind === kind) return { ...t, stream: { kind, text: t.stream.text + text } };
688
+ return { ...flushed(t), stream: { kind, text } };
689
+ });
690
+
691
+ const patchTool = (id: string, patch: Partial<ToolCall>) =>
692
+ setTx((t) => ({
693
+ ...t,
694
+ lines: t.lines.map((l) =>
695
+ l.kind === "tool" && l.call.id === id ? { kind: "tool", call: { ...l.call, ...patch } } : l,
696
+ ),
697
+ }));
698
+
699
+ useEffect(() => {
700
+ void subagentManager
701
+ .bindMainSession(session.sessionManager, cwd)
702
+ .catch((error) => append({ kind: "text", role: "error", text: String(error) }));
703
+ setThinkingLevel(session.agent.state.thinkingLevel as ThinkingLevel);
704
+ setModelId(session.agent.state.model.id);
705
+ setTx({
706
+ lines: replayEntries(session.sessionManager.buildContextEntries(), cwd, showThinkingRef.current),
707
+ stream: null,
708
+ pending: [],
709
+ });
710
+ setUsage(sessionUsage(session));
711
+ if (focusInputAfterSwitch.current) {
712
+ focusInputAfterSwitch.current = false;
713
+ inputRef.current?.focus();
714
+ }
715
+
716
+ return session.subscribe((event) => {
717
+ switch (event.type) {
718
+ case "message_start": {
719
+ if (event.message.role === "user") {
720
+ const text = messageText(event.message);
721
+ if (text) resolvePendingText(text);
722
+ }
723
+ break;
724
+ }
725
+ case "message_end":
726
+ if (event.message.role === "assistant") {
727
+ setTx((value) => settleTranscriptMessage(value));
728
+ }
729
+ break;
730
+ case "message_update": {
731
+ const update = event.assistantMessageEvent;
732
+ if (update.type === "text_delta") delta("assistant", update.delta);
733
+ else if (update.type === "thinking_delta" && showThinkingRef.current)
734
+ delta("thinking", update.delta);
735
+ break;
736
+ }
737
+ case "tool_execution_start":
738
+ append({
739
+ kind: "tool",
740
+ call: {
741
+ id: event.toolCallId,
742
+ name: event.toolName,
743
+ arg: toolArg(event.toolName, event.args, cwd),
744
+ state: "running",
745
+ },
746
+ });
747
+ break;
748
+ case "tool_execution_end":
749
+ patchTool(event.toolCallId, {
750
+ state: isRejectedToolResult(event.result)
751
+ ? "rejected"
752
+ : event.isError
753
+ ? "error"
754
+ : "ok",
755
+ detail: event.toolName === "edit" || event.toolName === "apply_patch"
756
+ ? editCounts(event.result)
757
+ : undefined,
758
+ });
759
+ break;
760
+ case "agent_start":
761
+ setWorking(true);
762
+ break;
763
+ case "turn_end": {
764
+ const u = (event.message as any)?.usage;
765
+ if (u) {
766
+ setUsage((prev) => addTurnUsage(
767
+ prev,
768
+ u,
769
+ session.agent.state.model.contextWindow,
770
+ ));
771
+ }
772
+ break;
773
+ }
774
+ case "thinking_level_changed":
775
+ setThinkingLevel(event.level as ThinkingLevel);
776
+ break;
777
+ // agent_end fires before auto-retries; agent_settled means truly done.
778
+ case "agent_settled":
779
+ setTx(flushed);
780
+ setWorking(false);
781
+ break;
782
+ }
783
+ });
784
+ }, [session]);
785
+
786
+ useEffect(
787
+ () => () => {
788
+ clearTimeout(quitTimer.current);
789
+ clearTimeout(cancelTimer.current);
790
+ clearPendingImages();
791
+ cleanupPendingImages();
792
+ },
793
+ [],
794
+ );
795
+
796
+ useEffect(() => {
797
+ resetCancelArm();
798
+ }, [activeAgentId, visibleBusy]);
799
+
800
+ // Recalculate visual rows after wrapping, terminal resizes, or editor height changes.
801
+ useEffect(() => {
802
+ const timer = setTimeout(syncInputMetrics, 0);
803
+ return () => clearTimeout(timer);
804
+ }, [width, commandInput, inputRows, quitArmed, cancelArmed]);
805
+
806
+ // Hosted web searches are not pi tool calls, so they arrive out of band.
807
+ useEffect(() => {
808
+ return observeSearchCalls(session.sessionId, (call) => {
809
+ if (call.phase === "start") {
810
+ append({
811
+ kind: "tool",
812
+ call: { id: call.id, name: "web_search", arg: call.query, state: "running" },
813
+ });
814
+ } else {
815
+ patchTool(call.id, {
816
+ state: call.ok ? "ok" : "error",
817
+ ...(call.query ? { arg: call.query } : {}),
818
+ });
819
+ }
820
+ // Hosted searches are not pi messages, so persist them as custom
821
+ // session entries for replay without adding them to LLM context.
822
+ persistSearchCall(session.sessionManager, call);
823
+ });
824
+ }, [session]);
825
+
826
+ // Git branch, live-watched.
827
+ useEffect(() => {
828
+ const cwd = process.cwd();
829
+ setBranch(readBranch(cwd));
830
+ return watchBranch(cwd, () => setBranch(readBranch(cwd)));
831
+ }, []);
832
+
833
+ // Turn timer, only while the main agent is working.
834
+ useEffect(() => {
835
+ if (!busy) return;
836
+ setElapsedSec(0);
837
+ const started = Date.now();
838
+ const id = setInterval(() => setElapsedSec(Math.floor((Date.now() - started) / 1000)), 1000);
839
+ return () => clearInterval(id);
840
+ }, [busy]);
841
+
842
+ useEffect(() => {
843
+ if (!activeAgent || !visibleBusy) {
844
+ setAgentElapsedSec(0);
845
+ return;
846
+ }
847
+ const started = activeAgent.runStartedAt ?? activeAgent.updatedAt;
848
+ const updateElapsed = () => setAgentElapsedSec(Math.floor((Date.now() - started) / 1000));
849
+ updateElapsed();
850
+ const id = setInterval(updateElapsed, 1000);
851
+ return () => clearInterval(id);
852
+ }, [activeAgent?.id, activeAgent?.status, activeAgent?.runStartedAt, visibleBusy]);
853
+
854
+ const update = (patch: Partial<PumSettings>) => {
855
+ const next = { ...settings, ...patch };
856
+ setSettings(next);
857
+ if (patch.webSearch !== undefined) webSearch.enabled = patch.webSearch;
858
+ if (patch.writingStyle !== undefined) setWritingStyle(patch.writingStyle);
859
+ if (patch.explanationStrength !== undefined) {
860
+ setExplanationStrength(patch.explanationStrength);
861
+ }
862
+ if (patch.checkMode !== undefined || patch.checkModel !== undefined) {
863
+ setCheckModeConfig({ enabled: next.checkMode, model: next.checkModel });
864
+ }
865
+ if (patch.showThinking !== undefined) showThinkingRef.current = patch.showThinking;
866
+ saveSettings(next);
867
+ };
868
+
869
+ const stepThinking = (step: number) => {
870
+ const i = THINKING_LEVELS.indexOf(thinkingLevel);
871
+ const target = THINKING_LEVELS[Math.max(0, Math.min(THINKING_LEVELS.length - 1, i + step))]!;
872
+ session.setThinkingLevel(target);
873
+ // setThinkingLevel clamps to what the model supports — show the real value.
874
+ setThinkingLevel(session.agent.state.thinkingLevel as ThinkingLevel);
875
+ };
876
+
877
+ const stepTheme = (step: number) => {
878
+ const i = PRESET_NAMES.indexOf(settings.theme);
879
+ const next = PRESET_NAMES[(i + step + PRESET_NAMES.length) % PRESET_NAMES.length]!;
880
+ update({ theme: next });
881
+ };
882
+
883
+ const stepWritingStyle = (step: number) => {
884
+ const i = WRITING_STYLES.indexOf(settings.writingStyle);
885
+ const next = WRITING_STYLES[(i + step + WRITING_STYLES.length) % WRITING_STYLES.length]!;
886
+ update({ writingStyle: next });
887
+ };
888
+
889
+ const stepExplanationStrength = (step: number) => {
890
+ const i = EXPLANATION_STRENGTHS.indexOf(settings.explanationStrength);
891
+ const next = EXPLANATION_STRENGTHS[
892
+ (i + step + EXPLANATION_STRENGTHS.length) % EXPLANATION_STRENGTHS.length
893
+ ]!;
894
+ update({ explanationStrength: next });
895
+ };
896
+
897
+ const stepWorkingRuleAnimation = (step: number) => {
898
+ const i = WORKING_RULE_ANIMATION_MODES.indexOf(settings.workingRuleAnimation);
899
+ const next = WORKING_RULE_ANIMATION_MODES[
900
+ (i + step + WORKING_RULE_ANIMATION_MODES.length) % WORKING_RULE_ANIMATION_MODES.length
901
+ ]!;
902
+ update({ workingRuleAnimation: next });
903
+ };
904
+
905
+ const openLogin = () => {
906
+ setSettingsOpen(false);
907
+ setHelpOpen(false);
908
+ setHistoryOpen(false);
909
+ setAgentSelectorOpen(false);
910
+ setLoginOpen(true);
911
+ loginControllerRef.current?.open();
912
+ };
913
+
914
+ const finishLogin = async (providerId: string, providerName: string) => {
915
+ const selected = await refreshAndSelectModel(
916
+ modelRuntime,
917
+ providerId,
918
+ (model) => session.setModel(model),
919
+ AbortSignal.timeout(15_000),
920
+ );
921
+ if (selected) {
922
+ setModelId(selected.id);
923
+ setLoginPage({ kind: "success", message: `${providerName} is ready. Selected ${selected.id}.` });
924
+ } else {
925
+ setLoginPage({ kind: "success", message: `${providerName} is configured. Open Settings to select an available model.` });
926
+ }
927
+ };
928
+
929
+ const selectModel = (model: Model<any>) => {
930
+ setPage("main");
931
+ setModelQuery("");
932
+ setModelSearchFocused(false);
933
+ session
934
+ .setModel(model)
935
+ .then(() => setModelId(session.agent.state.model.id))
936
+ .catch((err) => append({ kind: "text", role: "error", text: String(err) }));
937
+ };
938
+
939
+ const selectCheckModel = (model: Model<any>) => {
940
+ setPage("main");
941
+ setModelQuery("");
942
+ setModelSearchFocused(false);
943
+ update({ checkModel: `${model.provider}/${model.id}` });
944
+ };
945
+
946
+ const openHistory = () => {
947
+ if (busyRef.current) {
948
+ append({ kind: "text", role: "error", text: "wait for the current turn to finish before opening history" });
949
+ return;
950
+ }
951
+ setSettingsOpen(false);
952
+ setHelpOpen(false);
953
+ loadSessions()
954
+ .then((sessions) => {
955
+ const currentPath = session.sessionFile;
956
+ setHistorySessions(sessions.filter((candidate) => candidate.path !== currentPath));
957
+ setHistoryOpen(true);
958
+ })
959
+ .catch((err) => append({ kind: "text", role: "error", text: String(err) }));
960
+ };
961
+
962
+ const selectHistorySession = (path: string) => {
963
+ setHistoryOpen(false);
964
+ setWorking(true);
965
+ onSwitchSession(path)
966
+ .then((next) => {
967
+ focusInputAfterSwitch.current = true;
968
+ setSession(next);
969
+ })
970
+ .catch((err) => append({ kind: "text", role: "error", text: String(err) }))
971
+ .finally(() => setWorking(false));
972
+ };
973
+
974
+ const cancel = () => {
975
+ resetCancelArm();
976
+ append({ kind: "text", role: "system", text: "cancelled" });
977
+ // Hand a prompt back for editing: the queued steer if there is one, since
978
+ // that is the newest thing written, otherwise the prompt that was running.
979
+ const queued = session.clearQueue().steering;
980
+ setTx((value) => ({
981
+ ...value,
982
+ pending: value.pending.filter((item) => item.line.kind === "agent-message"),
983
+ }));
984
+ const restore = queued.length ? queued[queued.length - 1]! : inFlight.current;
985
+ if (inputRef.current && !inputRef.current.plainText) setEditorText(restore);
986
+ histCursor.current = null;
987
+ session.abort().finally(() => setWorking(false));
988
+ };
989
+
990
+ /** Up walks back through sent prompts, down returns to the current draft. */
991
+ const recall = (direction: -1 | 1) => {
992
+ const input = inputRef.current;
993
+ const list = history.current;
994
+ editingStashIndex.current = null;
995
+ if (!input || list.length === 0) return;
996
+
997
+ if (histCursor.current === null) {
998
+ if (direction === 1) return; // already on the draft line
999
+ draft.current = input.plainText;
1000
+ histCursor.current = list.length - 1;
1001
+ } else {
1002
+ const next = histCursor.current + direction;
1003
+ if (next >= list.length) {
1004
+ histCursor.current = null;
1005
+ setEditorText(draft.current);
1006
+ return;
1007
+ }
1008
+ histCursor.current = Math.max(0, next);
1009
+ }
1010
+ setEditorText(list[histCursor.current]!);
1011
+ };
1012
+
1013
+ const moveStash = (direction: -1 | 1, extend = false) => {
1014
+ const list = stashRef.current;
1015
+ if (list.length === 0) return;
1016
+ const current = stashCursorRef.current;
1017
+
1018
+ if (!extend) {
1019
+ clearStashSelection();
1020
+ if (direction === -1) {
1021
+ setSelectedStash(current < 0 ? list.length - 1 : Math.max(0, current - 1));
1022
+ } else if (current >= 0) {
1023
+ setSelectedStash(current + 1 < list.length ? current + 1 : -1);
1024
+ }
1025
+ return;
1026
+ }
1027
+
1028
+ const start = current < 0 ? (direction === -1 ? list.length - 1 : 0) : current;
1029
+ const anchor = stashSelectionAnchor.current ?? start;
1030
+ const next = Math.max(0, Math.min(list.length - 1, start + direction));
1031
+ setSelectedStash(next);
1032
+ setSelectedStashRange(selectedRange(anchor, next), anchor);
1033
+ };
1034
+
1035
+ const runCommand = (text: string): boolean => {
1036
+ const trimmed = text.trim();
1037
+ const compress = /^\/compress(?:\s+(.*))?$/s.exec(trimmed);
1038
+ const clear = /^\/(?:clear|new)$/.test(trimmed);
1039
+ const historyCommand = trimmed === "/history";
1040
+ const loginCommand = trimmed === "/login";
1041
+ const worktreeCommand = /^\/worktree(?:\s+([a-zA-Z0-9_-]+))?$/.exec(trimmed);
1042
+ if (!compress && !clear && !historyCommand && !loginCommand && !worktreeCommand) return false;
1043
+ editingStashIndex.current = null;
1044
+
1045
+ if (historyCommand) {
1046
+ setEditorText("");
1047
+ openHistory();
1048
+ return true;
1049
+ }
1050
+ if (loginCommand) {
1051
+ setEditorText("");
1052
+ openLogin();
1053
+ return true;
1054
+ }
1055
+
1056
+ setEditorText("");
1057
+ histCursor.current = null;
1058
+ draft.current = "";
1059
+
1060
+ if (busyRef.current) {
1061
+ append({ kind: "text", role: "error", text: "wait for the current turn to finish before running a command" });
1062
+ return true;
1063
+ }
1064
+
1065
+ setWorking(true);
1066
+ if (worktreeCommand) {
1067
+ runWorktreeCommand({
1068
+ name: worktreeCommand[1],
1069
+ manager: subagentManager,
1070
+ append: (call) => append({ kind: "tool", call }),
1071
+ patch: patchTool,
1072
+ settled: () => setWorking(false),
1073
+ });
1074
+ } else if (clear) {
1075
+ onNewSession()
1076
+ .then((next) => setSession(next))
1077
+ .catch((err) => append({ kind: "text", role: "error", text: String(err) }))
1078
+ .finally(() => setWorking(false));
1079
+ } else {
1080
+ session
1081
+ .compact(compress![1]?.trim() || undefined)
1082
+ .then((result) => append({
1083
+ kind: "text",
1084
+ role: "system",
1085
+ text: `compressed context (${result.tokensBefore.toLocaleString()} tokens before)`,
1086
+ }))
1087
+ .catch((err) => append({ kind: "text", role: "error", text: String(err) }))
1088
+ .finally(() => setWorking(false));
1089
+ }
1090
+ return true;
1091
+ };
1092
+
1093
+ const submitPrompt = (value?: string, stashIndex?: number) => {
1094
+ const displayText = value ?? inputRef.current?.plainText ?? "";
1095
+ const attachments = value === undefined ? [...pendingImages.current] : [];
1096
+ let promptText = displayText;
1097
+ for (const image of attachments) promptText = promptText.replace(image.marker, "");
1098
+ promptText = promptText.replace(/[ \t]{2,}/g, " ").trim();
1099
+
1100
+ if (!promptText && attachments.length === 0) return;
1101
+
1102
+ let images;
1103
+ try {
1104
+ images = attachments.map(imageContent);
1105
+ } catch (error) {
1106
+ append({ kind: "text", role: "error", text: `image attachment failed: ${String(error)}` });
1107
+ return;
1108
+ }
1109
+
1110
+ setEditorText("");
1111
+ clearPendingImages();
1112
+
1113
+ if (attachments.length === 0 && promptText === "/login") {
1114
+ openLogin();
1115
+ return;
1116
+ }
1117
+
1118
+ if (activeAgentId) {
1119
+ void subagentManager
1120
+ .sendUserMessage(activeAgentId, promptText, images, displayText.trim())
1121
+ .catch((error) => append({ kind: "text", role: "error", text: String(error) }));
1122
+ return;
1123
+ }
1124
+
1125
+ if (attachments.length === 0 && runCommand(promptText)) return;
1126
+
1127
+ if (promptText && stashIndex === undefined) {
1128
+ const editingIndex = editingStashIndex.current;
1129
+ if (editingIndex === null) addToStash(promptText, true);
1130
+ else replaceStashedPrompt(editingIndex, promptText, true);
1131
+ }
1132
+ editingStashIndex.current = null;
1133
+ if (promptText) history.current = promptHistoryStore.append(cwd, promptText);
1134
+ histCursor.current = null;
1135
+ draft.current = "";
1136
+ setSelectedStash(-1);
1137
+ const userLine: Extract<Line, { kind: "text" }> = {
1138
+ kind: "text",
1139
+ role: "user",
1140
+ text: displayText.trim(),
1141
+ };
1142
+
1143
+ // Working already: keep the steering message pending at the transcript
1144
+ // bottom until pi emits message_start for its actual insertion.
1145
+ if (busyRef.current) {
1146
+ const pending: PendingLine = {
1147
+ id: randomUUID().slice(0, 12),
1148
+ line: userLine,
1149
+ deliveryText: promptText,
1150
+ };
1151
+ addPending(pending);
1152
+ withSearchRoute(session.sessionId, () => session.steer(promptText, images)).catch((err) => {
1153
+ dropPending(pending.id);
1154
+ append({ kind: "text", role: "error", text: String(err) });
1155
+ });
1156
+ return;
1157
+ }
1158
+
1159
+ append(userLine);
1160
+ inFlight.current = promptText;
1161
+ setWorking(true);
1162
+ withSearchRoute(session.sessionId, () => session.prompt(promptText, { images })).catch((err) => {
1163
+ append({ kind: "text", role: "error", text: String(err) });
1164
+ setWorking(false);
1165
+ });
1166
+ };
1167
+
1168
+ const runSelectedStashBatch = () => {
1169
+ const indices = [...stashSelectionRef.current].sort((a, b) => a - b);
1170
+ const prompts = indices.flatMap((index) => {
1171
+ const prompt = stashRef.current[index];
1172
+ return prompt ? [prompt.text] : [];
1173
+ });
1174
+ if (prompts.length === 0) return;
1175
+
1176
+ const orchestrationPrompt = buildStashBatchPrompt(prompts);
1177
+ const displayText = [
1178
+ `Run ${prompts.length} cached tasks with worktree subagents:`,
1179
+ ...prompts.map((prompt, index) => `${index + 1}. ${prompt}`),
1180
+ ].join("\n");
1181
+
1182
+ const next = promptStashStore.markExecutedMany(cwd, indices);
1183
+ stashRef.current = next;
1184
+ setStash(next);
1185
+ for (const prompt of prompts) history.current = promptHistoryStore.append(cwd, prompt);
1186
+ editingStashIndex.current = null;
1187
+ histCursor.current = null;
1188
+ draft.current = "";
1189
+ setStashMode(false);
1190
+ setEditorText("");
1191
+ const userLine: Extract<Line, { kind: "text" }> = {
1192
+ kind: "text",
1193
+ role: "user",
1194
+ text: displayText,
1195
+ };
1196
+
1197
+ if (busyRef.current) {
1198
+ const pending: PendingLine = {
1199
+ id: randomUUID().slice(0, 12),
1200
+ line: userLine,
1201
+ deliveryText: orchestrationPrompt,
1202
+ };
1203
+ addPending(pending);
1204
+ withSearchRoute(session.sessionId, () => session.steer(orchestrationPrompt)).catch((error) => {
1205
+ dropPending(pending.id);
1206
+ append({ kind: "text", role: "error", text: String(error) });
1207
+ });
1208
+ return;
1209
+ }
1210
+
1211
+ append(userLine);
1212
+ inFlight.current = orchestrationPrompt;
1213
+ setWorking(true);
1214
+ withSearchRoute(session.sessionId, () => session.prompt(orchestrationPrompt)).catch((error) => {
1215
+ append({ kind: "text", role: "error", text: String(error) });
1216
+ setWorking(false);
1217
+ });
1218
+ };
1219
+
1220
+ const rowActions: Record<SettingRowId, { step?: (n: number) => void; enter?: () => void }> = {
1221
+ theme: { step: stepTheme },
1222
+ providers: { enter: openLogin },
1223
+ animations: { step: () => update({ animations: !settings.animations }) },
1224
+ workingRuleAnimation: { step: stepWorkingRuleAnimation },
1225
+ webSearch: { step: () => update({ webSearch: !settings.webSearch }) },
1226
+ writingStyle: { step: stepWritingStyle },
1227
+ explanationStrength: { step: stepExplanationStrength },
1228
+ checkMode: { step: () => update({ checkMode: !settings.checkMode }) },
1229
+ checkModel: { enter: () => { setModelQuery(""); setModelSearchFocused(false); setPage("checkModels"); } },
1230
+ thinkingLevel: { step: stepThinking },
1231
+ showThinking: { step: () => update({ showThinking: !settings.showThinking }) },
1232
+ model: { enter: () => { setModelQuery(""); setModelSearchFocused(false); setPage("models"); } },
1233
+ };
1234
+
1235
+ const animationUnavailable = !settings.animations
1236
+ ? " (global off)"
1237
+ : !supportsTrueColor()
1238
+ ? " (no truecolor)"
1239
+ : "";
1240
+ const rowValues: Record<SettingRowId, string> = {
1241
+ theme: `‹ ${theme.name} ›`,
1242
+ providers: "login and custom setup ›",
1243
+ animations: `‹ ${settings.animations ? "on" : "off"} ›`,
1244
+ workingRuleAnimation: `‹ ${settings.workingRuleAnimation} ›${settings.workingRuleAnimation === "off" ? "" : animationUnavailable}`,
1245
+ webSearch: `‹ ${settings.webSearch ? "on" : "off"} ›${searchProviders.length ? "" : " (not on provider)"}`,
1246
+ writingStyle: `‹ ${settings.writingStyle} ›`,
1247
+ explanationStrength: `‹ ${settings.explanationStrength} ›`,
1248
+ checkMode: `‹ ${settings.checkMode ? "on" : "off"} ›`,
1249
+ checkModel: `${settings.checkModel} ›`,
1250
+ thinkingLevel: `‹ ${thinkingLevel} ›`,
1251
+ showThinking: `‹ ${settings.showThinking ? "on" : "off"} ›`,
1252
+ model: `${modelId} ›`,
1253
+ };
1254
+
1255
+ const updateSettingsQuery = (query: string) => {
1256
+ const rows = filterSettingsRows(query);
1257
+ setSettingsQuery(query);
1258
+ setSelectedSettingId((current) =>
1259
+ rows.some((row) => row.id === current) ? current : rows[0]?.id ?? null,
1260
+ );
1261
+ };
1262
+
1263
+ const selectAgentView = (target: string | null): boolean => {
1264
+ if (pendingImages.current.length > 0) {
1265
+ append({ kind: "text", role: "error", text: "send or remove attached images before switching agents" });
1266
+ return false;
1267
+ }
1268
+ const current = activeAgentIdRef.current;
1269
+ if (target === current) return true;
1270
+ const currentKey = current ?? "main";
1271
+ const targetKey = target ?? "main";
1272
+ viewDrafts.current.set(currentKey, inputRef.current?.plainText ?? "");
1273
+ viewEditingStashIndices.current.set(currentKey, editingStashIndex.current);
1274
+ activeAgentIdRef.current = target;
1275
+ setActiveAgentId(target);
1276
+ editingStashIndex.current = viewEditingStashIndices.current.get(targetKey) ?? null;
1277
+ setEditorText(viewDrafts.current.get(targetKey) ?? "");
1278
+ setStashMode(false);
1279
+ histCursor.current = null;
1280
+ resetCancelArm();
1281
+ queueMicrotask(() => inputRef.current?.focus());
1282
+ return true;
1283
+ };
1284
+
1285
+ const cycleAgentView = (direction: -1 | 1) => {
1286
+ const ids: Array<string | null> = [null, ...agents.map((agent) => agent.id)];
1287
+ if (ids.length === 1) return;
1288
+ const current = ids.findIndex((id) => id === activeAgentIdRef.current);
1289
+ const next = (current + direction + ids.length) % ids.length;
1290
+ selectAgentView(ids[next] ?? null);
1291
+ };
1292
+
1293
+ useKeyboard((key) => {
1294
+ if (key.ctrl && key.name === "c") {
1295
+ key.stopPropagation();
1296
+ resetCancelArm();
1297
+ // Keyed off a timestamp, not `quitArmed`: two fast presses can land in
1298
+ // one React batch, where the state has not updated between them yet.
1299
+ const now = Date.now();
1300
+ if (now - lastQuitPress.current < QUIT_WINDOW_MS) void onExit();
1301
+ lastQuitPress.current = now;
1302
+ setQuitArmed(true);
1303
+ clearTimeout(quitTimer.current);
1304
+ quitTimer.current = setTimeout(() => setQuitArmed(false), QUIT_WINDOW_MS);
1305
+ return;
1306
+ }
1307
+ // Any other key disarms, so Ctrl+C · x · Ctrl+C does not quit.
1308
+ if (lastQuitPress.current) {
1309
+ lastQuitPress.current = 0;
1310
+ clearTimeout(quitTimer.current);
1311
+ setQuitArmed(false);
1312
+ }
1313
+ if (key.name !== "escape" && lastCancelPress.current !== null) resetCancelArm();
1314
+
1315
+ if (loginOpen) {
1316
+ key.stopPropagation();
1317
+ loginControllerRef.current?.handleKey(key);
1318
+ return;
1319
+ }
1320
+
1321
+ if (key.ctrl && key.name === "l") {
1322
+ key.stopPropagation();
1323
+ if (agentSelectorOpen) {
1324
+ setAgentSelectorOpen(false);
1325
+ queueMicrotask(() => inputRef.current?.focus());
1326
+ } else {
1327
+ setSettingsOpen(false);
1328
+ setHelpOpen(false);
1329
+ setHistoryOpen(false);
1330
+ const selected = Math.max(
1331
+ 0,
1332
+ agentTreeRows.findIndex((row) => row.id === activeAgentIdRef.current),
1333
+ );
1334
+ agentSelectorCursorRef.current = selected;
1335
+ setAgentSelectorCursor(selected);
1336
+ setAgentSelectorOpen(true);
1337
+ }
1338
+ return;
1339
+ }
1340
+
1341
+ if (agentSelectorOpen) {
1342
+ key.stopPropagation();
1343
+ if (key.name === "escape") {
1344
+ setAgentSelectorOpen(false);
1345
+ queueMicrotask(() => inputRef.current?.focus());
1346
+ } else if (key.name === "up" || key.name === "down") {
1347
+ const next = moveAgentSelection(
1348
+ agentSelectorCursorRef.current,
1349
+ agentTreeRows.length,
1350
+ key.name === "up" ? -1 : 1,
1351
+ );
1352
+ agentSelectorCursorRef.current = next;
1353
+ setAgentSelectorCursor(next);
1354
+ } else if (
1355
+ key.name === "right" ||
1356
+ key.name === "return" ||
1357
+ key.name === "enter" ||
1358
+ key.name === "kpenter"
1359
+ ) {
1360
+ const target = agentTreeRows[agentSelectorCursorRef.current]?.id ?? null;
1361
+ if (selectAgentView(target)) setAgentSelectorOpen(false);
1362
+ }
1363
+ return;
1364
+ }
1365
+
1366
+ if (helpOpen) {
1367
+ key.stopPropagation();
1368
+ if (key.name === "escape" || key.sequence === "?") setHelpOpen(false);
1369
+ else if (key.name === "up" || key.name === "pageup") {
1370
+ setHelpScrollOffset((offset) => Math.max(0, offset - (key.name === "pageup" ? 5 : 1)));
1371
+ } else if (key.name === "down" || key.name === "pagedown") {
1372
+ setHelpScrollOffset((offset) =>
1373
+ Math.min(maxHelpScrollOffset(height), offset + (key.name === "pagedown" ? 5 : 1)),
1374
+ );
1375
+ }
1376
+ return;
1377
+ }
1378
+
1379
+ if (historyOpen) {
1380
+ if (key.name === "escape") {
1381
+ key.stopPropagation();
1382
+ setHistoryOpen(false);
1383
+ }
1384
+ return; // navigation and Enter belong to the focused select
1385
+ }
1386
+
1387
+ if (key.ctrl && key.name === "h") {
1388
+ key.stopPropagation();
1389
+ openHistory();
1390
+ return;
1391
+ }
1392
+
1393
+ // `?` on an empty prompt opens help instead of typing a question mark.
1394
+ // With text already in the line it is just a character.
1395
+ if (key.sequence === "?" && !settingsOpen && !inputRef.current?.plainText) {
1396
+ key.stopPropagation();
1397
+ setHelpScrollOffset(0);
1398
+ setHelpOpen(true);
1399
+ return;
1400
+ }
1401
+
1402
+ if (settingsOpen) {
1403
+ if (key.name === "escape") {
1404
+ key.stopPropagation();
1405
+ if (page !== "main") setPage("main");
1406
+ else if (settingsSearchFocused) setSettingsSearchFocused(false);
1407
+ else setSettingsOpen(false);
1408
+ return;
1409
+ }
1410
+ if (page !== "main") {
1411
+ const isModelReturn = key.name === "return" || key.name === "enter" || key.name === "kpenter";
1412
+ if (modelSearchFocused) {
1413
+ if ((key.name === "up" || key.name === "down" || isModelReturn) && visibleModels.length > 0) {
1414
+ key.stopPropagation();
1415
+ setModelSearchFocused(false);
1416
+ }
1417
+ return;
1418
+ }
1419
+ if (isModelSearchShortcut(key, modelSearchFocused)) {
1420
+ key.stopPropagation();
1421
+ setModelSearchFocused(true);
1422
+ }
1423
+ return;
1424
+ }
1425
+
1426
+ const isSettingsReturn =
1427
+ key.name === "return" || key.name === "enter" || key.name === "kpenter" || key.name === "linefeed";
1428
+ if (settingsSearchFocused) {
1429
+ if (key.name === "down" || key.name === "up" || isSettingsReturn) {
1430
+ key.stopPropagation();
1431
+ setSettingsSearchFocused(false);
1432
+ setSelectedSettingId((current) =>
1433
+ visibleSettingRows.some((row) => row.id === current)
1434
+ ? current
1435
+ : key.name === "up"
1436
+ ? visibleSettingRows.at(-1)?.id ?? null
1437
+ : visibleSettingRows[0]?.id ?? null,
1438
+ );
1439
+ }
1440
+ return; // printable keys and editing keys belong to the focused <input>
1441
+ }
1442
+
1443
+ if (isSettingsSearchShortcut(key, settingsSearchFocused)) {
1444
+ key.stopPropagation();
1445
+ setSettingsSearchFocused(true);
1446
+ return;
1447
+ }
1448
+
1449
+ key.stopPropagation();
1450
+ const action = selectedSettingId ? rowActions[selectedSettingId] : undefined;
1451
+ const confirming = key.name === "space" || key.sequence === " " || isSettingsReturn;
1452
+ if (key.name === "up" || key.name === "down") {
1453
+ setSelectedSettingId((current) =>
1454
+ moveSettingSelection(visibleSettingRows, current, key.name === "up" ? -1 : 1),
1455
+ );
1456
+ } else if (key.name === "left") action?.step?.(-1);
1457
+ else if (key.name === "right") action?.step?.(1);
1458
+ else if (confirming) (action?.enter ?? (() => action?.step?.(1)))();
1459
+ return;
1460
+ }
1461
+
1462
+ const isAgentCycle =
1463
+ (key.name === "tab" && key.shift) ||
1464
+ key.name === "backtab" ||
1465
+ key.sequence === "\u001b[Z";
1466
+ if (isAgentCycle) {
1467
+ key.stopPropagation();
1468
+ cycleAgentView(key.ctrl ? -1 : 1);
1469
+ return;
1470
+ }
1471
+
1472
+ const isReturn =
1473
+ key.name === "return" ||
1474
+ key.name === "enter" ||
1475
+ key.name === "kpenter" ||
1476
+ key.name === "linefeed";
1477
+ const hasAlt = key.meta || key.option;
1478
+ // Ctrl+Alt+Enter is an explicit cache alias for terminals that reserve
1479
+ // Alt+Enter, such as Windows Terminal's default fullscreen binding.
1480
+ const isCacheReturn = hasAlt && isReturn;
1481
+ const isNewlineReturn = (key.ctrl || key.shift) && !hasAlt && isReturn;
1482
+ const isPlainReturn =
1483
+ isReturn && !key.ctrl && !key.shift && !key.meta && !key.option;
1484
+ const inputValue = inputRef.current?.plainText ?? "";
1485
+ const commandMatches = stashOpenRef.current
1486
+ ? []
1487
+ : matchingCommands(inputValue).slice(0, 5);
1488
+ const isContinuationReturn =
1489
+ isPlainReturn &&
1490
+ inputValue.endsWith("\\") &&
1491
+ (inputRef.current?.cursorOffset ?? 0) >= inputValue.length;
1492
+ const isWordBackspace =
1493
+ key.ctrl && (key.name === "backspace" || key.name === "w");
1494
+
1495
+ if ((key.meta || key.option) && key.name === "v") {
1496
+ key.stopPropagation();
1497
+ void pasteClipboardImage();
1498
+ return;
1499
+ }
1500
+
1501
+ if (isNewlineReturn) {
1502
+ key.stopPropagation();
1503
+ inputRef.current?.newLine();
1504
+ handleTextareaChange();
1505
+ histCursor.current = null;
1506
+ if (stashOpenRef.current) setSelectedStash(-1);
1507
+ return;
1508
+ }
1509
+
1510
+ // Enhanced keyboard protocols distinguish Ctrl+Backspace from Ctrl+H.
1511
+ // Legacy terminals can send both Ctrl+H and Backspace as raw ^H. Treat
1512
+ // that ambiguous byte as Backspace, and leave /history as the fallback.
1513
+ if (isWordBackspace) {
1514
+ key.stopPropagation();
1515
+ inputRef.current?.deleteWordBackward();
1516
+ handleTextareaChange();
1517
+ histCursor.current = null;
1518
+ if (stashOpenRef.current) setSelectedStash(-1);
1519
+ return;
1520
+ }
1521
+
1522
+ if (
1523
+ stashOpenRef.current &&
1524
+ stashCursorRef.current >= 0 &&
1525
+ key.name === "delete"
1526
+ ) {
1527
+ key.stopPropagation();
1528
+ deleteStashedPrompt(stashCursorRef.current);
1529
+ return;
1530
+ }
1531
+
1532
+ // Alt+Enter and Ctrl+Alt+Enter cache without executing.
1533
+ if (isCacheReturn) {
1534
+ key.stopPropagation();
1535
+ if (pendingImages.current.length > 0) {
1536
+ append({
1537
+ kind: "text",
1538
+ role: "error",
1539
+ text: "image prompts cannot be stored in the cache; send or remove the image first",
1540
+ });
1541
+ return;
1542
+ }
1543
+ if (inputValue.trim()) {
1544
+ const editingIndex = editingStashIndex.current;
1545
+ if (editingIndex === null) addToStash(inputValue);
1546
+ else replaceStashedPrompt(editingIndex, inputValue, false);
1547
+ editingStashIndex.current = null;
1548
+ setEditorText("");
1549
+ setSelectedStash(-1);
1550
+ }
1551
+ return;
1552
+ }
1553
+
1554
+ if (key.name === "tab" && !key.shift) {
1555
+ if (stashOpenRef.current || !inputValue.trim()) {
1556
+ key.stopPropagation();
1557
+ if (stashOpenRef.current) {
1558
+ const index = stashCursorRef.current;
1559
+ const prompt = index >= 0 ? stashRef.current[index] : undefined;
1560
+ if (prompt && inputRef.current) {
1561
+ setEditorText(prompt.text);
1562
+ editingStashIndex.current = index;
1563
+ histCursor.current = null;
1564
+ draft.current = "";
1565
+ }
1566
+ setStashMode(false);
1567
+ } else if (stashRef.current.length > 0) {
1568
+ setStashMode(true);
1569
+ }
1570
+ queueMicrotask(() => inputRef.current?.focus());
1571
+ return;
1572
+ }
1573
+ if (activeAgentId) return;
1574
+ if (commandMatches.length > 0 && !/\s/.test(inputValue)) {
1575
+ key.stopPropagation();
1576
+ const selected = commandMatches[Math.min(commandCursorRef.current, commandMatches.length - 1)]!;
1577
+ setEditorText(selected.name);
1578
+ return;
1579
+ }
1580
+ }
1581
+
1582
+ if (isContinuationReturn) {
1583
+ key.stopPropagation();
1584
+ inputRef.current?.deleteCharBackward();
1585
+ inputRef.current?.newLine();
1586
+ handleTextareaChange();
1587
+ histCursor.current = null;
1588
+ if (stashOpenRef.current) setSelectedStash(-1);
1589
+ return;
1590
+ }
1591
+
1592
+ if (isPlainReturn && commandMatches.length > 0 && !/\s/.test(inputValue)) {
1593
+ key.stopPropagation();
1594
+ const selected = commandMatches[Math.min(commandCursorRef.current, commandMatches.length - 1)]!;
1595
+ submitPrompt(selected.name);
1596
+ return;
1597
+ }
1598
+
1599
+ if (stashOpenRef.current && isPlainReturn) {
1600
+ key.stopPropagation();
1601
+ if (stashSelectionRef.current.size > 0) {
1602
+ runSelectedStashBatch();
1603
+ return;
1604
+ }
1605
+ const index = stashCursorRef.current;
1606
+ if (index >= 0) {
1607
+ const prompt = stashRef.current[index];
1608
+ if (prompt) {
1609
+ executeStashedPrompt(index);
1610
+ submitPrompt(prompt.text, index);
1611
+ }
1612
+ } else if (inputValue.trim()) {
1613
+ addToStash(inputValue);
1614
+ setEditorText("");
1615
+ }
1616
+ return;
1617
+ }
1618
+
1619
+ if (isPlainReturn) {
1620
+ key.stopPropagation();
1621
+ submitPrompt();
1622
+ return;
1623
+ }
1624
+
1625
+ // Editing puts you back on a fresh line, so the next Up starts from the
1626
+ // most recent prompt and Down returns to what you just typed.
1627
+ if (!NAV_KEYS.has(key.name)) {
1628
+ histCursor.current = null;
1629
+ if (stashOpenRef.current) setSelectedStash(-1);
1630
+ }
1631
+
1632
+ if (key.name === "up" || key.name === "down") {
1633
+ if (stashOpenRef.current) {
1634
+ key.stopPropagation();
1635
+ moveStash(key.name === "up" ? -1 : 1, key.shift);
1636
+ return;
1637
+ }
1638
+ if (activeAgentId) return;
1639
+ if (commandMatches.length > 0) {
1640
+ key.stopPropagation();
1641
+ const next = moveCommandSelection(
1642
+ commandCursorRef.current,
1643
+ commandMatches.length,
1644
+ key.name === "up" ? -1 : 1,
1645
+ );
1646
+ commandCursorRef.current = next;
1647
+ setCommandCursor(next);
1648
+ return;
1649
+ }
1650
+ // Keep arrow navigation inside multiline or visually wrapped prompts.
1651
+ if ((inputRef.current?.editorView.getTotalVirtualLineCount() ?? 1) > 1) return;
1652
+ key.stopPropagation();
1653
+ recall(key.name === "up" ? -1 : 1);
1654
+ return;
1655
+ }
1656
+
1657
+ if (key.name === "escape") {
1658
+ if (stashOpenRef.current) {
1659
+ key.stopPropagation();
1660
+ resetCancelArm();
1661
+ setStashMode(false);
1662
+ } else if (
1663
+ inputValue.startsWith("/") &&
1664
+ !/\s/.test(inputValue) &&
1665
+ matchingCommands(inputValue).length > 0
1666
+ ) {
1667
+ key.stopPropagation();
1668
+ resetCancelArm();
1669
+ setEditorText("");
1670
+ } else if ((activeAgentId && visibleBusy) || (!activeAgentId && busyRef.current)) {
1671
+ key.stopPropagation();
1672
+ const now = Date.now();
1673
+ const target = activeAgentId ?? "main";
1674
+ if (confirmsCancellation(lastCancelPress.current, cancelTarget.current, target, now)) {
1675
+ resetCancelArm();
1676
+ if (activeAgentId) void subagentManager.abortAgent(activeAgentId);
1677
+ else cancel();
1678
+ } else {
1679
+ lastCancelPress.current = now;
1680
+ cancelTarget.current = target;
1681
+ setCancelArmed(true);
1682
+ clearTimeout(cancelTimer.current);
1683
+ cancelTimer.current = setTimeout(resetCancelArm, CANCEL_WINDOW_MS);
1684
+ }
1685
+ } else {
1686
+ resetCancelArm();
1687
+ }
1688
+ return;
1689
+ }
1690
+ if (key.ctrl && key.name === "p") {
1691
+ key.stopPropagation();
1692
+ setSettingsQuery("");
1693
+ setSelectedSettingId(SETTINGS_ROWS[0]!.id);
1694
+ setSettingsSearchFocused(true);
1695
+ setPage("main");
1696
+ setSettingsOpen(true);
1697
+ }
1698
+ });
1699
+
1700
+ const lastLine = visibleTx.lines[visibleTx.lines.length - 1];
1701
+ const streamGap = visibleTx.stream
1702
+ ? needsTranscriptGap(lastLine, { kind: "text", role: visibleTx.stream.kind, text: visibleTx.stream.text })
1703
+ : false;
1704
+
1705
+ return (
1706
+ <AnimationProvider
1707
+ enabled={animations}
1708
+ working={visibleBusy}
1709
+ workingRuleWidth={width}
1710
+ >
1711
+ <box style={{ flexDirection: "column", height: "100%", backgroundColor: theme.bg }}>
1712
+ <WorkingRule
1713
+ theme={theme}
1714
+ width={Math.max(0, width)}
1715
+ busy={visibleBusy}
1716
+ mode={settings.workingRuleAnimation}
1717
+ role="headerTop"
1718
+ />
1719
+ <StatusBar
1720
+ theme={theme}
1721
+ modelId={visibleModelId}
1722
+ thinkingLevel={visibleThinkingLevel}
1723
+ branch={visibleBranch}
1724
+ outgoingTokens={visibleUsage.outgoing}
1725
+ incomingTokens={visibleUsage.incoming}
1726
+ cacheReadTokens={visibleUsage.cacheRead}
1727
+ cost={visibleUsage.cost}
1728
+ contextPct={visibleUsage.contextPct}
1729
+ busy={visibleBusy}
1730
+ elapsedSec={visibleElapsedSec}
1731
+ agentCount={agents.length}
1732
+ runningAgentCount={agents.filter((agent) => agent.status === "running" || agent.status === "starting").length}
1733
+ activeAgentName={activeAgent?.name}
1734
+ />
1735
+ <WorkingRule
1736
+ theme={theme}
1737
+ width={Math.max(0, width)}
1738
+ busy={visibleBusy}
1739
+ mode={settings.workingRuleAnimation}
1740
+ role="headerBottom"
1741
+ />
1742
+ <scrollbox
1743
+ key={activeAgentId ?? "main"}
1744
+ style={{ flexGrow: 1, paddingLeft: 1, paddingRight: 1 }}
1745
+ stickyScroll
1746
+ stickyStart="bottom"
1747
+ verticalScrollbarOptions={{ visible: true }}
1748
+ >
1749
+ {visibleTx.lines.map((line, i) => {
1750
+ const workingCaret = visibleBusy && !visibleTx.stream && i === visibleTx.lines.length - 1;
1751
+ const row =
1752
+ line.kind === "tool" ? (
1753
+ <ToolLine theme={theme} call={line.call} workingCaret={workingCaret} />
1754
+ ) : line.kind === "agent-message" ? (
1755
+ <AgentMessageLine theme={theme} syntaxStyle={syntaxStyle} line={line} />
1756
+ ) : (
1757
+ <TextLine
1758
+ theme={theme}
1759
+ syntaxStyle={syntaxStyle}
1760
+ role={line.role as Role}
1761
+ text={line.text}
1762
+ workingCaret={workingCaret}
1763
+ />
1764
+ );
1765
+ const gapBefore = needsTranscriptGap(visibleTx.lines[i - 1], line);
1766
+ const lineKey =
1767
+ line.kind === "tool"
1768
+ ? `tool:${line.call.id}`
1769
+ : line.kind === "agent-message"
1770
+ ? `agent:${line.sender}:${line.recipient}:${i}:${line.text}`
1771
+ : `text:${line.role}:${i}:${line.text}`;
1772
+ return (
1773
+ <Fragment key={lineKey}>
1774
+ {gapBefore ? <Gap /> : null}
1775
+ {row}
1776
+ </Fragment>
1777
+ );
1778
+ })}
1779
+ {visibleTx.stream ? (
1780
+ <>
1781
+ {/* Same gap while the answer is still arriving, so it does not
1782
+ jump down a row when the message settles. */}
1783
+ {streamGap ? <Gap /> : null}
1784
+ <StreamLine
1785
+ theme={theme}
1786
+ syntaxStyle={syntaxStyle}
1787
+ role={visibleTx.stream.kind}
1788
+ text={visibleTx.stream.text}
1789
+ />
1790
+ </>
1791
+ ) : null}
1792
+ {visibleTx.pending.some((pending) => !pending.delivered) ? (
1793
+ <>
1794
+ {(visibleTx.lines.length > 0 || visibleTx.stream) ? <Gap /> : null}
1795
+ {visibleTx.pending.filter((pending) => !pending.delivered).map((pending) => (
1796
+ <PendingMessageLine
1797
+ key={pending.id}
1798
+ theme={theme}
1799
+ syntaxStyle={syntaxStyle}
1800
+ pending={pending}
1801
+ />
1802
+ ))}
1803
+ </>
1804
+ ) : null}
1805
+ </scrollbox>
1806
+ <WorkingRule
1807
+ theme={theme}
1808
+ width={Math.max(0, width)}
1809
+ busy={visibleBusy}
1810
+ dimmed={stashOpen}
1811
+ mode={settings.workingRuleAnimation}
1812
+ role="inputTop"
1813
+ />
1814
+ {stashOpen ? (
1815
+ <PromptStash
1816
+ theme={theme}
1817
+ prompts={stash}
1818
+ cursor={stashCursor}
1819
+ selectedIndices={stashSelection}
1820
+ height={height}
1821
+ />
1822
+ ) : null}
1823
+ {commandSuggestions.length > 0 ? (
1824
+ <box
1825
+ style={{
1826
+ height: commandSuggestions.length,
1827
+ flexShrink: 0,
1828
+ flexDirection: "column",
1829
+ }}
1830
+ >
1831
+ {commandSuggestions.map((command, index) => {
1832
+ const highlighted = index === Math.min(commandCursor, commandSuggestions.length - 1);
1833
+ return (
1834
+ <box key={command.name} style={{ height: 1, flexShrink: 0, flexDirection: "row" }}>
1835
+ <box style={{ width: 2, flexShrink: 0 }}>
1836
+ {highlighted ? <text content="❯ " fg={theme.accent} /> : null}
1837
+ </box>
1838
+ <text
1839
+ content={`${command.name} — ${command.description}`}
1840
+ fg={highlighted ? theme.fg : theme.dim}
1841
+ wrapMode="none"
1842
+ style={{ flexGrow: 1, minWidth: 0 }}
1843
+ />
1844
+ </box>
1845
+ );
1846
+ })}
1847
+ </box>
1848
+ ) : null}
1849
+ <box
1850
+ style={{
1851
+ flexDirection: "row",
1852
+ width: "100%",
1853
+ height: inputRows,
1854
+ flexShrink: 0,
1855
+ }}
1856
+ >
1857
+ <box
1858
+ style={{
1859
+ flexDirection: "column",
1860
+ width: 2,
1861
+ height: inputRows,
1862
+ flexShrink: 0,
1863
+ }}
1864
+ >
1865
+ {Array.from({ length: inputRows }, (_, row) => (
1866
+ <box key={row} style={{ width: 2, height: 1, flexShrink: 0 }}>
1867
+ {commandSuggestions.length === 0 && row === inputCursorRow
1868
+ ? <text content="❯ " fg={theme.accent} />
1869
+ : null}
1870
+ </box>
1871
+ ))}
1872
+ </box>
1873
+ <textarea
1874
+ ref={inputRef}
1875
+ placeholder={promptPlaceholder({
1876
+ activeAgentName: activeAgent?.name,
1877
+ busy: visibleBusy,
1878
+ stashOpen,
1879
+ })}
1880
+ placeholderColor={theme.dim}
1881
+ textColor={theme.fg}
1882
+ cursorColor={theme.accent}
1883
+ selectionBg={theme.selectionBg}
1884
+ wrapMode="char"
1885
+ scrollMargin={1}
1886
+ focused={!settingsOpen && !helpOpen && !historyOpen && !agentSelectorOpen && !loginOpen}
1887
+ onContentChange={handleTextareaChange}
1888
+ onCursorChange={scheduleInputMetrics}
1889
+ onSubmit={() => submitPrompt()}
1890
+ style={{ width: promptInputColumns, flexShrink: 0, minWidth: 0, height: inputRows }}
1891
+ />
1892
+ {/* Reserve six columns on normal terminals. This forces wrapping
1893
+ before cursor movement can briefly overdraw the terminal edge. */}
1894
+ <box style={{ width: promptRightColumns, height: inputRows, flexShrink: 0 }} />
1895
+ {inputHint ? <text content={inputHint} fg={theme.warn} /> : null}
1896
+ </box>
1897
+ <WorkingRule
1898
+ theme={theme}
1899
+ width={Math.max(0, width)}
1900
+ busy={visibleBusy}
1901
+ dimmed={stashOpen}
1902
+ mode={settings.workingRuleAnimation}
1903
+ role="inputBottom"
1904
+ />
1905
+ {loginOpen ? (
1906
+ <LoginPopup theme={theme} page={loginPage} terminalWidth={width} terminalHeight={height} />
1907
+ ) : null}
1908
+ {helpOpen ? (
1909
+ <HelpPopup
1910
+ theme={theme}
1911
+ terminalWidth={width}
1912
+ terminalHeight={height}
1913
+ scrollOffset={helpScrollOffset}
1914
+ />
1915
+ ) : null}
1916
+ {agentSelectorOpen ? (
1917
+ <AgentSelectorPopup
1918
+ theme={theme}
1919
+ rows={agentTreeRows}
1920
+ cursor={agentSelectorCursor}
1921
+ />
1922
+ ) : null}
1923
+ {historyOpen ? (
1924
+ <SessionHistoryPopup
1925
+ theme={theme}
1926
+ sessions={historySessions}
1927
+ onSelect={selectHistorySession}
1928
+ />
1929
+ ) : null}
1930
+ {settingsOpen ? (
1931
+ <SettingsPopup
1932
+ theme={theme}
1933
+ page={page}
1934
+ rows={visibleSettingRows}
1935
+ selectedId={selectedSettingId}
1936
+ values={rowValues}
1937
+ query={settingsQuery}
1938
+ searchFocused={settingsSearchFocused}
1939
+ terminalWidth={width}
1940
+ terminalHeight={height}
1941
+ models={visibleModels}
1942
+ modelQuery={modelQuery}
1943
+ modelSearchFocused={modelSearchFocused}
1944
+ onSearchChange={updateSettingsQuery}
1945
+ onModelSearchChange={setModelQuery}
1946
+ onSelectModel={selectModel}
1947
+ onSelectCheckModel={selectCheckModel}
1948
+ />
1949
+ ) : null}
1950
+ </box>
1951
+ </AnimationProvider>
1952
+ );
1953
+ }