surfcode 0.0.0-stage → 0.1.1
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 +325 -2
- package/assets/surfcode-logo.gif +0 -0
- package/dist/cli/auth.js +315 -0
- package/dist/cli/auth.js.map +1 -0
- package/dist/cli/config.js +228 -0
- package/dist/cli/config.js.map +1 -0
- package/dist/cli/headless.js +355 -0
- package/dist/cli/headless.js.map +1 -0
- package/dist/cli/inspect.js +462 -0
- package/dist/cli/inspect.js.map +1 -0
- package/dist/cli/interactive.js +130 -0
- package/dist/cli/interactive.js.map +1 -0
- package/dist/cli/sessions.js +170 -0
- package/dist/cli/sessions.js.map +1 -0
- package/dist/cli/sync.js +190 -0
- package/dist/cli/sync.js.map +1 -0
- package/dist/cli/util.js +345 -0
- package/dist/cli/util.js.map +1 -0
- package/dist/cli.js +485 -0
- package/dist/cli.js.map +1 -0
- package/dist/core/agent.js +298 -0
- package/dist/core/agent.js.map +1 -0
- package/dist/core/agents.js +55 -0
- package/dist/core/agents.js.map +1 -0
- package/dist/core/auth.js +107 -0
- package/dist/core/auth.js.map +1 -0
- package/dist/core/catalog.js +306 -0
- package/dist/core/catalog.js.map +1 -0
- package/dist/core/chat/anthropic.js +222 -0
- package/dist/core/chat/anthropic.js.map +1 -0
- package/dist/core/chat/common.js +367 -0
- package/dist/core/chat/common.js.map +1 -0
- package/dist/core/chat/google.js +180 -0
- package/dist/core/chat/google.js.map +1 -0
- package/dist/core/chat/index.js +80 -0
- package/dist/core/chat/index.js.map +1 -0
- package/dist/core/chat/openai.js +164 -0
- package/dist/core/chat/openai.js.map +1 -0
- package/dist/core/chat/types.js +2 -0
- package/dist/core/chat/types.js.map +1 -0
- package/dist/core/clipboard.js +34 -0
- package/dist/core/clipboard.js.map +1 -0
- package/dist/core/compact.js +39 -0
- package/dist/core/compact.js.map +1 -0
- package/dist/core/config.js +211 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/delegate.js +184 -0
- package/dist/core/delegate.js.map +1 -0
- package/dist/core/effort.js +80 -0
- package/dist/core/effort.js.map +1 -0
- package/dist/core/errors.js +32 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/free.js +21 -0
- package/dist/core/free.js.map +1 -0
- package/dist/core/github.js +445 -0
- package/dist/core/github.js.map +1 -0
- package/dist/core/paths.js +51 -0
- package/dist/core/paths.js.map +1 -0
- package/dist/core/prompt.js +95 -0
- package/dist/core/prompt.js.map +1 -0
- package/dist/core/provider.js +298 -0
- package/dist/core/provider.js.map +1 -0
- package/dist/core/session.js +159 -0
- package/dist/core/session.js.map +1 -0
- package/dist/core/sync.js +126 -0
- package/dist/core/sync.js.map +1 -0
- package/dist/core/types.js +2 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/usage.js +127 -0
- package/dist/core/usage.js.map +1 -0
- package/dist/core/workspace.js +70 -0
- package/dist/core/workspace.js.map +1 -0
- package/dist/data/models.json +1 -0
- package/dist/sync/server.js +179 -0
- package/dist/sync/server.js.map +1 -0
- package/dist/tools/diff.js +21 -0
- package/dist/tools/diff.js.map +1 -0
- package/dist/tools/files.js +227 -0
- package/dist/tools/files.js.map +1 -0
- package/dist/tools/github.js +238 -0
- package/dist/tools/github.js.map +1 -0
- package/dist/tools/index.js +34 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/search.js +231 -0
- package/dist/tools/search.js.map +1 -0
- package/dist/tools/shell.js +118 -0
- package/dist/tools/shell.js.map +1 -0
- package/dist/tools/todos.js +50 -0
- package/dist/tools/todos.js.map +1 -0
- package/dist/tools/webfetch.js +108 -0
- package/dist/tools/webfetch.js.map +1 -0
- package/dist/tui/App.js +2984 -0
- package/dist/tui/App.js.map +1 -0
- package/dist/tui/ansi.js +151 -0
- package/dist/tui/ansi.js.map +1 -0
- package/dist/tui/commands.js +61 -0
- package/dist/tui/commands.js.map +1 -0
- package/dist/tui/explorer.js +152 -0
- package/dist/tui/explorer.js.map +1 -0
- package/dist/tui/icons.js +21 -0
- package/dist/tui/icons.js.map +1 -0
- package/dist/tui/layout.js +324 -0
- package/dist/tui/layout.js.map +1 -0
- package/dist/tui/markdown.js +355 -0
- package/dist/tui/markdown.js.map +1 -0
- package/dist/tui/overlays.js +316 -0
- package/dist/tui/overlays.js.map +1 -0
- package/dist/tui/render.js +234 -0
- package/dist/tui/render.js.map +1 -0
- package/dist/tui/theme.js +148 -0
- package/dist/tui/theme.js.map +1 -0
- package/dist/tui/types.js +73 -0
- package/dist/tui/types.js.map +1 -0
- package/dist/version.js +3 -0
- package/dist/version.js.map +1 -0
- package/package.json +75 -4
package/dist/tui/App.js
ADDED
|
@@ -0,0 +1,2984 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { Box, Text, useApp, useInput, usePaste, useStdout } from 'ink';
|
|
5
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
6
|
+
import { glob } from 'tinyglobby';
|
|
7
|
+
import { runAgent } from '../core/agent.js';
|
|
8
|
+
import { parseWorkerRef, resolveAgentWorkers, workerLabel } from '../core/agents.js';
|
|
9
|
+
import { credentialStatus, envNameFor, removeProviderKey, saveProviderKey } from '../core/auth.js';
|
|
10
|
+
import { readClipboard } from '../core/clipboard.js';
|
|
11
|
+
import { allModels, findModel, providerById, scoreModel } from '../core/catalog.js';
|
|
12
|
+
import { streamChat } from '../core/chat/index.js';
|
|
13
|
+
import { compactConversation } from '../core/compact.js';
|
|
14
|
+
import { updateConfig, updateConfigPath } from '../core/config.js';
|
|
15
|
+
import { EFFORT_SETTINGS, activeEffort, effortLabel, parseEffort } from '../core/effort.js';
|
|
16
|
+
import { SurfError, errorMessage } from '../core/errors.js';
|
|
17
|
+
import { FREE_API_LABEL, hasFreeApi } from '../core/free.js';
|
|
18
|
+
import { githubTokenStatus, githubViewer } from '../core/github.js';
|
|
19
|
+
import { providerHasEndpoint, resolveProvider, resolveSelection } from '../core/provider.js';
|
|
20
|
+
import { addSessionUsage, createSession, listSessions, loadSession, saveSession } from '../core/session.js';
|
|
21
|
+
import { resolveSyncSettings, syncSessions } from '../core/sync.js';
|
|
22
|
+
import { emptyUsage, estimateContextTokens, formatCost, formatPrice, formatTokens } from '../core/usage.js';
|
|
23
|
+
import { createTools } from '../tools/index.js';
|
|
24
|
+
import { shellTool } from '../tools/shell.js';
|
|
25
|
+
import { bg, fit } from './ansi.js';
|
|
26
|
+
import { COMMANDS } from './commands.js';
|
|
27
|
+
import { EXPLORER_DOCK_GAP, clampExplorerWidth, explorerDockWidth, explorerParent, isExplorerDirectory, listExplorer, readExplorerPreview } from './explorer.js';
|
|
28
|
+
import { BRAND_FRAME_MS, BRAND_WIDTH, brandTick, renderBrand, renderHero, renderInput, renderStatus } from './layout.js';
|
|
29
|
+
import { renderBtwOverlay, renderConnectInfo, renderExplorer, renderGithubInfo, renderHelpOverlay, renderPermission, renderPicker, renderSuggestions, renderThemePicker } from './overlays.js';
|
|
30
|
+
import { renderLive, renderTimeline } from './render.js';
|
|
31
|
+
import { getTheme, nextTheme, THEMES } from './theme.js';
|
|
32
|
+
import { timelineFromMessages } from './types.js';
|
|
33
|
+
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
34
|
+
const EMPTY_SUGGESTION_LIMIT = 6;
|
|
35
|
+
const NON_CHAT_MODEL = /(embed|embedding|tts|whisper|audio|speech|rerank|moderation|upscal|dall-e|stable-diffusion|flux|sora|veo|imagen|nano-banana|computer-use|realtime)/i;
|
|
36
|
+
const PROVIDER_PRIORITY = new Map([
|
|
37
|
+
'anthropic',
|
|
38
|
+
'openai',
|
|
39
|
+
'google',
|
|
40
|
+
'openrouter',
|
|
41
|
+
'deepseek',
|
|
42
|
+
'xai',
|
|
43
|
+
'mistral',
|
|
44
|
+
'groq',
|
|
45
|
+
'cerebras',
|
|
46
|
+
'togetherai',
|
|
47
|
+
'vercel',
|
|
48
|
+
'github-copilot',
|
|
49
|
+
'azure',
|
|
50
|
+
'amazon-bedrock',
|
|
51
|
+
'google-vertex',
|
|
52
|
+
'moonshotai',
|
|
53
|
+
'zhipuai',
|
|
54
|
+
'alibaba',
|
|
55
|
+
'minimax',
|
|
56
|
+
'cohere',
|
|
57
|
+
'perplexity',
|
|
58
|
+
'fireworks-ai',
|
|
59
|
+
'deepinfra',
|
|
60
|
+
'ollama-cloud'
|
|
61
|
+
].map((id, index) => [id, index]));
|
|
62
|
+
export default function App({ cwd, config: initialConfig, catalog, session: initialSession, initialNotice, initialPrompt, onExit }) {
|
|
63
|
+
const { exit } = useApp();
|
|
64
|
+
const { stdout } = useStdout();
|
|
65
|
+
const terminal = stdout;
|
|
66
|
+
const [size, setSize] = useState(() => ({
|
|
67
|
+
columns: Math.max(20, terminal.columns ?? 80),
|
|
68
|
+
rows: Math.max(8, terminal.rows ?? 24)
|
|
69
|
+
}));
|
|
70
|
+
const [config, setConfig] = useState(initialConfig);
|
|
71
|
+
const configRef = useRef(config);
|
|
72
|
+
configRef.current = config;
|
|
73
|
+
const [session, setSession] = useState(initialSession);
|
|
74
|
+
const sessionRef = useRef(session);
|
|
75
|
+
sessionRef.current = session;
|
|
76
|
+
const [themeName, setThemeName] = useState(initialConfig.theme);
|
|
77
|
+
const theme = getTheme(themeName);
|
|
78
|
+
const [timeline, setTimeline] = useState(() => {
|
|
79
|
+
const items = timelineFromMessages(initialSession.messages);
|
|
80
|
+
if (initialNotice) {
|
|
81
|
+
items.push({
|
|
82
|
+
id: `notice_initial_${Date.now()}`,
|
|
83
|
+
type: 'notice',
|
|
84
|
+
content: initialNotice,
|
|
85
|
+
level: 'info'
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return items;
|
|
89
|
+
});
|
|
90
|
+
const [input, setInput] = useState('');
|
|
91
|
+
// Kept in sync synchronously so a fast type-then-Enter sequence submits the
|
|
92
|
+
// character that was just typed, before React has committed the render.
|
|
93
|
+
const inputRef = useRef(input);
|
|
94
|
+
inputRef.current = input;
|
|
95
|
+
const [cursor, setCursor] = useState(0);
|
|
96
|
+
const cursorRef = useRef(cursor);
|
|
97
|
+
cursorRef.current = cursor;
|
|
98
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
99
|
+
const [suggestionIndex, setSuggestionIndex] = useState(0);
|
|
100
|
+
const [suggestionsDismissed, setSuggestionsDismissed] = useState(false);
|
|
101
|
+
const [overlay, setOverlay] = useState(null);
|
|
102
|
+
const overlayRef = useRef(overlay);
|
|
103
|
+
overlayRef.current = overlay;
|
|
104
|
+
const [explorer, setExplorer] = useState({
|
|
105
|
+
selected: 0,
|
|
106
|
+
expanded: [],
|
|
107
|
+
entries: [],
|
|
108
|
+
loading: true
|
|
109
|
+
});
|
|
110
|
+
const explorerRef = useRef(explorer);
|
|
111
|
+
explorerRef.current = explorer;
|
|
112
|
+
const [explorerFocused, setExplorerFocused] = useState(false);
|
|
113
|
+
const explorerFocusedRef = useRef(explorerFocused);
|
|
114
|
+
explorerFocusedRef.current = explorerFocused;
|
|
115
|
+
const explorerWidthTimer = useRef(null);
|
|
116
|
+
const [activity, setActivity] = useState(null);
|
|
117
|
+
const activityRef = useRef(activity);
|
|
118
|
+
activityRef.current = activity;
|
|
119
|
+
const [live, setLive] = useState(null);
|
|
120
|
+
const liveRef = useRef(null);
|
|
121
|
+
const abortRef = useRef(null);
|
|
122
|
+
const permissionsRef = useRef(null);
|
|
123
|
+
const [btw, setBtw] = useState(null);
|
|
124
|
+
const btwRef = useRef(null);
|
|
125
|
+
const btwAbortRef = useRef(null);
|
|
126
|
+
const btwCounter = useRef(0);
|
|
127
|
+
const [runUsage, setRunUsage] = useState(() => emptyUsage());
|
|
128
|
+
const [credentials, setCredentials] = useState(new Map());
|
|
129
|
+
const [githubAuth, setGithubAuth] = useState(null);
|
|
130
|
+
const [sessions, setSessions] = useState([]);
|
|
131
|
+
const [files, setFiles] = useState([]);
|
|
132
|
+
const filesLoaded = useRef(false);
|
|
133
|
+
const filesLoading = useRef(false);
|
|
134
|
+
const [history, setHistory] = useState([]);
|
|
135
|
+
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
136
|
+
const historyDraft = useRef('');
|
|
137
|
+
const [frame, setFrame] = useState(0);
|
|
138
|
+
const [logoAnimating, setLogoAnimating] = useState(true);
|
|
139
|
+
const [stopping, setStopping] = useState(false);
|
|
140
|
+
const [ctrlCArmed, setCtrlCArmed] = useState(false);
|
|
141
|
+
const ctrlCTimer = useRef(null);
|
|
142
|
+
const resizeRepaint = useRef(null);
|
|
143
|
+
const quitting = useRef(false);
|
|
144
|
+
const noticeCounter = useRef(0);
|
|
145
|
+
const syncInFlight = useRef(false);
|
|
146
|
+
const busy = activity !== null;
|
|
147
|
+
const btwRunning = Boolean(btw && !btw.done);
|
|
148
|
+
const spinner = SPINNER[frame % SPINNER.length] ?? '⠋';
|
|
149
|
+
const activeProviderId = config.provider;
|
|
150
|
+
const activeModelId = config.model;
|
|
151
|
+
const activeProvider = activeProviderId ? providerById(catalog, activeProviderId) : undefined;
|
|
152
|
+
const activeModelMeta = activeProviderId && activeModelId ? findModel(catalog, activeProviderId, activeModelId) : undefined;
|
|
153
|
+
const contextWindow = activeModelMeta?.limit?.context ?? 128_000;
|
|
154
|
+
const activeModelName = activeModelMeta?.name ?? activeModelId ?? '';
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
const onResize = () => {
|
|
157
|
+
setSize({
|
|
158
|
+
columns: Math.max(20, terminal.columns ?? 80),
|
|
159
|
+
rows: Math.max(8, terminal.rows ?? 24)
|
|
160
|
+
});
|
|
161
|
+
// A direct brand paint can land on the old bottom-right cell while the
|
|
162
|
+
// terminal is resizing. Repaint once the new geometry has settled.
|
|
163
|
+
if (resizeRepaint.current)
|
|
164
|
+
clearTimeout(resizeRepaint.current);
|
|
165
|
+
resizeRepaint.current = setTimeout(() => setFrame((value) => value + 1), 350);
|
|
166
|
+
};
|
|
167
|
+
terminal.on('resize', onResize);
|
|
168
|
+
return () => {
|
|
169
|
+
terminal.off('resize', onResize);
|
|
170
|
+
if (resizeRepaint.current)
|
|
171
|
+
clearTimeout(resizeRepaint.current);
|
|
172
|
+
};
|
|
173
|
+
}, [terminal]);
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
if (!busy && !btwRunning)
|
|
176
|
+
return;
|
|
177
|
+
// When live streaming is off we only need to advance the spinner and tool
|
|
178
|
+
// state, so repaint slowly. Streaming mode samples deltas faster.
|
|
179
|
+
const interval = config.streaming ? 45 : 120;
|
|
180
|
+
const timer = setInterval(() => setFrame((value) => value + 1), interval);
|
|
181
|
+
return () => clearInterval(timer);
|
|
182
|
+
}, [btwRunning, busy, config.streaming]);
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
if (!logoAnimating)
|
|
185
|
+
return;
|
|
186
|
+
const timer = setInterval(() => setFrame((value) => value + 1), 80);
|
|
187
|
+
const stop = setTimeout(() => setLogoAnimating(false), 3_400);
|
|
188
|
+
return () => {
|
|
189
|
+
clearInterval(timer);
|
|
190
|
+
clearTimeout(stop);
|
|
191
|
+
};
|
|
192
|
+
}, [logoAnimating]);
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
// The status bar is one Ink text node. Re-rendering the whole TUI just to
|
|
195
|
+
// advance the corner animation is expensive, so idle animation repaints
|
|
196
|
+
// only the final cells directly and leaves the React tree alone. While a
|
|
197
|
+
// turn is active the regular frame loop already repaints the brand.
|
|
198
|
+
if (!terminal.isTTY || busy || btwRunning)
|
|
199
|
+
return;
|
|
200
|
+
const timer = setInterval(() => {
|
|
201
|
+
const columns = terminal.columns ?? size.columns;
|
|
202
|
+
const rows = terminal.rows ?? size.rows;
|
|
203
|
+
if (columns !== size.columns || rows !== size.rows)
|
|
204
|
+
return;
|
|
205
|
+
const brand = renderBrand(theme, brandTick());
|
|
206
|
+
const painted = theme.background ? bg(theme.background, brand) : brand;
|
|
207
|
+
terminal.write(`\x1b7\x1b[${rows};${Math.max(1, columns - BRAND_WIDTH + 1)}H${painted}\x1b8`);
|
|
208
|
+
}, BRAND_FRAME_MS);
|
|
209
|
+
return () => clearInterval(timer);
|
|
210
|
+
}, [btwRunning, busy, size.columns, size.rows, terminal, theme]);
|
|
211
|
+
useEffect(() => () => {
|
|
212
|
+
if (ctrlCTimer.current)
|
|
213
|
+
clearTimeout(ctrlCTimer.current);
|
|
214
|
+
btwAbortRef.current?.abort();
|
|
215
|
+
}, []);
|
|
216
|
+
const refreshCredentials = useCallback(async () => {
|
|
217
|
+
try {
|
|
218
|
+
const statuses = await credentialStatus(catalog.providers, configRef.current);
|
|
219
|
+
setCredentials(new Map(statuses.map((status) => [status.provider.id, status])));
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
// Credential display is best-effort.
|
|
223
|
+
}
|
|
224
|
+
}, [catalog]);
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
void refreshCredentials();
|
|
227
|
+
}, [refreshCredentials]);
|
|
228
|
+
const refreshGithub = useCallback(async () => {
|
|
229
|
+
try {
|
|
230
|
+
setGithubAuth(await githubTokenStatus());
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
// GitHub is optional; a broken auth store should not block the TUI.
|
|
234
|
+
}
|
|
235
|
+
}, []);
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
void refreshGithub();
|
|
238
|
+
}, [refreshGithub]);
|
|
239
|
+
const loadFiles = useCallback(() => {
|
|
240
|
+
if (filesLoaded.current || filesLoading.current)
|
|
241
|
+
return;
|
|
242
|
+
filesLoading.current = true;
|
|
243
|
+
glob('**/*', {
|
|
244
|
+
cwd,
|
|
245
|
+
absolute: false,
|
|
246
|
+
onlyFiles: true,
|
|
247
|
+
dot: false,
|
|
248
|
+
followSymbolicLinks: false,
|
|
249
|
+
deep: 10,
|
|
250
|
+
ignore: [
|
|
251
|
+
'node_modules/**',
|
|
252
|
+
'.git/**',
|
|
253
|
+
'dist/**',
|
|
254
|
+
'build/**',
|
|
255
|
+
'coverage/**',
|
|
256
|
+
'.next/**',
|
|
257
|
+
'.nuxt/**',
|
|
258
|
+
'.turbo/**',
|
|
259
|
+
'.cache/**'
|
|
260
|
+
]
|
|
261
|
+
})
|
|
262
|
+
.then((found) => {
|
|
263
|
+
setFiles(found.slice(0, 5000));
|
|
264
|
+
})
|
|
265
|
+
.catch(() => {
|
|
266
|
+
// File completion is optional.
|
|
267
|
+
})
|
|
268
|
+
.finally(() => {
|
|
269
|
+
filesLoaded.current = true;
|
|
270
|
+
filesLoading.current = false;
|
|
271
|
+
});
|
|
272
|
+
}, [cwd]);
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
// Scanning the workspace eagerly is expensive in large repos. Only pay for
|
|
275
|
+
// it once the user actually asks for a file suggestion.
|
|
276
|
+
if (!input.includes('@'))
|
|
277
|
+
return;
|
|
278
|
+
loadFiles();
|
|
279
|
+
}, [input, loadFiles]);
|
|
280
|
+
const addNotice = useCallback((content, level = 'info') => {
|
|
281
|
+
noticeCounter.current += 1;
|
|
282
|
+
setTimeline((items) => [
|
|
283
|
+
...items,
|
|
284
|
+
{
|
|
285
|
+
id: `notice_${Date.now()}_${noticeCounter.current}`,
|
|
286
|
+
type: 'notice',
|
|
287
|
+
content,
|
|
288
|
+
level
|
|
289
|
+
}
|
|
290
|
+
]);
|
|
291
|
+
setScrollOffset(0);
|
|
292
|
+
}, []);
|
|
293
|
+
const setInputValue = useCallback((value, nextCursor = value.length) => {
|
|
294
|
+
inputRef.current = value;
|
|
295
|
+
setInput(value);
|
|
296
|
+
setCursor(Math.max(0, Math.min(nextCursor, value.length)));
|
|
297
|
+
setSuggestionIndex(0);
|
|
298
|
+
setSuggestionsDismissed(false);
|
|
299
|
+
setHistoryIndex(-1);
|
|
300
|
+
}, []);
|
|
301
|
+
const clearInput = useCallback(() => {
|
|
302
|
+
setInputValue('');
|
|
303
|
+
}, [setInputValue]);
|
|
304
|
+
const updateSetting = useCallback(async (patch, notice, level = 'success') => {
|
|
305
|
+
try {
|
|
306
|
+
const next = await updateConfig(patch);
|
|
307
|
+
setConfig(next);
|
|
308
|
+
if (patch.theme)
|
|
309
|
+
setThemeName(patch.theme);
|
|
310
|
+
if (notice)
|
|
311
|
+
addNotice(notice, level);
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
addNotice(`Could not save settings: ${errorMessage(error)}`, 'error');
|
|
315
|
+
}
|
|
316
|
+
}, [addNotice]);
|
|
317
|
+
const setMode = useCallback(async (mode, announce = false) => {
|
|
318
|
+
if (configRef.current.mode === mode)
|
|
319
|
+
return;
|
|
320
|
+
const next = await updateConfig({ mode });
|
|
321
|
+
setConfig(next);
|
|
322
|
+
setSession((current) => ({ ...current, mode }));
|
|
323
|
+
sessionRef.current = { ...sessionRef.current, mode };
|
|
324
|
+
if (announce)
|
|
325
|
+
addNotice(mode === 'plan' ? 'Plan mode: read-only research.' : 'Build mode: edits enabled.', 'success');
|
|
326
|
+
}, [addNotice]);
|
|
327
|
+
const showModelPicker = useCallback((providerFilter, query = '') => {
|
|
328
|
+
setOverlay({ kind: 'model', query, selected: 0, providerFilter });
|
|
329
|
+
}, []);
|
|
330
|
+
const showProviderPicker = useCallback((purpose = 'connect') => {
|
|
331
|
+
setOverlay({ kind: 'provider', query: '', selected: 0, purpose });
|
|
332
|
+
}, []);
|
|
333
|
+
const promptForKey = useCallback((provider, next) => {
|
|
334
|
+
setOverlay({ kind: 'connect-key', provider, value: '', cursor: 0, next });
|
|
335
|
+
}, []);
|
|
336
|
+
const refreshExplorer = useCallback(async (expanded, selectPath) => {
|
|
337
|
+
try {
|
|
338
|
+
const entries = await listExplorer(cwd, expanded);
|
|
339
|
+
setExplorer((current) => {
|
|
340
|
+
const target = selectPath ?? current.entries[current.selected]?.path;
|
|
341
|
+
const found = target ? entries.findIndex((entry) => entry.path === target) : -1;
|
|
342
|
+
const selected = found >= 0 ? found : Math.max(0, Math.min(current.selected, entries.length - 1));
|
|
343
|
+
const entry = entries[selected];
|
|
344
|
+
const preview = entry?.kind === 'file' && current.preview?.path === entry.path ? current.preview : undefined;
|
|
345
|
+
return {
|
|
346
|
+
...current,
|
|
347
|
+
entries,
|
|
348
|
+
expanded,
|
|
349
|
+
selected,
|
|
350
|
+
loading: false,
|
|
351
|
+
preview,
|
|
352
|
+
previewError: undefined
|
|
353
|
+
};
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
addNotice(`Could not read the workspace: ${errorMessage(error)}`, 'error');
|
|
358
|
+
setExplorer((current) => ({ ...current, loading: false }));
|
|
359
|
+
}
|
|
360
|
+
}, [addNotice, cwd]);
|
|
361
|
+
const focusExplorer = useCallback(() => {
|
|
362
|
+
setExplorerFocused(true);
|
|
363
|
+
if (!explorerRef.current.loading) {
|
|
364
|
+
void refreshExplorer(explorerRef.current.expanded);
|
|
365
|
+
}
|
|
366
|
+
}, [refreshExplorer]);
|
|
367
|
+
const resizeExplorer = useCallback((delta) => {
|
|
368
|
+
const columns = size.columns;
|
|
369
|
+
const current = clampExplorerWidth(configRef.current.explorerWidth, columns);
|
|
370
|
+
const next = clampExplorerWidth(current + delta, columns);
|
|
371
|
+
if (next === current)
|
|
372
|
+
return;
|
|
373
|
+
configRef.current = { ...configRef.current, explorerWidth: next };
|
|
374
|
+
setConfig((value) => ({ ...value, explorerWidth: next }));
|
|
375
|
+
if (explorerWidthTimer.current)
|
|
376
|
+
clearTimeout(explorerWidthTimer.current);
|
|
377
|
+
explorerWidthTimer.current = setTimeout(() => {
|
|
378
|
+
explorerWidthTimer.current = null;
|
|
379
|
+
void updateConfig({ explorerWidth: next })
|
|
380
|
+
.then((updated) => setConfig(updated))
|
|
381
|
+
.catch((error) => addNotice(`Could not save explorer width: ${errorMessage(error)}`, 'error'));
|
|
382
|
+
}, 350);
|
|
383
|
+
}, [addNotice, size.columns]);
|
|
384
|
+
const loadExplorerPreview = useCallback(async (path) => {
|
|
385
|
+
try {
|
|
386
|
+
const preview = await readExplorerPreview(cwd, path);
|
|
387
|
+
setExplorer((current) => current.entries[current.selected]?.path === path
|
|
388
|
+
? { ...current, preview, previewError: undefined }
|
|
389
|
+
: current);
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
setExplorer((current) => current.entries[current.selected]?.path === path
|
|
393
|
+
? { ...current, preview: undefined, previewError: errorMessage(error) }
|
|
394
|
+
: current);
|
|
395
|
+
}
|
|
396
|
+
}, [cwd]);
|
|
397
|
+
useEffect(() => {
|
|
398
|
+
void refreshExplorer([]);
|
|
399
|
+
}, [refreshExplorer]);
|
|
400
|
+
useEffect(() => {
|
|
401
|
+
const entry = explorer.entries[explorer.selected];
|
|
402
|
+
if (!entry || entry.kind !== 'file' || explorer.preview?.path === entry.path)
|
|
403
|
+
return;
|
|
404
|
+
void loadExplorerPreview(entry.path);
|
|
405
|
+
}, [explorer.entries, explorer.preview?.path, explorer.selected, loadExplorerPreview]);
|
|
406
|
+
useEffect(() => {
|
|
407
|
+
if (overlay)
|
|
408
|
+
setExplorerFocused(false);
|
|
409
|
+
}, [overlay]);
|
|
410
|
+
useEffect(() => () => {
|
|
411
|
+
if (explorerWidthTimer.current)
|
|
412
|
+
clearTimeout(explorerWidthTimer.current);
|
|
413
|
+
}, []);
|
|
414
|
+
const openGithubLogin = useCallback(() => {
|
|
415
|
+
setOverlay({ kind: 'github-key', value: '', cursor: 0 });
|
|
416
|
+
}, []);
|
|
417
|
+
const submitGithubToken = useCallback(async (raw) => {
|
|
418
|
+
const token = raw.trim();
|
|
419
|
+
if (!token)
|
|
420
|
+
return;
|
|
421
|
+
setActivity({ kind: 'sync', label: 'verifying GitHub token' });
|
|
422
|
+
try {
|
|
423
|
+
const viewer = await githubViewer(token);
|
|
424
|
+
await saveProviderKey('github', token);
|
|
425
|
+
await refreshGithub();
|
|
426
|
+
setOverlay(null);
|
|
427
|
+
addNotice(`GitHub connected as ${viewer.login}. Try “create a private repo for this project and upload everything”.`, 'success');
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
430
|
+
addNotice(`GitHub token not saved: ${errorMessage(error)}`, 'error');
|
|
431
|
+
}
|
|
432
|
+
finally {
|
|
433
|
+
setActivity(null);
|
|
434
|
+
}
|
|
435
|
+
}, [addNotice, refreshGithub]);
|
|
436
|
+
const chooseModel = useCallback(async (providerId, modelId) => {
|
|
437
|
+
setOverlay(null);
|
|
438
|
+
try {
|
|
439
|
+
const next = await updateConfig({ provider: providerId, model: modelId });
|
|
440
|
+
setConfig(next);
|
|
441
|
+
setSession((current) => ({ ...current, provider: providerId, model: modelId }));
|
|
442
|
+
sessionRef.current = { ...sessionRef.current, provider: providerId, model: modelId };
|
|
443
|
+
try {
|
|
444
|
+
await resolveProvider({ catalog, config: next, providerId });
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
addNotice(describeError(error), 'warning');
|
|
448
|
+
const provider = providerById(catalog, providerId);
|
|
449
|
+
if (provider && providerHasEndpoint(provider, next)) {
|
|
450
|
+
const status = credentials.get(providerId);
|
|
451
|
+
if (!status?.configured)
|
|
452
|
+
promptForKey(provider, 'chat');
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
addNotice(`Could not switch model: ${errorMessage(error)}`, 'error');
|
|
458
|
+
}
|
|
459
|
+
}, [addNotice, catalog, credentials, promptForKey]);
|
|
460
|
+
const selectProvider = useCallback((provider) => {
|
|
461
|
+
if (!providerHasEndpoint(provider, configRef.current)) {
|
|
462
|
+
addNotice(`${provider.name} needs a custom endpoint. Add providers.${provider.id}.baseURL to your SurfCode config, then restart.`, 'warning');
|
|
463
|
+
setOverlay(null);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (!localAvailable(provider, configRef.current)) {
|
|
467
|
+
addNotice(`${provider.name} is not responding. Start the local server${provider.api ? ` at ${provider.api.replace(/\/v1\/?$/, '')}` : ''}, then reopen /provider. If it lives at a different URL, set providers.${provider.id}.baseURL in your config.`, 'warning');
|
|
468
|
+
setOverlay(null);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const status = credentials.get(provider.id);
|
|
472
|
+
if (!status?.configured) {
|
|
473
|
+
promptForKey(provider, 'models');
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (Object.keys(provider.models).length === 0) {
|
|
477
|
+
addNotice(`No models found for ${provider.name}. ${provider.local ? 'Start the local server and reopen /model.' : 'Add a model or check your endpoint.'}`, 'warning');
|
|
478
|
+
setOverlay(null);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
showModelPicker(provider.id);
|
|
482
|
+
}, [addNotice, credentials, promptForKey, showModelPicker]);
|
|
483
|
+
const openSessions = useCallback(async () => {
|
|
484
|
+
try {
|
|
485
|
+
const list = await listSessions({ limit: 100 });
|
|
486
|
+
setSessions(list);
|
|
487
|
+
setOverlay({ kind: 'sessions', query: '', selected: 0 });
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
addNotice(`Could not list sessions: ${errorMessage(error)}`, 'error');
|
|
491
|
+
}
|
|
492
|
+
}, [addNotice]);
|
|
493
|
+
const resumeSession = useCallback(async (record) => {
|
|
494
|
+
setOverlay(null);
|
|
495
|
+
try {
|
|
496
|
+
if (sessionRef.current.messages.length > 0)
|
|
497
|
+
await saveSession(sessionRef.current);
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
// Best effort.
|
|
501
|
+
}
|
|
502
|
+
setSession(record);
|
|
503
|
+
sessionRef.current = record;
|
|
504
|
+
setTimeline(timelineFromMessages(record.messages));
|
|
505
|
+
setScrollOffset(0);
|
|
506
|
+
setLive(null);
|
|
507
|
+
liveRef.current = null;
|
|
508
|
+
setActivity(null);
|
|
509
|
+
setConfig((current) => ({
|
|
510
|
+
...current,
|
|
511
|
+
provider: record.provider || current.provider,
|
|
512
|
+
model: record.model || current.model,
|
|
513
|
+
mode: record.mode
|
|
514
|
+
}));
|
|
515
|
+
if (record.provider && record.model) {
|
|
516
|
+
void updateConfig({ provider: record.provider, model: record.model, mode: record.mode }).then(setConfig);
|
|
517
|
+
}
|
|
518
|
+
}, []);
|
|
519
|
+
const quit = useCallback(async () => {
|
|
520
|
+
if (quitting.current)
|
|
521
|
+
return;
|
|
522
|
+
quitting.current = true;
|
|
523
|
+
abortRef.current?.abort();
|
|
524
|
+
const current = sessionRef.current;
|
|
525
|
+
let saved = false;
|
|
526
|
+
if (current.messages.length > 0) {
|
|
527
|
+
try {
|
|
528
|
+
await saveSession(current);
|
|
529
|
+
saved = true;
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
// Never trap the user because a save failed.
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
onExit?.(current, saved);
|
|
536
|
+
exit();
|
|
537
|
+
}, [exit, onExit]);
|
|
538
|
+
const handleCtrlC = useCallback(() => {
|
|
539
|
+
const current = overlayRef.current;
|
|
540
|
+
if (current?.kind === 'permission') {
|
|
541
|
+
setOverlay(null);
|
|
542
|
+
current.resolve('deny');
|
|
543
|
+
setStopping(true);
|
|
544
|
+
abortRef.current?.abort();
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (current?.kind === 'themes') {
|
|
548
|
+
setThemeName(current.original);
|
|
549
|
+
setOverlay(null);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
if (current) {
|
|
553
|
+
setOverlay(null);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (activityRef.current) {
|
|
557
|
+
setStopping(true);
|
|
558
|
+
abortRef.current?.abort();
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (input.length > 0) {
|
|
562
|
+
clearInput();
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (ctrlCArmed) {
|
|
566
|
+
void quit();
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
setCtrlCArmed(true);
|
|
570
|
+
if (ctrlCTimer.current)
|
|
571
|
+
clearTimeout(ctrlCTimer.current);
|
|
572
|
+
ctrlCTimer.current = setTimeout(() => setCtrlCArmed(false), 1600);
|
|
573
|
+
}, [clearInput, ctrlCArmed, input.length, quit]);
|
|
574
|
+
const answerPermission = useCallback((decision) => {
|
|
575
|
+
const current = overlayRef.current;
|
|
576
|
+
if (current?.kind !== 'permission')
|
|
577
|
+
return;
|
|
578
|
+
setOverlay(null);
|
|
579
|
+
if (decision === 'always') {
|
|
580
|
+
const permissions = permissionsRef.current;
|
|
581
|
+
if (permissions)
|
|
582
|
+
permissions[current.request.action] = 'allow';
|
|
583
|
+
void updateConfig({
|
|
584
|
+
permissions: {
|
|
585
|
+
...configRef.current.permissions,
|
|
586
|
+
[current.request.action]: 'allow'
|
|
587
|
+
}
|
|
588
|
+
}).then(setConfig).catch(() => { });
|
|
589
|
+
}
|
|
590
|
+
current.resolve(decision);
|
|
591
|
+
}, []);
|
|
592
|
+
const requestPermission = useCallback((request) => {
|
|
593
|
+
return new Promise((resolvePermission) => {
|
|
594
|
+
setOverlay({ kind: 'permission', request, resolve: resolvePermission });
|
|
595
|
+
});
|
|
596
|
+
}, []);
|
|
597
|
+
const refreshCurrentFromDisk = useCallback(async () => {
|
|
598
|
+
try {
|
|
599
|
+
const current = await loadSession(sessionRef.current.id);
|
|
600
|
+
if (!current || current.updatedAt <= sessionRef.current.updatedAt)
|
|
601
|
+
return false;
|
|
602
|
+
setSession(current);
|
|
603
|
+
sessionRef.current = current;
|
|
604
|
+
setTimeline(timelineFromMessages(current.messages));
|
|
605
|
+
setScrollOffset(0);
|
|
606
|
+
setLive(null);
|
|
607
|
+
liveRef.current = null;
|
|
608
|
+
return true;
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
return false;
|
|
612
|
+
}
|
|
613
|
+
}, []);
|
|
614
|
+
const syncNow = useCallback(async (direction = 'both', quiet = false) => {
|
|
615
|
+
const sync = resolveSyncSettings(configRef.current);
|
|
616
|
+
if (!sync.enabled) {
|
|
617
|
+
if (!quiet) {
|
|
618
|
+
addNotice('Session sync is disabled. Run `surf sync setup --url <url> --token <token>`.', 'warning');
|
|
619
|
+
}
|
|
620
|
+
return undefined;
|
|
621
|
+
}
|
|
622
|
+
if (!quiet && activityRef.current) {
|
|
623
|
+
addNotice('Finish or stop the current turn before syncing.', 'warning');
|
|
624
|
+
return undefined;
|
|
625
|
+
}
|
|
626
|
+
if (syncInFlight.current) {
|
|
627
|
+
if (!quiet)
|
|
628
|
+
addNotice('A sync is already running.', 'info');
|
|
629
|
+
return undefined;
|
|
630
|
+
}
|
|
631
|
+
syncInFlight.current = true;
|
|
632
|
+
if (!quiet)
|
|
633
|
+
setActivity({ kind: 'sync', label: 'syncing sessions' });
|
|
634
|
+
try {
|
|
635
|
+
const result = await syncSessions({ config: configRef.current, direction });
|
|
636
|
+
await updateConfigPath(['sync', 'cursor'], result.cursor);
|
|
637
|
+
// Write only the cursor and timestamp: a token supplied through the
|
|
638
|
+
// environment must never be copied into config.json.
|
|
639
|
+
const nextConfig = await updateConfigPath(['sync', 'lastSyncAt'], Date.now());
|
|
640
|
+
setConfig(nextConfig);
|
|
641
|
+
if (!quiet) {
|
|
642
|
+
const refreshed = await refreshCurrentFromDisk();
|
|
643
|
+
const suffix = refreshed ? ' · refreshed current session' : '';
|
|
644
|
+
addNotice(`Sync complete: ${result.accepted} pushed · ${result.pulled} pulled${suffix}`, 'success');
|
|
645
|
+
}
|
|
646
|
+
else if (!activityRef.current) {
|
|
647
|
+
// Background auto-sync may have received a newer copy of the open
|
|
648
|
+
// session. Only adopt it while idle, never during an active turn.
|
|
649
|
+
await refreshCurrentFromDisk();
|
|
650
|
+
}
|
|
651
|
+
return result;
|
|
652
|
+
}
|
|
653
|
+
catch (error) {
|
|
654
|
+
if (!quiet)
|
|
655
|
+
addNotice(`Sync failed: ${errorMessage(error)}`, 'error');
|
|
656
|
+
return undefined;
|
|
657
|
+
}
|
|
658
|
+
finally {
|
|
659
|
+
syncInFlight.current = false;
|
|
660
|
+
setStopping(false);
|
|
661
|
+
if (!quiet)
|
|
662
|
+
setActivity(null);
|
|
663
|
+
}
|
|
664
|
+
}, [addNotice, refreshCurrentFromDisk]);
|
|
665
|
+
const autoSync = useCallback(() => {
|
|
666
|
+
const sync = resolveSyncSettings(configRef.current);
|
|
667
|
+
if (!sync.enabled || !sync.auto)
|
|
668
|
+
return;
|
|
669
|
+
void syncNow('both', true);
|
|
670
|
+
}, [syncNow]);
|
|
671
|
+
const persistSession = useCallback(async (next) => {
|
|
672
|
+
try {
|
|
673
|
+
await saveSession(next);
|
|
674
|
+
autoSync();
|
|
675
|
+
}
|
|
676
|
+
catch (error) {
|
|
677
|
+
addNotice(`Could not save session: ${errorMessage(error)}`, 'error');
|
|
678
|
+
}
|
|
679
|
+
}, [addNotice, autoSync]);
|
|
680
|
+
const executeTurn = useCallback(async (text, selection) => {
|
|
681
|
+
const controller = new AbortController();
|
|
682
|
+
abortRef.current = controller;
|
|
683
|
+
setStopping(false);
|
|
684
|
+
const startedAt = Date.now();
|
|
685
|
+
const initialLive = {
|
|
686
|
+
content: '',
|
|
687
|
+
reasoning: '',
|
|
688
|
+
tools: [],
|
|
689
|
+
startedAt,
|
|
690
|
+
thinkingStartedAt: startedAt,
|
|
691
|
+
thinkingMs: 0,
|
|
692
|
+
step: 1,
|
|
693
|
+
phase: 'thinking'
|
|
694
|
+
};
|
|
695
|
+
liveRef.current = initialLive;
|
|
696
|
+
setLive(initialLive);
|
|
697
|
+
setRunUsage(emptyUsage());
|
|
698
|
+
setActivity({ kind: 'agent', label: 'thinking' });
|
|
699
|
+
const permissions = configRef.current.fullAccess
|
|
700
|
+
? { read: 'allow', write: 'allow', shell: 'allow', fetch: 'allow' }
|
|
701
|
+
: { ...configRef.current.permissions };
|
|
702
|
+
permissionsRef.current = permissions;
|
|
703
|
+
const historyLength = sessionRef.current.messages.length;
|
|
704
|
+
let delegates;
|
|
705
|
+
if (configRef.current.agents.enabled) {
|
|
706
|
+
if (configRef.current.agents.workers.length === 0) {
|
|
707
|
+
addNotice('Agent mode is on but has no workers. Add one with /agent add provider/model.', 'warning');
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
const resolved = await resolveAgentWorkers(catalog, configRef.current);
|
|
711
|
+
delegates = resolved.delegates;
|
|
712
|
+
if (resolved.failures.length > 0) {
|
|
713
|
+
addNotice(`Agent workers skipped:\n${resolved.failures.join('\n')}`, 'warning');
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
try {
|
|
718
|
+
const outcome = await runAgent({
|
|
719
|
+
cwd,
|
|
720
|
+
provider: selection.provider,
|
|
721
|
+
model: selection.model,
|
|
722
|
+
history: sessionRef.current.messages,
|
|
723
|
+
input: text,
|
|
724
|
+
mode: configRef.current.mode,
|
|
725
|
+
tools: createTools(),
|
|
726
|
+
permissions,
|
|
727
|
+
maxSteps: configRef.current.maxSteps,
|
|
728
|
+
maxTokens: configRef.current.maxTokens ?? Math.min(selection.model.maxOutput || 8192, 32_768),
|
|
729
|
+
temperature: configRef.current.temperature,
|
|
730
|
+
effort: activeEffort(configRef.current.effort),
|
|
731
|
+
showReasoning: configRef.current.showReasoning,
|
|
732
|
+
delegates,
|
|
733
|
+
allowOutsideWorkspace: configRef.current.fullAccess || configRef.current.allowOutsideWorkspace,
|
|
734
|
+
signal: controller.signal,
|
|
735
|
+
callbacks: {
|
|
736
|
+
onText(delta) {
|
|
737
|
+
const current = liveRef.current;
|
|
738
|
+
if (!current)
|
|
739
|
+
return;
|
|
740
|
+
current.content += delta;
|
|
741
|
+
if (current.phase !== 'writing')
|
|
742
|
+
current.phase = 'writing';
|
|
743
|
+
},
|
|
744
|
+
onReasoning(delta) {
|
|
745
|
+
const current = liveRef.current;
|
|
746
|
+
if (!current)
|
|
747
|
+
return;
|
|
748
|
+
current.reasoning += delta;
|
|
749
|
+
if (!current.content && !current.preamble)
|
|
750
|
+
current.phase = 'thinking';
|
|
751
|
+
},
|
|
752
|
+
onToolStart(call) {
|
|
753
|
+
const current = liveRef.current;
|
|
754
|
+
if (!current)
|
|
755
|
+
return;
|
|
756
|
+
if (current.content) {
|
|
757
|
+
current.preamble = current.preamble
|
|
758
|
+
? `${current.preamble}\n\n${current.content}`
|
|
759
|
+
: current.content;
|
|
760
|
+
current.content = '';
|
|
761
|
+
}
|
|
762
|
+
current.tools.push({ call, pending: true });
|
|
763
|
+
current.phase = 'tool';
|
|
764
|
+
current.activeTool = call.name;
|
|
765
|
+
},
|
|
766
|
+
onToolResult(call, result, durationMs) {
|
|
767
|
+
const current = liveRef.current;
|
|
768
|
+
if (!current)
|
|
769
|
+
return;
|
|
770
|
+
const view = current.tools.find((entry) => entry.call.id === call.id);
|
|
771
|
+
if (view) {
|
|
772
|
+
view.result = result;
|
|
773
|
+
view.durationMs = durationMs;
|
|
774
|
+
view.pending = false;
|
|
775
|
+
}
|
|
776
|
+
current.phase = 'writing';
|
|
777
|
+
current.activeTool = undefined;
|
|
778
|
+
},
|
|
779
|
+
onUsage(total) {
|
|
780
|
+
setRunUsage({ ...total });
|
|
781
|
+
},
|
|
782
|
+
onStep(step) {
|
|
783
|
+
const current = liveRef.current;
|
|
784
|
+
if (current)
|
|
785
|
+
current.step = step;
|
|
786
|
+
},
|
|
787
|
+
requestPermission
|
|
788
|
+
}
|
|
789
|
+
});
|
|
790
|
+
const newMessages = outcome.messages.slice(historyLength);
|
|
791
|
+
// Hand the streamed reply over to the timeline in a single React batch.
|
|
792
|
+
// Persisting the session awaits I/O, and if `live` were only cleared in
|
|
793
|
+
// `finally` Ink can paint a frame that shows both the finalized item and
|
|
794
|
+
// the live copy, making the assistant reply appear twice.
|
|
795
|
+
liveRef.current = null;
|
|
796
|
+
setLive(null);
|
|
797
|
+
setTimeline((items) => appendAssistant(items, newMessages, startedAt, selection.model.name));
|
|
798
|
+
const nextUsage = addSessionUsage(sessionRef.current.usage, outcome.usage);
|
|
799
|
+
const nextSession = {
|
|
800
|
+
...sessionRef.current,
|
|
801
|
+
provider: selection.provider.id,
|
|
802
|
+
model: selection.model.id,
|
|
803
|
+
mode: configRef.current.mode,
|
|
804
|
+
messages: outcome.messages,
|
|
805
|
+
usage: nextUsage
|
|
806
|
+
};
|
|
807
|
+
setSession(nextSession);
|
|
808
|
+
sessionRef.current = nextSession;
|
|
809
|
+
await persistSession(nextSession);
|
|
810
|
+
if (outcome.aborted)
|
|
811
|
+
addNotice('Stopped.', 'warning');
|
|
812
|
+
}
|
|
813
|
+
catch (error) {
|
|
814
|
+
const message = describeError(error);
|
|
815
|
+
const current = liveRef.current;
|
|
816
|
+
if (current && (current.content || current.preamble || current.reasoning || current.tools.length > 0)) {
|
|
817
|
+
noticeCounter.current += 1;
|
|
818
|
+
setTimeline((items) => [
|
|
819
|
+
...items,
|
|
820
|
+
{
|
|
821
|
+
id: `failed_${Date.now()}_${noticeCounter.current}`,
|
|
822
|
+
type: 'assistant',
|
|
823
|
+
content: current.content,
|
|
824
|
+
preamble: current.preamble,
|
|
825
|
+
reasoning: current.reasoning || undefined,
|
|
826
|
+
toolViews: current.tools
|
|
827
|
+
}
|
|
828
|
+
]);
|
|
829
|
+
}
|
|
830
|
+
addNotice(message, 'error');
|
|
831
|
+
}
|
|
832
|
+
finally {
|
|
833
|
+
abortRef.current = null;
|
|
834
|
+
permissionsRef.current = null;
|
|
835
|
+
liveRef.current = null;
|
|
836
|
+
setLive(null);
|
|
837
|
+
setStopping(false);
|
|
838
|
+
setActivity(null);
|
|
839
|
+
setRunUsage(emptyUsage());
|
|
840
|
+
}
|
|
841
|
+
}, [addNotice, catalog, cwd, persistSession, requestPermission]);
|
|
842
|
+
const startBtw = useCallback(async (question) => {
|
|
843
|
+
const text = question.trim();
|
|
844
|
+
if (!text) {
|
|
845
|
+
addNotice('Usage: /btw <question>', 'warning');
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
if (!configRef.current.provider || !configRef.current.model) {
|
|
849
|
+
addNotice('No model connected. Choose a provider before asking a side question.', 'warning');
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
let selection;
|
|
853
|
+
try {
|
|
854
|
+
selection = await resolveSelection(catalog, configRef.current, {
|
|
855
|
+
provider: configRef.current.provider,
|
|
856
|
+
model: configRef.current.model
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
catch (error) {
|
|
860
|
+
addNotice(describeError(error), 'error');
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
btwAbortRef.current?.abort();
|
|
864
|
+
const controller = new AbortController();
|
|
865
|
+
btwAbortRef.current = controller;
|
|
866
|
+
const id = btwCounter.current + 1;
|
|
867
|
+
btwCounter.current = id;
|
|
868
|
+
const state = {
|
|
869
|
+
id,
|
|
870
|
+
question: text,
|
|
871
|
+
answer: '',
|
|
872
|
+
reasoning: '',
|
|
873
|
+
startedAt: Date.now(),
|
|
874
|
+
done: false
|
|
875
|
+
};
|
|
876
|
+
btwRef.current = state;
|
|
877
|
+
setBtw(state);
|
|
878
|
+
setOverlay({ kind: 'btw' });
|
|
879
|
+
setScrollOffset(0);
|
|
880
|
+
const system = `You are SurfCode answering a quick side question about the current coding session.
|
|
881
|
+
Workspace: ${cwd}
|
|
882
|
+
Model: ${selection.provider.name} / ${selection.model.name}
|
|
883
|
+
|
|
884
|
+
This is an ephemeral /btw exchange. Use the conversation transcript as context.
|
|
885
|
+
No tools are available: do not read files, run commands, or claim that you did.
|
|
886
|
+
Answer the question directly and concisely in GitHub-flavored Markdown. This exchange will not be saved.`;
|
|
887
|
+
try {
|
|
888
|
+
const result = await streamChat({
|
|
889
|
+
provider: selection.provider,
|
|
890
|
+
model: selection.model,
|
|
891
|
+
messages: [...sessionRef.current.messages, { role: 'user', content: text }],
|
|
892
|
+
system,
|
|
893
|
+
tools: [],
|
|
894
|
+
options: {
|
|
895
|
+
signal: controller.signal,
|
|
896
|
+
maxTokens: Math.min(configRef.current.maxTokens ?? 4_096, 8_192),
|
|
897
|
+
temperature: configRef.current.temperature,
|
|
898
|
+
effort: activeEffort(configRef.current.effort),
|
|
899
|
+
showReasoning: configRef.current.showReasoning,
|
|
900
|
+
timeout: selection.provider.settings.timeout
|
|
901
|
+
},
|
|
902
|
+
callbacks: {
|
|
903
|
+
onText(delta) {
|
|
904
|
+
if (btwRef.current?.id === id)
|
|
905
|
+
btwRef.current.answer += delta;
|
|
906
|
+
},
|
|
907
|
+
onReasoning(delta) {
|
|
908
|
+
if (btwRef.current?.id === id)
|
|
909
|
+
btwRef.current.reasoning += delta;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
});
|
|
913
|
+
if (btwRef.current?.id !== id)
|
|
914
|
+
return;
|
|
915
|
+
const final = {
|
|
916
|
+
...btwRef.current,
|
|
917
|
+
answer: result.text || btwRef.current.answer,
|
|
918
|
+
reasoning: result.reasoning || btwRef.current.reasoning,
|
|
919
|
+
done: true
|
|
920
|
+
};
|
|
921
|
+
btwRef.current = final;
|
|
922
|
+
setBtw(final);
|
|
923
|
+
}
|
|
924
|
+
catch (error) {
|
|
925
|
+
if (btwRef.current?.id !== id)
|
|
926
|
+
return;
|
|
927
|
+
if (controller.signal.aborted) {
|
|
928
|
+
btwRef.current = null;
|
|
929
|
+
setBtw(null);
|
|
930
|
+
setOverlay((current) => (current?.kind === 'btw' ? null : current));
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
const final = {
|
|
934
|
+
...btwRef.current,
|
|
935
|
+
done: true,
|
|
936
|
+
error: describeError(error)
|
|
937
|
+
};
|
|
938
|
+
btwRef.current = final;
|
|
939
|
+
setBtw(final);
|
|
940
|
+
}
|
|
941
|
+
finally {
|
|
942
|
+
if (btwAbortRef.current === controller)
|
|
943
|
+
btwAbortRef.current = null;
|
|
944
|
+
}
|
|
945
|
+
}, [addNotice, catalog, cwd]);
|
|
946
|
+
const sendPrompt = useCallback(async (raw, options = {}) => {
|
|
947
|
+
const text = raw.trim();
|
|
948
|
+
if (!text)
|
|
949
|
+
return;
|
|
950
|
+
if (activityRef.current) {
|
|
951
|
+
addNotice('Still working. Press esc to stop first.', 'warning');
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
if (!configRef.current.provider || !configRef.current.model) {
|
|
955
|
+
addNotice('No model connected. Choose a provider to begin.', 'warning');
|
|
956
|
+
showProviderPicker('connect');
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
let selection;
|
|
960
|
+
try {
|
|
961
|
+
selection = await resolveSelection(catalog, configRef.current, {
|
|
962
|
+
provider: configRef.current.provider,
|
|
963
|
+
model: configRef.current.model
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
catch (error) {
|
|
967
|
+
addNotice(describeError(error), 'error');
|
|
968
|
+
const provider = configRef.current.provider
|
|
969
|
+
? providerById(catalog, configRef.current.provider)
|
|
970
|
+
: undefined;
|
|
971
|
+
if (provider && providerHasEndpoint(provider, configRef.current)) {
|
|
972
|
+
const status = credentials.get(provider.id);
|
|
973
|
+
if (!status?.configured)
|
|
974
|
+
promptForKey(provider, 'chat');
|
|
975
|
+
}
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
if (options.clear !== false)
|
|
979
|
+
clearInput();
|
|
980
|
+
setHistory((current) => [...current.filter((entry) => entry !== text).slice(-49), text]);
|
|
981
|
+
setHistoryIndex(-1);
|
|
982
|
+
setScrollOffset(0);
|
|
983
|
+
noticeCounter.current += 1;
|
|
984
|
+
setTimeline((items) => [
|
|
985
|
+
...items,
|
|
986
|
+
{ id: `user_${Date.now()}_${noticeCounter.current}`, type: 'user', content: text }
|
|
987
|
+
]);
|
|
988
|
+
void executeTurn(text, selection);
|
|
989
|
+
}, [
|
|
990
|
+
addNotice,
|
|
991
|
+
catalog,
|
|
992
|
+
clearInput,
|
|
993
|
+
credentials,
|
|
994
|
+
executeTurn,
|
|
995
|
+
promptForKey,
|
|
996
|
+
showProviderPicker
|
|
997
|
+
]);
|
|
998
|
+
const initialPromptSent = useRef(false);
|
|
999
|
+
useEffect(() => {
|
|
1000
|
+
const prompt = initialPrompt?.trim();
|
|
1001
|
+
if (!prompt || initialPromptSent.current)
|
|
1002
|
+
return;
|
|
1003
|
+
initialPromptSent.current = true;
|
|
1004
|
+
const timer = setTimeout(() => {
|
|
1005
|
+
void sendPrompt(prompt);
|
|
1006
|
+
}, 120);
|
|
1007
|
+
return () => clearTimeout(timer);
|
|
1008
|
+
}, [initialPrompt, sendPrompt]);
|
|
1009
|
+
const runShell = useCallback(async (command) => {
|
|
1010
|
+
if (activityRef.current) {
|
|
1011
|
+
addNotice('Still working. Press esc to stop first.', 'warning');
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
const controller = new AbortController();
|
|
1015
|
+
abortRef.current = controller;
|
|
1016
|
+
setStopping(false);
|
|
1017
|
+
const callId = `shell_${Date.now().toString(36)}`;
|
|
1018
|
+
const call = { id: callId, name: 'bash', arguments: { command } };
|
|
1019
|
+
noticeCounter.current += 1;
|
|
1020
|
+
const itemId = `shell_item_${noticeCounter.current}`;
|
|
1021
|
+
setTimeline((items) => [
|
|
1022
|
+
...items,
|
|
1023
|
+
{
|
|
1024
|
+
id: itemId,
|
|
1025
|
+
type: 'assistant',
|
|
1026
|
+
content: '',
|
|
1027
|
+
toolViews: [{ call, pending: true }]
|
|
1028
|
+
}
|
|
1029
|
+
]);
|
|
1030
|
+
setActivity({ kind: 'shell', label: 'running command' });
|
|
1031
|
+
setScrollOffset(0);
|
|
1032
|
+
const startedAt = Date.now();
|
|
1033
|
+
try {
|
|
1034
|
+
const result = await shellTool.execute({ command }, {
|
|
1035
|
+
cwd,
|
|
1036
|
+
signal: controller.signal,
|
|
1037
|
+
allowOutsideWorkspace: configRef.current.fullAccess || configRef.current.allowOutsideWorkspace,
|
|
1038
|
+
planMode: false,
|
|
1039
|
+
requestPermission: async () => 'allow'
|
|
1040
|
+
});
|
|
1041
|
+
const durationMs = Date.now() - startedAt;
|
|
1042
|
+
setTimeline((items) => items.map((item) => item.id === itemId
|
|
1043
|
+
? { ...item, toolViews: [{ call, result, durationMs, pending: false }] }
|
|
1044
|
+
: item));
|
|
1045
|
+
const assistant = { role: 'assistant', content: '', toolCalls: [call] };
|
|
1046
|
+
const toolMessage = {
|
|
1047
|
+
role: 'tool',
|
|
1048
|
+
toolCallId: callId,
|
|
1049
|
+
name: 'bash',
|
|
1050
|
+
content: result.output,
|
|
1051
|
+
...(result.isError ? { isError: true } : {})
|
|
1052
|
+
};
|
|
1053
|
+
const nextSession = {
|
|
1054
|
+
...sessionRef.current,
|
|
1055
|
+
messages: [...sessionRef.current.messages, assistant, toolMessage]
|
|
1056
|
+
};
|
|
1057
|
+
setSession(nextSession);
|
|
1058
|
+
sessionRef.current = nextSession;
|
|
1059
|
+
await persistSession(nextSession);
|
|
1060
|
+
}
|
|
1061
|
+
catch (error) {
|
|
1062
|
+
if (controller.signal.aborted) {
|
|
1063
|
+
setTimeline((items) => items.map((item) => item.id === itemId
|
|
1064
|
+
? {
|
|
1065
|
+
...item,
|
|
1066
|
+
toolViews: [
|
|
1067
|
+
{
|
|
1068
|
+
call,
|
|
1069
|
+
result: { output: 'Cancelled by user.', isError: true },
|
|
1070
|
+
pending: false
|
|
1071
|
+
}
|
|
1072
|
+
]
|
|
1073
|
+
}
|
|
1074
|
+
: item));
|
|
1075
|
+
addNotice('Shell command stopped.', 'warning');
|
|
1076
|
+
}
|
|
1077
|
+
else {
|
|
1078
|
+
setTimeline((items) => items.filter((item) => item.id !== itemId));
|
|
1079
|
+
addNotice(`Shell command failed: ${errorMessage(error)}`, 'error');
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
finally {
|
|
1083
|
+
abortRef.current = null;
|
|
1084
|
+
setStopping(false);
|
|
1085
|
+
setActivity(null);
|
|
1086
|
+
}
|
|
1087
|
+
}, [addNotice, cwd, persistSession]);
|
|
1088
|
+
const compact = useCallback(async () => {
|
|
1089
|
+
if (activityRef.current)
|
|
1090
|
+
return;
|
|
1091
|
+
if (sessionRef.current.messages.length < 4) {
|
|
1092
|
+
addNotice('Not enough conversation to compact yet.', 'info');
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
if (!configRef.current.provider || !configRef.current.model) {
|
|
1096
|
+
addNotice('Connect a model before compacting.', 'warning');
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
let selection;
|
|
1100
|
+
try {
|
|
1101
|
+
selection = await resolveSelection(catalog, configRef.current, {
|
|
1102
|
+
provider: configRef.current.provider,
|
|
1103
|
+
model: configRef.current.model
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
catch (error) {
|
|
1107
|
+
addNotice(describeError(error), 'error');
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
const controller = new AbortController();
|
|
1111
|
+
abortRef.current = controller;
|
|
1112
|
+
setStopping(false);
|
|
1113
|
+
setActivity({ kind: 'compact', label: 'compacting context' });
|
|
1114
|
+
try {
|
|
1115
|
+
const result = await compactConversation({
|
|
1116
|
+
provider: selection.provider,
|
|
1117
|
+
model: selection.model,
|
|
1118
|
+
messages: sessionRef.current.messages,
|
|
1119
|
+
signal: controller.signal
|
|
1120
|
+
});
|
|
1121
|
+
const summaryMessage = {
|
|
1122
|
+
role: 'assistant',
|
|
1123
|
+
content: `## Session summary\n\n${result.summary}`
|
|
1124
|
+
};
|
|
1125
|
+
const nextUsage = addSessionUsage(sessionRef.current.usage, {
|
|
1126
|
+
inputTokens: result.usage.inputTokens ?? 0,
|
|
1127
|
+
outputTokens: result.usage.outputTokens ?? 0,
|
|
1128
|
+
reasoningTokens: result.usage.reasoningTokens ?? 0,
|
|
1129
|
+
cacheReadTokens: result.usage.cacheReadTokens ?? 0,
|
|
1130
|
+
cacheWriteTokens: result.usage.cacheWriteTokens ?? 0,
|
|
1131
|
+
cost: result.usage.cost ?? 0,
|
|
1132
|
+
requests: 1
|
|
1133
|
+
});
|
|
1134
|
+
const count = sessionRef.current.messages.length;
|
|
1135
|
+
const nextSession = {
|
|
1136
|
+
...sessionRef.current,
|
|
1137
|
+
messages: [summaryMessage],
|
|
1138
|
+
usage: nextUsage
|
|
1139
|
+
};
|
|
1140
|
+
setSession(nextSession);
|
|
1141
|
+
sessionRef.current = nextSession;
|
|
1142
|
+
await persistSession(nextSession);
|
|
1143
|
+
addNotice(`Compacted ${count} messages into one summary.`, 'success');
|
|
1144
|
+
}
|
|
1145
|
+
catch (error) {
|
|
1146
|
+
if (controller.signal.aborted)
|
|
1147
|
+
addNotice('Compaction stopped.', 'warning');
|
|
1148
|
+
else
|
|
1149
|
+
addNotice(`Compaction failed: ${errorMessage(error)}`, 'error');
|
|
1150
|
+
}
|
|
1151
|
+
finally {
|
|
1152
|
+
abortRef.current = null;
|
|
1153
|
+
setStopping(false);
|
|
1154
|
+
setActivity(null);
|
|
1155
|
+
}
|
|
1156
|
+
}, [addNotice, catalog, persistSession]);
|
|
1157
|
+
const exportSession = useCallback(async (pathArg) => {
|
|
1158
|
+
try {
|
|
1159
|
+
const target = pathArg?.trim()
|
|
1160
|
+
? resolve(cwd, pathArg.trim())
|
|
1161
|
+
: join(cwd, `surfcode-${sessionRef.current.id}.md`);
|
|
1162
|
+
await mkdir(dirname(target), { recursive: true });
|
|
1163
|
+
await writeFile(target, transcriptMarkdown(sessionRef.current), { encoding: 'utf8', mode: 0o644 });
|
|
1164
|
+
const shown = relative(cwd, target) || target;
|
|
1165
|
+
addNotice(`Exported transcript to ${shown}`, 'success');
|
|
1166
|
+
}
|
|
1167
|
+
catch (error) {
|
|
1168
|
+
addNotice(`Export failed: ${errorMessage(error)}`, 'error');
|
|
1169
|
+
}
|
|
1170
|
+
}, [addNotice, cwd]);
|
|
1171
|
+
const handleAgentCommand = useCallback(async (argument) => {
|
|
1172
|
+
const parts = argument.trim().split(/\s+/).filter(Boolean);
|
|
1173
|
+
const sub = (parts[0] ?? 'status').toLowerCase();
|
|
1174
|
+
const rest = parts.slice(1).join(' ').trim();
|
|
1175
|
+
const current = configRef.current.agents;
|
|
1176
|
+
const head = `${configRef.current.provider ?? 'none'}/${configRef.current.model ?? 'none'}`;
|
|
1177
|
+
const describe = () => `Agent mode: ${current.enabled ? 'on' : 'off'}\n` +
|
|
1178
|
+
`Head: ${head}\n` +
|
|
1179
|
+
(current.workers.length
|
|
1180
|
+
? `Workers:\n${current.workers.map((worker, index) => ` ${index + 1}. ${workerLabel(worker)}`).join('\n')}`
|
|
1181
|
+
: 'Workers: none · add one with /agent add provider/model');
|
|
1182
|
+
if (sub === 'status' || sub === 'list') {
|
|
1183
|
+
addNotice(describe(), 'info');
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
if (sub === 'on' || sub === 'off') {
|
|
1187
|
+
const enabled = sub === 'on';
|
|
1188
|
+
if (enabled && current.workers.length === 0) {
|
|
1189
|
+
addNotice('Add a worker first: /agent add provider/model', 'warning');
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
await updateSetting({ agents: { ...current, enabled } }, enabled
|
|
1193
|
+
? `Agent mode on · head ${head} · ${current.workers.length} worker${current.workers.length === 1 ? '' : 's'}`
|
|
1194
|
+
: 'Agent mode off.');
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
if (sub === 'add') {
|
|
1198
|
+
const worker = parseWorkerRef(rest);
|
|
1199
|
+
if (!worker) {
|
|
1200
|
+
addNotice('Usage: /agent add provider/model', 'warning');
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
if (!providerById(catalog, worker.provider) || !findModel(catalog, worker.provider, worker.model)) {
|
|
1204
|
+
addNotice(`Unknown model reference: ${workerLabel(worker)}. Try /model to find an id.`, 'warning');
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
if (current.workers.some((entry) => entry.provider === worker.provider && entry.model === worker.model)) {
|
|
1208
|
+
addNotice(`${workerLabel(worker)} is already a worker.`, 'info');
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
if (current.workers.length >= 8) {
|
|
1212
|
+
addNotice('Agent mode supports up to 8 workers.', 'warning');
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
const workers = [...current.workers, worker];
|
|
1216
|
+
const enabled = current.enabled || workers.length === 1;
|
|
1217
|
+
await updateSetting({ agents: { enabled, workers } }, `Added ${workerLabel(worker)} as a worker${enabled ? ' · agent mode on' : ''}.`);
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
if (sub === 'remove' || sub === 'rm') {
|
|
1221
|
+
if (!rest) {
|
|
1222
|
+
addNotice('Usage: /agent remove provider/model or /agent remove 2', 'warning');
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
const index = Number(rest) - 1;
|
|
1226
|
+
const target = Number.isInteger(index) && index >= 0 && index < current.workers.length
|
|
1227
|
+
? current.workers[index]
|
|
1228
|
+
: parseWorkerRef(rest);
|
|
1229
|
+
if (!target) {
|
|
1230
|
+
addNotice(`No worker matches "${rest}".`, 'warning');
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
const workers = current.workers.filter((entry) => entry.provider !== target.provider || entry.model !== target.model);
|
|
1234
|
+
if (workers.length === current.workers.length) {
|
|
1235
|
+
addNotice(`${workerLabel(target)} is not a worker.`, 'warning');
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
await updateSetting({ agents: { enabled: workers.length > 0 && current.enabled, workers } }, `Removed ${workerLabel(target)}.`);
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
if (sub === 'clear') {
|
|
1242
|
+
await updateSetting({ agents: { enabled: false, workers: [] } }, 'Agent workers cleared.');
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
if (sub === 'head') {
|
|
1246
|
+
if (!rest) {
|
|
1247
|
+
addNotice(`Head model: ${head}\nChange it with /agent head provider/model or /model.`, 'info');
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
const direct = findDirectModel(rest, catalog, configRef.current.provider);
|
|
1251
|
+
if (!direct) {
|
|
1252
|
+
addNotice(`Unknown model reference: ${rest}.`, 'warning');
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
await chooseModel(direct.provider, direct.model);
|
|
1256
|
+
addNotice(`Head model: ${direct.provider}/${direct.model}`, 'success');
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
addNotice('Usage: /agent [status|on|off|add provider/model|remove <n|provider/model>|list|clear|head provider/model]', 'warning');
|
|
1260
|
+
}, [addNotice, catalog, chooseModel, updateSetting]);
|
|
1261
|
+
const handleGithubCommand = useCallback(async (argument) => {
|
|
1262
|
+
const trimmed = argument.trim();
|
|
1263
|
+
const sub = (trimmed.split(/\s+/)[0] || 'status').toLowerCase();
|
|
1264
|
+
if (sub === 'login') {
|
|
1265
|
+
if (trimmed !== 'login') {
|
|
1266
|
+
addNotice('For safety, run /github login with no token in the command. Paste the token into the hidden prompt instead.', 'warning');
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
openGithubLogin();
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
if (sub === 'logout') {
|
|
1273
|
+
const removed = await removeProviderKey('github');
|
|
1274
|
+
await refreshGithub();
|
|
1275
|
+
const remaining = await githubTokenStatus();
|
|
1276
|
+
addNotice(`${removed ? 'Removed the saved GitHub token.' : 'No saved GitHub token to remove.'}` +
|
|
1277
|
+
(remaining.configured
|
|
1278
|
+
? `\n${remaining.envName ?? 'An environment token'} is still configured and will keep working.`
|
|
1279
|
+
: ''), remaining.configured ? 'warning' : 'success');
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
if (sub === 'status' || trimmed === '') {
|
|
1283
|
+
const status = await githubTokenStatus();
|
|
1284
|
+
if (!status.configured) {
|
|
1285
|
+
addNotice('GitHub is not connected.\nRun /github login and paste a personal access token. The token is stored in auth.json with mode 600.\nGITHUB_TOKEN or GH_TOKEN also works.', 'info');
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
addNotice(`GitHub token: ${status.source === 'env' ? status.envName : 'auth.json'} · ${status.masked ?? 'saved'}\n` +
|
|
1289
|
+
'Ask the agent to create a private or public repo, upload the project, or commit changes.\n' +
|
|
1290
|
+
'The agent can never see the token; git uses a temporary askpass helper. Use /github logout to disconnect.', 'success');
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
addNotice('Usage: /github [status|login|logout]', 'warning');
|
|
1294
|
+
}, [addNotice, openGithubLogin, refreshGithub]);
|
|
1295
|
+
const handleCommand = useCallback(async (name, arg) => {
|
|
1296
|
+
const argument = arg.trim();
|
|
1297
|
+
switch (name) {
|
|
1298
|
+
case 'model': {
|
|
1299
|
+
if (argument) {
|
|
1300
|
+
const direct = findDirectModel(argument, catalog, configRef.current.provider);
|
|
1301
|
+
if (direct) {
|
|
1302
|
+
await chooseModel(direct.provider, direct.model);
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
showModelPicker(undefined, argument);
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
case 'connect':
|
|
1310
|
+
showProviderPicker('connect');
|
|
1311
|
+
return;
|
|
1312
|
+
case 'provider':
|
|
1313
|
+
showProviderPicker('switch');
|
|
1314
|
+
return;
|
|
1315
|
+
case 'explorer': {
|
|
1316
|
+
if (argument) {
|
|
1317
|
+
const requested = Number.parseInt(argument, 10);
|
|
1318
|
+
if (!Number.isFinite(requested)) {
|
|
1319
|
+
addNotice('Usage: /explorer [width in columns]', 'warning');
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
const next = clampExplorerWidth(requested, size.columns);
|
|
1323
|
+
await updateSetting({ explorerWidth: next }, `Explorer width: ${next} columns.`);
|
|
1324
|
+
}
|
|
1325
|
+
focusExplorer();
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
case 'options':
|
|
1329
|
+
case 'settings':
|
|
1330
|
+
setOverlay({ kind: 'settings', selected: 0 });
|
|
1331
|
+
void refreshGithub();
|
|
1332
|
+
return;
|
|
1333
|
+
case 'github':
|
|
1334
|
+
await handleGithubCommand(argument);
|
|
1335
|
+
return;
|
|
1336
|
+
case 'theme': {
|
|
1337
|
+
if (argument) {
|
|
1338
|
+
const wanted = THEMES.find((entry) => entry.name === argument.toLowerCase() || entry.label.toLowerCase() === argument.toLowerCase());
|
|
1339
|
+
if (wanted) {
|
|
1340
|
+
setThemeName(wanted.name);
|
|
1341
|
+
await updateSetting({ theme: wanted.name }, `Theme set to ${wanted.label}.`);
|
|
1342
|
+
}
|
|
1343
|
+
else {
|
|
1344
|
+
addNotice(`Unknown theme "${argument}". Try ${THEMES.map((entry) => entry.name).join(', ')}.`, 'warning');
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
else {
|
|
1348
|
+
setOverlay({ kind: 'themes', selected: Math.max(0, THEMES.findIndex((entry) => entry.name === theme.name)), original: theme.name });
|
|
1349
|
+
}
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
case 'effort': {
|
|
1353
|
+
if (!argument) {
|
|
1354
|
+
addNotice(`Reasoning effort: ${effortLabel(configRef.current.effort)}\n` +
|
|
1355
|
+
'Use /effort low, /effort medium, /effort high, or /effort auto.', 'info');
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
const level = parseEffort(argument);
|
|
1359
|
+
if (!level) {
|
|
1360
|
+
addNotice(`Unknown effort "${argument}". Use low, medium, high, or auto.`, 'warning');
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
await updateSetting({ effort: level }, `Reasoning effort: ${effortLabel(level)}.`);
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
case 'thinking':
|
|
1367
|
+
await updateSetting({ showReasoning: !configRef.current.showReasoning }, configRef.current.showReasoning ? 'Reasoning hidden.' : 'Reasoning visible.');
|
|
1368
|
+
return;
|
|
1369
|
+
case 'stream': {
|
|
1370
|
+
const normalized = argument.toLowerCase();
|
|
1371
|
+
const next = normalized === 'on' || normalized === 'true' || normalized === '1'
|
|
1372
|
+
? true
|
|
1373
|
+
: normalized === 'off' || normalized === 'false' || normalized === '0'
|
|
1374
|
+
? false
|
|
1375
|
+
: !configRef.current.streaming;
|
|
1376
|
+
await updateSetting({ streaming: next }, next
|
|
1377
|
+
? 'Live streaming on · replies paint as tokens arrive.'
|
|
1378
|
+
: 'Live streaming off · replies appear when complete. Faster and calmer.');
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
case 'access': {
|
|
1382
|
+
const normalized = argument.toLowerCase();
|
|
1383
|
+
if (!normalized) {
|
|
1384
|
+
addNotice(configRef.current.fullAccess
|
|
1385
|
+
? 'Full computer access is ON. Tool approvals are bypassed and paths outside the workspace are allowed.\nUse /access safe to turn it off.'
|
|
1386
|
+
: 'Full computer access is OFF. Tools use the normal permission policy.\nUse /access full to allow everything for this machine.', configRef.current.fullAccess ? 'warning' : 'info');
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
if (normalized !== 'full' && normalized !== 'safe') {
|
|
1390
|
+
addNotice('Usage: /access full | /access safe', 'warning');
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
const fullAccess = normalized === 'full';
|
|
1394
|
+
await updateSetting({ fullAccess }, fullAccess
|
|
1395
|
+
? 'FULL ACCESS ON · every tool action is approved automatically, including paths outside this workspace. Use /access safe to stop.'
|
|
1396
|
+
: 'Full access off · normal permissions restored.');
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
case 'agent':
|
|
1400
|
+
await handleAgentCommand(argument);
|
|
1401
|
+
return;
|
|
1402
|
+
case 'verbose':
|
|
1403
|
+
await updateSetting({ verboseTools: !configRef.current.verboseTools }, configRef.current.verboseTools ? 'Tool output collapsed.' : 'Tool output expanded.');
|
|
1404
|
+
return;
|
|
1405
|
+
case 'plan':
|
|
1406
|
+
await setMode('plan', true);
|
|
1407
|
+
return;
|
|
1408
|
+
case 'build':
|
|
1409
|
+
await setMode('build', true);
|
|
1410
|
+
return;
|
|
1411
|
+
case 'clear':
|
|
1412
|
+
setTimeline([]);
|
|
1413
|
+
setScrollOffset(0);
|
|
1414
|
+
return;
|
|
1415
|
+
case 'new': {
|
|
1416
|
+
if (sessionRef.current.messages.length > 0) {
|
|
1417
|
+
try {
|
|
1418
|
+
await saveSession(sessionRef.current);
|
|
1419
|
+
}
|
|
1420
|
+
catch {
|
|
1421
|
+
// Keep going.
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
const next = createSession({
|
|
1425
|
+
cwd,
|
|
1426
|
+
provider: configRef.current.provider ?? '',
|
|
1427
|
+
model: configRef.current.model ?? '',
|
|
1428
|
+
mode: configRef.current.mode
|
|
1429
|
+
});
|
|
1430
|
+
setSession(next);
|
|
1431
|
+
sessionRef.current = next;
|
|
1432
|
+
setTimeline([]);
|
|
1433
|
+
setScrollOffset(0);
|
|
1434
|
+
setLive(null);
|
|
1435
|
+
liveRef.current = null;
|
|
1436
|
+
setRunUsage(emptyUsage());
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
case 'sessions':
|
|
1440
|
+
await openSessions();
|
|
1441
|
+
return;
|
|
1442
|
+
case 'sync': {
|
|
1443
|
+
const direction = argument === 'push' ? 'push' : argument === 'pull' ? 'pull' : 'both';
|
|
1444
|
+
await syncNow(direction);
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
case 'stop':
|
|
1448
|
+
if (activityRef.current?.kind === 'sync') {
|
|
1449
|
+
addNotice('Sync is already finishing.', 'info');
|
|
1450
|
+
}
|
|
1451
|
+
else if (activityRef.current) {
|
|
1452
|
+
setStopping(true);
|
|
1453
|
+
abortRef.current?.abort();
|
|
1454
|
+
}
|
|
1455
|
+
else {
|
|
1456
|
+
addNotice('Nothing is running.', 'info');
|
|
1457
|
+
}
|
|
1458
|
+
return;
|
|
1459
|
+
case 'compact':
|
|
1460
|
+
await compact();
|
|
1461
|
+
return;
|
|
1462
|
+
case 'btw':
|
|
1463
|
+
await startBtw(argument);
|
|
1464
|
+
return;
|
|
1465
|
+
case 'context': {
|
|
1466
|
+
const used = estimateContextTokens(sessionRef.current.messages, '') + 600;
|
|
1467
|
+
const pct = contextWindow > 0 ? Math.round((used / contextWindow) * 100) : 0;
|
|
1468
|
+
addNotice(`Context ~${formatTokens(used)} / ${formatTokens(contextWindow)} tokens (${pct}%)\n` +
|
|
1469
|
+
`${sessionRef.current.messages.length} messages · ${activeProviderId ?? 'no provider'}/${activeModelName || 'no model'}\n` +
|
|
1470
|
+
(pct > 85 ? 'Consider /compact to free space.' : 'Plenty of room.'), pct > 85 ? 'warning' : 'info');
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
case 'cost': {
|
|
1474
|
+
const usage = sessionRef.current.usage;
|
|
1475
|
+
addNotice(`Session cost ${formatCost(usage.cost)}\n` +
|
|
1476
|
+
`${formatTokens(usage.inputTokens)} input · ${formatTokens(usage.outputTokens)} output · ${usage.requests} requests\n` +
|
|
1477
|
+
(usage.cacheReadTokens ? `${formatTokens(usage.cacheReadTokens)} cached input tokens` : formatPrice(activeModelMeta)), 'info');
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
case 'export':
|
|
1481
|
+
await exportSession(argument || undefined);
|
|
1482
|
+
return;
|
|
1483
|
+
case 'init':
|
|
1484
|
+
await sendPrompt('Analyze this repository and create a SURFCODE.md file at the workspace root. ' +
|
|
1485
|
+
'Cover the project purpose, architecture, key directories, important commands, conventions, and ' +
|
|
1486
|
+
'anything a new engineer must know. Read the actual files first; do not guess. Keep it under 150 lines.', { clear: false });
|
|
1487
|
+
return;
|
|
1488
|
+
case 'help':
|
|
1489
|
+
setOverlay({ kind: 'help' });
|
|
1490
|
+
return;
|
|
1491
|
+
case 'quit':
|
|
1492
|
+
await quit();
|
|
1493
|
+
return;
|
|
1494
|
+
default:
|
|
1495
|
+
addNotice(`Unknown command "/${name}". Try /help.`, 'warning');
|
|
1496
|
+
}
|
|
1497
|
+
}, [
|
|
1498
|
+
activeModelMeta,
|
|
1499
|
+
activeModelName,
|
|
1500
|
+
activeProviderId,
|
|
1501
|
+
addNotice,
|
|
1502
|
+
catalog,
|
|
1503
|
+
chooseModel,
|
|
1504
|
+
compact,
|
|
1505
|
+
contextWindow,
|
|
1506
|
+
cwd,
|
|
1507
|
+
exportSession,
|
|
1508
|
+
handleAgentCommand,
|
|
1509
|
+
handleGithubCommand,
|
|
1510
|
+
focusExplorer,
|
|
1511
|
+
openSessions,
|
|
1512
|
+
quit,
|
|
1513
|
+
refreshGithub,
|
|
1514
|
+
sendPrompt,
|
|
1515
|
+
setMode,
|
|
1516
|
+
showModelPicker,
|
|
1517
|
+
showProviderPicker,
|
|
1518
|
+
size.columns,
|
|
1519
|
+
startBtw,
|
|
1520
|
+
syncNow,
|
|
1521
|
+
theme.name,
|
|
1522
|
+
updateSetting
|
|
1523
|
+
]);
|
|
1524
|
+
const submit = useCallback((draft) => {
|
|
1525
|
+
const value = draft ?? inputRef.current;
|
|
1526
|
+
if (!value.trim())
|
|
1527
|
+
return;
|
|
1528
|
+
const trimmed = value.trim();
|
|
1529
|
+
if (trimmed.startsWith('/')) {
|
|
1530
|
+
const match = /^\/([a-zA-Z][\w-]*)(?:\s+([\s\S]*))?$/.exec(trimmed);
|
|
1531
|
+
if (!match) {
|
|
1532
|
+
addNotice('Commands look like /model or /help.', 'warning');
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
const name = match[1].toLowerCase();
|
|
1536
|
+
const arg = match[2] ?? '';
|
|
1537
|
+
clearInput();
|
|
1538
|
+
void handleCommand(name, arg);
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
if (trimmed.startsWith('!')) {
|
|
1542
|
+
const command = trimmed.slice(1).trim();
|
|
1543
|
+
if (!command) {
|
|
1544
|
+
addNotice('Type a shell command after !', 'warning');
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
clearInput();
|
|
1548
|
+
setHistory((current) => [...current.filter((entry) => entry !== trimmed).slice(-49), trimmed]);
|
|
1549
|
+
void runShell(command);
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
void sendPrompt(value);
|
|
1553
|
+
}, [addNotice, clearInput, handleCommand, runShell, sendPrompt]);
|
|
1554
|
+
const completeSuggestion = useCallback((suggestion) => {
|
|
1555
|
+
if (!suggestion)
|
|
1556
|
+
return;
|
|
1557
|
+
if (suggestion.kind === 'command') {
|
|
1558
|
+
setInputValue(suggestion.complete);
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
const before = input.slice(0, cursor);
|
|
1562
|
+
const tokenStart = before.lastIndexOf('@');
|
|
1563
|
+
if (tokenStart === -1)
|
|
1564
|
+
return;
|
|
1565
|
+
const after = input.slice(cursor);
|
|
1566
|
+
setInputValue(`${input.slice(0, tokenStart)}@${suggestion.complete} ${after}`);
|
|
1567
|
+
}, [cursor, input, setInputValue]);
|
|
1568
|
+
const pasteClipboard = useCallback(async (target) => {
|
|
1569
|
+
const raw = await readClipboard();
|
|
1570
|
+
if (raw === undefined) {
|
|
1571
|
+
addNotice('Could not read the clipboard. Try your terminal paste shortcut (ctrl+shift+v).', 'warning');
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
if (target === 'key') {
|
|
1575
|
+
const current = overlayRef.current;
|
|
1576
|
+
if (current?.kind !== 'connect-key' && current?.kind !== 'github-key')
|
|
1577
|
+
return;
|
|
1578
|
+
const clean = raw.replace(/[\r\n]+/g, '').replace(/[\x00-\x1f\x7f]/g, '').trim();
|
|
1579
|
+
if (!clean)
|
|
1580
|
+
return;
|
|
1581
|
+
setOverlay({
|
|
1582
|
+
...current,
|
|
1583
|
+
value: current.value.slice(0, current.cursor) + clean + current.value.slice(current.cursor),
|
|
1584
|
+
cursor: current.cursor + clean.length
|
|
1585
|
+
});
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
if (overlayRef.current)
|
|
1589
|
+
return;
|
|
1590
|
+
const clean = raw
|
|
1591
|
+
.replace(/\r\n/g, '\n')
|
|
1592
|
+
.replace(/\r/g, '\n')
|
|
1593
|
+
.replace(/\t/g, ' ')
|
|
1594
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
|
|
1595
|
+
if (!clean)
|
|
1596
|
+
return;
|
|
1597
|
+
const value = inputRef.current;
|
|
1598
|
+
const at = cursorRef.current;
|
|
1599
|
+
setInputValue(`${value.slice(0, at)}${clean}${value.slice(at)}`, at + clean.length);
|
|
1600
|
+
}, [addNotice, setInputValue]);
|
|
1601
|
+
const modelEntries = useMemo(() => {
|
|
1602
|
+
const entries = [];
|
|
1603
|
+
for (const { provider, model } of allModels(catalog)) {
|
|
1604
|
+
if (model.modalities?.output && !model.modalities.output.includes('text'))
|
|
1605
|
+
continue;
|
|
1606
|
+
if (NON_CHAT_MODEL.test(`${model.id} ${model.name}`))
|
|
1607
|
+
continue;
|
|
1608
|
+
if (!localAvailable(provider, config))
|
|
1609
|
+
continue;
|
|
1610
|
+
// Only show models from APIs the user has actually configured. Keep the
|
|
1611
|
+
// current provider visible so a transient credential refresh never makes
|
|
1612
|
+
// the active model vanish from the picker.
|
|
1613
|
+
if (!providerReady(provider, credentials, config) && provider.id !== config.provider)
|
|
1614
|
+
continue;
|
|
1615
|
+
entries.push({
|
|
1616
|
+
provider,
|
|
1617
|
+
model,
|
|
1618
|
+
score: scoreModel(model),
|
|
1619
|
+
search: `${provider.id} ${provider.name} ${model.id} ${model.name}`.toLowerCase()
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1622
|
+
entries.sort((a, b) => {
|
|
1623
|
+
const aReady = providerReady(a.provider, credentials, config) ? 1 : 0;
|
|
1624
|
+
const bReady = providerReady(b.provider, credentials, config) ? 1 : 0;
|
|
1625
|
+
if (aReady !== bReady)
|
|
1626
|
+
return bReady - aReady;
|
|
1627
|
+
const aPriority = PROVIDER_PRIORITY.get(a.provider.id) ?? 1000;
|
|
1628
|
+
const bPriority = PROVIDER_PRIORITY.get(b.provider.id) ?? 1000;
|
|
1629
|
+
if (aPriority !== bPriority)
|
|
1630
|
+
return aPriority - bPriority;
|
|
1631
|
+
if (b.score !== a.score)
|
|
1632
|
+
return b.score - a.score;
|
|
1633
|
+
return a.provider.name.localeCompare(b.provider.name) || a.model.name.localeCompare(b.model.name);
|
|
1634
|
+
});
|
|
1635
|
+
return entries;
|
|
1636
|
+
}, [catalog, config, credentials]);
|
|
1637
|
+
const modelPickerItems = useMemo(() => {
|
|
1638
|
+
if (overlay?.kind !== 'model')
|
|
1639
|
+
return [];
|
|
1640
|
+
const query = overlay.query.trim().toLowerCase();
|
|
1641
|
+
const tokens = query.split(/\s+/).filter(Boolean);
|
|
1642
|
+
return modelEntries
|
|
1643
|
+
.filter((entry) => !overlay.providerFilter || entry.provider.id === overlay.providerFilter)
|
|
1644
|
+
.filter((entry) => tokens.every((token) => entry.search.includes(token)))
|
|
1645
|
+
.slice(0, 2500)
|
|
1646
|
+
.map((entry) => {
|
|
1647
|
+
const context = entry.model.limit?.context;
|
|
1648
|
+
return {
|
|
1649
|
+
id: `${entry.provider.id}/${entry.model.id}`,
|
|
1650
|
+
label: entry.model.name || entry.model.id,
|
|
1651
|
+
badge: entry.provider.id === activeProviderId ? 'current provider' : entry.provider.id,
|
|
1652
|
+
right: context ? `${formatTokens(context)} ctx` : undefined,
|
|
1653
|
+
configured: providerReady(entry.provider, credentials, config)
|
|
1654
|
+
};
|
|
1655
|
+
});
|
|
1656
|
+
}, [activeProviderId, config, credentials, modelEntries, overlay]);
|
|
1657
|
+
const providerItems = useMemo(() => {
|
|
1658
|
+
if (overlay?.kind !== 'provider')
|
|
1659
|
+
return [];
|
|
1660
|
+
const query = overlay.query.trim().toLowerCase();
|
|
1661
|
+
const tokens = query.split(/\s+/).filter(Boolean);
|
|
1662
|
+
const providers = [...catalog.providers].sort((a, b) => {
|
|
1663
|
+
const aReady = providerReady(a, credentials, config) ? 1 : 0;
|
|
1664
|
+
const bReady = providerReady(b, credentials, config) ? 1 : 0;
|
|
1665
|
+
if (aReady !== bReady)
|
|
1666
|
+
return bReady - aReady;
|
|
1667
|
+
return a.name.localeCompare(b.name);
|
|
1668
|
+
});
|
|
1669
|
+
return providers
|
|
1670
|
+
.filter((provider) => {
|
|
1671
|
+
const haystack = `${provider.id} ${provider.name}`.toLowerCase();
|
|
1672
|
+
return tokens.every((token) => haystack.includes(token));
|
|
1673
|
+
})
|
|
1674
|
+
.slice(0, 500)
|
|
1675
|
+
.map((provider) => {
|
|
1676
|
+
const endpoint = providerHasEndpoint(provider, config);
|
|
1677
|
+
const count = Object.keys(provider.models).length;
|
|
1678
|
+
return {
|
|
1679
|
+
id: provider.id,
|
|
1680
|
+
label: provider.name,
|
|
1681
|
+
badge: provider.custom ? 'custom' : provider.local ? 'local' : !endpoint ? 'setup' : undefined,
|
|
1682
|
+
freeApi: hasFreeApi(provider.id),
|
|
1683
|
+
right: `${count} model${count === 1 ? '' : 's'}`,
|
|
1684
|
+
configured: providerReady(provider, credentials, config)
|
|
1685
|
+
};
|
|
1686
|
+
});
|
|
1687
|
+
}, [catalog, config, credentials, overlay]);
|
|
1688
|
+
const sessionItems = useMemo(() => {
|
|
1689
|
+
if (overlay?.kind !== 'sessions')
|
|
1690
|
+
return [];
|
|
1691
|
+
const query = overlay.query.trim().toLowerCase();
|
|
1692
|
+
return sessions
|
|
1693
|
+
.filter((record) => {
|
|
1694
|
+
if (!query)
|
|
1695
|
+
return true;
|
|
1696
|
+
return `${record.title} ${record.provider} ${record.model} ${record.cwd}`.toLowerCase().includes(query);
|
|
1697
|
+
})
|
|
1698
|
+
.map((record) => ({
|
|
1699
|
+
id: record.id,
|
|
1700
|
+
label: record.title || 'Untitled session',
|
|
1701
|
+
badge: record.id === session.id ? 'current' : undefined,
|
|
1702
|
+
right: `${formatAge(record.updatedAt)} · ${record.model || record.provider || 'no model'}`,
|
|
1703
|
+
configured: record.messages.length > 0
|
|
1704
|
+
}));
|
|
1705
|
+
}, [overlay, session.id, sessions]);
|
|
1706
|
+
const settingsRows = useMemo(() => [
|
|
1707
|
+
{ id: 'theme', label: 'Theme', value: theme.label, kind: 'action' },
|
|
1708
|
+
{ id: 'model', label: 'Model', value: activeModelName || 'not selected', kind: 'action' },
|
|
1709
|
+
{
|
|
1710
|
+
id: 'provider',
|
|
1711
|
+
label: 'Provider',
|
|
1712
|
+
value: activeProvider?.name ?? activeProviderId ?? 'not selected',
|
|
1713
|
+
kind: 'action'
|
|
1714
|
+
},
|
|
1715
|
+
{ id: 'mode', label: 'Mode', value: config.mode, kind: 'choice' },
|
|
1716
|
+
{ id: 'effort', label: 'Reasoning effort', value: effortLabel(config.effort), kind: 'choice' },
|
|
1717
|
+
{ id: 'stream', label: 'Live streaming', value: config.streaming ? 'on' : 'off', kind: 'toggle' },
|
|
1718
|
+
{
|
|
1719
|
+
id: 'explorer',
|
|
1720
|
+
label: 'Explorer width',
|
|
1721
|
+
value: `${config.explorerWidth} cols`,
|
|
1722
|
+
kind: 'choice'
|
|
1723
|
+
},
|
|
1724
|
+
{
|
|
1725
|
+
id: 'thinking',
|
|
1726
|
+
label: 'Thinking text',
|
|
1727
|
+
value: config.showReasoning ? 'shown' : 'hidden',
|
|
1728
|
+
kind: 'toggle'
|
|
1729
|
+
},
|
|
1730
|
+
{
|
|
1731
|
+
id: 'tools',
|
|
1732
|
+
label: 'Tool output',
|
|
1733
|
+
value: config.verboseTools ? 'expanded' : 'compact',
|
|
1734
|
+
kind: 'toggle'
|
|
1735
|
+
},
|
|
1736
|
+
{
|
|
1737
|
+
id: 'access',
|
|
1738
|
+
label: 'Full computer access',
|
|
1739
|
+
value: config.fullAccess ? 'FULL · approvals off' : 'safe',
|
|
1740
|
+
kind: 'toggle'
|
|
1741
|
+
},
|
|
1742
|
+
{
|
|
1743
|
+
id: 'agent',
|
|
1744
|
+
label: 'Agent mode',
|
|
1745
|
+
value: config.agents.enabled ? `on · ${config.agents.workers.length} workers` : 'off',
|
|
1746
|
+
kind: 'toggle'
|
|
1747
|
+
},
|
|
1748
|
+
{
|
|
1749
|
+
id: 'github',
|
|
1750
|
+
label: 'GitHub',
|
|
1751
|
+
value: githubAuth?.configured
|
|
1752
|
+
? `${githubAuth.source === 'env' ? githubAuth.envName : 'connected'} · ${githubAuth.masked ?? ''}`.trim()
|
|
1753
|
+
: 'not connected',
|
|
1754
|
+
kind: 'action'
|
|
1755
|
+
}
|
|
1756
|
+
], [activeModelName, activeProvider, activeProviderId, config, githubAuth, theme]);
|
|
1757
|
+
const settingsItems = useMemo(() => settingsRows.map((row) => ({
|
|
1758
|
+
id: row.id,
|
|
1759
|
+
label: row.label,
|
|
1760
|
+
right: row.kind === 'action' ? `${row.value} ›` : row.value,
|
|
1761
|
+
badge: row.kind === 'toggle' ? (row.value === 'off' || row.value === 'safe' || row.value === 'hidden' || row.value === 'compact' ? undefined : 'on') : undefined
|
|
1762
|
+
})), [settingsRows]);
|
|
1763
|
+
const rawOverlaySelected = overlay && 'selected' in overlay ? overlay.selected : 0;
|
|
1764
|
+
const selectedOverlayIndex = overlay
|
|
1765
|
+
? Math.min(rawOverlaySelected, Math.max(0, (overlay.kind === 'model'
|
|
1766
|
+
? modelPickerItems.length
|
|
1767
|
+
: overlay.kind === 'provider'
|
|
1768
|
+
? providerItems.length
|
|
1769
|
+
: overlay.kind === 'sessions'
|
|
1770
|
+
? sessionItems.length
|
|
1771
|
+
: overlay.kind === 'themes'
|
|
1772
|
+
? THEMES.length
|
|
1773
|
+
: overlay.kind === 'settings'
|
|
1774
|
+
? settingsItems.length
|
|
1775
|
+
: 1) - 1))
|
|
1776
|
+
: 0;
|
|
1777
|
+
const suggestions = useMemo(() => {
|
|
1778
|
+
if (overlay || explorerFocused || suggestionsDismissed)
|
|
1779
|
+
return [];
|
|
1780
|
+
const before = input.slice(0, cursor);
|
|
1781
|
+
if (/^\s*\/[^\s]*$/.test(input) && input.startsWith('/')) {
|
|
1782
|
+
const normalized = input.trim().toLowerCase().slice(1);
|
|
1783
|
+
const commands = COMMANDS.filter((command) => command.name.slice(1).startsWith(normalized)).slice(0, EMPTY_SUGGESTION_LIMIT);
|
|
1784
|
+
return commands.map((command) => ({
|
|
1785
|
+
label: command.name,
|
|
1786
|
+
description: command.description,
|
|
1787
|
+
complete: `${command.name} `,
|
|
1788
|
+
kind: 'command'
|
|
1789
|
+
}));
|
|
1790
|
+
}
|
|
1791
|
+
const fileMatch = /(^|\s)@([^\s@]*)$/.exec(before);
|
|
1792
|
+
if (!fileMatch)
|
|
1793
|
+
return [];
|
|
1794
|
+
const query = (fileMatch[2] ?? '').toLowerCase();
|
|
1795
|
+
const ranked = files
|
|
1796
|
+
.map((path) => {
|
|
1797
|
+
const lower = path.toLowerCase();
|
|
1798
|
+
const rank = lower.startsWith(query)
|
|
1799
|
+
? 0
|
|
1800
|
+
: lower.includes(`/${query}`)
|
|
1801
|
+
? 1
|
|
1802
|
+
: lower.includes(query)
|
|
1803
|
+
? 2
|
|
1804
|
+
: 3;
|
|
1805
|
+
return { path, rank };
|
|
1806
|
+
})
|
|
1807
|
+
.filter((entry) => entry.rank < 3)
|
|
1808
|
+
.sort((a, b) => a.rank - b.rank || a.path.length - b.path.length || a.path.localeCompare(b.path))
|
|
1809
|
+
.slice(0, EMPTY_SUGGESTION_LIMIT);
|
|
1810
|
+
return ranked.map((entry) => ({
|
|
1811
|
+
label: basename(entry.path),
|
|
1812
|
+
description: entry.path,
|
|
1813
|
+
complete: entry.path,
|
|
1814
|
+
kind: 'file'
|
|
1815
|
+
}));
|
|
1816
|
+
}, [cursor, explorerFocused, files, input, overlay, suggestionsDismissed]);
|
|
1817
|
+
const suggestionSelected = Math.min(suggestionIndex, Math.max(0, suggestions.length - 1));
|
|
1818
|
+
const adjustSetting = useCallback(async (id, direction) => {
|
|
1819
|
+
const row = settingsRows.find((entry) => entry.id === id);
|
|
1820
|
+
if (!row)
|
|
1821
|
+
return;
|
|
1822
|
+
if (row.kind === 'action' && direction !== 0)
|
|
1823
|
+
return;
|
|
1824
|
+
const step = direction === 0 ? 1 : direction;
|
|
1825
|
+
switch (id) {
|
|
1826
|
+
case 'theme':
|
|
1827
|
+
setOverlay({
|
|
1828
|
+
kind: 'themes',
|
|
1829
|
+
selected: Math.max(0, THEMES.findIndex((entry) => entry.name === theme.name)),
|
|
1830
|
+
original: theme.name
|
|
1831
|
+
});
|
|
1832
|
+
return;
|
|
1833
|
+
case 'model':
|
|
1834
|
+
showModelPicker();
|
|
1835
|
+
return;
|
|
1836
|
+
case 'provider':
|
|
1837
|
+
showProviderPicker('switch');
|
|
1838
|
+
return;
|
|
1839
|
+
case 'github':
|
|
1840
|
+
await handleGithubCommand(githubAuth?.configured ? 'status' : 'login');
|
|
1841
|
+
return;
|
|
1842
|
+
case 'mode':
|
|
1843
|
+
await setMode(configRef.current.mode === 'plan' ? 'build' : 'plan', true);
|
|
1844
|
+
return;
|
|
1845
|
+
case 'effort': {
|
|
1846
|
+
const index = EFFORT_SETTINGS.indexOf(configRef.current.effort);
|
|
1847
|
+
const next = EFFORT_SETTINGS[(index + step + EFFORT_SETTINGS.length) % EFFORT_SETTINGS.length];
|
|
1848
|
+
await updateSetting({ effort: next }, `Reasoning effort: ${effortLabel(next)}.`);
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
case 'stream': {
|
|
1852
|
+
const next = !configRef.current.streaming;
|
|
1853
|
+
await updateSetting({ streaming: next }, next
|
|
1854
|
+
? 'Live streaming on · replies paint as tokens arrive.'
|
|
1855
|
+
: 'Live streaming off · replies appear when complete.');
|
|
1856
|
+
return;
|
|
1857
|
+
}
|
|
1858
|
+
case 'explorer': {
|
|
1859
|
+
resizeExplorer(step * 2);
|
|
1860
|
+
return;
|
|
1861
|
+
}
|
|
1862
|
+
case 'thinking': {
|
|
1863
|
+
const next = !configRef.current.showReasoning;
|
|
1864
|
+
await updateSetting({ showReasoning: next }, next ? 'Reasoning text visible.' : 'Reasoning text hidden.');
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
case 'tools': {
|
|
1868
|
+
const next = !configRef.current.verboseTools;
|
|
1869
|
+
await updateSetting({ verboseTools: next }, next ? 'Tool output expanded.' : 'Tool output compact.');
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
case 'access': {
|
|
1873
|
+
const next = !configRef.current.fullAccess;
|
|
1874
|
+
await updateSetting({ fullAccess: next }, next
|
|
1875
|
+
? 'FULL ACCESS ON · every tool action is approved automatically, including paths outside this workspace. Use /access safe to stop.'
|
|
1876
|
+
: 'Full access off · normal permissions restored.', next ? 'warning' : 'success');
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
case 'agent': {
|
|
1880
|
+
const next = !configRef.current.agents.enabled;
|
|
1881
|
+
if (next && configRef.current.agents.workers.length === 0) {
|
|
1882
|
+
addNotice('Add a worker first with /agent add provider/model.', 'warning');
|
|
1883
|
+
return;
|
|
1884
|
+
}
|
|
1885
|
+
try {
|
|
1886
|
+
const updated = await updateConfig({
|
|
1887
|
+
agents: { ...configRef.current.agents, enabled: next }
|
|
1888
|
+
});
|
|
1889
|
+
setConfig(updated);
|
|
1890
|
+
addNotice(next
|
|
1891
|
+
? `Agent mode on · ${updated.agents.workers.length} worker${updated.agents.workers.length === 1 ? '' : 's'}.`
|
|
1892
|
+
: 'Agent mode off.', 'success');
|
|
1893
|
+
}
|
|
1894
|
+
catch (error) {
|
|
1895
|
+
addNotice(`Could not save settings: ${errorMessage(error)}`, 'error');
|
|
1896
|
+
}
|
|
1897
|
+
return;
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
}, [
|
|
1901
|
+
addNotice,
|
|
1902
|
+
githubAuth?.configured,
|
|
1903
|
+
handleGithubCommand,
|
|
1904
|
+
resizeExplorer,
|
|
1905
|
+
setMode,
|
|
1906
|
+
settingsRows,
|
|
1907
|
+
showModelPicker,
|
|
1908
|
+
showProviderPicker,
|
|
1909
|
+
theme.name,
|
|
1910
|
+
updateSetting
|
|
1911
|
+
]);
|
|
1912
|
+
const moveExplorer = useCallback((delta) => {
|
|
1913
|
+
setExplorer((current) => {
|
|
1914
|
+
if (current.entries.length === 0)
|
|
1915
|
+
return current;
|
|
1916
|
+
const index = Math.max(0, Math.min(current.entries.length - 1, current.selected + delta));
|
|
1917
|
+
if (index === current.selected)
|
|
1918
|
+
return current;
|
|
1919
|
+
return { ...current, selected: index, preview: undefined, previewError: undefined };
|
|
1920
|
+
});
|
|
1921
|
+
}, []);
|
|
1922
|
+
const toggleExplorerDirectory = useCallback((entry) => {
|
|
1923
|
+
const current = explorerRef.current;
|
|
1924
|
+
const expanded = current.expanded.includes(entry.path)
|
|
1925
|
+
? current.expanded.filter((path) => path !== entry.path)
|
|
1926
|
+
: [...current.expanded, entry.path];
|
|
1927
|
+
setExplorer((state) => ({
|
|
1928
|
+
...state,
|
|
1929
|
+
expanded,
|
|
1930
|
+
preview: undefined,
|
|
1931
|
+
previewError: undefined
|
|
1932
|
+
}));
|
|
1933
|
+
void refreshExplorer(expanded, entry.path);
|
|
1934
|
+
}, [refreshExplorer]);
|
|
1935
|
+
const exploreParent = useCallback(() => {
|
|
1936
|
+
const current = explorerRef.current;
|
|
1937
|
+
const entry = current.entries[current.selected];
|
|
1938
|
+
if (!entry)
|
|
1939
|
+
return;
|
|
1940
|
+
if (entry.kind === 'directory' && current.expanded.includes(entry.path)) {
|
|
1941
|
+
toggleExplorerDirectory(entry);
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
const parent = explorerParent(entry.path);
|
|
1945
|
+
if (!parent)
|
|
1946
|
+
return;
|
|
1947
|
+
const index = current.entries.findIndex((candidate) => candidate.path === parent);
|
|
1948
|
+
if (index >= 0) {
|
|
1949
|
+
setExplorer({ ...current, selected: index, preview: undefined, previewError: undefined });
|
|
1950
|
+
}
|
|
1951
|
+
}, [toggleExplorerDirectory]);
|
|
1952
|
+
const attachExplorerEntry = useCallback(() => {
|
|
1953
|
+
const current = explorerRef.current;
|
|
1954
|
+
const entry = current.entries[current.selected];
|
|
1955
|
+
if (!entry)
|
|
1956
|
+
return;
|
|
1957
|
+
if (entry.kind === 'directory') {
|
|
1958
|
+
toggleExplorerDirectory(entry);
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1961
|
+
const value = inputRef.current;
|
|
1962
|
+
const at = cursorRef.current;
|
|
1963
|
+
const prefix = value.length > 0 && !/\s$/.test(value.slice(0, at)) ? ' ' : '';
|
|
1964
|
+
const attachment = `${prefix}@${entry.path} `;
|
|
1965
|
+
setInputValue(`${value.slice(0, at)}${attachment}${value.slice(at)}`, at + attachment.length);
|
|
1966
|
+
setExplorerFocused(false);
|
|
1967
|
+
}, [setInputValue, toggleExplorerDirectory]);
|
|
1968
|
+
const dockWidth = explorerDockWidth(config.explorerWidth, size.columns);
|
|
1969
|
+
const mainWidth = Math.max(20, size.columns - dockWidth - (dockWidth > 0 ? EXPLORER_DOCK_GAP : 0));
|
|
1970
|
+
useEffect(() => {
|
|
1971
|
+
if (dockWidth === 0 && explorerFocusedRef.current)
|
|
1972
|
+
setExplorerFocused(false);
|
|
1973
|
+
}, [dockWidth]);
|
|
1974
|
+
const transcriptLines = useMemo(() => renderTimeline(timeline, mainWidth, theme, {
|
|
1975
|
+
showReasoning: config.showReasoning,
|
|
1976
|
+
verboseTools: config.verboseTools,
|
|
1977
|
+
// Timeline entries are finalized, so their spinner never changes.
|
|
1978
|
+
// Keeping it out of the dependencies avoids repainting the whole
|
|
1979
|
+
// transcript for every spinner frame.
|
|
1980
|
+
spinner: '⠋'
|
|
1981
|
+
}), [config.showReasoning, config.verboseTools, mainWidth, theme, timeline]);
|
|
1982
|
+
const maxScrollRef = useRef(0);
|
|
1983
|
+
useInput((typed, key) => {
|
|
1984
|
+
if (key.ctrl && typed === 'c') {
|
|
1985
|
+
handleCtrlC();
|
|
1986
|
+
return;
|
|
1987
|
+
}
|
|
1988
|
+
if (key.ctrl && key.leftArrow) {
|
|
1989
|
+
resizeExplorer(-2);
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
if (key.ctrl && key.rightArrow) {
|
|
1993
|
+
resizeExplorer(2);
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
const currentOverlay = overlayRef.current;
|
|
1997
|
+
if (currentOverlay?.kind === 'permission') {
|
|
1998
|
+
if (typed.toLowerCase() === 'y' || key.return)
|
|
1999
|
+
answerPermission('allow');
|
|
2000
|
+
else if (typed.toLowerCase() === 'a')
|
|
2001
|
+
answerPermission('always');
|
|
2002
|
+
else if (typed.toLowerCase() === 'n')
|
|
2003
|
+
answerPermission('deny');
|
|
2004
|
+
else if (key.escape) {
|
|
2005
|
+
setOverlay(null);
|
|
2006
|
+
currentOverlay.resolve('deny');
|
|
2007
|
+
abortRef.current?.abort();
|
|
2008
|
+
}
|
|
2009
|
+
return;
|
|
2010
|
+
}
|
|
2011
|
+
if (currentOverlay?.kind === 'connect-key' || currentOverlay?.kind === 'github-key') {
|
|
2012
|
+
if (key.ctrl && (typed === 'v' || typed === '\x16')) {
|
|
2013
|
+
void pasteClipboard('key');
|
|
2014
|
+
return;
|
|
2015
|
+
}
|
|
2016
|
+
if (key.escape) {
|
|
2017
|
+
setOverlay(null);
|
|
2018
|
+
return;
|
|
2019
|
+
}
|
|
2020
|
+
if (key.return) {
|
|
2021
|
+
const value = currentOverlay.value.trim();
|
|
2022
|
+
if (!value)
|
|
2023
|
+
return;
|
|
2024
|
+
if (currentOverlay.kind === 'github-key') {
|
|
2025
|
+
void submitGithubToken(value);
|
|
2026
|
+
}
|
|
2027
|
+
else {
|
|
2028
|
+
void (async () => {
|
|
2029
|
+
try {
|
|
2030
|
+
await saveProviderKey(currentOverlay.provider.id, value);
|
|
2031
|
+
await refreshCredentials();
|
|
2032
|
+
setOverlay(null);
|
|
2033
|
+
addNotice(`Connected ${currentOverlay.provider.name}.`, 'success');
|
|
2034
|
+
if (currentOverlay.next === 'models')
|
|
2035
|
+
showModelPicker(currentOverlay.provider.id);
|
|
2036
|
+
}
|
|
2037
|
+
catch (error) {
|
|
2038
|
+
addNotice(`Could not save key: ${errorMessage(error)}`, 'error');
|
|
2039
|
+
}
|
|
2040
|
+
})();
|
|
2041
|
+
}
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
2044
|
+
if (key.backspace || key.delete) {
|
|
2045
|
+
if (currentOverlay.cursor > 0) {
|
|
2046
|
+
const value = currentOverlay.value.slice(0, currentOverlay.cursor - 1) +
|
|
2047
|
+
currentOverlay.value.slice(currentOverlay.cursor);
|
|
2048
|
+
setOverlay({
|
|
2049
|
+
...currentOverlay,
|
|
2050
|
+
value,
|
|
2051
|
+
cursor: currentOverlay.cursor - 1
|
|
2052
|
+
});
|
|
2053
|
+
}
|
|
2054
|
+
return;
|
|
2055
|
+
}
|
|
2056
|
+
if (key.leftArrow) {
|
|
2057
|
+
setOverlay({ ...currentOverlay, cursor: Math.max(0, currentOverlay.cursor - 1) });
|
|
2058
|
+
return;
|
|
2059
|
+
}
|
|
2060
|
+
if (key.rightArrow) {
|
|
2061
|
+
setOverlay({
|
|
2062
|
+
...currentOverlay,
|
|
2063
|
+
cursor: Math.min(currentOverlay.value.length, currentOverlay.cursor + 1)
|
|
2064
|
+
});
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
if (key.home) {
|
|
2068
|
+
setOverlay({ ...currentOverlay, cursor: 0 });
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
2071
|
+
if (key.end) {
|
|
2072
|
+
setOverlay({ ...currentOverlay, cursor: currentOverlay.value.length });
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
2075
|
+
if (typed && !key.ctrl && !key.meta && typed >= ' ') {
|
|
2076
|
+
const value = currentOverlay.value.slice(0, currentOverlay.cursor) +
|
|
2077
|
+
typed +
|
|
2078
|
+
currentOverlay.value.slice(currentOverlay.cursor);
|
|
2079
|
+
setOverlay({
|
|
2080
|
+
...currentOverlay,
|
|
2081
|
+
value,
|
|
2082
|
+
cursor: currentOverlay.cursor + typed.length
|
|
2083
|
+
});
|
|
2084
|
+
}
|
|
2085
|
+
return;
|
|
2086
|
+
}
|
|
2087
|
+
if (currentOverlay?.kind === 'settings') {
|
|
2088
|
+
if (key.escape || typed === 'q') {
|
|
2089
|
+
setOverlay(null);
|
|
2090
|
+
return;
|
|
2091
|
+
}
|
|
2092
|
+
if (key.upArrow) {
|
|
2093
|
+
setOverlay({ ...currentOverlay, selected: Math.max(0, selectedOverlayIndex - 1) });
|
|
2094
|
+
return;
|
|
2095
|
+
}
|
|
2096
|
+
if (key.downArrow || key.tab) {
|
|
2097
|
+
setOverlay({
|
|
2098
|
+
...currentOverlay,
|
|
2099
|
+
selected: Math.min(Math.max(0, settingsRows.length - 1), selectedOverlayIndex + 1)
|
|
2100
|
+
});
|
|
2101
|
+
return;
|
|
2102
|
+
}
|
|
2103
|
+
const row = settingsRows[selectedOverlayIndex];
|
|
2104
|
+
if (key.leftArrow) {
|
|
2105
|
+
if (row)
|
|
2106
|
+
void adjustSetting(row.id, -1);
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
if (key.rightArrow) {
|
|
2110
|
+
if (row)
|
|
2111
|
+
void adjustSetting(row.id, 1);
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
if (key.return || typed === ' ') {
|
|
2115
|
+
if (row)
|
|
2116
|
+
void adjustSetting(row.id, 0);
|
|
2117
|
+
return;
|
|
2118
|
+
}
|
|
2119
|
+
if (typed === 'g') {
|
|
2120
|
+
openGithubLogin();
|
|
2121
|
+
return;
|
|
2122
|
+
}
|
|
2123
|
+
return;
|
|
2124
|
+
}
|
|
2125
|
+
if (currentOverlay?.kind === 'btw') {
|
|
2126
|
+
if (key.escape || key.return || typed === 'q') {
|
|
2127
|
+
btwAbortRef.current?.abort();
|
|
2128
|
+
btwAbortRef.current = null;
|
|
2129
|
+
btwRef.current = null;
|
|
2130
|
+
setBtw(null);
|
|
2131
|
+
setOverlay(null);
|
|
2132
|
+
}
|
|
2133
|
+
return;
|
|
2134
|
+
}
|
|
2135
|
+
if (currentOverlay?.kind === 'help') {
|
|
2136
|
+
if (key.escape || key.return || typed === 'q' || typed === '?')
|
|
2137
|
+
setOverlay(null);
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
2140
|
+
if (currentOverlay?.kind === 'themes') {
|
|
2141
|
+
if (key.escape) {
|
|
2142
|
+
setThemeName(currentOverlay.original);
|
|
2143
|
+
setOverlay(null);
|
|
2144
|
+
return;
|
|
2145
|
+
}
|
|
2146
|
+
if (key.upArrow) {
|
|
2147
|
+
const next = (selectedOverlayIndex - 1 + THEMES.length) % THEMES.length;
|
|
2148
|
+
setOverlay({ ...currentOverlay, selected: next });
|
|
2149
|
+
setThemeName(THEMES[next].name);
|
|
2150
|
+
return;
|
|
2151
|
+
}
|
|
2152
|
+
if (key.downArrow || key.tab) {
|
|
2153
|
+
const next = (selectedOverlayIndex + 1) % THEMES.length;
|
|
2154
|
+
setOverlay({ ...currentOverlay, selected: next });
|
|
2155
|
+
setThemeName(THEMES[next].name);
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
if (key.return) {
|
|
2159
|
+
const chosen = THEMES[selectedOverlayIndex] ?? getTheme(currentOverlay.original);
|
|
2160
|
+
setThemeName(chosen.name);
|
|
2161
|
+
setOverlay(null);
|
|
2162
|
+
void updateSetting({ theme: chosen.name });
|
|
2163
|
+
return;
|
|
2164
|
+
}
|
|
2165
|
+
return;
|
|
2166
|
+
}
|
|
2167
|
+
if (currentOverlay?.kind === 'model' ||
|
|
2168
|
+
currentOverlay?.kind === 'provider' ||
|
|
2169
|
+
currentOverlay?.kind === 'sessions') {
|
|
2170
|
+
if (key.escape) {
|
|
2171
|
+
setOverlay(null);
|
|
2172
|
+
return;
|
|
2173
|
+
}
|
|
2174
|
+
if (key.upArrow) {
|
|
2175
|
+
setOverlay({
|
|
2176
|
+
...currentOverlay,
|
|
2177
|
+
selected: Math.max(0, selectedOverlayIndex - 1)
|
|
2178
|
+
});
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
const down = key.downArrow || key.tab;
|
|
2182
|
+
if (down) {
|
|
2183
|
+
const length = currentOverlay.kind === 'model'
|
|
2184
|
+
? modelPickerItems.length
|
|
2185
|
+
: currentOverlay.kind === 'provider'
|
|
2186
|
+
? providerItems.length
|
|
2187
|
+
: sessionItems.length;
|
|
2188
|
+
setOverlay({
|
|
2189
|
+
...currentOverlay,
|
|
2190
|
+
selected: Math.min(Math.max(0, length - 1), selectedOverlayIndex + 1)
|
|
2191
|
+
});
|
|
2192
|
+
return;
|
|
2193
|
+
}
|
|
2194
|
+
if (key.return) {
|
|
2195
|
+
if (currentOverlay.kind === 'model') {
|
|
2196
|
+
const entry = modelEntryForPicker(modelEntries, currentOverlay.providerFilter, currentOverlay.query, selectedOverlayIndex);
|
|
2197
|
+
if (entry)
|
|
2198
|
+
void chooseModel(entry.provider.id, entry.model.id);
|
|
2199
|
+
return;
|
|
2200
|
+
}
|
|
2201
|
+
if (currentOverlay.kind === 'provider') {
|
|
2202
|
+
// Resolve from the rendered list so Enter always activates the
|
|
2203
|
+
// highlighted row, even if the display sort ever changes.
|
|
2204
|
+
const item = providerItems[selectedOverlayIndex];
|
|
2205
|
+
const provider = item ? providerById(catalog, item.id) : undefined;
|
|
2206
|
+
if (provider)
|
|
2207
|
+
selectProvider(provider);
|
|
2208
|
+
return;
|
|
2209
|
+
}
|
|
2210
|
+
const item = sessionItems[selectedOverlayIndex];
|
|
2211
|
+
const record = item ? sessions.find((entry) => entry.id === item.id) : undefined;
|
|
2212
|
+
if (record)
|
|
2213
|
+
void resumeSession(record);
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
2216
|
+
if (key.backspace || key.delete) {
|
|
2217
|
+
setOverlay({ ...currentOverlay, query: currentOverlay.query.slice(0, -1), selected: 0 });
|
|
2218
|
+
return;
|
|
2219
|
+
}
|
|
2220
|
+
if (typed && !key.ctrl && !key.meta && typed >= ' ') {
|
|
2221
|
+
setOverlay({ ...currentOverlay, query: currentOverlay.query + typed, selected: 0 });
|
|
2222
|
+
return;
|
|
2223
|
+
}
|
|
2224
|
+
return;
|
|
2225
|
+
}
|
|
2226
|
+
if (explorerFocusedRef.current) {
|
|
2227
|
+
const current = explorerRef.current;
|
|
2228
|
+
const entry = current.entries[current.selected];
|
|
2229
|
+
if (key.escape || (key.ctrl && typed === 'o')) {
|
|
2230
|
+
setExplorerFocused(false);
|
|
2231
|
+
return;
|
|
2232
|
+
}
|
|
2233
|
+
if (typed === '[') {
|
|
2234
|
+
resizeExplorer(-2);
|
|
2235
|
+
return;
|
|
2236
|
+
}
|
|
2237
|
+
if (typed === ']') {
|
|
2238
|
+
resizeExplorer(2);
|
|
2239
|
+
return;
|
|
2240
|
+
}
|
|
2241
|
+
if (key.upArrow) {
|
|
2242
|
+
moveExplorer(-1);
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2245
|
+
if (key.downArrow) {
|
|
2246
|
+
moveExplorer(1);
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
if (key.pageUp) {
|
|
2250
|
+
moveExplorer(-10);
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
if (key.pageDown) {
|
|
2254
|
+
moveExplorer(10);
|
|
2255
|
+
return;
|
|
2256
|
+
}
|
|
2257
|
+
if (key.rightArrow) {
|
|
2258
|
+
if (entry && isExplorerDirectory(entry) && !current.expanded.includes(entry.path)) {
|
|
2259
|
+
toggleExplorerDirectory(entry);
|
|
2260
|
+
}
|
|
2261
|
+
return;
|
|
2262
|
+
}
|
|
2263
|
+
if (key.leftArrow) {
|
|
2264
|
+
exploreParent();
|
|
2265
|
+
return;
|
|
2266
|
+
}
|
|
2267
|
+
if (key.tab) {
|
|
2268
|
+
if (entry && isExplorerDirectory(entry))
|
|
2269
|
+
toggleExplorerDirectory(entry);
|
|
2270
|
+
else
|
|
2271
|
+
moveExplorer(1);
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
2274
|
+
if (key.return) {
|
|
2275
|
+
if (!entry)
|
|
2276
|
+
return;
|
|
2277
|
+
if (isExplorerDirectory(entry))
|
|
2278
|
+
toggleExplorerDirectory(entry);
|
|
2279
|
+
else
|
|
2280
|
+
attachExplorerEntry();
|
|
2281
|
+
return;
|
|
2282
|
+
}
|
|
2283
|
+
if (typed === 'a') {
|
|
2284
|
+
attachExplorerEntry();
|
|
2285
|
+
return;
|
|
2286
|
+
}
|
|
2287
|
+
if (typed === 'r') {
|
|
2288
|
+
void refreshExplorer(current.expanded, entry?.path);
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
// While the dock owns the keyboard, plain typing must not fall through
|
|
2292
|
+
// into the prompt behind it.
|
|
2293
|
+
return;
|
|
2294
|
+
}
|
|
2295
|
+
if (key.ctrl && typed === 'o') {
|
|
2296
|
+
focusExplorer();
|
|
2297
|
+
return;
|
|
2298
|
+
}
|
|
2299
|
+
if (key.ctrl && typed === 't') {
|
|
2300
|
+
const next = nextTheme(theme.name);
|
|
2301
|
+
setThemeName(next.name);
|
|
2302
|
+
void updateSetting({ theme: next.name });
|
|
2303
|
+
return;
|
|
2304
|
+
}
|
|
2305
|
+
if (key.escape) {
|
|
2306
|
+
if (suggestions.length > 0) {
|
|
2307
|
+
setSuggestionsDismissed(true);
|
|
2308
|
+
return;
|
|
2309
|
+
}
|
|
2310
|
+
if (activityRef.current) {
|
|
2311
|
+
if (activityRef.current.kind === 'sync') {
|
|
2312
|
+
addNotice('Sync is already finishing.', 'info');
|
|
2313
|
+
return;
|
|
2314
|
+
}
|
|
2315
|
+
setStopping(true);
|
|
2316
|
+
abortRef.current?.abort();
|
|
2317
|
+
return;
|
|
2318
|
+
}
|
|
2319
|
+
if (input.length > 0) {
|
|
2320
|
+
clearInput();
|
|
2321
|
+
return;
|
|
2322
|
+
}
|
|
2323
|
+
return;
|
|
2324
|
+
}
|
|
2325
|
+
if (key.pageUp) {
|
|
2326
|
+
setScrollOffset(Math.min(maxScrollRef.current, scrollOffset + Math.max(2, Math.floor(size.rows / 2))));
|
|
2327
|
+
return;
|
|
2328
|
+
}
|
|
2329
|
+
if (key.pageDown) {
|
|
2330
|
+
setScrollOffset(Math.max(0, scrollOffset - Math.max(2, Math.floor(size.rows / 2))));
|
|
2331
|
+
return;
|
|
2332
|
+
}
|
|
2333
|
+
if (key.ctrl && typed === 'u') {
|
|
2334
|
+
setScrollOffset(Math.min(maxScrollRef.current, scrollOffset + Math.max(2, Math.floor(size.rows / 4))));
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2337
|
+
if (key.ctrl && typed === 'd') {
|
|
2338
|
+
setScrollOffset(Math.max(0, scrollOffset - Math.max(2, Math.floor(size.rows / 4))));
|
|
2339
|
+
return;
|
|
2340
|
+
}
|
|
2341
|
+
if (key.tab && key.shift) {
|
|
2342
|
+
void setMode(configRef.current.mode === 'plan' ? 'build' : 'plan');
|
|
2343
|
+
return;
|
|
2344
|
+
}
|
|
2345
|
+
if (key.tab) {
|
|
2346
|
+
if (suggestions.length > 0)
|
|
2347
|
+
completeSuggestion(suggestions[suggestionSelected]);
|
|
2348
|
+
else {
|
|
2349
|
+
const next = `${input.slice(0, cursor)} ${input.slice(cursor)}`;
|
|
2350
|
+
setInputValue(next, cursor + 2);
|
|
2351
|
+
}
|
|
2352
|
+
return;
|
|
2353
|
+
}
|
|
2354
|
+
if (suggestions.length > 0) {
|
|
2355
|
+
if (key.upArrow) {
|
|
2356
|
+
setSuggestionIndex(Math.max(0, suggestionSelected - 1));
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
if (key.downArrow) {
|
|
2360
|
+
setSuggestionIndex(Math.min(suggestions.length - 1, suggestionSelected + 1));
|
|
2361
|
+
return;
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
if (key.return) {
|
|
2365
|
+
const draft = inputRef.current;
|
|
2366
|
+
if (suggestions.length > 0 &&
|
|
2367
|
+
suggestions[0]?.kind === 'command' &&
|
|
2368
|
+
!/^\/[^\s]+$/.test(draft.trim())) {
|
|
2369
|
+
completeSuggestion(suggestions[suggestionSelected]);
|
|
2370
|
+
return;
|
|
2371
|
+
}
|
|
2372
|
+
if (draft.startsWith('/') && !draft.trim().includes(' ')) {
|
|
2373
|
+
const exact = COMMANDS.find((command) => command.name === draft.trim());
|
|
2374
|
+
if (!exact && suggestions.length > 0) {
|
|
2375
|
+
completeSuggestion(suggestions[suggestionSelected]);
|
|
2376
|
+
return;
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
// /btw is deliberately allowed mid-turn: it is an isolated request and
|
|
2380
|
+
// never touches the active agent's history or abort controller.
|
|
2381
|
+
if (activityRef.current && !/^\/btw(?:\s|$)/i.test(draft.trim())) {
|
|
2382
|
+
addNotice('Still working. Press esc to stop first.', 'warning');
|
|
2383
|
+
return;
|
|
2384
|
+
}
|
|
2385
|
+
submit(draft);
|
|
2386
|
+
return;
|
|
2387
|
+
}
|
|
2388
|
+
if (typed === '\n') {
|
|
2389
|
+
const next = `${input.slice(0, cursor)}\n${input.slice(cursor)}`;
|
|
2390
|
+
setInputValue(next, cursor + 1);
|
|
2391
|
+
return;
|
|
2392
|
+
}
|
|
2393
|
+
if (key.backspace) {
|
|
2394
|
+
if (cursor > 0) {
|
|
2395
|
+
const next = input.slice(0, cursor - 1) + input.slice(cursor);
|
|
2396
|
+
setInputValue(next, cursor - 1);
|
|
2397
|
+
}
|
|
2398
|
+
return;
|
|
2399
|
+
}
|
|
2400
|
+
if (key.delete) {
|
|
2401
|
+
if (cursor < input.length) {
|
|
2402
|
+
const next = input.slice(0, cursor) + input.slice(cursor + 1);
|
|
2403
|
+
setInputValue(next, cursor);
|
|
2404
|
+
}
|
|
2405
|
+
return;
|
|
2406
|
+
}
|
|
2407
|
+
if (key.leftArrow) {
|
|
2408
|
+
setCursor(Math.max(0, cursor - 1));
|
|
2409
|
+
return;
|
|
2410
|
+
}
|
|
2411
|
+
if (key.rightArrow) {
|
|
2412
|
+
setCursor(Math.min(input.length, cursor + 1));
|
|
2413
|
+
return;
|
|
2414
|
+
}
|
|
2415
|
+
if (key.home || (key.ctrl && typed === 'a')) {
|
|
2416
|
+
setCursor(0);
|
|
2417
|
+
return;
|
|
2418
|
+
}
|
|
2419
|
+
if (key.end || (key.ctrl && typed === 'e')) {
|
|
2420
|
+
setCursor(input.length);
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
2423
|
+
if (key.ctrl && (typed === 'v' || typed === '\x16')) {
|
|
2424
|
+
void pasteClipboard('input');
|
|
2425
|
+
return;
|
|
2426
|
+
}
|
|
2427
|
+
if (key.ctrl && typed === 'k') {
|
|
2428
|
+
setInputValue(input.slice(0, cursor), cursor);
|
|
2429
|
+
return;
|
|
2430
|
+
}
|
|
2431
|
+
if (key.ctrl && typed === 'w') {
|
|
2432
|
+
const before = input.slice(0, cursor);
|
|
2433
|
+
const trimmed = before.replace(/\s+$/, '');
|
|
2434
|
+
const cut = Math.max(trimmed.lastIndexOf(' '), trimmed.lastIndexOf('\n'));
|
|
2435
|
+
const start = cut === -1 ? 0 : cut + 1;
|
|
2436
|
+
const next = input.slice(0, start) + input.slice(cursor);
|
|
2437
|
+
setInputValue(next, start);
|
|
2438
|
+
return;
|
|
2439
|
+
}
|
|
2440
|
+
if (key.upArrow) {
|
|
2441
|
+
if (history.length === 0)
|
|
2442
|
+
return;
|
|
2443
|
+
if (historyIndex === -1) {
|
|
2444
|
+
historyDraft.current = input;
|
|
2445
|
+
const nextIndex = 0;
|
|
2446
|
+
const value = history[history.length - 1];
|
|
2447
|
+
setHistoryIndex(nextIndex);
|
|
2448
|
+
inputRef.current = value;
|
|
2449
|
+
setInput(value);
|
|
2450
|
+
setCursor(value.length);
|
|
2451
|
+
setSuggestionsDismissed(false);
|
|
2452
|
+
}
|
|
2453
|
+
else {
|
|
2454
|
+
const nextIndex = Math.min(history.length - 1, historyIndex + 1);
|
|
2455
|
+
const value = history[history.length - 1 - nextIndex];
|
|
2456
|
+
setHistoryIndex(nextIndex);
|
|
2457
|
+
inputRef.current = value;
|
|
2458
|
+
setInput(value);
|
|
2459
|
+
setCursor(value.length);
|
|
2460
|
+
setSuggestionsDismissed(false);
|
|
2461
|
+
}
|
|
2462
|
+
return;
|
|
2463
|
+
}
|
|
2464
|
+
if (key.downArrow) {
|
|
2465
|
+
if (historyIndex === -1)
|
|
2466
|
+
return;
|
|
2467
|
+
const nextIndex = historyIndex - 1;
|
|
2468
|
+
if (nextIndex < 0) {
|
|
2469
|
+
setHistoryIndex(-1);
|
|
2470
|
+
const value = historyDraft.current;
|
|
2471
|
+
inputRef.current = value;
|
|
2472
|
+
setInput(value);
|
|
2473
|
+
setCursor(value.length);
|
|
2474
|
+
return;
|
|
2475
|
+
}
|
|
2476
|
+
const value = history[history.length - 1 - nextIndex];
|
|
2477
|
+
setHistoryIndex(nextIndex);
|
|
2478
|
+
inputRef.current = value;
|
|
2479
|
+
setInput(value);
|
|
2480
|
+
setCursor(value.length);
|
|
2481
|
+
return;
|
|
2482
|
+
}
|
|
2483
|
+
if (typed === '?' && input === '') {
|
|
2484
|
+
setOverlay({ kind: 'help' });
|
|
2485
|
+
return;
|
|
2486
|
+
}
|
|
2487
|
+
if (typed && !key.ctrl && !key.meta && typed >= ' ') {
|
|
2488
|
+
const next = `${input.slice(0, cursor)}${typed}${input.slice(cursor)}`;
|
|
2489
|
+
setInputValue(next, cursor + typed.length);
|
|
2490
|
+
}
|
|
2491
|
+
}, { isActive: true });
|
|
2492
|
+
usePaste((text) => {
|
|
2493
|
+
const current = overlayRef.current;
|
|
2494
|
+
if (current?.kind === 'connect-key' || current?.kind === 'github-key') {
|
|
2495
|
+
const clean = text.replace(/[\r\n]+/g, '').replace(/[\x00-\x1f\x7f]/g, '');
|
|
2496
|
+
if (!clean)
|
|
2497
|
+
return;
|
|
2498
|
+
setOverlay({
|
|
2499
|
+
...current,
|
|
2500
|
+
value: current.value.slice(0, current.cursor) + clean + current.value.slice(current.cursor),
|
|
2501
|
+
cursor: current.cursor + clean.length
|
|
2502
|
+
});
|
|
2503
|
+
return;
|
|
2504
|
+
}
|
|
2505
|
+
if (current?.kind === 'model' || current?.kind === 'provider' || current?.kind === 'sessions') {
|
|
2506
|
+
const clean = text.replace(/[\r\n]+/g, ' ').replace(/[\x00-\x1f\x7f]/g, '');
|
|
2507
|
+
if (!clean)
|
|
2508
|
+
return;
|
|
2509
|
+
setOverlay({ ...current, query: current.query + clean, selected: 0 });
|
|
2510
|
+
return;
|
|
2511
|
+
}
|
|
2512
|
+
if (current || explorerFocusedRef.current)
|
|
2513
|
+
return;
|
|
2514
|
+
const clean = text
|
|
2515
|
+
.replace(/\r\n/g, '\n')
|
|
2516
|
+
.replace(/\r/g, '\n')
|
|
2517
|
+
.replace(/\t/g, ' ')
|
|
2518
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
|
|
2519
|
+
if (!clean)
|
|
2520
|
+
return;
|
|
2521
|
+
const next = `${input.slice(0, cursor)}${clean}${input.slice(cursor)}`;
|
|
2522
|
+
setInputValue(next, cursor + clean.length);
|
|
2523
|
+
});
|
|
2524
|
+
const width = size.columns;
|
|
2525
|
+
const rows = size.rows;
|
|
2526
|
+
const statusHint = (() => {
|
|
2527
|
+
if (overlay?.kind === 'permission')
|
|
2528
|
+
return 'y allow once · a always · n deny · esc deny & stop';
|
|
2529
|
+
if (overlay?.kind === 'connect-key')
|
|
2530
|
+
return 'secret input · enter save · esc cancel';
|
|
2531
|
+
if (overlay?.kind === 'github-key')
|
|
2532
|
+
return 'secret input · enter verify & save · esc cancel';
|
|
2533
|
+
if (overlay?.kind === 'settings')
|
|
2534
|
+
return '↑↓ move · ←→ change · enter select · g github login · esc close';
|
|
2535
|
+
if (overlay?.kind === 'btw')
|
|
2536
|
+
return btw?.done ? 'esc close' : 'esc cancel';
|
|
2537
|
+
if (overlay?.kind === 'help')
|
|
2538
|
+
return 'esc close';
|
|
2539
|
+
if (overlay)
|
|
2540
|
+
return 'type to search · ↑↓ move · enter select · esc close';
|
|
2541
|
+
if (explorerFocused)
|
|
2542
|
+
return '↑↓ move · enter/a attach · [ ] resize · esc prompt';
|
|
2543
|
+
if (ctrlCArmed)
|
|
2544
|
+
return 'ctrl+c again to exit';
|
|
2545
|
+
if (busy)
|
|
2546
|
+
return 'esc stop · ctrl+j newline';
|
|
2547
|
+
if (config.fullAccess)
|
|
2548
|
+
return 'full access on · /access safe to restore approvals';
|
|
2549
|
+
if (!activeProviderId || !activeModelId)
|
|
2550
|
+
return '/connect to connect · /model to choose a model';
|
|
2551
|
+
return '/ commands · @ files · ctrl+o explorer · ? help';
|
|
2552
|
+
})();
|
|
2553
|
+
const statusState = overlay?.kind === 'permission'
|
|
2554
|
+
? 'permission'
|
|
2555
|
+
: stopping && activity
|
|
2556
|
+
? 'stopping'
|
|
2557
|
+
: activity?.kind === 'sync'
|
|
2558
|
+
? 'sync'
|
|
2559
|
+
: activity?.kind === 'compact'
|
|
2560
|
+
? 'thinking'
|
|
2561
|
+
: activity?.kind === 'shell'
|
|
2562
|
+
? 'tool'
|
|
2563
|
+
: live
|
|
2564
|
+
? live.phase === 'writing'
|
|
2565
|
+
? 'streaming'
|
|
2566
|
+
: live.phase
|
|
2567
|
+
: btwRunning
|
|
2568
|
+
? 'thinking'
|
|
2569
|
+
: 'ready';
|
|
2570
|
+
const baseContextTokens = useMemo(() => estimateContextTokens(session.messages, '') + 600, [session.messages]);
|
|
2571
|
+
// Live text is transient; a cheap length estimate avoids walking the whole
|
|
2572
|
+
// conversation on every paint.
|
|
2573
|
+
const liveContextTokens = live
|
|
2574
|
+
? Math.ceil((live.content.length + (live.preamble?.length ?? 0) + live.reasoning.length) / 3.7)
|
|
2575
|
+
: 0;
|
|
2576
|
+
const contextUsed = baseContextTokens + liveContextTokens;
|
|
2577
|
+
const statusLine = renderStatus({
|
|
2578
|
+
theme,
|
|
2579
|
+
width,
|
|
2580
|
+
hint: statusHint,
|
|
2581
|
+
contextUsed,
|
|
2582
|
+
contextWindow,
|
|
2583
|
+
cost: session.usage.cost + runUsage.cost,
|
|
2584
|
+
state: statusState,
|
|
2585
|
+
spinner,
|
|
2586
|
+
scrollOffset,
|
|
2587
|
+
mode: config.mode,
|
|
2588
|
+
effort: config.effort === 'auto' ? undefined : config.effort,
|
|
2589
|
+
provider: activeProviderId,
|
|
2590
|
+
model: activeModelName || undefined,
|
|
2591
|
+
fullAccess: config.fullAccess,
|
|
2592
|
+
agentWorkers: config.agents.enabled ? config.agents.workers.length : undefined,
|
|
2593
|
+
streaming: config.streaming,
|
|
2594
|
+
tick: brandTick()
|
|
2595
|
+
});
|
|
2596
|
+
const btwView = btw
|
|
2597
|
+
? {
|
|
2598
|
+
...btw,
|
|
2599
|
+
answer: btwRef.current?.answer ?? btw.answer,
|
|
2600
|
+
now: Date.now()
|
|
2601
|
+
}
|
|
2602
|
+
: undefined;
|
|
2603
|
+
const bodyHeightBase = Math.max(1, rows - 1);
|
|
2604
|
+
let frameLines;
|
|
2605
|
+
let effectiveScroll = scrollOffset;
|
|
2606
|
+
if (overlay?.kind === 'model') {
|
|
2607
|
+
const title = overlay.providerFilter ? `models · ${overlay.providerFilter}` : 'models';
|
|
2608
|
+
const subtitle = `${modelPickerItems.length} matches${overlay.query ? ` for “${overlay.query}”` : ' · connected providers'} · ${modelEntries.length} models available`;
|
|
2609
|
+
frameLines = [
|
|
2610
|
+
...renderPicker({
|
|
2611
|
+
title,
|
|
2612
|
+
subtitle,
|
|
2613
|
+
items: modelPickerItems,
|
|
2614
|
+
selected: selectedOverlayIndex,
|
|
2615
|
+
width,
|
|
2616
|
+
height: bodyHeightBase,
|
|
2617
|
+
theme,
|
|
2618
|
+
emptyText: overlay.providerFilter
|
|
2619
|
+
? 'No chat models for this provider. Connect it with /connect or add models to the provider config.'
|
|
2620
|
+
: 'No connected models yet. Use /connect to add a provider API key.',
|
|
2621
|
+
footer: `${selectedOverlayIndex + 1} of ${modelPickerItems.length} · type to search · enter select`
|
|
2622
|
+
}),
|
|
2623
|
+
statusLine
|
|
2624
|
+
];
|
|
2625
|
+
}
|
|
2626
|
+
else if (overlay?.kind === 'provider') {
|
|
2627
|
+
frameLines = [
|
|
2628
|
+
...renderPicker({
|
|
2629
|
+
title: overlay.purpose === 'connect' ? 'connect a provider' : 'providers',
|
|
2630
|
+
subtitle: `${providerItems.length} of ${catalog.providers.length} providers · ${[...credentials.values()].filter((entry) => entry.configured).length} connected · ${FREE_API_LABEL} no-cost tier`,
|
|
2631
|
+
items: providerItems,
|
|
2632
|
+
selected: selectedOverlayIndex,
|
|
2633
|
+
width,
|
|
2634
|
+
height: bodyHeightBase,
|
|
2635
|
+
theme,
|
|
2636
|
+
emptyText: 'No matching providers.',
|
|
2637
|
+
footer: `${selectedOverlayIndex + 1} of ${providerItems.length} · type to search · enter connect`
|
|
2638
|
+
}),
|
|
2639
|
+
statusLine
|
|
2640
|
+
];
|
|
2641
|
+
}
|
|
2642
|
+
else if (overlay?.kind === 'sessions') {
|
|
2643
|
+
frameLines = [
|
|
2644
|
+
...renderPicker({
|
|
2645
|
+
title: 'sessions',
|
|
2646
|
+
subtitle: 'Resume where you left off.',
|
|
2647
|
+
items: sessionItems,
|
|
2648
|
+
selected: selectedOverlayIndex,
|
|
2649
|
+
width,
|
|
2650
|
+
height: bodyHeightBase,
|
|
2651
|
+
theme,
|
|
2652
|
+
emptyText: 'No saved sessions yet.',
|
|
2653
|
+
footer: `${selectedOverlayIndex + 1} of ${sessionItems.length} · enter resume · esc close`
|
|
2654
|
+
}),
|
|
2655
|
+
statusLine
|
|
2656
|
+
];
|
|
2657
|
+
}
|
|
2658
|
+
else if (overlay?.kind === 'themes') {
|
|
2659
|
+
frameLines = [
|
|
2660
|
+
...renderThemePicker({
|
|
2661
|
+
themes: THEMES,
|
|
2662
|
+
selected: selectedOverlayIndex,
|
|
2663
|
+
width,
|
|
2664
|
+
height: bodyHeightBase,
|
|
2665
|
+
active: theme
|
|
2666
|
+
}),
|
|
2667
|
+
statusLine
|
|
2668
|
+
];
|
|
2669
|
+
}
|
|
2670
|
+
else if (overlay?.kind === 'settings') {
|
|
2671
|
+
frameLines = [
|
|
2672
|
+
...renderPicker({
|
|
2673
|
+
title: 'options',
|
|
2674
|
+
subtitle: 'Settings save immediately. Arrows change values; enter opens pickers.',
|
|
2675
|
+
items: settingsItems,
|
|
2676
|
+
selected: selectedOverlayIndex,
|
|
2677
|
+
width,
|
|
2678
|
+
height: bodyHeightBase,
|
|
2679
|
+
theme,
|
|
2680
|
+
emptyText: 'No settings available.',
|
|
2681
|
+
footer: '↑↓ move · ←→ change · enter select · esc close'
|
|
2682
|
+
}),
|
|
2683
|
+
statusLine
|
|
2684
|
+
];
|
|
2685
|
+
}
|
|
2686
|
+
else if (overlay?.kind === 'btw') {
|
|
2687
|
+
frameLines = [
|
|
2688
|
+
...padBlock(btwView ? renderBtwOverlay(btwView, width, bodyHeightBase, theme, spinner) : [], bodyHeightBase, width),
|
|
2689
|
+
statusLine
|
|
2690
|
+
];
|
|
2691
|
+
}
|
|
2692
|
+
else if (overlay?.kind === 'help') {
|
|
2693
|
+
frameLines = [
|
|
2694
|
+
...padBlock(renderHelpOverlay(width, bodyHeightBase, theme), bodyHeightBase, width),
|
|
2695
|
+
statusLine
|
|
2696
|
+
];
|
|
2697
|
+
}
|
|
2698
|
+
else if (overlay?.kind === 'permission') {
|
|
2699
|
+
frameLines = [
|
|
2700
|
+
...padBlock(renderPermission(overlay.request, width, bodyHeightBase, theme), bodyHeightBase, width),
|
|
2701
|
+
statusLine
|
|
2702
|
+
];
|
|
2703
|
+
}
|
|
2704
|
+
else if (overlay?.kind === 'connect-key') {
|
|
2705
|
+
const secretInput = renderInput({
|
|
2706
|
+
value: overlay.value,
|
|
2707
|
+
cursor: overlay.cursor,
|
|
2708
|
+
placeholder: envNameFor(overlay.provider) ? `paste ${envNameFor(overlay.provider)}…` : 'paste your API key…',
|
|
2709
|
+
title: `connect · ${overlay.provider.name}`,
|
|
2710
|
+
hint: 'enter save · esc cancel · stored with mode 600',
|
|
2711
|
+
theme,
|
|
2712
|
+
focused: true,
|
|
2713
|
+
secret: true,
|
|
2714
|
+
maxRows: 2
|
|
2715
|
+
}, width);
|
|
2716
|
+
const infoHeight = Math.max(1, bodyHeightBase - secretInput.lines.length);
|
|
2717
|
+
const info = renderConnectInfo(overlay.provider.name, envNameFor(overlay.provider), Object.keys(overlay.provider.models).length, width, infoHeight, theme);
|
|
2718
|
+
frameLines = [
|
|
2719
|
+
...padBlock(info, infoHeight, width),
|
|
2720
|
+
...secretInput.lines,
|
|
2721
|
+
statusLine
|
|
2722
|
+
];
|
|
2723
|
+
}
|
|
2724
|
+
else if (overlay?.kind === 'github-key') {
|
|
2725
|
+
const secretInput = renderInput({
|
|
2726
|
+
value: overlay.value,
|
|
2727
|
+
cursor: overlay.cursor,
|
|
2728
|
+
placeholder: 'paste a GitHub personal access token…',
|
|
2729
|
+
title: 'github · personal access token',
|
|
2730
|
+
hint: 'enter verify & save · esc cancel · stored with mode 600',
|
|
2731
|
+
theme,
|
|
2732
|
+
focused: true,
|
|
2733
|
+
secret: true,
|
|
2734
|
+
maxRows: 2
|
|
2735
|
+
}, width);
|
|
2736
|
+
const infoHeight = Math.max(1, bodyHeightBase - secretInput.lines.length);
|
|
2737
|
+
const info = renderGithubInfo({
|
|
2738
|
+
configured: Boolean(githubAuth?.configured),
|
|
2739
|
+
source: githubAuth?.source === 'env' ? githubAuth.envName : githubAuth?.source === 'auth' ? 'auth.json' : undefined,
|
|
2740
|
+
masked: githubAuth?.masked,
|
|
2741
|
+
width,
|
|
2742
|
+
height: infoHeight,
|
|
2743
|
+
theme
|
|
2744
|
+
});
|
|
2745
|
+
frameLines = [
|
|
2746
|
+
...padBlock(info, infoHeight, width),
|
|
2747
|
+
...secretInput.lines,
|
|
2748
|
+
statusLine
|
|
2749
|
+
];
|
|
2750
|
+
}
|
|
2751
|
+
else {
|
|
2752
|
+
const inputRender = renderInput({
|
|
2753
|
+
value: input,
|
|
2754
|
+
cursor,
|
|
2755
|
+
placeholder: input.startsWith('!') ? 'Run a shell command…' : 'Ask anything…',
|
|
2756
|
+
theme,
|
|
2757
|
+
focused: !explorerFocused,
|
|
2758
|
+
maxRows: 6,
|
|
2759
|
+
variant: 'minimal'
|
|
2760
|
+
}, mainWidth);
|
|
2761
|
+
const suggestionLines = suggestions.length > 0
|
|
2762
|
+
? renderSuggestions(suggestions.map((suggestion) => ({
|
|
2763
|
+
label: suggestion.label,
|
|
2764
|
+
description: suggestion.description
|
|
2765
|
+
})), suggestionSelected, mainWidth, theme)
|
|
2766
|
+
: [];
|
|
2767
|
+
const bodyHeight = Math.max(1, rows - 1 - inputRender.lines.length - suggestionLines.length);
|
|
2768
|
+
let body;
|
|
2769
|
+
if (timeline.length === 0 && !live) {
|
|
2770
|
+
const hero = renderHero({
|
|
2771
|
+
cwd,
|
|
2772
|
+
provider: activeProviderId,
|
|
2773
|
+
model: activeModelName || undefined,
|
|
2774
|
+
mode: config.mode,
|
|
2775
|
+
configuredProviders: catalog.providers.filter((provider) => providerReady(provider, credentials, config)).length,
|
|
2776
|
+
totalProviders: catalog.providers.length,
|
|
2777
|
+
tick: logoAnimating ? frame : undefined
|
|
2778
|
+
}, mainWidth, theme, bodyHeight);
|
|
2779
|
+
body = padBlock(centerBlock(hero, bodyHeight), bodyHeight, mainWidth);
|
|
2780
|
+
effectiveScroll = 0;
|
|
2781
|
+
}
|
|
2782
|
+
else {
|
|
2783
|
+
const liveLines = live
|
|
2784
|
+
? renderLive(live, mainWidth, theme, {
|
|
2785
|
+
showReasoning: config.showReasoning,
|
|
2786
|
+
verboseTools: config.verboseTools,
|
|
2787
|
+
spinner,
|
|
2788
|
+
streaming: config.streaming,
|
|
2789
|
+
now: Date.now()
|
|
2790
|
+
})
|
|
2791
|
+
: [];
|
|
2792
|
+
const allLines = live && transcriptLines.length > 0 ? [...transcriptLines, '', ...liveLines] : [...transcriptLines, ...liveLines];
|
|
2793
|
+
const maxScroll = Math.max(0, allLines.length - bodyHeight);
|
|
2794
|
+
maxScrollRef.current = maxScroll;
|
|
2795
|
+
effectiveScroll = Math.min(scrollOffset, maxScroll);
|
|
2796
|
+
const end = allLines.length - effectiveScroll;
|
|
2797
|
+
const start = Math.max(0, end - bodyHeight);
|
|
2798
|
+
const slice = allLines.slice(start, end);
|
|
2799
|
+
body = padBlock(slice, bodyHeight, mainWidth);
|
|
2800
|
+
}
|
|
2801
|
+
const leftLines = [...body, ...suggestionLines, ...inputRender.lines];
|
|
2802
|
+
if (dockWidth > 0) {
|
|
2803
|
+
const dockLines = renderExplorer({
|
|
2804
|
+
cwd,
|
|
2805
|
+
entries: explorer.entries,
|
|
2806
|
+
selected: explorer.selected,
|
|
2807
|
+
expanded: explorer.expanded,
|
|
2808
|
+
preview: explorer.preview,
|
|
2809
|
+
previewError: explorer.previewError,
|
|
2810
|
+
loading: explorer.loading,
|
|
2811
|
+
width: dockWidth,
|
|
2812
|
+
height: rows - 1,
|
|
2813
|
+
theme,
|
|
2814
|
+
focused: explorerFocused
|
|
2815
|
+
});
|
|
2816
|
+
const gap = ' '.repeat(EXPLORER_DOCK_GAP);
|
|
2817
|
+
const composed = [];
|
|
2818
|
+
for (let index = 0; index < rows - 1; index += 1) {
|
|
2819
|
+
composed.push(`${fit(leftLines[index] ?? '', mainWidth)}${gap}${fit(dockLines[index] ?? '', dockWidth)}`);
|
|
2820
|
+
}
|
|
2821
|
+
frameLines = [...composed, statusLine];
|
|
2822
|
+
}
|
|
2823
|
+
else {
|
|
2824
|
+
frameLines = [...leftLines, statusLine];
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
while (frameLines.length < rows)
|
|
2828
|
+
frameLines.push('');
|
|
2829
|
+
const text = frameLines
|
|
2830
|
+
.slice(0, rows)
|
|
2831
|
+
.map((line) => {
|
|
2832
|
+
const padded = fit(line, width);
|
|
2833
|
+
return theme.background ? bg(theme.background, padded) : padded;
|
|
2834
|
+
})
|
|
2835
|
+
.join('\n');
|
|
2836
|
+
return (_jsx(Box, { flexDirection: "column", width: width, height: rows, children: _jsx(Text, { wrap: "truncate-end", children: text }) }));
|
|
2837
|
+
}
|
|
2838
|
+
function appendAssistant(items, messages, startedAt, modelName) {
|
|
2839
|
+
const nonUser = messages.filter((message) => message.role !== 'user');
|
|
2840
|
+
if (nonUser.length === 0)
|
|
2841
|
+
return items;
|
|
2842
|
+
const grouped = timelineFromMessages(nonUser);
|
|
2843
|
+
if (grouped.length === 0)
|
|
2844
|
+
return items;
|
|
2845
|
+
const assistant = grouped[0];
|
|
2846
|
+
assistant.durationMs = Date.now() - startedAt;
|
|
2847
|
+
assistant.modelName = modelName;
|
|
2848
|
+
return [...items, assistant];
|
|
2849
|
+
}
|
|
2850
|
+
function findDirectModel(reference, catalog, currentProvider) {
|
|
2851
|
+
if (currentProvider && findModel(catalog, currentProvider, reference)) {
|
|
2852
|
+
return { provider: currentProvider, model: reference };
|
|
2853
|
+
}
|
|
2854
|
+
const slash = reference.indexOf('/');
|
|
2855
|
+
if (slash > 0) {
|
|
2856
|
+
const provider = reference.slice(0, slash);
|
|
2857
|
+
const model = reference.slice(slash + 1);
|
|
2858
|
+
const found = providerById(catalog, provider);
|
|
2859
|
+
if (found && (found.models[model] || Object.values(found.models).some((entry) => entry.id === model))) {
|
|
2860
|
+
return { provider, model };
|
|
2861
|
+
}
|
|
2862
|
+
}
|
|
2863
|
+
return undefined;
|
|
2864
|
+
}
|
|
2865
|
+
function modelEntryForPicker(entries, providerFilter, query, selected) {
|
|
2866
|
+
const tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
2867
|
+
const filtered = entries
|
|
2868
|
+
.filter((entry) => !providerFilter || entry.provider.id === providerFilter)
|
|
2869
|
+
.filter((entry) => tokens.every((token) => entry.search.includes(token)))
|
|
2870
|
+
.slice(0, 2500);
|
|
2871
|
+
return filtered[selected];
|
|
2872
|
+
}
|
|
2873
|
+
function padBlock(lines, height, width) {
|
|
2874
|
+
const result = lines.slice(0, height).map((line) => fit(line, width));
|
|
2875
|
+
while (result.length < height)
|
|
2876
|
+
result.push(' '.repeat(width));
|
|
2877
|
+
return result;
|
|
2878
|
+
}
|
|
2879
|
+
function centerBlock(lines, height) {
|
|
2880
|
+
if (lines.length >= height)
|
|
2881
|
+
return lines.slice(0, height);
|
|
2882
|
+
const top = Math.max(0, Math.floor((height - lines.length) / 2));
|
|
2883
|
+
return [...Array.from({ length: top }, () => ''), ...lines];
|
|
2884
|
+
}
|
|
2885
|
+
function providerReady(provider, credentials, config) {
|
|
2886
|
+
if (!credentials.get(provider.id)?.configured)
|
|
2887
|
+
return false;
|
|
2888
|
+
if (!providerHasEndpoint(provider, config))
|
|
2889
|
+
return false;
|
|
2890
|
+
if (!localAvailable(provider, config))
|
|
2891
|
+
return false;
|
|
2892
|
+
return true;
|
|
2893
|
+
}
|
|
2894
|
+
function localAvailable(provider, config) {
|
|
2895
|
+
const endpoint = config.providers[provider.id]?.baseURL ??
|
|
2896
|
+
config.customProviders[provider.id]?.baseURL ??
|
|
2897
|
+
provider.api ??
|
|
2898
|
+
'';
|
|
2899
|
+
const localEndpoint = provider.local || /localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]/i.test(endpoint);
|
|
2900
|
+
if (!localEndpoint)
|
|
2901
|
+
return true;
|
|
2902
|
+
return Boolean(provider.discovered ||
|
|
2903
|
+
config.providers[provider.id]?.baseURL ||
|
|
2904
|
+
config.customProviders[provider.id]?.baseURL);
|
|
2905
|
+
}
|
|
2906
|
+
function formatAge(timestamp) {
|
|
2907
|
+
const delta = Math.max(0, Date.now() - timestamp);
|
|
2908
|
+
if (delta < 60_000)
|
|
2909
|
+
return 'just now';
|
|
2910
|
+
if (delta < 3_600_000)
|
|
2911
|
+
return `${Math.round(delta / 60_000)}m ago`;
|
|
2912
|
+
if (delta < 86_400_000)
|
|
2913
|
+
return `${Math.round(delta / 3_600_000)}h ago`;
|
|
2914
|
+
if (delta < 604_800_000)
|
|
2915
|
+
return `${Math.round(delta / 86_400_000)}d ago`;
|
|
2916
|
+
return new Date(timestamp).toISOString().slice(0, 10);
|
|
2917
|
+
}
|
|
2918
|
+
function describeError(error) {
|
|
2919
|
+
if (error instanceof SurfError) {
|
|
2920
|
+
return `${error.message}${error.hint ? `\n${error.hint}` : ''}`;
|
|
2921
|
+
}
|
|
2922
|
+
return error instanceof Error ? error.message : String(error);
|
|
2923
|
+
}
|
|
2924
|
+
function transcriptMarkdown(session) {
|
|
2925
|
+
const lines = [];
|
|
2926
|
+
lines.push(`# ${session.title || 'SurfCode session'}`);
|
|
2927
|
+
lines.push('');
|
|
2928
|
+
lines.push(`- **Model:** ${session.provider}/${session.model}`);
|
|
2929
|
+
lines.push(`- **Workspace:** \`${session.cwd}\``);
|
|
2930
|
+
lines.push(`- **Mode:** ${session.mode}`);
|
|
2931
|
+
lines.push(`- **Started:** ${new Date(session.createdAt).toISOString()}`);
|
|
2932
|
+
lines.push(`- **Updated:** ${new Date(session.updatedAt).toISOString()}`);
|
|
2933
|
+
lines.push(`- **Cost:** $${session.usage.cost.toFixed(4)}`);
|
|
2934
|
+
lines.push('');
|
|
2935
|
+
for (const message of session.messages) {
|
|
2936
|
+
if (message.role === 'user') {
|
|
2937
|
+
lines.push('## User');
|
|
2938
|
+
lines.push('');
|
|
2939
|
+
lines.push(message.content);
|
|
2940
|
+
lines.push('');
|
|
2941
|
+
continue;
|
|
2942
|
+
}
|
|
2943
|
+
if (message.role === 'assistant') {
|
|
2944
|
+
lines.push('## SurfCode');
|
|
2945
|
+
lines.push('');
|
|
2946
|
+
if (message.reasoning) {
|
|
2947
|
+
lines.push('<details><summary>Reasoning</summary>');
|
|
2948
|
+
lines.push('');
|
|
2949
|
+
lines.push(message.reasoning);
|
|
2950
|
+
lines.push('');
|
|
2951
|
+
lines.push('</details>');
|
|
2952
|
+
lines.push('');
|
|
2953
|
+
}
|
|
2954
|
+
if (message.content) {
|
|
2955
|
+
lines.push(message.content);
|
|
2956
|
+
lines.push('');
|
|
2957
|
+
}
|
|
2958
|
+
for (const call of message.toolCalls ?? []) {
|
|
2959
|
+
const summary = typeof call.arguments.command === 'string'
|
|
2960
|
+
? call.arguments.command
|
|
2961
|
+
: typeof call.arguments.path === 'string'
|
|
2962
|
+
? call.arguments.path
|
|
2963
|
+
: '';
|
|
2964
|
+
lines.push(`<details><summary>Tool: ${call.name}${summary ? ` — ${summary}` : ''}</summary>`);
|
|
2965
|
+
lines.push('');
|
|
2966
|
+
lines.push('```json');
|
|
2967
|
+
lines.push(JSON.stringify(call.arguments, null, 2));
|
|
2968
|
+
lines.push('```');
|
|
2969
|
+
lines.push('');
|
|
2970
|
+
lines.push('</details>');
|
|
2971
|
+
lines.push('');
|
|
2972
|
+
}
|
|
2973
|
+
continue;
|
|
2974
|
+
}
|
|
2975
|
+
lines.push(`### Tool result · ${message.name}${message.isError ? ' (error)' : ''}`);
|
|
2976
|
+
lines.push('');
|
|
2977
|
+
lines.push('```');
|
|
2978
|
+
lines.push(message.content);
|
|
2979
|
+
lines.push('```');
|
|
2980
|
+
lines.push('');
|
|
2981
|
+
}
|
|
2982
|
+
return `${lines.join('\n').trim()}\n`;
|
|
2983
|
+
}
|
|
2984
|
+
//# sourceMappingURL=App.js.map
|