scalix 1.1.0 → 1.3.2

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