scalix 1.4.0 → 1.5.0

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