scalix 1.4.1 → 1.5.1
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 +60 -0
- package/README.md +29 -10
- package/dist/agent-replay.js +2 -1
- package/dist/agent.js +319 -26
- package/dist/api-client.js +159 -26
- package/dist/cli.js +671 -252
- package/dist/cloud.js +94 -5
- package/dist/constants.js +96 -10
- package/dist/cost-tracker.js +43 -5
- package/dist/headless.js +44 -20
- package/dist/ipc-server.js +63 -12
- 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 +117 -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, BYOK_DEFAULT_MAX_OUTPUT_TOKENS, USER_CONFIG_DIR, getModelContextWindow, fetchModelSpecs, normalizeModelId, registerUserModels, hasExplicitModelSpec } 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, loadGlobalConfig, 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,11 +42,12 @@ 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';
|
|
47
49
|
import { isWorkspaceTrusted, trustWorkspace, hasProjectExecSurface } from './workspace-trust.js';
|
|
48
|
-
import { fetchMe, fetchCloudTools, groupCloudTools, CONSOLE_KEYS_URL } from './cloud.js';
|
|
50
|
+
import { fetchMe, fetchCloudTools, groupCloudTools, filterCatalogByScopes, cloudKeyRemedy, CONSOLE_KEYS_URL } from './cloud.js';
|
|
49
51
|
logger.setLevel('silent');
|
|
50
52
|
// Promisified async child_process helpers (keep the TUI responsive vs execSync).
|
|
51
53
|
const execFileAsync = promisify(execFile);
|
|
@@ -145,39 +147,57 @@ 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) {
|
|
157
|
-
|
|
158
|
-
|
|
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
|
+
const cloudKey = targetAgent.getCloudApiKey();
|
|
172
|
+
if (!cloudKey) {
|
|
173
|
+
cloudConnection = targetAgent.getProvider() === 'scalix'
|
|
174
|
+
? { connected: false, identity: null, toolCount: 0, reason: 'no-key', error: 'no API key configured' }
|
|
175
|
+
: { connected: false, identity: null, toolCount: 0, reason: 'byok' };
|
|
159
176
|
return cloudConnection;
|
|
160
177
|
}
|
|
161
178
|
try {
|
|
162
|
-
const identity = await fetchMe(targetAgent.
|
|
163
|
-
|
|
179
|
+
const identity = await fetchMe(targetAgent.getCloudBaseUrl() || DEFAULT_BASE_URL, cloudKey);
|
|
180
|
+
// Hand the identity over so the catalog is scope-filtered without a
|
|
181
|
+
// second /v1/me round-trip.
|
|
182
|
+
const status = await targetAgent.refreshCloudTools(identity);
|
|
164
183
|
if (status.state === 'connected') {
|
|
165
|
-
cloudConnection = { connected: true, identity, toolCount: status.toolCount };
|
|
184
|
+
cloudConnection = { connected: true, identity, toolCount: status.toolCount, totalCount: status.totalCount, note: status.note };
|
|
166
185
|
}
|
|
167
186
|
else {
|
|
168
|
-
cloudConnection = { connected: false, identity, toolCount: 0, error: status.error };
|
|
187
|
+
cloudConnection = { connected: false, identity, toolCount: 0, reason: 'failed', error: status.error, remedy: status.remedy };
|
|
169
188
|
}
|
|
170
189
|
}
|
|
171
190
|
catch (err) {
|
|
172
|
-
// /v1/me failed. Run the catalog fetch anyway
|
|
173
|
-
// stale platform tools from a previous
|
|
174
|
-
// succeeds, the tools genuinely work and we
|
|
175
|
-
|
|
191
|
+
// /v1/me failed. Run the catalog fetch anyway (null = don't re-probe
|
|
192
|
+
// /v1/me): on failure it clears any stale platform tools from a previous
|
|
193
|
+
// connect; in the rare case it succeeds, the tools genuinely work and we
|
|
194
|
+
// say so (identity unavailable, catalog unfiltered).
|
|
195
|
+
const status = await targetAgent.refreshCloudTools(null).catch(() => null);
|
|
176
196
|
if (status?.state === 'connected') {
|
|
177
|
-
cloudConnection = { connected: true, identity: null, toolCount: status.toolCount, error: err?.message || String(err) };
|
|
197
|
+
cloudConnection = { connected: true, identity: null, toolCount: status.toolCount, totalCount: status.totalCount, error: err?.message || String(err) };
|
|
178
198
|
}
|
|
179
199
|
else {
|
|
180
|
-
cloudConnection = { connected: false, identity: null, toolCount: 0, error: err?.message || String(err) };
|
|
200
|
+
cloudConnection = { connected: false, identity: null, toolCount: 0, reason: 'failed', error: err?.message || String(err), remedy: status?.remedy ?? cloudKeyRemedy(err) };
|
|
181
201
|
}
|
|
182
202
|
}
|
|
183
203
|
return cloudConnection;
|
|
@@ -245,18 +265,49 @@ async function buildEnhancedInstructions() {
|
|
|
245
265
|
}
|
|
246
266
|
return instructions;
|
|
247
267
|
}
|
|
268
|
+
/** The config the current agent was initialized from — lets command handlers
|
|
269
|
+
* (e.g. the /model picker's BYOK discovery) resolve provider/endpoint without
|
|
270
|
+
* threading the config through every component. */
|
|
271
|
+
let activeAgentConfig = null;
|
|
272
|
+
/** Under BYOK a model without a registered spec sends no max_tokens, and
|
|
273
|
+
* gateways such as OpenRouter then 400 every request — a "successful" switch
|
|
274
|
+
* that can never answer. Ensure any model activated from the TUI has a spec:
|
|
275
|
+
* the catalog-reported window when known, the standard fallbacks otherwise.
|
|
276
|
+
* Explicit user specs are never overwritten. */
|
|
277
|
+
function ensureByokSpec(config, model, contextWindow) {
|
|
278
|
+
if (!config || resolveActiveProvider(config).provider !== 'openai')
|
|
279
|
+
return;
|
|
280
|
+
if (hasExplicitModelSpec(model))
|
|
281
|
+
return;
|
|
282
|
+
const spec = {
|
|
283
|
+
contextWindow: contextWindow && contextWindow > 0 ? contextWindow : BYOK_DEFAULT_CONTEXT_TOKENS,
|
|
284
|
+
maxOutputTokens: BYOK_DEFAULT_MAX_OUTPUT_TOKENS,
|
|
285
|
+
};
|
|
286
|
+
registerUserModels({ [model]: spec });
|
|
287
|
+
loadGlobalConfig().then((cfg) => {
|
|
288
|
+
cfg.customModels = { ...cfg.customModels, [model]: spec };
|
|
289
|
+
return saveConfig(cfg);
|
|
290
|
+
}).catch((err) => { logger.warn('Failed to persist model spec', { error: err.message }); });
|
|
291
|
+
}
|
|
248
292
|
function initAgent(config) {
|
|
249
293
|
const envModels = process.env[ENV_MODEL]
|
|
250
|
-
? process.env[ENV_MODEL].split(',').map((m) => m.trim()).filter(Boolean)
|
|
294
|
+
? process.env[ENV_MODEL].split(',').map((m) => m.trim()).filter(Boolean).map(normalizeModelId)
|
|
251
295
|
: null;
|
|
252
|
-
|
|
296
|
+
// length check, not truthiness: SCALIX_MODEL="," parses to [] which is
|
|
297
|
+
// truthy — the other entry points fall back to config models, so must we.
|
|
298
|
+
const models = envModels && envModels.length > 0 ? envModels : config.models;
|
|
299
|
+
activeAgentConfig = config;
|
|
253
300
|
if (models.length === 0) {
|
|
254
301
|
throw new Error('No models configured. Please add at least one model in setup.');
|
|
255
302
|
}
|
|
256
303
|
const limits = config.limits || DEFAULT_LIMITS;
|
|
304
|
+
const active = resolveActiveProvider(config);
|
|
257
305
|
agent = createAgent({
|
|
258
|
-
apiKey:
|
|
259
|
-
baseUrl:
|
|
306
|
+
apiKey: active.apiKey,
|
|
307
|
+
baseUrl: active.baseUrl,
|
|
308
|
+
provider: active.provider,
|
|
309
|
+
cloudApiKey: active.cloudApiKey,
|
|
310
|
+
cloudBaseUrl: active.cloudBaseUrl,
|
|
260
311
|
model: models.length === 1 ? models[0] : models,
|
|
261
312
|
instructions: baseInstructions,
|
|
262
313
|
tools: allTools,
|
|
@@ -339,6 +390,54 @@ function truncate(s, max) {
|
|
|
339
390
|
const lastBreak = Math.max(cut.lastIndexOf(','), cut.lastIndexOf(' '), cut.lastIndexOf('\n'));
|
|
340
391
|
return (lastBreak > max * 0.5 ? cut.slice(0, lastBreak) : cut) + '\u2026';
|
|
341
392
|
}
|
|
393
|
+
/**
|
|
394
|
+
* Collapse a tool result to one readable transcript line.
|
|
395
|
+
*
|
|
396
|
+
* The transcript used to print `JSON.stringify(result)` verbatim, so a
|
|
397
|
+
* successful `echo hello` read as
|
|
398
|
+
* ● run_command: {"stdout":"hello-from-tool\n","stderr":"","exitCode":0…
|
|
399
|
+
* — the output buried inside its own envelope, and the part that actually
|
|
400
|
+
* matters on a bad run (a non-zero exit, an error string) pushed past the
|
|
401
|
+
* truncation point. This is display only; the model still receives the whole
|
|
402
|
+
* result object. Shapes below are the ones the tool layer really returns
|
|
403
|
+
* (tools.ts run_command/read_file, tools-extra.ts search_files).
|
|
404
|
+
*/
|
|
405
|
+
function summarizeToolResult(result) {
|
|
406
|
+
const oneLine = (s) => s.replace(/\s*\r?\n\s*/g, ' ').trim();
|
|
407
|
+
if (typeof result === 'string')
|
|
408
|
+
return oneLine(result);
|
|
409
|
+
if (result === null || typeof result !== 'object')
|
|
410
|
+
return String(result);
|
|
411
|
+
const r = result;
|
|
412
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
413
|
+
if (typeof r.error === 'string')
|
|
414
|
+
return oneLine(r.error);
|
|
415
|
+
if ('exitCode' in r || 'stdout' in r) {
|
|
416
|
+
const out = oneLine(String(r.stdout ?? ''));
|
|
417
|
+
const err = oneLine(String(r.stderr ?? ''));
|
|
418
|
+
const code = r.exitCode;
|
|
419
|
+
if (code)
|
|
420
|
+
return `exit ${code}${err || out ? ` — ${err || out}` : ''}`;
|
|
421
|
+
return out || err || 'no output';
|
|
422
|
+
}
|
|
423
|
+
if (typeof r.matchCount === 'number')
|
|
424
|
+
return plural(r.matchCount, 'match');
|
|
425
|
+
if (Array.isArray(r.files))
|
|
426
|
+
return plural(r.files.length, 'file');
|
|
427
|
+
if (Array.isArray(r.entries))
|
|
428
|
+
return plural(r.entries.length, 'file');
|
|
429
|
+
if (typeof r.content === 'string') {
|
|
430
|
+
const lines = typeof r.totalLines === 'number' ? r.totalLines : r.content.split('\n').length;
|
|
431
|
+
return plural(lines, 'line');
|
|
432
|
+
}
|
|
433
|
+
if (typeof r.message === 'string')
|
|
434
|
+
return oneLine(r.message);
|
|
435
|
+
if (r.success === true)
|
|
436
|
+
return 'done';
|
|
437
|
+
if (r.success === false)
|
|
438
|
+
return 'failed';
|
|
439
|
+
return oneLine(JSON.stringify(result));
|
|
440
|
+
}
|
|
342
441
|
function formatTokens(n) {
|
|
343
442
|
if (n >= 1000000)
|
|
344
443
|
return `${(n / 1000000).toFixed(1)}M`;
|
|
@@ -347,7 +446,18 @@ function formatTokens(n) {
|
|
|
347
446
|
return String(n);
|
|
348
447
|
}
|
|
349
448
|
// ─── Components ───
|
|
449
|
+
// BigText wraps at narrow widths — "scalix coder" is 5 rows at 80 columns but
|
|
450
|
+
// 9 at 40 — while the layout budget in App assumes a flat 5. Below these bounds
|
|
451
|
+
// the logo is dropped rather than allowed to wrap or be sliced: in a 12-row
|
|
452
|
+
// terminal it rendered as one half-height row of glyphs with the status bar
|
|
453
|
+
// drawn over the rest of it.
|
|
454
|
+
const BANNER_MIN_COLS = 62;
|
|
455
|
+
const BANNER_MIN_ROWS = 20;
|
|
456
|
+
const bannerFits = (cols, rows) => cols >= BANNER_MIN_COLS && rows >= BANNER_MIN_ROWS;
|
|
350
457
|
const Banner = memo(function Banner() {
|
|
458
|
+
const { cols, rows } = useTermSize();
|
|
459
|
+
if (!bannerFits(cols, rows))
|
|
460
|
+
return null;
|
|
351
461
|
return (_jsx(Box, { flexDirection: "column", alignItems: "center", marginBottom: 0, children: _jsx(Gradient, { name: "teen", children: _jsx(BigText, { text: PRODUCT_NAME.toLowerCase(), font: "tiny" }) }) }));
|
|
352
462
|
});
|
|
353
463
|
// Strip bracketed paste markers and control chars that leak through ink's parser.
|
|
@@ -450,18 +560,50 @@ function formatContextSize(tokens) {
|
|
|
450
560
|
return `${(tokens / 1_000).toFixed(tokens % 1_000 === 0 ? 0 : 1)}K`;
|
|
451
561
|
return String(tokens);
|
|
452
562
|
}
|
|
563
|
+
/**
|
|
564
|
+
* What to pre-fill the context-window box with.
|
|
565
|
+
*
|
|
566
|
+
* Which number is honest depends on where inference is going. Scalix Cloud
|
|
567
|
+
* publishes a per-model limit and 1M is a real ceiling there; for an arbitrary
|
|
568
|
+
* OpenAI-compatible endpoint — most self-hosted models run 8K–128K — it is a
|
|
569
|
+
* figure we invented, and inventing it high defers auto-compression past the
|
|
570
|
+
* point where the endpoint starts rejecting the request. A limit the user has
|
|
571
|
+
* already chosen always wins over both.
|
|
572
|
+
*/
|
|
573
|
+
function defaultContextTokens(p, existing) {
|
|
574
|
+
if (existing && existing !== DEFAULT_LIMITS.maxContextTokens)
|
|
575
|
+
return existing;
|
|
576
|
+
return p === 'openai' ? BYOK_DEFAULT_CONTEXT_TOKENS : DEFAULT_LIMITS.maxContextTokens;
|
|
577
|
+
}
|
|
453
578
|
function SetupScreen({ onComplete, existingConfig }) {
|
|
454
|
-
const
|
|
455
|
-
|
|
579
|
+
const [provider, setProvider] = useState(existingConfig?.provider === 'openai' ? 'openai' : 'scalix');
|
|
580
|
+
// Scalix = zero-config default; BYOK (OpenAI-compatible) skips the
|
|
581
|
+
// Scalix-only cloud step and enters models manually.
|
|
582
|
+
const STEPS = provider === 'openai'
|
|
583
|
+
? ['provider', 'api-key', 'base-url', 'models', 'context-window']
|
|
584
|
+
: ['provider', 'api-key', 'base-url', 'cloud', 'models', 'context-window'];
|
|
585
|
+
const [step, setStep] = useState('provider');
|
|
456
586
|
const [apiKey, setApiKey] = useState(existingConfig?.apiKey || process.env[ENV_API_KEY] || '');
|
|
457
587
|
const [baseUrl, setBaseUrl] = useState(existingConfig?.baseUrl || DEFAULT_BASE_URL);
|
|
588
|
+
const [baseUrlError, setBaseUrlError] = useState(false);
|
|
458
589
|
const [models, setModels] = useState(existingConfig?.models?.length ? [...existingConfig.models] : [...DEFAULT_MODELS]);
|
|
459
590
|
const [modelsMode, setModelsMode] = useState('custom');
|
|
460
591
|
const [modelInput, setModelInput] = useState('');
|
|
461
|
-
|
|
592
|
+
// Only true once the user has actually submitted an empty box. The warning
|
|
593
|
+
// used to be tied to models.length === 0, which is the state every user
|
|
594
|
+
// arrives in — so the step opened with a yellow "At least one model is
|
|
595
|
+
// required" before they had typed a character, reading as a failure.
|
|
596
|
+
const [modelsWarned, setModelsWarned] = useState(false);
|
|
597
|
+
const [contextInput, setContextInput] = useState(String(defaultContextTokens(existingConfig?.provider === 'openai' ? 'openai' : 'scalix', existingConfig?.limits?.maxContextTokens)));
|
|
462
598
|
const [apiKeyError, setApiKeyError] = useState(false);
|
|
599
|
+
/** True while saveConfig() is in flight — see the context-window submit. */
|
|
600
|
+
const [saving, setSaving] = useState(false);
|
|
463
601
|
const [fetchedModels, setFetchedModels] = useState([]);
|
|
464
602
|
const [fetchingModels, setFetchingModels] = useState(false);
|
|
603
|
+
/** Context windows a BYOK endpoint reported for its own models, by id. Only
|
|
604
|
+
* the ones it actually sent — a model missing here has no entry, never a
|
|
605
|
+
* borrowed default. */
|
|
606
|
+
const [byokContextWindows, setByokContextWindows] = useState({});
|
|
465
607
|
const [cloudCheck, setCloudCheck] = useState({ phase: 'checking' });
|
|
466
608
|
const { exit } = useApp();
|
|
467
609
|
// Validate the key against Scalix Cloud when the cloud step is entered:
|
|
@@ -474,7 +616,10 @@ function SetupScreen({ onComplete, existingConfig }) {
|
|
|
474
616
|
(async () => {
|
|
475
617
|
const identity = await fetchMe(baseUrl, apiKey);
|
|
476
618
|
const catalog = await fetchCloudTools(baseUrl, apiKey, process.env.SCALIX_PROJECT);
|
|
477
|
-
|
|
619
|
+
// Count what will actually register for this key, not the raw catalog —
|
|
620
|
+
// the wizard's "N tools unlocked" must match the session that follows.
|
|
621
|
+
const visible = filterCatalogByScopes(catalog.tools, identity.permissions.scopes);
|
|
622
|
+
return { identity, toolCount: visible.length };
|
|
478
623
|
})().then((result) => {
|
|
479
624
|
if (cancelled)
|
|
480
625
|
return;
|
|
@@ -483,12 +628,30 @@ function SetupScreen({ onComplete, existingConfig }) {
|
|
|
483
628
|
}).catch((err) => {
|
|
484
629
|
if (cancelled)
|
|
485
630
|
return;
|
|
486
|
-
setCloudCheck({ phase: 'fail', error: err?.message || String(err) });
|
|
631
|
+
setCloudCheck({ phase: 'fail', error: err?.message || String(err), remedy: cloudKeyRemedy(err) });
|
|
487
632
|
});
|
|
488
633
|
return () => { cancelled = true; };
|
|
489
634
|
// apiKey/baseUrl are intentionally not deps: they are frozen once the user
|
|
490
635
|
// advances past base-url, and the step transition is the trigger.
|
|
491
636
|
}, [step]);
|
|
637
|
+
// The smallest window across the picked models, because compression has to
|
|
638
|
+
// fit the tightest one — a session that fits the 128K model and not the 8K
|
|
639
|
+
// fallback fails the moment it falls back. null when the endpoint reported
|
|
640
|
+
// nothing for any of them.
|
|
641
|
+
const reportedContextWindow = useMemo(() => {
|
|
642
|
+
const reported = models.map((m) => byokContextWindows[m]).filter((n) => !!n);
|
|
643
|
+
return reported.length > 0 ? Math.min(...reported) : null;
|
|
644
|
+
}, [models, byokContextWindows]);
|
|
645
|
+
// Fill the box with the reported window when the step opens, but never
|
|
646
|
+
// overwrite a number the user typed themselves.
|
|
647
|
+
const contextPrefilled = useRef(false);
|
|
648
|
+
useEffect(() => {
|
|
649
|
+
if (step !== 'context-window' || contextPrefilled.current)
|
|
650
|
+
return;
|
|
651
|
+
contextPrefilled.current = true;
|
|
652
|
+
if (reportedContextWindow !== null)
|
|
653
|
+
setContextInput(String(reportedContextWindow));
|
|
654
|
+
}, [step, reportedContextWindow]);
|
|
492
655
|
const totalSteps = STEPS.length;
|
|
493
656
|
const stepNumber = STEPS.indexOf(step) + 1;
|
|
494
657
|
const goBack = useCallback(() => {
|
|
@@ -500,11 +663,18 @@ function SetupScreen({ onComplete, existingConfig }) {
|
|
|
500
663
|
}
|
|
501
664
|
}, [step]);
|
|
502
665
|
useInput((_ch, key) => {
|
|
666
|
+
// Once the save is in flight the config is already being written; letting
|
|
667
|
+
// Escape kill the process or Backspace walk back a step from here just
|
|
668
|
+
// races the write.
|
|
669
|
+
if (saving)
|
|
670
|
+
return;
|
|
503
671
|
if (key.escape)
|
|
504
672
|
exit();
|
|
505
673
|
if (key.backspace || key.delete) {
|
|
506
|
-
if (step === 'api-key' && apiKey === '')
|
|
674
|
+
if (step === 'api-key' && apiKey === '') {
|
|
675
|
+
goBack();
|
|
507
676
|
return;
|
|
677
|
+
}
|
|
508
678
|
if (step === 'base-url' && baseUrl === '') {
|
|
509
679
|
goBack();
|
|
510
680
|
return;
|
|
@@ -515,7 +685,32 @@ function SetupScreen({ onComplete, existingConfig }) {
|
|
|
515
685
|
}
|
|
516
686
|
}
|
|
517
687
|
});
|
|
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 === '
|
|
688
|
+
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: [
|
|
689
|
+
{ label: 'Scalix Cloud (recommended) \u2014 api.scalix.world, platform tools included', value: 'scalix' },
|
|
690
|
+
{ label: 'OpenAI-compatible endpoint \u2014 bring your own key (OpenAI, OpenRouter, Ollama, vLLM, ...)', value: 'openai' },
|
|
691
|
+
], onSelect: (item) => {
|
|
692
|
+
const p = item.value === 'openai' ? 'openai' : 'scalix';
|
|
693
|
+
setProvider(p);
|
|
694
|
+
if (p === 'openai') {
|
|
695
|
+
setApiKey(existingConfig?.providers?.openai?.apiKey || '');
|
|
696
|
+
setBaseUrl(existingConfig?.providers?.openai?.baseUrl || '');
|
|
697
|
+
}
|
|
698
|
+
else {
|
|
699
|
+
setApiKey(existingConfig?.apiKey || process.env[ENV_API_KEY] || '');
|
|
700
|
+
setBaseUrl(existingConfig?.baseUrl || DEFAULT_BASE_URL);
|
|
701
|
+
}
|
|
702
|
+
// Model ids do not transfer between endpoints: keeping the
|
|
703
|
+
// configured list after a switch pre-fills the next step with
|
|
704
|
+
// ids the new endpoint has never heard of, and the wizard
|
|
705
|
+
// finishes "successfully" onto a 404 on the first turn.
|
|
706
|
+
const configured = (existingConfig?.provider === 'openai' ? 'openai' : 'scalix') === p;
|
|
707
|
+
setModels(configured && existingConfig?.models?.length ? [...existingConfig.models] : []);
|
|
708
|
+
setModelsMode('custom');
|
|
709
|
+
setFetchedModels([]);
|
|
710
|
+
setByokContextWindows({});
|
|
711
|
+
setContextInput(String(defaultContextTokens(p, existingConfig?.limits?.maxContextTokens)));
|
|
712
|
+
setStep('api-key');
|
|
713
|
+
} }) })] })), 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. Use a project-scoped key — the default all-scopes project key unlocks the platform tools.' }), _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
714
|
const cleaned = v.trim();
|
|
520
715
|
if (cleaned) {
|
|
521
716
|
setApiKey(cleaned);
|
|
@@ -524,8 +719,35 @@ function SetupScreen({ onComplete, existingConfig }) {
|
|
|
524
719
|
else {
|
|
525
720
|
setApiKeyError(true);
|
|
526
721
|
}
|
|
527
|
-
}, placeholder:
|
|
528
|
-
const
|
|
722
|
+
}, 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) => {
|
|
723
|
+
const typed = sanitizeInput(v).trim();
|
|
724
|
+
if (provider === 'openai') {
|
|
725
|
+
// BYOK has no default third-party URL \u2014 the endpoint is required.
|
|
726
|
+
const url = typed || baseUrl;
|
|
727
|
+
if (!url) {
|
|
728
|
+
setBaseUrlError(true);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
setBaseUrl(url);
|
|
732
|
+
// Ask the endpoint what it serves. Without this the next
|
|
733
|
+
// step can only tell the user to go read GET /v1/models
|
|
734
|
+
// themselves, and one mistyped id fails the first turn
|
|
735
|
+
// with nothing pointing at the id as the cause.
|
|
736
|
+
setFetchingModels(true);
|
|
737
|
+
fetchByokModels(url, apiKey).then((found) => {
|
|
738
|
+
setFetchedModels(found.map((m) => ({ id: m.id })));
|
|
739
|
+
setByokContextWindows(Object.fromEntries(found.filter((m) => m.contextWindow).map((m) => [m.id, m.contextWindow])));
|
|
740
|
+
}).catch((err) => { logger.warn('Failed to list endpoint models', { error: err.message }); })
|
|
741
|
+
.finally(() => { setFetchingModels(false); });
|
|
742
|
+
setStep('models');
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
// Backspacing the pre-filled default away leaves the box
|
|
746
|
+
// showing a greyed https://api.scalix.world \u2014 the placeholder.
|
|
747
|
+
// Enter on that used to save an empty base URL, and every
|
|
748
|
+
// later request failed with a bare "fetch failed". Honour
|
|
749
|
+
// what the placeholder promises.
|
|
750
|
+
const url = typed || baseUrl || DEFAULT_BASE_URL;
|
|
529
751
|
setBaseUrl(url);
|
|
530
752
|
// Model list loads in the background while the cloud step
|
|
531
753
|
// validates the key via /v1/me + the tool catalog.
|
|
@@ -538,7 +760,7 @@ function SetupScreen({ onComplete, existingConfig }) {
|
|
|
538
760
|
setFetchingModels(false);
|
|
539
761
|
});
|
|
540
762
|
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: [
|
|
763
|
+
}, 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.remedy && (_jsx(Text, { dimColor: true, children: cloudCheck.remedy })), !cloudCheck.remedy && !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
764
|
{ label: 'Re-enter API key', value: '__reenter__' },
|
|
543
765
|
{ label: 'Skip — continue without Scalix Cloud (local tools only)', value: '__skip__' },
|
|
544
766
|
], onSelect: (item) => {
|
|
@@ -548,7 +770,7 @@ function SetupScreen({ onComplete, existingConfig }) {
|
|
|
548
770
|
else {
|
|
549
771
|
setStep('models');
|
|
550
772
|
}
|
|
551
|
-
} }) })] }))] })), step === 'models' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Models" }), fetchedModels.length
|
|
773
|
+
} }) })] }))] })), 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' ? (_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
774
|
...fetchedModels.filter((fm) => !models.includes(fm.id)).map((fm) => ({
|
|
553
775
|
label: ` ${fm.name || fm.id}`,
|
|
554
776
|
value: fm.id,
|
|
@@ -569,7 +791,23 @@ function SetupScreen({ onComplete, existingConfig }) {
|
|
|
569
791
|
setModels((prev) => [...prev, item.value]);
|
|
570
792
|
}
|
|
571
793
|
}
|
|
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: [
|
|
794
|
+
} }) }), 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
|
|
795
|
+
// Open on Continue. The list's first entries are the
|
|
796
|
+
// models themselves and choosing one deletes it, so the
|
|
797
|
+
// cursor used to start on a destructive action: the
|
|
798
|
+
// previous screen says "press Enter (empty) to review &
|
|
799
|
+
// continue", and a second Enter — the obvious way to
|
|
800
|
+
// move on — wiped the only model and dropped the user
|
|
801
|
+
// back to an empty box with a warning.
|
|
802
|
+
, {
|
|
803
|
+
// Open on Continue. The list's first entries are the
|
|
804
|
+
// models themselves and choosing one deletes it, so the
|
|
805
|
+
// cursor used to start on a destructive action: the
|
|
806
|
+
// previous screen says "press Enter (empty) to review &
|
|
807
|
+
// continue", and a second Enter — the obvious way to
|
|
808
|
+
// move on — wiped the only model and dropped the user
|
|
809
|
+
// back to an empty box with a warning.
|
|
810
|
+
initialIndex: models.length + 1, items: [
|
|
573
811
|
...models.map((m) => ({
|
|
574
812
|
label: `\u25CF ${m}`,
|
|
575
813
|
value: m,
|
|
@@ -599,35 +837,83 @@ function SetupScreen({ onComplete, existingConfig }) {
|
|
|
599
837
|
setModels((prev) => [...prev, trimmed]);
|
|
600
838
|
}
|
|
601
839
|
setModelInput('');
|
|
840
|
+
setModelsWarned(false);
|
|
602
841
|
}
|
|
603
842
|
else if (models.length > 0) {
|
|
604
843
|
setModelsMode('list');
|
|
605
844
|
}
|
|
606
|
-
|
|
845
|
+
else {
|
|
846
|
+
setModelsWarned(true);
|
|
847
|
+
}
|
|
848
|
+
}, 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' && (
|
|
849
|
+
// Say where the pre-filled number came from either way. A user who
|
|
850
|
+
// takes a guessed default on trust for an 8K local model gets
|
|
851
|
+
// rejected requests, and one who is told a real reported limit is
|
|
852
|
+
// a guess will second-guess a correct number.
|
|
853
|
+
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
854
|
const parsed = parseInt(contextInput, 10);
|
|
608
855
|
const valid = parsed > 0;
|
|
609
856
|
return valid ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "#30c090", children: ['\u25CF', " ", formatContextSize(parsed), " tokens (", parsed.toLocaleString(), ")"] }) })) : null;
|
|
610
857
|
})(), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "#20a0a0", bold: true, children: ['\u276F', " "] }), _jsx(TextInput, { value: contextInput, onChange: setContextInput, onSubmit: (v) => {
|
|
858
|
+
// saveConfig() writes the key to the OS keychain, and that
|
|
859
|
+
// subprocess is not always quick — a locked keychain or a
|
|
860
|
+
// pending "allow access?" dialog made one measured save take
|
|
861
|
+
// 19.6s. Without this flag the final Enter looked like a dead
|
|
862
|
+
// key, and a second Enter re-entered the same handler.
|
|
863
|
+
if (saving)
|
|
864
|
+
return;
|
|
865
|
+
setSaving(true);
|
|
611
866
|
const parsed = parseInt(v.trim(), 10);
|
|
612
|
-
const tokens = parsed > 0 ? parsed :
|
|
613
|
-
|
|
867
|
+
const tokens = parsed > 0 ? parsed : defaultContextTokens(provider, existingConfig?.limits?.maxContextTokens);
|
|
868
|
+
// Preserve the user's /settings customizations — building
|
|
869
|
+
// from DEFAULT_LIMITS alone silently wiped every other
|
|
870
|
+
// limit each time the wizard was re-run.
|
|
871
|
+
const limits = { ...DEFAULT_LIMITS, ...(existingConfig?.limits || {}), maxContextTokens: tokens };
|
|
872
|
+
// BYOK: the endpoint's catalog told us per-model context
|
|
873
|
+
// windows during discovery. Persist them as specs — they
|
|
874
|
+
// used to be thrown away here, leaving every model
|
|
875
|
+
// spec-less, so no max_tokens was sent and OpenRouter-class
|
|
876
|
+
// gateways rejected every request with a 400. Existing
|
|
877
|
+
// explicit specs win.
|
|
878
|
+
const customModels = { ...(existingConfig?.customModels || {}) };
|
|
879
|
+
if (provider === 'openai') {
|
|
880
|
+
for (const m of models) {
|
|
881
|
+
if (!customModels[m]) {
|
|
882
|
+
customModels[m] = {
|
|
883
|
+
contextWindow: byokContextWindows[m] && byokContextWindows[m] > 0
|
|
884
|
+
? byokContextWindows[m]
|
|
885
|
+
: BYOK_DEFAULT_CONTEXT_TOKENS,
|
|
886
|
+
maxOutputTokens: BYOK_DEFAULT_MAX_OUTPUT_TOKENS,
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
// BYOK: openai creds live under providers.openai; the
|
|
892
|
+
// top-level apiKey/baseUrl stay reserved for Scalix so
|
|
893
|
+
// switching back never loses the Scalix connection.
|
|
614
894
|
const newConfig = {
|
|
615
|
-
apiKey,
|
|
616
|
-
baseUrl,
|
|
895
|
+
apiKey: provider === 'openai' ? (existingConfig?.apiKey || '') : apiKey,
|
|
896
|
+
baseUrl: provider === 'openai' ? (existingConfig?.baseUrl || DEFAULT_BASE_URL) : baseUrl,
|
|
617
897
|
models,
|
|
618
|
-
customModels
|
|
898
|
+
customModels,
|
|
619
899
|
limits,
|
|
620
900
|
permissionMode: existingConfig?.permissionMode || 'default',
|
|
621
901
|
thinkingMode: existingConfig?.thinkingMode || 'off',
|
|
622
902
|
security: existingConfig?.security || {},
|
|
623
903
|
injectionDetection: existingConfig?.injectionDetection || { enabled: true, sensitivity: 'medium' },
|
|
904
|
+
provider,
|
|
905
|
+
providers: provider === 'openai'
|
|
906
|
+
? { ...existingConfig?.providers, openai: { apiKey, baseUrl } }
|
|
907
|
+
: existingConfig?.providers,
|
|
624
908
|
};
|
|
625
|
-
saveConfig(newConfig).then(() => {
|
|
909
|
+
saveConfig(newConfig).then((result) => {
|
|
910
|
+
if (result?.plaintextSecret)
|
|
911
|
+
plaintextKeyPending = true;
|
|
626
912
|
onComplete(newConfig);
|
|
627
913
|
}).catch(() => {
|
|
628
914
|
onComplete(newConfig);
|
|
629
915
|
});
|
|
630
|
-
}, placeholder: String(
|
|
916
|
+
}, 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
917
|
}
|
|
632
918
|
const StatusBar = memo(function StatusBar({ status, model, toolCount, thinkStart, tokens, mode, contextPercent, rateLimitWarn }) {
|
|
633
919
|
const { cols } = useTermSize();
|
|
@@ -658,35 +944,119 @@ const StatusBar = memo(function StatusBar({ status, model, toolCount, thinkStart
|
|
|
658
944
|
registry.off('agent:completed', refresh);
|
|
659
945
|
};
|
|
660
946
|
}, []);
|
|
661
|
-
const
|
|
662
|
-
leftParts.push(shortModel(model));
|
|
947
|
+
const leftSegs = [{ id: 'model', text: shortModel(model) }];
|
|
663
948
|
if (tokens > 0)
|
|
664
|
-
|
|
665
|
-
|
|
949
|
+
leftSegs.push({ id: 'tok', text: `${formatTokens(tokens)} tok` });
|
|
950
|
+
leftSegs.push({ id: 'tools', text: `${toolCount} tools` });
|
|
666
951
|
if (activeAgents > 0)
|
|
667
|
-
|
|
952
|
+
leftSegs.push({ id: 'agents', text: `${activeAgents} agent${activeAgents > 1 ? 's' : ''}` });
|
|
668
953
|
if (contextPercent > 50)
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
954
|
+
leftSegs.push({ id: 'ctx', text: `ctx ${contextPercent}%` });
|
|
955
|
+
// Ink shrinks and wraps overflowing Text nodes rather than dropping them, so
|
|
956
|
+
// a status bar that does not fit its terminal doesn't degrade \u2014 it shatters.
|
|
957
|
+
// At 40 columns this rendered as "\u00B7 1 agent lt 5.0": model
|
|
958
|
+
// name gone, "default" chewed down to "lt", "v1.5.0" to "5.0". Shed whole
|
|
959
|
+
// segments instead, cheapest first. The permission mode is a safety signal
|
|
960
|
+
// and the model name answers "what am I even talking to", so those two are
|
|
961
|
+
// the last things standing.
|
|
962
|
+
const interior = Math.max(cols - 4, 0); // paddingX={2} on both sides
|
|
963
|
+
const hidden = new Set();
|
|
964
|
+
const warnWidth = rateLimitWarn ? 15 : 0; // gap + "\u26A0 Rate limited"
|
|
965
|
+
const leftWidth = () => 2 + leftSegs.filter((s) => !hidden.has(s.id)).map((s) => s.text).join(' \u00B7 ').length;
|
|
966
|
+
const rightWidth = () => mode.length + warnWidth
|
|
967
|
+
+ (elapsedStr && !hidden.has('elapsed') ? elapsedStr.length + 3 : 0)
|
|
968
|
+
+ (hidden.has('version') ? 0 : VERSION.length + 4);
|
|
969
|
+
for (const id of ['version', 'tools', 'tok', 'elapsed', 'ctx', 'agents']) {
|
|
970
|
+
if (leftWidth() + 1 + rightWidth() <= interior)
|
|
971
|
+
break;
|
|
972
|
+
hidden.add(id);
|
|
973
|
+
}
|
|
974
|
+
let leftText = leftSegs.filter((s) => !hidden.has(s.id)).map((s) => s.text).join(' \u00B7 ');
|
|
975
|
+
const rightLen = rightWidth();
|
|
976
|
+
const overflow = 2 + leftText.length + 1 + rightLen - interior;
|
|
977
|
+
if (overflow > 0)
|
|
978
|
+
leftText = leftText.slice(0, Math.max(0, leftText.length - overflow));
|
|
979
|
+
const gap = Math.max(interior - 2 - leftText.length - rightLen, 1);
|
|
980
|
+
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
981
|
});
|
|
982
|
+
/**
|
|
983
|
+
* The single sentence the UI uses for a Scalix Cloud connection that is not
|
|
984
|
+
* up. Shared by the welcome screen, /status and /cloud so all three say the
|
|
985
|
+
* same thing about the same state.
|
|
986
|
+
*/
|
|
987
|
+
/**
|
|
988
|
+
* @param detail Include the server's own reason for a failed connect. The
|
|
989
|
+
* welcome header passes false: the startup transcript already carries the
|
|
990
|
+
* full sentence, and both were on screen at once — the same "API key
|
|
991
|
+
* rejected — create one at <url>" printed twice, three lines apart.
|
|
992
|
+
*/
|
|
993
|
+
function cloudStatusLine(cloud, detail = true) {
|
|
994
|
+
if (cloud.connected) {
|
|
995
|
+
const plan = cloud.identity ? `${cloud.identity.limits.plan} · ` : '';
|
|
996
|
+
return `☁ Scalix Cloud · ${plan}${cloud.toolCount} platform tools`;
|
|
997
|
+
}
|
|
998
|
+
if (cloud.reason === 'byok') {
|
|
999
|
+
return '☁ Scalix Cloud not in use — this session runs on your own provider (local tools only)';
|
|
1000
|
+
}
|
|
1001
|
+
if (cloud.reason === 'failed') {
|
|
1002
|
+
const extra = detail
|
|
1003
|
+
? `${cloud.error ? ` · ${cloud.error}` : ''}${cloud.remedy ? ` · ${cloud.remedy}` : ''}`
|
|
1004
|
+
: '';
|
|
1005
|
+
return `☁ Scalix Cloud unavailable — local tools only${extra}`;
|
|
1006
|
+
}
|
|
1007
|
+
return '☁ not connected — /login to link your Scalix account';
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Real tool names, as many as fit on one line.
|
|
1011
|
+
*
|
|
1012
|
+
* This line used to be nine hand-written nicknames — shell, read, write, edit,
|
|
1013
|
+
* search, find, git, web, tree — and none of them was a tool the model can
|
|
1014
|
+
* call. `tree` was the only one that even looked like a name, and there is no
|
|
1015
|
+
* `tree` tool. It also never moved when the tool set did, so a session with
|
|
1016
|
+
* plugins or MCP servers loaded advertised the same nine words as a bare one.
|
|
1017
|
+
*/
|
|
1018
|
+
function toolNamePreview(cols) {
|
|
1019
|
+
const names = [...allTools, ...pluginTools]
|
|
1020
|
+
.map((t) => t?.function?.name ?? t?.name)
|
|
1021
|
+
.filter((n) => typeof n === 'string' && n.length > 0);
|
|
1022
|
+
if (names.length === 0)
|
|
1023
|
+
return 'none loaded';
|
|
1024
|
+
// paddingX={2} on the screen (4) + paddingLeft={2} on the row (2) + the
|
|
1025
|
+
// gap space (1) + "Tools:" (6), and room for the "+N more" tail so the
|
|
1026
|
+
// count is never the thing that gets truncated away.
|
|
1027
|
+
const budget = Math.max(cols - 13 - `+${names.length} more`.length, 20);
|
|
1028
|
+
const shown = [];
|
|
1029
|
+
let used = 0;
|
|
1030
|
+
for (const n of names) {
|
|
1031
|
+
if (used + n.length + (shown.length ? 1 : 0) > budget)
|
|
1032
|
+
break;
|
|
1033
|
+
used += n.length + (shown.length ? 1 : 0);
|
|
1034
|
+
shown.push(n);
|
|
1035
|
+
}
|
|
1036
|
+
const rest = names.length - shown.length;
|
|
1037
|
+
return rest > 0 ? `${shown.join(' ')} +${rest} more` : shown.join(' ');
|
|
1038
|
+
}
|
|
676
1039
|
const WelcomeScreen = memo(function WelcomeScreen({ cloud }) {
|
|
677
|
-
|
|
1040
|
+
const { cols } = useTermSize();
|
|
1041
|
+
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
1042
|
});
|
|
679
1043
|
const UserMessage = memo(function UserMessage({ text }) {
|
|
680
|
-
return (
|
|
1044
|
+
return (_jsx(Box, { paddingX: 2, marginTop: 1, children: _jsxs(Text, { wrap: "wrap", bold: true, children: [_jsx(Text, { color: "magentaBright", children: '\u276F ' }), text] }) }));
|
|
681
1045
|
});
|
|
682
1046
|
const AssistantMessage = memo(function AssistantMessage({ text }) {
|
|
683
|
-
|
|
1047
|
+
// Width has to come from useTermSize, not from renderMarkdown's
|
|
1048
|
+
// process.stdout.columns fallback. This component is memo'd on `text`, so
|
|
1049
|
+
// without a prop that changes on resize it never re-renders — every reply
|
|
1050
|
+
// already on screen stayed wrapped to the width it was first drawn at.
|
|
1051
|
+
const { cols } = useTermSize();
|
|
1052
|
+
const rendered = renderMarkdown(text, cols);
|
|
684
1053
|
return (_jsx(Box, { paddingLeft: 3, paddingRight: 2, flexDirection: "column", marginTop: 1, children: _jsx(Text, { wrap: "wrap", children: rendered }) }));
|
|
685
1054
|
});
|
|
686
1055
|
const ErrorMessage = memo(function ErrorMessage({ text, hint }) {
|
|
687
|
-
return (_jsxs(Box, { paddingLeft: 3, paddingRight: 2, flexDirection: "column", marginTop: 0, children: [
|
|
1056
|
+
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
1057
|
});
|
|
689
1058
|
const ToolCallMessage = memo(function ToolCallMessage({ name, args }) {
|
|
1059
|
+
const { cols } = useTermSize();
|
|
690
1060
|
let summary = '';
|
|
691
1061
|
try {
|
|
692
1062
|
const parsed = JSON.parse(args);
|
|
@@ -699,22 +1069,35 @@ const ToolCallMessage = memo(function ToolCallMessage({ name, args }) {
|
|
|
699
1069
|
else if (parsed.query)
|
|
700
1070
|
summary = parsed.query;
|
|
701
1071
|
else
|
|
702
|
-
summary =
|
|
1072
|
+
summary = args;
|
|
703
1073
|
}
|
|
704
1074
|
catch {
|
|
705
|
-
summary =
|
|
1075
|
+
summary = args;
|
|
706
1076
|
}
|
|
707
|
-
|
|
1077
|
+
// paddingLeft 3 + bullet + two gap spaces + paddingRight 2 = 8 columns of
|
|
1078
|
+
// chrome. This row used to clip at a flat 80 regardless of terminal width,
|
|
1079
|
+
// which leaves Ink to shrink and wrap the flex line instead of clipping it:
|
|
1080
|
+
// at 30 columns "run_command echo hello-from-tool" came out as
|
|
1081
|
+
// "\u25CBrun_commaecho" / "nd hello-from-tool".
|
|
1082
|
+
const room = Math.max(cols - 8 - stringWidth(name), 12);
|
|
1083
|
+
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
1084
|
});
|
|
709
1085
|
const ToolResultMessage = memo(function ToolResultMessage({ name, result }) {
|
|
710
1086
|
const { cols } = useTermSize();
|
|
711
|
-
|
|
712
|
-
|
|
1087
|
+
// Floor at 12, not 40: a 40-column floor on a 30-column terminal guarantees
|
|
1088
|
+
// the flex line overflows, and Ink answers that by shrinking and wrapping
|
|
1089
|
+
// every cell rather than clipping the one that is too long.
|
|
1090
|
+
const maxLen = Math.max(cols - name.length - 10, 12);
|
|
1091
|
+
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
1092
|
});
|
|
714
1093
|
const ToolBlockedMessage = memo(function ToolBlockedMessage({ text }) {
|
|
715
1094
|
return (_jsxs(Box, { paddingLeft: 3, paddingRight: 2, gap: 1, children: [_jsx(Text, { color: "red", children: '\u25CF' }), _jsx(Text, { color: "red", children: text })] }));
|
|
716
1095
|
});
|
|
717
|
-
const ToolGroupMessage = memo(function ToolGroupMessage({
|
|
1096
|
+
const ToolGroupMessage = memo(function ToolGroupMessage({ children, collapsed }) {
|
|
1097
|
+
// Count calls, not display rows. `children` holds one 'tool-call' and one
|
|
1098
|
+
// 'tool-result' per tool, and this used to be handed children.length — so a
|
|
1099
|
+
// turn that ran two tools announced "Ran 4 tools".
|
|
1100
|
+
const count = children?.filter((c) => c.kind === 'tool-call').length || children?.length || 0;
|
|
718
1101
|
if (collapsed || !children) {
|
|
719
1102
|
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
1103
|
}
|
|
@@ -745,7 +1128,7 @@ const DiffMessage = memo(function DiffMessage({ text, meta }) {
|
|
|
745
1128
|
}) })] }));
|
|
746
1129
|
});
|
|
747
1130
|
const SystemMessage = memo(function SystemMessage({ text }) {
|
|
748
|
-
return (
|
|
1131
|
+
return (_jsx(Box, { paddingX: 2, paddingLeft: 3, marginY: 0, children: _jsx(Text, { dimColor: true, wrap: "wrap", children: `\u2500 ${text}` }) }));
|
|
749
1132
|
});
|
|
750
1133
|
const RenderItem = memo(function RenderItem({ item }) {
|
|
751
1134
|
switch (item.kind) {
|
|
@@ -755,12 +1138,12 @@ const RenderItem = memo(function RenderItem({ item }) {
|
|
|
755
1138
|
case 'tool-call': return _jsx(ToolCallMessage, { name: item.meta ?? '', args: item.text });
|
|
756
1139
|
case 'tool-result': return _jsx(ToolResultMessage, { name: item.meta ?? '', result: item.text });
|
|
757
1140
|
case 'tool-blocked': return _jsx(ToolBlockedMessage, { text: item.text });
|
|
758
|
-
case 'tool-group': return _jsx(ToolGroupMessage, {
|
|
1141
|
+
case 'tool-group': return _jsx(ToolGroupMessage, { children: item.children, collapsed: item.collapsed });
|
|
759
1142
|
case 'diff': return _jsx(DiffMessage, { text: item.text, meta: item.meta });
|
|
760
1143
|
case 'system': return _jsx(SystemMessage, { text: item.text });
|
|
761
1144
|
}
|
|
762
1145
|
});
|
|
763
|
-
const StreamingArea = memo(function StreamingArea({ text, reasoning, model, maxLines }) {
|
|
1146
|
+
const StreamingArea = memo(function StreamingArea({ text, reasoning, model, maxLines, waiting }) {
|
|
764
1147
|
const { rows } = useTermSize();
|
|
765
1148
|
const limit = maxLines ?? Math.floor(rows * 0.5);
|
|
766
1149
|
const displayText = useMemo(() => {
|
|
@@ -779,7 +1162,7 @@ const StreamingArea = memo(function StreamingArea({ text, reasoning, model, maxL
|
|
|
779
1162
|
return reasoning;
|
|
780
1163
|
return lines.slice(-3).join('\n');
|
|
781
1164
|
}, [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" }) }),
|
|
1165
|
+
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
1166
|
});
|
|
784
1167
|
function ConfirmPrompt({ toolName, args, onRespond, onScrollUp, onScrollDown }) {
|
|
785
1168
|
const { cols } = useTermSize();
|
|
@@ -825,15 +1208,15 @@ function ConfirmPrompt({ toolName, args, onRespond, onScrollUp, onScrollDown })
|
|
|
825
1208
|
// (stringWidth), so wide/CJK chars keep the right border aligned. `used` is the
|
|
826
1209
|
// display width already emitted between the two \u2502 borders.
|
|
827
1210
|
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:
|
|
1211
|
+
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
1212
|
const shown = truncateText(highlightStr, boxWidth - highlightLabel.length - 6);
|
|
830
1213
|
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
1214
|
})(), extraFields.slice(0, 3).map(([k, v]) => {
|
|
832
1215
|
const val = truncateText(flatten(String(v)), boxWidth - k.length - 6);
|
|
833
1216
|
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: ' '
|
|
1217
|
+
}), _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
1218
|
}
|
|
836
|
-
function ModelPicker({ currentModels, onSelect, onCancel }) {
|
|
1219
|
+
function ModelPicker({ currentModels, availableModels = [], onSelect, onCancel }) {
|
|
837
1220
|
const [customInput, setCustomInput] = useState('');
|
|
838
1221
|
const [showCustom, setShowCustom] = useState(false);
|
|
839
1222
|
useInput((_ch, key) => { if (key.escape) {
|
|
@@ -850,11 +1233,16 @@ function ModelPicker({ currentModels, onSelect, onCancel }) {
|
|
|
850
1233
|
setShowCustom(false);
|
|
851
1234
|
}, placeholder: "provider/model-name" })] }), _jsx(Text, { dimColor: true, children: "ESC to go back" })] }));
|
|
852
1235
|
}
|
|
1236
|
+
const catalogOnly = availableModels.filter((m) => !currentModels.includes(m));
|
|
853
1237
|
const items = [
|
|
854
1238
|
...currentModels.map((m) => ({
|
|
855
1239
|
label: `\u25CF ${shortModel(m)}`,
|
|
856
1240
|
value: m,
|
|
857
1241
|
})),
|
|
1242
|
+
...catalogOnly.map((m) => ({
|
|
1243
|
+
label: `\u25CB ${shortModel(m)}`,
|
|
1244
|
+
value: m,
|
|
1245
|
+
})),
|
|
858
1246
|
{ label: '+ Add new model...', value: '__custom__' },
|
|
859
1247
|
{ label: ' Cancel', value: '__cancel__' },
|
|
860
1248
|
];
|
|
@@ -1033,6 +1421,12 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
|
|
|
1033
1421
|
const ctrlXPending = useRef(false);
|
|
1034
1422
|
const valueRef = useRef('');
|
|
1035
1423
|
const [imageChips, setImageChips] = useState([]);
|
|
1424
|
+
// Whether a picker was on screen for the frame the user is currently looking
|
|
1425
|
+
// at. TextInput is unfocused exactly when one is (focus={!pickerActive}), so
|
|
1426
|
+
// this is the single source of truth for who owns the next keystroke. It has
|
|
1427
|
+
// to be a post-render mirror rather than a live read: see the ownership note
|
|
1428
|
+
// in useInput below.
|
|
1429
|
+
const pickerRenderedRef = useRef(false);
|
|
1036
1430
|
const onChange = useCallback((v) => {
|
|
1037
1431
|
setValue(v);
|
|
1038
1432
|
valueRef.current = v;
|
|
@@ -1251,11 +1645,20 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
|
|
|
1251
1645
|
exit();
|
|
1252
1646
|
return;
|
|
1253
1647
|
}
|
|
1254
|
-
// ──
|
|
1255
|
-
|
|
1256
|
-
|
|
1648
|
+
// ── A picker is on screen, so TextInput is unfocused and this handler owns
|
|
1649
|
+
// the keystroke. Ownership must be read from what was rendered
|
|
1650
|
+
// (pickerRenderedRef), never recomputed from valueRef.current: every
|
|
1651
|
+
// active useInput handler gets the same key in one dispatch, and when
|
|
1652
|
+
// TextInput is focused its handler runs first and has already written
|
|
1653
|
+
// the new character into the ref. Deciding from the mutated ref made
|
|
1654
|
+
// this branch fire on top of TextInput and double every character, so
|
|
1655
|
+
// "/status" arrived as "/status/status", matched no command, and went
|
|
1656
|
+
// to the model as chat. The mirror case was just as bad: the old
|
|
1657
|
+
// condition required a leading "/", so while the @-file picker held
|
|
1658
|
+
// focus neither side handled typing and the keys were dropped. ──
|
|
1659
|
+
if (pickerRenderedRef.current && !disabled) {
|
|
1257
1660
|
if (key.upArrow || key.downArrow || key.return)
|
|
1258
|
-
return;
|
|
1661
|
+
return; // SelectInput's
|
|
1259
1662
|
if (key.backspace || key.delete) {
|
|
1260
1663
|
onChange(val.slice(0, -1));
|
|
1261
1664
|
return;
|
|
@@ -1308,57 +1711,7 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
|
|
|
1308
1711
|
}
|
|
1309
1712
|
});
|
|
1310
1713
|
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' },
|
|
1714
|
+
...SLASH_COMMANDS,
|
|
1362
1715
|
...customCommands.map((c) => ({ cmd: `/${c.name}`, desc: c.description })),
|
|
1363
1716
|
], [customCommands]);
|
|
1364
1717
|
const prefix = value.split(' ')[0].toLowerCase();
|
|
@@ -1384,7 +1737,11 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
|
|
|
1384
1737
|
})
|
|
1385
1738
|
.slice(0, 10);
|
|
1386
1739
|
}, [showFilePicker, atQuery]);
|
|
1387
|
-
const pickerActive = (showCommandPicker && filteredCommands.length > 0) || (showFilePicker && filteredFiles.length > 0);
|
|
1740
|
+
const pickerActive = (showCommandPicker && filteredCommands.length > 0) || !!(showFilePicker && filteredFiles.length > 0);
|
|
1741
|
+
// Mirror the rendered picker state for useInput. This runs after commit and
|
|
1742
|
+
// before any keystroke reaches the handler, so it always describes the frame
|
|
1743
|
+
// the user is actually typing against.
|
|
1744
|
+
useEffect(() => { pickerRenderedRef.current = pickerActive; }, [pickerActive]);
|
|
1388
1745
|
useEffect(() => {
|
|
1389
1746
|
if (!onHeightChange)
|
|
1390
1747
|
return;
|
|
@@ -1415,7 +1772,15 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
|
|
|
1415
1772
|
// Replace @query with the selected file path
|
|
1416
1773
|
const newValue = value.replace(/@\S*$/, '@' + item.value + ' ');
|
|
1417
1774
|
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
|
|
1775
|
+
}, 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
|
|
1776
|
+
// Remount across picker transitions so the caret lands at the end
|
|
1777
|
+
// of the text. TextInput owns its cursor offset and only re-syncs
|
|
1778
|
+
// it while focused, but the picker branch above edits the value
|
|
1779
|
+
// while it is unfocused — so on the way back the caret was left
|
|
1780
|
+
// wherever the picker opened, and backspace on "see @noz" deleted
|
|
1781
|
+
// the "@" instead of the "z". Remounting re-derives the offset
|
|
1782
|
+
// from the current value; nothing else here is stateful.
|
|
1783
|
+
, { 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
1784
|
});
|
|
1420
1785
|
// ─── App ───
|
|
1421
1786
|
function estimateLines(item, cols) {
|
|
@@ -1479,8 +1844,12 @@ function useScrollableItems(items, availableHeight, cols, isLoading, streamLineC
|
|
|
1479
1844
|
setScrollOffset(0);
|
|
1480
1845
|
autoScroll.current = true;
|
|
1481
1846
|
}, []);
|
|
1482
|
-
|
|
1483
|
-
|
|
1847
|
+
// The reservation can't exceed what there is to reserve from, or a short
|
|
1848
|
+
// terminal ends up selecting items for a negative-height window and falling
|
|
1849
|
+
// back on the floor, which puts more rows in the tree than the terminal has.
|
|
1850
|
+
const wanted = Math.max(4, Math.min(streamLineCount + 3, Math.floor(availableHeight * 0.5)));
|
|
1851
|
+
const reservedForStream = isLoading ? Math.min(wanted, Math.max(availableHeight - 1, 0)) : 0;
|
|
1852
|
+
const effectiveHeight = Math.max(availableHeight - reservedForStream, 1);
|
|
1484
1853
|
const visibleItems = [];
|
|
1485
1854
|
let usedLines = 0;
|
|
1486
1855
|
const endIdx = items.length - scrollOffset;
|
|
@@ -1504,6 +1873,9 @@ function App({ config }) {
|
|
|
1504
1873
|
const [streamText, setStreamText] = useState('');
|
|
1505
1874
|
const [reasoningText, setReasoningText] = useState('');
|
|
1506
1875
|
const [selectingModel, setSelectingModel] = useState(false);
|
|
1876
|
+
/** BYOK catalog fetched when the /model picker opens (ids + context windows). */
|
|
1877
|
+
const [pickerCatalog, setPickerCatalog] = useState([]);
|
|
1878
|
+
const [pickerWindows, setPickerWindows] = useState({});
|
|
1507
1879
|
const [confirmInfo, setConfirmInfo] = useState(null);
|
|
1508
1880
|
const [thinkStart, setThinkStart] = useState(null);
|
|
1509
1881
|
const [showBanner, setShowBanner] = useState(true);
|
|
@@ -1537,21 +1909,30 @@ function App({ config }) {
|
|
|
1537
1909
|
const collapseToolGroups = useCallback(() => {
|
|
1538
1910
|
setItems(prev => {
|
|
1539
1911
|
const isToolItem = (k) => k === 'tool-call' || k === 'tool-result';
|
|
1540
|
-
let
|
|
1541
|
-
while (
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1912
|
+
let start = prev.length;
|
|
1913
|
+
while (start > 0 && isToolItem(prev[start - 1].kind))
|
|
1914
|
+
start--;
|
|
1915
|
+
// Tool results land on the step after their calls, and this runs once per
|
|
1916
|
+
// step — so one turn of three tools built two separate "Ran N tools" rows,
|
|
1917
|
+
// the first holding the calls and the second the results. Absorb the group
|
|
1918
|
+
// from the previous step instead of stacking another one beside it, and
|
|
1919
|
+
// carry its collapsed flag over so Ctrl+O doesn't undo itself.
|
|
1920
|
+
const absorb = start > 0 && prev[start - 1].kind === 'tool-group';
|
|
1921
|
+
if (absorb && start === prev.length)
|
|
1922
|
+
return prev; // nothing new to fold in
|
|
1923
|
+
const previous = absorb ? prev[start - 1] : null;
|
|
1924
|
+
const toolItems = [...(previous?.children ?? []), ...prev.slice(start)];
|
|
1925
|
+
const calls = toolItems.filter((t) => t.kind === 'tool-call').length;
|
|
1926
|
+
if ((calls || toolItems.length) < TOOL_GROUP_THRESHOLD)
|
|
1546
1927
|
return prev;
|
|
1547
1928
|
const group = {
|
|
1548
|
-
id: nextId(),
|
|
1929
|
+
id: previous?.id ?? nextId(),
|
|
1549
1930
|
kind: 'tool-group',
|
|
1550
|
-
text: `Ran ${
|
|
1931
|
+
text: `Ran ${calls} tools`,
|
|
1551
1932
|
children: toolItems,
|
|
1552
|
-
collapsed: true,
|
|
1933
|
+
collapsed: previous?.collapsed ?? true,
|
|
1553
1934
|
};
|
|
1554
|
-
return [...prev.slice(0,
|
|
1935
|
+
return [...prev.slice(0, absorb ? start - 1 : start), group];
|
|
1555
1936
|
});
|
|
1556
1937
|
}, []);
|
|
1557
1938
|
const toggleToolGroups = useCallback(() => {
|
|
@@ -1664,12 +2045,10 @@ function App({ config }) {
|
|
|
1664
2045
|
add('tool-result', summary, name);
|
|
1665
2046
|
return;
|
|
1666
2047
|
}
|
|
1667
|
-
let text =
|
|
2048
|
+
let text = summarizeToolResult(result);
|
|
1668
2049
|
const MAX_DISPLAY = 2000;
|
|
1669
|
-
if (text.length > MAX_DISPLAY)
|
|
1670
|
-
|
|
1671
|
-
text = text.slice(0, MAX_DISPLAY) + `\n... (${truncated.toLocaleString()} chars hidden)`;
|
|
1672
|
-
}
|
|
2050
|
+
if (text.length > MAX_DISPLAY)
|
|
2051
|
+
text = text.slice(0, MAX_DISPLAY) + '…';
|
|
1673
2052
|
add('tool-result', text, name);
|
|
1674
2053
|
};
|
|
1675
2054
|
const onToolBlocked = (name, reason) => {
|
|
@@ -1710,6 +2089,18 @@ function App({ config }) {
|
|
|
1710
2089
|
}
|
|
1711
2090
|
add('error', msg, undefined, hint);
|
|
1712
2091
|
};
|
|
2092
|
+
const onRetry = (info) => {
|
|
2093
|
+
// Not every retry is a failed request — an empty assistant turn is
|
|
2094
|
+
// re-asked immediately with no status and no backoff, and labelling
|
|
2095
|
+
// that "Request failed — retrying in 0ms" is wrong twice over.
|
|
2096
|
+
const what = info.status === 429 ? 'Rate limited'
|
|
2097
|
+
: info.status ? `Request failed (${info.status})`
|
|
2098
|
+
: info.reason ? truncate(info.reason.split('\n')[0], 80)
|
|
2099
|
+
: 'Request failed';
|
|
2100
|
+
const secs = info.delayMs >= 1000 ? `${Math.round(info.delayMs / 1000)}s` : `${info.delayMs}ms`;
|
|
2101
|
+
const when = info.delayMs > 0 ? ` in ${secs}` : '';
|
|
2102
|
+
add('system', `${what} — retrying${when} (attempt ${info.attempt}/${info.maxAttempts})`);
|
|
2103
|
+
};
|
|
1713
2104
|
const onContextCompress = (info) => {
|
|
1714
2105
|
add('system', `Context compressed: ${formatTokens(info.saved)} tokens saved (${formatTokens(info.before)} \u2192 ${formatTokens(info.after)})`);
|
|
1715
2106
|
};
|
|
@@ -1735,6 +2126,7 @@ function App({ config }) {
|
|
|
1735
2126
|
agent.on('verify:pass', onVerifyPass);
|
|
1736
2127
|
agent.on('verify:fail', onVerifyFail);
|
|
1737
2128
|
agent.on('file:modified', onFileModified);
|
|
2129
|
+
agent.on('retry', onRetry);
|
|
1738
2130
|
agent.on('context:compress', onContextCompress);
|
|
1739
2131
|
agent.on('context:usage', onContextUsage);
|
|
1740
2132
|
const currentAgent = agent;
|
|
@@ -1758,6 +2150,7 @@ function App({ config }) {
|
|
|
1758
2150
|
currentAgent.off('verify:pass', onVerifyPass);
|
|
1759
2151
|
currentAgent.off('verify:fail', onVerifyFail);
|
|
1760
2152
|
currentAgent.off('file:modified', onFileModified);
|
|
2153
|
+
currentAgent.off('retry', onRetry);
|
|
1761
2154
|
currentAgent.off('context:compress', onContextCompress);
|
|
1762
2155
|
currentAgent.off('context:usage', onContextUsage);
|
|
1763
2156
|
};
|
|
@@ -1782,7 +2175,6 @@ function App({ config }) {
|
|
|
1782
2175
|
useEffect(() => {
|
|
1783
2176
|
if (!agent)
|
|
1784
2177
|
return;
|
|
1785
|
-
const hasKey = !!agent.getApiKey();
|
|
1786
2178
|
let cancelled = false;
|
|
1787
2179
|
connectCloud(agent).then((state) => {
|
|
1788
2180
|
if (cancelled)
|
|
@@ -1798,12 +2190,24 @@ function App({ config }) {
|
|
|
1798
2190
|
add('system', `Try:\n${CLOUD_TRY_PROMPTS.map((p) => ` ❯ ${p}`).join('\n')}`);
|
|
1799
2191
|
}
|
|
1800
2192
|
}
|
|
1801
|
-
else if (
|
|
1802
|
-
|
|
2193
|
+
else if (state.reason === 'failed') {
|
|
2194
|
+
// Only when we actually tried and were refused. `hasKey` used to gate
|
|
2195
|
+
// this, which fired on BYOK too (the provider key counts as a key) and
|
|
2196
|
+
// announced a Scalix Cloud outage to a session that never dialled it.
|
|
2197
|
+
add('system', cloudStatusLine(state));
|
|
1803
2198
|
}
|
|
1804
2199
|
});
|
|
1805
2200
|
return () => { cancelled = true; };
|
|
1806
2201
|
}, [add, agentVersion]);
|
|
2202
|
+
// A key the keychain refused is now sitting in config.json as plaintext.
|
|
2203
|
+
// saveConfig() prefers that over losing the key, but the user gets told —
|
|
2204
|
+
// a secret landing in a readable file is not a detail to swallow.
|
|
2205
|
+
useEffect(() => {
|
|
2206
|
+
if (!plaintextKeyPending)
|
|
2207
|
+
return;
|
|
2208
|
+
plaintextKeyPending = false;
|
|
2209
|
+
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.`);
|
|
2210
|
+
}, [add]);
|
|
1807
2211
|
// Idle session nudge: suggest /clear after 75+ minutes of inactivity
|
|
1808
2212
|
const lastActivityRef = useRef(Date.now());
|
|
1809
2213
|
const idleNudgedRef = useRef(false);
|
|
@@ -1877,71 +2281,7 @@ function App({ config }) {
|
|
|
1877
2281
|
const handleCommand = useCallback((text) => {
|
|
1878
2282
|
const cmd = text.split(' ')[0].toLowerCase();
|
|
1879
2283
|
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
|
-
];
|
|
2284
|
+
const helpLines = [...HELP_LINES];
|
|
1945
2285
|
if (customCommands.length > 0) {
|
|
1946
2286
|
helpLines.push('', 'Custom commands:');
|
|
1947
2287
|
for (const c of customCommands)
|
|
@@ -1982,6 +2322,18 @@ function App({ config }) {
|
|
|
1982
2322
|
}
|
|
1983
2323
|
if (cmd === '/model') {
|
|
1984
2324
|
setSelectingModel(true);
|
|
2325
|
+
// BYOK: the picker used to show only the already-configured list — no
|
|
2326
|
+
// way to discover what the endpoint actually serves. Fetch its catalog
|
|
2327
|
+
// in the background; the picker grows when (if) the list lands.
|
|
2328
|
+
if (activeAgentConfig && resolveActiveProvider(activeAgentConfig).provider === 'openai') {
|
|
2329
|
+
const activeNow = resolveActiveProvider(activeAgentConfig);
|
|
2330
|
+
fetchByokModels(activeNow.baseUrl, activeNow.apiKey).then((found) => {
|
|
2331
|
+
if (found.length === 0)
|
|
2332
|
+
return;
|
|
2333
|
+
setPickerCatalog(found.map((f) => f.id));
|
|
2334
|
+
setPickerWindows(Object.fromEntries(found.filter((f) => f.contextWindow && f.contextWindow > 0).map((f) => [f.id, f.contextWindow])));
|
|
2335
|
+
}).catch(() => { });
|
|
2336
|
+
}
|
|
1985
2337
|
return true;
|
|
1986
2338
|
}
|
|
1987
2339
|
if (cmd === '/compact') {
|
|
@@ -2004,7 +2356,7 @@ function App({ config }) {
|
|
|
2004
2356
|
return true;
|
|
2005
2357
|
}
|
|
2006
2358
|
if (cmd === '/cost') {
|
|
2007
|
-
add('system', costTracker.summary());
|
|
2359
|
+
add('system', costTracker.summary(resolveActiveProvider(config).provider));
|
|
2008
2360
|
return true;
|
|
2009
2361
|
}
|
|
2010
2362
|
if (cmd === '/save') {
|
|
@@ -2168,12 +2520,17 @@ function App({ config }) {
|
|
|
2168
2520
|
else {
|
|
2169
2521
|
add('system', `Identity unavailable: ${s.error ?? 'unknown error'}`);
|
|
2170
2522
|
}
|
|
2171
|
-
|
|
2523
|
+
const ofTotal = s.totalCount && s.totalCount > s.toolCount ? ` of ${s.totalCount} in the catalog (this key's scopes unlock ${s.toolCount})` : '';
|
|
2524
|
+
add('system', `Platform tools: ${s.toolCount} registered${ofTotal} ${'—'} /cloud tools to list them`);
|
|
2525
|
+
if (s.note)
|
|
2526
|
+
add('system', s.note);
|
|
2527
|
+
const cloudActiveModel = agent.getModels()[0];
|
|
2528
|
+
if (cloudActiveModel && !hasExplicitModelSpec(cloudActiveModel)) {
|
|
2529
|
+
add('system', `Model spec: assumed (${formatTokens(getModelContextWindow(cloudActiveModel))} context) ${'—'} the API did not report one for ${shortModel(cloudActiveModel)}. Set the real value in /setup's context-window step.`);
|
|
2530
|
+
}
|
|
2172
2531
|
}
|
|
2173
2532
|
else {
|
|
2174
|
-
add('system',
|
|
2175
|
-
if (s.error)
|
|
2176
|
-
add('system', `Last error: ${s.error}`);
|
|
2533
|
+
add('system', cloudStatusLine(s));
|
|
2177
2534
|
}
|
|
2178
2535
|
return true;
|
|
2179
2536
|
}
|
|
@@ -2182,7 +2539,7 @@ function App({ config }) {
|
|
|
2182
2539
|
if (tools.length === 0) {
|
|
2183
2540
|
add('system', cloudConnection.connected
|
|
2184
2541
|
? 'No platform tools are registered for this key (its scopes unlock none).'
|
|
2185
|
-
:
|
|
2542
|
+
: cloudStatusLine(cloudConnection));
|
|
2186
2543
|
return true;
|
|
2187
2544
|
}
|
|
2188
2545
|
add('system', `Platform tools (${tools.length}):`);
|
|
@@ -2200,7 +2557,7 @@ function App({ config }) {
|
|
|
2200
2557
|
add('system', `☁ Connected ${'—'} ${plan}${state.toolCount} platform tools`);
|
|
2201
2558
|
}
|
|
2202
2559
|
else {
|
|
2203
|
-
add('error',
|
|
2560
|
+
add('error', cloudStatusLine(state));
|
|
2204
2561
|
}
|
|
2205
2562
|
});
|
|
2206
2563
|
return true;
|
|
@@ -2213,9 +2570,9 @@ function App({ config }) {
|
|
|
2213
2570
|
if (arg && PERMISSION_MODES.includes(arg)) {
|
|
2214
2571
|
agent.setPermissionMode(arg);
|
|
2215
2572
|
setCurrentMode(arg);
|
|
2216
|
-
|
|
2573
|
+
loadGlobalConfig().then(cfg => {
|
|
2217
2574
|
cfg.permissionMode = arg;
|
|
2218
|
-
saveConfig(cfg);
|
|
2575
|
+
return saveConfig(cfg);
|
|
2219
2576
|
}).catch((err) => { logger.warn('Failed to save config', { error: err.message }); });
|
|
2220
2577
|
add('system', `Mode: ${arg.toUpperCase()} \u2014 ${getModeDescription(arg)}`);
|
|
2221
2578
|
}
|
|
@@ -2228,9 +2585,9 @@ function App({ config }) {
|
|
|
2228
2585
|
const arg = text.split(' ')[1]?.toLowerCase();
|
|
2229
2586
|
if (arg && THINKING_MODES.includes(arg)) {
|
|
2230
2587
|
agent.setThinking(arg);
|
|
2231
|
-
|
|
2588
|
+
loadGlobalConfig().then(cfg => {
|
|
2232
2589
|
cfg.thinkingMode = arg;
|
|
2233
|
-
saveConfig(cfg);
|
|
2590
|
+
return saveConfig(cfg);
|
|
2234
2591
|
}).catch((err) => { logger.warn('Failed to save config', { error: err.message }); });
|
|
2235
2592
|
add('system', `Thinking: ${arg.toUpperCase()}${arg === 'off' ? '' : ' \u2014 model will reason before responding'}`);
|
|
2236
2593
|
}
|
|
@@ -2805,13 +3162,25 @@ function App({ config }) {
|
|
|
2805
3162
|
const ctx = agent.getContextUsage();
|
|
2806
3163
|
const msgCount = agent.getMessages().length;
|
|
2807
3164
|
add('system', `${PRODUCT_NAME} v${VERSION}`);
|
|
2808
|
-
|
|
3165
|
+
const activeProv = resolveActiveProvider(config);
|
|
3166
|
+
add('system', `Provider: ${activeProv.provider === 'openai' ? 'OpenAI-compatible (BYOK)' : 'Scalix Cloud'}`);
|
|
3167
|
+
add('system', `Model: ${modelsStr}${process.env[ENV_MODEL] ? ' (set by SCALIX_MODEL env — overrides saved config)' : ''}`);
|
|
2809
3168
|
add('system', `API: ${baseUrl}`);
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
3169
|
+
if (activeProv.provider === 'scalix') {
|
|
3170
|
+
add('system', `Cloud: ${cloudStatusLine(cloudConnection)}`);
|
|
3171
|
+
}
|
|
3172
|
+
else {
|
|
3173
|
+
add('system', `Cloud: platform tools are Scalix-only ${'—'} /setup to switch provider`);
|
|
3174
|
+
}
|
|
2813
3175
|
add('system', `Mode: ${mode}`);
|
|
2814
3176
|
add('system', `Context: ${ctx.percentage}% (${formatTokens(ctx.tokens)} / ${formatTokens(ctx.maxTokens)})`);
|
|
3177
|
+
// Honesty over silence: when neither the user nor the API supplied a
|
|
3178
|
+
// spec for the active model, the context window above is our guess, and
|
|
3179
|
+
// saying so beats letting it read as a reported fact.
|
|
3180
|
+
const activeModel = agent.getModels()[0];
|
|
3181
|
+
if (activeModel && !hasExplicitModelSpec(activeModel)) {
|
|
3182
|
+
add('system', `Model spec: assumed (${formatTokens(getModelContextWindow(activeModel))} context) ${'—'} the API did not report one for ${shortModel(activeModel)}. Set the real value in /setup's context-window step or via customModels in ~/${USER_CONFIG_DIR}/config.json.`);
|
|
3183
|
+
}
|
|
2815
3184
|
add('system', `Messages: ${msgCount}`);
|
|
2816
3185
|
add('system', `Session: ${sessionId}`);
|
|
2817
3186
|
add('system', `CWD: ${process.cwd()}`);
|
|
@@ -2899,9 +3268,9 @@ function App({ config }) {
|
|
|
2899
3268
|
if (cmd === '/plan') {
|
|
2900
3269
|
agent.setPermissionMode('plan');
|
|
2901
3270
|
setCurrentMode('plan');
|
|
2902
|
-
|
|
3271
|
+
loadGlobalConfig().then(cfg => {
|
|
2903
3272
|
cfg.permissionMode = 'plan';
|
|
2904
|
-
saveConfig(cfg);
|
|
3273
|
+
return saveConfig(cfg);
|
|
2905
3274
|
}).catch((err) => { logger.warn('Failed to save config', { error: err.message }); });
|
|
2906
3275
|
add('system', `Mode: PLAN — ${getModeDescription('plan')}`);
|
|
2907
3276
|
return true;
|
|
@@ -3037,13 +3406,24 @@ function App({ config }) {
|
|
|
3037
3406
|
setStatus('thinking');
|
|
3038
3407
|
setThinkStart(Date.now());
|
|
3039
3408
|
try {
|
|
3040
|
-
const result = await agent.send(finalText, images);
|
|
3041
|
-
|
|
3042
|
-
|
|
3409
|
+
const result = (await agent.send(finalText, images))?.trim() ?? '';
|
|
3410
|
+
// send() answers with a sentinel rather than throwing when the model
|
|
3411
|
+
// gives back nothing at all — five empty turns in a row come back as
|
|
3412
|
+
// '[no response]', which is non-empty and so slipped past the old
|
|
3413
|
+
// check. Nothing else renders it either, so the whole turn was silence:
|
|
3414
|
+
// the prompt echoed and then no output, indistinguishable from a hang.
|
|
3415
|
+
// '[interrupted]' is deliberately excluded — the Ctrl+C path already
|
|
3416
|
+
// prints its own note and would otherwise double up.
|
|
3417
|
+
if (!result || result === '[no response]') {
|
|
3418
|
+
add('system', 'The model returned an empty response. Try rephrasing, or switch models with /model.');
|
|
3043
3419
|
}
|
|
3044
3420
|
}
|
|
3045
3421
|
catch (e) {
|
|
3046
|
-
|
|
3422
|
+
// agent.send() emits 'error' before it rethrows, and onError renders that
|
|
3423
|
+
// with the model name and a recovery hint. Printing here as well gave
|
|
3424
|
+
// every failed turn two entries, the second one strictly worse.
|
|
3425
|
+
if (!e?.[REPORTED_ERROR])
|
|
3426
|
+
add('error', e.message || 'Unknown error');
|
|
3047
3427
|
}
|
|
3048
3428
|
}, [add, handleCommand]);
|
|
3049
3429
|
const handleSubmit = useCallback(async (text) => {
|
|
@@ -3053,8 +3433,11 @@ function App({ config }) {
|
|
|
3053
3433
|
// Reset idle nudge on any user activity
|
|
3054
3434
|
lastActivityRef.current = Date.now();
|
|
3055
3435
|
idleNudgedRef.current = false;
|
|
3056
|
-
// Allow /mode even while the agent is working
|
|
3057
|
-
|
|
3436
|
+
// Allow /mode even while the agent is working. Exact match only —
|
|
3437
|
+
// startsWith('/mode') also matched /model and /models, letting the model
|
|
3438
|
+
// picker run (and switch the LIVE response's model) mid-stream while the
|
|
3439
|
+
// queue guard below thought it was protecting them.
|
|
3440
|
+
if (isLoadingRef.current && (trimmed === '/mode' || trimmed.startsWith('/mode '))) {
|
|
3058
3441
|
handleCommand(trimmed);
|
|
3059
3442
|
return;
|
|
3060
3443
|
}
|
|
@@ -3120,8 +3503,13 @@ function App({ config }) {
|
|
|
3120
3503
|
loadSession(session.id).then((data) => {
|
|
3121
3504
|
agent.loadMessages(data.messages);
|
|
3122
3505
|
sessionId = session.id;
|
|
3123
|
-
|
|
3124
|
-
agent.setModel(
|
|
3506
|
+
const resumeModel = normalizeModelId(session.model);
|
|
3507
|
+
agent.setModel(resumeModel);
|
|
3508
|
+
setModels(agent.getModels());
|
|
3509
|
+
// The session may have been recorded under another provider/spec —
|
|
3510
|
+
// without this a BYOK resume reactivates a spec-less model (no
|
|
3511
|
+
// max_tokens sent → gateways 400 every request).
|
|
3512
|
+
ensureByokSpec(activeAgentConfig, resumeModel);
|
|
3125
3513
|
if (data.costState) {
|
|
3126
3514
|
costTracker.restore(data.costState);
|
|
3127
3515
|
setTotalTokens(data.costState.totalTokens);
|
|
@@ -3198,7 +3586,7 @@ function App({ config }) {
|
|
|
3198
3586
|
}, [add]);
|
|
3199
3587
|
const [inputSectionHeight, setInputSectionHeight] = useState(3);
|
|
3200
3588
|
const handleInputHeightChange = useCallback((h) => setInputSectionHeight(h), []);
|
|
3201
|
-
const bannerHeight = showBanner ? 5 : 0;
|
|
3589
|
+
const bannerHeight = showBanner && bannerFits(cols, rows) ? 5 : 0;
|
|
3202
3590
|
const statusBarHeight = 1;
|
|
3203
3591
|
const bottomHeight = useMemo(() => {
|
|
3204
3592
|
if (confirmInfo) {
|
|
@@ -3211,7 +3599,12 @@ function App({ config }) {
|
|
|
3211
3599
|
}
|
|
3212
3600
|
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
3601
|
const hasHighlight = !!(parsed.command ?? parsed.filePath ?? parsed.file_path ?? parsed.notebook_path ?? parsed.message);
|
|
3214
|
-
|
|
3602
|
+
// 6 fixed rows: top border, title, Tool:, divider, key hints, bottom
|
|
3603
|
+
// border. This said 5, so with the dialog up the tree was always one row
|
|
3604
|
+
// taller than the budget — invisible on a big terminal, but at 12 rows it
|
|
3605
|
+
// pushed the closing border off the screen and left the status bar with
|
|
3606
|
+
// the scroll hint overdrawn on top of it.
|
|
3607
|
+
return 6 + (hasHighlight ? 1 : 0) + extraCount;
|
|
3215
3608
|
}
|
|
3216
3609
|
if (selectingModel)
|
|
3217
3610
|
return 6 + Math.min(models.length + 2, 12);
|
|
@@ -3224,7 +3617,13 @@ function App({ config }) {
|
|
|
3224
3617
|
return inputSectionHeight;
|
|
3225
3618
|
}, [confirmInfo, selectingModel, selectingMode, editingSettings, sessionList, models.length, rows, inputSectionHeight]);
|
|
3226
3619
|
const chromeHeight = bannerHeight + statusBarHeight + bottomHeight;
|
|
3227
|
-
|
|
3620
|
+
// Floor at 1, not 6. On a short terminal the floor is exactly what breaks the
|
|
3621
|
+
// frame: at 12 rows with the permission dialog open the chrome needs 7, and a
|
|
3622
|
+
// 6-row floor made the tree 13 rows tall inside a 12-row terminal. Ink drives
|
|
3623
|
+
// redraws with cursor-up sequences counted against its own frame height, so
|
|
3624
|
+
// once the frame is taller than the terminal the erase desyncs — the dialog's
|
|
3625
|
+
// closing border scrolled away and the banner got overdrawn by the status bar.
|
|
3626
|
+
const chatHeight = Math.max(rows - chromeHeight, 1);
|
|
3228
3627
|
const streamLineCount = useMemo(() => {
|
|
3229
3628
|
if (!isLoading)
|
|
3230
3629
|
return 0;
|
|
@@ -3244,26 +3643,36 @@ function App({ config }) {
|
|
|
3244
3643
|
if (showSetup) {
|
|
3245
3644
|
return _jsx(SetupScreen, { onComplete: handleSetupDone, existingConfig: config });
|
|
3246
3645
|
}
|
|
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: (
|
|
3646
|
+
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, availableModels: pickerCatalog, onSelect: (picked) => {
|
|
3647
|
+
const v = normalizeModelId(picked);
|
|
3248
3648
|
agent.setModel(v);
|
|
3249
|
-
|
|
3649
|
+
// Keep the whole failover chain visible, not just the pick —
|
|
3650
|
+
// setModel moves the model to the front and preserves the rest.
|
|
3651
|
+
setModels(agent.getModels());
|
|
3250
3652
|
setSelectingModel(false);
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3653
|
+
ensureByokSpec(activeAgentConfig, v, pickerWindows[v]);
|
|
3654
|
+
// Global config only (loadConfig() merges project-local overrides
|
|
3655
|
+
// and round-tripping them through saveConfig laundered them into
|
|
3656
|
+
// ~/.scalix-coder). Chain-preserving, and the saveConfig promise
|
|
3657
|
+
// is returned so its failure actually hits the catch.
|
|
3658
|
+
loadGlobalConfig().then(cfg => {
|
|
3659
|
+
cfg.models = [v, ...cfg.models.filter(m => m !== v)];
|
|
3660
|
+
return saveConfig(cfg);
|
|
3254
3661
|
}).catch((err) => { logger.warn('Failed to save config', { error: err.message }); });
|
|
3255
|
-
add('system', `Model switched to ${shortModel(v)}`);
|
|
3662
|
+
add('system', `Model switched to ${shortModel(v)}${process.env[ENV_MODEL] ? ' — note: SCALIX_MODEL is set and overrides this on next launch' : ''}`);
|
|
3256
3663
|
}, onCancel: () => setSelectingModel(false) })) : selectingMode ? (_jsx(ModePicker, { currentMode: currentMode, onSelect: (mode) => {
|
|
3257
3664
|
agent.setPermissionMode(mode);
|
|
3258
3665
|
setCurrentMode(mode);
|
|
3259
3666
|
setSelectingMode(false);
|
|
3260
|
-
|
|
3667
|
+
loadGlobalConfig().then(cfg => {
|
|
3261
3668
|
cfg.permissionMode = mode;
|
|
3262
|
-
saveConfig(cfg);
|
|
3669
|
+
return saveConfig(cfg);
|
|
3263
3670
|
}).catch((err) => { logger.warn('Failed to save config', { error: err.message }); });
|
|
3264
3671
|
add('system', `Mode: ${mode.toUpperCase()} \u2014 ${getModeDescription(mode)}`);
|
|
3265
3672
|
}, onCancel: () => setSelectingMode(false) })) : editingSettings ? (_jsx(SettingsEditor, { onSave: (key, value) => {
|
|
3266
|
-
|
|
3673
|
+
// Global config only — round-tripping the override-merged view
|
|
3674
|
+
// laundered project-local settings into ~/.scalix-coder.
|
|
3675
|
+
loadGlobalConfig().then((c) => {
|
|
3267
3676
|
const limits = { ...(c.limits || DEFAULT_LIMITS) };
|
|
3268
3677
|
const floatKeys = new Set([
|
|
3269
3678
|
'compressionThreshold', 'messageKeepRatio', 'subAgentCompressionThreshold',
|
|
@@ -3274,8 +3683,13 @@ function App({ config }) {
|
|
|
3274
3683
|
limits[key] = parsed;
|
|
3275
3684
|
const newConfig = { ...c, limits };
|
|
3276
3685
|
saveConfig(newConfig).then(() => {
|
|
3277
|
-
|
|
3278
|
-
|
|
3686
|
+
// updateLimit says whether the RUNNING agent honors the new
|
|
3687
|
+
// value — claiming "updated" for keys that only apply on
|
|
3688
|
+
// the next launch was a lie the user had no way to detect.
|
|
3689
|
+
const live = agent?.updateLimit(key, parsed) ?? false;
|
|
3690
|
+
add('system', live
|
|
3691
|
+
? `Setting updated: ${key} = ${parsed}`
|
|
3692
|
+
: `Setting saved: ${key} = ${parsed} — takes effect on the next launch`);
|
|
3279
3693
|
}).catch(() => {
|
|
3280
3694
|
add('error', `Failed to save setting: ${key}`);
|
|
3281
3695
|
});
|
|
@@ -3300,8 +3714,13 @@ function Root() {
|
|
|
3300
3714
|
c.apiKey = envKey;
|
|
3301
3715
|
setConfig(c);
|
|
3302
3716
|
if (isConfigured(c)) {
|
|
3303
|
-
// Fetch model specs from API in background (uses user-defined/built-in
|
|
3304
|
-
|
|
3717
|
+
// Fetch model specs from API in background (uses user-defined/built-in
|
|
3718
|
+
// as fallback). Scalix-shaped endpoint only — BYOK models are entered
|
|
3719
|
+
// manually with their own specs.
|
|
3720
|
+
const activeStart = resolveActiveProvider(c);
|
|
3721
|
+
if (activeStart.provider === 'scalix') {
|
|
3722
|
+
fetchModelSpecs(activeStart.baseUrl, activeStart.apiKey).catch((err) => { logger.warn('Failed to fetch model specs', { error: err.message }); });
|
|
3723
|
+
}
|
|
3305
3724
|
if (!process.env.NO_UPDATE_NOTIFIER && !c.disableUpdateCheck) {
|
|
3306
3725
|
checkForUpdate().catch(() => { });
|
|
3307
3726
|
}
|