scalix 1.2.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/dist/agent.js +57 -30
- package/dist/api-client.js +44 -9
- package/dist/auto-verify.js +3 -1
- package/dist/cli.js +392 -167
- package/dist/commands.js +19 -2
- package/dist/constants.js +84 -3
- 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 +86 -43
- package/dist/hooks.js +55 -5
- package/dist/injection-detector.js +18 -1
- package/dist/ipc-server.js +7 -1
- package/dist/keychain.js +117 -4
- package/dist/markdown.js +4 -1
- package/dist/model-router.js +11 -0
- package/dist/sandbox.js +73 -9
- package/dist/scoped-rules.js +10 -1
- package/dist/sessions.js +6 -6
- package/dist/settings.js +53 -14
- package/dist/sub-agent.js +59 -2
- 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 +42 -6
- package/dist/tools-web.js +76 -22
- package/dist/tools.js +27 -2
- package/dist/workspace-trust.js +90 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
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';
|
|
9
10
|
import os from 'node:os';
|
|
10
11
|
import fs from 'node:fs/promises';
|
|
11
12
|
import { existsSync, readFileSync } from 'node:fs';
|
|
@@ -42,7 +43,42 @@ import { loadSkills, skillsSummary, getSkillByTrigger, getSkillContent } from '.
|
|
|
42
43
|
import { AgentRegistry } from './agent-registry.js';
|
|
43
44
|
import { createWorktree, listWorktrees, removeWorktree, cleanupStaleWorktrees } from './worktree.js';
|
|
44
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';
|
|
45
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
|
+
}
|
|
46
82
|
setSandboxRoot(process.cwd());
|
|
47
83
|
// Suppress noisy React 19 development-mode reconciler warnings that leak to stderr
|
|
48
84
|
const _origConsoleError = console.error;
|
|
@@ -51,28 +87,42 @@ console.error = (...args) => {
|
|
|
51
87
|
return;
|
|
52
88
|
_origConsoleError(...args);
|
|
53
89
|
};
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
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');
|
|
57
95
|
const mcpTools = loadMcpServices();
|
|
58
96
|
let pluginTools = [];
|
|
59
97
|
const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...mcpTools];
|
|
60
98
|
// ─── Confirmation bridge (event-driven) ───
|
|
61
99
|
import { EventEmitter } from 'node:events';
|
|
62
100
|
const confirmEmitter = new EventEmitter();
|
|
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
|
+
}
|
|
64
112
|
const alwaysAllowedTools = new Set();
|
|
65
113
|
const recentDenials = [];
|
|
66
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;
|
|
67
120
|
if (alwaysAllowedTools.has(toolName))
|
|
68
121
|
return true;
|
|
69
|
-
const scoped = checkScopedPermission(toolName, args);
|
|
70
122
|
if (scoped === 'allow')
|
|
71
123
|
return true;
|
|
72
|
-
if (scoped === 'deny')
|
|
73
|
-
return false;
|
|
74
124
|
return new Promise((resolve) => {
|
|
75
|
-
|
|
125
|
+
confirmQueue.push({ resolve, toolName, args });
|
|
76
126
|
confirmEmitter.emit('pending');
|
|
77
127
|
});
|
|
78
128
|
};
|
|
@@ -85,6 +135,26 @@ let loadedPlugins = [];
|
|
|
85
135
|
let projectMemory = null;
|
|
86
136
|
let customCommands = [];
|
|
87
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
|
+
}
|
|
88
158
|
async function buildEnhancedInstructions() {
|
|
89
159
|
let instructions = baseInstructions;
|
|
90
160
|
const cwd = process.cwd();
|
|
@@ -114,13 +184,17 @@ async function buildEnhancedInstructions() {
|
|
|
114
184
|
instructions += '\n\n' + indexToContext(projectIndex);
|
|
115
185
|
}
|
|
116
186
|
catch { }
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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 { }
|
|
122
197
|
}
|
|
123
|
-
catch { }
|
|
124
198
|
return instructions;
|
|
125
199
|
}
|
|
126
200
|
function initAgent(config) {
|
|
@@ -152,16 +226,11 @@ function initAgent(config) {
|
|
|
152
226
|
thinkingMode: config.thinkingMode || 'off',
|
|
153
227
|
});
|
|
154
228
|
const currentAgent = agent;
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
for (const pt of pTools)
|
|
161
|
-
currentAgent.addTool(pt);
|
|
162
|
-
}).catch((err) => {
|
|
163
|
-
logger.warn('Plugin discovery failed', { error: err.message });
|
|
164
|
-
});
|
|
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);
|
|
165
234
|
buildEnhancedInstructions().then((enhanced) => {
|
|
166
235
|
currentAgent.setInstructions(enhanced);
|
|
167
236
|
}).catch((err) => {
|
|
@@ -179,22 +248,36 @@ function initAgent(config) {
|
|
|
179
248
|
function shortModel(m) {
|
|
180
249
|
return m.split('/').pop().replace(/:free$/, '').replace(/-instruct$/, '');
|
|
181
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
|
+
}
|
|
182
274
|
function useTermSize() {
|
|
183
|
-
const
|
|
184
|
-
const [size, setSize] = useState({ cols: stdout?.columns ?? 80, rows: stdout?.rows ?? 24 });
|
|
275
|
+
const [size, setSize] = useState(termSize);
|
|
185
276
|
useEffect(() => {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (prev.cols === newCols && prev.rows === newRows)
|
|
191
|
-
return prev;
|
|
192
|
-
return { cols: newCols, rows: newRows };
|
|
193
|
-
});
|
|
194
|
-
};
|
|
195
|
-
stdout?.on('resize', fn);
|
|
196
|
-
return () => { stdout?.off('resize', fn); };
|
|
197
|
-
}, [stdout]);
|
|
277
|
+
// Sync any change that happened between initial render and subscribe.
|
|
278
|
+
setSize(termSize);
|
|
279
|
+
return subscribeTermSize(() => setSize(termSize));
|
|
280
|
+
}, []);
|
|
198
281
|
return size;
|
|
199
282
|
}
|
|
200
283
|
function elapsed(start) {
|
|
@@ -219,17 +302,27 @@ function formatTokens(n) {
|
|
|
219
302
|
const Banner = memo(function Banner() {
|
|
220
303
|
return (_jsx(Box, { flexDirection: "column", alignItems: "center", marginBottom: 0, children: _jsx(Gradient, { name: "teen", children: _jsx(BigText, { text: PRODUCT_NAME.toLowerCase(), font: "tiny" }) }) }));
|
|
221
304
|
});
|
|
222
|
-
// 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.
|
|
223
308
|
function sanitizeInput(v) {
|
|
224
|
-
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, '');
|
|
225
315
|
}
|
|
226
316
|
async function tryPasteImage() {
|
|
227
317
|
const platform = os.platform();
|
|
228
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}"`;
|
|
229
322
|
try {
|
|
230
323
|
if (platform === 'darwin') {
|
|
231
324
|
try {
|
|
232
|
-
execSync(`pngpaste ${
|
|
325
|
+
execSync(`pngpaste ${q}`, { stdio: 'pipe', timeout: 3000 });
|
|
233
326
|
}
|
|
234
327
|
catch {
|
|
235
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();
|
|
@@ -238,8 +331,15 @@ async function tryPasteImage() {
|
|
|
238
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 });
|
|
239
332
|
}
|
|
240
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
|
+
}
|
|
241
341
|
else {
|
|
242
|
-
execSync(`xclip -selection clipboard -t image/png -o > ${
|
|
342
|
+
execSync(`xclip -selection clipboard -t image/png -o > ${q}`, { stdio: 'pipe', timeout: 3000 });
|
|
243
343
|
}
|
|
244
344
|
const { readFileSync, unlinkSync } = await import('node:fs');
|
|
245
345
|
const data = readFileSync(tmpFile);
|
|
@@ -458,7 +558,20 @@ const StatusBar = memo(function StatusBar({ status, model, toolCount, thinkStart
|
|
|
458
558
|
const modeColor = mode === 'yolo' ? 'red' : mode === 'auto' ? 'yellow' : mode === 'plan' ? 'blue' : 'green';
|
|
459
559
|
const statusIcon = status === 'thinking' ? '\u25D4' : status === 'error' ? '\u2718' : '\u25CF';
|
|
460
560
|
const statusColor = status === 'thinking' ? 'yellow' : status === 'error' ? 'red' : 'green';
|
|
461
|
-
|
|
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
|
+
}, []);
|
|
462
575
|
const leftParts = [];
|
|
463
576
|
leftParts.push(shortModel(model));
|
|
464
577
|
if (tokens > 0)
|
|
@@ -610,15 +723,28 @@ function ConfirmPrompt({ toolName, args, onRespond, onScrollUp, onScrollDown })
|
|
|
610
723
|
const isCommand = toolName === 'run_command';
|
|
611
724
|
const isWrite = toolName === 'write_file' || toolName === 'edit_file' || toolName === 'notebook_edit';
|
|
612
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');
|
|
613
729
|
const highlight = parsed.command ?? parsed.filePath ?? parsed.file_path ?? parsed.notebook_path ?? parsed.message ?? null;
|
|
614
730
|
const highlightLabel = isCommand ? 'Command' : isGit ? 'Message' : isWrite ? 'File' : 'Args';
|
|
615
|
-
const highlightStr = highlight ? String(highlight) : null;
|
|
616
|
-
|
|
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));
|
|
617
736
|
const boxWidth = Math.min(cols - 2, 80);
|
|
618
737
|
const border = '\u2500'.repeat(boxWidth - 2);
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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));
|
|
622
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'] })] }));
|
|
623
749
|
}
|
|
624
750
|
function ModelPicker({ currentModels, onSelect, onCancel }) {
|
|
@@ -805,7 +931,7 @@ function SettingsEditor({ onSave, onClose }) {
|
|
|
805
931
|
}
|
|
806
932
|
} })] }));
|
|
807
933
|
}
|
|
808
|
-
const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, onClear, onScrollUp, onScrollDown, onHeightChange, onToggleToolGroups, onImagePaste }) {
|
|
934
|
+
const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, onClear, onScrollUp, onScrollDown, onHeightChange, onToggleToolGroups, onImagePaste, onExit }) {
|
|
809
935
|
const { exit } = useApp();
|
|
810
936
|
const { cols } = useTermSize();
|
|
811
937
|
const line = useMemo(() => '\u2500'.repeat(Math.max(cols - 6, 20)), [cols]);
|
|
@@ -837,6 +963,9 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
|
|
|
837
963
|
setHistoryIndex(-1);
|
|
838
964
|
setValue('');
|
|
839
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([]);
|
|
840
969
|
onSubmit(trimmed);
|
|
841
970
|
}, [onSubmit]);
|
|
842
971
|
useInput((ch, key) => {
|
|
@@ -851,16 +980,20 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
|
|
|
851
980
|
onChange('');
|
|
852
981
|
return;
|
|
853
982
|
}
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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 {
|
|
859
992
|
if (ctrlCTimer.current)
|
|
860
993
|
clearTimeout(ctrlCTimer.current);
|
|
861
994
|
ctrlCTimer.current = setTimeout(() => setCtrlCCount(0), 3000);
|
|
862
|
-
|
|
863
|
-
}
|
|
995
|
+
setCtrlCCount(1);
|
|
996
|
+
}
|
|
864
997
|
return;
|
|
865
998
|
}
|
|
866
999
|
// ── Ctrl+D: exit when input is empty ──
|
|
@@ -1002,13 +1135,34 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
|
|
|
1002
1135
|
onChange(matches[0]);
|
|
1003
1136
|
return;
|
|
1004
1137
|
}
|
|
1005
|
-
// ── Escape: close
|
|
1138
|
+
// ── Escape: close picker → clear input → abort task → exit ──
|
|
1006
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.
|
|
1007
1147
|
if (val.startsWith('/') && !val.includes(' ')) {
|
|
1008
1148
|
onChange('');
|
|
1009
1149
|
return;
|
|
1010
1150
|
}
|
|
1011
|
-
|
|
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();
|
|
1012
1166
|
return;
|
|
1013
1167
|
}
|
|
1014
1168
|
// ── When command picker is open, handle input manually since TextInput is unfocused ──
|
|
@@ -1092,6 +1246,7 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
|
|
|
1092
1246
|
{ cmd: '/remember', desc: 'Save a note about this project' },
|
|
1093
1247
|
{ cmd: '/replay', desc: 'Show session timeline' },
|
|
1094
1248
|
{ cmd: '/plugins', desc: 'Show loaded plugins' },
|
|
1249
|
+
{ cmd: '/trust', desc: 'Trust this workspace (load project plugins/hooks/rules)' },
|
|
1095
1250
|
{ cmd: '/router', desc: 'Show model routing status' },
|
|
1096
1251
|
{ cmd: '/status', desc: 'Version, model & connection info' },
|
|
1097
1252
|
{ cmd: '/doctor', desc: 'Diagnose installation' },
|
|
@@ -1179,10 +1334,11 @@ function estimateLines(item, cols) {
|
|
|
1179
1334
|
// Account for paddingLeft=3 + paddingRight=2 used by most message components
|
|
1180
1335
|
const textWidth = Math.max(cols - 7, 20);
|
|
1181
1336
|
const lines = item.text.split('\n');
|
|
1182
|
-
// 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.
|
|
1183
1339
|
let totalLines = 0;
|
|
1184
1340
|
for (const line of lines) {
|
|
1185
|
-
totalLines += Math.max(1, Math.ceil((line
|
|
1341
|
+
totalLines += Math.max(1, Math.ceil((stringWidth(line) || 1) / textWidth));
|
|
1186
1342
|
}
|
|
1187
1343
|
switch (item.kind) {
|
|
1188
1344
|
case 'user': return totalLines + 2; // marginTop=1 + prompt icon row
|
|
@@ -1388,9 +1544,13 @@ function App({ config }) {
|
|
|
1388
1544
|
}
|
|
1389
1545
|
};
|
|
1390
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();
|
|
1391
1550
|
add('tool-call', typeof args === 'string' ? args : JSON.stringify(args), name);
|
|
1392
1551
|
};
|
|
1393
1552
|
const onToolResult = (name, result) => {
|
|
1553
|
+
resetStallTimer();
|
|
1394
1554
|
// Extract diff from write_file / edit_file results and show separately
|
|
1395
1555
|
let parsed = null;
|
|
1396
1556
|
if (typeof result === 'object' && result !== null) {
|
|
@@ -1521,6 +1681,12 @@ function App({ config }) {
|
|
|
1521
1681
|
}
|
|
1522
1682
|
}).catch(() => { });
|
|
1523
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]);
|
|
1524
1690
|
// Idle session nudge: suggest /clear after 75+ minutes of inactivity
|
|
1525
1691
|
const lastActivityRef = useRef(Date.now());
|
|
1526
1692
|
const idleNudgedRef = useRef(false);
|
|
@@ -1539,19 +1705,28 @@ function App({ config }) {
|
|
|
1539
1705
|
useEffect(() => {
|
|
1540
1706
|
if (!agent)
|
|
1541
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;
|
|
1542
1711
|
const onStreamEnd = () => {
|
|
1543
|
-
|
|
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) {
|
|
1544
1718
|
sendNotification('Scalix', 'Task complete');
|
|
1545
1719
|
}
|
|
1546
1720
|
};
|
|
1547
|
-
|
|
1548
|
-
return () => {
|
|
1721
|
+
currentAgent.on('stream:end', onStreamEnd);
|
|
1722
|
+
return () => { currentAgent.off('stream:end', onStreamEnd); };
|
|
1549
1723
|
}, [agentVersion]);
|
|
1550
|
-
// Confirmation bridge (event-driven)
|
|
1724
|
+
// Confirmation bridge (event-driven). Surfaces the head of the FIFO queue.
|
|
1551
1725
|
useEffect(() => {
|
|
1552
1726
|
const onPending = () => {
|
|
1553
|
-
if (
|
|
1554
|
-
|
|
1727
|
+
if (confirmQueue.length > 0 && !confirmInfo) {
|
|
1728
|
+
const head = confirmQueue[0];
|
|
1729
|
+
setConfirmInfo({ toolName: head.toolName, args: head.args });
|
|
1555
1730
|
}
|
|
1556
1731
|
};
|
|
1557
1732
|
confirmEmitter.on('pending', onPending);
|
|
@@ -1563,22 +1738,24 @@ function App({ config }) {
|
|
|
1563
1738
|
add('system', banner);
|
|
1564
1739
|
}, [add]);
|
|
1565
1740
|
const handleConfirm = useCallback((ok, always) => {
|
|
1566
|
-
|
|
1741
|
+
const current = confirmQueue.shift();
|
|
1742
|
+
if (current) {
|
|
1567
1743
|
if (ok && always)
|
|
1568
|
-
alwaysAllowedTools.add(
|
|
1744
|
+
alwaysAllowedTools.add(current.toolName);
|
|
1569
1745
|
if (!ok) {
|
|
1570
1746
|
recentDenials.push({
|
|
1571
|
-
toolName:
|
|
1572
|
-
args: JSON.stringify(
|
|
1747
|
+
toolName: current.toolName,
|
|
1748
|
+
args: JSON.stringify(current.args).slice(0, 100),
|
|
1573
1749
|
timestamp: Date.now(),
|
|
1574
1750
|
});
|
|
1575
1751
|
if (recentDenials.length > 20)
|
|
1576
1752
|
recentDenials.shift();
|
|
1577
1753
|
}
|
|
1578
|
-
|
|
1579
|
-
pendingConfirm = null;
|
|
1754
|
+
current.resolve(ok);
|
|
1580
1755
|
}
|
|
1581
|
-
|
|
1756
|
+
// Surface the next queued confirmation, if any.
|
|
1757
|
+
const next = confirmQueue[0];
|
|
1758
|
+
setConfirmInfo(next ? { toolName: next.toolName, args: next.args } : null);
|
|
1582
1759
|
}, []);
|
|
1583
1760
|
const handleCommand = useCallback((text) => {
|
|
1584
1761
|
const cmd = text.split(' ')[0].toLowerCase();
|
|
@@ -1635,6 +1812,7 @@ function App({ config }) {
|
|
|
1635
1812
|
' /mcp <l> <u> \u2014 add MCP server',
|
|
1636
1813
|
' /plugins \u2014 show loaded plugins',
|
|
1637
1814
|
' /plugin \u2014 install/enable/disable/uninstall plugins',
|
|
1815
|
+
' /trust \u2014 trust workspace (load project plugins/hooks/rules)',
|
|
1638
1816
|
' /notify \u2014 test terminal notification',
|
|
1639
1817
|
' /router \u2014 show model routing status',
|
|
1640
1818
|
' /replay \u2014 show session timeline',
|
|
@@ -1655,10 +1833,7 @@ function App({ config }) {
|
|
|
1655
1833
|
}
|
|
1656
1834
|
if (cmd === '/clear') {
|
|
1657
1835
|
agent.abort();
|
|
1658
|
-
|
|
1659
|
-
pendingConfirm.resolve(false);
|
|
1660
|
-
pendingConfirm = null;
|
|
1661
|
-
}
|
|
1836
|
+
drainConfirmQueue(false);
|
|
1662
1837
|
const msgs = agent.getMessages();
|
|
1663
1838
|
if (msgs.length > 0) {
|
|
1664
1839
|
saveSession(sessionId, msgs, models[0] || 'unknown', undefined, costTracker.getState()).then((meta) => {
|
|
@@ -1936,6 +2111,27 @@ function App({ config }) {
|
|
|
1936
2111
|
add('system', pluginsSummary(loadedPlugins));
|
|
1937
2112
|
return true;
|
|
1938
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
|
+
}
|
|
1939
2135
|
if (cmd === '/replay') {
|
|
1940
2136
|
const replay = agent.getReplay();
|
|
1941
2137
|
const timeline = replay.getTimeline();
|
|
@@ -2188,17 +2384,18 @@ function App({ config }) {
|
|
|
2188
2384
|
add('system', 'Generating commit...');
|
|
2189
2385
|
(async () => {
|
|
2190
2386
|
try {
|
|
2387
|
+
const gitOpts = { timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() };
|
|
2191
2388
|
// Check for staged changes
|
|
2192
2389
|
let diff = '';
|
|
2193
2390
|
try {
|
|
2194
|
-
diff =
|
|
2391
|
+
diff = (await execFileAsync('git', ['diff', '--staged'], gitOpts)).stdout.trim();
|
|
2195
2392
|
}
|
|
2196
2393
|
catch { }
|
|
2197
2394
|
if (!diff) {
|
|
2198
2395
|
// Nothing staged — stage tracked files only (safer than -A)
|
|
2199
2396
|
try {
|
|
2200
|
-
|
|
2201
|
-
diff =
|
|
2397
|
+
await execFileAsync('git', ['add', '-u'], gitOpts);
|
|
2398
|
+
diff = (await execFileAsync('git', ['diff', '--staged'], gitOpts)).stdout.trim();
|
|
2202
2399
|
}
|
|
2203
2400
|
catch (e) {
|
|
2204
2401
|
add('error', `Failed to stage files: ${e.message}`);
|
|
@@ -2220,7 +2417,7 @@ function App({ config }) {
|
|
|
2220
2417
|
add('system', `Commit message: ${cleanMsg}`);
|
|
2221
2418
|
try {
|
|
2222
2419
|
const fullMsg = cleanMsg + `\n\nCo-Authored-By: ${GIT_BOT_IDENTITY.name} <${GIT_BOT_IDENTITY.email}>`;
|
|
2223
|
-
const result =
|
|
2420
|
+
const result = (await execFileAsync('git', ['commit', '-m', fullMsg], gitOpts)).stdout.trim();
|
|
2224
2421
|
add('system', result);
|
|
2225
2422
|
}
|
|
2226
2423
|
catch (e) {
|
|
@@ -2245,26 +2442,27 @@ function App({ config }) {
|
|
|
2245
2442
|
add('system', 'Running code review...');
|
|
2246
2443
|
(async () => {
|
|
2247
2444
|
try {
|
|
2445
|
+
const gitOpts = { timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() };
|
|
2248
2446
|
let diff = '';
|
|
2249
2447
|
let baseBranch = 'main';
|
|
2250
2448
|
try {
|
|
2251
|
-
baseBranch =
|
|
2449
|
+
baseBranch = (await execFileAsync('git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], gitOpts)).stdout.trim().replace('refs/remotes/origin/', '');
|
|
2252
2450
|
}
|
|
2253
2451
|
catch {
|
|
2254
2452
|
try {
|
|
2255
|
-
const branches =
|
|
2453
|
+
const branches = (await execFileAsync('git', ['branch', '--list', 'main', 'master'], gitOpts)).stdout.trim();
|
|
2256
2454
|
if (branches.includes('master'))
|
|
2257
2455
|
baseBranch = 'master';
|
|
2258
2456
|
}
|
|
2259
2457
|
catch { }
|
|
2260
2458
|
}
|
|
2261
2459
|
try {
|
|
2262
|
-
diff =
|
|
2460
|
+
diff = (await execFileAsync('git', ['diff', `${baseBranch}...HEAD`], gitOpts)).stdout.trim();
|
|
2263
2461
|
}
|
|
2264
2462
|
catch { }
|
|
2265
2463
|
if (!diff) {
|
|
2266
2464
|
try {
|
|
2267
|
-
diff =
|
|
2465
|
+
diff = (await execFileAsync('git', ['diff'], gitOpts)).stdout.trim();
|
|
2268
2466
|
}
|
|
2269
2467
|
catch { }
|
|
2270
2468
|
}
|
|
@@ -2300,7 +2498,8 @@ function App({ config }) {
|
|
|
2300
2498
|
const MAX_RETRIES = 5;
|
|
2301
2499
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
2302
2500
|
try {
|
|
2303
|
-
const
|
|
2501
|
+
const { stdout } = await runShellAsync(watchCmd, { timeout: TOOL_DEFAULTS.maxCommandTimeoutMs, cwd: process.cwd() });
|
|
2502
|
+
const output = stdout.trim();
|
|
2304
2503
|
if (output)
|
|
2305
2504
|
add('system', output.length > 2000 ? output.slice(0, 2000) + '\n... (truncated)' : output);
|
|
2306
2505
|
add('system', `Watch: command succeeded on attempt ${attempt}.`);
|
|
@@ -2351,30 +2550,33 @@ function App({ config }) {
|
|
|
2351
2550
|
return true;
|
|
2352
2551
|
}
|
|
2353
2552
|
if (cmd === '/diff') {
|
|
2354
|
-
|
|
2355
|
-
let diff = '';
|
|
2356
|
-
try {
|
|
2357
|
-
diff = execSync('git diff', { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() }).trim();
|
|
2358
|
-
}
|
|
2359
|
-
catch { }
|
|
2360
|
-
let staged = '';
|
|
2553
|
+
(async () => {
|
|
2361
2554
|
try {
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
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
|
+
}
|
|
2369
2575
|
}
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
truncated.split('\n').forEach((l) => add('system', l));
|
|
2576
|
+
catch (e) {
|
|
2577
|
+
add('error', `Failed to get diff: ${e.message}`);
|
|
2373
2578
|
}
|
|
2374
|
-
}
|
|
2375
|
-
catch (e) {
|
|
2376
|
-
add('error', `Failed to get diff: ${e.message}`);
|
|
2377
|
-
}
|
|
2579
|
+
})();
|
|
2378
2580
|
return true;
|
|
2379
2581
|
}
|
|
2380
2582
|
if (cmd === '/copy') {
|
|
@@ -2602,43 +2804,13 @@ function App({ config }) {
|
|
|
2602
2804
|
const multilineRef = useRef([]);
|
|
2603
2805
|
const messageQueueRef = useRef([]);
|
|
2604
2806
|
const pendingImagesRef = useRef([]);
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
// Allow /mode even while the agent is working
|
|
2613
|
-
if (isLoadingRef.current && trimmed.startsWith('/mode')) {
|
|
2614
|
-
handleCommand(trimmed);
|
|
2615
|
-
return;
|
|
2616
|
-
}
|
|
2617
|
-
// Queue messages while agent is working
|
|
2618
|
-
if (isLoadingRef.current) {
|
|
2619
|
-
messageQueueRef.current.push(trimmed);
|
|
2620
|
-
add('system', `Queued (${messageQueueRef.current.length}): ${truncate(trimmed, 60)}`);
|
|
2621
|
-
return;
|
|
2622
|
-
}
|
|
2623
|
-
if (trimmed.endsWith('\\')) {
|
|
2624
|
-
if (multilineRef.current.length >= 50) {
|
|
2625
|
-
add('system', 'Multiline limit reached (50 lines). Sending now.');
|
|
2626
|
-
}
|
|
2627
|
-
else {
|
|
2628
|
-
multilineRef.current.push(trimmed.slice(0, -1));
|
|
2629
|
-
add('system', '... (continue typing, submit without \\ to send)');
|
|
2630
|
-
return;
|
|
2631
|
-
}
|
|
2632
|
-
}
|
|
2633
|
-
let finalText;
|
|
2634
|
-
if (multilineRef.current.length > 0) {
|
|
2635
|
-
multilineRef.current.push(trimmed);
|
|
2636
|
-
finalText = multilineRef.current.join('\n');
|
|
2637
|
-
multilineRef.current = [];
|
|
2638
|
-
}
|
|
2639
|
-
else {
|
|
2640
|
-
finalText = trimmed;
|
|
2641
|
-
}
|
|
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;
|
|
2642
2814
|
if (finalText.startsWith('/') && handleCommand(finalText))
|
|
2643
2815
|
return;
|
|
2644
2816
|
const triggeredSkill = getSkillByTrigger(loadedSkills, finalText);
|
|
@@ -2652,7 +2824,8 @@ function App({ config }) {
|
|
|
2652
2824
|
return;
|
|
2653
2825
|
add('system', `$ ${shellCmd}`);
|
|
2654
2826
|
try {
|
|
2655
|
-
|
|
2827
|
+
const { stdout } = await runShellAsync(shellCmd, { timeout: HEADLESS_DEFAULTS.commandTimeoutMs, cwd: process.cwd() });
|
|
2828
|
+
let output = stdout.trim();
|
|
2656
2829
|
if (output) {
|
|
2657
2830
|
if (output.length > 2000) {
|
|
2658
2831
|
output = output.slice(0, 2000) + `\n... (${(output.length - 2000).toLocaleString()} chars truncated)`;
|
|
@@ -2677,7 +2850,6 @@ function App({ config }) {
|
|
|
2677
2850
|
add('user', finalText);
|
|
2678
2851
|
}
|
|
2679
2852
|
pendingImagesRef.current = [];
|
|
2680
|
-
setIsLoading(true);
|
|
2681
2853
|
setStatus('thinking');
|
|
2682
2854
|
setThinkStart(Date.now());
|
|
2683
2855
|
try {
|
|
@@ -2689,30 +2861,73 @@ function App({ config }) {
|
|
|
2689
2861
|
catch (e) {
|
|
2690
2862
|
add('error', e.message || 'Unknown error');
|
|
2691
2863
|
}
|
|
2864
|
+
}, [add, handleCommand]);
|
|
2865
|
+
const handleSubmit = useCallback(async (text) => {
|
|
2866
|
+
const trimmed = text.trim();
|
|
2867
|
+
if (!trimmed)
|
|
2868
|
+
return;
|
|
2869
|
+
// Reset idle nudge on any user activity
|
|
2870
|
+
lastActivityRef.current = Date.now();
|
|
2871
|
+
idleNudgedRef.current = false;
|
|
2872
|
+
// Allow /mode even while the agent is working
|
|
2873
|
+
if (isLoadingRef.current && trimmed.startsWith('/mode')) {
|
|
2874
|
+
handleCommand(trimmed);
|
|
2875
|
+
return;
|
|
2876
|
+
}
|
|
2877
|
+
// Queue messages while agent is working
|
|
2878
|
+
if (isLoadingRef.current) {
|
|
2879
|
+
messageQueueRef.current.push(trimmed);
|
|
2880
|
+
add('system', `Queued (${messageQueueRef.current.length}): ${truncate(trimmed, 60)}`);
|
|
2881
|
+
return;
|
|
2882
|
+
}
|
|
2883
|
+
if (trimmed.endsWith('\\')) {
|
|
2884
|
+
if (multilineRef.current.length >= 50) {
|
|
2885
|
+
add('system', 'Multiline limit reached (50 lines). Sending now.');
|
|
2886
|
+
}
|
|
2887
|
+
else {
|
|
2888
|
+
multilineRef.current.push(trimmed.slice(0, -1));
|
|
2889
|
+
add('system', '... (continue typing, submit without \\ to send)');
|
|
2890
|
+
return;
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
let finalText;
|
|
2894
|
+
if (multilineRef.current.length > 0) {
|
|
2895
|
+
multilineRef.current.push(trimmed);
|
|
2896
|
+
finalText = multilineRef.current.join('\n');
|
|
2897
|
+
multilineRef.current = [];
|
|
2898
|
+
}
|
|
2899
|
+
else {
|
|
2900
|
+
finalText = trimmed;
|
|
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.
|
|
2904
|
+
if (finalText.startsWith('/') && handleCommand(finalText))
|
|
2905
|
+
return;
|
|
2906
|
+
if (finalText.startsWith('!')) {
|
|
2907
|
+
await processFinalizedInput(finalText);
|
|
2908
|
+
return;
|
|
2909
|
+
}
|
|
2910
|
+
setIsLoading(true);
|
|
2911
|
+
setStatus('thinking');
|
|
2912
|
+
setThinkStart(Date.now());
|
|
2913
|
+
try {
|
|
2914
|
+
await processFinalizedInput(finalText);
|
|
2915
|
+
}
|
|
2692
2916
|
finally {
|
|
2693
|
-
// 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.
|
|
2694
2919
|
while (messageQueueRef.current.length > 0) {
|
|
2695
2920
|
const next = messageQueueRef.current.shift();
|
|
2696
2921
|
const remaining = messageQueueRef.current.length;
|
|
2697
2922
|
if (remaining > 0)
|
|
2698
2923
|
add('system', `${remaining} more queued`);
|
|
2699
|
-
|
|
2700
|
-
setStatus('thinking');
|
|
2701
|
-
setThinkStart(Date.now());
|
|
2702
|
-
try {
|
|
2703
|
-
const qResult = await agent.send(next);
|
|
2704
|
-
if (!qResult || !qResult.trim())
|
|
2705
|
-
add('system', 'Agent returned no response.');
|
|
2706
|
-
}
|
|
2707
|
-
catch (e) {
|
|
2708
|
-
add('error', e.message || 'Unknown error');
|
|
2709
|
-
}
|
|
2924
|
+
await processFinalizedInput(next);
|
|
2710
2925
|
}
|
|
2711
2926
|
setIsLoading(false);
|
|
2712
2927
|
setStatus('ready');
|
|
2713
2928
|
setThinkStart(null);
|
|
2714
2929
|
}
|
|
2715
|
-
}, [add, handleCommand]);
|
|
2930
|
+
}, [add, handleCommand, processFinalizedInput]);
|
|
2716
2931
|
const handleSessionSelect = useCallback((session) => {
|
|
2717
2932
|
setSessionList(null);
|
|
2718
2933
|
setIsLoading(true);
|
|
@@ -2748,10 +2963,7 @@ function App({ config }) {
|
|
|
2748
2963
|
}, [add]);
|
|
2749
2964
|
const handleAbort = useCallback(() => {
|
|
2750
2965
|
agent.abort();
|
|
2751
|
-
|
|
2752
|
-
pendingConfirm.resolve(false);
|
|
2753
|
-
pendingConfirm = null;
|
|
2754
|
-
}
|
|
2966
|
+
drainConfirmQueue(false);
|
|
2755
2967
|
setConfirmInfo(null);
|
|
2756
2968
|
setIsLoading(false);
|
|
2757
2969
|
setStatus('ready');
|
|
@@ -2775,6 +2987,19 @@ function App({ config }) {
|
|
|
2775
2987
|
setRateLimitWarn(false);
|
|
2776
2988
|
sessionId = generateSessionId();
|
|
2777
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]);
|
|
2778
3003
|
const handleSetupDone = useCallback((newConfig) => {
|
|
2779
3004
|
setShowSetup(false);
|
|
2780
3005
|
try {
|
|
@@ -2800,7 +3025,7 @@ function App({ config }) {
|
|
|
2800
3025
|
catch {
|
|
2801
3026
|
parsed = {};
|
|
2802
3027
|
}
|
|
2803
|
-
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);
|
|
2804
3029
|
const hasHighlight = !!(parsed.command ?? parsed.filePath ?? parsed.file_path ?? parsed.notebook_path ?? parsed.message);
|
|
2805
3030
|
return 5 + (hasHighlight ? 1 : 0) + extraCount;
|
|
2806
3031
|
}
|
|
@@ -2877,7 +3102,7 @@ function App({ config }) {
|
|
|
2877
3102
|
}).catch(() => {
|
|
2878
3103
|
add('error', 'Failed to load config.');
|
|
2879
3104
|
});
|
|
2880
|
-
}, 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); } })) })] }));
|
|
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 })) })] }));
|
|
2881
3106
|
}
|
|
2882
3107
|
// ─── Root (config loading + setup gate) ───
|
|
2883
3108
|
function Root() {
|