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/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
- if (!targetAgent.getApiKey()) {
158
- cloudConnection = { connected: false, identity: null, toolCount: 0, error: 'no API key configured' };
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.getBaseUrl() || DEFAULT_BASE_URL, targetAgent.getApiKey());
163
- const status = await targetAgent.refreshCloudTools();
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: on failure it clears any
173
- // stale platform tools from a previous connect; in the rare case it
174
- // succeeds, the tools genuinely work and we say so (identity unavailable).
175
- const status = await targetAgent.refreshCloudTools().catch(() => null);
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
- const models = envModels || config.models;
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: config.apiKey,
259
- baseUrl: config.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 STEPS = ['api-key', 'base-url', 'cloud', 'models', 'context-window'];
455
- const [step, setStep] = useState('api-key');
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
- const [contextInput, setContextInput] = useState(String(existingConfig?.limits?.maxContextTokens ?? DEFAULT_LIMITS.maxContextTokens));
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
- return { identity, toolCount: catalog.tools.length };
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 === 'api-key' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "API Key" }), _jsx(Text, { dimColor: true, children: "Paste your API key and press Enter." }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "#20a0a0", bold: true, children: ['\u276F', " "] }), _jsx(LongTextInput, { value: apiKey, onChange: (v) => { setApiKey(v); setApiKeyError(false); }, onSubmit: (v) => {
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: "sk-or-v1-..." })] }), apiKeyError && (_jsx(Box, { marginTop: 0, paddingLeft: 2, children: _jsx(Text, { color: "yellow", dimColor: true, children: "API key is required." }) }))] })), step === 'base-url' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Base URL" }), _jsx(Text, { dimColor: true, children: "Scalix API endpoint (or any compatible URL)" }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "#20a0a0", bold: true, children: ['\u276F', " "] }), _jsx(TextInput, { value: baseUrl, onChange: (v) => setBaseUrl(sanitizeInput(v)), onSubmit: (v) => {
528
- const url = sanitizeInput(v).trim() || baseUrl;
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 > 0 && modelsMode === 'custom' && models.length === 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Select models from your API or type a custom model ID." }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(SelectInput, { items: [
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: [_jsx(Text, { dimColor: true, children: "Added models (Enter to remove, \u2191/\u2193 to navigate):" }), _jsx(SelectInput, { items: [
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
- }, placeholder: "model-id (from your API provider)" })] }), _jsx(Box, { paddingLeft: 2, marginTop: 0, children: models.length === 0 ? (_jsx(Text, { color: "yellow", dimColor: true, children: "At least one model is required." })) : (_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." }), (() => {
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 : DEFAULT_LIMITS.maxContextTokens;
613
- const limits = { ...DEFAULT_LIMITS, maxContextTokens: tokens };
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: existingConfig?.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(DEFAULT_LIMITS.maxContextTokens) })] })] })), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, italic: true, children: [stepNumber > 1 ? 'Backspace (empty) to go back \u2022 ' : '', "ESC to quit"] }) })] })] }));
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 leftParts = [];
662
- leftParts.push(shortModel(model));
947
+ const leftSegs = [{ id: 'model', text: shortModel(model) }];
663
948
  if (tokens > 0)
664
- leftParts.push(`${formatTokens(tokens)} tok`);
665
- leftParts.push(`${toolCount} tools`);
949
+ leftSegs.push({ id: 'tok', text: `${formatTokens(tokens)} tok` });
950
+ leftSegs.push({ id: 'tools', text: `${toolCount} tools` });
666
951
  if (activeAgents > 0)
667
- leftParts.push(`${activeAgents} agent${activeAgents > 1 ? 's' : ''}`);
952
+ leftSegs.push({ id: 'agents', text: `${activeAgents} agent${activeAgents > 1 ? 's' : ''}` });
668
953
  if (contextPercent > 50)
669
- leftParts.push(`ctx ${contextPercent}%`);
670
- const leftText = leftParts.join(' \u00B7 ');
671
- const warnText = rateLimitWarn ? ' \u26A0 Rate limited' : '';
672
- const rightLen = mode.length + VERSION.length + 6 + (elapsedStr ? elapsedStr.length + 3 : 0) + warnText.length;
673
- const gap = Math.max(cols - leftText.length - rightLen - 8, 2);
674
- return (_jsxs(Box, { paddingX: 2, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: statusColor, children: statusIcon }), _jsx(Text, { dimColor: true, children: leftText })] }), _jsx(Text, { children: ' '.repeat(gap) }), _jsxs(Box, { gap: 1, children: [rateLimitWarn && _jsxs(Text, { color: "red", bold: true, children: ['\u26A0', " Rate limited"] }), _jsx(Text, { color: modeColor, bold: true, children: mode }), elapsedStr ? _jsxs(Text, { dimColor: true, children: ['\u00B7', " ", elapsedStr] }) : null, _jsxs(Text, { dimColor: true, children: ['\u00B7', " v", VERSION] })] })] }));
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
- return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 0, children: [_jsx(Text, { dimColor: true, children: " Type a message or use /help for commands. Press Esc to exit." }), _jsxs(Box, { marginTop: 0, paddingLeft: 2, gap: 1, flexWrap: "wrap", children: [_jsx(Text, { dimColor: true, children: "Tools:" }), ['shell', 'read', 'write', 'edit', 'search', 'find', 'git', 'web', 'tree'].map((t) => (_jsx(Text, { dimColor: true, bold: true, children: t }, t)))] }), _jsx(Box, { paddingLeft: 2, children: cloud.connected ? (_jsxs(Text, { color: "#20a0a0", children: ['☁', " Scalix Cloud ", '·', " ", cloud.identity ? `${cloud.identity.limits.plan} ${'·'} ` : '', cloud.toolCount, " platform tools"] })) : (_jsxs(Text, { dimColor: true, children: ['☁', " not connected ", '—', " /login to link your Scalix account"] })) })] }));
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 (_jsxs(Box, { paddingX: 2, marginTop: 1, gap: 1, children: [_jsx(Text, { color: "magentaBright", bold: true, children: '\u276F' }), _jsx(Text, { wrap: "wrap", bold: true, children: text })] }));
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
- const rendered = renderMarkdown(text);
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: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "red", children: '\u2718' }), _jsx(Text, { wrap: "wrap", color: "red", children: text })] }), hint && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { wrap: "wrap", color: "yellow", dimColor: true, children: hint }) }))] }));
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 = truncate(args, 80);
1072
+ summary = args;
703
1073
  }
704
1074
  catch {
705
- summary = truncate(args, 80);
1075
+ summary = args;
706
1076
  }
707
- return (_jsxs(Box, { paddingLeft: 3, paddingRight: 2, gap: 1, marginTop: 1, children: [_jsx(Text, { color: "cyan", children: '\u25CB' }), _jsx(Text, { color: "cyan", bold: true, children: name }), _jsx(Text, { dimColor: true, children: summary })] }));
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
- const maxLen = Math.max(cols - name.length - 10, 40);
712
- return (_jsxs(Box, { paddingLeft: 3, paddingRight: 2, gap: 1, children: [_jsx(Text, { color: "green", children: '\u25CF' }), _jsxs(Text, { dimColor: true, children: [name, ":"] }), _jsx(Text, { dimColor: true, children: truncate(result, maxLen) })] }));
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({ count, children, collapsed }) {
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 (_jsxs(Box, { paddingX: 2, paddingLeft: 3, marginY: 0, gap: 1, children: [_jsx(Text, { dimColor: true, children: '\u2500' }), _jsx(Text, { dimColor: true, wrap: "wrap", children: text })] }));
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, { count: item.children?.length ?? 0, children: item.children, collapsed: item.collapsed });
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" }) }), _jsxs(Text, { dimColor: true, children: ["Thinking", model ? ` (${model})` : '', "..."] })] })) : null] }));
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: ' '.repeat(Math.max(0, boxWidth - 24)) }), '\u2502'] }), _jsxs(Text, { color: "yellow", children: ['\u2502', " ", _jsx(Text, { dimColor: true, children: "Tool:" }), " ", _jsx(Text, { color: "cyan", bold: true, children: toolName }), _jsx(Text, { children: pad(7 + stringWidth(toolName)) }), '\u2502'] }), highlightStr && (() => {
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: ' '.repeat(Math.max(0, boxWidth - 42)) }), '\u2502'] }), _jsxs(Text, { color: "yellow", children: ['\u2514', border, '\u2518'] })] }));
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
- // ── When command picker is open, handle input manually since TextInput is unfocused ──
1255
- const pickerOpen = !disabled && val.startsWith('/') && !val.includes(' ');
1256
- if (pickerOpen) {
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
- { cmd: '/help', desc: 'Show all commands' },
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, { value: value, onChange: onChange, onSubmit: handleSubmit, placeholder: disabled ? 'Queue a message... (sent after current task)' : 'Type a message...', showCursor: true, focus: !pickerActive }) }), disabled && (_jsx(Box, { flexShrink: 0, children: _jsx(Text, { dimColor: true, children: " ^C interrupt" }) }))] }) })] }));
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
- const reservedForStream = isLoading ? Math.max(4, Math.min(streamLineCount + 3, Math.floor(availableHeight * 0.5))) : 0;
1483
- const effectiveHeight = Math.max(availableHeight - reservedForStream, 4);
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 i = prev.length - 1;
1541
- while (i >= 0 && isToolItem(prev[i].kind))
1542
- i--;
1543
- const toolStart = i + 1;
1544
- const toolItems = prev.slice(toolStart);
1545
- if (toolItems.length < TOOL_GROUP_THRESHOLD)
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 ${Math.ceil(toolItems.length / 2)} tools`,
1931
+ text: `Ran ${calls} tools`,
1551
1932
  children: toolItems,
1552
- collapsed: true,
1933
+ collapsed: previous?.collapsed ?? true,
1553
1934
  };
1554
- return [...prev.slice(0, toolStart), group];
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 = typeof result === 'string' ? result : JSON.stringify(result);
2048
+ let text = summarizeToolResult(result);
1668
2049
  const MAX_DISPLAY = 2000;
1669
- if (text.length > MAX_DISPLAY) {
1670
- const truncated = text.length - MAX_DISPLAY;
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 (hasKey) {
1802
- add('system', `☁ Scalix Cloud unavailable — running with local tools only. ${state.error ?? ''}`.trimEnd());
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
- add('system', `Platform tools: ${s.toolCount} registered ${'—'} /cloud tools to list them`);
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', `☁ not connected ${'—'} /login to link your Scalix account`);
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
- : `☁ not connected ${'—'} /login to link your Scalix account`);
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', `☁ Scalix Cloud unavailable: ${state.error ?? 'unknown 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
- loadConfig().then(cfg => {
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
- loadConfig().then(cfg => {
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
- add('system', `Model: ${modelsStr}`);
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
- add('system', cloudConnection.connected
2811
- ? `Cloud: ☁ Scalix Cloud ${'·'} ${cloudConnection.identity ? `${cloudConnection.identity.limits.plan} ${'·'} ` : ''}${cloudConnection.toolCount} platform tools`
2812
- : `Cloud: ☁ not connected ${'—'} /login to link your Scalix account`);
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
- loadConfig().then(cfg => {
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
- if (!result || !result.trim()) {
3042
- add('system', 'Agent returned no response.');
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
- add('error', e.message || 'Unknown error');
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
- if (isLoadingRef.current && trimmed.startsWith('/mode')) {
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
- setModels([session.model]);
3124
- agent.setModel(session.model);
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
- return 5 + (hasHighlight ? 1 : 0) + extraCount;
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
- const chatHeight = Math.max(rows - chromeHeight, 6);
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: (v) => {
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
- setModels([v]);
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
- loadConfig().then(cfg => {
3252
- cfg.models = [v];
3253
- saveConfig(cfg);
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
- loadConfig().then(cfg => {
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
- loadConfig().then((c) => {
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
- agent?.updateLimit(key, parsed);
3278
- add('system', `Setting updated: ${key} = ${parsed}`);
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 as fallback)
3304
- fetchModelSpecs(c.baseUrl, c.apiKey).catch((err) => { logger.warn('Failed to fetch model specs', { error: err.message }); });
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
  }