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/CHANGELOG.md +27 -0
- package/README.md +13 -2
- package/bin/scalix.js +8 -0
- package/dist/agent.js +148 -32
- package/dist/api-client.js +49 -11
- package/dist/auto-verify.js +3 -1
- package/dist/cli.js +582 -173
- package/dist/cloud.js +236 -0
- package/dist/commands.js +19 -2
- package/dist/constants.js +138 -11
- package/dist/context-retriever.js +1 -1
- package/dist/cost-tracker.js +21 -16
- package/dist/git-context.js +8 -5
- package/dist/grounding-checker.js +8 -1
- package/dist/headless.js +102 -45
- package/dist/hooks.js +55 -5
- package/dist/injection-detector.js +18 -1
- package/dist/ipc-server.js +7 -1
- package/dist/keychain.js +117 -4
- package/dist/markdown.js +4 -1
- package/dist/model-router.js +11 -0
- package/dist/sandbox.js +93 -10
- package/dist/scoped-rules.js +10 -1
- package/dist/sessions.js +6 -6
- package/dist/settings.js +53 -14
- package/dist/sub-agent.js +59 -2
- package/dist/token-counter.js +10 -1
- package/dist/tool-helper.js +12 -4
- package/dist/tools-browser.js +65 -6
- package/dist/tools-extra.js +42 -6
- package/dist/tools-voice.js +9 -15
- package/dist/tools-web.js +81 -54
- package/dist/tools.js +27 -2
- package/dist/workspace-trust.js +90 -0
- package/package.json +1 -1
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
|
|
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
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
184
|
-
const [size, setSize] = useState({ cols: stdout?.columns ?? 80, rows: stdout?.rows ?? 24 });
|
|
323
|
+
const [size, setSize] = useState(termSize);
|
|
185
324
|
useEffect(() => {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
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 ${
|
|
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 > ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1548
|
-
return () => {
|
|
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 (
|
|
1554
|
-
|
|
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
|
-
|
|
1858
|
+
const current = confirmQueue.shift();
|
|
1859
|
+
if (current) {
|
|
1567
1860
|
if (ok && always)
|
|
1568
|
-
alwaysAllowedTools.add(
|
|
1861
|
+
alwaysAllowedTools.add(current.toolName);
|
|
1569
1862
|
if (!ok) {
|
|
1570
1863
|
recentDenials.push({
|
|
1571
|
-
toolName:
|
|
1572
|
-
args: JSON.stringify(
|
|
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
|
-
|
|
1579
|
-
pendingConfirm = null;
|
|
1871
|
+
current.resolve(ok);
|
|
1580
1872
|
}
|
|
1581
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
2201
|
-
diff =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
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
|
-
|
|
2371
|
-
|
|
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
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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() {
|