scalix 1.1.0 → 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PARITY-PLAN.md +116 -0
- package/bin/scalix.js +1 -0
- package/dist/agent.js +69 -31
- package/dist/api-client.js +44 -9
- package/dist/auto-verify.js +3 -1
- package/dist/cli.js +931 -183
- package/dist/commands.js +70 -2
- package/dist/constants.js +87 -6
- package/dist/context-retriever.js +1 -1
- package/dist/cost-tracker.js +21 -16
- package/dist/git-context.js +8 -5
- package/dist/grounding-checker.js +8 -1
- package/dist/headless.js +116 -33
- package/dist/hooks.js +195 -13
- package/dist/injection-detector.js +18 -1
- package/dist/ipc-server.js +7 -1
- package/dist/keychain.js +222 -0
- package/dist/markdown.js +49 -3
- package/dist/model-router.js +11 -0
- package/dist/plugins.js +132 -0
- package/dist/project-index.js +20 -1
- package/dist/sandbox.js +139 -9
- package/dist/scoped-rules.js +10 -1
- package/dist/sessions.js +65 -6
- package/dist/settings.js +124 -4
- package/dist/skills.js +269 -0
- package/dist/sub-agent.js +59 -2
- package/dist/terminal-notify.js +73 -0
- package/dist/text-utils.js +146 -0
- package/dist/token-counter.js +10 -1
- package/dist/tool-helper.js +12 -4
- package/dist/tools-browser.js +65 -6
- package/dist/tools-extra.js +44 -14
- package/dist/tools-web.js +76 -22
- package/dist/tools.js +27 -2
- package/dist/workspace-trust.js +90 -0
- package/package.json +3 -2
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useState, useEffect, useCallback, useRef, useMemo, memo } from 'react';
|
|
3
|
-
import { render, Box, Text, useInput, useApp
|
|
3
|
+
import { render, Box, Text, useInput, useApp } from 'ink';
|
|
4
4
|
import Spinner from 'ink-spinner';
|
|
5
5
|
import TextInput from 'ink-text-input';
|
|
6
6
|
import Gradient from 'ink-gradient';
|
|
7
7
|
import BigText from 'ink-big-text';
|
|
8
|
-
import { execSync, execFileSync } from 'node:child_process';
|
|
8
|
+
import { execSync, execFileSync, execFile, spawn } from 'node:child_process';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
10
|
+
import os from 'node:os';
|
|
9
11
|
import fs from 'node:fs/promises';
|
|
10
12
|
import { existsSync, readFileSync } from 'node:fs';
|
|
11
13
|
import path from 'node:path';
|
|
@@ -21,24 +23,62 @@ import { browserTools, closeBrowser } from './tools-browser.js';
|
|
|
21
23
|
import { loadMcpServices } from './mcp.js';
|
|
22
24
|
import { renderMarkdown } from './markdown.js';
|
|
23
25
|
import { costTracker } from './cost-tracker.js';
|
|
24
|
-
import { saveSession, listSessions, loadSession, generateSessionId } from './sessions.js';
|
|
26
|
+
import { saveSession, listSessions, loadSession, generateSessionId, saveCrashState, loadCrashState, clearCrashState, forkSession } from './sessions.js';
|
|
25
27
|
import { logger } from './logger.js';
|
|
26
|
-
import { VERSION, PRODUCT_NAME, DEFAULT_MODELS, DEFAULT_BASE_URL, DEFAULT_INSTRUCTIONS, ENV_API_KEY, ENV_MODEL, TOOL_DEFAULTS, HEADLESS_DEFAULTS, fetchModelSpecs } from './constants.js';
|
|
28
|
+
import { VERSION, PRODUCT_NAME, DEFAULT_MODELS, DEFAULT_BASE_URL, DEFAULT_INSTRUCTIONS, ENV_API_KEY, ENV_MODEL, TOOL_DEFAULTS, HEADLESS_DEFAULTS, GIT_BOT_IDENTITY, fetchModelSpecs } from './constants.js';
|
|
27
29
|
import { checkForUpdate, getUpdateBanner, performUpdate } from './update-checker.js';
|
|
28
30
|
import { loadConfig, saveConfig, isConfigured, DEFAULT_LIMITS, THINKING_MODES } from './settings.js';
|
|
29
|
-
import { getModeDescription, PERMISSION_MODES, setSandboxRoot } from './sandbox.js';
|
|
31
|
+
import { getModeDescription, PERMISSION_MODES, setSandboxRoot, checkScopedPermission, getScopedPermissionRules } from './sandbox.js';
|
|
30
32
|
import { buildProjectIndex, indexToContext } from './project-index.js';
|
|
31
33
|
import { collectGitInfo, gitInfoToContext } from './git-context.js';
|
|
32
34
|
import { loadMemory, autoDetectConventions, saveMemory, memoryToContext, addNote, detectCorrection } from './project-memory.js';
|
|
33
|
-
import { discoverPlugins, getPluginTools, pluginsSummary } from './plugins.js';
|
|
35
|
+
import { discoverPlugins, getPluginTools, pluginsSummary, installPlugin, uninstallPlugin, listInstalledPlugins, enablePlugin, disablePlugin } from './plugins.js';
|
|
34
36
|
import { createRouterConfig, getRouterStatus } from './model-router.js';
|
|
35
37
|
import { loadCustomCommands, expandCommand, commandsSummary } from './commands.js';
|
|
36
38
|
import { hooksSummary } from './hooks.js';
|
|
37
39
|
import { spawnSubAgent, pickCheapestModel } from './sub-agent.js';
|
|
38
40
|
import { createCheckpoint, undoLastCheckpoint, listCheckpoints } from './git-undo.js';
|
|
39
41
|
import { loadScopedRules, rulesToContext } from './scoped-rules.js';
|
|
42
|
+
import { loadSkills, skillsSummary, getSkillByTrigger, getSkillContent } from './skills.js';
|
|
40
43
|
import { AgentRegistry } from './agent-registry.js';
|
|
44
|
+
import { createWorktree, listWorktrees, removeWorktree, cleanupStaleWorktrees } from './worktree.js';
|
|
45
|
+
import { sendNotification, getTerminalType } from './terminal-notify.js';
|
|
46
|
+
import { stringWidth, truncateText } from './text-utils.js';
|
|
47
|
+
import { isWorkspaceTrusted, trustWorkspace, hasProjectExecSurface } from './workspace-trust.js';
|
|
41
48
|
logger.setLevel('silent');
|
|
49
|
+
// Promisified async child_process helpers (keep the TUI responsive vs execSync).
|
|
50
|
+
const execFileAsync = promisify(execFile);
|
|
51
|
+
// Run a shell command asynchronously, resolving with stdout/stderr like execSync's
|
|
52
|
+
// error shape (so callers can read err.stdout / err.stderr). Never blocks the event loop.
|
|
53
|
+
function runShellAsync(command, opts = {}) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const child = spawn(command, {
|
|
56
|
+
shell: true,
|
|
57
|
+
cwd: opts.cwd ?? process.cwd(),
|
|
58
|
+
timeout: opts.timeout,
|
|
59
|
+
});
|
|
60
|
+
let stdout = '';
|
|
61
|
+
let stderr = '';
|
|
62
|
+
child.stdout?.on('data', (d) => { stdout += d.toString(); });
|
|
63
|
+
child.stderr?.on('data', (d) => { stderr += d.toString(); });
|
|
64
|
+
child.on('error', (err) => { err.stdout = stdout; err.stderr = stderr; reject(err); });
|
|
65
|
+
child.on('close', (code) => {
|
|
66
|
+
if (code === 0)
|
|
67
|
+
resolve({ stdout, stderr });
|
|
68
|
+
else {
|
|
69
|
+
const err = new Error(`Command failed: ${command}`);
|
|
70
|
+
err.code = code;
|
|
71
|
+
err.stdout = stdout;
|
|
72
|
+
err.stderr = stderr;
|
|
73
|
+
reject(err);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
if (opts.input !== undefined) {
|
|
77
|
+
child.stdin?.write(opts.input);
|
|
78
|
+
child.stdin?.end();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
42
82
|
setSandboxRoot(process.cwd());
|
|
43
83
|
// Suppress noisy React 19 development-mode reconciler warnings that leak to stderr
|
|
44
84
|
const _origConsoleError = console.error;
|
|
@@ -47,20 +87,45 @@ console.error = (...args) => {
|
|
|
47
87
|
return;
|
|
48
88
|
_origConsoleError(...args);
|
|
49
89
|
};
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
90
|
+
// Enable bracketed paste mode so multi-line pastes arrive as a single chunk
|
|
91
|
+
// wrapped in \x1b[200~ … \x1b[201~ markers. We strip those markers (see
|
|
92
|
+
// sanitizeInput) and buffer the pasted content into the input value instead of
|
|
93
|
+
// treating each embedded newline as a separate Enter (which caused many submits).
|
|
94
|
+
process.stdout.write('\x1b[?2004h');
|
|
53
95
|
const mcpTools = loadMcpServices();
|
|
54
96
|
let pluginTools = [];
|
|
55
97
|
const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...mcpTools];
|
|
56
98
|
// ─── Confirmation bridge (event-driven) ───
|
|
57
99
|
import { EventEmitter } from 'node:events';
|
|
58
100
|
const confirmEmitter = new EventEmitter();
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
101
|
+
// FIFO queue of pending confirmations. Using a queue (instead of a single slot)
|
|
102
|
+
// ensures a second concurrent confirm can't overwrite/hang the first — each
|
|
103
|
+
// resolves in order and the next one is surfaced to the UI when the prior clears.
|
|
104
|
+
const confirmQueue = [];
|
|
105
|
+
// Resolve every queued confirmation (as denied) — used on abort / clear so no
|
|
106
|
+
// tool call is left hanging waiting for a decision that will never come.
|
|
107
|
+
function drainConfirmQueue(value = false) {
|
|
108
|
+
while (confirmQueue.length > 0) {
|
|
109
|
+
confirmQueue.shift().resolve(value);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const alwaysAllowedTools = new Set();
|
|
113
|
+
const recentDenials = [];
|
|
114
|
+
const confirmToolCall = async (toolName, args) => {
|
|
115
|
+
// Evaluate scoped permissions BEFORE consulting the session "Always allow" set,
|
|
116
|
+
// so a scoped `deny` still blocks even after the user pressed "Always".
|
|
117
|
+
const scoped = checkScopedPermission(toolName, args);
|
|
118
|
+
if (scoped === 'deny')
|
|
119
|
+
return false;
|
|
120
|
+
if (alwaysAllowedTools.has(toolName))
|
|
121
|
+
return true;
|
|
122
|
+
if (scoped === 'allow')
|
|
123
|
+
return true;
|
|
124
|
+
return new Promise((resolve) => {
|
|
125
|
+
confirmQueue.push({ resolve, toolName, args });
|
|
126
|
+
confirmEmitter.emit('pending');
|
|
127
|
+
});
|
|
128
|
+
};
|
|
64
129
|
// ─── Agent (deferred until config is ready) ───
|
|
65
130
|
let baseInstructions = DEFAULT_INSTRUCTIONS;
|
|
66
131
|
let agent = null;
|
|
@@ -69,6 +134,27 @@ let projectIndex = null;
|
|
|
69
134
|
let loadedPlugins = [];
|
|
70
135
|
let projectMemory = null;
|
|
71
136
|
let customCommands = [];
|
|
137
|
+
let loadedSkills = [];
|
|
138
|
+
// Set during initAgent when the workspace is untrusted but has project-local
|
|
139
|
+
// exec surface (plugins/hooks/rules/settings) that we skipped loading. The App
|
|
140
|
+
// reads this on mount to inform the user they can run /trust to enable them.
|
|
141
|
+
let workspaceUntrustedNotice = false;
|
|
142
|
+
// Discover and register project-local plugin tools for the given agent.
|
|
143
|
+
// Gated on workspace trust; reused by initAgent and the /trust command.
|
|
144
|
+
function discoverPluginsForAgent(targetAgent) {
|
|
145
|
+
loadedPlugins = [];
|
|
146
|
+
if (!isWorkspaceTrusted(process.cwd()))
|
|
147
|
+
return Promise.resolve();
|
|
148
|
+
return discoverPlugins(process.cwd()).then((plugins) => {
|
|
149
|
+
loadedPlugins = plugins;
|
|
150
|
+
const pTools = getPluginTools(plugins);
|
|
151
|
+
pluginTools = pTools;
|
|
152
|
+
for (const pt of pTools)
|
|
153
|
+
targetAgent.addTool(pt);
|
|
154
|
+
}).catch((err) => {
|
|
155
|
+
logger.warn('Plugin discovery failed', { error: err.message });
|
|
156
|
+
});
|
|
157
|
+
}
|
|
72
158
|
async function buildEnhancedInstructions() {
|
|
73
159
|
let instructions = baseInstructions;
|
|
74
160
|
const cwd = process.cwd();
|
|
@@ -98,13 +184,17 @@ async function buildEnhancedInstructions() {
|
|
|
98
184
|
instructions += '\n\n' + indexToContext(projectIndex);
|
|
99
185
|
}
|
|
100
186
|
catch { }
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
187
|
+
// Scoped rules are project-local content injected into the model context —
|
|
188
|
+
// only load them from a trusted workspace.
|
|
189
|
+
if (isWorkspaceTrusted(cwd)) {
|
|
190
|
+
try {
|
|
191
|
+
const rules = await loadScopedRules(cwd);
|
|
192
|
+
const rulesCtx = rulesToContext(rules);
|
|
193
|
+
if (rulesCtx)
|
|
194
|
+
instructions += '\n\n' + rulesCtx;
|
|
195
|
+
}
|
|
196
|
+
catch { }
|
|
106
197
|
}
|
|
107
|
-
catch { }
|
|
108
198
|
return instructions;
|
|
109
199
|
}
|
|
110
200
|
function initAgent(config) {
|
|
@@ -136,16 +226,11 @@ function initAgent(config) {
|
|
|
136
226
|
thinkingMode: config.thinkingMode || 'off',
|
|
137
227
|
});
|
|
138
228
|
const currentAgent = agent;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
for (const pt of pTools)
|
|
145
|
-
currentAgent.addTool(pt);
|
|
146
|
-
}).catch((err) => {
|
|
147
|
-
logger.warn('Plugin discovery failed', { error: err.message });
|
|
148
|
-
});
|
|
229
|
+
// Project-local plugins/hooks/rules are exec surface — only load from a
|
|
230
|
+
// trusted workspace. When skipped but present, flag it so the App can nudge
|
|
231
|
+
// the user toward /trust.
|
|
232
|
+
workspaceUntrustedNotice = !isWorkspaceTrusted(process.cwd()) && hasProjectExecSurface(process.cwd());
|
|
233
|
+
discoverPluginsForAgent(currentAgent);
|
|
149
234
|
buildEnhancedInstructions().then((enhanced) => {
|
|
150
235
|
currentAgent.setInstructions(enhanced);
|
|
151
236
|
}).catch((err) => {
|
|
@@ -154,28 +239,45 @@ function initAgent(config) {
|
|
|
154
239
|
loadCustomCommands(process.cwd()).then((cmds) => {
|
|
155
240
|
customCommands = cmds;
|
|
156
241
|
}).catch((err) => { logger.warn('Failed to load custom commands', { error: err.message }); });
|
|
242
|
+
loadSkills(process.cwd()).then((skills) => {
|
|
243
|
+
loadedSkills = skills;
|
|
244
|
+
}).catch((err) => { logger.warn('Failed to load skills', { error: err.message }); });
|
|
157
245
|
return agent;
|
|
158
246
|
}
|
|
159
247
|
// ─── Helpers ───
|
|
160
248
|
function shortModel(m) {
|
|
161
249
|
return m.split('/').pop().replace(/:free$/, '').replace(/-instruct$/, '');
|
|
162
250
|
}
|
|
251
|
+
const termSizeSubscribers = new Set();
|
|
252
|
+
let termSize = {
|
|
253
|
+
cols: process.stdout?.columns ?? 80,
|
|
254
|
+
rows: process.stdout?.rows ?? 24,
|
|
255
|
+
};
|
|
256
|
+
let termResizeListenerAttached = false;
|
|
257
|
+
function onTermResize() {
|
|
258
|
+
const cols = process.stdout?.columns ?? 80;
|
|
259
|
+
const rows = process.stdout?.rows ?? 24;
|
|
260
|
+
if (cols === termSize.cols && rows === termSize.rows)
|
|
261
|
+
return;
|
|
262
|
+
termSize = { cols, rows };
|
|
263
|
+
for (const notify of termSizeSubscribers)
|
|
264
|
+
notify();
|
|
265
|
+
}
|
|
266
|
+
function subscribeTermSize(cb) {
|
|
267
|
+
termSizeSubscribers.add(cb);
|
|
268
|
+
if (!termResizeListenerAttached) {
|
|
269
|
+
process.stdout?.on('resize', onTermResize);
|
|
270
|
+
termResizeListenerAttached = true;
|
|
271
|
+
}
|
|
272
|
+
return () => { termSizeSubscribers.delete(cb); };
|
|
273
|
+
}
|
|
163
274
|
function useTermSize() {
|
|
164
|
-
const
|
|
165
|
-
const [size, setSize] = useState({ cols: stdout?.columns ?? 80, rows: stdout?.rows ?? 24 });
|
|
275
|
+
const [size, setSize] = useState(termSize);
|
|
166
276
|
useEffect(() => {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (prev.cols === newCols && prev.rows === newRows)
|
|
172
|
-
return prev;
|
|
173
|
-
return { cols: newCols, rows: newRows };
|
|
174
|
-
});
|
|
175
|
-
};
|
|
176
|
-
stdout?.on('resize', fn);
|
|
177
|
-
return () => { stdout?.off('resize', fn); };
|
|
178
|
-
}, [stdout]);
|
|
277
|
+
// Sync any change that happened between initial render and subscribe.
|
|
278
|
+
setSize(termSize);
|
|
279
|
+
return subscribeTermSize(() => setSize(termSize));
|
|
280
|
+
}, []);
|
|
179
281
|
return size;
|
|
180
282
|
}
|
|
181
283
|
function elapsed(start) {
|
|
@@ -200,9 +302,63 @@ function formatTokens(n) {
|
|
|
200
302
|
const Banner = memo(function Banner() {
|
|
201
303
|
return (_jsx(Box, { flexDirection: "column", alignItems: "center", marginBottom: 0, children: _jsx(Gradient, { name: "teen", children: _jsx(BigText, { text: PRODUCT_NAME.toLowerCase(), font: "tiny" }) }) }));
|
|
202
304
|
});
|
|
203
|
-
// Strip bracketed paste markers and control chars that leak through ink's parser
|
|
305
|
+
// Strip bracketed paste markers and control chars that leak through ink's parser.
|
|
306
|
+
// Keeps newlines (\x0a) and tabs (\x09) so pasted multi-line content is preserved
|
|
307
|
+
// and buffered into the input value rather than being submitted line-by-line.
|
|
204
308
|
function sanitizeInput(v) {
|
|
205
|
-
return v
|
|
309
|
+
return v
|
|
310
|
+
// Full bracketed-paste markers (ESC [ 200~ / 201~) and the leftover form
|
|
311
|
+
// after ink strips the ESC byte ([200~ / [201~).
|
|
312
|
+
.replace(/\x1b?\[20[01]~/g, '')
|
|
313
|
+
// Strip control chars but preserve \t (\x09) and \n (\x0a).
|
|
314
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '');
|
|
315
|
+
}
|
|
316
|
+
async function tryPasteImage() {
|
|
317
|
+
const platform = os.platform();
|
|
318
|
+
const tmpFile = path.join(os.tmpdir(), `scalix-paste-${Date.now()}.png`);
|
|
319
|
+
// Quote the temp path so directories with spaces (e.g. "C:\Users\First Last")
|
|
320
|
+
// don't break the shell commands below.
|
|
321
|
+
const q = `"${tmpFile}"`;
|
|
322
|
+
try {
|
|
323
|
+
if (platform === 'darwin') {
|
|
324
|
+
try {
|
|
325
|
+
execSync(`pngpaste ${q}`, { stdio: 'pipe', timeout: 3000 });
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
const hasImage = execSync(`osascript -e 'clipboard info' 2>/dev/null | grep -q 'TIFF\\|PNG\\|JPEG' && echo yes || echo no`, { encoding: 'utf-8', timeout: 3000 }).trim();
|
|
329
|
+
if (hasImage !== 'yes')
|
|
330
|
+
return null;
|
|
331
|
+
execSync(`osascript -e 'set imgData to (the clipboard as «class PNGf»)' -e 'set fp to open for access POSIX file "${tmpFile}" with write permission' -e 'write imgData to fp' -e 'close access fp'`, { stdio: 'pipe', timeout: 5000 });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
else if (platform === 'win32') {
|
|
335
|
+
// Save the clipboard image via PowerShell. Get-Clipboard -Format Image
|
|
336
|
+
// returns a System.Drawing.Bitmap; Save() writes PNG to the temp path.
|
|
337
|
+
const psScript = `$img = Get-Clipboard -Format Image; ` +
|
|
338
|
+
`if ($img -ne $null) { $img.Save('${tmpFile.replace(/'/g, "''")}'); }`;
|
|
339
|
+
execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', psScript], { stdio: 'pipe', timeout: 5000 });
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
execSync(`xclip -selection clipboard -t image/png -o > ${q}`, { stdio: 'pipe', timeout: 3000 });
|
|
343
|
+
}
|
|
344
|
+
const { readFileSync, unlinkSync } = await import('node:fs');
|
|
345
|
+
const data = readFileSync(tmpFile);
|
|
346
|
+
try {
|
|
347
|
+
unlinkSync(tmpFile);
|
|
348
|
+
}
|
|
349
|
+
catch { }
|
|
350
|
+
if (data.length < 8)
|
|
351
|
+
return null;
|
|
352
|
+
return `data:image/png;base64,${data.toString('base64')}`;
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
try {
|
|
356
|
+
const { unlinkSync } = await import('node:fs');
|
|
357
|
+
unlinkSync(tmpFile);
|
|
358
|
+
}
|
|
359
|
+
catch { }
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
206
362
|
}
|
|
207
363
|
function LongTextInput({ value, onChange, onSubmit, placeholder, maxVisible = 80 }) {
|
|
208
364
|
const valueRef = useRef(value);
|
|
@@ -387,7 +543,7 @@ function SetupScreen({ onComplete, existingConfig }) {
|
|
|
387
543
|
});
|
|
388
544
|
}, placeholder: String(DEFAULT_LIMITS.maxContextTokens) })] })] })), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, italic: true, children: [stepNumber > 1 ? 'Backspace (empty) to go back \u2022 ' : '', "ESC to quit"] }) })] })] }));
|
|
389
545
|
}
|
|
390
|
-
const StatusBar = memo(function StatusBar({ status, model, toolCount, thinkStart, tokens, mode }) {
|
|
546
|
+
const StatusBar = memo(function StatusBar({ status, model, toolCount, thinkStart, tokens, mode, contextPercent, rateLimitWarn }) {
|
|
391
547
|
const { cols } = useTermSize();
|
|
392
548
|
const [elapsedStr, setElapsedStr] = useState('');
|
|
393
549
|
useEffect(() => {
|
|
@@ -402,7 +558,20 @@ const StatusBar = memo(function StatusBar({ status, model, toolCount, thinkStart
|
|
|
402
558
|
const modeColor = mode === 'yolo' ? 'red' : mode === 'auto' ? 'yellow' : mode === 'plan' ? 'blue' : 'green';
|
|
403
559
|
const statusIcon = status === 'thinking' ? '\u25D4' : status === 'error' ? '\u2718' : '\u25CF';
|
|
404
560
|
const statusColor = status === 'thinking' ? 'yellow' : status === 'error' ? 'red' : 'green';
|
|
405
|
-
|
|
561
|
+
// Subscribe to registry changes so this memoized component re-renders when
|
|
562
|
+
// agents spawn/complete, instead of reading a stale count during render only.
|
|
563
|
+
const [activeAgents, setActiveAgents] = useState(() => AgentRegistry.getInstance().listActive().length);
|
|
564
|
+
useEffect(() => {
|
|
565
|
+
const registry = AgentRegistry.getInstance();
|
|
566
|
+
const refresh = () => setActiveAgents(registry.listActive().length);
|
|
567
|
+
refresh();
|
|
568
|
+
registry.on('agent:spawned', refresh);
|
|
569
|
+
registry.on('agent:completed', refresh);
|
|
570
|
+
return () => {
|
|
571
|
+
registry.off('agent:spawned', refresh);
|
|
572
|
+
registry.off('agent:completed', refresh);
|
|
573
|
+
};
|
|
574
|
+
}, []);
|
|
406
575
|
const leftParts = [];
|
|
407
576
|
leftParts.push(shortModel(model));
|
|
408
577
|
if (tokens > 0)
|
|
@@ -410,10 +579,13 @@ const StatusBar = memo(function StatusBar({ status, model, toolCount, thinkStart
|
|
|
410
579
|
leftParts.push(`${toolCount} tools`);
|
|
411
580
|
if (activeAgents > 0)
|
|
412
581
|
leftParts.push(`${activeAgents} agent${activeAgents > 1 ? 's' : ''}`);
|
|
582
|
+
if (contextPercent > 50)
|
|
583
|
+
leftParts.push(`ctx ${contextPercent}%`);
|
|
413
584
|
const leftText = leftParts.join(' \u00B7 ');
|
|
414
|
-
const
|
|
585
|
+
const warnText = rateLimitWarn ? ' \u26A0 Rate limited' : '';
|
|
586
|
+
const rightLen = mode.length + VERSION.length + 6 + (elapsedStr ? elapsedStr.length + 3 : 0) + warnText.length;
|
|
415
587
|
const gap = Math.max(cols - leftText.length - rightLen - 8, 2);
|
|
416
|
-
return (_jsxs(Box, { paddingX: 2, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: statusColor, children: statusIcon }), _jsx(Text, { dimColor: true, children: leftText })] }), _jsx(Text, { children: ' '.repeat(gap) }), _jsxs(Box, { gap: 1, children: [_jsx(Text, { color: modeColor, bold: true, children: mode }), elapsedStr ? _jsxs(Text, { dimColor: true, children: ['\u00B7', " ", elapsedStr] }) : null, _jsxs(Text, { dimColor: true, children: ['\u00B7', " v", VERSION] })] })] }));
|
|
588
|
+
return (_jsxs(Box, { paddingX: 2, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: statusColor, children: statusIcon }), _jsx(Text, { dimColor: true, children: leftText })] }), _jsx(Text, { children: ' '.repeat(gap) }), _jsxs(Box, { gap: 1, children: [rateLimitWarn && _jsxs(Text, { color: "red", bold: true, children: ['\u26A0', " Rate limited"] }), _jsx(Text, { color: modeColor, bold: true, children: mode }), elapsedStr ? _jsxs(Text, { dimColor: true, children: ['\u00B7', " ", elapsedStr] }) : null, _jsxs(Text, { dimColor: true, children: ['\u00B7', " v", VERSION] })] })] }));
|
|
417
589
|
});
|
|
418
590
|
const WelcomeScreen = memo(function WelcomeScreen() {
|
|
419
591
|
return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 0, children: [_jsx(Text, { dimColor: true, children: " Type a message or use /help for commands. Press Esc to exit." }), _jsxs(Box, { marginTop: 0, paddingLeft: 2, gap: 1, flexWrap: "wrap", children: [_jsx(Text, { dimColor: true, children: "Tools:" }), ['shell', 'read', 'write', 'edit', 'search', 'find', 'git', 'web', 'tree'].map((t) => (_jsx(Text, { dimColor: true, bold: true, children: t }, t)))] })] }));
|
|
@@ -456,6 +628,12 @@ const ToolResultMessage = memo(function ToolResultMessage({ name, result }) {
|
|
|
456
628
|
const ToolBlockedMessage = memo(function ToolBlockedMessage({ text }) {
|
|
457
629
|
return (_jsxs(Box, { paddingLeft: 3, paddingRight: 2, gap: 1, children: [_jsx(Text, { color: "red", children: '\u25CF' }), _jsx(Text, { color: "red", children: text })] }));
|
|
458
630
|
});
|
|
631
|
+
const ToolGroupMessage = memo(function ToolGroupMessage({ count, children, collapsed }) {
|
|
632
|
+
if (collapsed || !children) {
|
|
633
|
+
return (_jsxs(Box, { paddingLeft: 3, paddingRight: 2, gap: 1, marginTop: 1, children: [_jsx(Text, { color: "cyan", children: '○' }), _jsxs(Text, { dimColor: true, children: ["Ran ", count, " tools"] }), _jsx(Text, { dimColor: true, italic: true, children: "(Ctrl+O to expand)" })] }));
|
|
634
|
+
}
|
|
635
|
+
return (_jsx(Box, { flexDirection: "column", children: children.map(child => _jsx(RenderItem, { item: child }, child.id)) }));
|
|
636
|
+
});
|
|
459
637
|
const DIFF_MAX_LINES = 60;
|
|
460
638
|
const DiffMessage = memo(function DiffMessage({ text, meta }) {
|
|
461
639
|
const allLines = text.split('\n');
|
|
@@ -491,6 +669,7 @@ const RenderItem = memo(function RenderItem({ item }) {
|
|
|
491
669
|
case 'tool-call': return _jsx(ToolCallMessage, { name: item.meta ?? '', args: item.text });
|
|
492
670
|
case 'tool-result': return _jsx(ToolResultMessage, { name: item.meta ?? '', result: item.text });
|
|
493
671
|
case 'tool-blocked': return _jsx(ToolBlockedMessage, { text: item.text });
|
|
672
|
+
case 'tool-group': return _jsx(ToolGroupMessage, { count: item.children?.length ?? 0, children: item.children, collapsed: item.collapsed });
|
|
494
673
|
case 'diff': return _jsx(DiffMessage, { text: item.text, meta: item.meta });
|
|
495
674
|
case 'system': return _jsx(SystemMessage, { text: item.text });
|
|
496
675
|
}
|
|
@@ -519,14 +698,20 @@ const StreamingArea = memo(function StreamingArea({ text, reasoning, model, maxL
|
|
|
519
698
|
function ConfirmPrompt({ toolName, args, onRespond, onScrollUp, onScrollDown }) {
|
|
520
699
|
const { cols } = useTermSize();
|
|
521
700
|
useInput((ch, key) => {
|
|
522
|
-
if (ch.toLowerCase() === '
|
|
701
|
+
if (ch.toLowerCase() === 'a')
|
|
702
|
+
onRespond(true, true);
|
|
703
|
+
else if (ch.toLowerCase() === 'y')
|
|
523
704
|
onRespond(true);
|
|
524
705
|
else if (ch.toLowerCase() === 'n' || key.escape)
|
|
525
706
|
onRespond(false);
|
|
526
|
-
else if (key.pageUp
|
|
527
|
-
onScrollUp?.();
|
|
528
|
-
else if (key.
|
|
529
|
-
|
|
707
|
+
else if (key.pageUp)
|
|
708
|
+
onScrollUp?.(15);
|
|
709
|
+
else if (key.upArrow && key.shift)
|
|
710
|
+
onScrollUp?.(5);
|
|
711
|
+
else if (key.pageDown)
|
|
712
|
+
onScrollDown?.(15);
|
|
713
|
+
else if (key.downArrow && key.shift)
|
|
714
|
+
onScrollDown?.(5);
|
|
530
715
|
});
|
|
531
716
|
let parsed = {};
|
|
532
717
|
try {
|
|
@@ -538,16 +723,29 @@ function ConfirmPrompt({ toolName, args, onRespond, onScrollUp, onScrollDown })
|
|
|
538
723
|
const isCommand = toolName === 'run_command';
|
|
539
724
|
const isWrite = toolName === 'write_file' || toolName === 'edit_file' || toolName === 'notebook_edit';
|
|
540
725
|
const isGit = toolName === 'git_commit';
|
|
726
|
+
// Flatten newlines to a single-line glyph so multi-line values (e.g. write_file
|
|
727
|
+
// content, edit_file old/new strings) can't shatter the box border.
|
|
728
|
+
const flatten = (s) => s.replace(/\r?\n/g, '\u23CE');
|
|
541
729
|
const highlight = parsed.command ?? parsed.filePath ?? parsed.file_path ?? parsed.notebook_path ?? parsed.message ?? null;
|
|
542
730
|
const highlightLabel = isCommand ? 'Command' : isGit ? 'Message' : isWrite ? 'File' : 'Args';
|
|
543
|
-
const highlightStr = highlight ? String(highlight) : null;
|
|
544
|
-
|
|
731
|
+
const highlightStr = highlight ? flatten(String(highlight)) : null;
|
|
732
|
+
// Exclude large multi-line payloads (content + edit old/new strings) from the
|
|
733
|
+
// extra-field rows \u2014 showing them here just corrupts the box layout.
|
|
734
|
+
const extraFields = Object.entries(parsed).filter(([k]) => !['command', 'filePath', 'file_path', 'notebook_path', 'message', 'content',
|
|
735
|
+
'oldString', 'newString', 'old_string', 'new_string'].includes(k));
|
|
545
736
|
const boxWidth = Math.min(cols - 2, 80);
|
|
546
737
|
const border = '\u2500'.repeat(boxWidth - 2);
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
738
|
+
// Pad the interior of a box row to (boxWidth - 2) columns using display width
|
|
739
|
+
// (stringWidth), so wide/CJK chars keep the right border aligned. `used` is the
|
|
740
|
+
// display width already emitted between the two \u2502 borders.
|
|
741
|
+
const pad = (used) => ' '.repeat(Math.max(0, boxWidth - 2 - used));
|
|
742
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { color: "yellow", children: ['\u250C', border, '\u2510'] }), _jsxs(Text, { color: "yellow", children: ['\u2502', " ", _jsxs(Text, { color: "yellow", bold: true, children: ['\u26A0', " Permission Required"] }), _jsx(Text, { children: ' '.repeat(Math.max(0, boxWidth - 24)) }), '\u2502'] }), _jsxs(Text, { color: "yellow", children: ['\u2502', " ", _jsx(Text, { dimColor: true, children: "Tool:" }), " ", _jsx(Text, { color: "cyan", bold: true, children: toolName }), _jsx(Text, { children: pad(7 + stringWidth(toolName)) }), '\u2502'] }), highlightStr && (() => {
|
|
743
|
+
const shown = truncateText(highlightStr, boxWidth - highlightLabel.length - 6);
|
|
744
|
+
return (_jsxs(Text, { color: "yellow", children: ['\u2502', " ", _jsxs(Text, { dimColor: true, children: [highlightLabel, ":"] }), " ", _jsx(Text, { color: "white", bold: true, children: shown }), _jsx(Text, { children: pad(highlightLabel.length + 3 + stringWidth(shown)) }), '\u2502'] }));
|
|
745
|
+
})(), extraFields.slice(0, 3).map(([k, v]) => {
|
|
746
|
+
const val = truncateText(flatten(String(v)), boxWidth - k.length - 6);
|
|
747
|
+
return (_jsxs(Text, { color: "yellow", children: ['\u2502', " ", _jsxs(Text, { dimColor: true, children: [k, ":"] }), " ", _jsx(Text, { children: val }), _jsx(Text, { children: pad(k.length + 3 + stringWidth(val)) }), '\u2502'] }, k));
|
|
748
|
+
}), _jsxs(Text, { color: "yellow", children: ['\u251C', border, '\u2524'] }), _jsxs(Text, { color: "yellow", children: ['\u2502', " ", _jsx(Text, { color: "green", bold: true, children: "[Y]" }), _jsx(Text, { children: " Allow " }), _jsx(Text, { color: "cyan", bold: true, children: "[A]" }), _jsx(Text, { children: " Always " }), _jsx(Text, { color: "red", bold: true, children: "[N]" }), _jsx(Text, { children: " Deny " }), _jsx(Text, { dimColor: true, children: "ESC deny" }), _jsx(Text, { children: ' '.repeat(Math.max(0, boxWidth - 42)) }), '\u2502'] }), _jsxs(Text, { color: "yellow", children: ['\u2514', border, '\u2518'] })] }));
|
|
551
749
|
}
|
|
552
750
|
function ModelPicker({ currentModels, onSelect, onCancel }) {
|
|
553
751
|
const [customInput, setCustomInput] = useState('');
|
|
@@ -733,7 +931,7 @@ function SettingsEditor({ onSave, onClose }) {
|
|
|
733
931
|
}
|
|
734
932
|
} })] }));
|
|
735
933
|
}
|
|
736
|
-
const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, onClear, onScrollUp, onScrollDown, onHeightChange }) {
|
|
934
|
+
const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, onClear, onScrollUp, onScrollDown, onHeightChange, onToggleToolGroups, onImagePaste, onExit }) {
|
|
737
935
|
const { exit } = useApp();
|
|
738
936
|
const { cols } = useTermSize();
|
|
739
937
|
const line = useMemo(() => '\u2500'.repeat(Math.max(cols - 6, 20)), [cols]);
|
|
@@ -744,7 +942,11 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
|
|
|
744
942
|
const [history, setHistory] = useState([]);
|
|
745
943
|
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
746
944
|
const savedInput = useRef('');
|
|
945
|
+
const [searchMode, setSearchMode] = useState(false);
|
|
946
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
947
|
+
const ctrlXPending = useRef(false);
|
|
747
948
|
const valueRef = useRef('');
|
|
949
|
+
const [imageChips, setImageChips] = useState([]);
|
|
748
950
|
const onChange = useCallback((v) => {
|
|
749
951
|
setValue(v);
|
|
750
952
|
valueRef.current = v;
|
|
@@ -761,6 +963,9 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
|
|
|
761
963
|
setHistoryIndex(-1);
|
|
762
964
|
setValue('');
|
|
763
965
|
valueRef.current = '';
|
|
966
|
+
// Clear image chips: pending images were consumed by this submit, so the
|
|
967
|
+
// indicators shouldn't linger and imply they'll attach to the next message.
|
|
968
|
+
setImageChips([]);
|
|
764
969
|
onSubmit(trimmed);
|
|
765
970
|
}, [onSubmit]);
|
|
766
971
|
useInput((ch, key) => {
|
|
@@ -775,16 +980,20 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
|
|
|
775
980
|
onChange('');
|
|
776
981
|
return;
|
|
777
982
|
}
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
983
|
+
// Keep the state updater pure — decide whether to exit outside of it,
|
|
984
|
+
// so StrictMode's double-invoke can't trigger exit() on the first press.
|
|
985
|
+
if (ctrlCCount >= 1) {
|
|
986
|
+
if (ctrlCTimer.current)
|
|
987
|
+
clearTimeout(ctrlCTimer.current);
|
|
988
|
+
setCtrlCCount(0);
|
|
989
|
+
exit();
|
|
990
|
+
}
|
|
991
|
+
else {
|
|
783
992
|
if (ctrlCTimer.current)
|
|
784
993
|
clearTimeout(ctrlCTimer.current);
|
|
785
994
|
ctrlCTimer.current = setTimeout(() => setCtrlCCount(0), 3000);
|
|
786
|
-
|
|
787
|
-
}
|
|
995
|
+
setCtrlCCount(1);
|
|
996
|
+
}
|
|
788
997
|
return;
|
|
789
998
|
}
|
|
790
999
|
// ── Ctrl+D: exit when input is empty ──
|
|
@@ -814,6 +1023,88 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
|
|
|
814
1023
|
onChange('');
|
|
815
1024
|
return;
|
|
816
1025
|
}
|
|
1026
|
+
// ── Alt+Enter / Meta+Enter: insert newline ──
|
|
1027
|
+
if (key.return && key.meta && !disabled) {
|
|
1028
|
+
onChange(val + '\n');
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
// ── Ctrl+X Ctrl+E: open external editor ──
|
|
1032
|
+
if (ch === 'x' && key.ctrl && !disabled) {
|
|
1033
|
+
ctrlXPending.current = true;
|
|
1034
|
+
setTimeout(() => { ctrlXPending.current = false; }, 1000);
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
if (ch === 'e' && key.ctrl && ctrlXPending.current && !disabled) {
|
|
1038
|
+
ctrlXPending.current = false;
|
|
1039
|
+
const editor = process.env.EDITOR || process.env.VISUAL || 'vi';
|
|
1040
|
+
const tmpFile = path.join(os.tmpdir(), `scalix-input-${Date.now()}.txt`);
|
|
1041
|
+
import('node:fs').then(async (fs) => {
|
|
1042
|
+
fs.writeFileSync(tmpFile, val);
|
|
1043
|
+
const { execSync } = await import('node:child_process');
|
|
1044
|
+
try {
|
|
1045
|
+
execSync(`${editor} ${tmpFile}`, { stdio: 'inherit' });
|
|
1046
|
+
const edited = fs.readFileSync(tmpFile, 'utf-8');
|
|
1047
|
+
onChange(edited.trimEnd());
|
|
1048
|
+
}
|
|
1049
|
+
catch { /* editor exited with error */ }
|
|
1050
|
+
try {
|
|
1051
|
+
fs.unlinkSync(tmpFile);
|
|
1052
|
+
}
|
|
1053
|
+
catch { /* cleanup */ }
|
|
1054
|
+
});
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
// ── Ctrl+O: toggle collapsed tool groups ──
|
|
1058
|
+
if (ch === 'o' && key.ctrl) {
|
|
1059
|
+
onToggleToolGroups?.();
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
// ── Ctrl+R: reverse history search ──
|
|
1063
|
+
if (ch === 'r' && key.ctrl && !disabled) {
|
|
1064
|
+
if (searchMode) {
|
|
1065
|
+
setSearchMode(false);
|
|
1066
|
+
setSearchQuery('');
|
|
1067
|
+
}
|
|
1068
|
+
else {
|
|
1069
|
+
setSearchMode(true);
|
|
1070
|
+
setSearchQuery('');
|
|
1071
|
+
}
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
// ── Search mode input handling ──
|
|
1075
|
+
if (searchMode && !disabled) {
|
|
1076
|
+
if (key.return) {
|
|
1077
|
+
// Accept found match
|
|
1078
|
+
setSearchMode(false);
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
if (key.escape) {
|
|
1082
|
+
setSearchMode(false);
|
|
1083
|
+
setSearchQuery('');
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
if (key.backspace || key.delete) {
|
|
1087
|
+
const newQ = searchQuery.slice(0, -1);
|
|
1088
|
+
setSearchQuery(newQ);
|
|
1089
|
+
const match = history.slice().reverse().find(h => h.toLowerCase().includes(newQ.toLowerCase()));
|
|
1090
|
+
if (match) {
|
|
1091
|
+
setValue(match);
|
|
1092
|
+
valueRef.current = match;
|
|
1093
|
+
}
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
if (!key.ctrl && !key.meta && !key.tab && ch.length > 0) {
|
|
1097
|
+
const newQ = searchQuery + ch;
|
|
1098
|
+
setSearchQuery(newQ);
|
|
1099
|
+
const match = history.slice().reverse().find(h => h.toLowerCase().includes(newQ.toLowerCase()));
|
|
1100
|
+
if (match) {
|
|
1101
|
+
setValue(match);
|
|
1102
|
+
valueRef.current = match;
|
|
1103
|
+
}
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
817
1108
|
// ── Ctrl+S: stash / restore prompt ──
|
|
818
1109
|
if (ch === 's' && key.ctrl && !disabled) {
|
|
819
1110
|
if (val.length > 0) {
|
|
@@ -826,6 +1117,16 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
|
|
|
826
1117
|
}
|
|
827
1118
|
return;
|
|
828
1119
|
}
|
|
1120
|
+
// ── Ctrl+V: paste image from clipboard ──
|
|
1121
|
+
if (ch === 'v' && key.ctrl && !disabled) {
|
|
1122
|
+
tryPasteImage().then(dataUrl => {
|
|
1123
|
+
if (dataUrl) {
|
|
1124
|
+
setImageChips(prev => [...prev, dataUrl]);
|
|
1125
|
+
onImagePaste?.(dataUrl);
|
|
1126
|
+
}
|
|
1127
|
+
}).catch(() => { });
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
829
1130
|
// ── Tab: autocomplete slash commands ──
|
|
830
1131
|
if (key.tab && !disabled && val.startsWith('/') && !val.includes(' ')) {
|
|
831
1132
|
const cmdNames = commands.map((c) => c.cmd);
|
|
@@ -834,13 +1135,34 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
|
|
|
834
1135
|
onChange(matches[0]);
|
|
835
1136
|
return;
|
|
836
1137
|
}
|
|
837
|
-
// ── Escape: close
|
|
1138
|
+
// ── Escape: close picker → clear input → abort task → exit ──
|
|
838
1139
|
if (key.escape) {
|
|
1140
|
+
// 1. @-file picker open → close it by removing the trailing @query.
|
|
1141
|
+
const atOpen = !!projectIndex && /@(\S*)$/.test(val);
|
|
1142
|
+
if (atOpen) {
|
|
1143
|
+
onChange(val.replace(/@\S*$/, ''));
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
// 2. Command picker open → close it.
|
|
839
1147
|
if (val.startsWith('/') && !val.includes(' ')) {
|
|
840
1148
|
onChange('');
|
|
841
1149
|
return;
|
|
842
1150
|
}
|
|
843
|
-
|
|
1151
|
+
// 3. Typed input present → clear it (don't lose work by exiting).
|
|
1152
|
+
if (val.length > 0) {
|
|
1153
|
+
onChange('');
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
// 4. A task is running → abort it rather than exit.
|
|
1157
|
+
if (disabled) {
|
|
1158
|
+
onAbort();
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
// 5. Nothing typed and idle → save session and exit (like /exit).
|
|
1162
|
+
if (onExit)
|
|
1163
|
+
onExit();
|
|
1164
|
+
else
|
|
1165
|
+
exit();
|
|
844
1166
|
return;
|
|
845
1167
|
}
|
|
846
1168
|
// ── When command picker is open, handle input manually since TextInput is unfocused ──
|
|
@@ -859,12 +1181,20 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
|
|
|
859
1181
|
return;
|
|
860
1182
|
}
|
|
861
1183
|
// ── Scroll ──
|
|
862
|
-
if (key.pageUp
|
|
863
|
-
onScrollUp();
|
|
1184
|
+
if (key.pageUp) {
|
|
1185
|
+
onScrollUp(15);
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
if (key.upArrow && key.shift) {
|
|
1189
|
+
onScrollUp(5);
|
|
864
1190
|
return;
|
|
865
1191
|
}
|
|
866
|
-
if (key.pageDown
|
|
867
|
-
onScrollDown();
|
|
1192
|
+
if (key.pageDown) {
|
|
1193
|
+
onScrollDown(15);
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
if (key.downArrow && key.shift) {
|
|
1197
|
+
onScrollDown(5);
|
|
868
1198
|
return;
|
|
869
1199
|
}
|
|
870
1200
|
// ── History navigation ──
|
|
@@ -906,6 +1236,8 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
|
|
|
906
1236
|
{ cmd: '/copy', desc: 'Copy last response to clipboard' },
|
|
907
1237
|
{ cmd: '/save', desc: 'Save current session' },
|
|
908
1238
|
{ cmd: '/load', desc: 'Resume a saved session' },
|
|
1239
|
+
{ cmd: '/resume', desc: 'Resume from crash recovery or saved session' },
|
|
1240
|
+
{ cmd: '/fork', desc: 'Fork conversation from a point in history' },
|
|
909
1241
|
{ cmd: '/export', desc: 'Export conversation as text' },
|
|
910
1242
|
{ cmd: '/index', desc: 'Scan & index project files' },
|
|
911
1243
|
{ cmd: '/init', desc: 'Initialize project config (SCALIX.md)' },
|
|
@@ -914,15 +1246,18 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
|
|
|
914
1246
|
{ cmd: '/remember', desc: 'Save a note about this project' },
|
|
915
1247
|
{ cmd: '/replay', desc: 'Show session timeline' },
|
|
916
1248
|
{ cmd: '/plugins', desc: 'Show loaded plugins' },
|
|
1249
|
+
{ cmd: '/trust', desc: 'Trust this workspace (load project plugins/hooks/rules)' },
|
|
917
1250
|
{ cmd: '/router', desc: 'Show model routing status' },
|
|
918
1251
|
{ cmd: '/status', desc: 'Version, model & connection info' },
|
|
919
1252
|
{ cmd: '/doctor', desc: 'Diagnose installation' },
|
|
920
1253
|
{ cmd: '/mcp', desc: 'Add MCP server' },
|
|
921
1254
|
{ cmd: '/setup', desc: 'Reconfigure API key, URL & models' },
|
|
922
1255
|
{ cmd: '/settings', desc: 'View & configure limits' },
|
|
1256
|
+
{ cmd: '/permissions', desc: 'View permission rules & recent denials' },
|
|
923
1257
|
{ cmd: '/shortcuts', desc: 'Keyboard shortcuts' },
|
|
924
1258
|
{ cmd: '/hooks', desc: 'Show lifecycle hooks' },
|
|
925
1259
|
{ cmd: '/commands', desc: 'Show custom commands' },
|
|
1260
|
+
{ cmd: '/skills', desc: 'List available skills' },
|
|
926
1261
|
{ cmd: '/delegate', desc: 'Run task with sub-agent' },
|
|
927
1262
|
{ cmd: '/agents', desc: 'Show active agents & usage' },
|
|
928
1263
|
{ cmd: '/checkpoint', desc: 'Create git checkpoint' },
|
|
@@ -932,6 +1267,9 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
|
|
|
932
1267
|
{ cmd: '/review', desc: 'AI code review of current changes' },
|
|
933
1268
|
{ cmd: '/watch', desc: 'Run command, auto-fix on failure' },
|
|
934
1269
|
{ cmd: '/create', desc: 'Scaffold a new project' },
|
|
1270
|
+
{ cmd: '/worktree', desc: 'Manage git worktrees for isolated work' },
|
|
1271
|
+
{ cmd: '/plugin', desc: 'Install/enable/disable/uninstall plugins' },
|
|
1272
|
+
{ cmd: '/notify', desc: 'Test terminal notification' },
|
|
935
1273
|
{ cmd: '/exit', desc: 'Save & exit' },
|
|
936
1274
|
...customCommands.map((c) => ({ cmd: `/${c.name}`, desc: c.description })),
|
|
937
1275
|
], [customCommands]);
|
|
@@ -940,18 +1278,37 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
|
|
|
940
1278
|
const filteredCommands = showCommandPicker
|
|
941
1279
|
? commands.filter((c) => c.cmd.startsWith(prefix))
|
|
942
1280
|
: [];
|
|
943
|
-
|
|
1281
|
+
// @-mention file typeahead
|
|
1282
|
+
const atMatch = value.match(/@(\S*)$/);
|
|
1283
|
+
const atQuery = atMatch?.[1]?.toLowerCase() ?? '';
|
|
1284
|
+
const showFilePicker = !disabled && !!atMatch && projectIndex;
|
|
1285
|
+
const filteredFiles = useMemo(() => {
|
|
1286
|
+
if (!showFilePicker || !projectIndex)
|
|
1287
|
+
return [];
|
|
1288
|
+
const q = atQuery;
|
|
1289
|
+
return projectIndex.files
|
|
1290
|
+
.map(f => f.relativePath)
|
|
1291
|
+
.filter(p => {
|
|
1292
|
+
if (!q)
|
|
1293
|
+
return true;
|
|
1294
|
+
const name = p.split('/').pop()?.toLowerCase() ?? '';
|
|
1295
|
+
return name.includes(q) || p.toLowerCase().includes(q);
|
|
1296
|
+
})
|
|
1297
|
+
.slice(0, 10);
|
|
1298
|
+
}, [showFilePicker, atQuery]);
|
|
1299
|
+
const pickerActive = (showCommandPicker && filteredCommands.length > 0) || (showFilePicker && filteredFiles.length > 0);
|
|
944
1300
|
useEffect(() => {
|
|
945
1301
|
if (!onHeightChange)
|
|
946
1302
|
return;
|
|
947
1303
|
let h = 3; // separator + input row + padding
|
|
1304
|
+
const pickerCount = showCommandPicker ? filteredCommands.length : filteredFiles.length;
|
|
948
1305
|
if (pickerActive)
|
|
949
|
-
h += Math.min(
|
|
1306
|
+
h += Math.min(pickerCount, 10) + 2;
|
|
950
1307
|
if (stash.length > 0 && !disabled)
|
|
951
1308
|
h += 1;
|
|
952
1309
|
onHeightChange(h);
|
|
953
|
-
}, [pickerActive, filteredCommands.length, stash.length, disabled, onHeightChange]);
|
|
954
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { paddingX: 2, children: _jsx(Text, { dimColor: true, children: line }) }),
|
|
1310
|
+
}, [pickerActive, filteredCommands.length, filteredFiles.length, stash.length, disabled, onHeightChange, showCommandPicker]);
|
|
1311
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { paddingX: 2, children: _jsx(Text, { dimColor: true, children: line }) }), showCommandPicker && filteredCommands.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingX: 3, marginX: 0, paddingY: 0, children: [_jsxs(Box, { gap: 1, marginBottom: 0, children: [_jsx(Text, { dimColor: true, bold: true, children: "Commands" }), _jsxs(Text, { dimColor: true, children: ["(", filteredCommands.length, ")"] })] }), _jsx(SelectInput, { items: filteredCommands.map((c) => ({
|
|
955
1312
|
label: `${c.cmd.padEnd(16)} ${c.desc}`,
|
|
956
1313
|
value: c.cmd,
|
|
957
1314
|
})), isFocused: true, onSelect: (item) => {
|
|
@@ -963,17 +1320,25 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
|
|
|
963
1320
|
onChange('');
|
|
964
1321
|
handleSubmit(item.value);
|
|
965
1322
|
}
|
|
966
|
-
}, limit: 10 }), _jsxs(Text, { dimColor: true, italic: true, children: ['↑↓', " navigate ", '•', " Enter select ", '•', " keep typing to filter"] })] })),
|
|
1323
|
+
}, limit: 10 }), _jsxs(Text, { dimColor: true, italic: true, children: ['↑↓', " navigate ", '•', " Enter select ", '•', " keep typing to filter"] })] })), showFilePicker && filteredFiles.length > 0 && !showCommandPicker && (_jsxs(Box, { flexDirection: "column", paddingX: 3, marginX: 0, paddingY: 0, children: [_jsxs(Box, { gap: 1, marginBottom: 0, children: [_jsx(Text, { dimColor: true, bold: true, children: "Files" }), _jsxs(Text, { dimColor: true, children: ["(", filteredFiles.length, ")"] })] }), _jsx(SelectInput, { items: filteredFiles.map((f) => ({
|
|
1324
|
+
label: f,
|
|
1325
|
+
value: f,
|
|
1326
|
+
})), isFocused: true, onSelect: (item) => {
|
|
1327
|
+
// Replace @query with the selected file path
|
|
1328
|
+
const newValue = value.replace(/@\S*$/, '@' + item.value + ' ');
|
|
1329
|
+
onChange(newValue);
|
|
1330
|
+
}, limit: 10 }), _jsxs(Text, { dimColor: true, italic: true, children: ['↑↓', " navigate ", '•', " Enter select ", '•', " keep typing to filter"] })] })), searchMode && (_jsxs(Box, { paddingX: 3, children: [_jsxs(Text, { color: "cyan", dimColor: true, children: ['🔍', " reverse-i-search: "] }), _jsx(Text, { color: "cyan", bold: true, children: searchQuery || '' }), _jsx(Text, { dimColor: true, children: " (Enter to select, Esc to cancel)" })] })), imageChips.length > 0 && !disabled && (_jsxs(Box, { paddingX: 3, gap: 1, children: [imageChips.map((_, i) => (_jsxs(Text, { color: "blue", bold: true, children: ["[Image #", i + 1, "]"] }, i))), _jsxs(Text, { dimColor: true, children: ["(", imageChips.length, " pasted)"] })] })), stash.length > 0 && !disabled && !searchMode && (_jsxs(Box, { paddingX: 3, children: [_jsxs(Text, { color: "magenta", dimColor: true, children: ['⚑', " Stashed: ", truncate(stash, 40), " "] }), _jsx(Text, { dimColor: true, children: "(Ctrl+S to restore)" })] })), _jsx(Box, { paddingX: 2, children: _jsxs(Box, { flexGrow: 1, children: [_jsx(Box, { flexShrink: 0, width: 2, children: disabled ? (_jsx(Text, { color: "magentaBright", children: _jsx(Spinner, { type: "dots" }) })) : (_jsx(Text, { color: "magentaBright", bold: true, children: '❯' })) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { value: value, onChange: onChange, onSubmit: handleSubmit, placeholder: disabled ? 'Queue a message... (sent after current task)' : 'Type a message...', showCursor: true, focus: !pickerActive }) }), disabled && (_jsx(Box, { flexShrink: 0, children: _jsx(Text, { dimColor: true, children: " ^C interrupt" }) }))] }) })] }));
|
|
967
1331
|
});
|
|
968
1332
|
// ─── App ───
|
|
969
1333
|
function estimateLines(item, cols) {
|
|
970
1334
|
// Account for paddingLeft=3 + paddingRight=2 used by most message components
|
|
971
1335
|
const textWidth = Math.max(cols - 7, 20);
|
|
972
1336
|
const lines = item.text.split('\n');
|
|
973
|
-
// Count wrapped lines per actual line
|
|
1337
|
+
// Count wrapped lines per actual line using display width (wide/CJK chars
|
|
1338
|
+
// occupy two columns), so scrollback estimates don't overflow.
|
|
974
1339
|
let totalLines = 0;
|
|
975
1340
|
for (const line of lines) {
|
|
976
|
-
totalLines += Math.max(1, Math.ceil((line
|
|
1341
|
+
totalLines += Math.max(1, Math.ceil((stringWidth(line) || 1) / textWidth));
|
|
977
1342
|
}
|
|
978
1343
|
switch (item.kind) {
|
|
979
1344
|
case 'user': return totalLines + 2; // marginTop=1 + prompt icon row
|
|
@@ -983,12 +1348,14 @@ function estimateLines(item, cols) {
|
|
|
983
1348
|
return totalLines + markdownExpansion + 2; // marginTop=1 + padding
|
|
984
1349
|
}
|
|
985
1350
|
case 'error': return totalLines + 2;
|
|
986
|
-
case 'tool-call': return 2;
|
|
987
|
-
case 'tool-result': return 1;
|
|
1351
|
+
case 'tool-call': return 2;
|
|
1352
|
+
case 'tool-result': return Math.max(1, totalLines);
|
|
988
1353
|
case 'tool-blocked': return 1;
|
|
989
|
-
case '
|
|
990
|
-
case '
|
|
1354
|
+
case 'tool-group': return item.collapsed ? 2 : (item.children?.length ?? 0) * 2;
|
|
1355
|
+
case 'diff': return lines.length + 2;
|
|
1356
|
+
case 'system': return Math.max(1, totalLines);
|
|
991
1357
|
}
|
|
1358
|
+
return totalLines;
|
|
992
1359
|
}
|
|
993
1360
|
function useScrollableItems(items, availableHeight, cols, isLoading, streamLineCount = 0) {
|
|
994
1361
|
const [scrollOffset, setScrollOffset] = useState(0);
|
|
@@ -1004,7 +1371,7 @@ function useScrollableItems(items, availableHeight, cols, isLoading, streamLineC
|
|
|
1004
1371
|
if (isLoading && autoScroll.current)
|
|
1005
1372
|
setScrollOffset(0);
|
|
1006
1373
|
}, [isLoading]);
|
|
1007
|
-
const scrollUp = useCallback((amount =
|
|
1374
|
+
const scrollUp = useCallback((amount = 5) => {
|
|
1008
1375
|
setScrollOffset((prev) => {
|
|
1009
1376
|
const maxOffset = Math.max(items.length - 1, 0);
|
|
1010
1377
|
const next = Math.min(prev + amount, maxOffset);
|
|
@@ -1012,7 +1379,7 @@ function useScrollableItems(items, availableHeight, cols, isLoading, streamLineC
|
|
|
1012
1379
|
return next;
|
|
1013
1380
|
});
|
|
1014
1381
|
}, [items.length]);
|
|
1015
|
-
const scrollDown = useCallback((amount =
|
|
1382
|
+
const scrollDown = useCallback((amount = 5) => {
|
|
1016
1383
|
setScrollOffset((prev) => {
|
|
1017
1384
|
const next = Math.max(prev - amount, 0);
|
|
1018
1385
|
if (next === 0)
|
|
@@ -1053,6 +1420,8 @@ function App({ config }) {
|
|
|
1053
1420
|
const [thinkStart, setThinkStart] = useState(null);
|
|
1054
1421
|
const [showBanner, setShowBanner] = useState(true);
|
|
1055
1422
|
const [totalTokens, setTotalTokens] = useState(0);
|
|
1423
|
+
const [contextPercent, setContextPercent] = useState(0);
|
|
1424
|
+
const [rateLimitWarn, setRateLimitWarn] = useState(false);
|
|
1056
1425
|
const [sessionList, setSessionList] = useState(null);
|
|
1057
1426
|
const [showSetup, setShowSetup] = useState(false);
|
|
1058
1427
|
const [currentMode, setCurrentMode] = useState(agent?.getPermissionMode() ?? 'default');
|
|
@@ -1065,6 +1434,7 @@ function App({ config }) {
|
|
|
1065
1434
|
useEffect(() => { modelsRef.current = models; }, [models]);
|
|
1066
1435
|
const nextId = () => ++counter.current;
|
|
1067
1436
|
const MAX_DISPLAY_ITEMS = 2000;
|
|
1437
|
+
const TOOL_GROUP_THRESHOLD = 3;
|
|
1068
1438
|
const add = useCallback((kind, text, meta, hint) => {
|
|
1069
1439
|
setItems((prev) => {
|
|
1070
1440
|
const next = [...prev, { id: nextId(), kind, text, meta, hint }];
|
|
@@ -1075,6 +1445,29 @@ function App({ config }) {
|
|
|
1075
1445
|
setShowBanner(false);
|
|
1076
1446
|
}
|
|
1077
1447
|
}, []);
|
|
1448
|
+
const collapseToolGroups = useCallback(() => {
|
|
1449
|
+
setItems(prev => {
|
|
1450
|
+
const isToolItem = (k) => k === 'tool-call' || k === 'tool-result';
|
|
1451
|
+
let i = prev.length - 1;
|
|
1452
|
+
while (i >= 0 && isToolItem(prev[i].kind))
|
|
1453
|
+
i--;
|
|
1454
|
+
const toolStart = i + 1;
|
|
1455
|
+
const toolItems = prev.slice(toolStart);
|
|
1456
|
+
if (toolItems.length < TOOL_GROUP_THRESHOLD)
|
|
1457
|
+
return prev;
|
|
1458
|
+
const group = {
|
|
1459
|
+
id: nextId(),
|
|
1460
|
+
kind: 'tool-group',
|
|
1461
|
+
text: `Ran ${Math.ceil(toolItems.length / 2)} tools`,
|
|
1462
|
+
children: toolItems,
|
|
1463
|
+
collapsed: true,
|
|
1464
|
+
};
|
|
1465
|
+
return [...prev.slice(0, toolStart), group];
|
|
1466
|
+
});
|
|
1467
|
+
}, []);
|
|
1468
|
+
const toggleToolGroups = useCallback(() => {
|
|
1469
|
+
setItems(prev => prev.map(item => item.kind === 'tool-group' ? { ...item, collapsed: !item.collapsed } : item));
|
|
1470
|
+
}, []);
|
|
1078
1471
|
// Register agent event handlers ONCE
|
|
1079
1472
|
useEffect(() => {
|
|
1080
1473
|
const onThinkingStart = () => {
|
|
@@ -1126,9 +1519,18 @@ function App({ config }) {
|
|
|
1126
1519
|
streamBuffer = '';
|
|
1127
1520
|
setStreamText('');
|
|
1128
1521
|
setReasoningText('');
|
|
1522
|
+
collapseToolGroups();
|
|
1129
1523
|
if (full.trim()) {
|
|
1130
1524
|
add('assistant', full);
|
|
1131
1525
|
}
|
|
1526
|
+
// Auto-save session after each assistant turn
|
|
1527
|
+
if (agent) {
|
|
1528
|
+
const msgs = agent.getMessages();
|
|
1529
|
+
if (msgs.length > 0) {
|
|
1530
|
+
saveCrashState(sessionId, msgs, modelsRef.current[0] || 'unknown', costTracker.getState())
|
|
1531
|
+
.catch(() => { });
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1132
1534
|
};
|
|
1133
1535
|
let reasoningBuffer = '';
|
|
1134
1536
|
let reasoningFlushTimer = null;
|
|
@@ -1142,9 +1544,13 @@ function App({ config }) {
|
|
|
1142
1544
|
}
|
|
1143
1545
|
};
|
|
1144
1546
|
const onToolCall = (name, args) => {
|
|
1547
|
+
// A tool running is progress, not a stall — reset the timer so long
|
|
1548
|
+
// builds/tests don't trip the "Stream appears stalled" warning.
|
|
1549
|
+
resetStallTimer();
|
|
1145
1550
|
add('tool-call', typeof args === 'string' ? args : JSON.stringify(args), name);
|
|
1146
1551
|
};
|
|
1147
1552
|
const onToolResult = (name, result) => {
|
|
1553
|
+
resetStallTimer();
|
|
1148
1554
|
// Extract diff from write_file / edit_file results and show separately
|
|
1149
1555
|
let parsed = null;
|
|
1150
1556
|
if (typeof result === 'object' && result !== null) {
|
|
@@ -1198,6 +1604,8 @@ function App({ config }) {
|
|
|
1198
1604
|
const lower = reason?.toLowerCase() || '';
|
|
1199
1605
|
if (lower.includes('rate limit') || lower.includes('429')) {
|
|
1200
1606
|
hint = 'Rate limited. Try /model to switch models, or wait a moment.';
|
|
1607
|
+
setRateLimitWarn(true);
|
|
1608
|
+
setTimeout(() => setRateLimitWarn(false), 120_000);
|
|
1201
1609
|
}
|
|
1202
1610
|
else if (lower.includes('auth') || lower.includes('401') || lower.includes('api key')) {
|
|
1203
1611
|
hint = 'Authentication failed. Run /setup to reconfigure your API key.';
|
|
@@ -1217,6 +1625,7 @@ function App({ config }) {
|
|
|
1217
1625
|
add('system', `Context compressed: ${formatTokens(info.saved)} tokens saved (${formatTokens(info.before)} \u2192 ${formatTokens(info.after)})`);
|
|
1218
1626
|
};
|
|
1219
1627
|
const onContextUsage = (info) => {
|
|
1628
|
+
setContextPercent(info.percentage);
|
|
1220
1629
|
if (info.percentage > 80) {
|
|
1221
1630
|
add('system', `Context usage: ${info.percentage}% (${formatTokens(info.tokens)}/${formatTokens(info.maxTokens)} tokens)`);
|
|
1222
1631
|
}
|
|
@@ -1264,11 +1673,60 @@ function App({ config }) {
|
|
|
1264
1673
|
currentAgent.off('context:usage', onContextUsage);
|
|
1265
1674
|
};
|
|
1266
1675
|
}, [add, agentVersion]);
|
|
1267
|
-
//
|
|
1676
|
+
// Crash recovery: check for recoverable session on startup
|
|
1677
|
+
useEffect(() => {
|
|
1678
|
+
loadCrashState().then(state => {
|
|
1679
|
+
if (state && state.messages.length > 0 && agent) {
|
|
1680
|
+
add('system', `Recoverable session found (${state.messages.length} messages). Use /resume to restore.`);
|
|
1681
|
+
}
|
|
1682
|
+
}).catch(() => { });
|
|
1683
|
+
}, [add]);
|
|
1684
|
+
// Untrusted-workspace notice: project-local plugins/hooks/rules were skipped.
|
|
1685
|
+
useEffect(() => {
|
|
1686
|
+
if (workspaceUntrustedNotice) {
|
|
1687
|
+
add('system', 'This workspace has project plugins/hooks/rules that were skipped because the workspace is not trusted. Run /trust to load them.');
|
|
1688
|
+
}
|
|
1689
|
+
}, [add]);
|
|
1690
|
+
// Idle session nudge: suggest /clear after 75+ minutes of inactivity
|
|
1691
|
+
const lastActivityRef = useRef(Date.now());
|
|
1692
|
+
const idleNudgedRef = useRef(false);
|
|
1693
|
+
useEffect(() => {
|
|
1694
|
+
const IDLE_THRESHOLD_MS = 75 * 60_000; // 75 minutes
|
|
1695
|
+
const timer = setInterval(() => {
|
|
1696
|
+
const idle = Date.now() - lastActivityRef.current;
|
|
1697
|
+
if (idle >= IDLE_THRESHOLD_MS && !idleNudgedRef.current && agent && agent.getMessages().length > 10) {
|
|
1698
|
+
idleNudgedRef.current = true;
|
|
1699
|
+
add('system', 'Session idle for 75+ minutes. Consider /clear to optimize token usage, or /compact to compress context.');
|
|
1700
|
+
}
|
|
1701
|
+
}, 60_000);
|
|
1702
|
+
return () => clearInterval(timer);
|
|
1703
|
+
}, [add]);
|
|
1704
|
+
// Terminal notification on task completion
|
|
1705
|
+
useEffect(() => {
|
|
1706
|
+
if (!agent)
|
|
1707
|
+
return;
|
|
1708
|
+
// Capture the agent for cleanup so .off() detaches from the same instance
|
|
1709
|
+
// even after the agent is re-initialized (e.g. /setup).
|
|
1710
|
+
const currentAgent = agent;
|
|
1711
|
+
const onStreamEnd = () => {
|
|
1712
|
+
// `document` doesn't exist in Node (referencing it threw a ReferenceError
|
|
1713
|
+
// on every stream:end). Gate purely on the env var; only consult a focus
|
|
1714
|
+
// check when running somewhere that actually has `document`.
|
|
1715
|
+
const doc = globalThis.document;
|
|
1716
|
+
const hasFocus = doc ? doc.hasFocus?.() : false;
|
|
1717
|
+
if (process.env.SCALIX_NOTIFY !== '0' && !hasFocus) {
|
|
1718
|
+
sendNotification('Scalix', 'Task complete');
|
|
1719
|
+
}
|
|
1720
|
+
};
|
|
1721
|
+
currentAgent.on('stream:end', onStreamEnd);
|
|
1722
|
+
return () => { currentAgent.off('stream:end', onStreamEnd); };
|
|
1723
|
+
}, [agentVersion]);
|
|
1724
|
+
// Confirmation bridge (event-driven). Surfaces the head of the FIFO queue.
|
|
1268
1725
|
useEffect(() => {
|
|
1269
1726
|
const onPending = () => {
|
|
1270
|
-
if (
|
|
1271
|
-
|
|
1727
|
+
if (confirmQueue.length > 0 && !confirmInfo) {
|
|
1728
|
+
const head = confirmQueue[0];
|
|
1729
|
+
setConfirmInfo({ toolName: head.toolName, args: head.args });
|
|
1272
1730
|
}
|
|
1273
1731
|
};
|
|
1274
1732
|
confirmEmitter.on('pending', onPending);
|
|
@@ -1279,12 +1737,25 @@ function App({ config }) {
|
|
|
1279
1737
|
if (banner)
|
|
1280
1738
|
add('system', banner);
|
|
1281
1739
|
}, [add]);
|
|
1282
|
-
const handleConfirm = useCallback((ok) => {
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1740
|
+
const handleConfirm = useCallback((ok, always) => {
|
|
1741
|
+
const current = confirmQueue.shift();
|
|
1742
|
+
if (current) {
|
|
1743
|
+
if (ok && always)
|
|
1744
|
+
alwaysAllowedTools.add(current.toolName);
|
|
1745
|
+
if (!ok) {
|
|
1746
|
+
recentDenials.push({
|
|
1747
|
+
toolName: current.toolName,
|
|
1748
|
+
args: JSON.stringify(current.args).slice(0, 100),
|
|
1749
|
+
timestamp: Date.now(),
|
|
1750
|
+
});
|
|
1751
|
+
if (recentDenials.length > 20)
|
|
1752
|
+
recentDenials.shift();
|
|
1753
|
+
}
|
|
1754
|
+
current.resolve(ok);
|
|
1286
1755
|
}
|
|
1287
|
-
|
|
1756
|
+
// Surface the next queued confirmation, if any.
|
|
1757
|
+
const next = confirmQueue[0];
|
|
1758
|
+
setConfirmInfo(next ? { toolName: next.toolName, args: next.args } : null);
|
|
1288
1759
|
}, []);
|
|
1289
1760
|
const handleCommand = useCallback((text) => {
|
|
1290
1761
|
const cmd = text.split(' ')[0].toLowerCase();
|
|
@@ -1318,6 +1789,7 @@ function App({ config }) {
|
|
|
1318
1789
|
' /checkpoint \u2014 create git checkpoint',
|
|
1319
1790
|
' /undo \u2014 undo last checkpoint',
|
|
1320
1791
|
' /checkpoints \u2014 list recent checkpoints',
|
|
1792
|
+
' /worktree \u2014 manage git worktrees (list/create/remove/cleanup)',
|
|
1321
1793
|
' /git \u2014 show git context',
|
|
1322
1794
|
'',
|
|
1323
1795
|
'Project:',
|
|
@@ -1339,6 +1811,9 @@ function App({ config }) {
|
|
|
1339
1811
|
' /setup \u2014 reconfigure API key, URL & models',
|
|
1340
1812
|
' /mcp <l> <u> \u2014 add MCP server',
|
|
1341
1813
|
' /plugins \u2014 show loaded plugins',
|
|
1814
|
+
' /plugin \u2014 install/enable/disable/uninstall plugins',
|
|
1815
|
+
' /trust \u2014 trust workspace (load project plugins/hooks/rules)',
|
|
1816
|
+
' /notify \u2014 test terminal notification',
|
|
1342
1817
|
' /router \u2014 show model routing status',
|
|
1343
1818
|
' /replay \u2014 show session timeline',
|
|
1344
1819
|
' /hooks \u2014 show lifecycle hooks',
|
|
@@ -1358,10 +1833,7 @@ function App({ config }) {
|
|
|
1358
1833
|
}
|
|
1359
1834
|
if (cmd === '/clear') {
|
|
1360
1835
|
agent.abort();
|
|
1361
|
-
|
|
1362
|
-
pendingConfirm.resolve(false);
|
|
1363
|
-
pendingConfirm = null;
|
|
1364
|
-
}
|
|
1836
|
+
drainConfirmQueue(false);
|
|
1365
1837
|
const msgs = agent.getMessages();
|
|
1366
1838
|
if (msgs.length > 0) {
|
|
1367
1839
|
saveSession(sessionId, msgs, models[0] || 'unknown', undefined, costTracker.getState()).then((meta) => {
|
|
@@ -1380,6 +1852,8 @@ function App({ config }) {
|
|
|
1380
1852
|
setShowBanner(true);
|
|
1381
1853
|
costTracker.reset();
|
|
1382
1854
|
setTotalTokens(0);
|
|
1855
|
+
setContextPercent(0);
|
|
1856
|
+
setRateLimitWarn(false);
|
|
1383
1857
|
sessionId = generateSessionId();
|
|
1384
1858
|
return true;
|
|
1385
1859
|
}
|
|
@@ -1432,7 +1906,35 @@ function App({ config }) {
|
|
|
1432
1906
|
});
|
|
1433
1907
|
return true;
|
|
1434
1908
|
}
|
|
1435
|
-
if (cmd === '/load') {
|
|
1909
|
+
if (cmd === '/load' || cmd === '/resume') {
|
|
1910
|
+
if (cmd === '/resume') {
|
|
1911
|
+
loadCrashState().then(async (state) => {
|
|
1912
|
+
if (state && state.messages.length > 0 && agent) {
|
|
1913
|
+
agent.loadMessages(state.messages);
|
|
1914
|
+
sessionId = state.sessionId;
|
|
1915
|
+
if (state.costState) {
|
|
1916
|
+
costTracker.restore(state.costState);
|
|
1917
|
+
setTotalTokens(state.costState.totalTokens);
|
|
1918
|
+
}
|
|
1919
|
+
add('system', `Resumed session with ${state.messages.length} messages.`);
|
|
1920
|
+
showBannerRef.current = false;
|
|
1921
|
+
setShowBanner(false);
|
|
1922
|
+
await clearCrashState();
|
|
1923
|
+
}
|
|
1924
|
+
else {
|
|
1925
|
+
const sessions = await listSessions();
|
|
1926
|
+
if (sessions.length === 0) {
|
|
1927
|
+
add('system', 'No saved sessions or crash recovery data found.');
|
|
1928
|
+
}
|
|
1929
|
+
else {
|
|
1930
|
+
setSessionList(sessions);
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
}).catch(() => {
|
|
1934
|
+
add('system', 'Failed to load crash recovery data.');
|
|
1935
|
+
});
|
|
1936
|
+
return true;
|
|
1937
|
+
}
|
|
1436
1938
|
listSessions().then((sessions) => {
|
|
1437
1939
|
if (sessions.length === 0) {
|
|
1438
1940
|
add('system', 'No saved sessions found.');
|
|
@@ -1443,20 +1945,43 @@ function App({ config }) {
|
|
|
1443
1945
|
});
|
|
1444
1946
|
return true;
|
|
1445
1947
|
}
|
|
1948
|
+
if (cmd === '/fork') {
|
|
1949
|
+
const pointStr = text.split(' ')[1];
|
|
1950
|
+
const point = pointStr ? parseInt(pointStr, 10) : agent?.getMessages().length;
|
|
1951
|
+
if (!point || point <= 0) {
|
|
1952
|
+
add('system', 'Usage: /fork [message_number] — fork conversation from a specific point');
|
|
1953
|
+
return true;
|
|
1954
|
+
}
|
|
1955
|
+
forkSession(sessionId, point).then(({ id, meta }) => {
|
|
1956
|
+
add('system', `Forked to new session: ${meta.name} (${id.slice(0, 8)})`);
|
|
1957
|
+
agent.loadMessages(agent.getMessages().slice(0, point));
|
|
1958
|
+
sessionId = id;
|
|
1959
|
+
}).catch((err) => {
|
|
1960
|
+
add('error', `Fork failed: ${err.message}`);
|
|
1961
|
+
});
|
|
1962
|
+
return true;
|
|
1963
|
+
}
|
|
1446
1964
|
if (cmd === '/shortcuts') {
|
|
1447
1965
|
const shortcuts = [
|
|
1448
1966
|
'Ctrl+C \u2014 interrupt agent / clear input / double-press to quit',
|
|
1449
1967
|
'Ctrl+D \u2014 exit (when input is empty)',
|
|
1450
1968
|
'Ctrl+L \u2014 clear screen & conversation',
|
|
1969
|
+
'Ctrl+O \u2014 toggle collapsed tool groups',
|
|
1451
1970
|
'Ctrl+U \u2014 clear entire input line',
|
|
1452
1971
|
'Ctrl+W \u2014 delete last word',
|
|
1453
1972
|
'Ctrl+K \u2014 delete to end of line',
|
|
1454
1973
|
'Ctrl+S \u2014 stash / restore current prompt',
|
|
1974
|
+
'Ctrl+V \u2014 paste image from clipboard',
|
|
1975
|
+
'Ctrl+R \u2014 reverse history search',
|
|
1976
|
+
'Ctrl+X Ctrl+E \u2014 open in external editor ($EDITOR)',
|
|
1977
|
+
'Alt+Enter \u2014 insert newline (multiline)',
|
|
1455
1978
|
'Tab \u2014 autocomplete slash commands',
|
|
1456
1979
|
'\u2191/\u2193 \u2014 navigate input history',
|
|
1457
|
-
'Shift+\u2191
|
|
1458
|
-
'Shift+\u2193
|
|
1459
|
-
'
|
|
1980
|
+
'Shift+\u2191 \u2014 scroll up (5 items)',
|
|
1981
|
+
'Shift+\u2193 \u2014 scroll down (5 items)',
|
|
1982
|
+
'PageUp \u2014 scroll up fast (15 items)',
|
|
1983
|
+
'PageDown \u2014 scroll down fast (15 items)',
|
|
1984
|
+
'Y/A/N \u2014 allow / always allow / deny tool execution',
|
|
1460
1985
|
'Esc \u2014 quit',
|
|
1461
1986
|
'',
|
|
1462
1987
|
'Copy \u2014 Cmd+C (Mac) / Ctrl+Shift+C (Linux)',
|
|
@@ -1468,6 +1993,32 @@ function App({ config }) {
|
|
|
1468
1993
|
shortcuts.forEach((s) => add('system', s));
|
|
1469
1994
|
return true;
|
|
1470
1995
|
}
|
|
1996
|
+
if (cmd === '/permissions') {
|
|
1997
|
+
add('system', `Permission Mode: ${currentMode.toUpperCase()} — ${getModeDescription(currentMode)}`);
|
|
1998
|
+
if (alwaysAllowedTools.size > 0) {
|
|
1999
|
+
add('system', `Always Allowed (session): ${[...alwaysAllowedTools].join(', ')}`);
|
|
2000
|
+
}
|
|
2001
|
+
const scopedRules = getScopedPermissionRules();
|
|
2002
|
+
if (scopedRules.length > 0) {
|
|
2003
|
+
add('system', 'Scoped Rules:');
|
|
2004
|
+
scopedRules.forEach(r => {
|
|
2005
|
+
const spec = r.prefix ? `${r.tool}(${r.prefix}*)` : r.tool;
|
|
2006
|
+
add('system', ` ${r.action === 'allow' ? '✓' : '✗'} ${spec}${r.regex ? ` [regex: ${r.regex}]` : ''}`);
|
|
2007
|
+
});
|
|
2008
|
+
}
|
|
2009
|
+
if (recentDenials.length > 0) {
|
|
2010
|
+
add('system', 'Recent Denials:');
|
|
2011
|
+
const recent = recentDenials.slice(-10);
|
|
2012
|
+
recent.forEach(d => {
|
|
2013
|
+
const ago = Math.round((Date.now() - d.timestamp) / 1000);
|
|
2014
|
+
add('system', ` ✗ ${d.toolName} (${ago}s ago) ${d.args}`);
|
|
2015
|
+
});
|
|
2016
|
+
}
|
|
2017
|
+
else {
|
|
2018
|
+
add('system', 'No recent denials.');
|
|
2019
|
+
}
|
|
2020
|
+
return true;
|
|
2021
|
+
}
|
|
1471
2022
|
if (cmd === '/settings') {
|
|
1472
2023
|
setEditingSettings(true);
|
|
1473
2024
|
return true;
|
|
@@ -1560,6 +2111,27 @@ function App({ config }) {
|
|
|
1560
2111
|
add('system', pluginsSummary(loadedPlugins));
|
|
1561
2112
|
return true;
|
|
1562
2113
|
}
|
|
2114
|
+
if (cmd === '/trust') {
|
|
2115
|
+
const cwd = process.cwd();
|
|
2116
|
+
if (isWorkspaceTrusted(cwd)) {
|
|
2117
|
+
add('system', 'Workspace is already trusted.');
|
|
2118
|
+
return true;
|
|
2119
|
+
}
|
|
2120
|
+
trustWorkspace(cwd);
|
|
2121
|
+
workspaceUntrustedNotice = false;
|
|
2122
|
+
add('system', `Workspace trusted: ${cwd}`);
|
|
2123
|
+
// Re-run project-local discovery now that the workspace is trusted.
|
|
2124
|
+
discoverPluginsForAgent(agent).then(() => {
|
|
2125
|
+
add('system', `Loaded ${loadedPlugins.length} plugin(s).`);
|
|
2126
|
+
}).catch(() => { });
|
|
2127
|
+
buildEnhancedInstructions().then((enhanced) => {
|
|
2128
|
+
agent.setInstructions(enhanced);
|
|
2129
|
+
add('system', 'Reloaded project rules into context.');
|
|
2130
|
+
}).catch(() => { });
|
|
2131
|
+
loadCustomCommands(cwd).then((cmds) => { customCommands = cmds; }).catch(() => { });
|
|
2132
|
+
loadSkills(cwd).then((skills) => { loadedSkills = skills; }).catch(() => { });
|
|
2133
|
+
return true;
|
|
2134
|
+
}
|
|
1563
2135
|
if (cmd === '/replay') {
|
|
1564
2136
|
const replay = agent.getReplay();
|
|
1565
2137
|
const timeline = replay.getTimeline();
|
|
@@ -1610,6 +2182,10 @@ function App({ config }) {
|
|
|
1610
2182
|
commandsSummary(customCommands).split('\n').forEach((l) => add('system', l));
|
|
1611
2183
|
return true;
|
|
1612
2184
|
}
|
|
2185
|
+
if (cmd === '/skills') {
|
|
2186
|
+
skillsSummary(loadedSkills).split('\n').forEach((l) => add('system', l));
|
|
2187
|
+
return true;
|
|
2188
|
+
}
|
|
1613
2189
|
if (cmd === '/delegate') {
|
|
1614
2190
|
const prompt = text.slice(10).trim();
|
|
1615
2191
|
if (!prompt) {
|
|
@@ -1677,6 +2253,130 @@ function App({ config }) {
|
|
|
1677
2253
|
}).catch(() => add('error', 'Failed to list checkpoints.'));
|
|
1678
2254
|
return true;
|
|
1679
2255
|
}
|
|
2256
|
+
if (cmd === '/worktree') {
|
|
2257
|
+
const subArgs = text.slice(10).trim().split(/\s+/);
|
|
2258
|
+
const subCmd = subArgs[0] || 'list';
|
|
2259
|
+
const cwd = process.cwd();
|
|
2260
|
+
if (subCmd === 'list' || subCmd === 'ls') {
|
|
2261
|
+
listWorktrees(cwd).then(wts => {
|
|
2262
|
+
if (wts.length === 0)
|
|
2263
|
+
add('system', 'No active worktrees.');
|
|
2264
|
+
else {
|
|
2265
|
+
add('system', `${wts.length} worktree(s):`);
|
|
2266
|
+
wts.forEach(wt => add('system', ` ${wt.branch} → ${wt.path}`));
|
|
2267
|
+
}
|
|
2268
|
+
}).catch(err => add('error', `Failed to list worktrees: ${err.message}`));
|
|
2269
|
+
}
|
|
2270
|
+
else if (subCmd === 'create' || subCmd === 'new') {
|
|
2271
|
+
const name = subArgs[1] || `work-${Date.now().toString(36)}`;
|
|
2272
|
+
createWorktree(name, cwd).then(wt => {
|
|
2273
|
+
add('system', `Worktree created: ${wt.branch} → ${wt.path}`);
|
|
2274
|
+
add('system', `cd ${wt.path} to work in isolation`);
|
|
2275
|
+
}).catch(err => add('error', `Failed to create worktree: ${err.message}`));
|
|
2276
|
+
}
|
|
2277
|
+
else if (subCmd === 'remove' || subCmd === 'rm') {
|
|
2278
|
+
const branchOrId = subArgs[1];
|
|
2279
|
+
if (!branchOrId) {
|
|
2280
|
+
add('system', 'Usage: /worktree remove <branch-or-agent-id>');
|
|
2281
|
+
return true;
|
|
2282
|
+
}
|
|
2283
|
+
listWorktrees(cwd).then(wts => {
|
|
2284
|
+
const wt = wts.find(w => w.branch.includes(branchOrId) || w.agentId.includes(branchOrId));
|
|
2285
|
+
if (!wt) {
|
|
2286
|
+
add('error', `Worktree not found: ${branchOrId}`);
|
|
2287
|
+
return;
|
|
2288
|
+
}
|
|
2289
|
+
removeWorktree(wt, cwd).then(() => {
|
|
2290
|
+
add('system', `Worktree removed: ${wt.branch}`);
|
|
2291
|
+
}).catch(err => add('error', `Failed to remove: ${err.message}`));
|
|
2292
|
+
}).catch(err => add('error', `Failed: ${err.message}`));
|
|
2293
|
+
}
|
|
2294
|
+
else if (subCmd === 'cleanup') {
|
|
2295
|
+
cleanupStaleWorktrees(cwd).then(count => {
|
|
2296
|
+
add('system', count > 0 ? `Cleaned up ${count} stale worktree(s).` : 'No stale worktrees found.');
|
|
2297
|
+
}).catch(err => add('error', `Cleanup failed: ${err.message}`));
|
|
2298
|
+
}
|
|
2299
|
+
else {
|
|
2300
|
+
add('system', 'Usage: /worktree [list|create <name>|remove <branch>|cleanup]');
|
|
2301
|
+
}
|
|
2302
|
+
return true;
|
|
2303
|
+
}
|
|
2304
|
+
if (cmd === '/plugin') {
|
|
2305
|
+
const subArgs = text.slice(8).trim().split(/\s+/);
|
|
2306
|
+
const subCmd = subArgs[0] || 'list';
|
|
2307
|
+
const cwd = process.cwd();
|
|
2308
|
+
if (subCmd === 'list' || subCmd === 'ls') {
|
|
2309
|
+
listInstalledPlugins(cwd).then(plugins => {
|
|
2310
|
+
if (plugins.length === 0) {
|
|
2311
|
+
add('system', 'No plugins installed. Use /plugin install <source> to add one.');
|
|
2312
|
+
}
|
|
2313
|
+
else {
|
|
2314
|
+
add('system', `${plugins.length} plugin(s) installed:`);
|
|
2315
|
+
plugins.forEach(p => {
|
|
2316
|
+
const status = p.enabled ? 'enabled' : 'disabled';
|
|
2317
|
+
add('system', ` ${p.name} v${p.version} [${status}]`);
|
|
2318
|
+
});
|
|
2319
|
+
}
|
|
2320
|
+
}).catch(err => add('error', `Failed to list plugins: ${err.message}`));
|
|
2321
|
+
}
|
|
2322
|
+
else if (subCmd === 'install') {
|
|
2323
|
+
const source = subArgs[1];
|
|
2324
|
+
if (!source) {
|
|
2325
|
+
add('system', 'Usage: /plugin install <path-or-git-url>');
|
|
2326
|
+
return true;
|
|
2327
|
+
}
|
|
2328
|
+
add('system', `Installing plugin from ${source}...`);
|
|
2329
|
+
installPlugin(cwd, source).then(result => {
|
|
2330
|
+
if (result) {
|
|
2331
|
+
add('system', `Plugin installed: ${result.name} v${result.version}`);
|
|
2332
|
+
add('system', 'Restart session to load new plugin tools.');
|
|
2333
|
+
}
|
|
2334
|
+
else {
|
|
2335
|
+
add('system', 'Plugin already installed.');
|
|
2336
|
+
}
|
|
2337
|
+
}).catch(err => add('error', `Install failed: ${err.message}`));
|
|
2338
|
+
}
|
|
2339
|
+
else if (subCmd === 'uninstall' || subCmd === 'remove') {
|
|
2340
|
+
const name = subArgs[1];
|
|
2341
|
+
if (!name) {
|
|
2342
|
+
add('system', 'Usage: /plugin uninstall <name>');
|
|
2343
|
+
return true;
|
|
2344
|
+
}
|
|
2345
|
+
uninstallPlugin(cwd, name).then(ok => {
|
|
2346
|
+
add(ok ? 'system' : 'error', ok ? `Plugin uninstalled: ${name}` : `Plugin not found: ${name}`);
|
|
2347
|
+
}).catch(err => add('error', `Uninstall failed: ${err.message}`));
|
|
2348
|
+
}
|
|
2349
|
+
else if (subCmd === 'enable') {
|
|
2350
|
+
const name = subArgs[1];
|
|
2351
|
+
if (!name) {
|
|
2352
|
+
add('system', 'Usage: /plugin enable <name>');
|
|
2353
|
+
return true;
|
|
2354
|
+
}
|
|
2355
|
+
enablePlugin(cwd, name).then(ok => {
|
|
2356
|
+
add(ok ? 'system' : 'error', ok ? `Plugin enabled: ${name}` : `Plugin not found or already enabled: ${name}`);
|
|
2357
|
+
}).catch(err => add('error', `Enable failed: ${err.message}`));
|
|
2358
|
+
}
|
|
2359
|
+
else if (subCmd === 'disable') {
|
|
2360
|
+
const name = subArgs[1];
|
|
2361
|
+
if (!name) {
|
|
2362
|
+
add('system', 'Usage: /plugin disable <name>');
|
|
2363
|
+
return true;
|
|
2364
|
+
}
|
|
2365
|
+
disablePlugin(cwd, name).then(ok => {
|
|
2366
|
+
add(ok ? 'system' : 'error', ok ? `Plugin disabled: ${name}` : `Plugin not found: ${name}`);
|
|
2367
|
+
}).catch(err => add('error', `Disable failed: ${err.message}`));
|
|
2368
|
+
}
|
|
2369
|
+
else {
|
|
2370
|
+
add('system', 'Usage: /plugin [list|install <source>|uninstall <name>|enable <name>|disable <name>]');
|
|
2371
|
+
}
|
|
2372
|
+
return true;
|
|
2373
|
+
}
|
|
2374
|
+
if (cmd === '/notify') {
|
|
2375
|
+
const message = text.slice(8).trim() || 'Test notification from Scalix';
|
|
2376
|
+
sendNotification('Scalix', message);
|
|
2377
|
+
add('system', `Notification sent (terminal: ${getTerminalType()})`);
|
|
2378
|
+
return true;
|
|
2379
|
+
}
|
|
1680
2380
|
if (cmd === '/commit') {
|
|
1681
2381
|
setIsLoading(true);
|
|
1682
2382
|
setStatus('thinking');
|
|
@@ -1684,17 +2384,18 @@ function App({ config }) {
|
|
|
1684
2384
|
add('system', 'Generating commit...');
|
|
1685
2385
|
(async () => {
|
|
1686
2386
|
try {
|
|
2387
|
+
const gitOpts = { timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() };
|
|
1687
2388
|
// Check for staged changes
|
|
1688
2389
|
let diff = '';
|
|
1689
2390
|
try {
|
|
1690
|
-
diff =
|
|
2391
|
+
diff = (await execFileAsync('git', ['diff', '--staged'], gitOpts)).stdout.trim();
|
|
1691
2392
|
}
|
|
1692
2393
|
catch { }
|
|
1693
2394
|
if (!diff) {
|
|
1694
2395
|
// Nothing staged — stage tracked files only (safer than -A)
|
|
1695
2396
|
try {
|
|
1696
|
-
|
|
1697
|
-
diff =
|
|
2397
|
+
await execFileAsync('git', ['add', '-u'], gitOpts);
|
|
2398
|
+
diff = (await execFileAsync('git', ['diff', '--staged'], gitOpts)).stdout.trim();
|
|
1698
2399
|
}
|
|
1699
2400
|
catch (e) {
|
|
1700
2401
|
add('error', `Failed to stage files: ${e.message}`);
|
|
@@ -1715,7 +2416,8 @@ function App({ config }) {
|
|
|
1715
2416
|
}
|
|
1716
2417
|
add('system', `Commit message: ${cleanMsg}`);
|
|
1717
2418
|
try {
|
|
1718
|
-
const
|
|
2419
|
+
const fullMsg = cleanMsg + `\n\nCo-Authored-By: ${GIT_BOT_IDENTITY.name} <${GIT_BOT_IDENTITY.email}>`;
|
|
2420
|
+
const result = (await execFileAsync('git', ['commit', '-m', fullMsg], gitOpts)).stdout.trim();
|
|
1719
2421
|
add('system', result);
|
|
1720
2422
|
}
|
|
1721
2423
|
catch (e) {
|
|
@@ -1740,26 +2442,27 @@ function App({ config }) {
|
|
|
1740
2442
|
add('system', 'Running code review...');
|
|
1741
2443
|
(async () => {
|
|
1742
2444
|
try {
|
|
2445
|
+
const gitOpts = { timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() };
|
|
1743
2446
|
let diff = '';
|
|
1744
2447
|
let baseBranch = 'main';
|
|
1745
2448
|
try {
|
|
1746
|
-
baseBranch =
|
|
2449
|
+
baseBranch = (await execFileAsync('git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], gitOpts)).stdout.trim().replace('refs/remotes/origin/', '');
|
|
1747
2450
|
}
|
|
1748
2451
|
catch {
|
|
1749
2452
|
try {
|
|
1750
|
-
const branches =
|
|
2453
|
+
const branches = (await execFileAsync('git', ['branch', '--list', 'main', 'master'], gitOpts)).stdout.trim();
|
|
1751
2454
|
if (branches.includes('master'))
|
|
1752
2455
|
baseBranch = 'master';
|
|
1753
2456
|
}
|
|
1754
2457
|
catch { }
|
|
1755
2458
|
}
|
|
1756
2459
|
try {
|
|
1757
|
-
diff =
|
|
2460
|
+
diff = (await execFileAsync('git', ['diff', `${baseBranch}...HEAD`], gitOpts)).stdout.trim();
|
|
1758
2461
|
}
|
|
1759
2462
|
catch { }
|
|
1760
2463
|
if (!diff) {
|
|
1761
2464
|
try {
|
|
1762
|
-
diff =
|
|
2465
|
+
diff = (await execFileAsync('git', ['diff'], gitOpts)).stdout.trim();
|
|
1763
2466
|
}
|
|
1764
2467
|
catch { }
|
|
1765
2468
|
}
|
|
@@ -1795,7 +2498,8 @@ function App({ config }) {
|
|
|
1795
2498
|
const MAX_RETRIES = 5;
|
|
1796
2499
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
1797
2500
|
try {
|
|
1798
|
-
const
|
|
2501
|
+
const { stdout } = await runShellAsync(watchCmd, { timeout: TOOL_DEFAULTS.maxCommandTimeoutMs, cwd: process.cwd() });
|
|
2502
|
+
const output = stdout.trim();
|
|
1799
2503
|
if (output)
|
|
1800
2504
|
add('system', output.length > 2000 ? output.slice(0, 2000) + '\n... (truncated)' : output);
|
|
1801
2505
|
add('system', `Watch: command succeeded on attempt ${attempt}.`);
|
|
@@ -1846,30 +2550,33 @@ function App({ config }) {
|
|
|
1846
2550
|
return true;
|
|
1847
2551
|
}
|
|
1848
2552
|
if (cmd === '/diff') {
|
|
1849
|
-
|
|
1850
|
-
let diff = '';
|
|
1851
|
-
try {
|
|
1852
|
-
diff = execSync('git diff', { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() }).trim();
|
|
1853
|
-
}
|
|
1854
|
-
catch { }
|
|
1855
|
-
let staged = '';
|
|
2553
|
+
(async () => {
|
|
1856
2554
|
try {
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
2555
|
+
const gitOpts = { timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() };
|
|
2556
|
+
let diff = '';
|
|
2557
|
+
try {
|
|
2558
|
+
diff = (await execFileAsync('git', ['diff'], gitOpts)).stdout.trim();
|
|
2559
|
+
}
|
|
2560
|
+
catch { }
|
|
2561
|
+
let staged = '';
|
|
2562
|
+
try {
|
|
2563
|
+
staged = (await execFileAsync('git', ['diff', '--staged'], gitOpts)).stdout.trim();
|
|
2564
|
+
}
|
|
2565
|
+
catch { }
|
|
2566
|
+
if (staged)
|
|
2567
|
+
diff = (diff ? diff + '\n' : '') + '--- Staged changes ---\n' + staged;
|
|
2568
|
+
if (!diff) {
|
|
2569
|
+
add('system', 'No uncommitted changes.');
|
|
2570
|
+
}
|
|
2571
|
+
else {
|
|
2572
|
+
const truncated = diff.length > 5000 ? diff.slice(0, 5000) + '\n... (truncated, full diff is ' + diff.length + ' chars)' : diff;
|
|
2573
|
+
truncated.split('\n').forEach((l) => add('system', l));
|
|
2574
|
+
}
|
|
1864
2575
|
}
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
truncated.split('\n').forEach((l) => add('system', l));
|
|
2576
|
+
catch (e) {
|
|
2577
|
+
add('error', `Failed to get diff: ${e.message}`);
|
|
1868
2578
|
}
|
|
1869
|
-
}
|
|
1870
|
-
catch (e) {
|
|
1871
|
-
add('error', `Failed to get diff: ${e.message}`);
|
|
1872
|
-
}
|
|
2579
|
+
})();
|
|
1873
2580
|
return true;
|
|
1874
2581
|
}
|
|
1875
2582
|
if (cmd === '/copy') {
|
|
@@ -2096,10 +2803,72 @@ function App({ config }) {
|
|
|
2096
2803
|
useEffect(() => { isLoadingRef.current = isLoading; }, [isLoading]);
|
|
2097
2804
|
const multilineRef = useRef([]);
|
|
2098
2805
|
const messageQueueRef = useRef([]);
|
|
2806
|
+
const pendingImagesRef = useRef([]);
|
|
2807
|
+
// Process ONE finalized user message through the full input pipeline:
|
|
2808
|
+
// slash-command → `!` shell → skill/correction → send to agent. Shared by the
|
|
2809
|
+
// main submit path and the queue-drain loop so queued /compact, /clear, !ls,
|
|
2810
|
+
// etc. behave identically to freshly typed input. Returns true if the message
|
|
2811
|
+
// was handled inline (command/shell) and did NOT go to the agent.
|
|
2812
|
+
const processFinalizedInput = useCallback(async (rawFinalText) => {
|
|
2813
|
+
let finalText = rawFinalText;
|
|
2814
|
+
if (finalText.startsWith('/') && handleCommand(finalText))
|
|
2815
|
+
return;
|
|
2816
|
+
const triggeredSkill = getSkillByTrigger(loadedSkills, finalText);
|
|
2817
|
+
if (triggeredSkill) {
|
|
2818
|
+
finalText = `${getSkillContent(triggeredSkill, 'full')}\n\n---\nUser request: ${finalText}`;
|
|
2819
|
+
add('system', `Skill activated: ${triggeredSkill.name}`);
|
|
2820
|
+
}
|
|
2821
|
+
if (finalText.startsWith('!')) {
|
|
2822
|
+
const shellCmd = finalText.slice(1).trim();
|
|
2823
|
+
if (!shellCmd)
|
|
2824
|
+
return;
|
|
2825
|
+
add('system', `$ ${shellCmd}`);
|
|
2826
|
+
try {
|
|
2827
|
+
const { stdout } = await runShellAsync(shellCmd, { timeout: HEADLESS_DEFAULTS.commandTimeoutMs, cwd: process.cwd() });
|
|
2828
|
+
let output = stdout.trim();
|
|
2829
|
+
if (output) {
|
|
2830
|
+
if (output.length > 2000) {
|
|
2831
|
+
output = output.slice(0, 2000) + `\n... (${(output.length - 2000).toLocaleString()} chars truncated)`;
|
|
2832
|
+
}
|
|
2833
|
+
add('system', output);
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
catch (err) {
|
|
2837
|
+
add('error', err.stderr?.trim() || err.message);
|
|
2838
|
+
}
|
|
2839
|
+
return;
|
|
2840
|
+
}
|
|
2841
|
+
const correction = detectCorrection(finalText);
|
|
2842
|
+
if (correction) {
|
|
2843
|
+
addNote(process.cwd(), `[correction] ${correction}`).catch((err) => { logger.warn('Failed to save correction note', { error: err.message }); });
|
|
2844
|
+
}
|
|
2845
|
+
const images = pendingImagesRef.current.length > 0 ? [...pendingImagesRef.current] : undefined;
|
|
2846
|
+
if (images) {
|
|
2847
|
+
add('user', `${finalText} [${images.length} image${images.length > 1 ? 's' : ''} attached]`);
|
|
2848
|
+
}
|
|
2849
|
+
else {
|
|
2850
|
+
add('user', finalText);
|
|
2851
|
+
}
|
|
2852
|
+
pendingImagesRef.current = [];
|
|
2853
|
+
setStatus('thinking');
|
|
2854
|
+
setThinkStart(Date.now());
|
|
2855
|
+
try {
|
|
2856
|
+
const result = await agent.send(finalText, images);
|
|
2857
|
+
if (!result || !result.trim()) {
|
|
2858
|
+
add('system', 'Agent returned no response.');
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
catch (e) {
|
|
2862
|
+
add('error', e.message || 'Unknown error');
|
|
2863
|
+
}
|
|
2864
|
+
}, [add, handleCommand]);
|
|
2099
2865
|
const handleSubmit = useCallback(async (text) => {
|
|
2100
2866
|
const trimmed = text.trim();
|
|
2101
2867
|
if (!trimmed)
|
|
2102
2868
|
return;
|
|
2869
|
+
// Reset idle nudge on any user activity
|
|
2870
|
+
lastActivityRef.current = Date.now();
|
|
2871
|
+
idleNudgedRef.current = false;
|
|
2103
2872
|
// Allow /mode even while the agent is working
|
|
2104
2873
|
if (isLoadingRef.current && trimmed.startsWith('/mode')) {
|
|
2105
2874
|
handleCommand(trimmed);
|
|
@@ -2130,68 +2899,35 @@ function App({ config }) {
|
|
|
2130
2899
|
else {
|
|
2131
2900
|
finalText = trimmed;
|
|
2132
2901
|
}
|
|
2902
|
+
// Inline commands/shell that never enter the loading state and don't touch
|
|
2903
|
+
// the queue: run them and return without flipping isLoading.
|
|
2133
2904
|
if (finalText.startsWith('/') && handleCommand(finalText))
|
|
2134
2905
|
return;
|
|
2135
2906
|
if (finalText.startsWith('!')) {
|
|
2136
|
-
|
|
2137
|
-
if (!shellCmd)
|
|
2138
|
-
return;
|
|
2139
|
-
add('system', `$ ${shellCmd}`);
|
|
2140
|
-
try {
|
|
2141
|
-
let output = execSync(shellCmd, { encoding: 'utf-8', timeout: HEADLESS_DEFAULTS.commandTimeoutMs, cwd: process.cwd() }).trim();
|
|
2142
|
-
if (output) {
|
|
2143
|
-
if (output.length > 2000) {
|
|
2144
|
-
output = output.slice(0, 2000) + `\n... (${(output.length - 2000).toLocaleString()} chars truncated)`;
|
|
2145
|
-
}
|
|
2146
|
-
add('system', output);
|
|
2147
|
-
}
|
|
2148
|
-
}
|
|
2149
|
-
catch (err) {
|
|
2150
|
-
add('error', err.stderr?.trim() || err.message);
|
|
2151
|
-
}
|
|
2907
|
+
await processFinalizedInput(finalText);
|
|
2152
2908
|
return;
|
|
2153
2909
|
}
|
|
2154
|
-
const correction = detectCorrection(finalText);
|
|
2155
|
-
if (correction) {
|
|
2156
|
-
addNote(process.cwd(), `[correction] ${correction}`).catch((err) => { logger.warn('Failed to save correction note', { error: err.message }); });
|
|
2157
|
-
}
|
|
2158
|
-
add('user', finalText);
|
|
2159
2910
|
setIsLoading(true);
|
|
2160
2911
|
setStatus('thinking');
|
|
2161
2912
|
setThinkStart(Date.now());
|
|
2162
2913
|
try {
|
|
2163
|
-
|
|
2164
|
-
if (!result || !result.trim()) {
|
|
2165
|
-
add('system', 'Agent returned no response.');
|
|
2166
|
-
}
|
|
2167
|
-
}
|
|
2168
|
-
catch (e) {
|
|
2169
|
-
add('error', e.message || 'Unknown error');
|
|
2914
|
+
await processFinalizedInput(finalText);
|
|
2170
2915
|
}
|
|
2171
2916
|
finally {
|
|
2172
|
-
// Process queued messages sequentially
|
|
2917
|
+
// Process queued messages sequentially through the SAME pipeline, so a
|
|
2918
|
+
// queued /compact, /clear, !ls, etc. behaves like freshly typed input.
|
|
2173
2919
|
while (messageQueueRef.current.length > 0) {
|
|
2174
2920
|
const next = messageQueueRef.current.shift();
|
|
2175
2921
|
const remaining = messageQueueRef.current.length;
|
|
2176
2922
|
if (remaining > 0)
|
|
2177
2923
|
add('system', `${remaining} more queued`);
|
|
2178
|
-
|
|
2179
|
-
setStatus('thinking');
|
|
2180
|
-
setThinkStart(Date.now());
|
|
2181
|
-
try {
|
|
2182
|
-
const qResult = await agent.send(next);
|
|
2183
|
-
if (!qResult || !qResult.trim())
|
|
2184
|
-
add('system', 'Agent returned no response.');
|
|
2185
|
-
}
|
|
2186
|
-
catch (e) {
|
|
2187
|
-
add('error', e.message || 'Unknown error');
|
|
2188
|
-
}
|
|
2924
|
+
await processFinalizedInput(next);
|
|
2189
2925
|
}
|
|
2190
2926
|
setIsLoading(false);
|
|
2191
2927
|
setStatus('ready');
|
|
2192
2928
|
setThinkStart(null);
|
|
2193
2929
|
}
|
|
2194
|
-
}, [add, handleCommand]);
|
|
2930
|
+
}, [add, handleCommand, processFinalizedInput]);
|
|
2195
2931
|
const handleSessionSelect = useCallback((session) => {
|
|
2196
2932
|
setSessionList(null);
|
|
2197
2933
|
setIsLoading(true);
|
|
@@ -2227,10 +2963,7 @@ function App({ config }) {
|
|
|
2227
2963
|
}, [add]);
|
|
2228
2964
|
const handleAbort = useCallback(() => {
|
|
2229
2965
|
agent.abort();
|
|
2230
|
-
|
|
2231
|
-
pendingConfirm.resolve(false);
|
|
2232
|
-
pendingConfirm = null;
|
|
2233
|
-
}
|
|
2966
|
+
drainConfirmQueue(false);
|
|
2234
2967
|
setConfirmInfo(null);
|
|
2235
2968
|
setIsLoading(false);
|
|
2236
2969
|
setStatus('ready');
|
|
@@ -2250,8 +2983,23 @@ function App({ config }) {
|
|
|
2250
2983
|
setShowBanner(true);
|
|
2251
2984
|
costTracker.reset();
|
|
2252
2985
|
setTotalTokens(0);
|
|
2986
|
+
setContextPercent(0);
|
|
2987
|
+
setRateLimitWarn(false);
|
|
2253
2988
|
sessionId = generateSessionId();
|
|
2254
2989
|
}, [models]);
|
|
2990
|
+
// Save the session (with the current model) and exit — mirrors /exit so that
|
|
2991
|
+
// Escape on an empty, idle prompt doesn't lose work.
|
|
2992
|
+
const handleExit = useCallback(() => {
|
|
2993
|
+
const msgs = agent?.getMessages() ?? [];
|
|
2994
|
+
if (msgs.length > 0) {
|
|
2995
|
+
saveSession(sessionId, msgs, models[0] || 'unknown', undefined, costTracker.getState())
|
|
2996
|
+
.catch((err) => { logger.warn('Failed to save session on exit', { error: err.message }); })
|
|
2997
|
+
.finally(() => process.exit(0));
|
|
2998
|
+
}
|
|
2999
|
+
else {
|
|
3000
|
+
process.exit(0);
|
|
3001
|
+
}
|
|
3002
|
+
}, [models]);
|
|
2255
3003
|
const handleSetupDone = useCallback((newConfig) => {
|
|
2256
3004
|
setShowSetup(false);
|
|
2257
3005
|
try {
|
|
@@ -2277,7 +3025,7 @@ function App({ config }) {
|
|
|
2277
3025
|
catch {
|
|
2278
3026
|
parsed = {};
|
|
2279
3027
|
}
|
|
2280
|
-
const extraCount = Math.min(Object.keys(parsed).filter(k => !['command', 'filePath', 'file_path', 'notebook_path', 'message', 'content'].includes(k)).length, 3);
|
|
3028
|
+
const extraCount = Math.min(Object.keys(parsed).filter(k => !['command', 'filePath', 'file_path', 'notebook_path', 'message', 'content', 'oldString', 'newString', 'old_string', 'new_string'].includes(k)).length, 3);
|
|
2281
3029
|
const hasHighlight = !!(parsed.command ?? parsed.filePath ?? parsed.file_path ?? parsed.notebook_path ?? parsed.message);
|
|
2282
3030
|
return 5 + (hasHighlight ? 1 : 0) + extraCount;
|
|
2283
3031
|
}
|
|
@@ -2300,7 +3048,7 @@ function App({ config }) {
|
|
|
2300
3048
|
const reasonLines = reasoningText ? (reasoningText.match(/\n/g) || []).length + 1 : 0;
|
|
2301
3049
|
return textLines + reasonLines;
|
|
2302
3050
|
}, [isLoading, streamText, reasoningText]);
|
|
2303
|
-
const { visibleItems, scrollUp, scrollDown, scrollToBottom, isAtBottom, } = useScrollableItems(items, chatHeight, cols, isLoading, streamLineCount);
|
|
3051
|
+
const { visibleItems, scrollUp, scrollDown, scrollToBottom, isAtBottom, isAtTop, } = useScrollableItems(items, chatHeight, cols, isLoading, streamLineCount);
|
|
2304
3052
|
const scrollToBottomRef = useRef(scrollToBottom);
|
|
2305
3053
|
scrollToBottomRef.current = scrollToBottom;
|
|
2306
3054
|
const handleSubmitRef = useRef(handleSubmit);
|
|
@@ -2312,7 +3060,7 @@ function App({ config }) {
|
|
|
2312
3060
|
if (showSetup) {
|
|
2313
3061
|
return _jsx(SetupScreen, { onComplete: handleSetupDone, existingConfig: config });
|
|
2314
3062
|
}
|
|
2315
|
-
return (_jsxs(Box, { flexDirection: "column", width: "100%", height: rows, children: [showBanner && _jsx(Banner, {}), _jsx(StatusBar, { status: status, model: models[0] || 'no model', toolCount: allTools.length + pluginTools.length, thinkStart: thinkStart, tokens: totalTokens, mode: currentMode }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 6, overflowY: "hidden", children: [showBanner && !isLoading && _jsx(WelcomeScreen, {}), visibleItems.map((item) => _jsx(RenderItem, { item: item }, item.id)), isLoading && _jsx(StreamingArea, { text: streamText, reasoning: reasoningText, model: shortModel(models[0] || '') }), !isAtBottom && (_jsx(Box, { justifyContent: "center", children: _jsxs(Text, { dimColor: true, children: [" ", '\u2193', " more below ", '\u2014', " Shift+Down "] }) }))] }), _jsx(Box, { flexShrink: 0, flexDirection: "column", children: confirmInfo ? (_jsx(ConfirmPrompt, { toolName: confirmInfo.toolName, args: confirmInfo.args, onRespond: handleConfirm, onScrollUp: scrollUp, onScrollDown: scrollDown })) : selectingModel ? (_jsx(ModelPicker, { currentModels: models, onSelect: (v) => {
|
|
3063
|
+
return (_jsxs(Box, { flexDirection: "column", width: "100%", height: rows, children: [showBanner && _jsx(Banner, {}), _jsx(StatusBar, { status: status, model: models[0] || 'no model', toolCount: allTools.length + pluginTools.length, thinkStart: thinkStart, tokens: totalTokens, mode: currentMode, contextPercent: contextPercent, rateLimitWarn: rateLimitWarn }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 6, overflowY: "hidden", children: [showBanner && !isLoading && _jsx(WelcomeScreen, {}), !isAtTop && !showBanner && (_jsx(Box, { justifyContent: "center", children: _jsxs(Text, { dimColor: true, children: [" ", '\u2191', " more above ", '\u2014', " Shift+Up "] }) })), visibleItems.map((item) => _jsx(RenderItem, { item: item }, item.id)), isLoading && _jsx(StreamingArea, { text: streamText, reasoning: reasoningText, model: shortModel(models[0] || '') }), !isAtBottom && (_jsx(Box, { justifyContent: "center", children: _jsxs(Text, { dimColor: true, children: [" ", '\u2193', " more below ", '\u2014', " Shift+Down "] }) }))] }), _jsx(Box, { flexShrink: 0, flexDirection: "column", children: confirmInfo ? (_jsx(ConfirmPrompt, { toolName: confirmInfo.toolName, args: confirmInfo.args, onRespond: handleConfirm, onScrollUp: scrollUp, onScrollDown: scrollDown })) : selectingModel ? (_jsx(ModelPicker, { currentModels: models, onSelect: (v) => {
|
|
2316
3064
|
agent.setModel(v);
|
|
2317
3065
|
setModels([v]);
|
|
2318
3066
|
setSelectingModel(false);
|
|
@@ -2354,7 +3102,7 @@ function App({ config }) {
|
|
|
2354
3102
|
}).catch(() => {
|
|
2355
3103
|
add('error', 'Failed to load config.');
|
|
2356
3104
|
});
|
|
2357
|
-
}, onClose: () => setEditingSettings(false) })) : sessionList ? (_jsx(SessionPicker, { sessions: sessionList, onSelect: handleSessionSelect, onCancel: () => setSessionList(null) })) : (_jsx(InputSection, { onSubmit: handleInputSubmit, disabled: isLoading, onAbort: handleAbort, onClear: handleClearScreen, onScrollUp: scrollUp, onScrollDown: scrollDown, onHeightChange: handleInputHeightChange })) })] }));
|
|
3105
|
+
}, onClose: () => setEditingSettings(false) })) : sessionList ? (_jsx(SessionPicker, { sessions: sessionList, onSelect: handleSessionSelect, onCancel: () => setSessionList(null) })) : (_jsx(InputSection, { onSubmit: handleInputSubmit, disabled: isLoading, onAbort: handleAbort, onClear: handleClearScreen, onScrollUp: scrollUp, onScrollDown: scrollDown, onHeightChange: handleInputHeightChange, onToggleToolGroups: toggleToolGroups, onImagePaste: (dataUrl) => { pendingImagesRef.current.push(dataUrl); }, onExit: handleExit })) })] }));
|
|
2358
3106
|
}
|
|
2359
3107
|
// ─── Root (config loading + setup gate) ───
|
|
2360
3108
|
function Root() {
|