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/LICENSE +21 -0
- package/README.md +196 -0
- package/package.json +69 -0
- package/src/agent-selector.tsx +217 -0
- package/src/agent-usage.ts +93 -0
- package/src/animation.tsx +476 -0
- package/src/app.tsx +1953 -0
- package/src/apply-patch.ts +583 -0
- package/src/cancel-confirmation.ts +14 -0
- package/src/check-mode.ts +630 -0
- package/src/commands.ts +45 -0
- package/src/config.ts +24 -0
- package/src/explanation-strength.ts +47 -0
- package/src/git-branch.ts +54 -0
- package/src/help-popup.tsx +279 -0
- package/src/history.ts +57 -0
- package/src/image-paste.ts +204 -0
- package/src/index.tsx +133 -0
- package/src/login-controller.ts +267 -0
- package/src/login-flow.ts +170 -0
- package/src/login-popup.tsx +154 -0
- package/src/platform.ts +94 -0
- package/src/prompt-stash.ts +130 -0
- package/src/replay.ts +188 -0
- package/src/session-history-popup.tsx +68 -0
- package/src/settings-popup.tsx +283 -0
- package/src/settings.ts +81 -0
- package/src/shutdown.ts +23 -0
- package/src/stash-batch.ts +28 -0
- package/src/status-bar.tsx +143 -0
- package/src/status-metadata.ts +110 -0
- package/src/subagents/manager.ts +1196 -0
- package/src/subagents/types.ts +86 -0
- package/src/syntax.ts +60 -0
- package/src/theme.ts +346 -0
- package/src/tool-line.ts +72 -0
- package/src/transcript.tsx +393 -0
- package/src/web-search.ts +157 -0
- package/src/worktree-command.ts +39 -0
- package/src/worktree.ts +219 -0
- package/src/writing-style.ts +54 -0
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
|
+
}
|