scalix 1.0.0 → 1.2.0

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, Spacer, useInput, useApp, useStdout } from 'ink';
3
+ import { render, Box, Text, useInput, useApp, useStdout } 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
8
  import { execSync, execFileSync } from 'node:child_process';
9
+ import os from 'node:os';
9
10
  import fs from 'node:fs/promises';
10
11
  import { existsSync, readFileSync } from 'node:fs';
11
12
  import path from 'node:path';
@@ -17,31 +18,36 @@ import { webTools } from './tools-web.js';
17
18
  import { advancedTools } from './tools-advanced.js';
18
19
  import { visionTools } from './tools-vision.js';
19
20
  import { voiceTools } from './tools-voice.js';
20
- import { browserTools } from './tools-browser.js';
21
+ import { browserTools, closeBrowser } from './tools-browser.js';
21
22
  import { loadMcpServices } from './mcp.js';
22
23
  import { renderMarkdown } from './markdown.js';
23
24
  import { costTracker } from './cost-tracker.js';
24
- import { saveSession, listSessions, loadSession, generateSessionId } from './sessions.js';
25
+ import { saveSession, listSessions, loadSession, generateSessionId, saveCrashState, loadCrashState, clearCrashState, forkSession } from './sessions.js';
25
26
  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';
27
+ 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';
28
+ import { checkForUpdate, getUpdateBanner, performUpdate } from './update-checker.js';
27
29
  import { loadConfig, saveConfig, isConfigured, DEFAULT_LIMITS, THINKING_MODES } from './settings.js';
28
- import { getModeDescription, PERMISSION_MODES, setSandboxRoot } from './sandbox.js';
30
+ import { getModeDescription, PERMISSION_MODES, setSandboxRoot, checkScopedPermission, getScopedPermissionRules } from './sandbox.js';
29
31
  import { buildProjectIndex, indexToContext } from './project-index.js';
30
32
  import { collectGitInfo, gitInfoToContext } from './git-context.js';
31
33
  import { loadMemory, autoDetectConventions, saveMemory, memoryToContext, addNote, detectCorrection } from './project-memory.js';
32
- import { discoverPlugins, getPluginTools, pluginsSummary } from './plugins.js';
34
+ import { discoverPlugins, getPluginTools, pluginsSummary, installPlugin, uninstallPlugin, listInstalledPlugins, enablePlugin, disablePlugin } from './plugins.js';
33
35
  import { createRouterConfig, getRouterStatus } from './model-router.js';
34
36
  import { loadCustomCommands, expandCommand, commandsSummary } from './commands.js';
35
37
  import { hooksSummary } from './hooks.js';
36
38
  import { spawnSubAgent, pickCheapestModel } from './sub-agent.js';
37
39
  import { createCheckpoint, undoLastCheckpoint, listCheckpoints } from './git-undo.js';
38
40
  import { loadScopedRules, rulesToContext } from './scoped-rules.js';
41
+ import { loadSkills, skillsSummary, getSkillByTrigger, getSkillContent } from './skills.js';
42
+ import { AgentRegistry } from './agent-registry.js';
43
+ import { createWorktree, listWorktrees, removeWorktree, cleanupStaleWorktrees } from './worktree.js';
44
+ import { sendNotification, getTerminalType } from './terminal-notify.js';
39
45
  logger.setLevel('silent');
40
46
  setSandboxRoot(process.cwd());
41
47
  // Suppress noisy React 19 development-mode reconciler warnings that leak to stderr
42
48
  const _origConsoleError = console.error;
43
49
  console.error = (...args) => {
44
- if (typeof args[0] === 'string' && args[0] === 'Warning: Encountered two children with the same key, `%s`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — the behavior is unsupported and could change in a future version.')
50
+ if (typeof args[0] === 'string' && args[0].startsWith('Warning:') && args[0].includes('same key'))
45
51
  return;
46
52
  _origConsoleError(...args);
47
53
  };
@@ -55,10 +61,21 @@ const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools,
55
61
  import { EventEmitter } from 'node:events';
56
62
  const confirmEmitter = new EventEmitter();
57
63
  let pendingConfirm = null;
58
- const confirmToolCall = async (toolName, args) => new Promise((resolve) => {
59
- pendingConfirm = { resolve, toolName, args };
60
- confirmEmitter.emit('pending');
61
- });
64
+ const alwaysAllowedTools = new Set();
65
+ const recentDenials = [];
66
+ const confirmToolCall = async (toolName, args) => {
67
+ if (alwaysAllowedTools.has(toolName))
68
+ return true;
69
+ const scoped = checkScopedPermission(toolName, args);
70
+ if (scoped === 'allow')
71
+ return true;
72
+ if (scoped === 'deny')
73
+ return false;
74
+ return new Promise((resolve) => {
75
+ pendingConfirm = { resolve, toolName, args };
76
+ confirmEmitter.emit('pending');
77
+ });
78
+ };
62
79
  // ─── Agent (deferred until config is ready) ───
63
80
  let baseInstructions = DEFAULT_INSTRUCTIONS;
64
81
  let agent = null;
@@ -67,11 +84,12 @@ let projectIndex = null;
67
84
  let loadedPlugins = [];
68
85
  let projectMemory = null;
69
86
  let customCommands = [];
87
+ let loadedSkills = [];
70
88
  async function buildEnhancedInstructions() {
71
89
  let instructions = baseInstructions;
72
90
  const cwd = process.cwd();
73
91
  try {
74
- const gitInfo = collectGitInfo(cwd);
92
+ const gitInfo = await collectGitInfo(cwd);
75
93
  if (gitInfo.isRepo)
76
94
  instructions += '\n\n' + gitInfoToContext(gitInfo);
77
95
  }
@@ -151,7 +169,10 @@ function initAgent(config) {
151
169
  });
152
170
  loadCustomCommands(process.cwd()).then((cmds) => {
153
171
  customCommands = cmds;
154
- }).catch(() => { });
172
+ }).catch((err) => { logger.warn('Failed to load custom commands', { error: err.message }); });
173
+ loadSkills(process.cwd()).then((skills) => {
174
+ loadedSkills = skills;
175
+ }).catch((err) => { logger.warn('Failed to load skills', { error: err.message }); });
155
176
  return agent;
156
177
  }
157
178
  // ─── Helpers ───
@@ -202,6 +223,43 @@ const Banner = memo(function Banner() {
202
223
  function sanitizeInput(v) {
203
224
  return v.replace(/\[20[01]~/g, '').replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '');
204
225
  }
226
+ async function tryPasteImage() {
227
+ const platform = os.platform();
228
+ const tmpFile = path.join(os.tmpdir(), `scalix-paste-${Date.now()}.png`);
229
+ try {
230
+ if (platform === 'darwin') {
231
+ try {
232
+ execSync(`pngpaste ${tmpFile}`, { stdio: 'pipe', timeout: 3000 });
233
+ }
234
+ catch {
235
+ 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();
236
+ if (hasImage !== 'yes')
237
+ return null;
238
+ 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
+ }
240
+ }
241
+ else {
242
+ execSync(`xclip -selection clipboard -t image/png -o > ${tmpFile}`, { stdio: 'pipe', timeout: 3000 });
243
+ }
244
+ const { readFileSync, unlinkSync } = await import('node:fs');
245
+ const data = readFileSync(tmpFile);
246
+ try {
247
+ unlinkSync(tmpFile);
248
+ }
249
+ catch { }
250
+ if (data.length < 8)
251
+ return null;
252
+ return `data:image/png;base64,${data.toString('base64')}`;
253
+ }
254
+ catch {
255
+ try {
256
+ const { unlinkSync } = await import('node:fs');
257
+ unlinkSync(tmpFile);
258
+ }
259
+ catch { }
260
+ return null;
261
+ }
262
+ }
205
263
  function LongTextInput({ value, onChange, onSubmit, placeholder, maxVisible = 80 }) {
206
264
  const valueRef = useRef(value);
207
265
  useEffect(() => { valueRef.current = value; }, [value]);
@@ -300,7 +358,7 @@ function SetupScreen({ onComplete, existingConfig }) {
300
358
  if (result.length > 0) {
301
359
  setFetchedModels(result.map((m) => ({ id: m.id, name: m.name })));
302
360
  }
303
- }).catch(() => { }).finally(() => {
361
+ }).catch((err) => { logger.warn('Failed to fetch model specs', { error: err.message }); }).finally(() => {
304
362
  setFetchingModels(false);
305
363
  setStep('models');
306
364
  });
@@ -375,6 +433,8 @@ function SetupScreen({ onComplete, existingConfig }) {
375
433
  limits,
376
434
  permissionMode: existingConfig?.permissionMode || 'default',
377
435
  thinkingMode: existingConfig?.thinkingMode || 'off',
436
+ security: existingConfig?.security || {},
437
+ injectionDetection: existingConfig?.injectionDetection || { enabled: true, sensitivity: 'medium' },
378
438
  };
379
439
  saveConfig(newConfig).then(() => {
380
440
  onComplete(newConfig);
@@ -383,7 +443,7 @@ function SetupScreen({ onComplete, existingConfig }) {
383
443
  });
384
444
  }, 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"] }) })] })] }));
385
445
  }
386
- const StatusBar = memo(function StatusBar({ status, model, toolCount, thinkStart, tokens, mode }) {
446
+ const StatusBar = memo(function StatusBar({ status, model, toolCount, thinkStart, tokens, mode, contextPercent, rateLimitWarn }) {
387
447
  const { cols } = useTermSize();
388
448
  const [elapsedStr, setElapsedStr] = useState('');
389
449
  useEffect(() => {
@@ -398,15 +458,21 @@ const StatusBar = memo(function StatusBar({ status, model, toolCount, thinkStart
398
458
  const modeColor = mode === 'yolo' ? 'red' : mode === 'auto' ? 'yellow' : mode === 'plan' ? 'blue' : 'green';
399
459
  const statusIcon = status === 'thinking' ? '\u25D4' : status === 'error' ? '\u2718' : '\u25CF';
400
460
  const statusColor = status === 'thinking' ? 'yellow' : status === 'error' ? 'red' : 'green';
461
+ const activeAgents = AgentRegistry.getInstance().listActive().length;
401
462
  const leftParts = [];
402
463
  leftParts.push(shortModel(model));
403
464
  if (tokens > 0)
404
465
  leftParts.push(`${formatTokens(tokens)} tok`);
405
466
  leftParts.push(`${toolCount} tools`);
467
+ if (activeAgents > 0)
468
+ leftParts.push(`${activeAgents} agent${activeAgents > 1 ? 's' : ''}`);
469
+ if (contextPercent > 50)
470
+ leftParts.push(`ctx ${contextPercent}%`);
406
471
  const leftText = leftParts.join(' \u00B7 ');
407
- const rightLen = mode.length + VERSION.length + 6 + (elapsedStr ? elapsedStr.length + 3 : 0);
472
+ const warnText = rateLimitWarn ? ' \u26A0 Rate limited' : '';
473
+ const rightLen = mode.length + VERSION.length + 6 + (elapsedStr ? elapsedStr.length + 3 : 0) + warnText.length;
408
474
  const gap = Math.max(cols - leftText.length - rightLen - 8, 2);
409
- 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] })] })] }));
475
+ 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] })] })] }));
410
476
  });
411
477
  const WelcomeScreen = memo(function WelcomeScreen() {
412
478
  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)))] })] }));
@@ -449,6 +515,12 @@ const ToolResultMessage = memo(function ToolResultMessage({ name, result }) {
449
515
  const ToolBlockedMessage = memo(function ToolBlockedMessage({ text }) {
450
516
  return (_jsxs(Box, { paddingLeft: 3, paddingRight: 2, gap: 1, children: [_jsx(Text, { color: "red", children: '\u25CF' }), _jsx(Text, { color: "red", children: text })] }));
451
517
  });
518
+ const ToolGroupMessage = memo(function ToolGroupMessage({ count, children, collapsed }) {
519
+ if (collapsed || !children) {
520
+ 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)" })] }));
521
+ }
522
+ return (_jsx(Box, { flexDirection: "column", children: children.map(child => _jsx(RenderItem, { item: child }, child.id)) }));
523
+ });
452
524
  const DIFF_MAX_LINES = 60;
453
525
  const DiffMessage = memo(function DiffMessage({ text, meta }) {
454
526
  const allLines = text.split('\n');
@@ -484,6 +556,7 @@ const RenderItem = memo(function RenderItem({ item }) {
484
556
  case 'tool-call': return _jsx(ToolCallMessage, { name: item.meta ?? '', args: item.text });
485
557
  case 'tool-result': return _jsx(ToolResultMessage, { name: item.meta ?? '', result: item.text });
486
558
  case 'tool-blocked': return _jsx(ToolBlockedMessage, { text: item.text });
559
+ case 'tool-group': return _jsx(ToolGroupMessage, { count: item.children?.length ?? 0, children: item.children, collapsed: item.collapsed });
487
560
  case 'diff': return _jsx(DiffMessage, { text: item.text, meta: item.meta });
488
561
  case 'system': return _jsx(SystemMessage, { text: item.text });
489
562
  }
@@ -510,18 +583,43 @@ const StreamingArea = memo(function StreamingArea({ text, reasoning, model, maxL
510
583
  return (_jsxs(Box, { flexDirection: "column", paddingLeft: 3, paddingRight: 2, marginTop: 1, children: [displayReasoning.length > 0 && (_jsx(Box, { paddingLeft: 0, marginBottom: 0, children: _jsxs(Text, { color: "magenta", dimColor: true, italic: true, wrap: "wrap", children: ['\u2502', " ", displayReasoning] }) })), displayText.length > 0 ? (_jsxs(Text, { wrap: "wrap", children: [renderMarkdown(displayText), _jsx(Text, { color: "magentaBright", children: '\u258E' })] })) : displayReasoning.length === 0 ? (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "magentaBright", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { dimColor: true, children: ["Thinking", model ? ` (${model})` : '', "..."] })] })) : null] }));
511
584
  });
512
585
  function ConfirmPrompt({ toolName, args, onRespond, onScrollUp, onScrollDown }) {
586
+ const { cols } = useTermSize();
513
587
  useInput((ch, key) => {
514
- if (ch.toLowerCase() === 'y')
588
+ if (ch.toLowerCase() === 'a')
589
+ onRespond(true, true);
590
+ else if (ch.toLowerCase() === 'y')
515
591
  onRespond(true);
516
592
  else if (ch.toLowerCase() === 'n' || key.escape)
517
593
  onRespond(false);
518
- else if (key.pageUp || (key.upArrow && key.shift))
519
- onScrollUp?.();
520
- else if (key.pageDown || (key.downArrow && key.shift))
521
- onScrollDown?.();
594
+ else if (key.pageUp)
595
+ onScrollUp?.(15);
596
+ else if (key.upArrow && key.shift)
597
+ onScrollUp?.(5);
598
+ else if (key.pageDown)
599
+ onScrollDown?.(15);
600
+ else if (key.downArrow && key.shift)
601
+ onScrollDown?.(5);
522
602
  });
523
- const preview = typeof args === 'string' ? args : JSON.stringify(args, null, 2);
524
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, marginX: 0, paddingY: 0, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "yellow", children: '\u25CB' }), _jsx(Text, { color: "yellow", bold: true, children: toolName }), _jsx(Text, { dimColor: true, children: truncate(preview, 80) })] }), _jsxs(Box, { gap: 2, paddingLeft: 3, children: [_jsx(Text, { color: "green", bold: true, children: "(y)" }), _jsx(Text, { dimColor: true, children: "allow" }), _jsx(Text, { color: "red", bold: true, children: "(n)" }), _jsx(Text, { dimColor: true, children: "deny" })] })] }));
603
+ let parsed = {};
604
+ try {
605
+ parsed = typeof args === 'string' ? JSON.parse(args) : args ?? {};
606
+ }
607
+ catch {
608
+ parsed = {};
609
+ }
610
+ const isCommand = toolName === 'run_command';
611
+ const isWrite = toolName === 'write_file' || toolName === 'edit_file' || toolName === 'notebook_edit';
612
+ const isGit = toolName === 'git_commit';
613
+ const highlight = parsed.command ?? parsed.filePath ?? parsed.file_path ?? parsed.notebook_path ?? parsed.message ?? null;
614
+ 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));
617
+ const boxWidth = Math.min(cols - 2, 80);
618
+ 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));
622
+ }), _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'] })] }));
525
623
  }
526
624
  function ModelPicker({ currentModels, onSelect, onCancel }) {
527
625
  const [customInput, setCustomInput] = useState('');
@@ -607,7 +705,7 @@ function SettingsEditor({ onSave, onClose }) {
607
705
  const [selectedKey, setSelectedKey] = useState(null);
608
706
  const [editValue, setEditValue] = useState('');
609
707
  const [currentConfig, setCurrentConfig] = useState(null);
610
- const reload = useCallback(() => { loadConfig().then(setCurrentConfig).catch(() => { }); }, []);
708
+ const reload = useCallback(() => { loadConfig().then(setCurrentConfig).catch((err) => { logger.warn('Failed to load config', { error: err.message }); }); }, []);
611
709
  useEffect(() => { reload(); }, [reload]);
612
710
  useInput((_ch, key) => {
613
711
  if (key.escape) {
@@ -707,7 +805,7 @@ function SettingsEditor({ onSave, onClose }) {
707
805
  }
708
806
  } })] }));
709
807
  }
710
- const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, onClear, onScrollUp, onScrollDown, onHeightChange }) {
808
+ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, onClear, onScrollUp, onScrollDown, onHeightChange, onToggleToolGroups, onImagePaste }) {
711
809
  const { exit } = useApp();
712
810
  const { cols } = useTermSize();
713
811
  const line = useMemo(() => '\u2500'.repeat(Math.max(cols - 6, 20)), [cols]);
@@ -718,7 +816,11 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
718
816
  const [history, setHistory] = useState([]);
719
817
  const [historyIndex, setHistoryIndex] = useState(-1);
720
818
  const savedInput = useRef('');
819
+ const [searchMode, setSearchMode] = useState(false);
820
+ const [searchQuery, setSearchQuery] = useState('');
821
+ const ctrlXPending = useRef(false);
721
822
  const valueRef = useRef('');
823
+ const [imageChips, setImageChips] = useState([]);
722
824
  const onChange = useCallback((v) => {
723
825
  setValue(v);
724
826
  valueRef.current = v;
@@ -788,6 +890,88 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
788
890
  onChange('');
789
891
  return;
790
892
  }
893
+ // ── Alt+Enter / Meta+Enter: insert newline ──
894
+ if (key.return && key.meta && !disabled) {
895
+ onChange(val + '\n');
896
+ return;
897
+ }
898
+ // ── Ctrl+X Ctrl+E: open external editor ──
899
+ if (ch === 'x' && key.ctrl && !disabled) {
900
+ ctrlXPending.current = true;
901
+ setTimeout(() => { ctrlXPending.current = false; }, 1000);
902
+ return;
903
+ }
904
+ if (ch === 'e' && key.ctrl && ctrlXPending.current && !disabled) {
905
+ ctrlXPending.current = false;
906
+ const editor = process.env.EDITOR || process.env.VISUAL || 'vi';
907
+ const tmpFile = path.join(os.tmpdir(), `scalix-input-${Date.now()}.txt`);
908
+ import('node:fs').then(async (fs) => {
909
+ fs.writeFileSync(tmpFile, val);
910
+ const { execSync } = await import('node:child_process');
911
+ try {
912
+ execSync(`${editor} ${tmpFile}`, { stdio: 'inherit' });
913
+ const edited = fs.readFileSync(tmpFile, 'utf-8');
914
+ onChange(edited.trimEnd());
915
+ }
916
+ catch { /* editor exited with error */ }
917
+ try {
918
+ fs.unlinkSync(tmpFile);
919
+ }
920
+ catch { /* cleanup */ }
921
+ });
922
+ return;
923
+ }
924
+ // ── Ctrl+O: toggle collapsed tool groups ──
925
+ if (ch === 'o' && key.ctrl) {
926
+ onToggleToolGroups?.();
927
+ return;
928
+ }
929
+ // ── Ctrl+R: reverse history search ──
930
+ if (ch === 'r' && key.ctrl && !disabled) {
931
+ if (searchMode) {
932
+ setSearchMode(false);
933
+ setSearchQuery('');
934
+ }
935
+ else {
936
+ setSearchMode(true);
937
+ setSearchQuery('');
938
+ }
939
+ return;
940
+ }
941
+ // ── Search mode input handling ──
942
+ if (searchMode && !disabled) {
943
+ if (key.return) {
944
+ // Accept found match
945
+ setSearchMode(false);
946
+ return;
947
+ }
948
+ if (key.escape) {
949
+ setSearchMode(false);
950
+ setSearchQuery('');
951
+ return;
952
+ }
953
+ if (key.backspace || key.delete) {
954
+ const newQ = searchQuery.slice(0, -1);
955
+ setSearchQuery(newQ);
956
+ const match = history.slice().reverse().find(h => h.toLowerCase().includes(newQ.toLowerCase()));
957
+ if (match) {
958
+ setValue(match);
959
+ valueRef.current = match;
960
+ }
961
+ return;
962
+ }
963
+ if (!key.ctrl && !key.meta && !key.tab && ch.length > 0) {
964
+ const newQ = searchQuery + ch;
965
+ setSearchQuery(newQ);
966
+ const match = history.slice().reverse().find(h => h.toLowerCase().includes(newQ.toLowerCase()));
967
+ if (match) {
968
+ setValue(match);
969
+ valueRef.current = match;
970
+ }
971
+ return;
972
+ }
973
+ return;
974
+ }
791
975
  // ── Ctrl+S: stash / restore prompt ──
792
976
  if (ch === 's' && key.ctrl && !disabled) {
793
977
  if (val.length > 0) {
@@ -800,6 +984,16 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
800
984
  }
801
985
  return;
802
986
  }
987
+ // ── Ctrl+V: paste image from clipboard ──
988
+ if (ch === 'v' && key.ctrl && !disabled) {
989
+ tryPasteImage().then(dataUrl => {
990
+ if (dataUrl) {
991
+ setImageChips(prev => [...prev, dataUrl]);
992
+ onImagePaste?.(dataUrl);
993
+ }
994
+ }).catch(() => { });
995
+ return;
996
+ }
803
997
  // ── Tab: autocomplete slash commands ──
804
998
  if (key.tab && !disabled && val.startsWith('/') && !val.includes(' ')) {
805
999
  const cmdNames = commands.map((c) => c.cmd);
@@ -833,12 +1027,20 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
833
1027
  return;
834
1028
  }
835
1029
  // ── Scroll ──
836
- if (key.pageUp || (key.upArrow && key.shift)) {
837
- onScrollUp();
1030
+ if (key.pageUp) {
1031
+ onScrollUp(15);
1032
+ return;
1033
+ }
1034
+ if (key.upArrow && key.shift) {
1035
+ onScrollUp(5);
1036
+ return;
1037
+ }
1038
+ if (key.pageDown) {
1039
+ onScrollDown(15);
838
1040
  return;
839
1041
  }
840
- if (key.pageDown || (key.downArrow && key.shift)) {
841
- onScrollDown();
1042
+ if (key.downArrow && key.shift) {
1043
+ onScrollDown(5);
842
1044
  return;
843
1045
  }
844
1046
  // ── History navigation ──
@@ -880,6 +1082,8 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
880
1082
  { cmd: '/copy', desc: 'Copy last response to clipboard' },
881
1083
  { cmd: '/save', desc: 'Save current session' },
882
1084
  { cmd: '/load', desc: 'Resume a saved session' },
1085
+ { cmd: '/resume', desc: 'Resume from crash recovery or saved session' },
1086
+ { cmd: '/fork', desc: 'Fork conversation from a point in history' },
883
1087
  { cmd: '/export', desc: 'Export conversation as text' },
884
1088
  { cmd: '/index', desc: 'Scan & index project files' },
885
1089
  { cmd: '/init', desc: 'Initialize project config (SCALIX.md)' },
@@ -894,10 +1098,13 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
894
1098
  { cmd: '/mcp', desc: 'Add MCP server' },
895
1099
  { cmd: '/setup', desc: 'Reconfigure API key, URL & models' },
896
1100
  { cmd: '/settings', desc: 'View & configure limits' },
1101
+ { cmd: '/permissions', desc: 'View permission rules & recent denials' },
897
1102
  { cmd: '/shortcuts', desc: 'Keyboard shortcuts' },
898
1103
  { cmd: '/hooks', desc: 'Show lifecycle hooks' },
899
1104
  { cmd: '/commands', desc: 'Show custom commands' },
1105
+ { cmd: '/skills', desc: 'List available skills' },
900
1106
  { cmd: '/delegate', desc: 'Run task with sub-agent' },
1107
+ { cmd: '/agents', desc: 'Show active agents & usage' },
901
1108
  { cmd: '/checkpoint', desc: 'Create git checkpoint' },
902
1109
  { cmd: '/undo', desc: 'Undo last checkpoint' },
903
1110
  { cmd: '/checkpoints', desc: 'List recent checkpoints' },
@@ -905,6 +1112,9 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
905
1112
  { cmd: '/review', desc: 'AI code review of current changes' },
906
1113
  { cmd: '/watch', desc: 'Run command, auto-fix on failure' },
907
1114
  { cmd: '/create', desc: 'Scaffold a new project' },
1115
+ { cmd: '/worktree', desc: 'Manage git worktrees for isolated work' },
1116
+ { cmd: '/plugin', desc: 'Install/enable/disable/uninstall plugins' },
1117
+ { cmd: '/notify', desc: 'Test terminal notification' },
908
1118
  { cmd: '/exit', desc: 'Save & exit' },
909
1119
  ...customCommands.map((c) => ({ cmd: `/${c.name}`, desc: c.description })),
910
1120
  ], [customCommands]);
@@ -913,18 +1123,37 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
913
1123
  const filteredCommands = showCommandPicker
914
1124
  ? commands.filter((c) => c.cmd.startsWith(prefix))
915
1125
  : [];
916
- const pickerActive = showCommandPicker && filteredCommands.length > 0;
1126
+ // @-mention file typeahead
1127
+ const atMatch = value.match(/@(\S*)$/);
1128
+ const atQuery = atMatch?.[1]?.toLowerCase() ?? '';
1129
+ const showFilePicker = !disabled && !!atMatch && projectIndex;
1130
+ const filteredFiles = useMemo(() => {
1131
+ if (!showFilePicker || !projectIndex)
1132
+ return [];
1133
+ const q = atQuery;
1134
+ return projectIndex.files
1135
+ .map(f => f.relativePath)
1136
+ .filter(p => {
1137
+ if (!q)
1138
+ return true;
1139
+ const name = p.split('/').pop()?.toLowerCase() ?? '';
1140
+ return name.includes(q) || p.toLowerCase().includes(q);
1141
+ })
1142
+ .slice(0, 10);
1143
+ }, [showFilePicker, atQuery]);
1144
+ const pickerActive = (showCommandPicker && filteredCommands.length > 0) || (showFilePicker && filteredFiles.length > 0);
917
1145
  useEffect(() => {
918
1146
  if (!onHeightChange)
919
1147
  return;
920
1148
  let h = 3; // separator + input row + padding
1149
+ const pickerCount = showCommandPicker ? filteredCommands.length : filteredFiles.length;
921
1150
  if (pickerActive)
922
- h += Math.min(filteredCommands.length, 10) + 2; // items + header + footer
1151
+ h += Math.min(pickerCount, 10) + 2;
923
1152
  if (stash.length > 0 && !disabled)
924
1153
  h += 1;
925
1154
  onHeightChange(h);
926
- }, [pickerActive, filteredCommands.length, stash.length, disabled, onHeightChange]);
927
- 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) => ({
1155
+ }, [pickerActive, filteredCommands.length, filteredFiles.length, stash.length, disabled, onHeightChange, showCommandPicker]);
1156
+ 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) => ({
928
1157
  label: `${c.cmd.padEnd(16)} ${c.desc}`,
929
1158
  value: c.cmd,
930
1159
  })), isFocused: true, onSelect: (item) => {
@@ -936,7 +1165,14 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
936
1165
  onChange('');
937
1166
  handleSubmit(item.value);
938
1167
  }
939
- }, 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: disabled ? (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "magentaBright", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: "Working..." }), _jsx(Spacer, {}), _jsx(Text, { dimColor: true, children: "^C interrupt" })] })) : (_jsxs(Box, { flexGrow: 1, children: [_jsx(Box, { flexShrink: 0, width: 2, children: _jsx(Text, { color: "magentaBright", bold: true, children: '❯' }) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { value: value, onChange: onChange, onSubmit: handleSubmit, placeholder: "Type a message...", showCursor: true, focus: !disabled && !pickerActive }) })] })) })] }));
1168
+ }, 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) => ({
1169
+ label: f,
1170
+ value: f,
1171
+ })), isFocused: true, onSelect: (item) => {
1172
+ // Replace @query with the selected file path
1173
+ const newValue = value.replace(/@\S*$/, '@' + item.value + ' ');
1174
+ onChange(newValue);
1175
+ }, 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" }) }))] }) })] }));
940
1176
  });
941
1177
  // ─── App ───
942
1178
  function estimateLines(item, cols) {
@@ -956,12 +1192,14 @@ function estimateLines(item, cols) {
956
1192
  return totalLines + markdownExpansion + 2; // marginTop=1 + padding
957
1193
  }
958
1194
  case 'error': return totalLines + 2;
959
- case 'tool-call': return 2; // single row + marginTop=1
960
- case 'tool-result': return 1;
1195
+ case 'tool-call': return 2;
1196
+ case 'tool-result': return Math.max(1, totalLines);
961
1197
  case 'tool-blocked': return 1;
962
- case 'diff': return lines.length + 2; // header + each diff line + footer
963
- case 'system': return 1;
1198
+ case 'tool-group': return item.collapsed ? 2 : (item.children?.length ?? 0) * 2;
1199
+ case 'diff': return lines.length + 2;
1200
+ case 'system': return Math.max(1, totalLines);
964
1201
  }
1202
+ return totalLines;
965
1203
  }
966
1204
  function useScrollableItems(items, availableHeight, cols, isLoading, streamLineCount = 0) {
967
1205
  const [scrollOffset, setScrollOffset] = useState(0);
@@ -977,7 +1215,7 @@ function useScrollableItems(items, availableHeight, cols, isLoading, streamLineC
977
1215
  if (isLoading && autoScroll.current)
978
1216
  setScrollOffset(0);
979
1217
  }, [isLoading]);
980
- const scrollUp = useCallback((amount = 3) => {
1218
+ const scrollUp = useCallback((amount = 5) => {
981
1219
  setScrollOffset((prev) => {
982
1220
  const maxOffset = Math.max(items.length - 1, 0);
983
1221
  const next = Math.min(prev + amount, maxOffset);
@@ -985,7 +1223,7 @@ function useScrollableItems(items, availableHeight, cols, isLoading, streamLineC
985
1223
  return next;
986
1224
  });
987
1225
  }, [items.length]);
988
- const scrollDown = useCallback((amount = 3) => {
1226
+ const scrollDown = useCallback((amount = 5) => {
989
1227
  setScrollOffset((prev) => {
990
1228
  const next = Math.max(prev - amount, 0);
991
1229
  if (next === 0)
@@ -1026,17 +1264,21 @@ function App({ config }) {
1026
1264
  const [thinkStart, setThinkStart] = useState(null);
1027
1265
  const [showBanner, setShowBanner] = useState(true);
1028
1266
  const [totalTokens, setTotalTokens] = useState(0);
1267
+ const [contextPercent, setContextPercent] = useState(0);
1268
+ const [rateLimitWarn, setRateLimitWarn] = useState(false);
1029
1269
  const [sessionList, setSessionList] = useState(null);
1030
1270
  const [showSetup, setShowSetup] = useState(false);
1031
1271
  const [currentMode, setCurrentMode] = useState(agent?.getPermissionMode() ?? 'default');
1032
1272
  const [selectingMode, setSelectingMode] = useState(false);
1033
1273
  const [editingSettings, setEditingSettings] = useState(false);
1274
+ const [agentVersion, setAgentVersion] = useState(0);
1034
1275
  const counter = useRef(0);
1035
1276
  const showBannerRef = useRef(true);
1036
1277
  const modelsRef = useRef(models);
1037
1278
  useEffect(() => { modelsRef.current = models; }, [models]);
1038
1279
  const nextId = () => ++counter.current;
1039
1280
  const MAX_DISPLAY_ITEMS = 2000;
1281
+ const TOOL_GROUP_THRESHOLD = 3;
1040
1282
  const add = useCallback((kind, text, meta, hint) => {
1041
1283
  setItems((prev) => {
1042
1284
  const next = [...prev, { id: nextId(), kind, text, meta, hint }];
@@ -1047,6 +1289,29 @@ function App({ config }) {
1047
1289
  setShowBanner(false);
1048
1290
  }
1049
1291
  }, []);
1292
+ const collapseToolGroups = useCallback(() => {
1293
+ setItems(prev => {
1294
+ const isToolItem = (k) => k === 'tool-call' || k === 'tool-result';
1295
+ let i = prev.length - 1;
1296
+ while (i >= 0 && isToolItem(prev[i].kind))
1297
+ i--;
1298
+ const toolStart = i + 1;
1299
+ const toolItems = prev.slice(toolStart);
1300
+ if (toolItems.length < TOOL_GROUP_THRESHOLD)
1301
+ return prev;
1302
+ const group = {
1303
+ id: nextId(),
1304
+ kind: 'tool-group',
1305
+ text: `Ran ${Math.ceil(toolItems.length / 2)} tools`,
1306
+ children: toolItems,
1307
+ collapsed: true,
1308
+ };
1309
+ return [...prev.slice(0, toolStart), group];
1310
+ });
1311
+ }, []);
1312
+ const toggleToolGroups = useCallback(() => {
1313
+ setItems(prev => prev.map(item => item.kind === 'tool-group' ? { ...item, collapsed: !item.collapsed } : item));
1314
+ }, []);
1050
1315
  // Register agent event handlers ONCE
1051
1316
  useEffect(() => {
1052
1317
  const onThinkingStart = () => {
@@ -1098,9 +1363,18 @@ function App({ config }) {
1098
1363
  streamBuffer = '';
1099
1364
  setStreamText('');
1100
1365
  setReasoningText('');
1366
+ collapseToolGroups();
1101
1367
  if (full.trim()) {
1102
1368
  add('assistant', full);
1103
1369
  }
1370
+ // Auto-save session after each assistant turn
1371
+ if (agent) {
1372
+ const msgs = agent.getMessages();
1373
+ if (msgs.length > 0) {
1374
+ saveCrashState(sessionId, msgs, modelsRef.current[0] || 'unknown', costTracker.getState())
1375
+ .catch(() => { });
1376
+ }
1377
+ }
1104
1378
  };
1105
1379
  let reasoningBuffer = '';
1106
1380
  let reasoningFlushTimer = null;
@@ -1170,6 +1444,8 @@ function App({ config }) {
1170
1444
  const lower = reason?.toLowerCase() || '';
1171
1445
  if (lower.includes('rate limit') || lower.includes('429')) {
1172
1446
  hint = 'Rate limited. Try /model to switch models, or wait a moment.';
1447
+ setRateLimitWarn(true);
1448
+ setTimeout(() => setRateLimitWarn(false), 120_000);
1173
1449
  }
1174
1450
  else if (lower.includes('auth') || lower.includes('401') || lower.includes('api key')) {
1175
1451
  hint = 'Authentication failed. Run /setup to reconfigure your API key.';
@@ -1189,6 +1465,7 @@ function App({ config }) {
1189
1465
  add('system', `Context compressed: ${formatTokens(info.saved)} tokens saved (${formatTokens(info.before)} \u2192 ${formatTokens(info.after)})`);
1190
1466
  };
1191
1467
  const onContextUsage = (info) => {
1468
+ setContextPercent(info.percentage);
1192
1469
  if (info.percentage > 80) {
1193
1470
  add('system', `Context usage: ${info.percentage}% (${formatTokens(info.tokens)}/${formatTokens(info.maxTokens)} tokens)`);
1194
1471
  }
@@ -1235,7 +1512,41 @@ function App({ config }) {
1235
1512
  currentAgent.off('context:compress', onContextCompress);
1236
1513
  currentAgent.off('context:usage', onContextUsage);
1237
1514
  };
1515
+ }, [add, agentVersion]);
1516
+ // Crash recovery: check for recoverable session on startup
1517
+ useEffect(() => {
1518
+ loadCrashState().then(state => {
1519
+ if (state && state.messages.length > 0 && agent) {
1520
+ add('system', `Recoverable session found (${state.messages.length} messages). Use /resume to restore.`);
1521
+ }
1522
+ }).catch(() => { });
1238
1523
  }, [add]);
1524
+ // Idle session nudge: suggest /clear after 75+ minutes of inactivity
1525
+ const lastActivityRef = useRef(Date.now());
1526
+ const idleNudgedRef = useRef(false);
1527
+ useEffect(() => {
1528
+ const IDLE_THRESHOLD_MS = 75 * 60_000; // 75 minutes
1529
+ const timer = setInterval(() => {
1530
+ const idle = Date.now() - lastActivityRef.current;
1531
+ if (idle >= IDLE_THRESHOLD_MS && !idleNudgedRef.current && agent && agent.getMessages().length > 10) {
1532
+ idleNudgedRef.current = true;
1533
+ add('system', 'Session idle for 75+ minutes. Consider /clear to optimize token usage, or /compact to compress context.');
1534
+ }
1535
+ }, 60_000);
1536
+ return () => clearInterval(timer);
1537
+ }, [add]);
1538
+ // Terminal notification on task completion
1539
+ useEffect(() => {
1540
+ if (!agent)
1541
+ return;
1542
+ const onStreamEnd = () => {
1543
+ if (!document.hasFocus?.() || process.env.SCALIX_NOTIFY !== '0') {
1544
+ sendNotification('Scalix', 'Task complete');
1545
+ }
1546
+ };
1547
+ agent.on('stream:end', onStreamEnd);
1548
+ return () => { agent.off('stream:end', onStreamEnd); };
1549
+ }, [agentVersion]);
1239
1550
  // Confirmation bridge (event-driven)
1240
1551
  useEffect(() => {
1241
1552
  const onPending = () => {
@@ -1246,8 +1557,24 @@ function App({ config }) {
1246
1557
  confirmEmitter.on('pending', onPending);
1247
1558
  return () => { confirmEmitter.off('pending', onPending); };
1248
1559
  }, [confirmInfo]);
1249
- const handleConfirm = useCallback((ok) => {
1560
+ useEffect(() => {
1561
+ const banner = getUpdateBanner();
1562
+ if (banner)
1563
+ add('system', banner);
1564
+ }, [add]);
1565
+ const handleConfirm = useCallback((ok, always) => {
1250
1566
  if (pendingConfirm) {
1567
+ if (ok && always)
1568
+ alwaysAllowedTools.add(pendingConfirm.toolName);
1569
+ if (!ok) {
1570
+ recentDenials.push({
1571
+ toolName: pendingConfirm.toolName,
1572
+ args: JSON.stringify(pendingConfirm.args).slice(0, 100),
1573
+ timestamp: Date.now(),
1574
+ });
1575
+ if (recentDenials.length > 20)
1576
+ recentDenials.shift();
1577
+ }
1251
1578
  pendingConfirm.resolve(ok);
1252
1579
  pendingConfirm = null;
1253
1580
  }
@@ -1285,6 +1612,7 @@ function App({ config }) {
1285
1612
  ' /checkpoint \u2014 create git checkpoint',
1286
1613
  ' /undo \u2014 undo last checkpoint',
1287
1614
  ' /checkpoints \u2014 list recent checkpoints',
1615
+ ' /worktree \u2014 manage git worktrees (list/create/remove/cleanup)',
1288
1616
  ' /git \u2014 show git context',
1289
1617
  '',
1290
1618
  'Project:',
@@ -1295,15 +1623,19 @@ function App({ config }) {
1295
1623
  ' /watch <cmd> \u2014 run command, auto-fix on failure',
1296
1624
  ' /create <d> \u2014 scaffold a new project',
1297
1625
  ' /delegate <t> \u2014 run task with sub-agent',
1626
+ ' /agents \u2014 show active agents & usage',
1298
1627
  '',
1299
1628
  'Tools & Config:',
1300
1629
  ' /copy \u2014 copy last response to clipboard',
1301
1630
  ' /status \u2014 show version, model & connection info',
1631
+ ' /update \u2014 check for and install updates',
1302
1632
  ' /doctor \u2014 diagnose installation',
1303
1633
  ' /settings \u2014 view & configure limits',
1304
1634
  ' /setup \u2014 reconfigure API key, URL & models',
1305
1635
  ' /mcp <l> <u> \u2014 add MCP server',
1306
1636
  ' /plugins \u2014 show loaded plugins',
1637
+ ' /plugin \u2014 install/enable/disable/uninstall plugins',
1638
+ ' /notify \u2014 test terminal notification',
1307
1639
  ' /router \u2014 show model routing status',
1308
1640
  ' /replay \u2014 show session timeline',
1309
1641
  ' /hooks \u2014 show lifecycle hooks',
@@ -1331,7 +1663,7 @@ function App({ config }) {
1331
1663
  if (msgs.length > 0) {
1332
1664
  saveSession(sessionId, msgs, models[0] || 'unknown', undefined, costTracker.getState()).then((meta) => {
1333
1665
  add('system', `Auto-saved: ${meta.name}`);
1334
- }).catch(() => { });
1666
+ }).catch((err) => { logger.warn('Failed to auto-save session', { error: err.message }); });
1335
1667
  }
1336
1668
  agent.clearHistory();
1337
1669
  setItems([]);
@@ -1345,6 +1677,8 @@ function App({ config }) {
1345
1677
  setShowBanner(true);
1346
1678
  costTracker.reset();
1347
1679
  setTotalTokens(0);
1680
+ setContextPercent(0);
1681
+ setRateLimitWarn(false);
1348
1682
  sessionId = generateSessionId();
1349
1683
  return true;
1350
1684
  }
@@ -1397,7 +1731,35 @@ function App({ config }) {
1397
1731
  });
1398
1732
  return true;
1399
1733
  }
1400
- if (cmd === '/load') {
1734
+ if (cmd === '/load' || cmd === '/resume') {
1735
+ if (cmd === '/resume') {
1736
+ loadCrashState().then(async (state) => {
1737
+ if (state && state.messages.length > 0 && agent) {
1738
+ agent.loadMessages(state.messages);
1739
+ sessionId = state.sessionId;
1740
+ if (state.costState) {
1741
+ costTracker.restore(state.costState);
1742
+ setTotalTokens(state.costState.totalTokens);
1743
+ }
1744
+ add('system', `Resumed session with ${state.messages.length} messages.`);
1745
+ showBannerRef.current = false;
1746
+ setShowBanner(false);
1747
+ await clearCrashState();
1748
+ }
1749
+ else {
1750
+ const sessions = await listSessions();
1751
+ if (sessions.length === 0) {
1752
+ add('system', 'No saved sessions or crash recovery data found.');
1753
+ }
1754
+ else {
1755
+ setSessionList(sessions);
1756
+ }
1757
+ }
1758
+ }).catch(() => {
1759
+ add('system', 'Failed to load crash recovery data.');
1760
+ });
1761
+ return true;
1762
+ }
1401
1763
  listSessions().then((sessions) => {
1402
1764
  if (sessions.length === 0) {
1403
1765
  add('system', 'No saved sessions found.');
@@ -1408,20 +1770,43 @@ function App({ config }) {
1408
1770
  });
1409
1771
  return true;
1410
1772
  }
1773
+ if (cmd === '/fork') {
1774
+ const pointStr = text.split(' ')[1];
1775
+ const point = pointStr ? parseInt(pointStr, 10) : agent?.getMessages().length;
1776
+ if (!point || point <= 0) {
1777
+ add('system', 'Usage: /fork [message_number] — fork conversation from a specific point');
1778
+ return true;
1779
+ }
1780
+ forkSession(sessionId, point).then(({ id, meta }) => {
1781
+ add('system', `Forked to new session: ${meta.name} (${id.slice(0, 8)})`);
1782
+ agent.loadMessages(agent.getMessages().slice(0, point));
1783
+ sessionId = id;
1784
+ }).catch((err) => {
1785
+ add('error', `Fork failed: ${err.message}`);
1786
+ });
1787
+ return true;
1788
+ }
1411
1789
  if (cmd === '/shortcuts') {
1412
1790
  const shortcuts = [
1413
1791
  'Ctrl+C \u2014 interrupt agent / clear input / double-press to quit',
1414
1792
  'Ctrl+D \u2014 exit (when input is empty)',
1415
1793
  'Ctrl+L \u2014 clear screen & conversation',
1794
+ 'Ctrl+O \u2014 toggle collapsed tool groups',
1416
1795
  'Ctrl+U \u2014 clear entire input line',
1417
1796
  'Ctrl+W \u2014 delete last word',
1418
1797
  'Ctrl+K \u2014 delete to end of line',
1419
1798
  'Ctrl+S \u2014 stash / restore current prompt',
1799
+ 'Ctrl+V \u2014 paste image from clipboard',
1800
+ 'Ctrl+R \u2014 reverse history search',
1801
+ 'Ctrl+X Ctrl+E \u2014 open in external editor ($EDITOR)',
1802
+ 'Alt+Enter \u2014 insert newline (multiline)',
1420
1803
  'Tab \u2014 autocomplete slash commands',
1421
1804
  '\u2191/\u2193 \u2014 navigate input history',
1422
- 'Shift+\u2191 / PageUp \u2014 scroll chat up',
1423
- 'Shift+\u2193 / PageDown \u2014 scroll chat down',
1424
- 'Y/N \u2014 confirm or deny tool execution',
1805
+ 'Shift+\u2191 \u2014 scroll up (5 items)',
1806
+ 'Shift+\u2193 \u2014 scroll down (5 items)',
1807
+ 'PageUp \u2014 scroll up fast (15 items)',
1808
+ 'PageDown \u2014 scroll down fast (15 items)',
1809
+ 'Y/A/N \u2014 allow / always allow / deny tool execution',
1425
1810
  'Esc \u2014 quit',
1426
1811
  '',
1427
1812
  'Copy \u2014 Cmd+C (Mac) / Ctrl+Shift+C (Linux)',
@@ -1433,6 +1818,32 @@ function App({ config }) {
1433
1818
  shortcuts.forEach((s) => add('system', s));
1434
1819
  return true;
1435
1820
  }
1821
+ if (cmd === '/permissions') {
1822
+ add('system', `Permission Mode: ${currentMode.toUpperCase()} — ${getModeDescription(currentMode)}`);
1823
+ if (alwaysAllowedTools.size > 0) {
1824
+ add('system', `Always Allowed (session): ${[...alwaysAllowedTools].join(', ')}`);
1825
+ }
1826
+ const scopedRules = getScopedPermissionRules();
1827
+ if (scopedRules.length > 0) {
1828
+ add('system', 'Scoped Rules:');
1829
+ scopedRules.forEach(r => {
1830
+ const spec = r.prefix ? `${r.tool}(${r.prefix}*)` : r.tool;
1831
+ add('system', ` ${r.action === 'allow' ? '✓' : '✗'} ${spec}${r.regex ? ` [regex: ${r.regex}]` : ''}`);
1832
+ });
1833
+ }
1834
+ if (recentDenials.length > 0) {
1835
+ add('system', 'Recent Denials:');
1836
+ const recent = recentDenials.slice(-10);
1837
+ recent.forEach(d => {
1838
+ const ago = Math.round((Date.now() - d.timestamp) / 1000);
1839
+ add('system', ` ✗ ${d.toolName} (${ago}s ago) ${d.args}`);
1840
+ });
1841
+ }
1842
+ else {
1843
+ add('system', 'No recent denials.');
1844
+ }
1845
+ return true;
1846
+ }
1436
1847
  if (cmd === '/settings') {
1437
1848
  setEditingSettings(true);
1438
1849
  return true;
@@ -1449,7 +1860,7 @@ function App({ config }) {
1449
1860
  loadConfig().then(cfg => {
1450
1861
  cfg.permissionMode = arg;
1451
1862
  saveConfig(cfg);
1452
- }).catch(() => { });
1863
+ }).catch((err) => { logger.warn('Failed to save config', { error: err.message }); });
1453
1864
  add('system', `Mode: ${arg.toUpperCase()} \u2014 ${getModeDescription(arg)}`);
1454
1865
  }
1455
1866
  else {
@@ -1464,7 +1875,7 @@ function App({ config }) {
1464
1875
  loadConfig().then(cfg => {
1465
1876
  cfg.thinkingMode = arg;
1466
1877
  saveConfig(cfg);
1467
- }).catch(() => { });
1878
+ }).catch((err) => { logger.warn('Failed to save config', { error: err.message }); });
1468
1879
  add('system', `Thinking: ${arg.toUpperCase()}${arg === 'off' ? '' : ' \u2014 model will reason before responding'}`);
1469
1880
  }
1470
1881
  else {
@@ -1510,14 +1921,15 @@ function App({ config }) {
1510
1921
  return true;
1511
1922
  }
1512
1923
  if (cmd === '/git') {
1513
- const gitInfo = collectGitInfo(process.cwd());
1514
- if (!gitInfo.isRepo) {
1515
- add('system', 'Not a git repository.');
1516
- }
1517
- else {
1518
- const lines = gitInfoToContext(gitInfo).split('\n');
1519
- lines.forEach((l) => add('system', l));
1520
- }
1924
+ collectGitInfo(process.cwd()).then((gitInfo) => {
1925
+ if (!gitInfo.isRepo) {
1926
+ add('system', 'Not a git repository.');
1927
+ }
1928
+ else {
1929
+ const lines = gitInfoToContext(gitInfo).split('\n');
1930
+ lines.forEach((l) => add('system', l));
1931
+ }
1932
+ }).catch(() => { add('system', 'Failed to collect git info.'); });
1521
1933
  return true;
1522
1934
  }
1523
1935
  if (cmd === '/plugins') {
@@ -1574,6 +1986,10 @@ function App({ config }) {
1574
1986
  commandsSummary(customCommands).split('\n').forEach((l) => add('system', l));
1575
1987
  return true;
1576
1988
  }
1989
+ if (cmd === '/skills') {
1990
+ skillsSummary(loadedSkills).split('\n').forEach((l) => add('system', l));
1991
+ return true;
1992
+ }
1577
1993
  if (cmd === '/delegate') {
1578
1994
  const prompt = text.slice(10).trim();
1579
1995
  if (!prompt) {
@@ -1597,6 +2013,23 @@ function App({ config }) {
1597
2013
  });
1598
2014
  return true;
1599
2015
  }
2016
+ if (cmd === '/agents') {
2017
+ const registry = AgentRegistry.getInstance();
2018
+ const summary = registry.getUsageSummary();
2019
+ const tree = registry.getAgentTree();
2020
+ const lines = [
2021
+ `Active: ${summary.activeCount} | Completed: ${summary.completedCount} | Failed: ${summary.failedCount}`,
2022
+ `Total tokens: ${summary.totalTokens.toLocaleString()}`,
2023
+ ];
2024
+ if (tree && tree !== '(no agents)') {
2025
+ lines.push('', 'Agent tree:', tree);
2026
+ }
2027
+ else {
2028
+ lines.push('', 'No agents have been spawned yet.');
2029
+ }
2030
+ lines.forEach(l => add('system', l));
2031
+ return true;
2032
+ }
1600
2033
  if (cmd === '/checkpoint') {
1601
2034
  const label = text.slice(12).trim() || undefined;
1602
2035
  createCheckpoint(process.cwd(), label).then((hash) => {
@@ -1624,6 +2057,130 @@ function App({ config }) {
1624
2057
  }).catch(() => add('error', 'Failed to list checkpoints.'));
1625
2058
  return true;
1626
2059
  }
2060
+ if (cmd === '/worktree') {
2061
+ const subArgs = text.slice(10).trim().split(/\s+/);
2062
+ const subCmd = subArgs[0] || 'list';
2063
+ const cwd = process.cwd();
2064
+ if (subCmd === 'list' || subCmd === 'ls') {
2065
+ listWorktrees(cwd).then(wts => {
2066
+ if (wts.length === 0)
2067
+ add('system', 'No active worktrees.');
2068
+ else {
2069
+ add('system', `${wts.length} worktree(s):`);
2070
+ wts.forEach(wt => add('system', ` ${wt.branch} → ${wt.path}`));
2071
+ }
2072
+ }).catch(err => add('error', `Failed to list worktrees: ${err.message}`));
2073
+ }
2074
+ else if (subCmd === 'create' || subCmd === 'new') {
2075
+ const name = subArgs[1] || `work-${Date.now().toString(36)}`;
2076
+ createWorktree(name, cwd).then(wt => {
2077
+ add('system', `Worktree created: ${wt.branch} → ${wt.path}`);
2078
+ add('system', `cd ${wt.path} to work in isolation`);
2079
+ }).catch(err => add('error', `Failed to create worktree: ${err.message}`));
2080
+ }
2081
+ else if (subCmd === 'remove' || subCmd === 'rm') {
2082
+ const branchOrId = subArgs[1];
2083
+ if (!branchOrId) {
2084
+ add('system', 'Usage: /worktree remove <branch-or-agent-id>');
2085
+ return true;
2086
+ }
2087
+ listWorktrees(cwd).then(wts => {
2088
+ const wt = wts.find(w => w.branch.includes(branchOrId) || w.agentId.includes(branchOrId));
2089
+ if (!wt) {
2090
+ add('error', `Worktree not found: ${branchOrId}`);
2091
+ return;
2092
+ }
2093
+ removeWorktree(wt, cwd).then(() => {
2094
+ add('system', `Worktree removed: ${wt.branch}`);
2095
+ }).catch(err => add('error', `Failed to remove: ${err.message}`));
2096
+ }).catch(err => add('error', `Failed: ${err.message}`));
2097
+ }
2098
+ else if (subCmd === 'cleanup') {
2099
+ cleanupStaleWorktrees(cwd).then(count => {
2100
+ add('system', count > 0 ? `Cleaned up ${count} stale worktree(s).` : 'No stale worktrees found.');
2101
+ }).catch(err => add('error', `Cleanup failed: ${err.message}`));
2102
+ }
2103
+ else {
2104
+ add('system', 'Usage: /worktree [list|create <name>|remove <branch>|cleanup]');
2105
+ }
2106
+ return true;
2107
+ }
2108
+ if (cmd === '/plugin') {
2109
+ const subArgs = text.slice(8).trim().split(/\s+/);
2110
+ const subCmd = subArgs[0] || 'list';
2111
+ const cwd = process.cwd();
2112
+ if (subCmd === 'list' || subCmd === 'ls') {
2113
+ listInstalledPlugins(cwd).then(plugins => {
2114
+ if (plugins.length === 0) {
2115
+ add('system', 'No plugins installed. Use /plugin install <source> to add one.');
2116
+ }
2117
+ else {
2118
+ add('system', `${plugins.length} plugin(s) installed:`);
2119
+ plugins.forEach(p => {
2120
+ const status = p.enabled ? 'enabled' : 'disabled';
2121
+ add('system', ` ${p.name} v${p.version} [${status}]`);
2122
+ });
2123
+ }
2124
+ }).catch(err => add('error', `Failed to list plugins: ${err.message}`));
2125
+ }
2126
+ else if (subCmd === 'install') {
2127
+ const source = subArgs[1];
2128
+ if (!source) {
2129
+ add('system', 'Usage: /plugin install <path-or-git-url>');
2130
+ return true;
2131
+ }
2132
+ add('system', `Installing plugin from ${source}...`);
2133
+ installPlugin(cwd, source).then(result => {
2134
+ if (result) {
2135
+ add('system', `Plugin installed: ${result.name} v${result.version}`);
2136
+ add('system', 'Restart session to load new plugin tools.');
2137
+ }
2138
+ else {
2139
+ add('system', 'Plugin already installed.');
2140
+ }
2141
+ }).catch(err => add('error', `Install failed: ${err.message}`));
2142
+ }
2143
+ else if (subCmd === 'uninstall' || subCmd === 'remove') {
2144
+ const name = subArgs[1];
2145
+ if (!name) {
2146
+ add('system', 'Usage: /plugin uninstall <name>');
2147
+ return true;
2148
+ }
2149
+ uninstallPlugin(cwd, name).then(ok => {
2150
+ add(ok ? 'system' : 'error', ok ? `Plugin uninstalled: ${name}` : `Plugin not found: ${name}`);
2151
+ }).catch(err => add('error', `Uninstall failed: ${err.message}`));
2152
+ }
2153
+ else if (subCmd === 'enable') {
2154
+ const name = subArgs[1];
2155
+ if (!name) {
2156
+ add('system', 'Usage: /plugin enable <name>');
2157
+ return true;
2158
+ }
2159
+ enablePlugin(cwd, name).then(ok => {
2160
+ add(ok ? 'system' : 'error', ok ? `Plugin enabled: ${name}` : `Plugin not found or already enabled: ${name}`);
2161
+ }).catch(err => add('error', `Enable failed: ${err.message}`));
2162
+ }
2163
+ else if (subCmd === 'disable') {
2164
+ const name = subArgs[1];
2165
+ if (!name) {
2166
+ add('system', 'Usage: /plugin disable <name>');
2167
+ return true;
2168
+ }
2169
+ disablePlugin(cwd, name).then(ok => {
2170
+ add(ok ? 'system' : 'error', ok ? `Plugin disabled: ${name}` : `Plugin not found: ${name}`);
2171
+ }).catch(err => add('error', `Disable failed: ${err.message}`));
2172
+ }
2173
+ else {
2174
+ add('system', 'Usage: /plugin [list|install <source>|uninstall <name>|enable <name>|disable <name>]');
2175
+ }
2176
+ return true;
2177
+ }
2178
+ if (cmd === '/notify') {
2179
+ const message = text.slice(8).trim() || 'Test notification from Scalix';
2180
+ sendNotification('Scalix', message);
2181
+ add('system', `Notification sent (terminal: ${getTerminalType()})`);
2182
+ return true;
2183
+ }
1627
2184
  if (cmd === '/commit') {
1628
2185
  setIsLoading(true);
1629
2186
  setStatus('thinking');
@@ -1662,7 +2219,8 @@ function App({ config }) {
1662
2219
  }
1663
2220
  add('system', `Commit message: ${cleanMsg}`);
1664
2221
  try {
1665
- const result = execFileSync('git', ['commit', '-m', cleanMsg], { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() }).trim();
2222
+ 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();
1666
2224
  add('system', result);
1667
2225
  }
1668
2226
  catch (e) {
@@ -1871,6 +2429,32 @@ function App({ config }) {
1871
2429
  add('system', `Messages: ${msgCount}`);
1872
2430
  add('system', `Session: ${sessionId}`);
1873
2431
  add('system', `CWD: ${process.cwd()}`);
2432
+ const updateBanner = getUpdateBanner();
2433
+ if (updateBanner)
2434
+ add('system', updateBanner);
2435
+ return true;
2436
+ }
2437
+ if (cmd === '/update') {
2438
+ add('system', 'Checking for updates...');
2439
+ checkForUpdate().then((info) => {
2440
+ if (info && info.updateAvailable) {
2441
+ add('system', `New version available: ${info.current} → ${info.latest}`);
2442
+ add('system', 'Installing update...');
2443
+ performUpdate().then(({ success, output }) => {
2444
+ if (success) {
2445
+ add('system', `Updated successfully. Restart Scalix to use v${info.latest}.`);
2446
+ }
2447
+ else {
2448
+ add('system', `Update failed: ${output}\nTry manually: npm install -g scalix@latest`);
2449
+ }
2450
+ });
2451
+ }
2452
+ else {
2453
+ add('system', `Scalix v${VERSION} is up to date.`);
2454
+ }
2455
+ }).catch(() => {
2456
+ add('system', 'Failed to check for updates. Try: npm install -g scalix@latest');
2457
+ });
1874
2458
  return true;
1875
2459
  }
1876
2460
  if (cmd === '/context') {
@@ -1919,7 +2503,7 @@ function App({ config }) {
1919
2503
  if (cmd === '/exit' || cmd === '/quit') {
1920
2504
  const msgs = agent.getMessages();
1921
2505
  if (msgs.length > 0) {
1922
- saveSession(sessionId, msgs, models[0] || 'unknown', undefined, costTracker.getState()).catch(() => { }).finally(() => process.exit(0));
2506
+ saveSession(sessionId, msgs, models[0] || 'unknown', undefined, costTracker.getState()).catch((err) => { logger.warn('Failed to save session on exit', { error: err.message }); }).finally(() => process.exit(0));
1923
2507
  }
1924
2508
  else {
1925
2509
  process.exit(0);
@@ -1932,7 +2516,7 @@ function App({ config }) {
1932
2516
  loadConfig().then(cfg => {
1933
2517
  cfg.permissionMode = 'plan';
1934
2518
  saveConfig(cfg);
1935
- }).catch(() => { });
2519
+ }).catch((err) => { logger.warn('Failed to save config', { error: err.message }); });
1936
2520
  add('system', `Mode: PLAN — ${getModeDescription('plan')}`);
1937
2521
  return true;
1938
2522
  }
@@ -2016,10 +2600,26 @@ function App({ config }) {
2016
2600
  const isLoadingRef = useRef(false);
2017
2601
  useEffect(() => { isLoadingRef.current = isLoading; }, [isLoading]);
2018
2602
  const multilineRef = useRef([]);
2603
+ const messageQueueRef = useRef([]);
2604
+ const pendingImagesRef = useRef([]);
2019
2605
  const handleSubmit = useCallback(async (text) => {
2020
2606
  const trimmed = text.trim();
2021
- if (!trimmed || isLoadingRef.current)
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)}`);
2022
2621
  return;
2622
+ }
2023
2623
  if (trimmed.endsWith('\\')) {
2024
2624
  if (multilineRef.current.length >= 50) {
2025
2625
  add('system', 'Multiline limit reached (50 lines). Sending now.');
@@ -2041,6 +2641,11 @@ function App({ config }) {
2041
2641
  }
2042
2642
  if (finalText.startsWith('/') && handleCommand(finalText))
2043
2643
  return;
2644
+ const triggeredSkill = getSkillByTrigger(loadedSkills, finalText);
2645
+ if (triggeredSkill) {
2646
+ finalText = `${getSkillContent(triggeredSkill, 'full')}\n\n---\nUser request: ${finalText}`;
2647
+ add('system', `Skill activated: ${triggeredSkill.name}`);
2648
+ }
2044
2649
  if (finalText.startsWith('!')) {
2045
2650
  const shellCmd = finalText.slice(1).trim();
2046
2651
  if (!shellCmd)
@@ -2062,14 +2667,21 @@ function App({ config }) {
2062
2667
  }
2063
2668
  const correction = detectCorrection(finalText);
2064
2669
  if (correction) {
2065
- addNote(process.cwd(), `[correction] ${correction}`).catch(() => { });
2670
+ addNote(process.cwd(), `[correction] ${correction}`).catch((err) => { logger.warn('Failed to save correction note', { error: err.message }); });
2671
+ }
2672
+ const images = pendingImagesRef.current.length > 0 ? [...pendingImagesRef.current] : undefined;
2673
+ if (images) {
2674
+ add('user', `${finalText} [${images.length} image${images.length > 1 ? 's' : ''} attached]`);
2066
2675
  }
2067
- add('user', finalText);
2676
+ else {
2677
+ add('user', finalText);
2678
+ }
2679
+ pendingImagesRef.current = [];
2068
2680
  setIsLoading(true);
2069
2681
  setStatus('thinking');
2070
2682
  setThinkStart(Date.now());
2071
2683
  try {
2072
- const result = await agent.send(finalText);
2684
+ const result = await agent.send(finalText, images);
2073
2685
  if (!result || !result.trim()) {
2074
2686
  add('system', 'Agent returned no response.');
2075
2687
  }
@@ -2078,6 +2690,24 @@ function App({ config }) {
2078
2690
  add('error', e.message || 'Unknown error');
2079
2691
  }
2080
2692
  finally {
2693
+ // Process queued messages sequentially
2694
+ while (messageQueueRef.current.length > 0) {
2695
+ const next = messageQueueRef.current.shift();
2696
+ const remaining = messageQueueRef.current.length;
2697
+ if (remaining > 0)
2698
+ 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
+ }
2710
+ }
2081
2711
  setIsLoading(false);
2082
2712
  setStatus('ready');
2083
2713
  setThinkStart(null);
@@ -2087,6 +2717,7 @@ function App({ config }) {
2087
2717
  setSessionList(null);
2088
2718
  setIsLoading(true);
2089
2719
  setStatus('thinking');
2720
+ setThinkStart(Date.now());
2090
2721
  loadSession(session.id).then((data) => {
2091
2722
  agent.loadMessages(data.messages);
2092
2723
  sessionId = session.id;
@@ -2112,6 +2743,7 @@ function App({ config }) {
2112
2743
  }).finally(() => {
2113
2744
  setIsLoading(false);
2114
2745
  setStatus('ready');
2746
+ setThinkStart(null);
2115
2747
  });
2116
2748
  }, [add]);
2117
2749
  const handleAbort = useCallback(() => {
@@ -2131,7 +2763,7 @@ function App({ config }) {
2131
2763
  const handleClearScreen = useCallback(() => {
2132
2764
  const msgs = agent.getMessages();
2133
2765
  if (msgs.length > 0) {
2134
- saveSession(sessionId, msgs, models[0] || 'unknown', undefined, costTracker.getState()).catch(() => { });
2766
+ saveSession(sessionId, msgs, models[0] || 'unknown', undefined, costTracker.getState()).catch((err) => { logger.warn('Failed to save session on clear', { error: err.message }); });
2135
2767
  }
2136
2768
  agent.clearHistory();
2137
2769
  setItems([]);
@@ -2139,6 +2771,8 @@ function App({ config }) {
2139
2771
  setShowBanner(true);
2140
2772
  costTracker.reset();
2141
2773
  setTotalTokens(0);
2774
+ setContextPercent(0);
2775
+ setRateLimitWarn(false);
2142
2776
  sessionId = generateSessionId();
2143
2777
  }, [models]);
2144
2778
  const handleSetupDone = useCallback((newConfig) => {
@@ -2146,6 +2780,7 @@ function App({ config }) {
2146
2780
  try {
2147
2781
  initAgent(newConfig);
2148
2782
  setModels(agent.getModels());
2783
+ setAgentVersion(v => v + 1);
2149
2784
  add('system', 'Configuration updated. Agent reinitialized.');
2150
2785
  }
2151
2786
  catch (err) {
@@ -2157,8 +2792,18 @@ function App({ config }) {
2157
2792
  const bannerHeight = showBanner ? 5 : 0;
2158
2793
  const statusBarHeight = 1;
2159
2794
  const bottomHeight = useMemo(() => {
2160
- if (confirmInfo)
2161
- return 5;
2795
+ if (confirmInfo) {
2796
+ let parsed = {};
2797
+ try {
2798
+ parsed = typeof confirmInfo.args === 'string' ? JSON.parse(confirmInfo.args) : confirmInfo.args ?? {};
2799
+ }
2800
+ catch {
2801
+ parsed = {};
2802
+ }
2803
+ const extraCount = Math.min(Object.keys(parsed).filter(k => !['command', 'filePath', 'file_path', 'notebook_path', 'message', 'content'].includes(k)).length, 3);
2804
+ const hasHighlight = !!(parsed.command ?? parsed.filePath ?? parsed.file_path ?? parsed.notebook_path ?? parsed.message);
2805
+ return 5 + (hasHighlight ? 1 : 0) + extraCount;
2806
+ }
2162
2807
  if (selectingModel)
2163
2808
  return 6 + Math.min(models.length + 2, 12);
2164
2809
  if (selectingMode)
@@ -2178,7 +2823,7 @@ function App({ config }) {
2178
2823
  const reasonLines = reasoningText ? (reasoningText.match(/\n/g) || []).length + 1 : 0;
2179
2824
  return textLines + reasonLines;
2180
2825
  }, [isLoading, streamText, reasoningText]);
2181
- const { visibleItems, scrollUp, scrollDown, scrollToBottom, isAtBottom, } = useScrollableItems(items, chatHeight, cols, isLoading, streamLineCount);
2826
+ const { visibleItems, scrollUp, scrollDown, scrollToBottom, isAtBottom, isAtTop, } = useScrollableItems(items, chatHeight, cols, isLoading, streamLineCount);
2182
2827
  const scrollToBottomRef = useRef(scrollToBottom);
2183
2828
  scrollToBottomRef.current = scrollToBottom;
2184
2829
  const handleSubmitRef = useRef(handleSubmit);
@@ -2190,14 +2835,14 @@ function App({ config }) {
2190
2835
  if (showSetup) {
2191
2836
  return _jsx(SetupScreen, { onComplete: handleSetupDone, existingConfig: config });
2192
2837
  }
2193
- 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) => {
2838
+ 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) => {
2194
2839
  agent.setModel(v);
2195
2840
  setModels([v]);
2196
2841
  setSelectingModel(false);
2197
2842
  loadConfig().then(cfg => {
2198
2843
  cfg.models = [v];
2199
2844
  saveConfig(cfg);
2200
- }).catch(() => { });
2845
+ }).catch((err) => { logger.warn('Failed to save config', { error: err.message }); });
2201
2846
  add('system', `Model switched to ${shortModel(v)}`);
2202
2847
  }, onCancel: () => setSelectingModel(false) })) : selectingMode ? (_jsx(ModePicker, { currentMode: currentMode, onSelect: (mode) => {
2203
2848
  agent.setPermissionMode(mode);
@@ -2206,7 +2851,7 @@ function App({ config }) {
2206
2851
  loadConfig().then(cfg => {
2207
2852
  cfg.permissionMode = mode;
2208
2853
  saveConfig(cfg);
2209
- }).catch(() => { });
2854
+ }).catch((err) => { logger.warn('Failed to save config', { error: err.message }); });
2210
2855
  add('system', `Mode: ${mode.toUpperCase()} \u2014 ${getModeDescription(mode)}`);
2211
2856
  }, onCancel: () => setSelectingMode(false) })) : editingSettings ? (_jsx(SettingsEditor, { onSave: (key, value) => {
2212
2857
  loadConfig().then((c) => {
@@ -2232,7 +2877,7 @@ function App({ config }) {
2232
2877
  }).catch(() => {
2233
2878
  add('error', 'Failed to load config.');
2234
2879
  });
2235
- }, 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 })) })] }));
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); } })) })] }));
2236
2881
  }
2237
2882
  // ─── Root (config loading + setup gate) ───
2238
2883
  function Root() {
@@ -2247,7 +2892,10 @@ function Root() {
2247
2892
  setConfig(c);
2248
2893
  if (isConfigured(c)) {
2249
2894
  // Fetch model specs from API in background (uses user-defined/built-in as fallback)
2250
- fetchModelSpecs(c.baseUrl, c.apiKey).catch(() => { });
2895
+ fetchModelSpecs(c.baseUrl, c.apiKey).catch((err) => { logger.warn('Failed to fetch model specs', { error: err.message }); });
2896
+ if (!process.env.NO_UPDATE_NOTIFIER && !c.disableUpdateCheck) {
2897
+ checkForUpdate().catch(() => { });
2898
+ }
2251
2899
  try {
2252
2900
  initAgent(c);
2253
2901
  setReady(true);
@@ -2257,7 +2905,7 @@ function Root() {
2257
2905
  }
2258
2906
  }
2259
2907
  }).catch(() => {
2260
- setConfig({ apiKey: '', baseUrl: DEFAULT_BASE_URL, models: [...DEFAULT_MODELS], customModels: {}, limits: { ...DEFAULT_LIMITS }, permissionMode: 'default', thinkingMode: 'off' });
2908
+ setConfig({ apiKey: '', baseUrl: DEFAULT_BASE_URL, models: [...DEFAULT_MODELS], customModels: {}, limits: { ...DEFAULT_LIMITS }, permissionMode: 'default', thinkingMode: 'off', security: {}, injectionDetection: { enabled: true, sensitivity: 'medium' } });
2261
2909
  });
2262
2910
  }, []);
2263
2911
  const handleSetupComplete = useCallback((c) => {
@@ -2324,6 +2972,6 @@ function _restoreTerminal() {
2324
2972
  _origWrite('\x1b[?25h'); // show cursor
2325
2973
  }
2326
2974
  process.on('exit', _restoreTerminal);
2327
- process.on('SIGINT', () => { _restoreTerminal(); process.exit(0); });
2328
- process.on('SIGTERM', () => { _restoreTerminal(); process.exit(0); });
2975
+ process.on('SIGINT', () => { _restoreTerminal(); closeBrowser().finally(() => process.exit(0)); });
2976
+ process.on('SIGTERM', () => { _restoreTerminal(); closeBrowser().finally(() => process.exit(0)); });
2329
2977
  render(_jsx(Root, {}), { exitOnCtrlC: false, patchConsole: false });