scalix 1.2.0 → 1.4.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, useInput, useApp, useStdout } from 'ink';
3
+ import { render, Box, Text, useInput, useApp } from 'ink';
4
4
  import Spinner from 'ink-spinner';
5
5
  import TextInput from 'ink-text-input';
6
6
  import Gradient from 'ink-gradient';
7
7
  import BigText from 'ink-big-text';
8
- import { execSync, execFileSync } from 'node:child_process';
8
+ import { execSync, execFileSync, execFile, spawn } from 'node:child_process';
9
+ import { promisify } from 'node:util';
9
10
  import os from 'node:os';
10
11
  import fs from 'node:fs/promises';
11
12
  import { existsSync, readFileSync } from 'node:fs';
@@ -42,7 +43,43 @@ import { loadSkills, skillsSummary, getSkillByTrigger, getSkillContent } from '.
42
43
  import { AgentRegistry } from './agent-registry.js';
43
44
  import { createWorktree, listWorktrees, removeWorktree, cleanupStaleWorktrees } from './worktree.js';
44
45
  import { sendNotification, getTerminalType } from './terminal-notify.js';
46
+ import { stringWidth, truncateText } from './text-utils.js';
47
+ import { isWorkspaceTrusted, trustWorkspace, hasProjectExecSurface } from './workspace-trust.js';
48
+ import { fetchMe, fetchCloudTools, groupCloudTools, CONSOLE_KEYS_URL } from './cloud.js';
45
49
  logger.setLevel('silent');
50
+ // Promisified async child_process helpers (keep the TUI responsive vs execSync).
51
+ const execFileAsync = promisify(execFile);
52
+ // Run a shell command asynchronously, resolving with stdout/stderr like execSync's
53
+ // error shape (so callers can read err.stdout / err.stderr). Never blocks the event loop.
54
+ function runShellAsync(command, opts = {}) {
55
+ return new Promise((resolve, reject) => {
56
+ const child = spawn(command, {
57
+ shell: true,
58
+ cwd: opts.cwd ?? process.cwd(),
59
+ timeout: opts.timeout,
60
+ });
61
+ let stdout = '';
62
+ let stderr = '';
63
+ child.stdout?.on('data', (d) => { stdout += d.toString(); });
64
+ child.stderr?.on('data', (d) => { stderr += d.toString(); });
65
+ child.on('error', (err) => { err.stdout = stdout; err.stderr = stderr; reject(err); });
66
+ child.on('close', (code) => {
67
+ if (code === 0)
68
+ resolve({ stdout, stderr });
69
+ else {
70
+ const err = new Error(`Command failed: ${command}`);
71
+ err.code = code;
72
+ err.stdout = stdout;
73
+ err.stderr = stderr;
74
+ reject(err);
75
+ }
76
+ });
77
+ if (opts.input !== undefined) {
78
+ child.stdin?.write(opts.input);
79
+ child.stdin?.end();
80
+ }
81
+ });
82
+ }
46
83
  setSandboxRoot(process.cwd());
47
84
  // Suppress noisy React 19 development-mode reconciler warnings that leak to stderr
48
85
  const _origConsoleError = console.error;
@@ -51,28 +88,47 @@ console.error = (...args) => {
51
88
  return;
52
89
  _origConsoleError(...args);
53
90
  };
54
- // Disable bracketed paste mode — terminals (via zsh) may leave it enabled,
55
- // which wraps pasted text in escape sequences that corrupt TextInput values.
56
- process.stdout.write('\x1b[?2004l');
91
+ // Enable bracketed paste mode so multi-line pastes arrive as a single chunk
92
+ // wrapped in \x1b[200~ … \x1b[201~ markers. We strip those markers (see
93
+ // sanitizeInput) and buffer the pasted content into the input value instead of
94
+ // treating each embedded newline as a separate Enter (which caused many submits).
95
+ process.stdout.write('\x1b[?2004h');
96
+ // User-registered external MCP servers (mcp-services.json). These are
97
+ // decorative config entries only — they are filtered out of API requests by
98
+ // transformToolsForApi. Scalix Cloud platform tools do NOT use this path: they
99
+ // are fetched from the account's catalog and registered as first-class
100
+ // function tools via agent.refreshCloudTools() (see connectCloud below).
57
101
  const mcpTools = loadMcpServices();
58
102
  let pluginTools = [];
59
103
  const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...mcpTools];
60
104
  // ─── Confirmation bridge (event-driven) ───
61
105
  import { EventEmitter } from 'node:events';
62
106
  const confirmEmitter = new EventEmitter();
63
- let pendingConfirm = null;
107
+ // FIFO queue of pending confirmations. Using a queue (instead of a single slot)
108
+ // ensures a second concurrent confirm can't overwrite/hang the first — each
109
+ // resolves in order and the next one is surfaced to the UI when the prior clears.
110
+ const confirmQueue = [];
111
+ // Resolve every queued confirmation (as denied) — used on abort / clear so no
112
+ // tool call is left hanging waiting for a decision that will never come.
113
+ function drainConfirmQueue(value = false) {
114
+ while (confirmQueue.length > 0) {
115
+ confirmQueue.shift().resolve(value);
116
+ }
117
+ }
64
118
  const alwaysAllowedTools = new Set();
65
119
  const recentDenials = [];
66
120
  const confirmToolCall = async (toolName, args) => {
121
+ // Evaluate scoped permissions BEFORE consulting the session "Always allow" set,
122
+ // so a scoped `deny` still blocks even after the user pressed "Always".
123
+ const scoped = checkScopedPermission(toolName, args);
124
+ if (scoped === 'deny')
125
+ return false;
67
126
  if (alwaysAllowedTools.has(toolName))
68
127
  return true;
69
- const scoped = checkScopedPermission(toolName, args);
70
128
  if (scoped === 'allow')
71
129
  return true;
72
- if (scoped === 'deny')
73
- return false;
74
130
  return new Promise((resolve) => {
75
- pendingConfirm = { resolve, toolName, args };
131
+ confirmQueue.push({ resolve, toolName, args });
76
132
  confirmEmitter.emit('pending');
77
133
  });
78
134
  };
@@ -85,6 +141,68 @@ let loadedPlugins = [];
85
141
  let projectMemory = null;
86
142
  let customCommands = [];
87
143
  let loadedSkills = [];
144
+ // Set during initAgent when the workspace is untrusted but has project-local
145
+ // exec surface (plugins/hooks/rules/settings) that we skipped loading. The App
146
+ // reads this on mount to inform the user they can run /trust to enable them.
147
+ let workspaceUntrustedNotice = false;
148
+ let cloudConnection = { connected: false, identity: null, toolCount: 0 };
149
+ // Set by the setup wizard when its Scalix Cloud validation step succeeded, so
150
+ // the App shows the "Try:" teach-in exactly once after a wizard or /login
151
+ // connect (not on every ordinary startup reconnect).
152
+ let cloudTeachInPending = false;
153
+ // Validate the key against /v1/me, then fetch the account's tool catalog and
154
+ // register it on the agent. One attempt, no retry loop: failure leaves the
155
+ // agent with local tools only and carries the server's own error message.
156
+ async function connectCloud(targetAgent) {
157
+ if (!targetAgent.getApiKey()) {
158
+ cloudConnection = { connected: false, identity: null, toolCount: 0, error: 'no API key configured' };
159
+ return cloudConnection;
160
+ }
161
+ try {
162
+ const identity = await fetchMe(targetAgent.getBaseUrl() || DEFAULT_BASE_URL, targetAgent.getApiKey());
163
+ const status = await targetAgent.refreshCloudTools();
164
+ if (status.state === 'connected') {
165
+ cloudConnection = { connected: true, identity, toolCount: status.toolCount };
166
+ }
167
+ else {
168
+ cloudConnection = { connected: false, identity, toolCount: 0, error: status.error };
169
+ }
170
+ }
171
+ catch (err) {
172
+ // /v1/me failed. Run the catalog fetch anyway: on failure it clears any
173
+ // stale platform tools from a previous connect; in the rare case it
174
+ // succeeds, the tools genuinely work and we say so (identity unavailable).
175
+ const status = await targetAgent.refreshCloudTools().catch(() => null);
176
+ if (status?.state === 'connected') {
177
+ cloudConnection = { connected: true, identity: null, toolCount: status.toolCount, error: err?.message || String(err) };
178
+ }
179
+ else {
180
+ cloudConnection = { connected: false, identity: null, toolCount: 0, error: err?.message || String(err) };
181
+ }
182
+ }
183
+ return cloudConnection;
184
+ }
185
+ const CLOUD_TRY_PROMPTS = [
186
+ 'deploy this app to Scalix Run',
187
+ 'create a table in my Scalix database and insert seed data',
188
+ 'list my storage buckets and upload dist/ to one',
189
+ ];
190
+ // Discover and register project-local plugin tools for the given agent.
191
+ // Gated on workspace trust; reused by initAgent and the /trust command.
192
+ function discoverPluginsForAgent(targetAgent) {
193
+ loadedPlugins = [];
194
+ if (!isWorkspaceTrusted(process.cwd()))
195
+ return Promise.resolve();
196
+ return discoverPlugins(process.cwd()).then((plugins) => {
197
+ loadedPlugins = plugins;
198
+ const pTools = getPluginTools(plugins);
199
+ pluginTools = pTools;
200
+ for (const pt of pTools)
201
+ targetAgent.addTool(pt);
202
+ }).catch((err) => {
203
+ logger.warn('Plugin discovery failed', { error: err.message });
204
+ });
205
+ }
88
206
  async function buildEnhancedInstructions() {
89
207
  let instructions = baseInstructions;
90
208
  const cwd = process.cwd();
@@ -114,13 +232,17 @@ async function buildEnhancedInstructions() {
114
232
  instructions += '\n\n' + indexToContext(projectIndex);
115
233
  }
116
234
  catch { }
117
- try {
118
- const rules = await loadScopedRules(cwd);
119
- const rulesCtx = rulesToContext(rules);
120
- if (rulesCtx)
121
- instructions += '\n\n' + rulesCtx;
235
+ // Scoped rules are project-local content injected into the model context —
236
+ // only load them from a trusted workspace.
237
+ if (isWorkspaceTrusted(cwd)) {
238
+ try {
239
+ const rules = await loadScopedRules(cwd);
240
+ const rulesCtx = rulesToContext(rules);
241
+ if (rulesCtx)
242
+ instructions += '\n\n' + rulesCtx;
243
+ }
244
+ catch { }
122
245
  }
123
- catch { }
124
246
  return instructions;
125
247
  }
126
248
  function initAgent(config) {
@@ -152,16 +274,11 @@ function initAgent(config) {
152
274
  thinkingMode: config.thinkingMode || 'off',
153
275
  });
154
276
  const currentAgent = agent;
155
- loadedPlugins = [];
156
- discoverPlugins(process.cwd()).then((plugins) => {
157
- loadedPlugins = plugins;
158
- const pTools = getPluginTools(plugins);
159
- pluginTools = pTools;
160
- for (const pt of pTools)
161
- currentAgent.addTool(pt);
162
- }).catch((err) => {
163
- logger.warn('Plugin discovery failed', { error: err.message });
164
- });
277
+ // Project-local plugins/hooks/rules are exec surface — only load from a
278
+ // trusted workspace. When skipped but present, flag it so the App can nudge
279
+ // the user toward /trust.
280
+ workspaceUntrustedNotice = !isWorkspaceTrusted(process.cwd()) && hasProjectExecSurface(process.cwd());
281
+ discoverPluginsForAgent(currentAgent);
165
282
  buildEnhancedInstructions().then((enhanced) => {
166
283
  currentAgent.setInstructions(enhanced);
167
284
  }).catch((err) => {
@@ -179,22 +296,36 @@ function initAgent(config) {
179
296
  function shortModel(m) {
180
297
  return m.split('/').pop().replace(/:free$/, '').replace(/-instruct$/, '');
181
298
  }
299
+ const termSizeSubscribers = new Set();
300
+ let termSize = {
301
+ cols: process.stdout?.columns ?? 80,
302
+ rows: process.stdout?.rows ?? 24,
303
+ };
304
+ let termResizeListenerAttached = false;
305
+ function onTermResize() {
306
+ const cols = process.stdout?.columns ?? 80;
307
+ const rows = process.stdout?.rows ?? 24;
308
+ if (cols === termSize.cols && rows === termSize.rows)
309
+ return;
310
+ termSize = { cols, rows };
311
+ for (const notify of termSizeSubscribers)
312
+ notify();
313
+ }
314
+ function subscribeTermSize(cb) {
315
+ termSizeSubscribers.add(cb);
316
+ if (!termResizeListenerAttached) {
317
+ process.stdout?.on('resize', onTermResize);
318
+ termResizeListenerAttached = true;
319
+ }
320
+ return () => { termSizeSubscribers.delete(cb); };
321
+ }
182
322
  function useTermSize() {
183
- const { stdout } = useStdout();
184
- const [size, setSize] = useState({ cols: stdout?.columns ?? 80, rows: stdout?.rows ?? 24 });
323
+ const [size, setSize] = useState(termSize);
185
324
  useEffect(() => {
186
- const fn = () => {
187
- const newCols = stdout?.columns ?? 80;
188
- const newRows = stdout?.rows ?? 24;
189
- setSize((prev) => {
190
- if (prev.cols === newCols && prev.rows === newRows)
191
- return prev;
192
- return { cols: newCols, rows: newRows };
193
- });
194
- };
195
- stdout?.on('resize', fn);
196
- return () => { stdout?.off('resize', fn); };
197
- }, [stdout]);
325
+ // Sync any change that happened between initial render and subscribe.
326
+ setSize(termSize);
327
+ return subscribeTermSize(() => setSize(termSize));
328
+ }, []);
198
329
  return size;
199
330
  }
200
331
  function elapsed(start) {
@@ -219,17 +350,27 @@ function formatTokens(n) {
219
350
  const Banner = memo(function Banner() {
220
351
  return (_jsx(Box, { flexDirection: "column", alignItems: "center", marginBottom: 0, children: _jsx(Gradient, { name: "teen", children: _jsx(BigText, { text: PRODUCT_NAME.toLowerCase(), font: "tiny" }) }) }));
221
352
  });
222
- // Strip bracketed paste markers and control chars that leak through ink's parser
353
+ // Strip bracketed paste markers and control chars that leak through ink's parser.
354
+ // Keeps newlines (\x0a) and tabs (\x09) so pasted multi-line content is preserved
355
+ // and buffered into the input value rather than being submitted line-by-line.
223
356
  function sanitizeInput(v) {
224
- return v.replace(/\[20[01]~/g, '').replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '');
357
+ return v
358
+ // Full bracketed-paste markers (ESC [ 200~ / 201~) and the leftover form
359
+ // after ink strips the ESC byte ([200~ / [201~).
360
+ .replace(/\x1b?\[20[01]~/g, '')
361
+ // Strip control chars but preserve \t (\x09) and \n (\x0a).
362
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '');
225
363
  }
226
364
  async function tryPasteImage() {
227
365
  const platform = os.platform();
228
366
  const tmpFile = path.join(os.tmpdir(), `scalix-paste-${Date.now()}.png`);
367
+ // Quote the temp path so directories with spaces (e.g. "C:\Users\First Last")
368
+ // don't break the shell commands below.
369
+ const q = `"${tmpFile}"`;
229
370
  try {
230
371
  if (platform === 'darwin') {
231
372
  try {
232
- execSync(`pngpaste ${tmpFile}`, { stdio: 'pipe', timeout: 3000 });
373
+ execSync(`pngpaste ${q}`, { stdio: 'pipe', timeout: 3000 });
233
374
  }
234
375
  catch {
235
376
  const hasImage = execSync(`osascript -e 'clipboard info' 2>/dev/null | grep -q 'TIFF\\|PNG\\|JPEG' && echo yes || echo no`, { encoding: 'utf-8', timeout: 3000 }).trim();
@@ -238,8 +379,15 @@ async function tryPasteImage() {
238
379
  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
380
  }
240
381
  }
382
+ else if (platform === 'win32') {
383
+ // Save the clipboard image via PowerShell. Get-Clipboard -Format Image
384
+ // returns a System.Drawing.Bitmap; Save() writes PNG to the temp path.
385
+ const psScript = `$img = Get-Clipboard -Format Image; ` +
386
+ `if ($img -ne $null) { $img.Save('${tmpFile.replace(/'/g, "''")}'); }`;
387
+ execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', psScript], { stdio: 'pipe', timeout: 5000 });
388
+ }
241
389
  else {
242
- execSync(`xclip -selection clipboard -t image/png -o > ${tmpFile}`, { stdio: 'pipe', timeout: 3000 });
390
+ execSync(`xclip -selection clipboard -t image/png -o > ${q}`, { stdio: 'pipe', timeout: 3000 });
243
391
  }
244
392
  const { readFileSync, unlinkSync } = await import('node:fs');
245
393
  const data = readFileSync(tmpFile);
@@ -303,7 +451,7 @@ function formatContextSize(tokens) {
303
451
  return String(tokens);
304
452
  }
305
453
  function SetupScreen({ onComplete, existingConfig }) {
306
- const STEPS = ['api-key', 'base-url', 'models', 'context-window'];
454
+ const STEPS = ['api-key', 'base-url', 'cloud', 'models', 'context-window'];
307
455
  const [step, setStep] = useState('api-key');
308
456
  const [apiKey, setApiKey] = useState(existingConfig?.apiKey || process.env[ENV_API_KEY] || '');
309
457
  const [baseUrl, setBaseUrl] = useState(existingConfig?.baseUrl || DEFAULT_BASE_URL);
@@ -314,7 +462,33 @@ function SetupScreen({ onComplete, existingConfig }) {
314
462
  const [apiKeyError, setApiKeyError] = useState(false);
315
463
  const [fetchedModels, setFetchedModels] = useState([]);
316
464
  const [fetchingModels, setFetchingModels] = useState(false);
465
+ const [cloudCheck, setCloudCheck] = useState({ phase: 'checking' });
317
466
  const { exit } = useApp();
467
+ // Validate the key against Scalix Cloud when the cloud step is entered:
468
+ // /v1/me for identity/plan, then the tool catalog for the unlocked count.
469
+ useEffect(() => {
470
+ if (step !== 'cloud')
471
+ return;
472
+ let cancelled = false;
473
+ setCloudCheck({ phase: 'checking' });
474
+ (async () => {
475
+ const identity = await fetchMe(baseUrl, apiKey);
476
+ const catalog = await fetchCloudTools(baseUrl, apiKey, process.env.SCALIX_PROJECT);
477
+ return { identity, toolCount: catalog.tools.length };
478
+ })().then((result) => {
479
+ if (cancelled)
480
+ return;
481
+ cloudTeachInPending = true;
482
+ setCloudCheck({ phase: 'ok', identity: result.identity, toolCount: result.toolCount });
483
+ }).catch((err) => {
484
+ if (cancelled)
485
+ return;
486
+ setCloudCheck({ phase: 'fail', error: err?.message || String(err) });
487
+ });
488
+ return () => { cancelled = true; };
489
+ // apiKey/baseUrl are intentionally not deps: they are frozen once the user
490
+ // advances past base-url, and the step transition is the trigger.
491
+ }, [step]);
318
492
  const totalSteps = STEPS.length;
319
493
  const stepNumber = STEPS.indexOf(step) + 1;
320
494
  const goBack = useCallback(() => {
@@ -353,6 +527,8 @@ function SetupScreen({ onComplete, existingConfig }) {
353
527
  }, placeholder: "sk-or-v1-..." })] }), apiKeyError && (_jsx(Box, { marginTop: 0, paddingLeft: 2, children: _jsx(Text, { color: "yellow", dimColor: true, children: "API key is required." }) }))] })), step === 'base-url' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Base URL" }), _jsx(Text, { dimColor: true, children: "Scalix API endpoint (or any compatible URL)" }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "#20a0a0", bold: true, children: ['\u276F', " "] }), _jsx(TextInput, { value: baseUrl, onChange: (v) => setBaseUrl(sanitizeInput(v)), onSubmit: (v) => {
354
528
  const url = sanitizeInput(v).trim() || baseUrl;
355
529
  setBaseUrl(url);
530
+ // Model list loads in the background while the cloud step
531
+ // validates the key via /v1/me + the tool catalog.
356
532
  setFetchingModels(true);
357
533
  fetchModelSpecs(url, apiKey).then((result) => {
358
534
  if (result.length > 0) {
@@ -360,9 +536,19 @@ function SetupScreen({ onComplete, existingConfig }) {
360
536
  }
361
537
  }).catch((err) => { logger.warn('Failed to fetch model specs', { error: err.message }); }).finally(() => {
362
538
  setFetchingModels(false);
363
- setStep('models');
364
539
  });
365
- }, placeholder: DEFAULT_BASE_URL })] }), fetchingModels && (_jsx(Box, { paddingLeft: 2, marginTop: 1, children: _jsx(Text, { color: "#20a0a0", dimColor: true, children: "Fetching available models..." }) }))] })), step === 'models' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Models" }), fetchedModels.length > 0 && modelsMode === 'custom' && models.length === 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Select models from your API or type a custom model ID." }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(SelectInput, { items: [
540
+ setStep('cloud');
541
+ }, placeholder: DEFAULT_BASE_URL })] }), fetchingModels && (_jsx(Box, { paddingLeft: 2, marginTop: 1, children: _jsx(Text, { color: "#20a0a0", dimColor: true, children: "Fetching available models..." }) }))] })), step === 'cloud' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Scalix Cloud" }), cloudCheck.phase === 'checking' && (_jsxs(Box, { marginTop: 1, gap: 1, children: [_jsx(Text, { color: "#20a0a0", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: "Validating your API key with Scalix Cloud..." })] })), cloudCheck.phase === 'ok' && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "#30c090", paddingX: 2, marginTop: 1, alignSelf: "flex-start", children: [_jsxs(Text, { color: "#30c090", bold: true, children: ['✓', " Connected to Scalix Cloud"] }), _jsxs(Text, { children: ["Org: ", _jsx(Text, { color: "#20a0a0", children: cloudCheck.identity.identity.orgId })] }), _jsxs(Text, { children: ["Project: ", _jsx(Text, { color: "#20a0a0", children: cloudCheck.identity.identity.projectId || 'org-wide' })] }), _jsxs(Text, { children: ["Plan: ", _jsx(Text, { color: "#20a0a0", children: cloudCheck.identity.limits.plan })] }), _jsxs(Text, { children: ["Rate limit: ", _jsxs(Text, { color: "#20a0a0", children: [cloudCheck.identity.limits.rateLimitRps, " req/s"] })] }), _jsxs(Text, { color: "#30c090", bold: true, children: ['●', " ", cloudCheck.toolCount, " platform tools unlocked"] })] }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [{ label: '→ Continue', value: '__continue__' }], onSelect: () => setStep('models') }) })] })), cloudCheck.phase === 'fail' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", children: ['✘', " ", cloudCheck.error] }) }), _jsxs(Text, { dimColor: true, children: ["Create or check your key at ", CONSOLE_KEYS_URL] }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [
542
+ { label: 'Re-enter API key', value: '__reenter__' },
543
+ { label: 'Skip — continue without Scalix Cloud (local tools only)', value: '__skip__' },
544
+ ], onSelect: (item) => {
545
+ if (item.value === '__reenter__') {
546
+ setStep('api-key');
547
+ }
548
+ else {
549
+ setStep('models');
550
+ }
551
+ } }) })] }))] })), step === 'models' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Models" }), fetchedModels.length > 0 && modelsMode === 'custom' && models.length === 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Select models from your API or type a custom model ID." }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(SelectInput, { items: [
366
552
  ...fetchedModels.filter((fm) => !models.includes(fm.id)).map((fm) => ({
367
553
  label: ` ${fm.name || fm.id}`,
368
554
  value: fm.id,
@@ -458,7 +644,20 @@ const StatusBar = memo(function StatusBar({ status, model, toolCount, thinkStart
458
644
  const modeColor = mode === 'yolo' ? 'red' : mode === 'auto' ? 'yellow' : mode === 'plan' ? 'blue' : 'green';
459
645
  const statusIcon = status === 'thinking' ? '\u25D4' : status === 'error' ? '\u2718' : '\u25CF';
460
646
  const statusColor = status === 'thinking' ? 'yellow' : status === 'error' ? 'red' : 'green';
461
- const activeAgents = AgentRegistry.getInstance().listActive().length;
647
+ // Subscribe to registry changes so this memoized component re-renders when
648
+ // agents spawn/complete, instead of reading a stale count during render only.
649
+ const [activeAgents, setActiveAgents] = useState(() => AgentRegistry.getInstance().listActive().length);
650
+ useEffect(() => {
651
+ const registry = AgentRegistry.getInstance();
652
+ const refresh = () => setActiveAgents(registry.listActive().length);
653
+ refresh();
654
+ registry.on('agent:spawned', refresh);
655
+ registry.on('agent:completed', refresh);
656
+ return () => {
657
+ registry.off('agent:spawned', refresh);
658
+ registry.off('agent:completed', refresh);
659
+ };
660
+ }, []);
462
661
  const leftParts = [];
463
662
  leftParts.push(shortModel(model));
464
663
  if (tokens > 0)
@@ -474,8 +673,8 @@ const StatusBar = memo(function StatusBar({ status, model, toolCount, thinkStart
474
673
  const gap = Math.max(cols - leftText.length - rightLen - 8, 2);
475
674
  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] })] })] }));
476
675
  });
477
- const WelcomeScreen = memo(function WelcomeScreen() {
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)))] })] }));
676
+ const WelcomeScreen = memo(function WelcomeScreen({ cloud }) {
677
+ 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)))] }), _jsx(Box, { paddingLeft: 2, children: cloud.connected ? (_jsxs(Text, { color: "#20a0a0", children: ['☁', " Scalix Cloud ", '·', " ", cloud.identity ? `${cloud.identity.limits.plan} ${'·'} ` : '', cloud.toolCount, " platform tools"] })) : (_jsxs(Text, { dimColor: true, children: ['☁', " not connected ", '—', " /login to link your Scalix account"] })) })] }));
479
678
  });
480
679
  const UserMessage = memo(function UserMessage({ text }) {
481
680
  return (_jsxs(Box, { paddingX: 2, marginTop: 1, gap: 1, children: [_jsx(Text, { color: "magentaBright", bold: true, children: '\u276F' }), _jsx(Text, { wrap: "wrap", bold: true, children: text })] }));
@@ -610,15 +809,28 @@ function ConfirmPrompt({ toolName, args, onRespond, onScrollUp, onScrollDown })
610
809
  const isCommand = toolName === 'run_command';
611
810
  const isWrite = toolName === 'write_file' || toolName === 'edit_file' || toolName === 'notebook_edit';
612
811
  const isGit = toolName === 'git_commit';
812
+ // Flatten newlines to a single-line glyph so multi-line values (e.g. write_file
813
+ // content, edit_file old/new strings) can't shatter the box border.
814
+ const flatten = (s) => s.replace(/\r?\n/g, '\u23CE');
613
815
  const highlight = parsed.command ?? parsed.filePath ?? parsed.file_path ?? parsed.notebook_path ?? parsed.message ?? null;
614
816
  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));
817
+ const highlightStr = highlight ? flatten(String(highlight)) : null;
818
+ // Exclude large multi-line payloads (content + edit old/new strings) from the
819
+ // extra-field rows \u2014 showing them here just corrupts the box layout.
820
+ const extraFields = Object.entries(parsed).filter(([k]) => !['command', 'filePath', 'file_path', 'notebook_path', 'message', 'content',
821
+ 'oldString', 'newString', 'old_string', 'new_string'].includes(k));
617
822
  const boxWidth = Math.min(cols - 2, 80);
618
823
  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));
824
+ // Pad the interior of a box row to (boxWidth - 2) columns using display width
825
+ // (stringWidth), so wide/CJK chars keep the right border aligned. `used` is the
826
+ // display width already emitted between the two \u2502 borders.
827
+ const pad = (used) => ' '.repeat(Math.max(0, boxWidth - 2 - used));
828
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { color: "yellow", children: ['\u250C', border, '\u2510'] }), _jsxs(Text, { color: "yellow", children: ['\u2502', " ", _jsxs(Text, { color: "yellow", bold: true, children: ['\u26A0', " Permission Required"] }), _jsx(Text, { children: ' '.repeat(Math.max(0, boxWidth - 24)) }), '\u2502'] }), _jsxs(Text, { color: "yellow", children: ['\u2502', " ", _jsx(Text, { dimColor: true, children: "Tool:" }), " ", _jsx(Text, { color: "cyan", bold: true, children: toolName }), _jsx(Text, { children: pad(7 + stringWidth(toolName)) }), '\u2502'] }), highlightStr && (() => {
829
+ const shown = truncateText(highlightStr, boxWidth - highlightLabel.length - 6);
830
+ return (_jsxs(Text, { color: "yellow", children: ['\u2502', " ", _jsxs(Text, { dimColor: true, children: [highlightLabel, ":"] }), " ", _jsx(Text, { color: "white", bold: true, children: shown }), _jsx(Text, { children: pad(highlightLabel.length + 3 + stringWidth(shown)) }), '\u2502'] }));
831
+ })(), extraFields.slice(0, 3).map(([k, v]) => {
832
+ const val = truncateText(flatten(String(v)), boxWidth - k.length - 6);
833
+ return (_jsxs(Text, { color: "yellow", children: ['\u2502', " ", _jsxs(Text, { dimColor: true, children: [k, ":"] }), " ", _jsx(Text, { children: val }), _jsx(Text, { children: pad(k.length + 3 + stringWidth(val)) }), '\u2502'] }, k));
622
834
  }), _jsxs(Text, { color: "yellow", children: ['\u251C', border, '\u2524'] }), _jsxs(Text, { color: "yellow", children: ['\u2502', " ", _jsx(Text, { color: "green", bold: true, children: "[Y]" }), _jsx(Text, { children: " Allow " }), _jsx(Text, { color: "cyan", bold: true, children: "[A]" }), _jsx(Text, { children: " Always " }), _jsx(Text, { color: "red", bold: true, children: "[N]" }), _jsx(Text, { children: " Deny " }), _jsx(Text, { dimColor: true, children: "ESC deny" }), _jsx(Text, { children: ' '.repeat(Math.max(0, boxWidth - 42)) }), '\u2502'] }), _jsxs(Text, { color: "yellow", children: ['\u2514', border, '\u2518'] })] }));
623
835
  }
624
836
  function ModelPicker({ currentModels, onSelect, onCancel }) {
@@ -805,7 +1017,7 @@ function SettingsEditor({ onSave, onClose }) {
805
1017
  }
806
1018
  } })] }));
807
1019
  }
808
- const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, onClear, onScrollUp, onScrollDown, onHeightChange, onToggleToolGroups, onImagePaste }) {
1020
+ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, onClear, onScrollUp, onScrollDown, onHeightChange, onToggleToolGroups, onImagePaste, onExit }) {
809
1021
  const { exit } = useApp();
810
1022
  const { cols } = useTermSize();
811
1023
  const line = useMemo(() => '\u2500'.repeat(Math.max(cols - 6, 20)), [cols]);
@@ -837,6 +1049,9 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
837
1049
  setHistoryIndex(-1);
838
1050
  setValue('');
839
1051
  valueRef.current = '';
1052
+ // Clear image chips: pending images were consumed by this submit, so the
1053
+ // indicators shouldn't linger and imply they'll attach to the next message.
1054
+ setImageChips([]);
840
1055
  onSubmit(trimmed);
841
1056
  }, [onSubmit]);
842
1057
  useInput((ch, key) => {
@@ -851,16 +1066,20 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
851
1066
  onChange('');
852
1067
  return;
853
1068
  }
854
- setCtrlCCount((prev) => {
855
- if (prev >= 1) {
856
- exit();
857
- return 0;
858
- }
1069
+ // Keep the state updater pure — decide whether to exit outside of it,
1070
+ // so StrictMode's double-invoke can't trigger exit() on the first press.
1071
+ if (ctrlCCount >= 1) {
1072
+ if (ctrlCTimer.current)
1073
+ clearTimeout(ctrlCTimer.current);
1074
+ setCtrlCCount(0);
1075
+ exit();
1076
+ }
1077
+ else {
859
1078
  if (ctrlCTimer.current)
860
1079
  clearTimeout(ctrlCTimer.current);
861
1080
  ctrlCTimer.current = setTimeout(() => setCtrlCCount(0), 3000);
862
- return prev + 1;
863
- });
1081
+ setCtrlCCount(1);
1082
+ }
864
1083
  return;
865
1084
  }
866
1085
  // ── Ctrl+D: exit when input is empty ──
@@ -1002,13 +1221,34 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
1002
1221
  onChange(matches[0]);
1003
1222
  return;
1004
1223
  }
1005
- // ── Escape: close command picker or exit ──
1224
+ // ── Escape: close picker → clear input → abort task → exit ──
1006
1225
  if (key.escape) {
1226
+ // 1. @-file picker open → close it by removing the trailing @query.
1227
+ const atOpen = !!projectIndex && /@(\S*)$/.test(val);
1228
+ if (atOpen) {
1229
+ onChange(val.replace(/@\S*$/, ''));
1230
+ return;
1231
+ }
1232
+ // 2. Command picker open → close it.
1007
1233
  if (val.startsWith('/') && !val.includes(' ')) {
1008
1234
  onChange('');
1009
1235
  return;
1010
1236
  }
1011
- exit();
1237
+ // 3. Typed input present → clear it (don't lose work by exiting).
1238
+ if (val.length > 0) {
1239
+ onChange('');
1240
+ return;
1241
+ }
1242
+ // 4. A task is running → abort it rather than exit.
1243
+ if (disabled) {
1244
+ onAbort();
1245
+ return;
1246
+ }
1247
+ // 5. Nothing typed and idle → save session and exit (like /exit).
1248
+ if (onExit)
1249
+ onExit();
1250
+ else
1251
+ exit();
1012
1252
  return;
1013
1253
  }
1014
1254
  // ── When command picker is open, handle input manually since TextInput is unfocused ──
@@ -1092,10 +1332,13 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
1092
1332
  { cmd: '/remember', desc: 'Save a note about this project' },
1093
1333
  { cmd: '/replay', desc: 'Show session timeline' },
1094
1334
  { cmd: '/plugins', desc: 'Show loaded plugins' },
1335
+ { cmd: '/trust', desc: 'Trust this workspace (load project plugins/hooks/rules)' },
1095
1336
  { cmd: '/router', desc: 'Show model routing status' },
1096
1337
  { cmd: '/status', desc: 'Version, model & connection info' },
1097
1338
  { cmd: '/doctor', desc: 'Diagnose installation' },
1098
1339
  { cmd: '/mcp', desc: 'Add MCP server' },
1340
+ { cmd: '/cloud', desc: 'Scalix Cloud status, platform tools & refresh' },
1341
+ { cmd: '/login', desc: 'Link your Scalix account (API key)' },
1099
1342
  { cmd: '/setup', desc: 'Reconfigure API key, URL & models' },
1100
1343
  { cmd: '/settings', desc: 'View & configure limits' },
1101
1344
  { cmd: '/permissions', desc: 'View permission rules & recent denials' },
@@ -1179,10 +1422,11 @@ function estimateLines(item, cols) {
1179
1422
  // Account for paddingLeft=3 + paddingRight=2 used by most message components
1180
1423
  const textWidth = Math.max(cols - 7, 20);
1181
1424
  const lines = item.text.split('\n');
1182
- // Count wrapped lines per actual line
1425
+ // Count wrapped lines per actual line using display width (wide/CJK chars
1426
+ // occupy two columns), so scrollback estimates don't overflow.
1183
1427
  let totalLines = 0;
1184
1428
  for (const line of lines) {
1185
- totalLines += Math.max(1, Math.ceil((line.length || 1) / textWidth));
1429
+ totalLines += Math.max(1, Math.ceil((stringWidth(line) || 1) / textWidth));
1186
1430
  }
1187
1431
  switch (item.kind) {
1188
1432
  case 'user': return totalLines + 2; // marginTop=1 + prompt icon row
@@ -1272,6 +1516,7 @@ function App({ config }) {
1272
1516
  const [selectingMode, setSelectingMode] = useState(false);
1273
1517
  const [editingSettings, setEditingSettings] = useState(false);
1274
1518
  const [agentVersion, setAgentVersion] = useState(0);
1519
+ const [cloudInfo, setCloudInfo] = useState(cloudConnection);
1275
1520
  const counter = useRef(0);
1276
1521
  const showBannerRef = useRef(true);
1277
1522
  const modelsRef = useRef(models);
@@ -1388,9 +1633,13 @@ function App({ config }) {
1388
1633
  }
1389
1634
  };
1390
1635
  const onToolCall = (name, args) => {
1636
+ // A tool running is progress, not a stall — reset the timer so long
1637
+ // builds/tests don't trip the "Stream appears stalled" warning.
1638
+ resetStallTimer();
1391
1639
  add('tool-call', typeof args === 'string' ? args : JSON.stringify(args), name);
1392
1640
  };
1393
1641
  const onToolResult = (name, result) => {
1642
+ resetStallTimer();
1394
1643
  // Extract diff from write_file / edit_file results and show separately
1395
1644
  let parsed = null;
1396
1645
  if (typeof result === 'object' && result !== null) {
@@ -1521,6 +1770,40 @@ function App({ config }) {
1521
1770
  }
1522
1771
  }).catch(() => { });
1523
1772
  }, [add]);
1773
+ // Untrusted-workspace notice: project-local plugins/hooks/rules were skipped.
1774
+ useEffect(() => {
1775
+ if (workspaceUntrustedNotice) {
1776
+ add('system', 'This workspace has project plugins/hooks/rules that were skipped because the workspace is not trusted. Run /trust to load them.');
1777
+ }
1778
+ }, [add]);
1779
+ // Scalix Cloud: on agent start (and after /setup or /login re-init), fetch
1780
+ // the account's tool catalog and register it. Failure or no key → local
1781
+ // tools only, with exactly one honest notice. No retry loop.
1782
+ useEffect(() => {
1783
+ if (!agent)
1784
+ return;
1785
+ const hasKey = !!agent.getApiKey();
1786
+ let cancelled = false;
1787
+ connectCloud(agent).then((state) => {
1788
+ if (cancelled)
1789
+ return;
1790
+ setCloudInfo({ ...state });
1791
+ if (state.connected) {
1792
+ // Teach-in: only right after a wizard or /login connect, and only
1793
+ // when tools actually loaded.
1794
+ if (cloudTeachInPending && state.toolCount > 0) {
1795
+ cloudTeachInPending = false;
1796
+ const plan = state.identity ? ` ${'·'} ${state.identity.limits.plan}` : '';
1797
+ add('system', `☁ Connected to Scalix Cloud${plan} ${'·'} ${state.toolCount} platform tools`);
1798
+ add('system', `Try:\n${CLOUD_TRY_PROMPTS.map((p) => ` ❯ ${p}`).join('\n')}`);
1799
+ }
1800
+ }
1801
+ else if (hasKey) {
1802
+ add('system', `☁ Scalix Cloud unavailable — running with local tools only. ${state.error ?? ''}`.trimEnd());
1803
+ }
1804
+ });
1805
+ return () => { cancelled = true; };
1806
+ }, [add, agentVersion]);
1524
1807
  // Idle session nudge: suggest /clear after 75+ minutes of inactivity
1525
1808
  const lastActivityRef = useRef(Date.now());
1526
1809
  const idleNudgedRef = useRef(false);
@@ -1539,19 +1822,28 @@ function App({ config }) {
1539
1822
  useEffect(() => {
1540
1823
  if (!agent)
1541
1824
  return;
1825
+ // Capture the agent for cleanup so .off() detaches from the same instance
1826
+ // even after the agent is re-initialized (e.g. /setup).
1827
+ const currentAgent = agent;
1542
1828
  const onStreamEnd = () => {
1543
- if (!document.hasFocus?.() || process.env.SCALIX_NOTIFY !== '0') {
1829
+ // `document` doesn't exist in Node (referencing it threw a ReferenceError
1830
+ // on every stream:end). Gate purely on the env var; only consult a focus
1831
+ // check when running somewhere that actually has `document`.
1832
+ const doc = globalThis.document;
1833
+ const hasFocus = doc ? doc.hasFocus?.() : false;
1834
+ if (process.env.SCALIX_NOTIFY !== '0' && !hasFocus) {
1544
1835
  sendNotification('Scalix', 'Task complete');
1545
1836
  }
1546
1837
  };
1547
- agent.on('stream:end', onStreamEnd);
1548
- return () => { agent.off('stream:end', onStreamEnd); };
1838
+ currentAgent.on('stream:end', onStreamEnd);
1839
+ return () => { currentAgent.off('stream:end', onStreamEnd); };
1549
1840
  }, [agentVersion]);
1550
- // Confirmation bridge (event-driven)
1841
+ // Confirmation bridge (event-driven). Surfaces the head of the FIFO queue.
1551
1842
  useEffect(() => {
1552
1843
  const onPending = () => {
1553
- if (pendingConfirm && !confirmInfo) {
1554
- setConfirmInfo({ toolName: pendingConfirm.toolName, args: pendingConfirm.args });
1844
+ if (confirmQueue.length > 0 && !confirmInfo) {
1845
+ const head = confirmQueue[0];
1846
+ setConfirmInfo({ toolName: head.toolName, args: head.args });
1555
1847
  }
1556
1848
  };
1557
1849
  confirmEmitter.on('pending', onPending);
@@ -1563,22 +1855,24 @@ function App({ config }) {
1563
1855
  add('system', banner);
1564
1856
  }, [add]);
1565
1857
  const handleConfirm = useCallback((ok, always) => {
1566
- if (pendingConfirm) {
1858
+ const current = confirmQueue.shift();
1859
+ if (current) {
1567
1860
  if (ok && always)
1568
- alwaysAllowedTools.add(pendingConfirm.toolName);
1861
+ alwaysAllowedTools.add(current.toolName);
1569
1862
  if (!ok) {
1570
1863
  recentDenials.push({
1571
- toolName: pendingConfirm.toolName,
1572
- args: JSON.stringify(pendingConfirm.args).slice(0, 100),
1864
+ toolName: current.toolName,
1865
+ args: JSON.stringify(current.args).slice(0, 100),
1573
1866
  timestamp: Date.now(),
1574
1867
  });
1575
1868
  if (recentDenials.length > 20)
1576
1869
  recentDenials.shift();
1577
1870
  }
1578
- pendingConfirm.resolve(ok);
1579
- pendingConfirm = null;
1871
+ current.resolve(ok);
1580
1872
  }
1581
- setConfirmInfo(null);
1873
+ // Surface the next queued confirmation, if any.
1874
+ const next = confirmQueue[0];
1875
+ setConfirmInfo(next ? { toolName: next.toolName, args: next.args } : null);
1582
1876
  }, []);
1583
1877
  const handleCommand = useCallback((text) => {
1584
1878
  const cmd = text.split(' ')[0].toLowerCase();
@@ -1627,6 +1921,8 @@ function App({ config }) {
1627
1921
  '',
1628
1922
  'Tools & Config:',
1629
1923
  ' /copy \u2014 copy last response to clipboard',
1924
+ ' /cloud \u2014 Scalix Cloud status (/cloud tools, /cloud refresh)',
1925
+ ' /login \u2014 link your Scalix account (API key)',
1630
1926
  ' /status \u2014 show version, model & connection info',
1631
1927
  ' /update \u2014 check for and install updates',
1632
1928
  ' /doctor \u2014 diagnose installation',
@@ -1635,6 +1931,7 @@ function App({ config }) {
1635
1931
  ' /mcp <l> <u> \u2014 add MCP server',
1636
1932
  ' /plugins \u2014 show loaded plugins',
1637
1933
  ' /plugin \u2014 install/enable/disable/uninstall plugins',
1934
+ ' /trust \u2014 trust workspace (load project plugins/hooks/rules)',
1638
1935
  ' /notify \u2014 test terminal notification',
1639
1936
  ' /router \u2014 show model routing status',
1640
1937
  ' /replay \u2014 show session timeline',
@@ -1655,10 +1952,7 @@ function App({ config }) {
1655
1952
  }
1656
1953
  if (cmd === '/clear') {
1657
1954
  agent.abort();
1658
- if (pendingConfirm) {
1659
- pendingConfirm.resolve(false);
1660
- pendingConfirm = null;
1661
- }
1955
+ drainConfirmQueue(false);
1662
1956
  const msgs = agent.getMessages();
1663
1957
  if (msgs.length > 0) {
1664
1958
  saveSession(sessionId, msgs, models[0] || 'unknown', undefined, costTracker.getState()).then((meta) => {
@@ -1852,6 +2146,68 @@ function App({ config }) {
1852
2146
  setShowSetup(true);
1853
2147
  return true;
1854
2148
  }
2149
+ if (cmd === '/login') {
2150
+ // Key (re-)entry runs through the setup wizard, whose cloud step
2151
+ // performs the same fetchMe + catalog validation as first-run setup.
2152
+ setShowSetup(true);
2153
+ return true;
2154
+ }
2155
+ if (cmd === '/cloud') {
2156
+ const sub = text.split(' ')[1]?.toLowerCase() || 'status';
2157
+ if (sub === 'status') {
2158
+ const s = cloudConnection;
2159
+ if (s.connected) {
2160
+ add('system', `☁ Scalix Cloud ${'—'} connected`);
2161
+ if (s.identity) {
2162
+ add('system', `Org: ${s.identity.identity.orgId}`);
2163
+ add('system', `Project: ${s.identity.identity.projectId || agent.getCloudProjectId() || 'org-wide'}`);
2164
+ add('system', `Key: ${s.identity.identity.keyId} (${s.identity.permissions.role})`);
2165
+ add('system', `Plan: ${s.identity.limits.plan} ${'·'} ${s.identity.limits.rateLimitRps} req/s ${'·'} ${s.identity.limits.apiKeysMax} API keys max`);
2166
+ add('system', `Scopes: ${s.identity.permissions.scopes.join(', ') || 'none'}`);
2167
+ }
2168
+ else {
2169
+ add('system', `Identity unavailable: ${s.error ?? 'unknown error'}`);
2170
+ }
2171
+ add('system', `Platform tools: ${s.toolCount} registered ${'—'} /cloud tools to list them`);
2172
+ }
2173
+ else {
2174
+ add('system', `☁ not connected ${'—'} /login to link your Scalix account`);
2175
+ if (s.error)
2176
+ add('system', `Last error: ${s.error}`);
2177
+ }
2178
+ return true;
2179
+ }
2180
+ if (sub === 'tools') {
2181
+ const tools = agent.getCloudTools();
2182
+ if (tools.length === 0) {
2183
+ add('system', cloudConnection.connected
2184
+ ? 'No platform tools are registered for this key (its scopes unlock none).'
2185
+ : `☁ not connected ${'—'} /login to link your Scalix account`);
2186
+ return true;
2187
+ }
2188
+ add('system', `Platform tools (${tools.length}):`);
2189
+ for (const [group, groupTools] of groupCloudTools(tools)) {
2190
+ add('system', ` ${group} (${groupTools.length}): ${groupTools.map((t) => t.name).join(', ')}`);
2191
+ }
2192
+ return true;
2193
+ }
2194
+ if (sub === 'refresh') {
2195
+ add('system', 'Refreshing Scalix Cloud connection...');
2196
+ connectCloud(agent).then((state) => {
2197
+ setCloudInfo({ ...state });
2198
+ if (state.connected) {
2199
+ const plan = state.identity ? `${state.identity.limits.plan} plan ${'·'} ` : '';
2200
+ add('system', `☁ Connected ${'—'} ${plan}${state.toolCount} platform tools`);
2201
+ }
2202
+ else {
2203
+ add('error', `☁ Scalix Cloud unavailable: ${state.error ?? 'unknown error'}`);
2204
+ }
2205
+ });
2206
+ return true;
2207
+ }
2208
+ add('system', 'Usage: /cloud [status|tools|refresh]');
2209
+ return true;
2210
+ }
1855
2211
  if (cmd === '/mode') {
1856
2212
  const arg = text.split(' ')[1]?.toLowerCase();
1857
2213
  if (arg && PERMISSION_MODES.includes(arg)) {
@@ -1936,6 +2292,27 @@ function App({ config }) {
1936
2292
  add('system', pluginsSummary(loadedPlugins));
1937
2293
  return true;
1938
2294
  }
2295
+ if (cmd === '/trust') {
2296
+ const cwd = process.cwd();
2297
+ if (isWorkspaceTrusted(cwd)) {
2298
+ add('system', 'Workspace is already trusted.');
2299
+ return true;
2300
+ }
2301
+ trustWorkspace(cwd);
2302
+ workspaceUntrustedNotice = false;
2303
+ add('system', `Workspace trusted: ${cwd}`);
2304
+ // Re-run project-local discovery now that the workspace is trusted.
2305
+ discoverPluginsForAgent(agent).then(() => {
2306
+ add('system', `Loaded ${loadedPlugins.length} plugin(s).`);
2307
+ }).catch(() => { });
2308
+ buildEnhancedInstructions().then((enhanced) => {
2309
+ agent.setInstructions(enhanced);
2310
+ add('system', 'Reloaded project rules into context.');
2311
+ }).catch(() => { });
2312
+ loadCustomCommands(cwd).then((cmds) => { customCommands = cmds; }).catch(() => { });
2313
+ loadSkills(cwd).then((skills) => { loadedSkills = skills; }).catch(() => { });
2314
+ return true;
2315
+ }
1939
2316
  if (cmd === '/replay') {
1940
2317
  const replay = agent.getReplay();
1941
2318
  const timeline = replay.getTimeline();
@@ -2188,17 +2565,18 @@ function App({ config }) {
2188
2565
  add('system', 'Generating commit...');
2189
2566
  (async () => {
2190
2567
  try {
2568
+ const gitOpts = { timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() };
2191
2569
  // Check for staged changes
2192
2570
  let diff = '';
2193
2571
  try {
2194
- diff = execSync('git diff --staged', { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() }).trim();
2572
+ diff = (await execFileAsync('git', ['diff', '--staged'], gitOpts)).stdout.trim();
2195
2573
  }
2196
2574
  catch { }
2197
2575
  if (!diff) {
2198
2576
  // Nothing staged — stage tracked files only (safer than -A)
2199
2577
  try {
2200
- execSync('git add -u', { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() });
2201
- diff = execSync('git diff --staged', { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() }).trim();
2578
+ await execFileAsync('git', ['add', '-u'], gitOpts);
2579
+ diff = (await execFileAsync('git', ['diff', '--staged'], gitOpts)).stdout.trim();
2202
2580
  }
2203
2581
  catch (e) {
2204
2582
  add('error', `Failed to stage files: ${e.message}`);
@@ -2220,7 +2598,7 @@ function App({ config }) {
2220
2598
  add('system', `Commit message: ${cleanMsg}`);
2221
2599
  try {
2222
2600
  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();
2601
+ const result = (await execFileAsync('git', ['commit', '-m', fullMsg], gitOpts)).stdout.trim();
2224
2602
  add('system', result);
2225
2603
  }
2226
2604
  catch (e) {
@@ -2245,26 +2623,27 @@ function App({ config }) {
2245
2623
  add('system', 'Running code review...');
2246
2624
  (async () => {
2247
2625
  try {
2626
+ const gitOpts = { timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() };
2248
2627
  let diff = '';
2249
2628
  let baseBranch = 'main';
2250
2629
  try {
2251
- baseBranch = execSync('git symbolic-ref refs/remotes/origin/HEAD', { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() }).trim().replace('refs/remotes/origin/', '');
2630
+ baseBranch = (await execFileAsync('git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], gitOpts)).stdout.trim().replace('refs/remotes/origin/', '');
2252
2631
  }
2253
2632
  catch {
2254
2633
  try {
2255
- const branches = execSync('git branch --list main master', { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() }).trim();
2634
+ const branches = (await execFileAsync('git', ['branch', '--list', 'main', 'master'], gitOpts)).stdout.trim();
2256
2635
  if (branches.includes('master'))
2257
2636
  baseBranch = 'master';
2258
2637
  }
2259
2638
  catch { }
2260
2639
  }
2261
2640
  try {
2262
- diff = execSync(`git diff ${baseBranch}...HEAD`, { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() }).trim();
2641
+ diff = (await execFileAsync('git', ['diff', `${baseBranch}...HEAD`], gitOpts)).stdout.trim();
2263
2642
  }
2264
2643
  catch { }
2265
2644
  if (!diff) {
2266
2645
  try {
2267
- diff = execSync('git diff', { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() }).trim();
2646
+ diff = (await execFileAsync('git', ['diff'], gitOpts)).stdout.trim();
2268
2647
  }
2269
2648
  catch { }
2270
2649
  }
@@ -2300,7 +2679,8 @@ function App({ config }) {
2300
2679
  const MAX_RETRIES = 5;
2301
2680
  for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
2302
2681
  try {
2303
- const output = execSync(watchCmd, { encoding: 'utf-8', timeout: TOOL_DEFAULTS.maxCommandTimeoutMs, cwd: process.cwd() }).trim();
2682
+ const { stdout } = await runShellAsync(watchCmd, { timeout: TOOL_DEFAULTS.maxCommandTimeoutMs, cwd: process.cwd() });
2683
+ const output = stdout.trim();
2304
2684
  if (output)
2305
2685
  add('system', output.length > 2000 ? output.slice(0, 2000) + '\n... (truncated)' : output);
2306
2686
  add('system', `Watch: command succeeded on attempt ${attempt}.`);
@@ -2351,30 +2731,33 @@ function App({ config }) {
2351
2731
  return true;
2352
2732
  }
2353
2733
  if (cmd === '/diff') {
2354
- try {
2355
- let diff = '';
2356
- try {
2357
- diff = execSync('git diff', { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() }).trim();
2358
- }
2359
- catch { }
2360
- let staged = '';
2734
+ (async () => {
2361
2735
  try {
2362
- staged = execSync('git diff --staged', { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() }).trim();
2363
- }
2364
- catch { }
2365
- if (staged)
2366
- diff = (diff ? diff + '\n' : '') + '--- Staged changes ---\n' + staged;
2367
- if (!diff) {
2368
- add('system', 'No uncommitted changes.');
2736
+ const gitOpts = { timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() };
2737
+ let diff = '';
2738
+ try {
2739
+ diff = (await execFileAsync('git', ['diff'], gitOpts)).stdout.trim();
2740
+ }
2741
+ catch { }
2742
+ let staged = '';
2743
+ try {
2744
+ staged = (await execFileAsync('git', ['diff', '--staged'], gitOpts)).stdout.trim();
2745
+ }
2746
+ catch { }
2747
+ if (staged)
2748
+ diff = (diff ? diff + '\n' : '') + '--- Staged changes ---\n' + staged;
2749
+ if (!diff) {
2750
+ add('system', 'No uncommitted changes.');
2751
+ }
2752
+ else {
2753
+ const truncated = diff.length > 5000 ? diff.slice(0, 5000) + '\n... (truncated, full diff is ' + diff.length + ' chars)' : diff;
2754
+ truncated.split('\n').forEach((l) => add('system', l));
2755
+ }
2369
2756
  }
2370
- else {
2371
- const truncated = diff.length > 5000 ? diff.slice(0, 5000) + '\n... (truncated, full diff is ' + diff.length + ' chars)' : diff;
2372
- truncated.split('\n').forEach((l) => add('system', l));
2757
+ catch (e) {
2758
+ add('error', `Failed to get diff: ${e.message}`);
2373
2759
  }
2374
- }
2375
- catch (e) {
2376
- add('error', `Failed to get diff: ${e.message}`);
2377
- }
2760
+ })();
2378
2761
  return true;
2379
2762
  }
2380
2763
  if (cmd === '/copy') {
@@ -2424,6 +2807,9 @@ function App({ config }) {
2424
2807
  add('system', `${PRODUCT_NAME} v${VERSION}`);
2425
2808
  add('system', `Model: ${modelsStr}`);
2426
2809
  add('system', `API: ${baseUrl}`);
2810
+ add('system', cloudConnection.connected
2811
+ ? `Cloud: ☁ Scalix Cloud ${'·'} ${cloudConnection.identity ? `${cloudConnection.identity.limits.plan} ${'·'} ` : ''}${cloudConnection.toolCount} platform tools`
2812
+ : `Cloud: ☁ not connected ${'—'} /login to link your Scalix account`);
2427
2813
  add('system', `Mode: ${mode}`);
2428
2814
  add('system', `Context: ${ctx.percentage}% (${formatTokens(ctx.tokens)} / ${formatTokens(ctx.maxTokens)})`);
2429
2815
  add('system', `Messages: ${msgCount}`);
@@ -2602,43 +2988,13 @@ function App({ config }) {
2602
2988
  const multilineRef = useRef([]);
2603
2989
  const messageQueueRef = useRef([]);
2604
2990
  const pendingImagesRef = useRef([]);
2605
- const handleSubmit = useCallback(async (text) => {
2606
- const trimmed = text.trim();
2607
- if (!trimmed)
2608
- return;
2609
- // Reset idle nudge on any user activity
2610
- lastActivityRef.current = Date.now();
2611
- idleNudgedRef.current = false;
2612
- // Allow /mode even while the agent is working
2613
- if (isLoadingRef.current && trimmed.startsWith('/mode')) {
2614
- handleCommand(trimmed);
2615
- return;
2616
- }
2617
- // Queue messages while agent is working
2618
- if (isLoadingRef.current) {
2619
- messageQueueRef.current.push(trimmed);
2620
- add('system', `Queued (${messageQueueRef.current.length}): ${truncate(trimmed, 60)}`);
2621
- return;
2622
- }
2623
- if (trimmed.endsWith('\\')) {
2624
- if (multilineRef.current.length >= 50) {
2625
- add('system', 'Multiline limit reached (50 lines). Sending now.');
2626
- }
2627
- else {
2628
- multilineRef.current.push(trimmed.slice(0, -1));
2629
- add('system', '... (continue typing, submit without \\ to send)');
2630
- return;
2631
- }
2632
- }
2633
- let finalText;
2634
- if (multilineRef.current.length > 0) {
2635
- multilineRef.current.push(trimmed);
2636
- finalText = multilineRef.current.join('\n');
2637
- multilineRef.current = [];
2638
- }
2639
- else {
2640
- finalText = trimmed;
2641
- }
2991
+ // Process ONE finalized user message through the full input pipeline:
2992
+ // slash-command → `!` shell → skill/correction → send to agent. Shared by the
2993
+ // main submit path and the queue-drain loop so queued /compact, /clear, !ls,
2994
+ // etc. behave identically to freshly typed input. Returns true if the message
2995
+ // was handled inline (command/shell) and did NOT go to the agent.
2996
+ const processFinalizedInput = useCallback(async (rawFinalText) => {
2997
+ let finalText = rawFinalText;
2642
2998
  if (finalText.startsWith('/') && handleCommand(finalText))
2643
2999
  return;
2644
3000
  const triggeredSkill = getSkillByTrigger(loadedSkills, finalText);
@@ -2652,7 +3008,8 @@ function App({ config }) {
2652
3008
  return;
2653
3009
  add('system', `$ ${shellCmd}`);
2654
3010
  try {
2655
- let output = execSync(shellCmd, { encoding: 'utf-8', timeout: HEADLESS_DEFAULTS.commandTimeoutMs, cwd: process.cwd() }).trim();
3011
+ const { stdout } = await runShellAsync(shellCmd, { timeout: HEADLESS_DEFAULTS.commandTimeoutMs, cwd: process.cwd() });
3012
+ let output = stdout.trim();
2656
3013
  if (output) {
2657
3014
  if (output.length > 2000) {
2658
3015
  output = output.slice(0, 2000) + `\n... (${(output.length - 2000).toLocaleString()} chars truncated)`;
@@ -2677,7 +3034,6 @@ function App({ config }) {
2677
3034
  add('user', finalText);
2678
3035
  }
2679
3036
  pendingImagesRef.current = [];
2680
- setIsLoading(true);
2681
3037
  setStatus('thinking');
2682
3038
  setThinkStart(Date.now());
2683
3039
  try {
@@ -2689,30 +3045,73 @@ function App({ config }) {
2689
3045
  catch (e) {
2690
3046
  add('error', e.message || 'Unknown error');
2691
3047
  }
3048
+ }, [add, handleCommand]);
3049
+ const handleSubmit = useCallback(async (text) => {
3050
+ const trimmed = text.trim();
3051
+ if (!trimmed)
3052
+ return;
3053
+ // Reset idle nudge on any user activity
3054
+ lastActivityRef.current = Date.now();
3055
+ idleNudgedRef.current = false;
3056
+ // Allow /mode even while the agent is working
3057
+ if (isLoadingRef.current && trimmed.startsWith('/mode')) {
3058
+ handleCommand(trimmed);
3059
+ return;
3060
+ }
3061
+ // Queue messages while agent is working
3062
+ if (isLoadingRef.current) {
3063
+ messageQueueRef.current.push(trimmed);
3064
+ add('system', `Queued (${messageQueueRef.current.length}): ${truncate(trimmed, 60)}`);
3065
+ return;
3066
+ }
3067
+ if (trimmed.endsWith('\\')) {
3068
+ if (multilineRef.current.length >= 50) {
3069
+ add('system', 'Multiline limit reached (50 lines). Sending now.');
3070
+ }
3071
+ else {
3072
+ multilineRef.current.push(trimmed.slice(0, -1));
3073
+ add('system', '... (continue typing, submit without \\ to send)');
3074
+ return;
3075
+ }
3076
+ }
3077
+ let finalText;
3078
+ if (multilineRef.current.length > 0) {
3079
+ multilineRef.current.push(trimmed);
3080
+ finalText = multilineRef.current.join('\n');
3081
+ multilineRef.current = [];
3082
+ }
3083
+ else {
3084
+ finalText = trimmed;
3085
+ }
3086
+ // Inline commands/shell that never enter the loading state and don't touch
3087
+ // the queue: run them and return without flipping isLoading.
3088
+ if (finalText.startsWith('/') && handleCommand(finalText))
3089
+ return;
3090
+ if (finalText.startsWith('!')) {
3091
+ await processFinalizedInput(finalText);
3092
+ return;
3093
+ }
3094
+ setIsLoading(true);
3095
+ setStatus('thinking');
3096
+ setThinkStart(Date.now());
3097
+ try {
3098
+ await processFinalizedInput(finalText);
3099
+ }
2692
3100
  finally {
2693
- // Process queued messages sequentially
3101
+ // Process queued messages sequentially through the SAME pipeline, so a
3102
+ // queued /compact, /clear, !ls, etc. behaves like freshly typed input.
2694
3103
  while (messageQueueRef.current.length > 0) {
2695
3104
  const next = messageQueueRef.current.shift();
2696
3105
  const remaining = messageQueueRef.current.length;
2697
3106
  if (remaining > 0)
2698
3107
  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
- }
3108
+ await processFinalizedInput(next);
2710
3109
  }
2711
3110
  setIsLoading(false);
2712
3111
  setStatus('ready');
2713
3112
  setThinkStart(null);
2714
3113
  }
2715
- }, [add, handleCommand]);
3114
+ }, [add, handleCommand, processFinalizedInput]);
2716
3115
  const handleSessionSelect = useCallback((session) => {
2717
3116
  setSessionList(null);
2718
3117
  setIsLoading(true);
@@ -2748,10 +3147,7 @@ function App({ config }) {
2748
3147
  }, [add]);
2749
3148
  const handleAbort = useCallback(() => {
2750
3149
  agent.abort();
2751
- if (pendingConfirm) {
2752
- pendingConfirm.resolve(false);
2753
- pendingConfirm = null;
2754
- }
3150
+ drainConfirmQueue(false);
2755
3151
  setConfirmInfo(null);
2756
3152
  setIsLoading(false);
2757
3153
  setStatus('ready');
@@ -2775,6 +3171,19 @@ function App({ config }) {
2775
3171
  setRateLimitWarn(false);
2776
3172
  sessionId = generateSessionId();
2777
3173
  }, [models]);
3174
+ // Save the session (with the current model) and exit — mirrors /exit so that
3175
+ // Escape on an empty, idle prompt doesn't lose work.
3176
+ const handleExit = useCallback(() => {
3177
+ const msgs = agent?.getMessages() ?? [];
3178
+ if (msgs.length > 0) {
3179
+ saveSession(sessionId, msgs, models[0] || 'unknown', undefined, costTracker.getState())
3180
+ .catch((err) => { logger.warn('Failed to save session on exit', { error: err.message }); })
3181
+ .finally(() => process.exit(0));
3182
+ }
3183
+ else {
3184
+ process.exit(0);
3185
+ }
3186
+ }, [models]);
2778
3187
  const handleSetupDone = useCallback((newConfig) => {
2779
3188
  setShowSetup(false);
2780
3189
  try {
@@ -2800,7 +3209,7 @@ function App({ config }) {
2800
3209
  catch {
2801
3210
  parsed = {};
2802
3211
  }
2803
- const extraCount = Math.min(Object.keys(parsed).filter(k => !['command', 'filePath', 'file_path', 'notebook_path', 'message', 'content'].includes(k)).length, 3);
3212
+ const extraCount = Math.min(Object.keys(parsed).filter(k => !['command', 'filePath', 'file_path', 'notebook_path', 'message', 'content', 'oldString', 'newString', 'old_string', 'new_string'].includes(k)).length, 3);
2804
3213
  const hasHighlight = !!(parsed.command ?? parsed.filePath ?? parsed.file_path ?? parsed.notebook_path ?? parsed.message);
2805
3214
  return 5 + (hasHighlight ? 1 : 0) + extraCount;
2806
3215
  }
@@ -2835,7 +3244,7 @@ function App({ config }) {
2835
3244
  if (showSetup) {
2836
3245
  return _jsx(SetupScreen, { onComplete: handleSetupDone, existingConfig: config });
2837
3246
  }
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) => {
3247
+ 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 + cloudInfo.toolCount, 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, { cloud: cloudInfo }), !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) => {
2839
3248
  agent.setModel(v);
2840
3249
  setModels([v]);
2841
3250
  setSelectingModel(false);
@@ -2877,7 +3286,7 @@ function App({ config }) {
2877
3286
  }).catch(() => {
2878
3287
  add('error', 'Failed to load config.');
2879
3288
  });
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); } })) })] }));
3289
+ }, onClose: () => setEditingSettings(false) })) : sessionList ? (_jsx(SessionPicker, { sessions: sessionList, onSelect: handleSessionSelect, onCancel: () => setSessionList(null) })) : (_jsx(InputSection, { onSubmit: handleInputSubmit, disabled: isLoading, onAbort: handleAbort, onClear: handleClearScreen, onScrollUp: scrollUp, onScrollDown: scrollDown, onHeightChange: handleInputHeightChange, onToggleToolGroups: toggleToolGroups, onImagePaste: (dataUrl) => { pendingImagesRef.current.push(dataUrl); }, onExit: handleExit })) })] }));
2881
3290
  }
2882
3291
  // ─── Root (config loading + setup gate) ───
2883
3292
  function Root() {