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/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, useStdout } from 'ink';
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
- // Disable bracketed paste mode — terminals (via zsh) may leave it enabled,
55
- // which wraps pasted text in escape sequences that corrupt TextInput values.
56
- process.stdout.write('\x1b[?2004l');
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
- let pendingConfirm = null;
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
- pendingConfirm = { resolve, toolName, args };
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
- try {
118
- const rules = await loadScopedRules(cwd);
119
- const rulesCtx = rulesToContext(rules);
120
- if (rulesCtx)
121
- instructions += '\n\n' + rulesCtx;
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
- loadedPlugins = [];
156
- discoverPlugins(process.cwd()).then((plugins) => {
157
- loadedPlugins = plugins;
158
- const pTools = getPluginTools(plugins);
159
- pluginTools = pTools;
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 { stdout } = useStdout();
184
- const [size, setSize] = useState({ cols: stdout?.columns ?? 80, rows: stdout?.rows ?? 24 });
275
+ const [size, setSize] = useState(termSize);
185
276
  useEffect(() => {
186
- const fn = () => {
187
- const newCols = stdout?.columns ?? 80;
188
- const newRows = stdout?.rows ?? 24;
189
- setSize((prev) => {
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.replace(/\[20[01]~/g, '').replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '');
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 ${tmpFile}`, { stdio: 'pipe', timeout: 3000 });
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 > ${tmpFile}`, { stdio: 'pipe', timeout: 3000 });
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
- const activeAgents = AgentRegistry.getInstance().listActive().length;
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
- const extraFields = Object.entries(parsed).filter(([k]) => !['command', 'filePath', 'file_path', 'notebook_path', 'message', 'content'].includes(k));
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
- 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: ' '.repeat(Math.max(0, boxWidth - 9 - toolName.length)) }), '\u2502'] }), highlightStr && (_jsxs(Text, { color: "yellow", children: ['\u2502', " ", _jsxs(Text, { dimColor: true, children: [highlightLabel, ":"] }), " ", _jsx(Text, { color: "white", bold: true, children: truncate(highlightStr, boxWidth - highlightLabel.length - 6) }), _jsx(Text, { children: ' '.repeat(Math.max(0, boxWidth - highlightLabel.length - 4 - Math.min(highlightStr.length, boxWidth - highlightLabel.length - 6))) }), '\u2502'] })), extraFields.slice(0, 3).map(([k, v]) => {
620
- const val = truncate(String(v), boxWidth - k.length - 6);
621
- return (_jsxs(Text, { color: "yellow", children: ['\u2502', " ", _jsxs(Text, { dimColor: true, children: [k, ":"] }), " ", _jsx(Text, { children: val }), _jsx(Text, { children: ' '.repeat(Math.max(0, boxWidth - k.length - 4 - val.length)) }), '\u2502'] }, k));
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
- setCtrlCCount((prev) => {
855
- if (prev >= 1) {
856
- exit();
857
- return 0;
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
- return prev + 1;
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 command picker or exit ──
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
- exit();
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.length || 1) / textWidth));
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
- if (!document.hasFocus?.() || process.env.SCALIX_NOTIFY !== '0') {
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
- agent.on('stream:end', onStreamEnd);
1548
- return () => { agent.off('stream:end', onStreamEnd); };
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 (pendingConfirm && !confirmInfo) {
1554
- setConfirmInfo({ toolName: pendingConfirm.toolName, args: pendingConfirm.args });
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
- if (pendingConfirm) {
1741
+ const current = confirmQueue.shift();
1742
+ if (current) {
1567
1743
  if (ok && always)
1568
- alwaysAllowedTools.add(pendingConfirm.toolName);
1744
+ alwaysAllowedTools.add(current.toolName);
1569
1745
  if (!ok) {
1570
1746
  recentDenials.push({
1571
- toolName: pendingConfirm.toolName,
1572
- args: JSON.stringify(pendingConfirm.args).slice(0, 100),
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
- pendingConfirm.resolve(ok);
1579
- pendingConfirm = null;
1754
+ current.resolve(ok);
1580
1755
  }
1581
- setConfirmInfo(null);
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
- if (pendingConfirm) {
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 = execSync('git diff --staged', { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() }).trim();
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
- execSync('git add -u', { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() });
2201
- diff = execSync('git diff --staged', { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() }).trim();
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 = execFileSync('git', ['commit', '-m', fullMsg], { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() }).trim();
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 = execSync('git symbolic-ref refs/remotes/origin/HEAD', { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() }).trim().replace('refs/remotes/origin/', '');
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 = execSync('git branch --list main master', { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() }).trim();
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 = execSync(`git diff ${baseBranch}...HEAD`, { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() }).trim();
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 = execSync('git diff', { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() }).trim();
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 output = execSync(watchCmd, { encoding: 'utf-8', timeout: TOOL_DEFAULTS.maxCommandTimeoutMs, cwd: process.cwd() }).trim();
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
- try {
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
- staged = execSync('git diff --staged', { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() }).trim();
2363
- }
2364
- catch { }
2365
- if (staged)
2366
- diff = (diff ? diff + '\n' : '') + '--- Staged changes ---\n' + staged;
2367
- if (!diff) {
2368
- add('system', 'No uncommitted changes.');
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
- else {
2371
- const truncated = diff.length > 5000 ? diff.slice(0, 5000) + '\n... (truncated, full diff is ' + diff.length + ' chars)' : diff;
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
- const handleSubmit = useCallback(async (text) => {
2606
- const trimmed = text.trim();
2607
- if (!trimmed)
2608
- return;
2609
- // Reset idle nudge on any user activity
2610
- lastActivityRef.current = Date.now();
2611
- idleNudgedRef.current = false;
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
- let output = execSync(shellCmd, { encoding: 'utf-8', timeout: HEADLESS_DEFAULTS.commandTimeoutMs, cwd: process.cwd() }).trim();
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
- add('user', next);
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
- if (pendingConfirm) {
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() {