scalix 1.1.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
@@ -6,6 +6,7 @@ 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';
@@ -21,23 +22,26 @@ 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';
27
28
  import { checkForUpdate, getUpdateBanner, performUpdate } from './update-checker.js';
28
29
  import { loadConfig, saveConfig, isConfigured, DEFAULT_LIMITS, THINKING_MODES } from './settings.js';
29
- import { getModeDescription, PERMISSION_MODES, setSandboxRoot } from './sandbox.js';
30
+ import { getModeDescription, PERMISSION_MODES, setSandboxRoot, checkScopedPermission, getScopedPermissionRules } from './sandbox.js';
30
31
  import { buildProjectIndex, indexToContext } from './project-index.js';
31
32
  import { collectGitInfo, gitInfoToContext } from './git-context.js';
32
33
  import { loadMemory, autoDetectConventions, saveMemory, memoryToContext, addNote, detectCorrection } from './project-memory.js';
33
- import { discoverPlugins, getPluginTools, pluginsSummary } from './plugins.js';
34
+ import { discoverPlugins, getPluginTools, pluginsSummary, installPlugin, uninstallPlugin, listInstalledPlugins, enablePlugin, disablePlugin } from './plugins.js';
34
35
  import { createRouterConfig, getRouterStatus } from './model-router.js';
35
36
  import { loadCustomCommands, expandCommand, commandsSummary } from './commands.js';
36
37
  import { hooksSummary } from './hooks.js';
37
38
  import { spawnSubAgent, pickCheapestModel } from './sub-agent.js';
38
39
  import { createCheckpoint, undoLastCheckpoint, listCheckpoints } from './git-undo.js';
39
40
  import { loadScopedRules, rulesToContext } from './scoped-rules.js';
41
+ import { loadSkills, skillsSummary, getSkillByTrigger, getSkillContent } from './skills.js';
40
42
  import { AgentRegistry } from './agent-registry.js';
43
+ import { createWorktree, listWorktrees, removeWorktree, cleanupStaleWorktrees } from './worktree.js';
44
+ import { sendNotification, getTerminalType } from './terminal-notify.js';
41
45
  logger.setLevel('silent');
42
46
  setSandboxRoot(process.cwd());
43
47
  // Suppress noisy React 19 development-mode reconciler warnings that leak to stderr
@@ -57,10 +61,21 @@ const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools,
57
61
  import { EventEmitter } from 'node:events';
58
62
  const confirmEmitter = new EventEmitter();
59
63
  let pendingConfirm = null;
60
- const confirmToolCall = async (toolName, args) => new Promise((resolve) => {
61
- pendingConfirm = { resolve, toolName, args };
62
- confirmEmitter.emit('pending');
63
- });
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
+ };
64
79
  // ─── Agent (deferred until config is ready) ───
65
80
  let baseInstructions = DEFAULT_INSTRUCTIONS;
66
81
  let agent = null;
@@ -69,6 +84,7 @@ let projectIndex = null;
69
84
  let loadedPlugins = [];
70
85
  let projectMemory = null;
71
86
  let customCommands = [];
87
+ let loadedSkills = [];
72
88
  async function buildEnhancedInstructions() {
73
89
  let instructions = baseInstructions;
74
90
  const cwd = process.cwd();
@@ -154,6 +170,9 @@ function initAgent(config) {
154
170
  loadCustomCommands(process.cwd()).then((cmds) => {
155
171
  customCommands = cmds;
156
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 }); });
157
176
  return agent;
158
177
  }
159
178
  // ─── Helpers ───
@@ -204,6 +223,43 @@ const Banner = memo(function Banner() {
204
223
  function sanitizeInput(v) {
205
224
  return v.replace(/\[20[01]~/g, '').replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '');
206
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
+ }
207
263
  function LongTextInput({ value, onChange, onSubmit, placeholder, maxVisible = 80 }) {
208
264
  const valueRef = useRef(value);
209
265
  useEffect(() => { valueRef.current = value; }, [value]);
@@ -387,7 +443,7 @@ function SetupScreen({ onComplete, existingConfig }) {
387
443
  });
388
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"] }) })] })] }));
389
445
  }
390
- 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 }) {
391
447
  const { cols } = useTermSize();
392
448
  const [elapsedStr, setElapsedStr] = useState('');
393
449
  useEffect(() => {
@@ -410,10 +466,13 @@ const StatusBar = memo(function StatusBar({ status, model, toolCount, thinkStart
410
466
  leftParts.push(`${toolCount} tools`);
411
467
  if (activeAgents > 0)
412
468
  leftParts.push(`${activeAgents} agent${activeAgents > 1 ? 's' : ''}`);
469
+ if (contextPercent > 50)
470
+ leftParts.push(`ctx ${contextPercent}%`);
413
471
  const leftText = leftParts.join(' \u00B7 ');
414
- 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;
415
474
  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] })] })] }));
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] })] })] }));
417
476
  });
418
477
  const WelcomeScreen = memo(function WelcomeScreen() {
419
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)))] })] }));
@@ -456,6 +515,12 @@ const ToolResultMessage = memo(function ToolResultMessage({ name, result }) {
456
515
  const ToolBlockedMessage = memo(function ToolBlockedMessage({ text }) {
457
516
  return (_jsxs(Box, { paddingLeft: 3, paddingRight: 2, gap: 1, children: [_jsx(Text, { color: "red", children: '\u25CF' }), _jsx(Text, { color: "red", children: text })] }));
458
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
+ });
459
524
  const DIFF_MAX_LINES = 60;
460
525
  const DiffMessage = memo(function DiffMessage({ text, meta }) {
461
526
  const allLines = text.split('\n');
@@ -491,6 +556,7 @@ const RenderItem = memo(function RenderItem({ item }) {
491
556
  case 'tool-call': return _jsx(ToolCallMessage, { name: item.meta ?? '', args: item.text });
492
557
  case 'tool-result': return _jsx(ToolResultMessage, { name: item.meta ?? '', result: item.text });
493
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 });
494
560
  case 'diff': return _jsx(DiffMessage, { text: item.text, meta: item.meta });
495
561
  case 'system': return _jsx(SystemMessage, { text: item.text });
496
562
  }
@@ -519,14 +585,20 @@ const StreamingArea = memo(function StreamingArea({ text, reasoning, model, maxL
519
585
  function ConfirmPrompt({ toolName, args, onRespond, onScrollUp, onScrollDown }) {
520
586
  const { cols } = useTermSize();
521
587
  useInput((ch, key) => {
522
- if (ch.toLowerCase() === 'y')
588
+ if (ch.toLowerCase() === 'a')
589
+ onRespond(true, true);
590
+ else if (ch.toLowerCase() === 'y')
523
591
  onRespond(true);
524
592
  else if (ch.toLowerCase() === 'n' || key.escape)
525
593
  onRespond(false);
526
- else if (key.pageUp || (key.upArrow && key.shift))
527
- onScrollUp?.();
528
- else if (key.pageDown || (key.downArrow && key.shift))
529
- 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);
530
602
  });
531
603
  let parsed = {};
532
604
  try {
@@ -547,7 +619,7 @@ function ConfirmPrompt({ toolName, args, onRespond, onScrollUp, onScrollDown })
547
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]) => {
548
620
  const val = truncate(String(v), boxWidth - k.length - 6);
549
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));
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'] })] }));
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'] })] }));
551
623
  }
552
624
  function ModelPicker({ currentModels, onSelect, onCancel }) {
553
625
  const [customInput, setCustomInput] = useState('');
@@ -733,7 +805,7 @@ function SettingsEditor({ onSave, onClose }) {
733
805
  }
734
806
  } })] }));
735
807
  }
736
- 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 }) {
737
809
  const { exit } = useApp();
738
810
  const { cols } = useTermSize();
739
811
  const line = useMemo(() => '\u2500'.repeat(Math.max(cols - 6, 20)), [cols]);
@@ -744,7 +816,11 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
744
816
  const [history, setHistory] = useState([]);
745
817
  const [historyIndex, setHistoryIndex] = useState(-1);
746
818
  const savedInput = useRef('');
819
+ const [searchMode, setSearchMode] = useState(false);
820
+ const [searchQuery, setSearchQuery] = useState('');
821
+ const ctrlXPending = useRef(false);
747
822
  const valueRef = useRef('');
823
+ const [imageChips, setImageChips] = useState([]);
748
824
  const onChange = useCallback((v) => {
749
825
  setValue(v);
750
826
  valueRef.current = v;
@@ -814,6 +890,88 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
814
890
  onChange('');
815
891
  return;
816
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
+ }
817
975
  // ── Ctrl+S: stash / restore prompt ──
818
976
  if (ch === 's' && key.ctrl && !disabled) {
819
977
  if (val.length > 0) {
@@ -826,6 +984,16 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
826
984
  }
827
985
  return;
828
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
+ }
829
997
  // ── Tab: autocomplete slash commands ──
830
998
  if (key.tab && !disabled && val.startsWith('/') && !val.includes(' ')) {
831
999
  const cmdNames = commands.map((c) => c.cmd);
@@ -859,12 +1027,20 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
859
1027
  return;
860
1028
  }
861
1029
  // ── Scroll ──
862
- if (key.pageUp || (key.upArrow && key.shift)) {
863
- onScrollUp();
1030
+ if (key.pageUp) {
1031
+ onScrollUp(15);
864
1032
  return;
865
1033
  }
866
- if (key.pageDown || (key.downArrow && key.shift)) {
867
- onScrollDown();
1034
+ if (key.upArrow && key.shift) {
1035
+ onScrollUp(5);
1036
+ return;
1037
+ }
1038
+ if (key.pageDown) {
1039
+ onScrollDown(15);
1040
+ return;
1041
+ }
1042
+ if (key.downArrow && key.shift) {
1043
+ onScrollDown(5);
868
1044
  return;
869
1045
  }
870
1046
  // ── History navigation ──
@@ -906,6 +1082,8 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
906
1082
  { cmd: '/copy', desc: 'Copy last response to clipboard' },
907
1083
  { cmd: '/save', desc: 'Save current session' },
908
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' },
909
1087
  { cmd: '/export', desc: 'Export conversation as text' },
910
1088
  { cmd: '/index', desc: 'Scan & index project files' },
911
1089
  { cmd: '/init', desc: 'Initialize project config (SCALIX.md)' },
@@ -920,9 +1098,11 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
920
1098
  { cmd: '/mcp', desc: 'Add MCP server' },
921
1099
  { cmd: '/setup', desc: 'Reconfigure API key, URL & models' },
922
1100
  { cmd: '/settings', desc: 'View & configure limits' },
1101
+ { cmd: '/permissions', desc: 'View permission rules & recent denials' },
923
1102
  { cmd: '/shortcuts', desc: 'Keyboard shortcuts' },
924
1103
  { cmd: '/hooks', desc: 'Show lifecycle hooks' },
925
1104
  { cmd: '/commands', desc: 'Show custom commands' },
1105
+ { cmd: '/skills', desc: 'List available skills' },
926
1106
  { cmd: '/delegate', desc: 'Run task with sub-agent' },
927
1107
  { cmd: '/agents', desc: 'Show active agents & usage' },
928
1108
  { cmd: '/checkpoint', desc: 'Create git checkpoint' },
@@ -932,6 +1112,9 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
932
1112
  { cmd: '/review', desc: 'AI code review of current changes' },
933
1113
  { cmd: '/watch', desc: 'Run command, auto-fix on failure' },
934
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' },
935
1118
  { cmd: '/exit', desc: 'Save & exit' },
936
1119
  ...customCommands.map((c) => ({ cmd: `/${c.name}`, desc: c.description })),
937
1120
  ], [customCommands]);
@@ -940,18 +1123,37 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
940
1123
  const filteredCommands = showCommandPicker
941
1124
  ? commands.filter((c) => c.cmd.startsWith(prefix))
942
1125
  : [];
943
- 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);
944
1145
  useEffect(() => {
945
1146
  if (!onHeightChange)
946
1147
  return;
947
1148
  let h = 3; // separator + input row + padding
1149
+ const pickerCount = showCommandPicker ? filteredCommands.length : filteredFiles.length;
948
1150
  if (pickerActive)
949
- h += Math.min(filteredCommands.length, 10) + 2; // items + header + footer
1151
+ h += Math.min(pickerCount, 10) + 2;
950
1152
  if (stash.length > 0 && !disabled)
951
1153
  h += 1;
952
1154
  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) => ({
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) => ({
955
1157
  label: `${c.cmd.padEnd(16)} ${c.desc}`,
956
1158
  value: c.cmd,
957
1159
  })), isFocused: true, onSelect: (item) => {
@@ -963,7 +1165,14 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
963
1165
  onChange('');
964
1166
  handleSubmit(item.value);
965
1167
  }
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" }) }))] }) })] }));
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" }) }))] }) })] }));
967
1176
  });
968
1177
  // ─── App ───
969
1178
  function estimateLines(item, cols) {
@@ -983,12 +1192,14 @@ function estimateLines(item, cols) {
983
1192
  return totalLines + markdownExpansion + 2; // marginTop=1 + padding
984
1193
  }
985
1194
  case 'error': return totalLines + 2;
986
- case 'tool-call': return 2; // single row + marginTop=1
987
- case 'tool-result': return 1;
1195
+ case 'tool-call': return 2;
1196
+ case 'tool-result': return Math.max(1, totalLines);
988
1197
  case 'tool-blocked': return 1;
989
- case 'diff': return lines.length + 2; // header + each diff line + footer
990
- 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);
991
1201
  }
1202
+ return totalLines;
992
1203
  }
993
1204
  function useScrollableItems(items, availableHeight, cols, isLoading, streamLineCount = 0) {
994
1205
  const [scrollOffset, setScrollOffset] = useState(0);
@@ -1004,7 +1215,7 @@ function useScrollableItems(items, availableHeight, cols, isLoading, streamLineC
1004
1215
  if (isLoading && autoScroll.current)
1005
1216
  setScrollOffset(0);
1006
1217
  }, [isLoading]);
1007
- const scrollUp = useCallback((amount = 3) => {
1218
+ const scrollUp = useCallback((amount = 5) => {
1008
1219
  setScrollOffset((prev) => {
1009
1220
  const maxOffset = Math.max(items.length - 1, 0);
1010
1221
  const next = Math.min(prev + amount, maxOffset);
@@ -1012,7 +1223,7 @@ function useScrollableItems(items, availableHeight, cols, isLoading, streamLineC
1012
1223
  return next;
1013
1224
  });
1014
1225
  }, [items.length]);
1015
- const scrollDown = useCallback((amount = 3) => {
1226
+ const scrollDown = useCallback((amount = 5) => {
1016
1227
  setScrollOffset((prev) => {
1017
1228
  const next = Math.max(prev - amount, 0);
1018
1229
  if (next === 0)
@@ -1053,6 +1264,8 @@ function App({ config }) {
1053
1264
  const [thinkStart, setThinkStart] = useState(null);
1054
1265
  const [showBanner, setShowBanner] = useState(true);
1055
1266
  const [totalTokens, setTotalTokens] = useState(0);
1267
+ const [contextPercent, setContextPercent] = useState(0);
1268
+ const [rateLimitWarn, setRateLimitWarn] = useState(false);
1056
1269
  const [sessionList, setSessionList] = useState(null);
1057
1270
  const [showSetup, setShowSetup] = useState(false);
1058
1271
  const [currentMode, setCurrentMode] = useState(agent?.getPermissionMode() ?? 'default');
@@ -1065,6 +1278,7 @@ function App({ config }) {
1065
1278
  useEffect(() => { modelsRef.current = models; }, [models]);
1066
1279
  const nextId = () => ++counter.current;
1067
1280
  const MAX_DISPLAY_ITEMS = 2000;
1281
+ const TOOL_GROUP_THRESHOLD = 3;
1068
1282
  const add = useCallback((kind, text, meta, hint) => {
1069
1283
  setItems((prev) => {
1070
1284
  const next = [...prev, { id: nextId(), kind, text, meta, hint }];
@@ -1075,6 +1289,29 @@ function App({ config }) {
1075
1289
  setShowBanner(false);
1076
1290
  }
1077
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
+ }, []);
1078
1315
  // Register agent event handlers ONCE
1079
1316
  useEffect(() => {
1080
1317
  const onThinkingStart = () => {
@@ -1126,9 +1363,18 @@ function App({ config }) {
1126
1363
  streamBuffer = '';
1127
1364
  setStreamText('');
1128
1365
  setReasoningText('');
1366
+ collapseToolGroups();
1129
1367
  if (full.trim()) {
1130
1368
  add('assistant', full);
1131
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
+ }
1132
1378
  };
1133
1379
  let reasoningBuffer = '';
1134
1380
  let reasoningFlushTimer = null;
@@ -1198,6 +1444,8 @@ function App({ config }) {
1198
1444
  const lower = reason?.toLowerCase() || '';
1199
1445
  if (lower.includes('rate limit') || lower.includes('429')) {
1200
1446
  hint = 'Rate limited. Try /model to switch models, or wait a moment.';
1447
+ setRateLimitWarn(true);
1448
+ setTimeout(() => setRateLimitWarn(false), 120_000);
1201
1449
  }
1202
1450
  else if (lower.includes('auth') || lower.includes('401') || lower.includes('api key')) {
1203
1451
  hint = 'Authentication failed. Run /setup to reconfigure your API key.';
@@ -1217,6 +1465,7 @@ function App({ config }) {
1217
1465
  add('system', `Context compressed: ${formatTokens(info.saved)} tokens saved (${formatTokens(info.before)} \u2192 ${formatTokens(info.after)})`);
1218
1466
  };
1219
1467
  const onContextUsage = (info) => {
1468
+ setContextPercent(info.percentage);
1220
1469
  if (info.percentage > 80) {
1221
1470
  add('system', `Context usage: ${info.percentage}% (${formatTokens(info.tokens)}/${formatTokens(info.maxTokens)} tokens)`);
1222
1471
  }
@@ -1264,6 +1513,40 @@ function App({ config }) {
1264
1513
  currentAgent.off('context:usage', onContextUsage);
1265
1514
  };
1266
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(() => { });
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]);
1267
1550
  // Confirmation bridge (event-driven)
1268
1551
  useEffect(() => {
1269
1552
  const onPending = () => {
@@ -1279,8 +1562,19 @@ function App({ config }) {
1279
1562
  if (banner)
1280
1563
  add('system', banner);
1281
1564
  }, [add]);
1282
- const handleConfirm = useCallback((ok) => {
1565
+ const handleConfirm = useCallback((ok, always) => {
1283
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
+ }
1284
1578
  pendingConfirm.resolve(ok);
1285
1579
  pendingConfirm = null;
1286
1580
  }
@@ -1318,6 +1612,7 @@ function App({ config }) {
1318
1612
  ' /checkpoint \u2014 create git checkpoint',
1319
1613
  ' /undo \u2014 undo last checkpoint',
1320
1614
  ' /checkpoints \u2014 list recent checkpoints',
1615
+ ' /worktree \u2014 manage git worktrees (list/create/remove/cleanup)',
1321
1616
  ' /git \u2014 show git context',
1322
1617
  '',
1323
1618
  'Project:',
@@ -1339,6 +1634,8 @@ function App({ config }) {
1339
1634
  ' /setup \u2014 reconfigure API key, URL & models',
1340
1635
  ' /mcp <l> <u> \u2014 add MCP server',
1341
1636
  ' /plugins \u2014 show loaded plugins',
1637
+ ' /plugin \u2014 install/enable/disable/uninstall plugins',
1638
+ ' /notify \u2014 test terminal notification',
1342
1639
  ' /router \u2014 show model routing status',
1343
1640
  ' /replay \u2014 show session timeline',
1344
1641
  ' /hooks \u2014 show lifecycle hooks',
@@ -1380,6 +1677,8 @@ function App({ config }) {
1380
1677
  setShowBanner(true);
1381
1678
  costTracker.reset();
1382
1679
  setTotalTokens(0);
1680
+ setContextPercent(0);
1681
+ setRateLimitWarn(false);
1383
1682
  sessionId = generateSessionId();
1384
1683
  return true;
1385
1684
  }
@@ -1432,7 +1731,35 @@ function App({ config }) {
1432
1731
  });
1433
1732
  return true;
1434
1733
  }
1435
- 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
+ }
1436
1763
  listSessions().then((sessions) => {
1437
1764
  if (sessions.length === 0) {
1438
1765
  add('system', 'No saved sessions found.');
@@ -1443,20 +1770,43 @@ function App({ config }) {
1443
1770
  });
1444
1771
  return true;
1445
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
+ }
1446
1789
  if (cmd === '/shortcuts') {
1447
1790
  const shortcuts = [
1448
1791
  'Ctrl+C \u2014 interrupt agent / clear input / double-press to quit',
1449
1792
  'Ctrl+D \u2014 exit (when input is empty)',
1450
1793
  'Ctrl+L \u2014 clear screen & conversation',
1794
+ 'Ctrl+O \u2014 toggle collapsed tool groups',
1451
1795
  'Ctrl+U \u2014 clear entire input line',
1452
1796
  'Ctrl+W \u2014 delete last word',
1453
1797
  'Ctrl+K \u2014 delete to end of line',
1454
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)',
1455
1803
  'Tab \u2014 autocomplete slash commands',
1456
1804
  '\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',
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',
1460
1810
  'Esc \u2014 quit',
1461
1811
  '',
1462
1812
  'Copy \u2014 Cmd+C (Mac) / Ctrl+Shift+C (Linux)',
@@ -1468,6 +1818,32 @@ function App({ config }) {
1468
1818
  shortcuts.forEach((s) => add('system', s));
1469
1819
  return true;
1470
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
+ }
1471
1847
  if (cmd === '/settings') {
1472
1848
  setEditingSettings(true);
1473
1849
  return true;
@@ -1610,6 +1986,10 @@ function App({ config }) {
1610
1986
  commandsSummary(customCommands).split('\n').forEach((l) => add('system', l));
1611
1987
  return true;
1612
1988
  }
1989
+ if (cmd === '/skills') {
1990
+ skillsSummary(loadedSkills).split('\n').forEach((l) => add('system', l));
1991
+ return true;
1992
+ }
1613
1993
  if (cmd === '/delegate') {
1614
1994
  const prompt = text.slice(10).trim();
1615
1995
  if (!prompt) {
@@ -1677,6 +2057,130 @@ function App({ config }) {
1677
2057
  }).catch(() => add('error', 'Failed to list checkpoints.'));
1678
2058
  return true;
1679
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
+ }
1680
2184
  if (cmd === '/commit') {
1681
2185
  setIsLoading(true);
1682
2186
  setStatus('thinking');
@@ -1715,7 +2219,8 @@ function App({ config }) {
1715
2219
  }
1716
2220
  add('system', `Commit message: ${cleanMsg}`);
1717
2221
  try {
1718
- 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();
1719
2224
  add('system', result);
1720
2225
  }
1721
2226
  catch (e) {
@@ -2096,10 +2601,14 @@ function App({ config }) {
2096
2601
  useEffect(() => { isLoadingRef.current = isLoading; }, [isLoading]);
2097
2602
  const multilineRef = useRef([]);
2098
2603
  const messageQueueRef = useRef([]);
2604
+ const pendingImagesRef = useRef([]);
2099
2605
  const handleSubmit = useCallback(async (text) => {
2100
2606
  const trimmed = text.trim();
2101
2607
  if (!trimmed)
2102
2608
  return;
2609
+ // Reset idle nudge on any user activity
2610
+ lastActivityRef.current = Date.now();
2611
+ idleNudgedRef.current = false;
2103
2612
  // Allow /mode even while the agent is working
2104
2613
  if (isLoadingRef.current && trimmed.startsWith('/mode')) {
2105
2614
  handleCommand(trimmed);
@@ -2132,6 +2641,11 @@ function App({ config }) {
2132
2641
  }
2133
2642
  if (finalText.startsWith('/') && handleCommand(finalText))
2134
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
+ }
2135
2649
  if (finalText.startsWith('!')) {
2136
2650
  const shellCmd = finalText.slice(1).trim();
2137
2651
  if (!shellCmd)
@@ -2155,12 +2669,19 @@ function App({ config }) {
2155
2669
  if (correction) {
2156
2670
  addNote(process.cwd(), `[correction] ${correction}`).catch((err) => { logger.warn('Failed to save correction note', { error: err.message }); });
2157
2671
  }
2158
- add('user', finalText);
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]`);
2675
+ }
2676
+ else {
2677
+ add('user', finalText);
2678
+ }
2679
+ pendingImagesRef.current = [];
2159
2680
  setIsLoading(true);
2160
2681
  setStatus('thinking');
2161
2682
  setThinkStart(Date.now());
2162
2683
  try {
2163
- const result = await agent.send(finalText);
2684
+ const result = await agent.send(finalText, images);
2164
2685
  if (!result || !result.trim()) {
2165
2686
  add('system', 'Agent returned no response.');
2166
2687
  }
@@ -2250,6 +2771,8 @@ function App({ config }) {
2250
2771
  setShowBanner(true);
2251
2772
  costTracker.reset();
2252
2773
  setTotalTokens(0);
2774
+ setContextPercent(0);
2775
+ setRateLimitWarn(false);
2253
2776
  sessionId = generateSessionId();
2254
2777
  }, [models]);
2255
2778
  const handleSetupDone = useCallback((newConfig) => {
@@ -2300,7 +2823,7 @@ function App({ config }) {
2300
2823
  const reasonLines = reasoningText ? (reasoningText.match(/\n/g) || []).length + 1 : 0;
2301
2824
  return textLines + reasonLines;
2302
2825
  }, [isLoading, streamText, reasoningText]);
2303
- 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);
2304
2827
  const scrollToBottomRef = useRef(scrollToBottom);
2305
2828
  scrollToBottomRef.current = scrollToBottom;
2306
2829
  const handleSubmitRef = useRef(handleSubmit);
@@ -2312,7 +2835,7 @@ function App({ config }) {
2312
2835
  if (showSetup) {
2313
2836
  return _jsx(SetupScreen, { onComplete: handleSetupDone, existingConfig: config });
2314
2837
  }
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) => {
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) => {
2316
2839
  agent.setModel(v);
2317
2840
  setModels([v]);
2318
2841
  setSelectingModel(false);
@@ -2354,7 +2877,7 @@ function App({ config }) {
2354
2877
  }).catch(() => {
2355
2878
  add('error', 'Failed to load config.');
2356
2879
  });
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 })) })] }));
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); } })) })] }));
2358
2881
  }
2359
2882
  // ─── Root (config loading + setup gate) ───
2360
2883
  function Root() {