zidane 4.1.3 → 4.1.5

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/tui.js CHANGED
@@ -1,12 +1,14 @@
1
1
  import { d as createAgent } from "./tools-DpeWKzP1.js";
2
2
  import { n as toolResultToText } from "./types-Bx_F8jet.js";
3
3
  import { r as basic_default } from "./presets-Cs7_CsMk.js";
4
- import { i as anthropic, n as openai, r as cerebras, t as openrouter } from "./providers-CX-R-Oy-.js";
4
+ import { i as anthropic, n as openai, r as cerebras, t as openrouter } from "./providers-CCDvIXGJ.js";
5
5
  import { n as loadSession, t as createSession } from "./session-Cn68UASv.js";
6
6
  import { createSqliteStore } from "./session/sqlite.js";
7
+ import { spawn } from "node:child_process";
7
8
  import { dirname, resolve } from "node:path";
8
9
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
9
10
  import { homedir } from "node:os";
11
+ import { anthropicOAuthProvider, openaiCodexOAuthProvider } from "@mariozechner/pi-ai/oauth";
10
12
  import { getModel, getModels } from "@mariozechner/pi-ai";
11
13
  import { RGBA, SyntaxStyle, createCliRenderer, defaultTextareaKeyBindings } from "@opentui/core";
12
14
  import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
@@ -480,103 +482,277 @@ function ToolResultBlock({ text, indent }) {
480
482
  });
481
483
  }
482
484
  //#endregion
483
- //#region src/tui/auth.ts
484
- const ENV_KEYS = {
485
- anthropic: "ANTHROPIC_API_KEY",
486
- openai: "OPENAI_CODEX_API_KEY",
487
- openrouter: "OPENROUTER_API_KEY",
488
- cerebras: "CEREBRAS_API_KEY"
485
+ //#region src/tui/providers.ts
486
+ /** Convenience accessor — returns `credentialFileKey ?? key`. */
487
+ function credKeyOf(desc) {
488
+ return desc.credentialFileKey ?? desc.key;
489
+ }
490
+ /** Convenience accessor — returns `piProviderId ?? key`. */
491
+ function piIdOf(desc) {
492
+ return desc.piProviderId ?? desc.key;
493
+ }
494
+ const anthropicDescriptor = {
495
+ key: "anthropic",
496
+ label: "Anthropic",
497
+ factory: anthropic,
498
+ defaultModel: "claude-opus-4-7",
499
+ envKey: "ANTHROPIC_API_KEY",
500
+ apiKeyPlaceholder: "sk-ant-…",
501
+ oauthProvider: anthropicOAuthProvider,
502
+ oauthHint: "Claude Pro/Max subscription"
503
+ };
504
+ const openaiDescriptor = {
505
+ key: "openai",
506
+ label: "OpenAI Codex",
507
+ factory: openai,
508
+ defaultModel: "gpt-5.4",
509
+ envKey: "OPENAI_CODEX_API_KEY",
510
+ credentialFileKey: "openai-codex",
511
+ piProviderId: "openai-codex",
512
+ apiKeyPlaceholder: "sk-… or eyJ… (Codex)",
513
+ oauthProvider: openaiCodexOAuthProvider
489
514
  };
490
- /** Maps a provider to the credentials.json key written by `bun run auth`. */
491
- const OAUTH_KEYS = {
492
- anthropic: "anthropic",
493
- openai: "openai-codex"
515
+ const openrouterDescriptor = {
516
+ key: "openrouter",
517
+ label: "OpenRouter",
518
+ factory: openrouter,
519
+ defaultModel: "anthropic/claude-sonnet-4-6",
520
+ envKey: "OPENROUTER_API_KEY",
521
+ apiKeyPlaceholder: "sk-or-…"
494
522
  };
495
- const LABELS = {
496
- anthropic: "Anthropic",
497
- openai: "OpenAI Codex",
498
- openrouter: "OpenRouter",
499
- cerebras: "Cerebras"
523
+ const cerebrasDescriptor = {
524
+ key: "cerebras",
525
+ label: "Cerebras",
526
+ factory: cerebras,
527
+ defaultModel: "zai-glm-4.7",
528
+ envKey: "CEREBRAS_API_KEY",
529
+ apiKeyPlaceholder: "csk-…"
530
+ };
531
+ /**
532
+ * Default provider registry. Passed verbatim when `runTui` is invoked without
533
+ * an explicit `providers` option. Hosts that want to override per-provider
534
+ * metadata can spread this and replace specific entries:
535
+ *
536
+ * ```ts
537
+ * runTui({ providers: { ...BUILTIN_PROVIDERS, anthropic: myOwnAnthropicDescriptor } })
538
+ * ```
539
+ */
540
+ const BUILTIN_PROVIDERS = {
541
+ anthropic: anthropicDescriptor,
542
+ openai: openaiDescriptor,
543
+ openrouter: openrouterDescriptor,
544
+ cerebras: cerebrasDescriptor
500
545
  };
501
- function envKeyFor(key) {
502
- return ENV_KEYS[key];
546
+ /**
547
+ * Resolve the model list for a given provider. Honors `descriptor.models`
548
+ * when set; otherwise queries pi-ai via `descriptor.piProviderId`. Returns
549
+ * `[]` for descriptors with no known mapping (custom providers without a
550
+ * model list) — callers should hide the model picker in that case.
551
+ */
552
+ function modelsForDescriptor(descriptor) {
553
+ if (descriptor.models) return descriptor.models;
554
+ try {
555
+ return getModels(piIdOf(descriptor));
556
+ } catch {
557
+ return [];
558
+ }
559
+ }
560
+ /**
561
+ * Look up the model's max context window via the descriptor's model source.
562
+ * Returns `null` when the model isn't known (custom slugs, providers without
563
+ * a registry); callers should hide the context indicator in that case.
564
+ */
565
+ function getContextWindow(descriptor, modelId) {
566
+ if (descriptor.models) return descriptor.models.find((m) => m.id === modelId)?.contextWindow ?? null;
567
+ try {
568
+ return getModel(piIdOf(descriptor), modelId)?.contextWindow ?? null;
569
+ } catch {
570
+ return null;
571
+ }
503
572
  }
573
+ //#endregion
574
+ //#region src/tui/credentials.ts
575
+ /** POSIX mode for the credentials file. Ignored on Windows. */
576
+ const FILE_MODE = 384;
504
577
  /**
505
- * Detect available auth across the providers the harness ships with.
578
+ * Resolve the credentials file path given the resolved TUI data directory
579
+ * (typically `~/.zidane`, i.e. `config.paths.dir`).
506
580
  *
507
- * Mirrors the resolution order used by the providers at runtime:
508
- * - explicit env var (highest)
509
- * - OAuth credentials in `.credentials.json` (anthropic + openai-codex only)
581
+ * Matches the convention used elsewhere in the TUI (sessions.db, state.json)
582
+ * so a single `ZIDANE_STORAGE_DIR` override moves the entire data root.
583
+ */
584
+ function credentialsPath(dataDir) {
585
+ return resolve(dataDir, "credentials.json");
586
+ }
587
+ /**
588
+ * Read credentials from disk.
510
589
  *
511
- * Pure read never refreshes or rewrites the credentials file.
590
+ * Returns `{}` when the file is missing or corrupt (last-ditch tolerance
591
+ * a hand-edit gone wrong shouldn't lock the user out of re-authing). On first
592
+ * call with no file present, attempts a migration from `cwd/.credentials.json`
593
+ * (the legacy location used by `bun run auth`).
512
594
  */
513
- function detectAuth(env = process.env) {
514
- const credsPath = resolve(process.cwd(), ".credentials.json");
515
- let creds = {};
516
- if (existsSync(credsPath)) try {
517
- const parsed = JSON.parse(readFileSync(credsPath, "utf-8"));
518
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) creds = parsed;
519
- } catch {}
520
- return Object.keys(LABELS).map((key) => {
521
- const methods = [];
522
- const envKey = ENV_KEYS[key];
523
- if (env[envKey]) methods.push({
524
- source: "env",
525
- detail: envKey
526
- });
527
- const oauthKey = OAUTH_KEYS[key];
528
- if (oauthKey) {
529
- const entry = creds[oauthKey];
530
- if (entry?.access && entry.refresh) {
531
- const detail = entry.expires ? `oauth · expires ${new Date(entry.expires).toLocaleString()}` : "oauth · .credentials.json";
532
- methods.push({
533
- source: "oauth",
534
- detail
535
- });
536
- }
537
- }
538
- return {
539
- key,
540
- label: LABELS[key],
541
- available: methods.length > 0,
542
- methods
543
- };
544
- });
595
+ function readCredentials(dataDir) {
596
+ const path = credentialsPath(dataDir);
597
+ if (!existsSync(path)) {
598
+ const migrated = migrateLegacyFile(path);
599
+ if (migrated) return migrated;
600
+ return {};
601
+ }
602
+ try {
603
+ const raw = readFileSync(path, "utf-8");
604
+ const parsed = JSON.parse(raw);
605
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
606
+ return parsed;
607
+ } catch {
608
+ return {};
609
+ }
610
+ }
611
+ /** Read a single provider's credential (translating via the descriptor). */
612
+ function readProviderCredential(dataDir, descriptor) {
613
+ return readCredentials(dataDir)[credKeyOf(descriptor)];
545
614
  }
546
- //#endregion
547
- //#region src/tui/providers.ts
548
615
  /**
549
- * Construct a fresh provider instance for a given key.
616
+ * Write credentials atomically (write-then-rename) with mode 0o600.
550
617
  *
551
- * Providers are cheap to buildcredentials are resolved lazily at first
552
- * stream call so we instantiate on demand rather than caching a singleton.
553
- * This also avoids leaking state across session/provider switches.
618
+ * Atomic on the same filesystemreaders either see the previous file or the
619
+ * new one, never a half-written intermediate. Creates the parent dir if needed
620
+ * (first launch on a fresh machine: `~/.zidane/` may not exist yet).
554
621
  */
555
- const FACTORIES = {
556
- anthropic,
557
- openai,
558
- openrouter,
559
- cerebras
560
- };
561
- /** zidane provider key → pi-ai provider id (some don't match 1:1). */
562
- const PI_PROVIDER_ID = {
563
- anthropic: "anthropic",
564
- openai: "openai-codex",
565
- openrouter: "openrouter",
566
- cerebras: "cerebras"
567
- };
622
+ function writeCredentials(dataDir, creds) {
623
+ const path = credentialsPath(dataDir);
624
+ mkdirSync(dirname(path), { recursive: true });
625
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
626
+ writeFileSync(tmp, `${JSON.stringify(creds, null, 2)}\n`, { mode: FILE_MODE });
627
+ renameSync(tmp, path);
628
+ }
629
+ function setProviderCredential(dataDir, descriptor, cred) {
630
+ const all = readCredentials(dataDir);
631
+ all[credKeyOf(descriptor)] = cred;
632
+ writeCredentials(dataDir, all);
633
+ }
634
+ function removeProviderCredential(dataDir, descriptor) {
635
+ const all = readCredentials(dataDir);
636
+ const fileKey = credKeyOf(descriptor);
637
+ if (!(fileKey in all)) return;
638
+ delete all[fileKey];
639
+ writeCredentials(dataDir, all);
640
+ }
641
+ /**
642
+ * Inject API-key credentials into `process.env` so the harness providers pick
643
+ * them up via their existing env-var resolution. Called once at TUI launch
644
+ * after the credentials file has been resolved. OAuth credentials are NOT
645
+ * injected — those reach providers via `ZIDANE_CREDENTIALS_PATH` + the file
646
+ * reader in `src/providers/oauth.ts`.
647
+ *
648
+ * Does not overwrite env vars that are already set — explicit user-provided
649
+ * env values win over stored API keys.
650
+ *
651
+ * Descriptors without an `envKey` (OAuth-only providers, custom providers
652
+ * that bypass env-var resolution) are skipped silently.
653
+ */
654
+ function applyApiKeyEnv(dataDir, registry) {
655
+ const creds = readCredentials(dataDir);
656
+ for (const descriptor of Object.values(registry)) {
657
+ if (!descriptor.envKey || process.env[descriptor.envKey]) continue;
658
+ const cred = creds[credKeyOf(descriptor)];
659
+ if (cred?.kind === "apikey" && cred.value) process.env[descriptor.envKey] = cred.value;
660
+ }
661
+ }
568
662
  /**
569
- * Look up the model's max context window via pi-ai's model registry.
570
- * Returns `null` when the model isn't known (e.g. a custom openrouter slug);
571
- * callers should hide the context indicator in that case.
663
+ * `bun run auth` (pre-TUI) wrote `cwd/.credentials.json` with an entry per
664
+ * provider mapping directly to an OAuthCredentials payload, e.g.:
665
+ *
666
+ * {
667
+ * "anthropic": { "access": "...", "refresh": "...", "expires": 123 },
668
+ * "openai-codex": { "access": "...", "refresh": "...", "expires": 123, "accountId": "..." }
669
+ * }
670
+ *
671
+ * We don't delete the legacy file — it might still be used by a host that
672
+ * imports the harness directly. We just copy its contents into the new
673
+ * location under the kind-tagged shape so the TUI picks them up.
674
+ *
675
+ * Migration is provider-agnostic: any top-level entry with an `access` field
676
+ * is preserved verbatim (extras included), under the same key. The TUI's
677
+ * detection then looks them up via the matching descriptor's `credentialFileKey`.
678
+ *
679
+ * Returns the migrated credentials when the migration ran, or `null` when
680
+ * there's no legacy file to migrate.
572
681
  */
573
- function getContextWindow(key, modelId) {
682
+ function migrateLegacyFile(targetPath) {
683
+ const legacyPath = resolve(process.cwd(), ".credentials.json");
684
+ if (!existsSync(legacyPath)) return null;
685
+ let legacy;
574
686
  try {
575
- const providerId = PI_PROVIDER_ID[key];
576
- return getModel(providerId, modelId)?.contextWindow ?? null;
687
+ legacy = JSON.parse(readFileSync(legacyPath, "utf-8"));
577
688
  } catch {
578
689
  return null;
579
690
  }
691
+ if (!legacy || typeof legacy !== "object" || Array.isArray(legacy)) return null;
692
+ const migrated = {};
693
+ for (const [fileKey, value] of Object.entries(legacy)) {
694
+ if (!isOAuthLegacy(value)) continue;
695
+ const { access, refresh, expires, ...extras } = value;
696
+ migrated[fileKey] = {
697
+ kind: "oauth",
698
+ access,
699
+ ...typeof refresh === "string" ? { refresh } : {},
700
+ ...typeof expires === "number" ? { expires } : {},
701
+ ...extras
702
+ };
703
+ }
704
+ if (Object.keys(migrated).length === 0) return null;
705
+ mkdirSync(dirname(targetPath), { recursive: true });
706
+ const tmp = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
707
+ writeFileSync(tmp, `${JSON.stringify(migrated, null, 2)}\n`, { mode: FILE_MODE });
708
+ renameSync(tmp, targetPath);
709
+ return migrated;
710
+ }
711
+ function isOAuthLegacy(value) {
712
+ return typeof value === "object" && value !== null && "access" in value && typeof value.access === "string";
713
+ }
714
+ //#endregion
715
+ //#region src/tui/auth.ts
716
+ /**
717
+ * Detect available auth for every registered provider.
718
+ *
719
+ * Resolution order per provider (a method appears in `methods` for each
720
+ * layer that has a credential — the agent itself resolves them in the same
721
+ * order via its provider factories):
722
+ *
723
+ * 1. `kind: 'apikey'` from `credentials.json` (injected into env at TUI launch)
724
+ * 2. explicit env var (descriptor's `envKey`)
725
+ * 3. `kind: 'oauth'` from `credentials.json` (or legacy `cwd/.credentials.json`)
726
+ *
727
+ * Pure read — never refreshes or rewrites the credentials file.
728
+ */
729
+ function detectAuth(dataDir, registry, env = process.env) {
730
+ const creds = readCredentials(dataDir);
731
+ return Object.values(registry).map((descriptor) => {
732
+ const methods = [];
733
+ const fileEntry = creds[credKeyOf(descriptor)];
734
+ if (fileEntry?.kind === "apikey" && fileEntry.value) methods.push({
735
+ source: "apikey",
736
+ detail: "credentials.json"
737
+ });
738
+ if (descriptor.envKey && env[descriptor.envKey]) methods.push({
739
+ source: "env",
740
+ detail: descriptor.envKey
741
+ });
742
+ if (fileEntry?.kind === "oauth" && fileEntry.access) {
743
+ const detail = typeof fileEntry.expires === "number" ? `oauth · expires ${new Date(fileEntry.expires).toLocaleString()}` : "oauth · credentials.json";
744
+ methods.push({
745
+ source: "oauth",
746
+ detail
747
+ });
748
+ }
749
+ return {
750
+ key: descriptor.key,
751
+ label: descriptor.label,
752
+ available: methods.length > 0,
753
+ methods
754
+ };
755
+ });
580
756
  }
581
757
  //#endregion
582
758
  //#region src/tui/store.ts
@@ -714,13 +890,12 @@ function resolveConfig(options = {}) {
714
890
  const store = options.store ?? createTuiStore(paths.db);
715
891
  const stateStore = createStateStore(paths.state);
716
892
  const initialState = stateStore.load();
717
- const providers = {
718
- ...FACTORIES,
719
- ...options.providers ?? {}
720
- };
893
+ const providers = options.providers ?? BUILTIN_PROVIDERS;
721
894
  const preset = options.preset ?? basic_default;
722
- const modelsFor = makeModelsResolver(options.models);
723
- const resumeProvider = resolveResumeProvider(initialState, providers);
895
+ process.env.ZIDANE_CREDENTIALS_PATH = credentialsPath(dir);
896
+ applyApiKeyEnv(dir, providers);
897
+ const modelsFor = makeModelsResolver(providers);
898
+ const resumeProvider = resolveResumeProvider(initialState, providers, dir);
724
899
  const initialPicked = resumeProvider ? pickInitial(resumeProvider, providers, initialState) : null;
725
900
  return {
726
901
  prefix,
@@ -737,31 +912,32 @@ function resolveConfig(options = {}) {
737
912
  initialPicked
738
913
  };
739
914
  }
740
- function makeModelsResolver(custom) {
915
+ function makeModelsResolver(registry) {
741
916
  return (key) => {
742
- const overridden = custom?.[key];
743
- if (overridden) return overridden;
744
- try {
745
- const piId = PI_PROVIDER_ID[key];
746
- return getModels(piId);
747
- } catch {
748
- return [];
749
- }
917
+ const descriptor = registry[key];
918
+ return descriptor ? modelsForDescriptor(descriptor) : [];
750
919
  };
751
920
  }
752
- function resolveResumeProvider(state, providers) {
921
+ function resolveResumeProvider(state, providers, storageDir) {
753
922
  if (!state.lastProvider) return null;
754
923
  if (!providers[state.lastProvider]) return null;
755
- return detectAuth().find((p) => p.key === state.lastProvider && p.available) ?? null;
924
+ return detectAuth(storageDir, providers).find((p) => p.key === state.lastProvider && p.available) ?? null;
756
925
  }
757
926
  function pickInitial(auth, providers, state) {
758
- const factory = providers[auth.key];
759
- if (!factory) return null;
760
- const provider = factory();
761
- return {
927
+ const descriptor = providers[auth.key];
928
+ if (!descriptor) return null;
929
+ const model = state.lastModelByProvider?.[auth.key] ?? descriptor.defaultModel ?? safeFactoryDefault(descriptor);
930
+ return model ? {
762
931
  provider: auth,
763
- model: state.lastModelByProvider?.[auth.key] ?? provider.meta.defaultModel
764
- };
932
+ model
933
+ } : null;
934
+ }
935
+ function safeFactoryDefault(descriptor) {
936
+ try {
937
+ return descriptor.factory().meta.defaultModel;
938
+ } catch {
939
+ return;
940
+ }
765
941
  }
766
942
  const ConfigContext = createContext(null);
767
943
  function ConfigProvider({ config, children }) {
@@ -868,9 +1044,10 @@ function Modal({ title, onClose, children, maxWidth = 92, minWidth = 44, horizon
868
1044
  /** Cap the visible scroll window so a 30-model list doesn't push the modal off-screen. */
869
1045
  const VISIBLE_ROW_CAP = 12;
870
1046
  /**
871
- * Modal that lists the available models for the current provider and lets the
872
- * user pick one. Options come from `runTui({ models })` if supplied, otherwise
873
- * from pi-ai's built-in registry.
1047
+ * Modal that lists the available models for the current provider and lets
1048
+ * the user pick one. Options come from the active `ProviderDescriptor`
1049
+ * either its declared `models` list or, when absent, pi-ai's built-in
1050
+ * registry looked up via `piProviderId`.
874
1051
  *
875
1052
  * Each row shows: `● selected · name (ctx N · reasoning · vision)`.
876
1053
  */
@@ -933,9 +1110,21 @@ function EmptyState() {
933
1110
  children: [/* @__PURE__ */ jsx("text", {
934
1111
  fg: COLOR.dim,
935
1112
  children: "No models available for this provider."
936
- }), /* @__PURE__ */ jsx("text", {
1113
+ }), /* @__PURE__ */ jsxs("text", {
937
1114
  fg: COLOR.mute,
938
- children: "Pass a `models` registry to `runTui()` to populate this list."
1115
+ children: [
1116
+ "Set",
1117
+ /* @__PURE__ */ jsx("span", {
1118
+ fg: COLOR.model,
1119
+ children: " models "
1120
+ }),
1121
+ "on the provider descriptor (or a",
1122
+ /* @__PURE__ */ jsx("span", {
1123
+ fg: COLOR.model,
1124
+ children: " piProviderId "
1125
+ }),
1126
+ "that pi-ai recognizes) to populate this list."
1127
+ ]
939
1128
  })]
940
1129
  });
941
1130
  }
@@ -947,35 +1136,140 @@ function describeModel(m) {
947
1136
  return parts.join(" · ");
948
1137
  }
949
1138
  //#endregion
1139
+ //#region src/tui/oauth.ts
1140
+ function supportsOAuth(descriptor) {
1141
+ return descriptor.oauthProvider !== void 0;
1142
+ }
1143
+ /**
1144
+ * Run the OAuth login flow for a provider.
1145
+ *
1146
+ * Returns the OAuth credentials on success; caller persists them via
1147
+ * `setProviderCredential(dataDir, descriptor, { kind: 'oauth', ...credentials })`.
1148
+ * Throws when the descriptor has no `oauthProvider` configured.
1149
+ */
1150
+ async function runOAuthLogin(descriptor, options) {
1151
+ if (!descriptor.oauthProvider) throw new Error(`OAuth not supported for ${descriptor.label} (${descriptor.key}) — use an API key instead.`);
1152
+ const callbacks = {
1153
+ onAuth: (info) => {
1154
+ options.onUrl(info.url, info.instructions);
1155
+ tryOpenBrowser(info.url);
1156
+ },
1157
+ onPrompt: async () => {
1158
+ if (!options.onCodeRequest) throw new Error("OAuth flow requires manual code input but no handler is wired.");
1159
+ return options.onCodeRequest();
1160
+ },
1161
+ onProgress: options.onProgress,
1162
+ signal: options.signal
1163
+ };
1164
+ return descriptor.oauthProvider.login(callbacks);
1165
+ }
1166
+ /**
1167
+ * Best-effort cross-platform browser open. macOS uses `open`, Linux uses
1168
+ * `xdg-open`, Windows uses `start`. Failures are swallowed — the callback
1169
+ * server is already listening, and the URL is displayed in the TUI for
1170
+ * manual click.
1171
+ *
1172
+ * Uses `spawn` (not `exec`) so the URL is passed as an argv element rather
1173
+ * than interpolated into a shell command — no need to think about quoting
1174
+ * URLs that contain `&`, `?`, `"` or other shell metacharacters.
1175
+ */
1176
+ function tryOpenBrowser(url) {
1177
+ const [cmd, ...args] = (() => {
1178
+ if (process.platform === "darwin") return ["open", url];
1179
+ if (process.platform === "win32") return [
1180
+ "cmd",
1181
+ "/c",
1182
+ "start",
1183
+ "",
1184
+ url
1185
+ ];
1186
+ return ["xdg-open", url];
1187
+ })();
1188
+ try {
1189
+ const child = spawn(cmd, args, {
1190
+ stdio: "ignore",
1191
+ detached: true
1192
+ });
1193
+ child.on("error", () => {});
1194
+ child.unref();
1195
+ } catch {}
1196
+ }
1197
+ //#endregion
950
1198
  //#region src/tui/screens.tsx
951
1199
  /**
952
- * Textarea bindings: plain `enter` submits; `shift+enter` inserts a newline.
953
- * All `return` defaults are stripped and replaced so the user's preferred
954
- * binding wins regardless of modifier state.
1200
+ * Build a key-binding set for the prompt textarea / API-key input. Strips the
1201
+ * default `return` action and reinstalls it with our preferred meaning, so the
1202
+ * binding wins regardless of modifier state. Pass `allowShiftReturnNewline`
1203
+ * to enable `shift+enter` → newline (multi-line input).
955
1204
  */
956
- const TEXTAREA_BINDINGS = [
957
- ...defaultTextareaKeyBindings.filter((b) => b.name !== "return"),
958
- {
1205
+ function makeSubmitBindings(allowShiftReturnNewline) {
1206
+ const base = defaultTextareaKeyBindings.filter((b) => b.name !== "return");
1207
+ return allowShiftReturnNewline ? [
1208
+ ...base,
1209
+ {
1210
+ name: "return",
1211
+ action: "submit"
1212
+ },
1213
+ {
1214
+ name: "return",
1215
+ shift: true,
1216
+ action: "newline"
1217
+ }
1218
+ ] : [...base, {
959
1219
  name: "return",
960
1220
  action: "submit"
961
- },
962
- {
963
- name: "return",
964
- shift: true,
965
- action: "newline"
966
- }
967
- ];
1221
+ }];
1222
+ }
1223
+ const TEXTAREA_BINDINGS = makeSubmitBindings(true);
1224
+ const API_KEY_INPUT_BINDINGS = makeSubmitBindings(false);
1225
+ /**
1226
+ * Look up a `{ key }` item by the value of a `<select>` option. Used by every
1227
+ * screen that mixes keyed-entry rows with sentinel "+ new" / "← back" rows —
1228
+ * sentinel handling stays explicit at the call site, this helper just trims
1229
+ * the boilerplate `.find(i => i.key === ...)` typing.
1230
+ */
1231
+ function findByKey(items, value) {
1232
+ return typeof value === "string" ? items.find((i) => i.key === value) : void 0;
1233
+ }
1234
+ /**
1235
+ * Sentinel value used by the picker's "+ add / re-configure" option. Lives
1236
+ * outside the provider key namespace (key strings are at least one char, no
1237
+ * leading `__`) so we can't collide with a real registry entry.
1238
+ */
1239
+ const WIZARD_OPTION_VALUE = "__wizard__";
968
1240
  function AuthScreen({ onPick }) {
969
- const { providers: registry } = useConfig();
1241
+ const config = useConfig();
1242
+ const { providers: registry } = config;
970
1243
  const focused = useModalAwareFocus();
971
- const providers = useMemo(() => detectAuth().filter((p) => p.key in registry), [registry]);
1244
+ const [providers, setProviders] = useState([]);
1245
+ const refresh = useCallback(() => setProviders(detectAuth(config.paths.dir, registry)), [config.paths.dir, registry]);
1246
+ useEffect(() => {
1247
+ refresh();
1248
+ }, [refresh]);
1249
+ const [forceWizard, setForceWizard] = useState(false);
972
1250
  const available = useMemo(() => providers.filter((p) => p.available), [providers]);
973
- if (available.length === 0) return /* @__PURE__ */ jsx(NoAuthScreen, { providers });
974
- const options = available.map((p) => ({
1251
+ const onWizardDone = useCallback(() => {
1252
+ setForceWizard(false);
1253
+ refresh();
1254
+ }, [refresh]);
1255
+ if (available.length === 0 || forceWizard) {
1256
+ const canCancel = forceWizard && available.length > 0;
1257
+ return /* @__PURE__ */ jsx(SetupWizard, {
1258
+ registry,
1259
+ dataDir: config.paths.dir,
1260
+ onConfigured: onWizardDone,
1261
+ onCancel: canCancel ? () => setForceWizard(false) : void 0
1262
+ });
1263
+ }
1264
+ const options = [...available.map((p) => ({
975
1265
  name: p.label,
976
1266
  description: p.methods.map((m) => m.detail).join(" · "),
977
1267
  value: p.key
978
- }));
1268
+ })), {
1269
+ name: "+ add or re-configure a provider",
1270
+ description: "launch the setup wizard",
1271
+ value: WIZARD_OPTION_VALUE
1272
+ }];
979
1273
  return /* @__PURE__ */ jsx("box", {
980
1274
  title: " pick a provider ",
981
1275
  style: {
@@ -992,48 +1286,318 @@ function AuthScreen({ onPick }) {
992
1286
  wrapSelection: true,
993
1287
  onSelect: (_idx, option) => {
994
1288
  if (!option) return;
995
- const provider = available.find((p) => p.key === option.value);
1289
+ if (option.value === WIZARD_OPTION_VALUE) {
1290
+ setForceWizard(true);
1291
+ return;
1292
+ }
1293
+ const provider = findByKey(available, option.value);
996
1294
  if (provider) onPick(provider);
997
1295
  },
998
1296
  style: { flexGrow: 1 }
999
1297
  })
1000
1298
  });
1001
1299
  }
1002
- function NoAuthScreen({ providers }) {
1300
+ function SetupWizard({ registry, dataDir, onConfigured, onCancel }) {
1301
+ const [step, setStep] = useState({ kind: "pick-provider" });
1302
+ const [error, setError] = useState(null);
1303
+ const descriptors = useMemo(() => Object.values(registry), [registry]);
1304
+ const onPickProvider = useCallback((descriptor) => {
1305
+ setError(null);
1306
+ setStep({
1307
+ kind: "pick-method",
1308
+ descriptor
1309
+ });
1310
+ }, []);
1311
+ const onPickMethod = useCallback((descriptor, method) => {
1312
+ setError(null);
1313
+ if (method === "apikey") setStep({
1314
+ kind: "enter-apikey",
1315
+ descriptor
1316
+ });
1317
+ else setStep({
1318
+ kind: "oauth-running",
1319
+ descriptor
1320
+ });
1321
+ }, []);
1322
+ const onApiKeySubmit = useCallback((descriptor, value) => {
1323
+ const trimmed = value.trim();
1324
+ if (!trimmed) {
1325
+ setError("API key cannot be empty.");
1326
+ return;
1327
+ }
1328
+ try {
1329
+ setProviderCredential(dataDir, descriptor, {
1330
+ kind: "apikey",
1331
+ value: trimmed
1332
+ });
1333
+ if (descriptor.envKey) process.env[descriptor.envKey] = trimmed;
1334
+ onConfigured();
1335
+ } catch (err) {
1336
+ setError(err instanceof Error ? err.message : String(err));
1337
+ }
1338
+ }, [dataDir, onConfigured]);
1339
+ if (descriptors.length === 0) return /* @__PURE__ */ jsx(EmptyRegistryNotice, {});
1340
+ if (step.kind === "pick-provider") return /* @__PURE__ */ jsx(PickProviderStep, {
1341
+ descriptors,
1342
+ error,
1343
+ onPick: onPickProvider,
1344
+ onCancel
1345
+ });
1346
+ if (step.kind === "pick-method") return /* @__PURE__ */ jsx(PickMethodStep, {
1347
+ descriptor: step.descriptor,
1348
+ error,
1349
+ onPick: onPickMethod
1350
+ });
1351
+ if (step.kind === "enter-apikey") return /* @__PURE__ */ jsx(EnterApiKeyStep, {
1352
+ descriptor: step.descriptor,
1353
+ error,
1354
+ onSubmit: onApiKeySubmit
1355
+ });
1356
+ return /* @__PURE__ */ jsx(OAuthRunningStep, {
1357
+ descriptor: step.descriptor,
1358
+ dataDir,
1359
+ onSuccess: onConfigured,
1360
+ onError: (msg) => {
1361
+ setError(msg);
1362
+ setStep({
1363
+ kind: "pick-method",
1364
+ descriptor: step.descriptor
1365
+ });
1366
+ }
1367
+ });
1368
+ }
1369
+ /**
1370
+ * Shared wrapper for every wizard step — same border + padding + flex layout
1371
+ * with a customizable title and accent color. Footnote slot at the bottom for
1372
+ * an error banner.
1373
+ */
1374
+ function WizardPanel({ title, accent = COLOR.border, error, children }) {
1003
1375
  return /* @__PURE__ */ jsxs("box", {
1004
- title: " no authentication detected ",
1376
+ title,
1005
1377
  style: {
1006
1378
  border: true,
1007
- borderColor: COLOR.error,
1379
+ borderColor: accent,
1008
1380
  padding: 1,
1009
- flexDirection: "column",
1010
1381
  gap: 1,
1382
+ flexDirection: "column",
1011
1383
  flexGrow: 1
1012
1384
  },
1385
+ children: [children, error && /* @__PURE__ */ jsx("text", {
1386
+ fg: COLOR.error,
1387
+ children: error
1388
+ })]
1389
+ });
1390
+ }
1391
+ /** "esc to exit" footer hint shared by every wizard step that doesn't offer a "← back" affordance. */
1392
+ function WizardEscHint() {
1393
+ return /* @__PURE__ */ jsx("text", {
1394
+ fg: COLOR.dim,
1395
+ children: "esc to exit"
1396
+ });
1397
+ }
1398
+ function EmptyRegistryNotice() {
1399
+ return /* @__PURE__ */ jsxs(WizardPanel, {
1400
+ title: " no providers configured ",
1401
+ accent: COLOR.error,
1402
+ children: [/* @__PURE__ */ jsx("text", {
1403
+ fg: COLOR.error,
1404
+ children: "This TUI has no providers registered."
1405
+ }), /* @__PURE__ */ jsxs("text", {
1406
+ fg: COLOR.dim,
1407
+ children: [
1408
+ "Pass providers via",
1409
+ /* @__PURE__ */ jsx("span", {
1410
+ fg: COLOR.model,
1411
+ children: " runTui({ providers }) "
1412
+ }),
1413
+ "or use the built-ins via",
1414
+ /* @__PURE__ */ jsx("span", {
1415
+ fg: COLOR.model,
1416
+ children: " BUILTIN_PROVIDERS "
1417
+ }),
1418
+ "."
1419
+ ]
1420
+ })]
1421
+ });
1422
+ }
1423
+ /** Sentinel option value used for the wizard's "← back to picker" entry. */
1424
+ const WIZARD_BACK_VALUE = "__back__";
1425
+ function PickProviderStep({ descriptors, error, onPick, onCancel }) {
1426
+ const focused = useModalAwareFocus();
1427
+ const options = [...descriptors.map((d) => {
1428
+ const methods = supportsOAuth(d) ? ["API key", "OAuth"] : ["API key"];
1429
+ return {
1430
+ name: d.label,
1431
+ description: methods.join(" · "),
1432
+ value: d.key
1433
+ };
1434
+ }), ...onCancel ? [{
1435
+ name: "← back",
1436
+ description: "return to the provider list",
1437
+ value: WIZARD_BACK_VALUE
1438
+ }] : []];
1439
+ return /* @__PURE__ */ jsxs(WizardPanel, {
1440
+ title: onCancel ? " add or re-configure a provider " : " welcome to zidane · pick a provider ",
1441
+ error,
1442
+ children: [!onCancel && /* @__PURE__ */ jsxs("text", {
1443
+ fg: COLOR.dim,
1444
+ children: [
1445
+ "No provider credentials yet. Pick a provider to configure — keys are stored in",
1446
+ /* @__PURE__ */ jsx("span", {
1447
+ fg: COLOR.model,
1448
+ children: " ~/.zidane/credentials.json "
1449
+ }),
1450
+ "(owner-only)."
1451
+ ]
1452
+ }), /* @__PURE__ */ jsx("select", {
1453
+ ...SELECT_THEME,
1454
+ focused,
1455
+ options,
1456
+ wrapSelection: true,
1457
+ onSelect: (_idx, option) => {
1458
+ if (!option) return;
1459
+ if (option.value === WIZARD_BACK_VALUE) {
1460
+ onCancel?.();
1461
+ return;
1462
+ }
1463
+ const descriptor = findByKey(descriptors, option.value);
1464
+ if (descriptor) onPick(descriptor);
1465
+ },
1466
+ style: { flexGrow: 1 }
1467
+ })]
1468
+ });
1469
+ }
1470
+ function PickMethodStep({ descriptor, error, onPick }) {
1471
+ const focused = useModalAwareFocus();
1472
+ const options = useMemo(() => {
1473
+ const items = [{
1474
+ name: "API key",
1475
+ description: `paste your ${descriptor.label} API key`,
1476
+ value: "apikey"
1477
+ }];
1478
+ if (supportsOAuth(descriptor)) {
1479
+ const hint = descriptor.oauthHint ? ` (${descriptor.oauthHint})` : "";
1480
+ items.push({
1481
+ name: "OAuth",
1482
+ description: `browser-based sign-in${hint}`,
1483
+ value: "oauth"
1484
+ });
1485
+ }
1486
+ return items;
1487
+ }, [descriptor]);
1488
+ return /* @__PURE__ */ jsxs(WizardPanel, {
1489
+ title: ` configure ${descriptor.label} — pick auth method `,
1490
+ error,
1491
+ children: [/* @__PURE__ */ jsx(WizardEscHint, {}), /* @__PURE__ */ jsx("select", {
1492
+ ...SELECT_THEME,
1493
+ focused,
1494
+ options,
1495
+ wrapSelection: true,
1496
+ onSelect: (_idx, option) => {
1497
+ if (option) onPick(descriptor, option.value);
1498
+ },
1499
+ style: { flexGrow: 1 }
1500
+ })]
1501
+ });
1502
+ }
1503
+ function EnterApiKeyStep({ descriptor, error, onSubmit }) {
1504
+ const focused = useModalAwareFocus();
1505
+ const inputRef = useRef(null);
1506
+ const submit = useCallback(() => {
1507
+ onSubmit(descriptor, inputRef.current?.value ?? "");
1508
+ }, [descriptor, onSubmit]);
1509
+ return /* @__PURE__ */ jsxs(WizardPanel, {
1510
+ title: ` configure ${descriptor.label} — paste API key `,
1511
+ error,
1512
+ children: [/* @__PURE__ */ jsxs("text", {
1513
+ fg: COLOR.dim,
1514
+ children: [
1515
+ "Paste your",
1516
+ ` ${descriptor.label} `,
1517
+ "API key and press",
1518
+ /* @__PURE__ */ jsx("span", {
1519
+ fg: COLOR.model,
1520
+ children: " enter "
1521
+ }),
1522
+ "to save. Esc to exit."
1523
+ ]
1524
+ }), /* @__PURE__ */ jsx("box", {
1525
+ style: {
1526
+ border: true,
1527
+ borderColor: COLOR.borderActive,
1528
+ paddingLeft: 1,
1529
+ paddingRight: 1,
1530
+ height: 3
1531
+ },
1532
+ children: /* @__PURE__ */ jsx("input", {
1533
+ ref: inputRef,
1534
+ focused,
1535
+ keyBindings: API_KEY_INPUT_BINDINGS,
1536
+ placeholder: descriptor.apiKeyPlaceholder ?? "API key…",
1537
+ onSubmit: submit,
1538
+ style: { flexGrow: 1 }
1539
+ })
1540
+ })]
1541
+ });
1542
+ }
1543
+ function OAuthRunningStep({ descriptor, dataDir, onSuccess, onError }) {
1544
+ const [url, setUrl] = useState(null);
1545
+ const [status, setStatus] = useState("starting browser…");
1546
+ useEffect(() => {
1547
+ const ac = new AbortController();
1548
+ let cancelled = false;
1549
+ (async () => {
1550
+ try {
1551
+ const creds = await runOAuthLogin(descriptor, {
1552
+ onUrl: (loginUrl) => {
1553
+ if (cancelled) return;
1554
+ setUrl(loginUrl);
1555
+ setStatus("waiting for browser callback…");
1556
+ },
1557
+ onProgress: (message) => {
1558
+ if (!cancelled) setStatus(message);
1559
+ },
1560
+ signal: ac.signal
1561
+ });
1562
+ if (cancelled) return;
1563
+ setProviderCredential(dataDir, descriptor, {
1564
+ kind: "oauth",
1565
+ ...creds
1566
+ });
1567
+ onSuccess();
1568
+ } catch (err) {
1569
+ if (cancelled) return;
1570
+ onError(err instanceof Error ? err.message : String(err));
1571
+ }
1572
+ })();
1573
+ return () => {
1574
+ cancelled = true;
1575
+ ac.abort();
1576
+ };
1577
+ }, [
1578
+ descriptor,
1579
+ dataDir,
1580
+ onSuccess,
1581
+ onError
1582
+ ]);
1583
+ return /* @__PURE__ */ jsxs(WizardPanel, {
1584
+ title: ` configure ${descriptor.label} — OAuth `,
1013
1585
  children: [
1014
- /* @__PURE__ */ jsx("text", {
1015
- fg: COLOR.error,
1016
- children: "No provider credentials found."
1017
- }),
1018
- /* @__PURE__ */ jsx("text", {
1019
- fg: COLOR.dim,
1020
- children: "Set one of these env vars (or run `bun run auth` for OAuth):"
1021
- }),
1022
- providers.map((p) => /* @__PURE__ */ jsxs("text", {
1023
- fg: COLOR.dim,
1024
- children: [
1025
- " · ",
1026
- /* @__PURE__ */ jsx("span", {
1027
- fg: COLOR.brand,
1028
- children: p.label
1029
- }),
1030
- " → ",
1031
- /* @__PURE__ */ jsx("span", {
1032
- fg: COLOR.model,
1033
- children: envKeyFor(p.key)
1034
- })
1035
- ]
1036
- }, p.key))
1586
+ /* @__PURE__ */ jsx(WizardEscHint, {}),
1587
+ /* @__PURE__ */ jsx(Spinner, { label: status }),
1588
+ url && /* @__PURE__ */ jsxs("box", {
1589
+ style: {
1590
+ flexDirection: "column",
1591
+ gap: 0
1592
+ },
1593
+ children: [/* @__PURE__ */ jsx("text", {
1594
+ fg: COLOR.dim,
1595
+ children: "If the browser didn't open, visit:"
1596
+ }), /* @__PURE__ */ jsx("text", {
1597
+ fg: COLOR.model,
1598
+ children: url
1599
+ })]
1600
+ })
1037
1601
  ]
1038
1602
  });
1039
1603
  }
@@ -1074,7 +1638,7 @@ function SessionsScreen({ sessions, currentId, onPick, onCreate }) {
1074
1638
  onSelect: (_idx, option) => {
1075
1639
  if (!option) return;
1076
1640
  if (option.value === NEW_VALUE) onCreate();
1077
- else onPick(option.value);
1641
+ else if (typeof option.value === "string") onPick(option.value);
1078
1642
  },
1079
1643
  style: { flexGrow: 1 }
1080
1644
  })
@@ -1087,7 +1651,7 @@ function ChatScreen({ events, busy, settings, onSubmit, session }) {
1087
1651
  const title = useMemo(() => {
1088
1652
  if (!session) return " untitled ";
1089
1653
  const turns = `${session.turnCount} turn${session.turnCount === 1 ? "" : "s"}`;
1090
- return ` ${session.title} · #${shortId(session.id)} · ${turns} `;
1654
+ return ` ${session.title} · #${shortId(session.id)} · ${turns} `;
1091
1655
  }, [session]);
1092
1656
  const userPrompts = useMemo(() => events.filter((e) => e.kind === "info").map((e) => e.text.replace(/^❯ /, "")), [events]);
1093
1657
  return /* @__PURE__ */ jsxs("box", {
@@ -1239,40 +1803,76 @@ function useSettings() {
1239
1803
  if (!ctx) throw new Error("useSettings must be used inside <SettingsProvider>");
1240
1804
  return ctx;
1241
1805
  }
1242
- const ROWS = [
1806
+ const TOGGLES = [
1243
1807
  {
1808
+ kind: "toggle",
1244
1809
  key: "showThinking",
1245
1810
  label: "Thinking blocks",
1246
1811
  description: "agent reasoning shown inline"
1247
1812
  },
1248
1813
  {
1814
+ kind: "toggle",
1249
1815
  key: "showToolCalls",
1250
1816
  label: "Tool calls",
1251
1817
  description: "the ↳ name(args) lines"
1252
1818
  },
1253
1819
  {
1820
+ kind: "toggle",
1254
1821
  key: "showToolResults",
1255
1822
  label: "Tool outputs",
1256
1823
  description: "the ┃ result blocks under tool calls"
1257
1824
  }
1258
1825
  ];
1259
- function SettingsModal() {
1826
+ function SettingsModal({ actions } = {}) {
1260
1827
  const { settings, toggle } = useSettings();
1261
- const [cursor, setCursor] = useState(0);
1828
+ const [cursor, setCursorRaw] = useState(0);
1829
+ const items = useMemo(() => {
1830
+ const actionItems = [];
1831
+ if (actions?.onReauth) actionItems.push({
1832
+ kind: "action",
1833
+ id: "reauth",
1834
+ label: "Authentication",
1835
+ description: "switch provider, add another, or re-authenticate",
1836
+ onPick: actions.onReauth
1837
+ });
1838
+ return [...TOGGLES, ...actionItems];
1839
+ }, [actions]);
1840
+ const safeCursor = Math.min(cursor, items.length - 1);
1841
+ const setCursor = useCallback((update) => setCursorRaw((prev) => Math.min(Math.max(0, update(prev)), items.length - 1)), [items.length]);
1262
1842
  useKeyboard((key) => {
1263
- if (key.name === "up" || key.ctrl && key.name === "p") setCursor((c) => Math.max(0, c - 1));
1264
- else if (key.name === "down" || key.ctrl && key.name === "n") setCursor((c) => Math.min(ROWS.length - 1, c + 1));
1265
- else if (key.name === "return" || key.name === "space") toggle(ROWS[cursor].key);
1843
+ if (key.name === "up" || key.ctrl && key.name === "p") setCursor((c) => c - 1);
1844
+ else if (key.name === "down" || key.ctrl && key.name === "n") setCursor((c) => c + 1);
1845
+ else if (key.name === "return" || key.name === "space") {
1846
+ const item = items[safeCursor];
1847
+ if (!item) return;
1848
+ if (item.kind === "toggle") toggle(item.key);
1849
+ else item.onPick();
1850
+ }
1266
1851
  });
1852
+ const firstActionIndex = items.findIndex((i) => i.kind === "action");
1267
1853
  return /* @__PURE__ */ jsxs(Modal, {
1268
1854
  title: "settings",
1269
1855
  children: [/* @__PURE__ */ jsx("box", {
1270
1856
  style: { flexDirection: "column" },
1271
- children: ROWS.map((row, i) => /* @__PURE__ */ jsx(SettingRowView, {
1272
- row,
1273
- enabled: settings[row.key],
1274
- focused: i === cursor
1275
- }, row.key))
1857
+ children: items.map((item, i) => /* @__PURE__ */ jsxs("box", {
1858
+ style: { flexDirection: "column" },
1859
+ children: [i === firstActionIndex && i > 0 && /* @__PURE__ */ jsx("box", { style: {
1860
+ border: ["top"],
1861
+ borderColor: COLOR.mute,
1862
+ height: 1,
1863
+ marginTop: 1,
1864
+ marginBottom: 1
1865
+ } }), item.kind === "toggle" ? /* @__PURE__ */ jsx(ToggleRow, {
1866
+ label: item.label,
1867
+ description: item.description,
1868
+ enabled: settings[item.key],
1869
+ focused: i === safeCursor
1870
+ }) : /* @__PURE__ */ jsx(ActionRow, {
1871
+ label: item.label,
1872
+ description: item.description,
1873
+ focused: i === safeCursor
1874
+ })]
1875
+ }, item.kind === "toggle" ? item.key : item.id))
1276
1876
  }), /* @__PURE__ */ jsxs("text", {
1277
1877
  fg: COLOR.mute,
1278
1878
  children: [
@@ -1285,7 +1885,7 @@ function SettingsModal() {
1285
1885
  fg: COLOR.warn,
1286
1886
  children: "↵"
1287
1887
  }),
1288
- " toggle · ",
1888
+ firstActionIndex >= 0 ? " toggle/select · " : " toggle · ",
1289
1889
  /* @__PURE__ */ jsx("span", {
1290
1890
  fg: COLOR.warn,
1291
1891
  children: "esc"
@@ -1296,14 +1896,13 @@ function SettingsModal() {
1296
1896
  });
1297
1897
  }
1298
1898
  /**
1299
- * A single setting row — `▶` marker · checkbox · label · description.
1899
+ * Toggle row — `▶` marker · checkbox · label · description.
1300
1900
  *
1301
1901
  * Rendered as one `<text>` so OpenTUI's word-wrap handles narrow terminals
1302
1902
  * automatically: on wide screens everything sits on one line; on narrow ones
1303
- * the trailing description wraps under the label without breaking the row's
1304
- * structure.
1903
+ * the trailing description wraps under the label without breaking the row.
1305
1904
  */
1306
- function SettingRowView({ row, enabled, focused }) {
1905
+ function ToggleRow({ label, description, enabled, focused }) {
1307
1906
  return /* @__PURE__ */ jsxs("text", {
1308
1907
  fg: focused ? COLOR.brand : COLOR.dim,
1309
1908
  children: [
@@ -1317,11 +1916,42 @@ function SettingRowView({ row, enabled, focused }) {
1317
1916
  }),
1318
1917
  /* @__PURE__ */ jsx("span", {
1319
1918
  fg: focused ? COLOR.brand : COLOR.dim,
1320
- children: row.label
1919
+ children: label
1321
1920
  }),
1322
1921
  /* @__PURE__ */ jsx("span", {
1323
1922
  fg: COLOR.mute,
1324
- children: ` ${row.description}`
1923
+ children: ` ${description}`
1924
+ })
1925
+ ]
1926
+ });
1927
+ }
1928
+ /**
1929
+ * Action row — cursor marker · label · description · (focus-only) trailing arrow.
1930
+ *
1931
+ * The label sits in the same column as a toggle row's `[✓]` checkbox (right
1932
+ * after the 2-col cursor slot). The trailing `›` only renders when focused
1933
+ * so it reads as a "this row runs" affordance, not a static decoration on
1934
+ * every action.
1935
+ */
1936
+ function ActionRow({ label, description, focused }) {
1937
+ return /* @__PURE__ */ jsxs("text", {
1938
+ fg: focused ? COLOR.brand : COLOR.dim,
1939
+ children: [
1940
+ /* @__PURE__ */ jsx("span", {
1941
+ fg: focused ? COLOR.brand : COLOR.mute,
1942
+ children: focused ? "▶ " : " "
1943
+ }),
1944
+ /* @__PURE__ */ jsx("span", {
1945
+ fg: focused ? COLOR.brand : COLOR.accent,
1946
+ children: label
1947
+ }),
1948
+ /* @__PURE__ */ jsx("span", {
1949
+ fg: COLOR.mute,
1950
+ children: ` ${description}`
1951
+ }),
1952
+ focused && /* @__PURE__ */ jsx("span", {
1953
+ fg: COLOR.brand,
1954
+ children: " ›"
1325
1955
  })
1326
1956
  ]
1327
1957
  });
@@ -1544,20 +2174,20 @@ function AppShell() {
1544
2174
  const sessionRef = useRef(null);
1545
2175
  const stream = useStreamBuffer(setEvents);
1546
2176
  const makePicked = useCallback((provider, modelId) => {
1547
- const factory = providerRegistry[provider.key];
1548
- if (!factory) return null;
2177
+ const descriptor = providerRegistry[provider.key];
2178
+ if (!descriptor) return null;
1549
2179
  const remembered = initialState.lastModelByProvider?.[provider.key];
1550
2180
  return {
1551
2181
  provider,
1552
- model: modelId ?? remembered ?? factory().meta.defaultModel
2182
+ model: modelId ?? remembered ?? descriptor.defaultModel ?? descriptor.factory().meta.defaultModel
1553
2183
  };
1554
2184
  }, [providerRegistry, initialState]);
1555
2185
  const buildAgent = useCallback((session, key) => {
1556
- const factory = providerRegistry[key];
1557
- if (!factory) throw new Error(`No provider registered for key "${key}"`);
2186
+ const descriptor = providerRegistry[key];
2187
+ if (!descriptor) throw new Error(`No provider registered for key "${key}"`);
1558
2188
  const agent = createAgent({
1559
2189
  ...preset,
1560
- provider: factory(),
2190
+ provider: descriptor.factory(),
1561
2191
  session
1562
2192
  });
1563
2193
  agent.hooks.hook("stream:thinking", ({ delta }) => stream.queueStreamDelta("thinking", delta));
@@ -1799,10 +2429,14 @@ function AppShell() {
1799
2429
  events.length,
1800
2430
  stream
1801
2431
  ]);
2432
+ const onReauth = useCallback(() => {
2433
+ modal.close();
2434
+ setScreen("auth");
2435
+ }, [modal]);
1802
2436
  useKeyboard((key) => {
1803
2437
  if (modal.isOpen) return;
1804
2438
  if (key.ctrl && key.name === "," && screen !== "auth") {
1805
- modal.open(/* @__PURE__ */ jsx(SettingsModal, {}));
2439
+ modal.open(/* @__PURE__ */ jsx(SettingsModal, { actions: { onReauth } }));
1806
2440
  return;
1807
2441
  }
1808
2442
  if (key.ctrl && key.name === "m" && screen === "chat" && picked && !busy) {
@@ -1821,6 +2455,10 @@ function AppShell() {
1821
2455
  else renderer.destroy();
1822
2456
  return;
1823
2457
  }
2458
+ if (picked) {
2459
+ setScreen(currentSession ? "chat" : "sessions");
2460
+ return;
2461
+ }
1824
2462
  renderer.destroy();
1825
2463
  });
1826
2464
  const hints = useMemo(() => buildHints(screen, busy, currentSession), [
@@ -1830,7 +2468,9 @@ function AppShell() {
1830
2468
  ]);
1831
2469
  const contextUsage = useMemo(() => {
1832
2470
  if (screen !== "chat" || !picked) return null;
1833
- const max = modelsFor(picked.provider.key).find((m) => m.id === picked.model)?.contextWindow ?? getContextWindow(picked.provider.key, picked.model);
2471
+ const descriptor = providerRegistry[picked.provider.key];
2472
+ if (!descriptor) return null;
2473
+ const max = getContextWindow(descriptor, picked.model);
1834
2474
  return max ? {
1835
2475
  used: lastInputTokens,
1836
2476
  max
@@ -1839,7 +2479,7 @@ function AppShell() {
1839
2479
  screen,
1840
2480
  picked,
1841
2481
  lastInputTokens,
1842
- modelsFor
2482
+ providerRegistry
1843
2483
  ]);
1844
2484
  useEffect(() => () => {
1845
2485
  teardown();
@@ -1971,15 +2611,14 @@ let runTuiInvoked = false;
1971
2611
  * to `runTui({ storageDir, prefix })`.
1972
2612
  *
1973
2613
  * ```ts
1974
- * import { runTui } from 'zidane/tui'
2614
+ * import { BUILTIN_PROVIDERS, runTui } from 'zidane/tui'
1975
2615
  * import { createRemoteStore } from 'zidane/session' // for the `store` option
1976
2616
  *
1977
2617
  * await runTui() // ~/.zidane/sessions.db + state.json
1978
2618
  * await runTui({ prefix: '.myapp' }) // ~/.myapp/...
1979
2619
  * await runTui({ storageDir: '/data', prefix: 'myapp' })
1980
- * await runTui({ providers: { custom: () => myProvider() } })
2620
+ * await runTui({ providers: { ...BUILTIN_PROVIDERS, mine: myDescriptor } })
1981
2621
  * await runTui({ store: createRemoteStore({ url: '…' }) })
1982
- * await runTui({ models: { anthropic: [{ id: 'claude-foo', contextWindow: 200_000 }] } })
1983
2622
  * ```
1984
2623
  */
1985
2624
  async function runTui(options = {}) {
@@ -1999,6 +2638,6 @@ async function runTui(options = {}) {
1999
2638
  process.exit(0);
2000
2639
  }
2001
2640
  //#endregion
2002
- export { App, AuthScreen, COLOR, ChatScreen, ConfigProvider, DEFAULT_SETTINGS, FACTORIES, Footer, MD_STYLE, Modal, ModalRoot, ModelPickerModal, PI_PROVIDER_ID, SELECT_THEME, SessionsScreen, SettingsModal, SettingsProvider, Spinner, Transcript, ageString, createStateStore, createTuiStore, detectAuth, envKeyFor, eventsFromTurns, finalizeStreamingMarkdown, finalizeStreamingMarkdownForOwner, fmtTokens, getContextWindow, lastContextSizeFromTurns, listSessionMeta, loadState, marginTopFor, onInputSubmit, resolveConfig, runTui, saveState, shortId, titleFromTurns, toolCallPreview, toolResultText, turnContextSize, useConfig, useModal, useModalAwareFocus, useSettings, useStreamBuffer };
2641
+ export { App, AuthScreen, BUILTIN_PROVIDERS, COLOR, ChatScreen, ConfigProvider, DEFAULT_SETTINGS, Footer, MD_STYLE, Modal, ModalRoot, ModelPickerModal, SELECT_THEME, SessionsScreen, SettingsModal, SettingsProvider, Spinner, Transcript, ageString, anthropicDescriptor, applyApiKeyEnv, cerebrasDescriptor, createStateStore, createTuiStore, credKeyOf, credentialsPath, detectAuth, eventsFromTurns, finalizeStreamingMarkdown, finalizeStreamingMarkdownForOwner, fmtTokens, getContextWindow, lastContextSizeFromTurns, listSessionMeta, loadState, marginTopFor, modelsForDescriptor, onInputSubmit, openaiDescriptor, openrouterDescriptor, piIdOf, readCredentials, readProviderCredential, removeProviderCredential, resolveConfig, runOAuthLogin, runTui, saveState, setProviderCredential, shortId, supportsOAuth, titleFromTurns, toolCallPreview, toolResultText, turnContextSize, useConfig, useModal, useModalAwareFocus, useSettings, useStreamBuffer, writeCredentials };
2003
2642
 
2004
2643
  //# sourceMappingURL=tui.js.map