scalix 1.4.1 → 1.5.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 +36 -0
- package/README.md +29 -10
- package/dist/agent-replay.js +2 -1
- package/dist/agent.js +207 -15
- package/dist/api-client.js +150 -25
- package/dist/cli.js +516 -213
- package/dist/constants.js +61 -6
- package/dist/cost-tracker.js +43 -5
- package/dist/headless.js +24 -14
- package/dist/ipc-server.js +26 -8
- package/dist/keychain.js +46 -22
- package/dist/markdown.js +18 -1
- package/dist/providers/index.js +31 -0
- package/dist/providers/openai.js +149 -0
- package/dist/settings.js +86 -8
- package/dist/slash-commands.js +148 -0
- package/dist/sub-agent.js +1 -0
- package/package.json +13 -3
- package/PARITY-PLAN.md +0 -116
- package/docs/DISTRIBUTION.md +0 -301
package/dist/cli.js
CHANGED
|
@@ -12,7 +12,7 @@ import fs from 'node:fs/promises';
|
|
|
12
12
|
import { existsSync, readFileSync } from 'node:fs';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import SelectInput from 'ink-select-input';
|
|
15
|
-
import { createAgent, messageContentToString } from './agent.js';
|
|
15
|
+
import { createAgent, messageContentToString, REPORTED_ERROR } from './agent.js';
|
|
16
16
|
import { defaultTools } from './tools.js';
|
|
17
17
|
import { extraTools } from './tools-extra.js';
|
|
18
18
|
import { webTools } from './tools-web.js';
|
|
@@ -25,9 +25,10 @@ import { renderMarkdown } from './markdown.js';
|
|
|
25
25
|
import { costTracker } from './cost-tracker.js';
|
|
26
26
|
import { saveSession, listSessions, loadSession, generateSessionId, saveCrashState, loadCrashState, clearCrashState, forkSession } from './sessions.js';
|
|
27
27
|
import { logger } from './logger.js';
|
|
28
|
-
import { VERSION, PRODUCT_NAME, DEFAULT_MODELS, DEFAULT_BASE_URL, DEFAULT_INSTRUCTIONS, ENV_API_KEY, ENV_MODEL, TOOL_DEFAULTS, HEADLESS_DEFAULTS, GIT_BOT_IDENTITY, fetchModelSpecs } from './constants.js';
|
|
28
|
+
import { VERSION, PRODUCT_NAME, DEFAULT_MODELS, DEFAULT_BASE_URL, DEFAULT_INSTRUCTIONS, ENV_API_KEY, ENV_MODEL, TOOL_DEFAULTS, HEADLESS_DEFAULTS, GIT_BOT_IDENTITY, BYOK_DEFAULT_CONTEXT_TOKENS, USER_CONFIG_DIR, fetchModelSpecs } from './constants.js';
|
|
29
29
|
import { checkForUpdate, getUpdateBanner, performUpdate } from './update-checker.js';
|
|
30
|
-
import { loadConfig, saveConfig, isConfigured, DEFAULT_LIMITS, THINKING_MODES } from './settings.js';
|
|
30
|
+
import { loadConfig, saveConfig, isConfigured, resolveActiveProvider, DEFAULT_LIMITS, THINKING_MODES } from './settings.js';
|
|
31
|
+
import { fetchByokModels } from './providers/openai.js';
|
|
31
32
|
import { getModeDescription, PERMISSION_MODES, setSandboxRoot, checkScopedPermission, getScopedPermissionRules } from './sandbox.js';
|
|
32
33
|
import { buildProjectIndex, indexToContext } from './project-index.js';
|
|
33
34
|
import { collectGitInfo, gitInfoToContext } from './git-context.js';
|
|
@@ -41,6 +42,7 @@ import { createCheckpoint, undoLastCheckpoint, listCheckpoints } from './git-und
|
|
|
41
42
|
import { loadScopedRules, rulesToContext } from './scoped-rules.js';
|
|
42
43
|
import { loadSkills, skillsSummary, getSkillByTrigger, getSkillContent } from './skills.js';
|
|
43
44
|
import { AgentRegistry } from './agent-registry.js';
|
|
45
|
+
import { SLASH_COMMANDS, HELP_LINES } from './slash-commands.js';
|
|
44
46
|
import { createWorktree, listWorktrees, removeWorktree, cleanupStaleWorktrees } from './worktree.js';
|
|
45
47
|
import { sendNotification, getTerminalType } from './terminal-notify.js';
|
|
46
48
|
import { stringWidth, truncateText } from './text-utils.js';
|
|
@@ -145,17 +147,33 @@ let loadedSkills = [];
|
|
|
145
147
|
// exec surface (plugins/hooks/rules/settings) that we skipped loading. The App
|
|
146
148
|
// reads this on mount to inform the user they can run /trust to enable them.
|
|
147
149
|
let workspaceUntrustedNotice = false;
|
|
148
|
-
let cloudConnection = { connected: false, identity: null, toolCount: 0 };
|
|
150
|
+
let cloudConnection = { connected: false, identity: null, toolCount: 0, reason: 'no-key' };
|
|
149
151
|
// Set by the setup wizard when its Scalix Cloud validation step succeeded, so
|
|
150
152
|
// the App shows the "Try:" teach-in exactly once after a wizard or /login
|
|
151
153
|
// connect (not on every ordinary startup reconnect).
|
|
152
154
|
let cloudTeachInPending = false;
|
|
155
|
+
/**
|
|
156
|
+
* Set by the setup wizard when the OS keychain refused the write and the key
|
|
157
|
+
* had to stay in config.json. Surfaced once on the first render of the app —
|
|
158
|
+
* a secret quietly landing in a plaintext file is not something to swallow.
|
|
159
|
+
*/
|
|
160
|
+
let plaintextKeyPending = false;
|
|
153
161
|
// Validate the key against /v1/me, then fetch the account's tool catalog and
|
|
154
162
|
// register it on the agent. One attempt, no retry loop: failure leaves the
|
|
155
163
|
// agent with local tools only and carries the server's own error message.
|
|
156
164
|
async function connectCloud(targetAgent) {
|
|
165
|
+
// On BYOK the base URL is a third-party host — OpenRouter, Ollama, vLLM —
|
|
166
|
+
// that has no /v1/me. Probing it sent the user's provider key to a route
|
|
167
|
+
// that does not exist and turned the 404 body into a "Scalix Cloud
|
|
168
|
+
// unavailable" banner on a session that never asked for Scalix Cloud.
|
|
169
|
+
// refreshCloudTools() already refuses the catalog fetch for these providers;
|
|
170
|
+
// the identity probe has to refuse it too.
|
|
171
|
+
if (!targetAgent.supportsPlatformTools()) {
|
|
172
|
+
cloudConnection = { connected: false, identity: null, toolCount: 0, reason: 'byok' };
|
|
173
|
+
return cloudConnection;
|
|
174
|
+
}
|
|
157
175
|
if (!targetAgent.getApiKey()) {
|
|
158
|
-
cloudConnection = { connected: false, identity: null, toolCount: 0, error: 'no API key configured' };
|
|
176
|
+
cloudConnection = { connected: false, identity: null, toolCount: 0, reason: 'no-key', error: 'no API key configured' };
|
|
159
177
|
return cloudConnection;
|
|
160
178
|
}
|
|
161
179
|
try {
|
|
@@ -165,7 +183,7 @@ async function connectCloud(targetAgent) {
|
|
|
165
183
|
cloudConnection = { connected: true, identity, toolCount: status.toolCount };
|
|
166
184
|
}
|
|
167
185
|
else {
|
|
168
|
-
cloudConnection = { connected: false, identity, toolCount: 0, error: status.error };
|
|
186
|
+
cloudConnection = { connected: false, identity, toolCount: 0, reason: 'failed', error: status.error };
|
|
169
187
|
}
|
|
170
188
|
}
|
|
171
189
|
catch (err) {
|
|
@@ -177,7 +195,7 @@ async function connectCloud(targetAgent) {
|
|
|
177
195
|
cloudConnection = { connected: true, identity: null, toolCount: status.toolCount, error: err?.message || String(err) };
|
|
178
196
|
}
|
|
179
197
|
else {
|
|
180
|
-
cloudConnection = { connected: false, identity: null, toolCount: 0, error: err?.message || String(err) };
|
|
198
|
+
cloudConnection = { connected: false, identity: null, toolCount: 0, reason: 'failed', error: err?.message || String(err) };
|
|
181
199
|
}
|
|
182
200
|
}
|
|
183
201
|
return cloudConnection;
|
|
@@ -254,9 +272,11 @@ function initAgent(config) {
|
|
|
254
272
|
throw new Error('No models configured. Please add at least one model in setup.');
|
|
255
273
|
}
|
|
256
274
|
const limits = config.limits || DEFAULT_LIMITS;
|
|
275
|
+
const active = resolveActiveProvider(config);
|
|
257
276
|
agent = createAgent({
|
|
258
|
-
apiKey:
|
|
259
|
-
baseUrl:
|
|
277
|
+
apiKey: active.apiKey,
|
|
278
|
+
baseUrl: active.baseUrl,
|
|
279
|
+
provider: active.provider,
|
|
260
280
|
model: models.length === 1 ? models[0] : models,
|
|
261
281
|
instructions: baseInstructions,
|
|
262
282
|
tools: allTools,
|
|
@@ -339,6 +359,54 @@ function truncate(s, max) {
|
|
|
339
359
|
const lastBreak = Math.max(cut.lastIndexOf(','), cut.lastIndexOf(' '), cut.lastIndexOf('\n'));
|
|
340
360
|
return (lastBreak > max * 0.5 ? cut.slice(0, lastBreak) : cut) + '\u2026';
|
|
341
361
|
}
|
|
362
|
+
/**
|
|
363
|
+
* Collapse a tool result to one readable transcript line.
|
|
364
|
+
*
|
|
365
|
+
* The transcript used to print `JSON.stringify(result)` verbatim, so a
|
|
366
|
+
* successful `echo hello` read as
|
|
367
|
+
* ● run_command: {"stdout":"hello-from-tool\n","stderr":"","exitCode":0…
|
|
368
|
+
* — the output buried inside its own envelope, and the part that actually
|
|
369
|
+
* matters on a bad run (a non-zero exit, an error string) pushed past the
|
|
370
|
+
* truncation point. This is display only; the model still receives the whole
|
|
371
|
+
* result object. Shapes below are the ones the tool layer really returns
|
|
372
|
+
* (tools.ts run_command/read_file, tools-extra.ts search_files).
|
|
373
|
+
*/
|
|
374
|
+
function summarizeToolResult(result) {
|
|
375
|
+
const oneLine = (s) => s.replace(/\s*\r?\n\s*/g, ' ').trim();
|
|
376
|
+
if (typeof result === 'string')
|
|
377
|
+
return oneLine(result);
|
|
378
|
+
if (result === null || typeof result !== 'object')
|
|
379
|
+
return String(result);
|
|
380
|
+
const r = result;
|
|
381
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
382
|
+
if (typeof r.error === 'string')
|
|
383
|
+
return oneLine(r.error);
|
|
384
|
+
if ('exitCode' in r || 'stdout' in r) {
|
|
385
|
+
const out = oneLine(String(r.stdout ?? ''));
|
|
386
|
+
const err = oneLine(String(r.stderr ?? ''));
|
|
387
|
+
const code = r.exitCode;
|
|
388
|
+
if (code)
|
|
389
|
+
return `exit ${code}${err || out ? ` — ${err || out}` : ''}`;
|
|
390
|
+
return out || err || 'no output';
|
|
391
|
+
}
|
|
392
|
+
if (typeof r.matchCount === 'number')
|
|
393
|
+
return plural(r.matchCount, 'match');
|
|
394
|
+
if (Array.isArray(r.files))
|
|
395
|
+
return plural(r.files.length, 'file');
|
|
396
|
+
if (Array.isArray(r.entries))
|
|
397
|
+
return plural(r.entries.length, 'file');
|
|
398
|
+
if (typeof r.content === 'string') {
|
|
399
|
+
const lines = typeof r.totalLines === 'number' ? r.totalLines : r.content.split('\n').length;
|
|
400
|
+
return plural(lines, 'line');
|
|
401
|
+
}
|
|
402
|
+
if (typeof r.message === 'string')
|
|
403
|
+
return oneLine(r.message);
|
|
404
|
+
if (r.success === true)
|
|
405
|
+
return 'done';
|
|
406
|
+
if (r.success === false)
|
|
407
|
+
return 'failed';
|
|
408
|
+
return oneLine(JSON.stringify(result));
|
|
409
|
+
}
|
|
342
410
|
function formatTokens(n) {
|
|
343
411
|
if (n >= 1000000)
|
|
344
412
|
return `${(n / 1000000).toFixed(1)}M`;
|
|
@@ -347,7 +415,18 @@ function formatTokens(n) {
|
|
|
347
415
|
return String(n);
|
|
348
416
|
}
|
|
349
417
|
// ─── Components ───
|
|
418
|
+
// BigText wraps at narrow widths — "scalix coder" is 5 rows at 80 columns but
|
|
419
|
+
// 9 at 40 — while the layout budget in App assumes a flat 5. Below these bounds
|
|
420
|
+
// the logo is dropped rather than allowed to wrap or be sliced: in a 12-row
|
|
421
|
+
// terminal it rendered as one half-height row of glyphs with the status bar
|
|
422
|
+
// drawn over the rest of it.
|
|
423
|
+
const BANNER_MIN_COLS = 62;
|
|
424
|
+
const BANNER_MIN_ROWS = 20;
|
|
425
|
+
const bannerFits = (cols, rows) => cols >= BANNER_MIN_COLS && rows >= BANNER_MIN_ROWS;
|
|
350
426
|
const Banner = memo(function Banner() {
|
|
427
|
+
const { cols, rows } = useTermSize();
|
|
428
|
+
if (!bannerFits(cols, rows))
|
|
429
|
+
return null;
|
|
351
430
|
return (_jsx(Box, { flexDirection: "column", alignItems: "center", marginBottom: 0, children: _jsx(Gradient, { name: "teen", children: _jsx(BigText, { text: PRODUCT_NAME.toLowerCase(), font: "tiny" }) }) }));
|
|
352
431
|
});
|
|
353
432
|
// Strip bracketed paste markers and control chars that leak through ink's parser.
|
|
@@ -450,18 +529,50 @@ function formatContextSize(tokens) {
|
|
|
450
529
|
return `${(tokens / 1_000).toFixed(tokens % 1_000 === 0 ? 0 : 1)}K`;
|
|
451
530
|
return String(tokens);
|
|
452
531
|
}
|
|
532
|
+
/**
|
|
533
|
+
* What to pre-fill the context-window box with.
|
|
534
|
+
*
|
|
535
|
+
* Which number is honest depends on where inference is going. Scalix Cloud
|
|
536
|
+
* publishes a per-model limit and 1M is a real ceiling there; for an arbitrary
|
|
537
|
+
* OpenAI-compatible endpoint — most self-hosted models run 8K–128K — it is a
|
|
538
|
+
* figure we invented, and inventing it high defers auto-compression past the
|
|
539
|
+
* point where the endpoint starts rejecting the request. A limit the user has
|
|
540
|
+
* already chosen always wins over both.
|
|
541
|
+
*/
|
|
542
|
+
function defaultContextTokens(p, existing) {
|
|
543
|
+
if (existing && existing !== DEFAULT_LIMITS.maxContextTokens)
|
|
544
|
+
return existing;
|
|
545
|
+
return p === 'openai' ? BYOK_DEFAULT_CONTEXT_TOKENS : DEFAULT_LIMITS.maxContextTokens;
|
|
546
|
+
}
|
|
453
547
|
function SetupScreen({ onComplete, existingConfig }) {
|
|
454
|
-
const
|
|
455
|
-
|
|
548
|
+
const [provider, setProvider] = useState(existingConfig?.provider === 'openai' ? 'openai' : 'scalix');
|
|
549
|
+
// Scalix = zero-config default; BYOK (OpenAI-compatible) skips the
|
|
550
|
+
// Scalix-only cloud step and enters models manually.
|
|
551
|
+
const STEPS = provider === 'openai'
|
|
552
|
+
? ['provider', 'api-key', 'base-url', 'models', 'context-window']
|
|
553
|
+
: ['provider', 'api-key', 'base-url', 'cloud', 'models', 'context-window'];
|
|
554
|
+
const [step, setStep] = useState('provider');
|
|
456
555
|
const [apiKey, setApiKey] = useState(existingConfig?.apiKey || process.env[ENV_API_KEY] || '');
|
|
457
556
|
const [baseUrl, setBaseUrl] = useState(existingConfig?.baseUrl || DEFAULT_BASE_URL);
|
|
557
|
+
const [baseUrlError, setBaseUrlError] = useState(false);
|
|
458
558
|
const [models, setModels] = useState(existingConfig?.models?.length ? [...existingConfig.models] : [...DEFAULT_MODELS]);
|
|
459
559
|
const [modelsMode, setModelsMode] = useState('custom');
|
|
460
560
|
const [modelInput, setModelInput] = useState('');
|
|
461
|
-
|
|
561
|
+
// Only true once the user has actually submitted an empty box. The warning
|
|
562
|
+
// used to be tied to models.length === 0, which is the state every user
|
|
563
|
+
// arrives in — so the step opened with a yellow "At least one model is
|
|
564
|
+
// required" before they had typed a character, reading as a failure.
|
|
565
|
+
const [modelsWarned, setModelsWarned] = useState(false);
|
|
566
|
+
const [contextInput, setContextInput] = useState(String(defaultContextTokens(existingConfig?.provider === 'openai' ? 'openai' : 'scalix', existingConfig?.limits?.maxContextTokens)));
|
|
462
567
|
const [apiKeyError, setApiKeyError] = useState(false);
|
|
568
|
+
/** True while saveConfig() is in flight — see the context-window submit. */
|
|
569
|
+
const [saving, setSaving] = useState(false);
|
|
463
570
|
const [fetchedModels, setFetchedModels] = useState([]);
|
|
464
571
|
const [fetchingModels, setFetchingModels] = useState(false);
|
|
572
|
+
/** Context windows a BYOK endpoint reported for its own models, by id. Only
|
|
573
|
+
* the ones it actually sent — a model missing here has no entry, never a
|
|
574
|
+
* borrowed default. */
|
|
575
|
+
const [byokContextWindows, setByokContextWindows] = useState({});
|
|
465
576
|
const [cloudCheck, setCloudCheck] = useState({ phase: 'checking' });
|
|
466
577
|
const { exit } = useApp();
|
|
467
578
|
// Validate the key against Scalix Cloud when the cloud step is entered:
|
|
@@ -489,6 +600,24 @@ function SetupScreen({ onComplete, existingConfig }) {
|
|
|
489
600
|
// apiKey/baseUrl are intentionally not deps: they are frozen once the user
|
|
490
601
|
// advances past base-url, and the step transition is the trigger.
|
|
491
602
|
}, [step]);
|
|
603
|
+
// The smallest window across the picked models, because compression has to
|
|
604
|
+
// fit the tightest one — a session that fits the 128K model and not the 8K
|
|
605
|
+
// fallback fails the moment it falls back. null when the endpoint reported
|
|
606
|
+
// nothing for any of them.
|
|
607
|
+
const reportedContextWindow = useMemo(() => {
|
|
608
|
+
const reported = models.map((m) => byokContextWindows[m]).filter((n) => !!n);
|
|
609
|
+
return reported.length > 0 ? Math.min(...reported) : null;
|
|
610
|
+
}, [models, byokContextWindows]);
|
|
611
|
+
// Fill the box with the reported window when the step opens, but never
|
|
612
|
+
// overwrite a number the user typed themselves.
|
|
613
|
+
const contextPrefilled = useRef(false);
|
|
614
|
+
useEffect(() => {
|
|
615
|
+
if (step !== 'context-window' || contextPrefilled.current)
|
|
616
|
+
return;
|
|
617
|
+
contextPrefilled.current = true;
|
|
618
|
+
if (reportedContextWindow !== null)
|
|
619
|
+
setContextInput(String(reportedContextWindow));
|
|
620
|
+
}, [step, reportedContextWindow]);
|
|
492
621
|
const totalSteps = STEPS.length;
|
|
493
622
|
const stepNumber = STEPS.indexOf(step) + 1;
|
|
494
623
|
const goBack = useCallback(() => {
|
|
@@ -500,11 +629,18 @@ function SetupScreen({ onComplete, existingConfig }) {
|
|
|
500
629
|
}
|
|
501
630
|
}, [step]);
|
|
502
631
|
useInput((_ch, key) => {
|
|
632
|
+
// Once the save is in flight the config is already being written; letting
|
|
633
|
+
// Escape kill the process or Backspace walk back a step from here just
|
|
634
|
+
// races the write.
|
|
635
|
+
if (saving)
|
|
636
|
+
return;
|
|
503
637
|
if (key.escape)
|
|
504
638
|
exit();
|
|
505
639
|
if (key.backspace || key.delete) {
|
|
506
|
-
if (step === 'api-key' && apiKey === '')
|
|
640
|
+
if (step === 'api-key' && apiKey === '') {
|
|
641
|
+
goBack();
|
|
507
642
|
return;
|
|
643
|
+
}
|
|
508
644
|
if (step === 'base-url' && baseUrl === '') {
|
|
509
645
|
goBack();
|
|
510
646
|
return;
|
|
@@ -515,7 +651,32 @@ function SetupScreen({ onComplete, existingConfig }) {
|
|
|
515
651
|
}
|
|
516
652
|
}
|
|
517
653
|
});
|
|
518
|
-
return (_jsxs(Box, { flexDirection: "column", width: "100%", children: [_jsx(Banner, {}), _jsxs(Box, { flexDirection: "column", paddingX: 3, paddingY: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "#20a0a0", bold: true, children: ['\u2501'.repeat(4), " First-time Setup ", '\u2501'.repeat(4)] }) }), _jsxs(Box, { marginBottom: 1, gap: 1, children: [_jsx(Text, { color: "#20a0a0", children: STEPS.map((_, i) => i < stepNumber ? '\u25CF' : '\u25CB').join('') }), _jsxs(Text, { dimColor: true, children: ["Step ", stepNumber, " of ", totalSteps] })] }), step === '
|
|
654
|
+
return (_jsxs(Box, { flexDirection: "column", width: "100%", children: [_jsx(Banner, {}), _jsxs(Box, { flexDirection: "column", paddingX: 3, paddingY: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "#20a0a0", bold: true, children: ['\u2501'.repeat(4), " First-time Setup ", '\u2501'.repeat(4)] }) }), _jsxs(Box, { marginBottom: 1, gap: 1, children: [_jsx(Text, { color: "#20a0a0", children: STEPS.map((_, i) => i < stepNumber ? '\u25CF' : '\u25CB').join('') }), _jsxs(Text, { dimColor: true, children: ["Step ", stepNumber, " of ", totalSteps] })] }), step === 'provider' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Provider" }), _jsx(Text, { dimColor: true, children: "Where should the agent send inference?" }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [
|
|
655
|
+
{ label: 'Scalix Cloud (recommended) \u2014 api.scalix.world, platform tools included', value: 'scalix' },
|
|
656
|
+
{ label: 'OpenAI-compatible endpoint \u2014 bring your own key (OpenAI, OpenRouter, Ollama, vLLM, ...)', value: 'openai' },
|
|
657
|
+
], onSelect: (item) => {
|
|
658
|
+
const p = item.value === 'openai' ? 'openai' : 'scalix';
|
|
659
|
+
setProvider(p);
|
|
660
|
+
if (p === 'openai') {
|
|
661
|
+
setApiKey(existingConfig?.providers?.openai?.apiKey || '');
|
|
662
|
+
setBaseUrl(existingConfig?.providers?.openai?.baseUrl || '');
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
setApiKey(existingConfig?.apiKey || process.env[ENV_API_KEY] || '');
|
|
666
|
+
setBaseUrl(existingConfig?.baseUrl || DEFAULT_BASE_URL);
|
|
667
|
+
}
|
|
668
|
+
// Model ids do not transfer between endpoints: keeping the
|
|
669
|
+
// configured list after a switch pre-fills the next step with
|
|
670
|
+
// ids the new endpoint has never heard of, and the wizard
|
|
671
|
+
// finishes "successfully" onto a 404 on the first turn.
|
|
672
|
+
const configured = (existingConfig?.provider === 'openai' ? 'openai' : 'scalix') === p;
|
|
673
|
+
setModels(configured && existingConfig?.models?.length ? [...existingConfig.models] : []);
|
|
674
|
+
setModelsMode('custom');
|
|
675
|
+
setFetchedModels([]);
|
|
676
|
+
setByokContextWindows({});
|
|
677
|
+
setContextInput(String(defaultContextTokens(p, existingConfig?.limits?.maxContextTokens)));
|
|
678
|
+
setStep('api-key');
|
|
679
|
+
} }) })] })), step === 'api-key' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "API Key" }), _jsx(Text, { dimColor: true, children: provider === 'openai' ? 'The API key for your endpoint. Press Enter to continue.' : 'Paste your Scalix API key (console.scalix.world/org/keys) and press Enter.' }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "#20a0a0", bold: true, children: ['\u276F', " "] }), _jsx(LongTextInput, { value: apiKey, onChange: (v) => { setApiKey(v); setApiKeyError(false); }, onSubmit: (v) => {
|
|
519
680
|
const cleaned = v.trim();
|
|
520
681
|
if (cleaned) {
|
|
521
682
|
setApiKey(cleaned);
|
|
@@ -524,8 +685,35 @@ function SetupScreen({ onComplete, existingConfig }) {
|
|
|
524
685
|
else {
|
|
525
686
|
setApiKeyError(true);
|
|
526
687
|
}
|
|
527
|
-
}, placeholder:
|
|
528
|
-
const
|
|
688
|
+
}, placeholder: provider === 'openai' ? 'sk-...' : 'scalix_sk_...' })] }), 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: provider === 'openai' ? 'Endpoint URL' : 'Base URL' }), _jsx(Text, { dimColor: true, children: provider === 'openai' ? 'Your OpenAI-compatible endpoint root, with or without /v1 (required).' : 'Scalix API endpoint (or your self-hosted gateway)' }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "#20a0a0", bold: true, children: ['\u276F', " "] }), _jsx(TextInput, { value: baseUrl, onChange: (v) => { setBaseUrl(sanitizeInput(v)); setBaseUrlError(false); }, onSubmit: (v) => {
|
|
689
|
+
const typed = sanitizeInput(v).trim();
|
|
690
|
+
if (provider === 'openai') {
|
|
691
|
+
// BYOK has no default third-party URL \u2014 the endpoint is required.
|
|
692
|
+
const url = typed || baseUrl;
|
|
693
|
+
if (!url) {
|
|
694
|
+
setBaseUrlError(true);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
setBaseUrl(url);
|
|
698
|
+
// Ask the endpoint what it serves. Without this the next
|
|
699
|
+
// step can only tell the user to go read GET /v1/models
|
|
700
|
+
// themselves, and one mistyped id fails the first turn
|
|
701
|
+
// with nothing pointing at the id as the cause.
|
|
702
|
+
setFetchingModels(true);
|
|
703
|
+
fetchByokModels(url, apiKey).then((found) => {
|
|
704
|
+
setFetchedModels(found.map((m) => ({ id: m.id })));
|
|
705
|
+
setByokContextWindows(Object.fromEntries(found.filter((m) => m.contextWindow).map((m) => [m.id, m.contextWindow])));
|
|
706
|
+
}).catch((err) => { logger.warn('Failed to list endpoint models', { error: err.message }); })
|
|
707
|
+
.finally(() => { setFetchingModels(false); });
|
|
708
|
+
setStep('models');
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
// Backspacing the pre-filled default away leaves the box
|
|
712
|
+
// showing a greyed https://api.scalix.world \u2014 the placeholder.
|
|
713
|
+
// Enter on that used to save an empty base URL, and every
|
|
714
|
+
// later request failed with a bare "fetch failed". Honour
|
|
715
|
+
// what the placeholder promises.
|
|
716
|
+
const url = typed || baseUrl || DEFAULT_BASE_URL;
|
|
529
717
|
setBaseUrl(url);
|
|
530
718
|
// Model list loads in the background while the cloud step
|
|
531
719
|
// validates the key via /v1/me + the tool catalog.
|
|
@@ -538,7 +726,7 @@ function SetupScreen({ onComplete, existingConfig }) {
|
|
|
538
726
|
setFetchingModels(false);
|
|
539
727
|
});
|
|
540
728
|
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: [
|
|
729
|
+
}, placeholder: provider === 'openai' ? 'e.g. http://localhost:11434/v1' : DEFAULT_BASE_URL })] }), baseUrlError && (_jsx(Box, { marginTop: 0, paddingLeft: 2, children: _jsx(Text, { color: "yellow", dimColor: true, children: "An endpoint URL is required for a BYOK provider." }) })), 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] }) }), !cloudCheck.error.includes(CONSOLE_KEYS_URL) && (_jsxs(Text, { dimColor: true, children: ["Create or check your key at ", CONSOLE_KEYS_URL] })), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [
|
|
542
730
|
{ label: 'Re-enter API key', value: '__reenter__' },
|
|
543
731
|
{ label: 'Skip — continue without Scalix Cloud (local tools only)', value: '__skip__' },
|
|
544
732
|
], onSelect: (item) => {
|
|
@@ -548,7 +736,7 @@ function SetupScreen({ onComplete, existingConfig }) {
|
|
|
548
736
|
else {
|
|
549
737
|
setStep('models');
|
|
550
738
|
}
|
|
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: [
|
|
739
|
+
} }) })] }))] })), step === 'models' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Models" }), fetchingModels && fetchedModels.length === 0 && models.length === 0 ? (_jsxs(Box, { marginTop: 1, gap: 1, children: [_jsx(Text, { color: "#20a0a0", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: "Asking your endpoint which models it serves..." })] })) : 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: [
|
|
552
740
|
...fetchedModels.filter((fm) => !models.includes(fm.id)).map((fm) => ({
|
|
553
741
|
label: ` ${fm.name || fm.id}`,
|
|
554
742
|
value: fm.id,
|
|
@@ -569,7 +757,23 @@ function SetupScreen({ onComplete, existingConfig }) {
|
|
|
569
757
|
setModels((prev) => [...prev, item.value]);
|
|
570
758
|
}
|
|
571
759
|
}
|
|
572
|
-
} }) }), models.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginTop: 1, children: [_jsx(Text, { dimColor: true, bold: true, children: "Selected:" }), models.map((m) => (_jsxs(Text, { color: "#30c090", children: ['\u25CF', " ", m] }, m)))] }))] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "Type a model ID and press Enter to add. Add at least one." }), models.length > 0 && modelsMode === 'list' ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [
|
|
760
|
+
} }) }), models.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginTop: 1, children: [_jsx(Text, { dimColor: true, bold: true, children: "Selected:" }), models.map((m) => (_jsxs(Text, { color: "#30c090", children: ['\u25CF', " ", m] }, m)))] }))] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "Type a model ID and press Enter to add. Add at least one." }), models.length > 0 && modelsMode === 'list' ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: ["Added models (", '↑', "/", '↓', " to navigate, Enter on a model removes it):"] }), _jsx(SelectInput
|
|
761
|
+
// Open on Continue. The list's first entries are the
|
|
762
|
+
// models themselves and choosing one deletes it, so the
|
|
763
|
+
// cursor used to start on a destructive action: the
|
|
764
|
+
// previous screen says "press Enter (empty) to review &
|
|
765
|
+
// continue", and a second Enter — the obvious way to
|
|
766
|
+
// move on — wiped the only model and dropped the user
|
|
767
|
+
// back to an empty box with a warning.
|
|
768
|
+
, {
|
|
769
|
+
// Open on Continue. The list's first entries are the
|
|
770
|
+
// models themselves and choosing one deletes it, so the
|
|
771
|
+
// cursor used to start on a destructive action: the
|
|
772
|
+
// previous screen says "press Enter (empty) to review &
|
|
773
|
+
// continue", and a second Enter — the obvious way to
|
|
774
|
+
// move on — wiped the only model and dropped the user
|
|
775
|
+
// back to an empty box with a warning.
|
|
776
|
+
initialIndex: models.length + 1, items: [
|
|
573
777
|
...models.map((m) => ({
|
|
574
778
|
label: `\u25CF ${m}`,
|
|
575
779
|
value: m,
|
|
@@ -599,21 +803,41 @@ function SetupScreen({ onComplete, existingConfig }) {
|
|
|
599
803
|
setModels((prev) => [...prev, trimmed]);
|
|
600
804
|
}
|
|
601
805
|
setModelInput('');
|
|
806
|
+
setModelsWarned(false);
|
|
602
807
|
}
|
|
603
808
|
else if (models.length > 0) {
|
|
604
809
|
setModelsMode('list');
|
|
605
810
|
}
|
|
606
|
-
|
|
811
|
+
else {
|
|
812
|
+
setModelsWarned(true);
|
|
813
|
+
}
|
|
814
|
+
}, placeholder: "model-id (from your API provider)" })] }), _jsx(Box, { paddingLeft: 2, marginTop: 0, children: models.length === 0 ? (modelsWarned ? (_jsx(Text, { color: "yellow", dimColor: true, children: "At least one model is required." })) : provider === 'openai' && fetchedModels.length === 0 ? (_jsx(Text, { dimColor: true, children: "Your endpoint didn't return a model list \u2014 type the id it serves." })) : (_jsx(Text, { dimColor: true, children: "Use the exact id your endpoint serves \u2014 the values under GET /v1/models." }))) : (_jsx(Text, { dimColor: true, children: "Enter another model or press Enter (empty) to review & continue" })) })] }))] }))] })), step === 'context-window' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Context Window Size" }), _jsx(Text, { dimColor: true, children: "Enter the max token count your model supports." }), _jsx(Text, { dimColor: true, children: "Check your model provider's docs for the exact limit." }), _jsx(Text, { dimColor: true, children: "This controls when the agent auto-compresses conversation context." }), provider === 'openai' && (
|
|
815
|
+
// Say where the pre-filled number came from either way. A user who
|
|
816
|
+
// takes a guessed default on trust for an 8K local model gets
|
|
817
|
+
// rejected requests, and one who is told a real reported limit is
|
|
818
|
+
// a guess will second-guess a correct number.
|
|
819
|
+
reportedContextWindow !== null ? (_jsxs(Text, { dimColor: true, children: ["Pre-filled with ", formatContextSize(reportedContextWindow), " \u2014 reported by your endpoint", models.length > 1 ? ', the smallest across the models you picked' : '', "."] })) : (_jsxs(Text, { dimColor: true, children: ["Pre-filled with a safe ", formatContextSize(BYOK_DEFAULT_CONTEXT_TOKENS), " \u2014 we can't read your endpoint's real limit."] }))), (() => {
|
|
607
820
|
const parsed = parseInt(contextInput, 10);
|
|
608
821
|
const valid = parsed > 0;
|
|
609
822
|
return valid ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "#30c090", children: ['\u25CF', " ", formatContextSize(parsed), " tokens (", parsed.toLocaleString(), ")"] }) })) : null;
|
|
610
823
|
})(), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "#20a0a0", bold: true, children: ['\u276F', " "] }), _jsx(TextInput, { value: contextInput, onChange: setContextInput, onSubmit: (v) => {
|
|
824
|
+
// saveConfig() writes the key to the OS keychain, and that
|
|
825
|
+
// subprocess is not always quick — a locked keychain or a
|
|
826
|
+
// pending "allow access?" dialog made one measured save take
|
|
827
|
+
// 19.6s. Without this flag the final Enter looked like a dead
|
|
828
|
+
// key, and a second Enter re-entered the same handler.
|
|
829
|
+
if (saving)
|
|
830
|
+
return;
|
|
831
|
+
setSaving(true);
|
|
611
832
|
const parsed = parseInt(v.trim(), 10);
|
|
612
|
-
const tokens = parsed > 0 ? parsed :
|
|
833
|
+
const tokens = parsed > 0 ? parsed : defaultContextTokens(provider, existingConfig?.limits?.maxContextTokens);
|
|
613
834
|
const limits = { ...DEFAULT_LIMITS, maxContextTokens: tokens };
|
|
835
|
+
// BYOK: openai creds live under providers.openai; the
|
|
836
|
+
// top-level apiKey/baseUrl stay reserved for Scalix so
|
|
837
|
+
// switching back never loses the Scalix connection.
|
|
614
838
|
const newConfig = {
|
|
615
|
-
apiKey,
|
|
616
|
-
baseUrl,
|
|
839
|
+
apiKey: provider === 'openai' ? (existingConfig?.apiKey || '') : apiKey,
|
|
840
|
+
baseUrl: provider === 'openai' ? (existingConfig?.baseUrl || DEFAULT_BASE_URL) : baseUrl,
|
|
617
841
|
models,
|
|
618
842
|
customModels: existingConfig?.customModels || {},
|
|
619
843
|
limits,
|
|
@@ -621,13 +845,19 @@ function SetupScreen({ onComplete, existingConfig }) {
|
|
|
621
845
|
thinkingMode: existingConfig?.thinkingMode || 'off',
|
|
622
846
|
security: existingConfig?.security || {},
|
|
623
847
|
injectionDetection: existingConfig?.injectionDetection || { enabled: true, sensitivity: 'medium' },
|
|
848
|
+
provider,
|
|
849
|
+
providers: provider === 'openai'
|
|
850
|
+
? { ...existingConfig?.providers, openai: { apiKey, baseUrl } }
|
|
851
|
+
: existingConfig?.providers,
|
|
624
852
|
};
|
|
625
|
-
saveConfig(newConfig).then(() => {
|
|
853
|
+
saveConfig(newConfig).then((result) => {
|
|
854
|
+
if (result?.plaintextSecret)
|
|
855
|
+
plaintextKeyPending = true;
|
|
626
856
|
onComplete(newConfig);
|
|
627
857
|
}).catch(() => {
|
|
628
858
|
onComplete(newConfig);
|
|
629
859
|
});
|
|
630
|
-
}, placeholder: String(
|
|
860
|
+
}, placeholder: String(defaultContextTokens(provider, existingConfig?.limits?.maxContextTokens)), focus: !saving })] }), saving && (_jsxs(Box, { marginTop: 1, gap: 1, children: [_jsx(Text, { color: "#20a0a0", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: "Saving configuration (storing your key in the OS keychain)..." })] }))] })), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, italic: true, children: [stepNumber > 1 ? 'Backspace (empty) to go back \u2022 ' : '', "ESC to quit"] }) })] })] }));
|
|
631
861
|
}
|
|
632
862
|
const StatusBar = memo(function StatusBar({ status, model, toolCount, thinkStart, tokens, mode, contextPercent, rateLimitWarn }) {
|
|
633
863
|
const { cols } = useTermSize();
|
|
@@ -658,35 +888,116 @@ const StatusBar = memo(function StatusBar({ status, model, toolCount, thinkStart
|
|
|
658
888
|
registry.off('agent:completed', refresh);
|
|
659
889
|
};
|
|
660
890
|
}, []);
|
|
661
|
-
const
|
|
662
|
-
leftParts.push(shortModel(model));
|
|
891
|
+
const leftSegs = [{ id: 'model', text: shortModel(model) }];
|
|
663
892
|
if (tokens > 0)
|
|
664
|
-
|
|
665
|
-
|
|
893
|
+
leftSegs.push({ id: 'tok', text: `${formatTokens(tokens)} tok` });
|
|
894
|
+
leftSegs.push({ id: 'tools', text: `${toolCount} tools` });
|
|
666
895
|
if (activeAgents > 0)
|
|
667
|
-
|
|
896
|
+
leftSegs.push({ id: 'agents', text: `${activeAgents} agent${activeAgents > 1 ? 's' : ''}` });
|
|
668
897
|
if (contextPercent > 50)
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
898
|
+
leftSegs.push({ id: 'ctx', text: `ctx ${contextPercent}%` });
|
|
899
|
+
// Ink shrinks and wraps overflowing Text nodes rather than dropping them, so
|
|
900
|
+
// a status bar that does not fit its terminal doesn't degrade \u2014 it shatters.
|
|
901
|
+
// At 40 columns this rendered as "\u00B7 1 agent lt 5.0": model
|
|
902
|
+
// name gone, "default" chewed down to "lt", "v1.5.0" to "5.0". Shed whole
|
|
903
|
+
// segments instead, cheapest first. The permission mode is a safety signal
|
|
904
|
+
// and the model name answers "what am I even talking to", so those two are
|
|
905
|
+
// the last things standing.
|
|
906
|
+
const interior = Math.max(cols - 4, 0); // paddingX={2} on both sides
|
|
907
|
+
const hidden = new Set();
|
|
908
|
+
const warnWidth = rateLimitWarn ? 15 : 0; // gap + "\u26A0 Rate limited"
|
|
909
|
+
const leftWidth = () => 2 + leftSegs.filter((s) => !hidden.has(s.id)).map((s) => s.text).join(' \u00B7 ').length;
|
|
910
|
+
const rightWidth = () => mode.length + warnWidth
|
|
911
|
+
+ (elapsedStr && !hidden.has('elapsed') ? elapsedStr.length + 3 : 0)
|
|
912
|
+
+ (hidden.has('version') ? 0 : VERSION.length + 4);
|
|
913
|
+
for (const id of ['version', 'tools', 'tok', 'elapsed', 'ctx', 'agents']) {
|
|
914
|
+
if (leftWidth() + 1 + rightWidth() <= interior)
|
|
915
|
+
break;
|
|
916
|
+
hidden.add(id);
|
|
917
|
+
}
|
|
918
|
+
let leftText = leftSegs.filter((s) => !hidden.has(s.id)).map((s) => s.text).join(' \u00B7 ');
|
|
919
|
+
const rightLen = rightWidth();
|
|
920
|
+
const overflow = 2 + leftText.length + 1 + rightLen - interior;
|
|
921
|
+
if (overflow > 0)
|
|
922
|
+
leftText = leftText.slice(0, Math.max(0, leftText.length - overflow));
|
|
923
|
+
const gap = Math.max(interior - 2 - leftText.length - rightLen, 1);
|
|
924
|
+
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 && !hidden.has('elapsed') ? _jsxs(Text, { dimColor: true, children: ['\u00B7', " ", elapsedStr] }) : null, hidden.has('version') ? null : _jsxs(Text, { dimColor: true, children: ['\u00B7', " v", VERSION] })] })] }));
|
|
675
925
|
});
|
|
926
|
+
/**
|
|
927
|
+
* The single sentence the UI uses for a Scalix Cloud connection that is not
|
|
928
|
+
* up. Shared by the welcome screen, /status and /cloud so all three say the
|
|
929
|
+
* same thing about the same state.
|
|
930
|
+
*/
|
|
931
|
+
/**
|
|
932
|
+
* @param detail Include the server's own reason for a failed connect. The
|
|
933
|
+
* welcome header passes false: the startup transcript already carries the
|
|
934
|
+
* full sentence, and both were on screen at once — the same "API key
|
|
935
|
+
* rejected — create one at <url>" printed twice, three lines apart.
|
|
936
|
+
*/
|
|
937
|
+
function cloudStatusLine(cloud, detail = true) {
|
|
938
|
+
if (cloud.connected) {
|
|
939
|
+
const plan = cloud.identity ? `${cloud.identity.limits.plan} · ` : '';
|
|
940
|
+
return `☁ Scalix Cloud · ${plan}${cloud.toolCount} platform tools`;
|
|
941
|
+
}
|
|
942
|
+
if (cloud.reason === 'byok') {
|
|
943
|
+
return '☁ Scalix Cloud not in use — this session runs on your own provider (local tools only)';
|
|
944
|
+
}
|
|
945
|
+
if (cloud.reason === 'failed') {
|
|
946
|
+
return `☁ Scalix Cloud unavailable — local tools only${detail && cloud.error ? ` · ${cloud.error}` : ''}`;
|
|
947
|
+
}
|
|
948
|
+
return '☁ not connected — /login to link your Scalix account';
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Real tool names, as many as fit on one line.
|
|
952
|
+
*
|
|
953
|
+
* This line used to be nine hand-written nicknames — shell, read, write, edit,
|
|
954
|
+
* search, find, git, web, tree — and none of them was a tool the model can
|
|
955
|
+
* call. `tree` was the only one that even looked like a name, and there is no
|
|
956
|
+
* `tree` tool. It also never moved when the tool set did, so a session with
|
|
957
|
+
* plugins or MCP servers loaded advertised the same nine words as a bare one.
|
|
958
|
+
*/
|
|
959
|
+
function toolNamePreview(cols) {
|
|
960
|
+
const names = [...allTools, ...pluginTools]
|
|
961
|
+
.map((t) => t?.function?.name ?? t?.name)
|
|
962
|
+
.filter((n) => typeof n === 'string' && n.length > 0);
|
|
963
|
+
if (names.length === 0)
|
|
964
|
+
return 'none loaded';
|
|
965
|
+
// paddingX={2} on the screen (4) + paddingLeft={2} on the row (2) + the
|
|
966
|
+
// gap space (1) + "Tools:" (6), and room for the "+N more" tail so the
|
|
967
|
+
// count is never the thing that gets truncated away.
|
|
968
|
+
const budget = Math.max(cols - 13 - `+${names.length} more`.length, 20);
|
|
969
|
+
const shown = [];
|
|
970
|
+
let used = 0;
|
|
971
|
+
for (const n of names) {
|
|
972
|
+
if (used + n.length + (shown.length ? 1 : 0) > budget)
|
|
973
|
+
break;
|
|
974
|
+
used += n.length + (shown.length ? 1 : 0);
|
|
975
|
+
shown.push(n);
|
|
976
|
+
}
|
|
977
|
+
const rest = names.length - shown.length;
|
|
978
|
+
return rest > 0 ? `${shown.join(' ')} +${rest} more` : shown.join(' ');
|
|
979
|
+
}
|
|
676
980
|
const WelcomeScreen = memo(function WelcomeScreen({ cloud }) {
|
|
677
|
-
|
|
981
|
+
const { cols } = useTermSize();
|
|
982
|
+
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, children: [_jsx(Text, { dimColor: true, children: "Tools:" }), _jsx(Text, { dimColor: true, bold: true, wrap: "truncate-end", children: toolNamePreview(cols) })] }), _jsx(Box, { paddingLeft: 2, children: cloud.connected ? (_jsxs(Text, { color: "#20a0a0", children: ['☁', " Scalix Cloud ", '·', " ", cloud.identity ? `${cloud.identity.limits.plan} ${'·'} ` : '', cloud.toolCount, " platform tools"] })) : (_jsx(Text, { dimColor: true, children: cloudStatusLine(cloud, false) })) })] }));
|
|
678
983
|
});
|
|
679
984
|
const UserMessage = memo(function UserMessage({ text }) {
|
|
680
|
-
return (
|
|
985
|
+
return (_jsx(Box, { paddingX: 2, marginTop: 1, children: _jsxs(Text, { wrap: "wrap", bold: true, children: [_jsx(Text, { color: "magentaBright", children: '\u276F ' }), text] }) }));
|
|
681
986
|
});
|
|
682
987
|
const AssistantMessage = memo(function AssistantMessage({ text }) {
|
|
683
|
-
|
|
988
|
+
// Width has to come from useTermSize, not from renderMarkdown's
|
|
989
|
+
// process.stdout.columns fallback. This component is memo'd on `text`, so
|
|
990
|
+
// without a prop that changes on resize it never re-renders — every reply
|
|
991
|
+
// already on screen stayed wrapped to the width it was first drawn at.
|
|
992
|
+
const { cols } = useTermSize();
|
|
993
|
+
const rendered = renderMarkdown(text, cols);
|
|
684
994
|
return (_jsx(Box, { paddingLeft: 3, paddingRight: 2, flexDirection: "column", marginTop: 1, children: _jsx(Text, { wrap: "wrap", children: rendered }) }));
|
|
685
995
|
});
|
|
686
996
|
const ErrorMessage = memo(function ErrorMessage({ text, hint }) {
|
|
687
|
-
return (_jsxs(Box, { paddingLeft: 3, paddingRight: 2, flexDirection: "column", marginTop: 0, children: [
|
|
997
|
+
return (_jsxs(Box, { paddingLeft: 3, paddingRight: 2, flexDirection: "column", marginTop: 0, children: [_jsx(Box, { children: _jsx(Text, { wrap: "wrap", color: "red", children: `\u2718 ${text}` }) }), hint && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { wrap: "wrap", color: "yellow", dimColor: true, children: hint }) }))] }));
|
|
688
998
|
});
|
|
689
999
|
const ToolCallMessage = memo(function ToolCallMessage({ name, args }) {
|
|
1000
|
+
const { cols } = useTermSize();
|
|
690
1001
|
let summary = '';
|
|
691
1002
|
try {
|
|
692
1003
|
const parsed = JSON.parse(args);
|
|
@@ -699,22 +1010,35 @@ const ToolCallMessage = memo(function ToolCallMessage({ name, args }) {
|
|
|
699
1010
|
else if (parsed.query)
|
|
700
1011
|
summary = parsed.query;
|
|
701
1012
|
else
|
|
702
|
-
summary =
|
|
1013
|
+
summary = args;
|
|
703
1014
|
}
|
|
704
1015
|
catch {
|
|
705
|
-
summary =
|
|
1016
|
+
summary = args;
|
|
706
1017
|
}
|
|
707
|
-
|
|
1018
|
+
// paddingLeft 3 + bullet + two gap spaces + paddingRight 2 = 8 columns of
|
|
1019
|
+
// chrome. This row used to clip at a flat 80 regardless of terminal width,
|
|
1020
|
+
// which leaves Ink to shrink and wrap the flex line instead of clipping it:
|
|
1021
|
+
// at 30 columns "run_command echo hello-from-tool" came out as
|
|
1022
|
+
// "\u25CBrun_commaecho" / "nd hello-from-tool".
|
|
1023
|
+
const room = Math.max(cols - 8 - stringWidth(name), 12);
|
|
1024
|
+
return (_jsxs(Box, { paddingLeft: 3, paddingRight: 2, gap: 1, marginTop: 1, children: [_jsx(Text, { color: "cyan", children: '\u25CB' }), _jsx(Text, { color: "cyan", bold: true, wrap: "truncate-end", children: name }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: truncate(summary, room) })] }));
|
|
708
1025
|
});
|
|
709
1026
|
const ToolResultMessage = memo(function ToolResultMessage({ name, result }) {
|
|
710
1027
|
const { cols } = useTermSize();
|
|
711
|
-
|
|
712
|
-
|
|
1028
|
+
// Floor at 12, not 40: a 40-column floor on a 30-column terminal guarantees
|
|
1029
|
+
// the flex line overflows, and Ink answers that by shrinking and wrapping
|
|
1030
|
+
// every cell rather than clipping the one that is too long.
|
|
1031
|
+
const maxLen = Math.max(cols - name.length - 10, 12);
|
|
1032
|
+
return (_jsxs(Box, { paddingLeft: 3, paddingRight: 2, gap: 1, children: [_jsx(Text, { color: "green", children: '\u25CF' }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [name, ":"] }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: truncate(result, maxLen) })] }));
|
|
713
1033
|
});
|
|
714
1034
|
const ToolBlockedMessage = memo(function ToolBlockedMessage({ text }) {
|
|
715
1035
|
return (_jsxs(Box, { paddingLeft: 3, paddingRight: 2, gap: 1, children: [_jsx(Text, { color: "red", children: '\u25CF' }), _jsx(Text, { color: "red", children: text })] }));
|
|
716
1036
|
});
|
|
717
|
-
const ToolGroupMessage = memo(function ToolGroupMessage({
|
|
1037
|
+
const ToolGroupMessage = memo(function ToolGroupMessage({ children, collapsed }) {
|
|
1038
|
+
// Count calls, not display rows. `children` holds one 'tool-call' and one
|
|
1039
|
+
// 'tool-result' per tool, and this used to be handed children.length — so a
|
|
1040
|
+
// turn that ran two tools announced "Ran 4 tools".
|
|
1041
|
+
const count = children?.filter((c) => c.kind === 'tool-call').length || children?.length || 0;
|
|
718
1042
|
if (collapsed || !children) {
|
|
719
1043
|
return (_jsxs(Box, { paddingLeft: 3, paddingRight: 2, gap: 1, marginTop: 1, children: [_jsx(Text, { color: "cyan", children: '○' }), _jsxs(Text, { dimColor: true, children: ["Ran ", count, " tools"] }), _jsx(Text, { dimColor: true, italic: true, children: "(Ctrl+O to expand)" })] }));
|
|
720
1044
|
}
|
|
@@ -745,7 +1069,7 @@ const DiffMessage = memo(function DiffMessage({ text, meta }) {
|
|
|
745
1069
|
}) })] }));
|
|
746
1070
|
});
|
|
747
1071
|
const SystemMessage = memo(function SystemMessage({ text }) {
|
|
748
|
-
return (
|
|
1072
|
+
return (_jsx(Box, { paddingX: 2, paddingLeft: 3, marginY: 0, children: _jsx(Text, { dimColor: true, wrap: "wrap", children: `\u2500 ${text}` }) }));
|
|
749
1073
|
});
|
|
750
1074
|
const RenderItem = memo(function RenderItem({ item }) {
|
|
751
1075
|
switch (item.kind) {
|
|
@@ -755,12 +1079,12 @@ const RenderItem = memo(function RenderItem({ item }) {
|
|
|
755
1079
|
case 'tool-call': return _jsx(ToolCallMessage, { name: item.meta ?? '', args: item.text });
|
|
756
1080
|
case 'tool-result': return _jsx(ToolResultMessage, { name: item.meta ?? '', result: item.text });
|
|
757
1081
|
case 'tool-blocked': return _jsx(ToolBlockedMessage, { text: item.text });
|
|
758
|
-
case 'tool-group': return _jsx(ToolGroupMessage, {
|
|
1082
|
+
case 'tool-group': return _jsx(ToolGroupMessage, { children: item.children, collapsed: item.collapsed });
|
|
759
1083
|
case 'diff': return _jsx(DiffMessage, { text: item.text, meta: item.meta });
|
|
760
1084
|
case 'system': return _jsx(SystemMessage, { text: item.text });
|
|
761
1085
|
}
|
|
762
1086
|
});
|
|
763
|
-
const StreamingArea = memo(function StreamingArea({ text, reasoning, model, maxLines }) {
|
|
1087
|
+
const StreamingArea = memo(function StreamingArea({ text, reasoning, model, maxLines, waiting }) {
|
|
764
1088
|
const { rows } = useTermSize();
|
|
765
1089
|
const limit = maxLines ?? Math.floor(rows * 0.5);
|
|
766
1090
|
const displayText = useMemo(() => {
|
|
@@ -779,7 +1103,7 @@ const StreamingArea = memo(function StreamingArea({ text, reasoning, model, maxL
|
|
|
779
1103
|
return reasoning;
|
|
780
1104
|
return lines.slice(-3).join('\n');
|
|
781
1105
|
}, [reasoning]);
|
|
782
|
-
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 3, paddingRight: 2, marginTop: 1, children: [displayReasoning.length > 0 && (_jsx(Box, { paddingLeft: 0, marginBottom: 0, children: _jsxs(Text, { color: "magenta", dimColor: true, italic: true, wrap: "wrap", children: ['\u2502', " ", displayReasoning] }) })), displayText.length > 0 ? (_jsxs(Text, { wrap: "wrap", children: [renderMarkdown(displayText), _jsx(Text, { color: "magentaBright", children: '\u258E' })] })) : displayReasoning.length === 0 ? (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "magentaBright", children: _jsx(Spinner, { type: "dots" }) }),
|
|
1106
|
+
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 3, paddingRight: 2, marginTop: 1, children: [displayReasoning.length > 0 && (_jsx(Box, { paddingLeft: 0, marginBottom: 0, children: _jsxs(Text, { color: "magenta", dimColor: true, italic: true, wrap: "wrap", children: ['\u2502', " ", displayReasoning] }) })), displayText.length > 0 ? (_jsxs(Text, { wrap: "wrap", children: [renderMarkdown(displayText), _jsx(Text, { color: "magentaBright", children: '\u258E' })] })) : displayReasoning.length === 0 ? (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "magentaBright", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: waiting ? 'Waiting for your approval...' : `Thinking${model ? ` (${model})` : ''}...` })] })) : null] }));
|
|
783
1107
|
});
|
|
784
1108
|
function ConfirmPrompt({ toolName, args, onRespond, onScrollUp, onScrollDown }) {
|
|
785
1109
|
const { cols } = useTermSize();
|
|
@@ -825,13 +1149,13 @@ function ConfirmPrompt({ toolName, args, onRespond, onScrollUp, onScrollDown })
|
|
|
825
1149
|
// (stringWidth), so wide/CJK chars keep the right border aligned. `used` is the
|
|
826
1150
|
// display width already emitted between the two \u2502 borders.
|
|
827
1151
|
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:
|
|
1152
|
+
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: pad(22) }), '\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
1153
|
const shown = truncateText(highlightStr, boxWidth - highlightLabel.length - 6);
|
|
830
1154
|
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
1155
|
})(), extraFields.slice(0, 3).map(([k, v]) => {
|
|
832
1156
|
const val = truncateText(flatten(String(v)), boxWidth - k.length - 6);
|
|
833
1157
|
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));
|
|
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: ' '
|
|
1158
|
+
}), _jsxs(Text, { color: "yellow", children: ['\u251C', border, '\u2524'] }), boxWidth - 2 >= 42 ? (_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: pad(42) }), '\u2502'] })) : (_jsxs(Text, { color: "yellow", children: ['\u2502', " ", _jsx(Text, { color: "green", bold: true, children: "[Y]" }), _jsx(Text, { children: "es " }), _jsx(Text, { color: "cyan", bold: true, children: "[A]" }), _jsx(Text, { children: "lways " }), _jsx(Text, { color: "red", bold: true, children: "[N]" }), _jsx(Text, { children: "o" }), _jsx(Text, { children: pad(20) }), '\u2502'] })), _jsxs(Text, { color: "yellow", children: ['\u2514', border, '\u2518'] })] }));
|
|
835
1159
|
}
|
|
836
1160
|
function ModelPicker({ currentModels, onSelect, onCancel }) {
|
|
837
1161
|
const [customInput, setCustomInput] = useState('');
|
|
@@ -1033,6 +1357,12 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
|
|
|
1033
1357
|
const ctrlXPending = useRef(false);
|
|
1034
1358
|
const valueRef = useRef('');
|
|
1035
1359
|
const [imageChips, setImageChips] = useState([]);
|
|
1360
|
+
// Whether a picker was on screen for the frame the user is currently looking
|
|
1361
|
+
// at. TextInput is unfocused exactly when one is (focus={!pickerActive}), so
|
|
1362
|
+
// this is the single source of truth for who owns the next keystroke. It has
|
|
1363
|
+
// to be a post-render mirror rather than a live read: see the ownership note
|
|
1364
|
+
// in useInput below.
|
|
1365
|
+
const pickerRenderedRef = useRef(false);
|
|
1036
1366
|
const onChange = useCallback((v) => {
|
|
1037
1367
|
setValue(v);
|
|
1038
1368
|
valueRef.current = v;
|
|
@@ -1251,11 +1581,20 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
|
|
|
1251
1581
|
exit();
|
|
1252
1582
|
return;
|
|
1253
1583
|
}
|
|
1254
|
-
// ──
|
|
1255
|
-
|
|
1256
|
-
|
|
1584
|
+
// ── A picker is on screen, so TextInput is unfocused and this handler owns
|
|
1585
|
+
// the keystroke. Ownership must be read from what was rendered
|
|
1586
|
+
// (pickerRenderedRef), never recomputed from valueRef.current: every
|
|
1587
|
+
// active useInput handler gets the same key in one dispatch, and when
|
|
1588
|
+
// TextInput is focused its handler runs first and has already written
|
|
1589
|
+
// the new character into the ref. Deciding from the mutated ref made
|
|
1590
|
+
// this branch fire on top of TextInput and double every character, so
|
|
1591
|
+
// "/status" arrived as "/status/status", matched no command, and went
|
|
1592
|
+
// to the model as chat. The mirror case was just as bad: the old
|
|
1593
|
+
// condition required a leading "/", so while the @-file picker held
|
|
1594
|
+
// focus neither side handled typing and the keys were dropped. ──
|
|
1595
|
+
if (pickerRenderedRef.current && !disabled) {
|
|
1257
1596
|
if (key.upArrow || key.downArrow || key.return)
|
|
1258
|
-
return;
|
|
1597
|
+
return; // SelectInput's
|
|
1259
1598
|
if (key.backspace || key.delete) {
|
|
1260
1599
|
onChange(val.slice(0, -1));
|
|
1261
1600
|
return;
|
|
@@ -1308,57 +1647,7 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
|
|
|
1308
1647
|
}
|
|
1309
1648
|
});
|
|
1310
1649
|
const commands = useMemo(() => [
|
|
1311
|
-
|
|
1312
|
-
{ cmd: '/clear', desc: 'Reset conversation' },
|
|
1313
|
-
{ cmd: '/models', desc: 'List active models' },
|
|
1314
|
-
{ cmd: '/model', desc: 'Switch model' },
|
|
1315
|
-
{ cmd: '/mode', desc: 'Permission mode (plan/default/auto/yolo)' },
|
|
1316
|
-
{ cmd: '/thinking', desc: 'Thinking mode (off/low/medium/high)' },
|
|
1317
|
-
{ cmd: '/plan', desc: 'Enter plan mode (read-only)' },
|
|
1318
|
-
{ cmd: '/compact', desc: 'Compress conversation context' },
|
|
1319
|
-
{ cmd: '/context', desc: 'Show context usage & token count' },
|
|
1320
|
-
{ cmd: '/cost', desc: 'Show token usage & cost' },
|
|
1321
|
-
{ cmd: '/diff', desc: 'Show uncommitted changes' },
|
|
1322
|
-
{ cmd: '/copy', desc: 'Copy last response to clipboard' },
|
|
1323
|
-
{ cmd: '/save', desc: 'Save current session' },
|
|
1324
|
-
{ cmd: '/load', desc: 'Resume a saved session' },
|
|
1325
|
-
{ cmd: '/resume', desc: 'Resume from crash recovery or saved session' },
|
|
1326
|
-
{ cmd: '/fork', desc: 'Fork conversation from a point in history' },
|
|
1327
|
-
{ cmd: '/export', desc: 'Export conversation as text' },
|
|
1328
|
-
{ cmd: '/index', desc: 'Scan & index project files' },
|
|
1329
|
-
{ cmd: '/init', desc: 'Initialize project config (SCALIX.md)' },
|
|
1330
|
-
{ cmd: '/git', desc: 'Show git context' },
|
|
1331
|
-
{ cmd: '/memory', desc: 'Show project memory' },
|
|
1332
|
-
{ cmd: '/remember', desc: 'Save a note about this project' },
|
|
1333
|
-
{ cmd: '/replay', desc: 'Show session timeline' },
|
|
1334
|
-
{ cmd: '/plugins', desc: 'Show loaded plugins' },
|
|
1335
|
-
{ cmd: '/trust', desc: 'Trust this workspace (load project plugins/hooks/rules)' },
|
|
1336
|
-
{ cmd: '/router', desc: 'Show model routing status' },
|
|
1337
|
-
{ cmd: '/status', desc: 'Version, model & connection info' },
|
|
1338
|
-
{ cmd: '/doctor', desc: 'Diagnose installation' },
|
|
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)' },
|
|
1342
|
-
{ cmd: '/setup', desc: 'Reconfigure API key, URL & models' },
|
|
1343
|
-
{ cmd: '/settings', desc: 'View & configure limits' },
|
|
1344
|
-
{ cmd: '/permissions', desc: 'View permission rules & recent denials' },
|
|
1345
|
-
{ cmd: '/shortcuts', desc: 'Keyboard shortcuts' },
|
|
1346
|
-
{ cmd: '/hooks', desc: 'Show lifecycle hooks' },
|
|
1347
|
-
{ cmd: '/commands', desc: 'Show custom commands' },
|
|
1348
|
-
{ cmd: '/skills', desc: 'List available skills' },
|
|
1349
|
-
{ cmd: '/delegate', desc: 'Run task with sub-agent' },
|
|
1350
|
-
{ cmd: '/agents', desc: 'Show active agents & usage' },
|
|
1351
|
-
{ cmd: '/checkpoint', desc: 'Create git checkpoint' },
|
|
1352
|
-
{ cmd: '/undo', desc: 'Undo last checkpoint' },
|
|
1353
|
-
{ cmd: '/checkpoints', desc: 'List recent checkpoints' },
|
|
1354
|
-
{ cmd: '/commit', desc: 'Auto-generate commit message & commit' },
|
|
1355
|
-
{ cmd: '/review', desc: 'AI code review of current changes' },
|
|
1356
|
-
{ cmd: '/watch', desc: 'Run command, auto-fix on failure' },
|
|
1357
|
-
{ cmd: '/create', desc: 'Scaffold a new project' },
|
|
1358
|
-
{ cmd: '/worktree', desc: 'Manage git worktrees for isolated work' },
|
|
1359
|
-
{ cmd: '/plugin', desc: 'Install/enable/disable/uninstall plugins' },
|
|
1360
|
-
{ cmd: '/notify', desc: 'Test terminal notification' },
|
|
1361
|
-
{ cmd: '/exit', desc: 'Save & exit' },
|
|
1650
|
+
...SLASH_COMMANDS,
|
|
1362
1651
|
...customCommands.map((c) => ({ cmd: `/${c.name}`, desc: c.description })),
|
|
1363
1652
|
], [customCommands]);
|
|
1364
1653
|
const prefix = value.split(' ')[0].toLowerCase();
|
|
@@ -1384,7 +1673,11 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
|
|
|
1384
1673
|
})
|
|
1385
1674
|
.slice(0, 10);
|
|
1386
1675
|
}, [showFilePicker, atQuery]);
|
|
1387
|
-
const pickerActive = (showCommandPicker && filteredCommands.length > 0) || (showFilePicker && filteredFiles.length > 0);
|
|
1676
|
+
const pickerActive = (showCommandPicker && filteredCommands.length > 0) || !!(showFilePicker && filteredFiles.length > 0);
|
|
1677
|
+
// Mirror the rendered picker state for useInput. This runs after commit and
|
|
1678
|
+
// before any keystroke reaches the handler, so it always describes the frame
|
|
1679
|
+
// the user is actually typing against.
|
|
1680
|
+
useEffect(() => { pickerRenderedRef.current = pickerActive; }, [pickerActive]);
|
|
1388
1681
|
useEffect(() => {
|
|
1389
1682
|
if (!onHeightChange)
|
|
1390
1683
|
return;
|
|
@@ -1415,7 +1708,15 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
|
|
|
1415
1708
|
// Replace @query with the selected file path
|
|
1416
1709
|
const newValue = value.replace(/@\S*$/, '@' + item.value + ' ');
|
|
1417
1710
|
onChange(newValue);
|
|
1418
|
-
}, limit: 10 }), _jsxs(Text, { dimColor: true, italic: true, children: ['↑↓', " navigate ", '•', " Enter select ", '•', " keep typing to filter"] })] })), searchMode && (_jsxs(Box, { paddingX: 3, children: [_jsxs(Text, { color: "cyan", dimColor: true, children: ['🔍', " reverse-i-search: "] }), _jsx(Text, { color: "cyan", bold: true, children: searchQuery || '' }), _jsx(Text, { dimColor: true, children: " (Enter to select, Esc to cancel)" })] })), imageChips.length > 0 && !disabled && (_jsxs(Box, { paddingX: 3, gap: 1, children: [imageChips.map((_, i) => (_jsxs(Text, { color: "blue", bold: true, children: ["[Image #", i + 1, "]"] }, i))), _jsxs(Text, { dimColor: true, children: ["(", imageChips.length, " pasted)"] })] })), stash.length > 0 && !disabled && !searchMode && (_jsxs(Box, { paddingX: 3, children: [_jsxs(Text, { color: "magenta", dimColor: true, children: ['⚑', " Stashed: ", truncate(stash, 40), " "] }), _jsx(Text, { dimColor: true, children: "(Ctrl+S to restore)" })] })), _jsx(Box, { paddingX: 2, children: _jsxs(Box, { flexGrow: 1, children: [_jsx(Box, { flexShrink: 0, width: 2, children: disabled ? (_jsx(Text, { color: "magentaBright", children: _jsx(Spinner, { type: "dots" }) })) : (_jsx(Text, { color: "magentaBright", bold: true, children: '❯' })) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput
|
|
1711
|
+
}, limit: 10 }), _jsxs(Text, { dimColor: true, italic: true, children: ['↑↓', " navigate ", '•', " Enter select ", '•', " keep typing to filter"] })] })), searchMode && (_jsxs(Box, { paddingX: 3, children: [_jsxs(Text, { color: "cyan", dimColor: true, children: ['🔍', " reverse-i-search: "] }), _jsx(Text, { color: "cyan", bold: true, children: searchQuery || '' }), _jsx(Text, { dimColor: true, children: " (Enter to select, Esc to cancel)" })] })), imageChips.length > 0 && !disabled && (_jsxs(Box, { paddingX: 3, gap: 1, children: [imageChips.map((_, i) => (_jsxs(Text, { color: "blue", bold: true, children: ["[Image #", i + 1, "]"] }, i))), _jsxs(Text, { dimColor: true, children: ["(", imageChips.length, " pasted)"] })] })), stash.length > 0 && !disabled && !searchMode && (_jsxs(Box, { paddingX: 3, children: [_jsxs(Text, { color: "magenta", dimColor: true, children: ['⚑', " Stashed: ", truncate(stash, 40), " "] }), _jsx(Text, { dimColor: true, children: "(Ctrl+S to restore)" })] })), _jsx(Box, { paddingX: 2, children: _jsxs(Box, { flexGrow: 1, children: [_jsx(Box, { flexShrink: 0, width: 2, children: disabled ? (_jsx(Text, { color: "magentaBright", children: _jsx(Spinner, { type: "dots" }) })) : (_jsx(Text, { color: "magentaBright", bold: true, children: '❯' })) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput
|
|
1712
|
+
// Remount across picker transitions so the caret lands at the end
|
|
1713
|
+
// of the text. TextInput owns its cursor offset and only re-syncs
|
|
1714
|
+
// it while focused, but the picker branch above edits the value
|
|
1715
|
+
// while it is unfocused — so on the way back the caret was left
|
|
1716
|
+
// wherever the picker opened, and backspace on "see @noz" deleted
|
|
1717
|
+
// the "@" instead of the "z". Remounting re-derives the offset
|
|
1718
|
+
// from the current value; nothing else here is stateful.
|
|
1719
|
+
, { value: value, onChange: onChange, onSubmit: handleSubmit, placeholder: disabled ? 'Queue a message... (sent after current task)' : 'Type a message...', showCursor: true, focus: !pickerActive }, pickerActive ? 'picker' : 'typing') }), disabled && (_jsx(Box, { flexShrink: 0, children: _jsx(Text, { dimColor: true, children: " ^C interrupt" }) }))] }) })] }));
|
|
1419
1720
|
});
|
|
1420
1721
|
// ─── App ───
|
|
1421
1722
|
function estimateLines(item, cols) {
|
|
@@ -1479,8 +1780,12 @@ function useScrollableItems(items, availableHeight, cols, isLoading, streamLineC
|
|
|
1479
1780
|
setScrollOffset(0);
|
|
1480
1781
|
autoScroll.current = true;
|
|
1481
1782
|
}, []);
|
|
1482
|
-
|
|
1483
|
-
|
|
1783
|
+
// The reservation can't exceed what there is to reserve from, or a short
|
|
1784
|
+
// terminal ends up selecting items for a negative-height window and falling
|
|
1785
|
+
// back on the floor, which puts more rows in the tree than the terminal has.
|
|
1786
|
+
const wanted = Math.max(4, Math.min(streamLineCount + 3, Math.floor(availableHeight * 0.5)));
|
|
1787
|
+
const reservedForStream = isLoading ? Math.min(wanted, Math.max(availableHeight - 1, 0)) : 0;
|
|
1788
|
+
const effectiveHeight = Math.max(availableHeight - reservedForStream, 1);
|
|
1484
1789
|
const visibleItems = [];
|
|
1485
1790
|
let usedLines = 0;
|
|
1486
1791
|
const endIdx = items.length - scrollOffset;
|
|
@@ -1537,21 +1842,30 @@ function App({ config }) {
|
|
|
1537
1842
|
const collapseToolGroups = useCallback(() => {
|
|
1538
1843
|
setItems(prev => {
|
|
1539
1844
|
const isToolItem = (k) => k === 'tool-call' || k === 'tool-result';
|
|
1540
|
-
let
|
|
1541
|
-
while (
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1845
|
+
let start = prev.length;
|
|
1846
|
+
while (start > 0 && isToolItem(prev[start - 1].kind))
|
|
1847
|
+
start--;
|
|
1848
|
+
// Tool results land on the step after their calls, and this runs once per
|
|
1849
|
+
// step — so one turn of three tools built two separate "Ran N tools" rows,
|
|
1850
|
+
// the first holding the calls and the second the results. Absorb the group
|
|
1851
|
+
// from the previous step instead of stacking another one beside it, and
|
|
1852
|
+
// carry its collapsed flag over so Ctrl+O doesn't undo itself.
|
|
1853
|
+
const absorb = start > 0 && prev[start - 1].kind === 'tool-group';
|
|
1854
|
+
if (absorb && start === prev.length)
|
|
1855
|
+
return prev; // nothing new to fold in
|
|
1856
|
+
const previous = absorb ? prev[start - 1] : null;
|
|
1857
|
+
const toolItems = [...(previous?.children ?? []), ...prev.slice(start)];
|
|
1858
|
+
const calls = toolItems.filter((t) => t.kind === 'tool-call').length;
|
|
1859
|
+
if ((calls || toolItems.length) < TOOL_GROUP_THRESHOLD)
|
|
1546
1860
|
return prev;
|
|
1547
1861
|
const group = {
|
|
1548
|
-
id: nextId(),
|
|
1862
|
+
id: previous?.id ?? nextId(),
|
|
1549
1863
|
kind: 'tool-group',
|
|
1550
|
-
text: `Ran ${
|
|
1864
|
+
text: `Ran ${calls} tools`,
|
|
1551
1865
|
children: toolItems,
|
|
1552
|
-
collapsed: true,
|
|
1866
|
+
collapsed: previous?.collapsed ?? true,
|
|
1553
1867
|
};
|
|
1554
|
-
return [...prev.slice(0,
|
|
1868
|
+
return [...prev.slice(0, absorb ? start - 1 : start), group];
|
|
1555
1869
|
});
|
|
1556
1870
|
}, []);
|
|
1557
1871
|
const toggleToolGroups = useCallback(() => {
|
|
@@ -1664,12 +1978,10 @@ function App({ config }) {
|
|
|
1664
1978
|
add('tool-result', summary, name);
|
|
1665
1979
|
return;
|
|
1666
1980
|
}
|
|
1667
|
-
let text =
|
|
1981
|
+
let text = summarizeToolResult(result);
|
|
1668
1982
|
const MAX_DISPLAY = 2000;
|
|
1669
|
-
if (text.length > MAX_DISPLAY)
|
|
1670
|
-
|
|
1671
|
-
text = text.slice(0, MAX_DISPLAY) + `\n... (${truncated.toLocaleString()} chars hidden)`;
|
|
1672
|
-
}
|
|
1983
|
+
if (text.length > MAX_DISPLAY)
|
|
1984
|
+
text = text.slice(0, MAX_DISPLAY) + '…';
|
|
1673
1985
|
add('tool-result', text, name);
|
|
1674
1986
|
};
|
|
1675
1987
|
const onToolBlocked = (name, reason) => {
|
|
@@ -1710,6 +2022,18 @@ function App({ config }) {
|
|
|
1710
2022
|
}
|
|
1711
2023
|
add('error', msg, undefined, hint);
|
|
1712
2024
|
};
|
|
2025
|
+
const onRetry = (info) => {
|
|
2026
|
+
// Not every retry is a failed request — an empty assistant turn is
|
|
2027
|
+
// re-asked immediately with no status and no backoff, and labelling
|
|
2028
|
+
// that "Request failed — retrying in 0ms" is wrong twice over.
|
|
2029
|
+
const what = info.status === 429 ? 'Rate limited'
|
|
2030
|
+
: info.status ? `Request failed (${info.status})`
|
|
2031
|
+
: info.reason ? truncate(info.reason.split('\n')[0], 80)
|
|
2032
|
+
: 'Request failed';
|
|
2033
|
+
const secs = info.delayMs >= 1000 ? `${Math.round(info.delayMs / 1000)}s` : `${info.delayMs}ms`;
|
|
2034
|
+
const when = info.delayMs > 0 ? ` in ${secs}` : '';
|
|
2035
|
+
add('system', `${what} — retrying${when} (attempt ${info.attempt}/${info.maxAttempts})`);
|
|
2036
|
+
};
|
|
1713
2037
|
const onContextCompress = (info) => {
|
|
1714
2038
|
add('system', `Context compressed: ${formatTokens(info.saved)} tokens saved (${formatTokens(info.before)} \u2192 ${formatTokens(info.after)})`);
|
|
1715
2039
|
};
|
|
@@ -1735,6 +2059,7 @@ function App({ config }) {
|
|
|
1735
2059
|
agent.on('verify:pass', onVerifyPass);
|
|
1736
2060
|
agent.on('verify:fail', onVerifyFail);
|
|
1737
2061
|
agent.on('file:modified', onFileModified);
|
|
2062
|
+
agent.on('retry', onRetry);
|
|
1738
2063
|
agent.on('context:compress', onContextCompress);
|
|
1739
2064
|
agent.on('context:usage', onContextUsage);
|
|
1740
2065
|
const currentAgent = agent;
|
|
@@ -1758,6 +2083,7 @@ function App({ config }) {
|
|
|
1758
2083
|
currentAgent.off('verify:pass', onVerifyPass);
|
|
1759
2084
|
currentAgent.off('verify:fail', onVerifyFail);
|
|
1760
2085
|
currentAgent.off('file:modified', onFileModified);
|
|
2086
|
+
currentAgent.off('retry', onRetry);
|
|
1761
2087
|
currentAgent.off('context:compress', onContextCompress);
|
|
1762
2088
|
currentAgent.off('context:usage', onContextUsage);
|
|
1763
2089
|
};
|
|
@@ -1782,7 +2108,6 @@ function App({ config }) {
|
|
|
1782
2108
|
useEffect(() => {
|
|
1783
2109
|
if (!agent)
|
|
1784
2110
|
return;
|
|
1785
|
-
const hasKey = !!agent.getApiKey();
|
|
1786
2111
|
let cancelled = false;
|
|
1787
2112
|
connectCloud(agent).then((state) => {
|
|
1788
2113
|
if (cancelled)
|
|
@@ -1798,12 +2123,24 @@ function App({ config }) {
|
|
|
1798
2123
|
add('system', `Try:\n${CLOUD_TRY_PROMPTS.map((p) => ` ❯ ${p}`).join('\n')}`);
|
|
1799
2124
|
}
|
|
1800
2125
|
}
|
|
1801
|
-
else if (
|
|
1802
|
-
|
|
2126
|
+
else if (state.reason === 'failed') {
|
|
2127
|
+
// Only when we actually tried and were refused. `hasKey` used to gate
|
|
2128
|
+
// this, which fired on BYOK too (the provider key counts as a key) and
|
|
2129
|
+
// announced a Scalix Cloud outage to a session that never dialled it.
|
|
2130
|
+
add('system', cloudStatusLine(state));
|
|
1803
2131
|
}
|
|
1804
2132
|
});
|
|
1805
2133
|
return () => { cancelled = true; };
|
|
1806
2134
|
}, [add, agentVersion]);
|
|
2135
|
+
// A key the keychain refused is now sitting in config.json as plaintext.
|
|
2136
|
+
// saveConfig() prefers that over losing the key, but the user gets told —
|
|
2137
|
+
// a secret landing in a readable file is not a detail to swallow.
|
|
2138
|
+
useEffect(() => {
|
|
2139
|
+
if (!plaintextKeyPending)
|
|
2140
|
+
return;
|
|
2141
|
+
plaintextKeyPending = false;
|
|
2142
|
+
add('system', `⚠ Your API key could not be stored in the OS keychain — it was written to ~/${USER_CONFIG_DIR}/config.json instead (file mode 600, but plaintext). Re-run /setup once the keychain is reachable to move it.`);
|
|
2143
|
+
}, [add]);
|
|
1807
2144
|
// Idle session nudge: suggest /clear after 75+ minutes of inactivity
|
|
1808
2145
|
const lastActivityRef = useRef(Date.now());
|
|
1809
2146
|
const idleNudgedRef = useRef(false);
|
|
@@ -1877,71 +2214,7 @@ function App({ config }) {
|
|
|
1877
2214
|
const handleCommand = useCallback((text) => {
|
|
1878
2215
|
const cmd = text.split(' ')[0].toLowerCase();
|
|
1879
2216
|
if (cmd === '/help') {
|
|
1880
|
-
const helpLines = [
|
|
1881
|
-
'Session & Navigation:',
|
|
1882
|
-
' /help \u2014 show commands',
|
|
1883
|
-
' /shortcuts \u2014 keyboard shortcuts',
|
|
1884
|
-
' /clear \u2014 reset conversation',
|
|
1885
|
-
' /save [name] \u2014 save current session',
|
|
1886
|
-
' /load \u2014 resume a saved session',
|
|
1887
|
-
' /export [f] \u2014 export conversation as text',
|
|
1888
|
-
' /exit \u2014 save & exit',
|
|
1889
|
-
'',
|
|
1890
|
-
'Model & Mode:',
|
|
1891
|
-
' /models \u2014 list active models',
|
|
1892
|
-
' /model \u2014 switch model',
|
|
1893
|
-
' /mode [m] \u2014 permission mode (plan/default/auto/yolo)',
|
|
1894
|
-
' /thinking [m] \u2014 thinking mode (off/low/medium/high)',
|
|
1895
|
-
' /plan \u2014 enter plan mode (read-only)',
|
|
1896
|
-
'',
|
|
1897
|
-
'Context & Cost:',
|
|
1898
|
-
' /compact \u2014 compress conversation context',
|
|
1899
|
-
' /context \u2014 show context usage & token count',
|
|
1900
|
-
' /cost \u2014 show token usage & cost',
|
|
1901
|
-
'',
|
|
1902
|
-
'Code & Git:',
|
|
1903
|
-
' /diff \u2014 show uncommitted changes',
|
|
1904
|
-
' /commit \u2014 auto-generate commit message & commit',
|
|
1905
|
-
' /review \u2014 AI code review of current changes',
|
|
1906
|
-
' /checkpoint \u2014 create git checkpoint',
|
|
1907
|
-
' /undo \u2014 undo last checkpoint',
|
|
1908
|
-
' /checkpoints \u2014 list recent checkpoints',
|
|
1909
|
-
' /worktree \u2014 manage git worktrees (list/create/remove/cleanup)',
|
|
1910
|
-
' /git \u2014 show git context',
|
|
1911
|
-
'',
|
|
1912
|
-
'Project:',
|
|
1913
|
-
' /init \u2014 initialize project config (SCALIX.md)',
|
|
1914
|
-
' /index \u2014 scan & index project files',
|
|
1915
|
-
' /memory \u2014 show project memory',
|
|
1916
|
-
' /remember <n> \u2014 save a note about this project',
|
|
1917
|
-
' /watch <cmd> \u2014 run command, auto-fix on failure',
|
|
1918
|
-
' /create <d> \u2014 scaffold a new project',
|
|
1919
|
-
' /delegate <t> \u2014 run task with sub-agent',
|
|
1920
|
-
' /agents \u2014 show active agents & usage',
|
|
1921
|
-
'',
|
|
1922
|
-
'Tools & Config:',
|
|
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)',
|
|
1926
|
-
' /status \u2014 show version, model & connection info',
|
|
1927
|
-
' /update \u2014 check for and install updates',
|
|
1928
|
-
' /doctor \u2014 diagnose installation',
|
|
1929
|
-
' /settings \u2014 view & configure limits',
|
|
1930
|
-
' /setup \u2014 reconfigure API key, URL & models',
|
|
1931
|
-
' /mcp <l> <u> \u2014 add MCP server',
|
|
1932
|
-
' /plugins \u2014 show loaded plugins',
|
|
1933
|
-
' /plugin \u2014 install/enable/disable/uninstall plugins',
|
|
1934
|
-
' /trust \u2014 trust workspace (load project plugins/hooks/rules)',
|
|
1935
|
-
' /notify \u2014 test terminal notification',
|
|
1936
|
-
' /router \u2014 show model routing status',
|
|
1937
|
-
' /replay \u2014 show session timeline',
|
|
1938
|
-
' /hooks \u2014 show lifecycle hooks',
|
|
1939
|
-
' /commands \u2014 show custom commands',
|
|
1940
|
-
'',
|
|
1941
|
-
'Input modes:',
|
|
1942
|
-
' !<cmd> \u2014 run shell command (e.g. !ls -la)',
|
|
1943
|
-
' text\\ \u2014 multiline input (end with \\ to continue)',
|
|
1944
|
-
];
|
|
2217
|
+
const helpLines = [...HELP_LINES];
|
|
1945
2218
|
if (customCommands.length > 0) {
|
|
1946
2219
|
helpLines.push('', 'Custom commands:');
|
|
1947
2220
|
for (const c of customCommands)
|
|
@@ -2004,7 +2277,7 @@ function App({ config }) {
|
|
|
2004
2277
|
return true;
|
|
2005
2278
|
}
|
|
2006
2279
|
if (cmd === '/cost') {
|
|
2007
|
-
add('system', costTracker.summary());
|
|
2280
|
+
add('system', costTracker.summary(resolveActiveProvider(config).provider));
|
|
2008
2281
|
return true;
|
|
2009
2282
|
}
|
|
2010
2283
|
if (cmd === '/save') {
|
|
@@ -2171,9 +2444,7 @@ function App({ config }) {
|
|
|
2171
2444
|
add('system', `Platform tools: ${s.toolCount} registered ${'—'} /cloud tools to list them`);
|
|
2172
2445
|
}
|
|
2173
2446
|
else {
|
|
2174
|
-
add('system',
|
|
2175
|
-
if (s.error)
|
|
2176
|
-
add('system', `Last error: ${s.error}`);
|
|
2447
|
+
add('system', cloudStatusLine(s));
|
|
2177
2448
|
}
|
|
2178
2449
|
return true;
|
|
2179
2450
|
}
|
|
@@ -2182,7 +2453,7 @@ function App({ config }) {
|
|
|
2182
2453
|
if (tools.length === 0) {
|
|
2183
2454
|
add('system', cloudConnection.connected
|
|
2184
2455
|
? 'No platform tools are registered for this key (its scopes unlock none).'
|
|
2185
|
-
:
|
|
2456
|
+
: cloudStatusLine(cloudConnection));
|
|
2186
2457
|
return true;
|
|
2187
2458
|
}
|
|
2188
2459
|
add('system', `Platform tools (${tools.length}):`);
|
|
@@ -2200,7 +2471,7 @@ function App({ config }) {
|
|
|
2200
2471
|
add('system', `☁ Connected ${'—'} ${plan}${state.toolCount} platform tools`);
|
|
2201
2472
|
}
|
|
2202
2473
|
else {
|
|
2203
|
-
add('error',
|
|
2474
|
+
add('error', cloudStatusLine(state));
|
|
2204
2475
|
}
|
|
2205
2476
|
});
|
|
2206
2477
|
return true;
|
|
@@ -2805,11 +3076,16 @@ function App({ config }) {
|
|
|
2805
3076
|
const ctx = agent.getContextUsage();
|
|
2806
3077
|
const msgCount = agent.getMessages().length;
|
|
2807
3078
|
add('system', `${PRODUCT_NAME} v${VERSION}`);
|
|
3079
|
+
const activeProv = resolveActiveProvider(config);
|
|
3080
|
+
add('system', `Provider: ${activeProv.provider === 'openai' ? 'OpenAI-compatible (BYOK)' : 'Scalix Cloud'}`);
|
|
2808
3081
|
add('system', `Model: ${modelsStr}`);
|
|
2809
3082
|
add('system', `API: ${baseUrl}`);
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
3083
|
+
if (activeProv.provider === 'scalix') {
|
|
3084
|
+
add('system', `Cloud: ${cloudStatusLine(cloudConnection)}`);
|
|
3085
|
+
}
|
|
3086
|
+
else {
|
|
3087
|
+
add('system', `Cloud: platform tools are Scalix-only ${'—'} /setup to switch provider`);
|
|
3088
|
+
}
|
|
2813
3089
|
add('system', `Mode: ${mode}`);
|
|
2814
3090
|
add('system', `Context: ${ctx.percentage}% (${formatTokens(ctx.tokens)} / ${formatTokens(ctx.maxTokens)})`);
|
|
2815
3091
|
add('system', `Messages: ${msgCount}`);
|
|
@@ -3037,13 +3313,24 @@ function App({ config }) {
|
|
|
3037
3313
|
setStatus('thinking');
|
|
3038
3314
|
setThinkStart(Date.now());
|
|
3039
3315
|
try {
|
|
3040
|
-
const result = await agent.send(finalText, images);
|
|
3041
|
-
|
|
3042
|
-
|
|
3316
|
+
const result = (await agent.send(finalText, images))?.trim() ?? '';
|
|
3317
|
+
// send() answers with a sentinel rather than throwing when the model
|
|
3318
|
+
// gives back nothing at all — five empty turns in a row come back as
|
|
3319
|
+
// '[no response]', which is non-empty and so slipped past the old
|
|
3320
|
+
// check. Nothing else renders it either, so the whole turn was silence:
|
|
3321
|
+
// the prompt echoed and then no output, indistinguishable from a hang.
|
|
3322
|
+
// '[interrupted]' is deliberately excluded — the Ctrl+C path already
|
|
3323
|
+
// prints its own note and would otherwise double up.
|
|
3324
|
+
if (!result || result === '[no response]') {
|
|
3325
|
+
add('system', 'The model returned an empty response. Try rephrasing, or switch models with /model.');
|
|
3043
3326
|
}
|
|
3044
3327
|
}
|
|
3045
3328
|
catch (e) {
|
|
3046
|
-
|
|
3329
|
+
// agent.send() emits 'error' before it rethrows, and onError renders that
|
|
3330
|
+
// with the model name and a recovery hint. Printing here as well gave
|
|
3331
|
+
// every failed turn two entries, the second one strictly worse.
|
|
3332
|
+
if (!e?.[REPORTED_ERROR])
|
|
3333
|
+
add('error', e.message || 'Unknown error');
|
|
3047
3334
|
}
|
|
3048
3335
|
}, [add, handleCommand]);
|
|
3049
3336
|
const handleSubmit = useCallback(async (text) => {
|
|
@@ -3198,7 +3485,7 @@ function App({ config }) {
|
|
|
3198
3485
|
}, [add]);
|
|
3199
3486
|
const [inputSectionHeight, setInputSectionHeight] = useState(3);
|
|
3200
3487
|
const handleInputHeightChange = useCallback((h) => setInputSectionHeight(h), []);
|
|
3201
|
-
const bannerHeight = showBanner ? 5 : 0;
|
|
3488
|
+
const bannerHeight = showBanner && bannerFits(cols, rows) ? 5 : 0;
|
|
3202
3489
|
const statusBarHeight = 1;
|
|
3203
3490
|
const bottomHeight = useMemo(() => {
|
|
3204
3491
|
if (confirmInfo) {
|
|
@@ -3211,7 +3498,12 @@ function App({ config }) {
|
|
|
3211
3498
|
}
|
|
3212
3499
|
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);
|
|
3213
3500
|
const hasHighlight = !!(parsed.command ?? parsed.filePath ?? parsed.file_path ?? parsed.notebook_path ?? parsed.message);
|
|
3214
|
-
|
|
3501
|
+
// 6 fixed rows: top border, title, Tool:, divider, key hints, bottom
|
|
3502
|
+
// border. This said 5, so with the dialog up the tree was always one row
|
|
3503
|
+
// taller than the budget — invisible on a big terminal, but at 12 rows it
|
|
3504
|
+
// pushed the closing border off the screen and left the status bar with
|
|
3505
|
+
// the scroll hint overdrawn on top of it.
|
|
3506
|
+
return 6 + (hasHighlight ? 1 : 0) + extraCount;
|
|
3215
3507
|
}
|
|
3216
3508
|
if (selectingModel)
|
|
3217
3509
|
return 6 + Math.min(models.length + 2, 12);
|
|
@@ -3224,7 +3516,13 @@ function App({ config }) {
|
|
|
3224
3516
|
return inputSectionHeight;
|
|
3225
3517
|
}, [confirmInfo, selectingModel, selectingMode, editingSettings, sessionList, models.length, rows, inputSectionHeight]);
|
|
3226
3518
|
const chromeHeight = bannerHeight + statusBarHeight + bottomHeight;
|
|
3227
|
-
|
|
3519
|
+
// Floor at 1, not 6. On a short terminal the floor is exactly what breaks the
|
|
3520
|
+
// frame: at 12 rows with the permission dialog open the chrome needs 7, and a
|
|
3521
|
+
// 6-row floor made the tree 13 rows tall inside a 12-row terminal. Ink drives
|
|
3522
|
+
// redraws with cursor-up sequences counted against its own frame height, so
|
|
3523
|
+
// once the frame is taller than the terminal the erase desyncs — the dialog's
|
|
3524
|
+
// closing border scrolled away and the banner got overdrawn by the status bar.
|
|
3525
|
+
const chatHeight = Math.max(rows - chromeHeight, 1);
|
|
3228
3526
|
const streamLineCount = useMemo(() => {
|
|
3229
3527
|
if (!isLoading)
|
|
3230
3528
|
return 0;
|
|
@@ -3244,7 +3542,7 @@ function App({ config }) {
|
|
|
3244
3542
|
if (showSetup) {
|
|
3245
3543
|
return _jsx(SetupScreen, { onComplete: handleSetupDone, existingConfig: config });
|
|
3246
3544
|
}
|
|
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) => {
|
|
3545
|
+
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: Math.min(6, chatHeight), 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] || ''), waiting: !!confirmInfo }), !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) => {
|
|
3248
3546
|
agent.setModel(v);
|
|
3249
3547
|
setModels([v]);
|
|
3250
3548
|
setSelectingModel(false);
|
|
@@ -3300,8 +3598,13 @@ function Root() {
|
|
|
3300
3598
|
c.apiKey = envKey;
|
|
3301
3599
|
setConfig(c);
|
|
3302
3600
|
if (isConfigured(c)) {
|
|
3303
|
-
// Fetch model specs from API in background (uses user-defined/built-in
|
|
3304
|
-
|
|
3601
|
+
// Fetch model specs from API in background (uses user-defined/built-in
|
|
3602
|
+
// as fallback). Scalix-shaped endpoint only — BYOK models are entered
|
|
3603
|
+
// manually with their own specs.
|
|
3604
|
+
const activeStart = resolveActiveProvider(c);
|
|
3605
|
+
if (activeStart.provider === 'scalix') {
|
|
3606
|
+
fetchModelSpecs(activeStart.baseUrl, activeStart.apiKey).catch((err) => { logger.warn('Failed to fetch model specs', { error: err.message }); });
|
|
3607
|
+
}
|
|
3305
3608
|
if (!process.env.NO_UPDATE_NOTIFIER && !c.disableUpdateCheck) {
|
|
3306
3609
|
checkForUpdate().catch(() => { });
|
|
3307
3610
|
}
|