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.
Files changed (117) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +325 -2
  3. package/assets/surfcode-logo.gif +0 -0
  4. package/dist/cli/auth.js +315 -0
  5. package/dist/cli/auth.js.map +1 -0
  6. package/dist/cli/config.js +228 -0
  7. package/dist/cli/config.js.map +1 -0
  8. package/dist/cli/headless.js +355 -0
  9. package/dist/cli/headless.js.map +1 -0
  10. package/dist/cli/inspect.js +462 -0
  11. package/dist/cli/inspect.js.map +1 -0
  12. package/dist/cli/interactive.js +130 -0
  13. package/dist/cli/interactive.js.map +1 -0
  14. package/dist/cli/sessions.js +170 -0
  15. package/dist/cli/sessions.js.map +1 -0
  16. package/dist/cli/sync.js +190 -0
  17. package/dist/cli/sync.js.map +1 -0
  18. package/dist/cli/util.js +345 -0
  19. package/dist/cli/util.js.map +1 -0
  20. package/dist/cli.js +485 -0
  21. package/dist/cli.js.map +1 -0
  22. package/dist/core/agent.js +298 -0
  23. package/dist/core/agent.js.map +1 -0
  24. package/dist/core/agents.js +55 -0
  25. package/dist/core/agents.js.map +1 -0
  26. package/dist/core/auth.js +107 -0
  27. package/dist/core/auth.js.map +1 -0
  28. package/dist/core/catalog.js +306 -0
  29. package/dist/core/catalog.js.map +1 -0
  30. package/dist/core/chat/anthropic.js +222 -0
  31. package/dist/core/chat/anthropic.js.map +1 -0
  32. package/dist/core/chat/common.js +367 -0
  33. package/dist/core/chat/common.js.map +1 -0
  34. package/dist/core/chat/google.js +180 -0
  35. package/dist/core/chat/google.js.map +1 -0
  36. package/dist/core/chat/index.js +80 -0
  37. package/dist/core/chat/index.js.map +1 -0
  38. package/dist/core/chat/openai.js +164 -0
  39. package/dist/core/chat/openai.js.map +1 -0
  40. package/dist/core/chat/types.js +2 -0
  41. package/dist/core/chat/types.js.map +1 -0
  42. package/dist/core/clipboard.js +34 -0
  43. package/dist/core/clipboard.js.map +1 -0
  44. package/dist/core/compact.js +39 -0
  45. package/dist/core/compact.js.map +1 -0
  46. package/dist/core/config.js +211 -0
  47. package/dist/core/config.js.map +1 -0
  48. package/dist/core/delegate.js +184 -0
  49. package/dist/core/delegate.js.map +1 -0
  50. package/dist/core/effort.js +80 -0
  51. package/dist/core/effort.js.map +1 -0
  52. package/dist/core/errors.js +32 -0
  53. package/dist/core/errors.js.map +1 -0
  54. package/dist/core/free.js +21 -0
  55. package/dist/core/free.js.map +1 -0
  56. package/dist/core/github.js +445 -0
  57. package/dist/core/github.js.map +1 -0
  58. package/dist/core/paths.js +51 -0
  59. package/dist/core/paths.js.map +1 -0
  60. package/dist/core/prompt.js +95 -0
  61. package/dist/core/prompt.js.map +1 -0
  62. package/dist/core/provider.js +298 -0
  63. package/dist/core/provider.js.map +1 -0
  64. package/dist/core/session.js +159 -0
  65. package/dist/core/session.js.map +1 -0
  66. package/dist/core/sync.js +126 -0
  67. package/dist/core/sync.js.map +1 -0
  68. package/dist/core/types.js +2 -0
  69. package/dist/core/types.js.map +1 -0
  70. package/dist/core/usage.js +127 -0
  71. package/dist/core/usage.js.map +1 -0
  72. package/dist/core/workspace.js +70 -0
  73. package/dist/core/workspace.js.map +1 -0
  74. package/dist/data/models.json +1 -0
  75. package/dist/sync/server.js +179 -0
  76. package/dist/sync/server.js.map +1 -0
  77. package/dist/tools/diff.js +21 -0
  78. package/dist/tools/diff.js.map +1 -0
  79. package/dist/tools/files.js +227 -0
  80. package/dist/tools/files.js.map +1 -0
  81. package/dist/tools/github.js +238 -0
  82. package/dist/tools/github.js.map +1 -0
  83. package/dist/tools/index.js +34 -0
  84. package/dist/tools/index.js.map +1 -0
  85. package/dist/tools/search.js +231 -0
  86. package/dist/tools/search.js.map +1 -0
  87. package/dist/tools/shell.js +118 -0
  88. package/dist/tools/shell.js.map +1 -0
  89. package/dist/tools/todos.js +50 -0
  90. package/dist/tools/todos.js.map +1 -0
  91. package/dist/tools/webfetch.js +108 -0
  92. package/dist/tools/webfetch.js.map +1 -0
  93. package/dist/tui/App.js +2984 -0
  94. package/dist/tui/App.js.map +1 -0
  95. package/dist/tui/ansi.js +151 -0
  96. package/dist/tui/ansi.js.map +1 -0
  97. package/dist/tui/commands.js +61 -0
  98. package/dist/tui/commands.js.map +1 -0
  99. package/dist/tui/explorer.js +152 -0
  100. package/dist/tui/explorer.js.map +1 -0
  101. package/dist/tui/icons.js +21 -0
  102. package/dist/tui/icons.js.map +1 -0
  103. package/dist/tui/layout.js +324 -0
  104. package/dist/tui/layout.js.map +1 -0
  105. package/dist/tui/markdown.js +355 -0
  106. package/dist/tui/markdown.js.map +1 -0
  107. package/dist/tui/overlays.js +316 -0
  108. package/dist/tui/overlays.js.map +1 -0
  109. package/dist/tui/render.js +234 -0
  110. package/dist/tui/render.js.map +1 -0
  111. package/dist/tui/theme.js +148 -0
  112. package/dist/tui/theme.js.map +1 -0
  113. package/dist/tui/types.js +73 -0
  114. package/dist/tui/types.js.map +1 -0
  115. package/dist/version.js +3 -0
  116. package/dist/version.js.map +1 -0
  117. package/package.json +75 -4
@@ -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