acryl-cli-linux-x64 0.1.31 → 0.1.36

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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/runtime/lib/bin.js +865 -242
  3. package/runtime/lib/index.js +867 -242
  4. package/runtime/lib/types/buildInfo.generated.d.ts +4 -0
  5. package/runtime/lib/types/tui/acrylMark.d.ts +14 -0
  6. package/runtime/lib/types/tui/actions.d.ts +9 -6
  7. package/runtime/lib/types/tui/auth-guidance.d.ts +0 -2
  8. package/runtime/lib/types/tui/listWindow.d.ts +24 -0
  9. package/runtime/lib/types/tui/login/LoginOverlay.d.ts +52 -11
  10. package/runtime/lib/types/tui/login/types.d.ts +6 -1
  11. package/runtime/lib/types/tui/modelProfile/ModelProfileOverlay.d.ts +41 -3
  12. package/runtime/lib/types/tui/modelProfile/types.d.ts +24 -0
  13. package/runtime/lib/types/tui/store.d.ts +10 -0
  14. package/runtime/lib/types/yly/yly-pet.d.ts +1 -1
  15. package/runtime/lib/types/yly/yly-programs.d.ts +1 -1
  16. package/runtime/node_modules/.modules.yaml +2 -2
  17. package/runtime/node_modules/.package-map.json +1 -1
  18. package/runtime/node_modules/@earendil-works/pi-ai/dist/auth/oauth/oauth-page.js +1 -1
  19. package/runtime/node_modules/acryl-control/lib/index.js +66 -1
  20. package/runtime/node_modules/acryl-control/lib/types/authorization/service.d.ts +23 -0
  21. package/runtime/node_modules/acryl-control/lib/types/authorization/types.d.ts +46 -0
  22. package/runtime/node_modules/acryl-control/lib/types/credential/projection.d.ts +63 -0
  23. package/runtime/node_modules/acryl-control/lib/types/credential/types.d.ts +38 -0
  24. package/runtime/node_modules/acryl-control/lib/types/index.d.ts +4 -0
  25. package/runtime/node_modules/acryl-control/package.json +1 -1
  26. package/runtime/node_modules/acryl-harness-runtime/lib/index.mjs +129 -2
  27. package/runtime/node_modules/acryl-harness-runtime/lib/types/acryl-home.d.ts +35 -0
  28. package/runtime/node_modules/acryl-harness-runtime/lib/types/index.d.ts +2 -0
  29. package/runtime/node_modules/acryl-harness-runtime/lib/types/session-bridge.d.ts +13 -0
  30. package/runtime/node_modules/acryl-harness-runtime/lib/types/session-log-exporter.d.ts +31 -0
  31. package/runtime/node_modules/acryl-harness-runtime/package.json +1 -1
  32. package/runtime/node_modules/acryl-harness-runtime/src/acryl-home.ts +50 -0
  33. package/runtime/node_modules/acryl-harness-runtime/src/index.ts +16 -0
  34. package/runtime/node_modules/acryl-harness-runtime/src/session-bridge.ts +24 -1
  35. package/runtime/node_modules/acryl-harness-runtime/src/session-log-exporter.ts +69 -0
  36. package/runtime/package.json +4 -2
  37. package/runtime/receipt.json +2 -2
@@ -6,9 +6,10 @@ import { join, relative, resolve } from "node:path";
6
6
  import { execFile, spawn } from "node:child_process";
7
7
  import { randomUUID } from "node:crypto";
8
8
  import { bootAcrylHarnessProfile, createAcrylSessionBridge } from "acryl-harness-runtime";
9
+ import { AuthorizationService, computeCredentialProjection } from "acryl-control";
9
10
  import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
10
11
  import { diffLines } from "diff";
11
- import { Container, Editor, HStack, Key, KeybindingsManager, ProcessTerminal, ScrollView, TUI_KEYBINDINGS, Text, TuiAltScreen, VStack, matchesKey, setKeybindings, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
12
+ import { Container, Editor, HStack, Key, KeybindingsManager, ProcessTerminal, ScrollView, TUI_KEYBINDINGS, Text, TuiAltScreen, VStack, fuzzyFilter, matchesKey, setKeybindings, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
12
13
  import { readdir } from "node:fs/promises";
13
14
  import { ManualCompactionError } from "@deepseek-ai/dsh-compaction";
14
15
  import { GoalError } from "@deepseek-ai/dsh-goal";
@@ -750,6 +751,7 @@ var TuiStore = class {
750
751
  title: void 0,
751
752
  stats: EMPTY_STATS,
752
753
  preset: void 0,
754
+ activeModel: void 0,
753
755
  streaming: void 0,
754
756
  pendingToolCalls: this.pendingToolCallsSnapshot(),
755
757
  shellRun: void 0,
@@ -860,6 +862,10 @@ var TuiStore = class {
860
862
  setPreset(preset) {
861
863
  this.set({ preset });
862
864
  }
865
+ /** Record a live `/model` switch so the status bar reflects it immediately. */
866
+ setActiveModel(activeModel) {
867
+ this.set({ activeModel });
868
+ }
863
869
  shellRunSeq = 0;
864
870
  /** Begin one local shell-escape run; its output accumulates via `appendShellOutput` until `finishShellRun` settles it into the transcript. */
865
871
  startShellRun(command) {
@@ -916,7 +922,6 @@ var TuiStore = class {
916
922
  kind: "login",
917
923
  login: {
918
924
  flows: void 0,
919
- selected: 0,
920
925
  signingIn: void 0,
921
926
  prompt: void 0,
922
927
  busy: true,
@@ -1040,6 +1045,36 @@ var TuiStore = class {
1040
1045
  }
1041
1046
  };
1042
1047
  //#endregion
1048
+ //#region src/tui/acrylMark.ts
1049
+ /**
1050
+ * The compact ACRYL wordmark shown in the fixed header, next to the pet.
1051
+ *
1052
+ * This is the real pixelized brand wordmark (`assets/pixelized/`), rendered
1053
+ * natively as half-block characters (each terminal row packs 2 pixel rows
1054
+ * via \u2580/\u2584/\u2588) instead of downsampled or replaced with a different mark \u2014 54
1055
+ * columns wide at the artwork's own resolution, not simplified. Generated
1056
+ * from the SVG's rect grid and verified by printing it before wiring into
1057
+ * the header \u2014 same proven-before-embedding convention as
1058
+ * `yly/compile-sprites.mjs`.
1059
+ * @module @tomowave/dsh-tui/tui/acrylMark
1060
+ */
1061
+ /** The plain (uncolored) rows of the ACRYL mark \u2014 11 lines, 54 columns. */
1062
+ const ACRYL_MARK_ROWS = [
1063
+ " ▄▀▀▀▀▀▀▀▀ ",
1064
+ " ▄▀ ▄▄▄▄▄▄▄▄▄ ",
1065
+ " ▄▄ ▄ ▀▄ ",
1066
+ " ▄█▀█▄ ▄▀ ▄▄▄▄▄█ ",
1067
+ " ▄█▀ ▀█▀▀▀▀█▄ ▀▄ ▄▀ ",
1068
+ " █▀ ▀▄ ▀█ ▀▄▄ ▄█ ",
1069
+ "▀▀ ▀ ▀█▄ ▄ ▄██▄▄▄▄▄▄▄▄▄▄",
1070
+ " ▀▀▀▀▀▀▀▀▀▀▀ ▀▀▄ ▄▀▀ ",
1071
+ " ▀▄▄▄▀ ",
1072
+ " █ ",
1073
+ " ▀ "
1074
+ ];
1075
+ /** ISO timestamp this build was produced at. */
1076
+ const BUILD_TIME = "2026-09-08T18:16:12.207Z";
1077
+ //#endregion
1043
1078
  //#region src/tui/statsFormat.ts
1044
1079
  /**
1045
1080
  * Compact token count: 517 / 12.2K / 517K / 1.2M (one decimal under three digits).
@@ -1193,7 +1228,7 @@ function stripSessionIdPrefix(id) {
1193
1228
  //#region src/tui/liveText.ts
1194
1229
  const dim = fg(theme.muted);
1195
1230
  const accent = fg(theme.accent);
1196
- const warning$1 = fg(theme.warning);
1231
+ const warning$2 = fg(theme.warning);
1197
1232
  function buildStatusBarText(params) {
1198
1233
  const { sessionId, provider, model, status, queuedCount, presetLabel, eventCount, spinnerChar } = params;
1199
1234
  const queuedSuffix = queuedCount > 0 ? ` · ${queuedCount} queued` : "";
@@ -1236,7 +1271,7 @@ const PERMISSION_COLORS = {
1236
1271
  */
1237
1272
  function buildUpdateHintText(currentVersion, latestVersion) {
1238
1273
  if (latestVersion === void 0) return "";
1239
- return warning$1(`⬆ ACRYL update available: v${currentVersion} → v${latestVersion}`) + dim(" (run `pnpm add -g @acryl/cli` to upgrade)");
1274
+ return warning$2(`⬆ ACRYL update available: v${currentVersion} → v${latestVersion}`) + dim(" (run `pnpm add -g @acryl/cli` to upgrade)");
1240
1275
  }
1241
1276
  function buildPermissionText(permission) {
1242
1277
  if (permission === void 0) return "";
@@ -2856,27 +2891,120 @@ function miniTextFieldInput(state, data) {
2856
2891
  cursor: state.cursor + data.length
2857
2892
  };
2858
2893
  }
2894
+ /**
2895
+ * Mask a value for display while typing, revealing the first/last couple of
2896
+ * characters instead of hiding it entirely — a fully-masked live field looks
2897
+ * indistinguishable from an empty one, giving no confirmation that anything
2898
+ * was actually typed. Length-preserving (unlike a static preview's `sk-p…9sZ4`
2899
+ * ellipsis form) so cursor-position math against the masked string stays
2900
+ * correct: every hidden character becomes exactly one `mask` character.
2901
+ */
2902
+ function partiallyMask(value, mask) {
2903
+ if (value.length <= 2) return mask.repeat(value.length);
2904
+ if (value.length <= 10) return `${value[0]}${mask.repeat(value.length - 2)}${value.at(-1)}`;
2905
+ return `${value.slice(0, 4)}${mask.repeat(value.length - 8)}${value.slice(-4)}`;
2906
+ }
2859
2907
  /** Render the field's text, optionally with an inverse-video cursor block at the cursor position. */
2860
2908
  function renderMiniTextField(state, cursorVisible, mask) {
2861
- const display = mask === void 0 ? state.value : mask.repeat(state.value.length);
2909
+ const display = mask === void 0 ? state.value : partiallyMask(state.value, mask);
2862
2910
  if (!cursorVisible) return display;
2863
2911
  return `${display.slice(0, state.cursor)}\x1b[7m${display[state.cursor] ?? " "}\x1b[0m${display.slice(state.cursor + 1)}`;
2864
2912
  }
2865
2913
  //#endregion
2914
+ //#region src/tui/listWindow.ts
2915
+ /**
2916
+ * Scrollable-list windowing shared by every overlay that paints a
2917
+ * selection-following list (`/login`'s provider list, `/model`'s picker and
2918
+ * provider list). Previously duplicated verbatim in `LoginOverlay.ts` and
2919
+ * `ModelProfileOverlay.ts` (specs/001-acryl-refactor-improvements-and-tech-debt, R7).
2920
+ * @module @tomowang/dsh-tui/tui/listWindow
2921
+ */
2922
+ /**
2923
+ * Rows available for a scrollable list body: terminal height minus the lines
2924
+ * every such screen spends on chrome (header/hint/notice/search — `chrome`
2925
+ * lines, caller-counted since it varies by screen). Long catalogs (~35
2926
+ * providers, matching that many models) previously rendered every row
2927
+ * unconditionally, pushing the key-legend hint line — the only place a
2928
+ * shortcut is documented — past the bottom of the terminal, invisible
2929
+ * without scrolling back. Every list-shaped view windows around the current
2930
+ * selection instead, so the hint line is always the last line printed and
2931
+ * always fits on screen.
2932
+ */
2933
+ function listWindow(terminalRows, chrome) {
2934
+ return Math.max(3, terminalRows - chrome);
2935
+ }
2936
+ /** The `[start, end)` slice of `count` items to show so `selected` stays visible within `maxVisible` rows, biased to keep it centered. */
2937
+ function visibleRange(count, selected, maxVisible) {
2938
+ if (count <= maxVisible) return {
2939
+ start: 0,
2940
+ end: count
2941
+ };
2942
+ const start = Math.max(0, Math.min(selected - Math.floor(maxVisible / 2), count - maxVisible));
2943
+ return {
2944
+ start,
2945
+ end: start + maxVisible
2946
+ };
2947
+ }
2948
+ //#endregion
2949
+ //#region src/tui/modelProfile/types.ts
2950
+ /**
2951
+ * The `apiKeyEnv` reference a provider route falls back to when its settings
2952
+ * profile names none, e.g. `my-proxy` -> `MY_PROXY_API_KEY`. Shared between
2953
+ * the overlay (recomputing it for a just-typed custom route before save) and
2954
+ * the host wiring (deriving it for every already-configured row), so the
2955
+ * naming rule lives in exactly one place.
2956
+ */
2957
+ function deriveApiKeyRef(route) {
2958
+ const upper = route.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
2959
+ return `${/^[A-Z_]/.test(upper) ? upper : `P_${upper}`}_API_KEY`;
2960
+ }
2961
+ /**
2962
+ * A short, identifying (never full) preview of a stored secret — first 4 and
2963
+ * last 4 characters, so the API-key field isn't blank-looking when a key is
2964
+ * already set and the user can tell *which* key it is without ever seeing
2965
+ * enough of it to be usable on its own. Short values (<= 10 chars, where that
2966
+ * split would show most of the string anyway) mask everything but the first
2967
+ * and last single character instead.
2968
+ */
2969
+ function maskKeyPreview(value) {
2970
+ if (value.length <= 10) return value.length <= 2 ? "*".repeat(value.length) : `${value[0]}${"*".repeat(value.length - 2)}${value.at(-1)}`;
2971
+ return `${value.slice(0, 4)}…${value.slice(-4)}`;
2972
+ }
2973
+ //#endregion
2866
2974
  //#region src/tui/modelProfile/ModelProfileOverlay.ts
2867
2975
  const bold$9 = (s) => `\x1b[1m${s}\x1b[0m`;
2868
2976
  const secondary$9 = fg(theme.secondary);
2869
2977
  const muted$10 = fg(theme.muted);
2978
+ const warning$1 = fg(theme.warning);
2870
2979
  const errorColor$5 = fg(theme.error);
2871
2980
  const invert$5 = (s) => `\x1b[7m${s}\x1b[0m`;
2981
+ /**
2982
+ * The only wire protocols `dsh-llm-pi-ai` can speak (`supportedProtocols()`),
2983
+ * matching the DSH Desktop GUI's "Custom provider" dialog's own dropdown
2984
+ * exactly. Free-text protocol entry let a typo reach `assertServiceable` as a
2985
+ * runtime save failure instead of being impossible to enter in the first
2986
+ * place, so this is a cycling choice, never a typed field.
2987
+ */
2988
+ const PROTOCOLS = [
2989
+ "openai-completions",
2990
+ "openai-responses",
2991
+ "anthropic-messages"
2992
+ ];
2872
2993
  var ModelProfileOverlay = class {
2994
+ tui;
2873
2995
  store;
2874
2996
  actions;
2997
+ mode = "picker";
2998
+ modelSearch = emptyMiniTextField();
2999
+ modelPickerCursor = 0;
3000
+ providerSearch = emptyMiniTextField();
3001
+ providerCursor = 0;
2875
3002
  confirmDelete;
3003
+ pendingShowModels = false;
2876
3004
  formKeySeen = -1;
2877
3005
  route = emptyMiniTextField();
2878
3006
  displayName = emptyMiniTextField();
2879
- api = emptyMiniTextField();
3007
+ api = PROTOCOLS[0];
2880
3008
  baseURL = emptyMiniTextField();
2881
3009
  apiKeyDraft = emptyMiniTextField();
2882
3010
  models = [];
@@ -2884,25 +3012,32 @@ var ModelProfileOverlay = class {
2884
3012
  focused = 0;
2885
3013
  modelDraftId = emptyMiniTextField();
2886
3014
  modelSelected = 0;
2887
- modelInputFocused = false;
2888
- constructor(store, actions) {
3015
+ constructor(tui, store, actions) {
3016
+ this.tui = tui;
2889
3017
  this.store = store;
2890
3018
  this.actions = actions;
2891
3019
  }
2892
3020
  invalidate() {}
3021
+ /**
3022
+ * The global one-line notice (`store.setNotice`), rendered inline here.
3023
+ * `/model` runs as a full-screen overlay that paints over the whole
3024
+ * terminal, so the notice dock underneath — where "Saved X."/"Active model
3025
+ * set to X." otherwise lands — is invisible for as long as this overlay
3026
+ * stays open (e.g. right after `saveProvider` returns to the list view
3027
+ * without closing the overlay). Surfacing it here is what makes a save
3028
+ * actually visible as "done" instead of the screen looking unchanged.
3029
+ */
3030
+ noticeLines() {
3031
+ const notice = this.store.getSnapshot().notice;
3032
+ return notice === void 0 ? [] : [muted$10(notice)];
3033
+ }
2893
3034
  textFields(draft) {
2894
- return draft.isNew ? [
3035
+ const base = draft.isNew ? [
2895
3036
  "route",
2896
3037
  "displayName",
2897
- "api",
2898
- "baseURL",
2899
- "apiKey"
2900
- ] : [
2901
- "displayName",
2902
- "api",
2903
- "baseURL",
2904
- "apiKey"
2905
- ];
3038
+ "baseURL"
3039
+ ] : ["displayName", "baseURL"];
3040
+ return draft.authMethod === "oauth" ? base : [...base, "apiKey"];
2906
3041
  }
2907
3042
  /** Reinitialize form-local state from the store's draft when `formKey` changes — the equivalent of the old `key={formKey}` remount. */
2908
3043
  syncFormState(mp) {
@@ -2912,24 +3047,29 @@ var ModelProfileOverlay = class {
2912
3047
  this.formKeySeen = mp.formKey;
2913
3048
  this.route = emptyMiniTextField(draft.route);
2914
3049
  this.displayName = emptyMiniTextField(draft.displayName);
2915
- this.api = emptyMiniTextField(draft.api);
3050
+ this.api = PROTOCOLS.includes(draft.api) ? draft.api : PROTOCOLS[0];
2916
3051
  this.baseURL = emptyMiniTextField(draft.baseURL);
2917
3052
  this.apiKeyDraft = emptyMiniTextField("");
2918
3053
  this.models = [...draft.models];
2919
- this.showModels = false;
3054
+ this.showModels = this.pendingShowModels;
3055
+ this.pendingShowModels = false;
2920
3056
  this.focused = 0;
2921
3057
  this.modelDraftId = emptyMiniTextField();
2922
3058
  this.modelSelected = 0;
2923
- this.modelInputFocused = false;
2924
3059
  }
2925
3060
  return draft;
2926
3061
  }
2927
3062
  buildDraft(draft) {
3063
+ const route = draft.isNew ? this.route.value.trim() : draft.route;
2928
3064
  return {
2929
3065
  ...draft,
2930
- route: draft.isNew ? this.route.value.trim() : draft.route,
3066
+ route,
3067
+ ...draft.isNew ? {
3068
+ settingsPath: ["providers", route],
3069
+ apiKeyRef: deriveApiKeyRef(route)
3070
+ } : {},
2931
3071
  displayName: this.displayName.value,
2932
- api: this.api.value,
3072
+ api: this.api,
2933
3073
  baseURL: this.baseURL.value,
2934
3074
  apiKeyDraft: this.apiKeyDraft.value,
2935
3075
  models: this.models
@@ -2940,111 +3080,223 @@ var ModelProfileOverlay = class {
2940
3080
  if (overlay.kind !== "modelProfile") return [];
2941
3081
  const mp = overlay.modelProfile;
2942
3082
  const draft = this.syncFormState(mp);
2943
- if (draft !== void 0) return this.showModels ? this.renderModelListEditor(mp) : this.renderForm(draft, mp);
2944
- return this.renderList(mp);
3083
+ if (draft !== void 0) {
3084
+ if (this.showModels) return this.renderModelListEditor(mp);
3085
+ return this.renderForm(draft, mp);
3086
+ }
3087
+ return this.mode === "picker" ? this.renderModelPicker(mp) : this.renderList(mp);
3088
+ }
3089
+ pickerItems(mp) {
3090
+ const items = [];
3091
+ for (const row of mp.providers ?? []) {
3092
+ if (!row.live) continue;
3093
+ for (const model of row.models) items.push({
3094
+ route: row.route,
3095
+ id: model.id,
3096
+ authMethod: row.authMethod
3097
+ });
3098
+ }
3099
+ return items;
3100
+ }
3101
+ filteredPickerItems(mp) {
3102
+ const items = this.pickerItems(mp);
3103
+ const query = this.modelSearch.value.trim();
3104
+ return query === "" ? items : fuzzyFilter([...items], query, (item) => `${item.id} ${item.route}`);
3105
+ }
3106
+ /**
3107
+ * pi.dev's `/model` picker (see Image #45): a flat, fuzzy-searchable list of
3108
+ * every model on a live (actually usable) route, not a per-provider drill-down.
3109
+ * Provider add/edit/delete moves to a secondary `p` mode instead of being the
3110
+ * default, since picking a model to make active is the far more common action.
3111
+ */
3112
+ renderModelPicker(mp) {
3113
+ const lines = [warning$1("Only showing models from configured providers. Use /login to add providers.")];
3114
+ lines.push(...this.noticeLines());
3115
+ if (mp.error !== void 0) lines.push(errorColor$5(mp.error));
3116
+ lines.push(`> ${renderMiniTextField(this.modelSearch, true)}`);
3117
+ if (mp.busy && mp.providers === void 0) lines.push(muted$10("Loading…"));
3118
+ const all = this.pickerItems(mp);
3119
+ const filtered = this.filteredPickerItems(mp);
3120
+ const chrome = lines.length + 2;
3121
+ const maxVisible = listWindow(this.tui.terminal.rows, chrome);
3122
+ const { start, end } = visibleRange(filtered.length, this.modelPickerCursor, maxVisible);
3123
+ filtered.slice(start, end).forEach((item, offset) => {
3124
+ const isSelected = start + offset === this.modelPickerCursor;
3125
+ const via = item.authMethod === "oauth" ? " oauth" : item.authMethod === "api-key" ? " api" : "";
3126
+ const text = `${isSelected ? "→ " : " "}${item.id} [${item.route}${via}]`;
3127
+ lines.push(isSelected ? invert$5(text) : text);
3128
+ });
3129
+ if (filtered.length > maxVisible) lines.push(muted$10(`(${this.modelPickerCursor + 1}/${filtered.length})`));
3130
+ if (all.length === 0 && mp.providers !== void 0) lines.push(muted$10("No models available yet — use /login to sign in to a provider."));
3131
+ else if (filtered.length === 0) lines.push(muted$10("No matching models."));
3132
+ lines.push(muted$10("type to search · ↑↓ select · enter set active · ctrl+p manage providers (add/remove models) · esc close"));
3133
+ return lines;
3134
+ }
3135
+ handleModelPickerInput(data, mp) {
3136
+ if (matchesKey(data, Key.escape)) {
3137
+ this.actions.closeModelProfile();
3138
+ return;
3139
+ }
3140
+ if (matchesKey(data, Key.ctrl("p"))) {
3141
+ this.mode = "providers";
3142
+ return;
3143
+ }
3144
+ const filtered = this.filteredPickerItems(mp);
3145
+ if (matchesKey(data, Key.up)) {
3146
+ this.modelPickerCursor = Math.max(0, this.modelPickerCursor - 1);
3147
+ return;
3148
+ }
3149
+ if (matchesKey(data, Key.down)) {
3150
+ this.modelPickerCursor = Math.min(Math.max(0, filtered.length - 1), this.modelPickerCursor + 1);
3151
+ return;
3152
+ }
3153
+ if (matchesKey(data, Key.enter)) {
3154
+ const item = filtered[this.modelPickerCursor];
3155
+ if (item !== void 0) {
3156
+ this.actions.setActiveModel(item.route, item.id);
3157
+ this.actions.closeModelProfile();
3158
+ }
3159
+ return;
3160
+ }
3161
+ const next = miniTextFieldInput(this.modelSearch, data);
3162
+ if (next !== void 0) {
3163
+ this.modelSearch = next;
3164
+ this.modelPickerCursor = 0;
3165
+ }
3166
+ }
3167
+ filteredProviders(mp) {
3168
+ const providers = mp.providers ?? [];
3169
+ const query = this.providerSearch.value.trim();
3170
+ return query === "" ? providers : fuzzyFilter([...providers], query, (row) => `${row.displayName} ${row.route}`);
2945
3171
  }
2946
3172
  renderList(mp) {
2947
- const { providers, selected, busy, error } = mp;
3173
+ const { providers, busy, error } = mp;
2948
3174
  const lines = [bold$9(secondary$9("Model providers"))];
3175
+ lines.push(...this.noticeLines());
2949
3176
  if (error !== void 0) lines.push(errorColor$5(error));
3177
+ lines.push(`> ${renderMiniTextField(this.providerSearch, true)}`);
2950
3178
  if (busy && providers === void 0) lines.push(muted$10("Loading…"));
2951
- providers?.forEach((row, index) => {
3179
+ const filtered = this.filteredProviders(mp);
3180
+ const chrome = lines.length + 2;
3181
+ const maxVisible = listWindow(this.tui.terminal.rows, chrome);
3182
+ const { start, end } = visibleRange(filtered.length, this.providerCursor, maxVisible);
3183
+ filtered.slice(start, end).forEach((row, offset) => {
3184
+ const index = start + offset;
2952
3185
  const marker = row.configured ? "● " : "○ ";
2953
3186
  const active = row.live ? " (active)" : "";
2954
- const noKey = row.apiKeyConfigured ? "" : " [no api key]";
2955
- const confirm = this.confirmDelete === index ? " — press d again to delete" : "";
2956
- const text = `${index === selected ? "› " : " "}${marker}${row.displayName}${active}${noKey}${confirm}`;
2957
- lines.push(index === selected ? invert$5(text) : text);
3187
+ const noKey = row.apiKeyConfigured ? ` [${row.authMethod === "oauth" ? "oauth" : "api key"}]` : " [no api key]";
3188
+ const confirm = this.confirmDelete === index ? " — press ctrl+x again to delete" : "";
3189
+ const text = `${index === this.providerCursor ? "› " : " "}${marker}${row.displayName}${active}${noKey}${confirm}`;
3190
+ lines.push(index === this.providerCursor ? invert$5(text) : text);
2958
3191
  });
2959
- if (providers?.length === 0) lines.push(muted$10("No providers configured yet — press a to add one."));
2960
- lines.push(muted$10("↑↓ select · enter edit · a add · d delete · s set active model · esc close"));
3192
+ if (filtered.length > maxVisible) lines.push(muted$10(`(${this.providerCursor + 1}/${filtered.length})`));
3193
+ if (providers?.length === 0) lines.push(muted$10("No providers configured yet ctrl+n to add one."));
3194
+ else if (filtered.length === 0) lines.push(muted$10("No matching providers."));
3195
+ lines.push(muted$10("type to search · ↑↓ select · enter edit · ctrl+n add · ctrl+x delete · ctrl+e edit models · ctrl+a set active model · esc back"));
2961
3196
  return lines;
2962
3197
  }
2963
3198
  handleListInput(data, mp) {
2964
- const { providers, selected } = mp;
2965
3199
  if (matchesKey(data, Key.escape)) {
2966
- this.actions.closeModelProfile();
3200
+ this.mode = "picker";
2967
3201
  return;
2968
3202
  }
2969
- if (providers === void 0 || providers.length === 0) {
2970
- if (data === "a") this.actions.createProvider();
3203
+ if (matchesKey(data, Key.ctrl("n"))) {
3204
+ this.actions.createProvider();
2971
3205
  return;
2972
3206
  }
3207
+ const filtered = this.filteredProviders(mp);
3208
+ if (filtered.length === 0) return;
2973
3209
  if (matchesKey(data, Key.up)) {
2974
3210
  this.confirmDelete = void 0;
2975
- this.actions.selectProvider(Math.max(0, selected - 1));
3211
+ this.providerCursor = Math.max(0, this.providerCursor - 1);
2976
3212
  return;
2977
3213
  }
2978
3214
  if (matchesKey(data, Key.down)) {
2979
3215
  this.confirmDelete = void 0;
2980
- this.actions.selectProvider(Math.min(providers.length - 1, selected + 1));
3216
+ this.providerCursor = Math.min(filtered.length - 1, this.providerCursor + 1);
2981
3217
  return;
2982
3218
  }
2983
3219
  if (matchesKey(data, Key.enter)) {
2984
- this.actions.editProvider(providers[selected].route);
3220
+ this.actions.editProvider(filtered[this.providerCursor].route);
2985
3221
  return;
2986
3222
  }
2987
- if (data === "a") {
2988
- this.actions.createProvider();
3223
+ if (matchesKey(data, Key.ctrl("e"))) {
3224
+ this.pendingShowModels = true;
3225
+ this.actions.editProvider(filtered[this.providerCursor].route);
2989
3226
  return;
2990
3227
  }
2991
- if (data === "s") {
2992
- const row = providers[selected];
3228
+ if (matchesKey(data, Key.ctrl("a"))) {
3229
+ const row = filtered[this.providerCursor];
2993
3230
  const model = row.models[0];
2994
3231
  if (model !== void 0) this.actions.setActiveModel(row.route, model.id);
2995
3232
  return;
2996
3233
  }
2997
- if (data === "d") {
2998
- if (this.confirmDelete === selected) {
3234
+ if (matchesKey(data, Key.ctrl("x"))) {
3235
+ if (this.confirmDelete === this.providerCursor) {
2999
3236
  this.confirmDelete = void 0;
3000
- this.actions.deleteProvider(providers[selected]);
3001
- } else this.confirmDelete = selected;
3237
+ this.actions.deleteProvider(filtered[this.providerCursor]);
3238
+ } else this.confirmDelete = this.providerCursor;
3002
3239
  return;
3003
3240
  }
3004
3241
  this.confirmDelete = void 0;
3242
+ const next = miniTextFieldInput(this.providerSearch, data);
3243
+ if (next !== void 0) {
3244
+ this.providerSearch = next;
3245
+ this.providerCursor = 0;
3246
+ }
3005
3247
  }
3006
3248
  renderForm(draft, mp) {
3007
3249
  const textFields = this.textFields(draft);
3008
- const modelsRow = textFields.length;
3009
- const saveRow = textFields.length + 1;
3250
+ const protocolRow = textFields.length;
3251
+ const modelsRow = protocolRow + 1;
3252
+ const saveRow = protocolRow + 2;
3010
3253
  const fieldState = {
3011
3254
  route: this.route,
3012
3255
  displayName: this.displayName,
3013
- api: this.api,
3014
3256
  baseURL: this.baseURL,
3015
3257
  apiKey: this.apiKeyDraft
3016
3258
  };
3017
3259
  const labels = {
3018
- route: "Route",
3019
- displayName: "Name",
3020
- api: "Protocol",
3260
+ route: "Provider ID",
3261
+ displayName: "Display name",
3021
3262
  baseURL: "Base URL",
3022
- apiKey: draft.apiKeyConfigured ? "API key (set — leave blank to keep)" : "API key"
3263
+ apiKey: draft.apiKeyConfigured ? `API key (set${draft.apiKeyPreview === void 0 ? "" : `: ${draft.apiKeyPreview}`} — leave blank to keep)` : "API key"
3023
3264
  };
3024
- const lines = [bold$9(secondary$9(draft.isNew ? "Add provider" : `Edit ${draft.displayName || draft.route}`))];
3265
+ const lines = [bold$9(secondary$9(draft.isNew ? "Custom provider" : `Edit ${draft.displayName || draft.route}`))];
3266
+ lines.push(...this.noticeLines());
3025
3267
  if (mp.error !== void 0) lines.push(errorColor$5(mp.error));
3268
+ if (draft.authMethod === "oauth") lines.push(muted$10("(signed in via OAuth — no separate API key for this route)"));
3026
3269
  textFields.forEach((field, index) => {
3027
3270
  const isFocused = this.focused === index;
3028
3271
  const mask = field === "apiKey" ? "*" : void 0;
3029
3272
  const prefix = `${isFocused ? "› " : " "}${labels[field]}: `;
3030
3273
  lines.push(`${prefix}${renderMiniTextField(fieldState[field], isFocused, mask)}`);
3031
3274
  });
3275
+ const protocolFocused = this.focused === protocolRow;
3276
+ const protocolText = `${protocolFocused ? "› " : " "}API protocol: ‹ ${this.api} ›`;
3277
+ lines.push(protocolFocused ? invert$5(protocolText) : protocolText);
3032
3278
  const modelsText = `${this.focused === modelsRow ? "› " : " "}Models (${this.models.length}) — enter to edit`;
3033
3279
  lines.push(this.focused === modelsRow ? invert$5(modelsText) : modelsText);
3034
- const saveText = `${this.focused === saveRow ? "› " : " "}${mp.busy ? "Saving…" : "Save"}`;
3280
+ const saveText = `${this.focused === saveRow ? "› " : " "}${mp.busy ? "Saving…" : draft.isNew ? "Create provider" : "Save changes"}`;
3035
3281
  lines.push(this.focused === saveRow ? invert$5(saveText) : saveText);
3036
- lines.push(muted$10("tab/shift+tab move · enter confirm field / activate row · esc cancel"));
3282
+ const removeHint = draft.apiKeyConfigured ? " · ctrl+d sign out (remove key/OAuth grant)" : "";
3283
+ lines.push(muted$10(`tab/shift+tab move · ←→ cycle protocol · enter confirm/activate · esc cancel${removeHint}`));
3037
3284
  return lines;
3038
3285
  }
3039
3286
  handleFormInput(data, draft) {
3040
3287
  const textFields = this.textFields(draft);
3041
- const modelsRow = textFields.length;
3042
- const saveRow = textFields.length + 1;
3043
- const rowCount = textFields.length + 2;
3288
+ const protocolRow = textFields.length;
3289
+ const modelsRow = protocolRow + 1;
3290
+ const saveRow = protocolRow + 2;
3291
+ const rowCount = protocolRow + 3;
3044
3292
  if (matchesKey(data, Key.escape)) {
3045
3293
  this.actions.backToProviderList();
3046
3294
  return;
3047
3295
  }
3296
+ if (matchesKey(data, Key.ctrl("d")) && draft.apiKeyConfigured) {
3297
+ this.actions.clearApiKey(draft);
3298
+ return;
3299
+ }
3048
3300
  if (matchesKey(data, "shift+tab")) {
3049
3301
  this.focused = (this.focused - 1 + rowCount) % rowCount;
3050
3302
  return;
@@ -3053,6 +3305,12 @@ var ModelProfileOverlay = class {
3053
3305
  this.focused = (this.focused + 1) % rowCount;
3054
3306
  return;
3055
3307
  }
3308
+ if (this.focused === protocolRow && (matchesKey(data, Key.left) || matchesKey(data, Key.right))) {
3309
+ const direction = matchesKey(data, Key.left) ? -1 : 1;
3310
+ const currentIndex = PROTOCOLS.indexOf(this.api);
3311
+ this.api = PROTOCOLS[(currentIndex + direction + PROTOCOLS.length) % PROTOCOLS.length];
3312
+ return;
3313
+ }
3056
3314
  if (matchesKey(data, Key.enter) && this.focused === modelsRow) {
3057
3315
  this.showModels = true;
3058
3316
  return;
@@ -3061,7 +3319,7 @@ var ModelProfileOverlay = class {
3061
3319
  this.actions.saveProvider(this.buildDraft(draft));
3062
3320
  return;
3063
3321
  }
3064
- if (matchesKey(data, Key.enter) && this.focused < textFields.length) {
3322
+ if (matchesKey(data, Key.enter) && this.focused <= protocolRow) {
3065
3323
  this.focused = (this.focused + 1) % rowCount;
3066
3324
  return;
3067
3325
  }
@@ -3070,34 +3328,43 @@ var ModelProfileOverlay = class {
3070
3328
  const next = miniTextFieldInput({
3071
3329
  route: this.route,
3072
3330
  displayName: this.displayName,
3073
- api: this.api,
3074
3331
  baseURL: this.baseURL,
3075
3332
  apiKey: this.apiKeyDraft
3076
3333
  }[field], data);
3077
3334
  if (next === void 0) return;
3078
3335
  if (field === "route") this.route = next;
3079
3336
  else if (field === "displayName") this.displayName = next;
3080
- else if (field === "api") this.api = next;
3081
3337
  else if (field === "baseURL") this.baseURL = next;
3082
3338
  else this.apiKeyDraft = next;
3083
3339
  }
3084
3340
  }
3341
+ /**
3342
+ * No Tab-toggled focus, on purpose: a hidden "which of two things am I
3343
+ * typing into right now" mode with no visible indicator (the earlier
3344
+ * design) is exactly the kind of invisible-state bug already fixed
3345
+ * elsewhere in this file. Instead the "Add id" field is *always* live —
3346
+ * every printable key, paste, and left/right/backspace/delete/home/end
3347
+ * goes there, full stop, since ↑/↓ are the only keys `miniTextFieldInput`
3348
+ * never claims. That leaves ↑/↓ free to always drive the list selection
3349
+ * (for removal) at the same time, with no mode switch and no key that can
3350
+ * ever mean two different things depending on invisible state.
3351
+ */
3085
3352
  renderModelListEditor(mp) {
3086
3353
  const lines = [bold$9(secondary$9("Models"))];
3087
3354
  this.models.forEach((model, index) => {
3088
- const isSelected = !this.modelInputFocused && index === this.modelSelected;
3355
+ const isSelected = index === this.modelSelected;
3089
3356
  const text = `${isSelected ? "› " : " "}${model.id}${model.name === void 0 ? "" : ` — ${model.name}`}`;
3090
3357
  lines.push(isSelected ? invert$5(text) : text);
3091
3358
  });
3092
3359
  if (this.models.length === 0) lines.push(muted$10("No models yet."));
3093
- lines.push(`${this.modelInputFocused ? "› " : " "}Add id: ${renderMiniTextField(this.modelDraftId, this.modelInputFocused)}`);
3360
+ lines.push(`Add id: ${renderMiniTextField(this.modelDraftId, true)}`);
3094
3361
  if (mp.busy) lines.push(muted$10("Discovering…"));
3095
3362
  if (mp.discovered !== void 0) if (mp.discovered.length === 0) lines.push(muted$10("No models discovered."));
3096
3363
  else {
3097
- lines.push(muted$10("Discovered — tab to the id field and type one to adopt it:"));
3364
+ lines.push(muted$10("Discovered — type one into \"Add id\" above to adopt it:"));
3098
3365
  for (const model of mp.discovered) lines.push(muted$10(` ${model.id}${model.name === void 0 ? "" : ` — ${model.name}`}`));
3099
3366
  }
3100
- lines.push(muted$10("tab toggle list/input · ↑↓ select · x remove · g discover · esc back"));
3367
+ lines.push(muted$10("type + enter add · ↑↓ select · ctrl+x remove · ctrl+g discover · ctrl+s save · esc back (without saving)"));
3101
3368
  return lines;
3102
3369
  }
3103
3370
  addModel(id) {
@@ -3113,36 +3380,34 @@ var ModelProfileOverlay = class {
3113
3380
  this.showModels = false;
3114
3381
  return;
3115
3382
  }
3116
- if (matchesKey(data, Key.tab)) {
3117
- this.modelInputFocused = !this.modelInputFocused;
3383
+ if (matchesKey(data, Key.ctrl("s"))) {
3384
+ this.actions.saveProvider(this.buildDraft(draft));
3118
3385
  return;
3119
3386
  }
3120
- if (this.modelInputFocused) {
3121
- if (matchesKey(data, Key.enter)) {
3122
- this.addModel(this.modelDraftId.value);
3123
- return;
3124
- }
3125
- const next = miniTextFieldInput(this.modelDraftId, data);
3126
- if (next !== void 0) this.modelDraftId = next;
3387
+ if (matchesKey(data, Key.ctrl("g"))) {
3388
+ this.actions.discoverModelsForDraft(this.buildDraft(draft));
3127
3389
  return;
3128
3390
  }
3129
- if (data === "g") {
3130
- this.actions.discoverModelsForDraft(this.buildDraft(draft));
3391
+ if (matchesKey(data, Key.ctrl("x"))) {
3392
+ if (this.models.length === 0) return;
3393
+ this.models = this.models.filter((_, index) => index !== this.modelSelected);
3394
+ this.modelSelected = Math.max(0, Math.min(this.modelSelected, this.models.length - 1));
3131
3395
  return;
3132
3396
  }
3133
- if (this.models.length === 0) return;
3134
3397
  if (matchesKey(data, Key.up)) {
3135
- this.modelSelected = Math.max(0, this.modelSelected - 1);
3398
+ if (this.models.length > 0) this.modelSelected = Math.max(0, this.modelSelected - 1);
3136
3399
  return;
3137
3400
  }
3138
3401
  if (matchesKey(data, Key.down)) {
3139
- this.modelSelected = Math.min(this.models.length - 1, this.modelSelected + 1);
3402
+ if (this.models.length > 0) this.modelSelected = Math.min(this.models.length - 1, this.modelSelected + 1);
3140
3403
  return;
3141
3404
  }
3142
- if (data === "x") {
3143
- this.models = this.models.filter((_, index) => index !== this.modelSelected);
3144
- this.modelSelected = Math.max(0, Math.min(this.modelSelected, this.models.length - 1));
3405
+ if (matchesKey(data, Key.enter)) {
3406
+ this.addModel(this.modelDraftId.value);
3407
+ return;
3145
3408
  }
3409
+ const next = miniTextFieldInput(this.modelDraftId, data);
3410
+ if (next !== void 0) this.modelDraftId = next;
3146
3411
  }
3147
3412
  handleInput(data) {
3148
3413
  const overlay = this.store.getSnapshot().overlay;
@@ -3154,7 +3419,8 @@ var ModelProfileOverlay = class {
3154
3419
  else this.handleFormInput(data, draft);
3155
3420
  return;
3156
3421
  }
3157
- this.handleListInput(data, mp);
3422
+ if (this.mode === "picker") this.handleModelPickerInput(data, mp);
3423
+ else this.handleListInput(data, mp);
3158
3424
  }
3159
3425
  };
3160
3426
  //#endregion
@@ -3163,33 +3429,126 @@ const bold$8 = (s) => `\x1b[1m${s}\x1b[0m`;
3163
3429
  const secondary$8 = fg(theme.secondary);
3164
3430
  const muted$9 = fg(theme.muted);
3165
3431
  const errorColor$4 = fg(theme.error);
3432
+ const successColor = fg(theme.success);
3166
3433
  const invert$4 = (s) => `\x1b[7m${s}\x1b[0m`;
3434
+ /** A non-secret reminder that replacing an API-key prompt will overwrite an existing stored key. */
3435
+ function formatStoredApiKeyHint(preview) {
3436
+ return preview === void 0 ? void 0 : `Stored key: ${preview}`;
3437
+ }
3438
+ const AUTH_TYPES = ["oauth", "api-key"];
3439
+ const AUTH_TYPE_LABELS = {
3440
+ oauth: "Sign in with an account",
3441
+ "api-key": "Sign in with an API key"
3442
+ };
3167
3443
  var LoginOverlay = class {
3444
+ tui;
3168
3445
  store;
3169
3446
  actions;
3447
+ view = {
3448
+ kind: "authType",
3449
+ cursor: 0
3450
+ };
3170
3451
  promptField = emptyMiniTextField();
3171
3452
  promptCursor = 0;
3172
- constructor(store, actions) {
3453
+ constructor(tui, store, actions) {
3454
+ this.tui = tui;
3173
3455
  this.store = store;
3174
3456
  this.actions = actions;
3175
3457
  }
3176
3458
  invalidate() {}
3177
- renderList(login) {
3178
- const lines = [bold$8(secondary$8("Sign in"))];
3459
+ /**
3460
+ * The global one-line notice (`store.setNotice`), rendered inline here.
3461
+ * `/login` runs as a full-screen overlay that paints over the whole
3462
+ * terminal, so the notice dock underneath — where sign-in progress,
3463
+ * success ("Signed in to X."), and failure text otherwise land — is
3464
+ * completely hidden for as long as this overlay stays open. Surfacing it
3465
+ * here is what makes an OAuth callback's result actually visible instead
3466
+ * of the screen appearing to do nothing once the browser redirects back.
3467
+ */
3468
+ noticeLines() {
3469
+ const notice = this.store.getSnapshot().notice;
3470
+ return notice === void 0 ? [] : [muted$9(notice)];
3471
+ }
3472
+ /**
3473
+ * Pure: given the loaded flows, which single method (if any) they all
3474
+ * share — the chooser has nothing to offer when only one method type is
3475
+ * registered at all. Recomputed fresh every call (no cached/latched
3476
+ * result), so a later-refreshed flow set is always re-evaluated instead of
3477
+ * replaying a stale decision (specs/001-…, R6).
3478
+ */
3479
+ autoSkipAuthType(login) {
3480
+ if (login.flows === void 0) return void 0;
3481
+ const hasOAuth = login.flows.some((flow) => flow.methods.some((method) => method.id === "oauth"));
3482
+ return hasOAuth === login.flows.some((flow) => flow.methods.some((method) => method.id === "api-key")) ? void 0 : hasOAuth ? "oauth" : "api-key";
3483
+ }
3484
+ /**
3485
+ * Pure: the view to actually render/operate against. `this.view` stays the
3486
+ * untouched `authType` default until the user explicitly chooses (Enter on
3487
+ * the chooser) — until then, this derives the auto-skipped `list` view on
3488
+ * every call instead of mutating `this.view` from inside `render()`. The
3489
+ * first `handleInput` mutation against a derived (not-yet-explicit) `list`
3490
+ * view reassigns `this.view` directly (see `handleProviderListInput`), so
3491
+ * cursor/search state persists across renders once the user interacts.
3492
+ */
3493
+ effectiveView(login) {
3494
+ if (this.view.kind === "authType") {
3495
+ const auto = this.autoSkipAuthType(login);
3496
+ if (auto !== void 0) return {
3497
+ kind: "list",
3498
+ authType: auto,
3499
+ chooserSkipped: true,
3500
+ cursor: 0,
3501
+ searchQuery: emptyMiniTextField()
3502
+ };
3503
+ }
3504
+ return this.view;
3505
+ }
3506
+ filteredFlows(login, view) {
3507
+ const all = login.flows ?? [];
3508
+ const byType = view.authType === void 0 ? all : all.filter((flow) => flow.methods.some((method) => method.id === view.authType));
3509
+ const query = view.searchQuery.value.trim();
3510
+ return query === "" ? byType : fuzzyFilter([...byType], query, (flow) => flow.label);
3511
+ }
3512
+ renderAuthTypeChooser(login, view) {
3513
+ const lines = [bold$8(secondary$8("Select authentication method"))];
3514
+ lines.push(...this.noticeLines());
3515
+ if (login.error !== void 0) lines.push(errorColor$4(login.error));
3516
+ if (login.busy && login.flows === void 0) lines.push(muted$9("Loading…"));
3517
+ AUTH_TYPES.forEach((type, index) => {
3518
+ const text = `${index === view.cursor ? "› " : " "}${AUTH_TYPE_LABELS[type]}`;
3519
+ lines.push(index === view.cursor ? invert$4(text) : text);
3520
+ });
3521
+ lines.push(muted$9("↑↓ select · enter continue · esc close"));
3522
+ return lines;
3523
+ }
3524
+ renderProviderList(login, view) {
3525
+ const lines = [bold$8(secondary$8("Select provider to configure:"))];
3526
+ lines.push(...this.noticeLines());
3179
3527
  if (login.error !== void 0) lines.push(errorColor$4(login.error));
3528
+ lines.push(`> ${renderMiniTextField(view.searchQuery, true)}`);
3180
3529
  if (login.busy && login.flows === void 0) lines.push(muted$9("Loading…"));
3181
- login.flows?.forEach((flow, index) => {
3182
- const marker = flow.inFlight ? "· " : "○ ";
3530
+ const flows = this.filteredFlows(login, view);
3531
+ const chrome = lines.length + 2;
3532
+ const maxVisible = listWindow(this.tui.terminal.rows, chrome);
3533
+ const { start, end } = visibleRange(flows.length, view.cursor, maxVisible);
3534
+ flows.slice(start, end).forEach((flow, offset) => {
3535
+ const index = start + offset;
3536
+ const marker = flow.inFlight ? "· " : flow.configured ? "✓ " : "○ ";
3183
3537
  const signingIn = login.signingIn === flow.key ? " — signing in…" : "";
3184
- const text = `${index === login.selected ? " " : " "}${marker}${flow.label}${signingIn}`;
3185
- lines.push(index === login.selected ? invert$4(text) : text);
3538
+ const via = flow.authMethod === "oauth" ? " [oauth]" : flow.authMethod === "api-key" ? " [api]" : "";
3539
+ const readyNote = login.signingIn !== flow.key && flow.configured ? ` — ready to use${via}` : "";
3540
+ const text = `${index === view.cursor ? "› " : " "}${marker}${flow.label}${signingIn}${readyNote}`;
3541
+ lines.push(index === view.cursor ? invert$4(text) : flow.configured ? successColor(text) : text);
3186
3542
  });
3187
- if (login.flows?.length === 0) lines.push(muted$9("No sign-in providers are available in this profile."));
3188
- lines.push(muted$9("↑↓ select · enter sign in · esc close"));
3543
+ if (flows.length > maxVisible) lines.push(muted$9(`(${view.cursor + 1}/${flows.length})`));
3544
+ if (login.flows !== void 0 && flows.length === 0) lines.push(muted$9("No matching providers."));
3545
+ const back = view.chooserSkipped ? "esc close" : "esc back";
3546
+ lines.push(muted$9(`type to search · ↑↓ select · enter sign in (or edit key/models if already configured) · ctrl+p add custom provider · ${back}`));
3189
3547
  return lines;
3190
3548
  }
3191
3549
  renderPrompt(prompt) {
3192
3550
  const lines = [bold$8(secondary$8("Sign in"))];
3551
+ lines.push(...this.noticeLines());
3193
3552
  lines.push(prompt.message);
3194
3553
  if (prompt.kind === "select") {
3195
3554
  prompt.options.forEach((option, index) => {
@@ -3202,26 +3561,35 @@ var LoginOverlay = class {
3202
3561
  const mask = prompt.kind === "secret" ? "•" : void 0;
3203
3562
  const placeholder = prompt.placeholder ?? (prompt.kind === "secret" ? "API key" : "code");
3204
3563
  const field = this.promptField.value === "" ? `(${placeholder})` : renderMiniTextField(this.promptField, true, mask);
3564
+ const storedKeyHint = prompt.kind === "secret" ? formatStoredApiKeyHint(prompt.storedApiKeyPreview) : void 0;
3565
+ if (storedKeyHint !== void 0) lines.push(muted$9(storedKeyHint));
3205
3566
  lines.push(`> ${field}`);
3206
- lines.push(muted$9("type + enter submit · esc cancel"));
3567
+ lines.push(muted$9("escape/ctrl+c to cancel, enter to submit"));
3207
3568
  }
3208
3569
  return lines;
3209
3570
  }
3210
3571
  render(_width) {
3211
3572
  const overlay = this.store.getSnapshot().overlay;
3212
3573
  if (overlay.kind !== "login") return [];
3213
- const { prompt } = overlay.login;
3214
- return prompt === void 0 ? this.renderList(overlay.login) : this.renderPrompt(prompt);
3574
+ const { login } = overlay;
3575
+ if (login.prompt !== void 0) return this.renderPrompt(login.prompt);
3576
+ const view = this.effectiveView(login);
3577
+ return view.kind === "authType" ? this.renderAuthTypeChooser(login, view) : this.renderProviderList(login, view);
3215
3578
  }
3216
3579
  handleInput(data) {
3217
3580
  const overlay = this.store.getSnapshot().overlay;
3218
3581
  if (overlay.kind !== "login") return;
3219
- const { prompt } = overlay.login;
3220
- if (prompt !== void 0) this.handlePromptInput(data, prompt);
3221
- else this.handleListInput(data, overlay.login);
3582
+ const { login } = overlay;
3583
+ if (login.prompt !== void 0) {
3584
+ this.handlePromptInput(data, login.prompt);
3585
+ return;
3586
+ }
3587
+ const view = this.effectiveView(login);
3588
+ if (view.kind === "authType") this.handleAuthTypeChooserInput(data, view);
3589
+ else this.handleProviderListInput(data, login, view);
3222
3590
  }
3223
3591
  handlePromptInput(data, prompt) {
3224
- if (matchesKey(data, Key.escape)) {
3592
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
3225
3593
  this.actions.answerAuthorizationPrompt("");
3226
3594
  return;
3227
3595
  }
@@ -3244,22 +3612,79 @@ var LoginOverlay = class {
3244
3612
  const next = miniTextFieldInput(this.promptField, data);
3245
3613
  if (next !== void 0) this.promptField = next;
3246
3614
  }
3247
- handleListInput(data, login) {
3615
+ handleAuthTypeChooserInput(data, view) {
3248
3616
  if (matchesKey(data, Key.escape)) {
3249
3617
  this.actions.closeLogin();
3250
3618
  return;
3251
3619
  }
3252
- const flows = login.flows;
3253
- if (flows === void 0 || flows.length === 0) return;
3254
3620
  if (matchesKey(data, Key.up)) {
3255
- this.actions.selectLoginFlow(Math.max(0, login.selected - 1));
3621
+ this.view = {
3622
+ kind: "authType",
3623
+ cursor: Math.max(0, view.cursor - 1)
3624
+ };
3256
3625
  return;
3257
3626
  }
3258
3627
  if (matchesKey(data, Key.down)) {
3259
- this.actions.selectLoginFlow(Math.min(flows.length - 1, login.selected + 1));
3628
+ this.view = {
3629
+ kind: "authType",
3630
+ cursor: Math.min(AUTH_TYPES.length - 1, view.cursor + 1)
3631
+ };
3260
3632
  return;
3261
3633
  }
3262
- if (matchesKey(data, Key.enter)) this.actions.beginAuthorization(flows[login.selected].key);
3634
+ if (matchesKey(data, Key.enter)) this.view = {
3635
+ kind: "list",
3636
+ authType: AUTH_TYPES[view.cursor],
3637
+ chooserSkipped: false,
3638
+ cursor: 0,
3639
+ searchQuery: emptyMiniTextField()
3640
+ };
3641
+ }
3642
+ handleProviderListInput(data, login, view) {
3643
+ if (matchesKey(data, Key.escape)) {
3644
+ if (view.chooserSkipped) this.actions.closeLogin();
3645
+ else this.view = {
3646
+ kind: "authType",
3647
+ cursor: 0
3648
+ };
3649
+ return;
3650
+ }
3651
+ if (matchesKey(data, Key.ctrl("p"))) {
3652
+ this.actions.addCustomProvider();
3653
+ return;
3654
+ }
3655
+ const flows = this.filteredFlows(login, view);
3656
+ if (matchesKey(data, Key.up)) {
3657
+ this.view = {
3658
+ ...view,
3659
+ cursor: Math.max(0, view.cursor - 1)
3660
+ };
3661
+ return;
3662
+ }
3663
+ if (matchesKey(data, Key.down)) {
3664
+ this.view = {
3665
+ ...view,
3666
+ cursor: Math.min(Math.max(0, flows.length - 1), view.cursor + 1)
3667
+ };
3668
+ return;
3669
+ }
3670
+ if (matchesKey(data, Key.ctrl("e"))) {
3671
+ const flow = flows[view.cursor];
3672
+ if (flow !== void 0 && flow.configured && flow.key.startsWith("llm-pi-ai/")) this.actions.openProviderEditor(flow.key.slice(10));
3673
+ return;
3674
+ }
3675
+ if (matchesKey(data, Key.enter)) {
3676
+ const flow = flows[view.cursor];
3677
+ if (flow === void 0) return;
3678
+ if (flow.configured && flow.key.startsWith("llm-pi-ai/")) this.actions.openProviderEditor(flow.key.slice(10));
3679
+ else this.actions.beginAuthorization(flow.key, view.authType);
3680
+ return;
3681
+ }
3682
+ const next = miniTextFieldInput(view.searchQuery, data);
3683
+ if (next !== void 0) this.view = {
3684
+ ...view,
3685
+ searchQuery: next,
3686
+ cursor: 0
3687
+ };
3263
3688
  }
3264
3689
  };
3265
3690
  //#endregion
@@ -4544,8 +4969,8 @@ var TuiApp = class {
4544
4969
  const state = store.getSnapshot();
4545
4970
  return buildStatusBarText({
4546
4971
  sessionId: options.sessionId,
4547
- provider: options.provider,
4548
- model: options.model,
4972
+ provider: state.activeModel?.provider ?? options.provider,
4973
+ model: state.activeModel?.model ?? options.model,
4549
4974
  status: state.status,
4550
4975
  queuedCount: state.queued.length,
4551
4976
  presetLabel: state.preset?.current,
@@ -4573,29 +4998,36 @@ var TuiApp = class {
4573
4998
  updateHintText,
4574
4999
  statsLineText
4575
5000
  ], { gap: 0 });
4576
- const headerInfo = new DynamicText(() => {
5001
+ const headerMark = new DynamicText(() => ACRYL_MARK_ROWS.map((row) => fg(theme.primary)(row)).join("\n"));
5002
+ const header = new HStack([{
5003
+ component: this.pet,
5004
+ basis: 34,
5005
+ shrink: 0
5006
+ }, {
5007
+ component: headerMark,
5008
+ basis: "auto",
5009
+ grow: 1
5010
+ }], {
5011
+ gap: 1,
5012
+ align: "start"
5013
+ });
5014
+ const buildStamp = `build a980b38 · ${(/* @__PURE__ */ new Date(BUILD_TIME)).toLocaleTimeString()}`;
5015
+ const sessionInfo = new DynamicText(() => {
4577
5016
  const { provider, model, cwd } = options;
4578
5017
  return [
4579
- fg(theme.primary)("ACRYL"),
4580
- "",
4581
5018
  `${provider}/${model}`,
4582
- cwd
5019
+ cwd,
5020
+ fg(theme.muted)(buildStamp)
4583
5021
  ].join("\n");
4584
5022
  });
4585
5023
  const layoutRoot = new VStack([
4586
5024
  {
4587
- component: new HStack([{
4588
- component: this.pet,
4589
- basis: 34,
4590
- shrink: 0
4591
- }, {
4592
- component: headerInfo,
4593
- basis: "auto",
4594
- grow: 1
4595
- }], {
4596
- gap: 1,
4597
- align: "start"
4598
- }),
5025
+ component: header,
5026
+ basis: "auto",
5027
+ shrink: 0
5028
+ },
5029
+ {
5030
+ component: sessionInfo,
4599
5031
  basis: "auto",
4600
5032
  shrink: 0
4601
5033
  },
@@ -4694,8 +5126,8 @@ var TuiApp = class {
4694
5126
  const { store, actions, getTool, getToolCall } = this.options;
4695
5127
  switch (overlay.kind) {
4696
5128
  case "none": return;
4697
- case "modelProfile": return new ModelProfileOverlay(store, actions);
4698
- case "login": return new LoginOverlay(store, actions);
5129
+ case "modelProfile": return new ModelProfileOverlay(this.tui, store, actions);
5130
+ case "login": return new LoginOverlay(this.tui, store, actions);
4699
5131
  case "trajectory": return new TrajectoryOverlay(this.tui, store, actions, getTool);
4700
5132
  case "toolCards": return new ToolCardsOverlay(this.tui, store, actions, getTool, getToolCall);
4701
5133
  case "context": return new ContextOverlay(store, actions);
@@ -4818,14 +5250,6 @@ async function walkDirectory(cwd) {
4818
5250
  }
4819
5251
  return results;
4820
5252
  }
4821
- //#endregion
4822
- //#region src/tui/auth-guidance.ts
4823
- function logoutSuccessMessage(provider) {
4824
- return `Removed stored authentication for ${provider.trim() === "" ? "provider" : provider}.`;
4825
- }
4826
- function logoutNoneMessage() {
4827
- return "No active provider is selected; nothing to log out.";
4828
- }
4829
5253
  /** Canonical ACRYL version string used by the CLI and the release smoke checks. */
4830
5254
  const ACRYL_VERSION = createRequire(import.meta.url)("../package.json").version;
4831
5255
  //#endregion
@@ -4840,6 +5264,8 @@ const ACRYL_VERSION = createRequire(import.meta.url)("../package.json").version;
4840
5264
  * TUI shows the same capabilities the web surface does. `/clear` flushes the
4841
5265
  * current session and re-attaches a fresh one (durable history stays on disk).
4842
5266
  */
5267
+ /** The credential-record scope `dsh-llm-pi-ai` writes a `/login` sign-in under (`credentialKey('llm-pi-ai', providerId)`). */
5268
+ const LOGIN_RECORD_SCOPE = "llm-pi-ai";
4843
5269
  const TUI_VERSION = ACRYL_VERSION;
4844
5270
  const PROMPT_HISTORY_LIMIT = 200;
4845
5271
  function failUnknown(status) {
@@ -4881,10 +5307,35 @@ function getAtPath(value, path) {
4881
5307
  }
4882
5308
  return current;
4883
5309
  }
4884
- /** Derive a POSIX-identifier credential ref from a provider route, e.g. `my-proxy` -> `MY_PROXY_API_KEY`. */
4885
- function deriveApiKeyRef(route) {
4886
- const upper = route.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
4887
- return `${/^[A-Z_]/.test(upper) ? upper : `P_${upper}`}_API_KEY`;
5310
+ /**
5311
+ * `dsh-llm-pi-ai`'s own credential record key under `LOGIN_RECORD_SCOPE`
5312
+ * (`credentialKey('llm-pi-ai', providerId)`, i.e. `"llm-pi-ai/<providerId>"`).
5313
+ * A `/login` sign-in (OAuth or API key) writes here through pi-ai's own
5314
+ * `CredentialStore`, entirely separate from the `apiKeyEnv` reference a
5315
+ * manually configured provider's settings profile points at.
5316
+ * `acryl-control`'s `CredentialProjection` computes this internally for the
5317
+ * read side; only `clearApiKey`'s explicit delete still needs it directly.
5318
+ */
5319
+ function loginRecordKey(providerId) {
5320
+ return `${LOGIN_RECORD_SCOPE}/${providerId}`;
5321
+ }
5322
+ /**
5323
+ * Wait out `ctx.settings`' asynchronous watcher queue after a write.
5324
+ *
5325
+ * `settingsSvc.update()`'s returned promise resolves once the write commits
5326
+ * to the settings document — but `dsh-llm-pi-ai`'s own reactive watcher (the
5327
+ * thing that actually rebuilds its live `Models` registry and re-registers
5328
+ * routes with `ctx.llm`) runs on a *separate* promise chained off that
5329
+ * commit, not before it settles. A caller that reads `ctx.llm.listModels()`
5330
+ * or `listProviders()` immediately after `update()` resolves races that
5331
+ * watcher and sees the pre-write state. `setImmediate` runs after the
5332
+ * Node microtask queue drains, which is exactly where that chained watcher
5333
+ * promise settles, so awaiting one flush is enough to see the write reflected.
5334
+ */
5335
+ function flushSettingsWatchers() {
5336
+ return new Promise((resolve) => {
5337
+ setImmediate(resolve);
5338
+ });
4888
5339
  }
4889
5340
  /** Open a URL in the platform's default browser (fire-and-forget). */
4890
5341
  function openBrowser(url) {
@@ -4949,11 +5400,46 @@ async function attachSession(host, resumeId) {
4949
5400
  const agent = host.ctx.agents?.get?.(SessionId(id));
4950
5401
  const session = agent?.session;
4951
5402
  const history = [];
4952
- async function loadProviders() {
5403
+ function credentialProjectionServices() {
4953
5404
  const settingsSvc = host.ctx.get("settings");
4954
5405
  const credentialsSvc = host.ctx.get("credentials");
4955
5406
  const llmSvc = host.ctx.get("llm");
4956
- if (settingsSvc === void 0 || credentialsSvc === void 0 || llmSvc === void 0) {
5407
+ if (settingsSvc === void 0 || credentialsSvc === void 0 || llmSvc === void 0) return void 0;
5408
+ return {
5409
+ settings: settingsSvc,
5410
+ credentials: credentialsSvc,
5411
+ llm: llmSvc
5412
+ };
5413
+ }
5414
+ async function credentialRows() {
5415
+ const services = credentialProjectionServices();
5416
+ if (services === void 0) return void 0;
5417
+ return computeCredentialProjection(services, {
5418
+ deriveApiKeyRef,
5419
+ loginRecordScope: LOGIN_RECORD_SCOPE
5420
+ });
5421
+ }
5422
+ /** Map the shared `CredentialProjectionRow` onto `/model`'s own `ProviderRow` presentation shape. */
5423
+ function toProviderRow(row) {
5424
+ return {
5425
+ route: row.route,
5426
+ displayName: row.displayName,
5427
+ settingsNs: row.settingsNs,
5428
+ settingsPath: row.settingsPath,
5429
+ configured: row.hasSettingsProfile,
5430
+ live: row.isLive,
5431
+ api: row.api,
5432
+ baseURL: row.baseURL,
5433
+ apiKeyRef: row.apiKeyRef,
5434
+ apiKeyConfigured: row.hasCredential,
5435
+ authMethod: row.authMethod,
5436
+ models: row.models,
5437
+ revision: row.revision
5438
+ };
5439
+ }
5440
+ async function loadProviders() {
5441
+ const rows = await credentialRows();
5442
+ if (rows === void 0) {
4957
5443
  store.updateModelProfile({
4958
5444
  providers: [],
4959
5445
  busy: false,
@@ -4961,40 +5447,64 @@ async function attachSession(host, resumeId) {
4961
5447
  });
4962
5448
  return;
4963
5449
  }
4964
- const configurable = llmSvc.listConfigurableProviders();
4965
- const live = new Set(llmSvc.listProviders().map((provider) => provider.id));
4966
- const descriptors = settingsSvc.describe({ redactSecrets: true });
4967
- const byNs = new Map(descriptors.map((descriptor) => [descriptor.ns, descriptor]));
4968
- const rows = [];
4969
- for (const entry of configurable) {
4970
- const descriptor = byNs.get(entry.settingsNs);
4971
- const value = descriptor === void 0 ? void 0 : getAtPath(descriptor.value, entry.settingsPath);
4972
- const userValue = descriptor === void 0 ? void 0 : getAtPath(descriptor.user, entry.settingsPath);
4973
- const apiKeyRef = value?.apiKeyEnv ?? deriveApiKeyRef(entry.provider);
4974
- const info = await credentialsSvc.describe(apiKeyRef);
4975
- rows.push({
4976
- route: entry.provider,
4977
- displayName: value?.displayName ?? entry.displayName,
4978
- settingsNs: entry.settingsNs,
4979
- settingsPath: entry.settingsPath,
4980
- configured: userValue !== void 0,
4981
- live: live.has(entry.provider),
4982
- api: value?.api,
4983
- baseURL: value?.baseURL,
4984
- apiKeyRef,
4985
- apiKeyConfigured: info.configured,
4986
- models: value?.models ?? [],
4987
- revision: descriptor?.revision
4988
- });
4989
- }
5450
+ const providers = rows.map(toProviderRow);
4990
5451
  store.updateModelProfile({
4991
- providers: rows,
5452
+ providers,
4992
5453
  busy: false,
4993
5454
  error: void 0,
4994
5455
  selected: 0
4995
5456
  });
4996
5457
  }
4997
- async function loadAuthorizationFlows() {
5458
+ function openEditFormForRow(row) {
5459
+ const overlay = store.getSnapshot().overlay;
5460
+ if (overlay.kind !== "modelProfile") return;
5461
+ const draft = {
5462
+ route: row.route,
5463
+ isNew: false,
5464
+ settingsNs: row.settingsNs,
5465
+ settingsPath: row.settingsPath,
5466
+ displayName: row.displayName,
5467
+ api: row.api ?? "",
5468
+ baseURL: row.baseURL ?? "",
5469
+ apiKeyRef: row.apiKeyRef,
5470
+ apiKeyConfigured: row.apiKeyConfigured,
5471
+ authMethod: row.authMethod,
5472
+ apiKeyDraft: "",
5473
+ apiKeyPreview: void 0,
5474
+ models: row.models,
5475
+ revision: row.revision
5476
+ };
5477
+ const formKey = overlay.modelProfile.formKey + 1;
5478
+ store.updateModelProfile({
5479
+ view: "form",
5480
+ draft,
5481
+ formKey
5482
+ });
5483
+ if (row.authMethod === "api-key") (async () => {
5484
+ const resolved = await host.ctx.get("credentials")?.resolve?.(row.apiKeyRef).catch(() => void 0);
5485
+ if (resolved?.value === void 0) return;
5486
+ const current = store.getSnapshot().overlay;
5487
+ if (current.kind !== "modelProfile" || current.modelProfile.formKey !== formKey) return;
5488
+ const currentDraft = current.modelProfile.draft;
5489
+ if (currentDraft === void 0) return;
5490
+ store.updateModelProfile({ draft: {
5491
+ ...currentDraft,
5492
+ apiKeyPreview: maskKeyPreview(resolved.value)
5493
+ } });
5494
+ })();
5495
+ }
5496
+ const routeActivationPort = { async ensureRouteActivated(providerId) {
5497
+ const settingsSvc = host.ctx.get("settings");
5498
+ const llmSvc = host.ctx.get("llm");
5499
+ if (settingsSvc === void 0 || llmSvc === void 0) return;
5500
+ const entry = llmSvc.listConfigurableProviders().find((candidate) => candidate.provider === providerId);
5501
+ if (entry === void 0) return;
5502
+ const descriptor = settingsSvc.describe({ redactSecrets: true }).find((candidate) => candidate.ns === entry.settingsNs);
5503
+ if ((descriptor === void 0 ? void 0 : getAtPath(descriptor.user, entry.settingsPath)) !== void 0) return;
5504
+ await settingsSvc.update(entry.settingsNs, nestAtPath(entry.settingsPath, {}), descriptor?.revision);
5505
+ await flushSettingsWatchers();
5506
+ } };
5507
+ async function loadLoginFlows() {
4998
5508
  const authSvc = host.ctx.get("authorization");
4999
5509
  if (authSvc === void 0) {
5000
5510
  store.updateLogin({
@@ -5005,7 +5515,17 @@ async function attachSession(host, resumeId) {
5005
5515
  return;
5006
5516
  }
5007
5517
  try {
5008
- const list = authSvc.list();
5518
+ const entries = authSvc.list();
5519
+ const rows = await credentialRows();
5520
+ const byKey = new Map((rows ?? []).map((row) => [loginRecordKey(row.route), row]));
5521
+ const list = entries.map((entry) => {
5522
+ const row = byKey.get(entry.key);
5523
+ return {
5524
+ ...entry,
5525
+ configured: row?.hasCredential ?? false,
5526
+ authMethod: row?.authMethod
5527
+ };
5528
+ });
5009
5529
  store.updateLogin({
5010
5530
  flows: list,
5011
5531
  busy: false,
@@ -5018,6 +5538,41 @@ async function attachSession(host, resumeId) {
5018
5538
  });
5019
5539
  }
5020
5540
  }
5541
+ function syncCredentialViews() {
5542
+ loadProviders();
5543
+ loadLoginFlows();
5544
+ }
5545
+ /** Open `/model`'s blank custom-provider draft — assumes `/model` is (or is about to become) the open overlay. Shared by `createProvider` (already there) and `addCustomProvider` (getting there first). */
5546
+ function openCustomProviderDraft() {
5547
+ const settingsNs = host.ctx.get("llm")?.listConfigurableProviders?.()[0]?.settingsNs;
5548
+ if (settingsNs === void 0) {
5549
+ store.setNotice("Adding a custom provider is not available in this profile.");
5550
+ return;
5551
+ }
5552
+ const draft = {
5553
+ route: "",
5554
+ isNew: true,
5555
+ settingsNs,
5556
+ settingsPath: [],
5557
+ displayName: "",
5558
+ api: "",
5559
+ baseURL: "",
5560
+ apiKeyRef: "",
5561
+ apiKeyConfigured: false,
5562
+ authMethod: void 0,
5563
+ apiKeyDraft: "",
5564
+ apiKeyPreview: void 0,
5565
+ models: [],
5566
+ revision: void 0
5567
+ };
5568
+ const overlay = store.getSnapshot().overlay;
5569
+ const formKey = overlay.kind === "modelProfile" ? overlay.modelProfile.formKey + 1 : 1;
5570
+ store.updateModelProfile({
5571
+ view: "form",
5572
+ draft,
5573
+ formKey
5574
+ });
5575
+ }
5021
5576
  async function loadAgentPresets() {
5022
5577
  const presetsSvc = host.ctx.get("agentPresets");
5023
5578
  if (presetsSvc === void 0) {
@@ -5060,7 +5615,9 @@ async function attachSession(host, resumeId) {
5060
5615
  });
5061
5616
  },
5062
5617
  cancel() {
5063
- bridge.cancel(id).catch(() => {});
5618
+ bridge.cancel(id).catch((error) => {
5619
+ store.setNotice(`Could not cancel: ${error instanceof Error ? error.message : String(error)}`);
5620
+ });
5064
5621
  },
5065
5622
  shutdown() {
5066
5623
  signal("exit");
@@ -5168,17 +5725,15 @@ async function attachSession(host, resumeId) {
5168
5725
  },
5169
5726
  login() {
5170
5727
  store.openLogin();
5171
- loadAuthorizationFlows();
5728
+ loadLoginFlows();
5172
5729
  },
5173
5730
  closeLogin() {
5174
5731
  store.closeOverlay();
5175
5732
  },
5176
- selectLoginFlow(index) {
5177
- store.updateLogin({ selected: index });
5178
- },
5179
- beginAuthorization(key) {
5733
+ beginAuthorization(key, method) {
5180
5734
  (async () => {
5181
5735
  const authSvc = host.ctx.get("authorization");
5736
+ const credentialsSvc = host.ctx.get("credentials");
5182
5737
  if (authSvc === void 0) {
5183
5738
  store.setNotice("Sign-in is not available in this profile.");
5184
5739
  return;
@@ -5186,6 +5741,8 @@ async function attachSession(host, resumeId) {
5186
5741
  const overlay = store.getSnapshot().overlay;
5187
5742
  const flow = overlay.kind === "login" ? overlay.login.flows?.find((entry) => entry.key === key) : void 0;
5188
5743
  if (flow === void 0) return;
5744
+ const existingRecord = (method ?? flow.methods[0]?.id) === "api-key" ? await credentialsSvc?.readRecord?.(flow.key) : void 0;
5745
+ const storedApiKeyPreview = typeof existingRecord?.key === "string" ? maskKeyPreview(existingRecord.key) : void 0;
5189
5746
  store.updateLogin({ signingIn: key });
5190
5747
  const interaction = {
5191
5748
  notify(notice) {
@@ -5210,8 +5767,13 @@ async function attachSession(host, resumeId) {
5210
5767
  label: option.label,
5211
5768
  description: option.description
5212
5769
  }))
5770
+ } : prompt.kind === "secret" ? {
5771
+ kind: "secret",
5772
+ message: prompt.message,
5773
+ placeholder: prompt.placeholder,
5774
+ storedApiKeyPreview
5213
5775
  } : {
5214
- kind: prompt.kind,
5776
+ kind: "text",
5215
5777
  message: prompt.message,
5216
5778
  placeholder: prompt.placeholder
5217
5779
  };
@@ -5224,16 +5786,20 @@ async function attachSession(host, resumeId) {
5224
5786
  });
5225
5787
  }
5226
5788
  };
5789
+ const authorizationService = new AuthorizationService({
5790
+ authorization: authSvc,
5791
+ routeActivation: routeActivationPort,
5792
+ onCredentialChanged: () => syncCredentialViews()
5793
+ });
5227
5794
  try {
5228
- if ((await authSvc.begin({
5795
+ if ((await authorizationService.begin({
5229
5796
  key,
5797
+ method,
5230
5798
  interaction
5231
- })).status === "authorized") {
5232
- store.setNotice(`Signed in to ${flow.label}.`);
5233
- loadAuthorizationFlows();
5234
- } else store.setNotice("Sign-in cancelled.");
5799
+ })).status === "authorized") store.setNotice(`Signed in to ${flow.label} — credentials saved, ready to use from /model.`);
5800
+ else store.updateLogin({ error: "Sign-in cancelled." });
5235
5801
  } catch (error) {
5236
- store.setNotice(`Sign-in failed: ${error instanceof Error ? error.message : String(error)}`);
5802
+ store.updateLogin({ error: `Sign-in failed: ${error instanceof Error ? error.message : String(error)}` });
5237
5803
  } finally {
5238
5804
  store.updateLogin({ signingIn: void 0 });
5239
5805
  }
@@ -5246,28 +5812,9 @@ async function attachSession(host, resumeId) {
5246
5812
  if (resolve !== void 0) resolve(value);
5247
5813
  },
5248
5814
  logout() {
5249
- (async () => {
5250
- const credentialsSvc = host.ctx.get("credentials");
5251
- if (credentialsSvc === void 0) {
5252
- store.setNotice("Credentials are not available in this profile.");
5253
- return;
5254
- }
5255
- const selection = host.ctx.get("agentDefaultModel")?.currentSelection?.();
5256
- const overlay = store.getSnapshot().overlay;
5257
- const rows = overlay.kind === "modelProfile" ? overlay.modelProfile.providers : void 0;
5258
- const row = rows?.find((entry) => entry.route === selection?.provider) ?? (rows !== void 0 && rows.length === 1 ? rows[0] : void 0);
5259
- if (row === void 0) {
5260
- store.setNotice(logoutNoneMessage());
5261
- return;
5262
- }
5263
- try {
5264
- await credentialsSvc.unset(row.apiKeyRef);
5265
- store.setNotice(logoutSuccessMessage(row.displayName));
5266
- loadProviders();
5267
- } catch (error) {
5268
- store.setNotice(`logout failed: ${error instanceof Error ? error.message : String(error)}`);
5269
- }
5270
- })();
5815
+ store.openModelProfile();
5816
+ store.setNotice("Select a provider, then Ctrl+D to sign out.");
5817
+ loadProviders();
5271
5818
  },
5272
5819
  openTrajectory() {
5273
5820
  store.openTrajectory();
@@ -5295,40 +5842,41 @@ async function attachSession(host, resumeId) {
5295
5842
  },
5296
5843
  backToProviderList() {
5297
5844
  store.updateModelProfile({ view: "list" });
5298
- },
5299
- selectProvider(index) {
5300
- store.updateModelProfile({ selected: index });
5845
+ syncCredentialViews();
5301
5846
  },
5302
5847
  createProvider() {
5303
- store.updateModelProfile({
5304
- view: "form",
5305
- draft: void 0
5306
- });
5848
+ openCustomProviderDraft();
5849
+ },
5850
+ addCustomProvider() {
5851
+ store.openModelProfile();
5852
+ loadProviders();
5853
+ openCustomProviderDraft();
5307
5854
  },
5308
5855
  editProvider(route) {
5309
5856
  const overlay = store.getSnapshot().overlay;
5310
5857
  if (overlay.kind !== "modelProfile") return;
5311
5858
  const row = overlay.modelProfile.providers?.find((entry) => entry.route === route);
5312
5859
  if (row === void 0) return;
5313
- const draft = {
5314
- route: row.route,
5315
- isNew: false,
5316
- settingsNs: row.settingsNs,
5317
- settingsPath: row.settingsPath,
5318
- displayName: row.displayName,
5319
- api: row.api ?? "",
5320
- baseURL: row.baseURL ?? "",
5321
- apiKeyRef: row.apiKeyRef,
5322
- apiKeyConfigured: row.apiKeyConfigured,
5323
- apiKeyDraft: "",
5324
- models: row.models,
5325
- revision: row.revision
5326
- };
5327
- store.updateModelProfile({
5328
- view: "form",
5329
- draft,
5330
- formKey: overlay.modelProfile.formKey + 1
5331
- });
5860
+ openEditFormForRow(row);
5861
+ },
5862
+ openProviderEditor(route) {
5863
+ (async () => {
5864
+ store.openModelProfile();
5865
+ const rows = await credentialRows();
5866
+ const providers = rows?.map(toProviderRow);
5867
+ store.updateModelProfile({
5868
+ providers: providers ?? [],
5869
+ busy: false,
5870
+ error: rows === void 0 ? "Model provider settings are not available in this profile." : void 0,
5871
+ selected: 0
5872
+ });
5873
+ const row = providers?.find((entry) => entry.route === route);
5874
+ if (row === void 0) {
5875
+ store.setNotice(`Provider "${route}" not found.`);
5876
+ return;
5877
+ }
5878
+ openEditFormForRow(row);
5879
+ })();
5332
5880
  },
5333
5881
  saveProvider(draft) {
5334
5882
  (async () => {
@@ -5338,20 +5886,25 @@ async function attachSession(host, resumeId) {
5338
5886
  store.setNotice("Provider settings are not available in this profile.");
5339
5887
  return;
5340
5888
  }
5889
+ if (draft.isNew && draft.route.trim() === "") {
5890
+ store.updateModelProfile({ error: "Provider ID is required." });
5891
+ return;
5892
+ }
5341
5893
  try {
5342
5894
  const key = draft.apiKeyDraft.trim();
5343
5895
  if (key !== "") await credentialsSvc.set(draft.apiKeyRef, key);
5344
5896
  const section = {
5345
- displayName: draft.displayName,
5346
- api: draft.api,
5347
- baseURL: draft.baseURL,
5897
+ ...draft.displayName.trim() === "" ? {} : { displayName: draft.displayName },
5898
+ ...draft.api.trim() === "" ? {} : { api: draft.api },
5899
+ ...draft.baseURL.trim() === "" ? {} : { baseURL: draft.baseURL },
5348
5900
  apiKeyEnv: draft.apiKeyRef,
5349
- models: draft.models
5901
+ ...draft.models.length === 0 ? {} : { models: draft.models }
5350
5902
  };
5351
5903
  await settingsSvc.update(draft.settingsNs, nestAtPath(draft.settingsPath, section), draft.revision);
5352
- store.setNotice(`Saved ${draft.displayName || draft.route}.`);
5904
+ await flushSettingsWatchers();
5905
+ store.setNotice(`Saved ${draft.displayName || draft.route} — credentials stored, ready to use from /model.`);
5353
5906
  store.updateModelProfile({ view: "list" });
5354
- loadProviders();
5907
+ syncCredentialViews();
5355
5908
  } catch (error) {
5356
5909
  store.setNotice(`save failed: ${error instanceof Error ? error.message : String(error)}`);
5357
5910
  }
@@ -5368,15 +5921,85 @@ async function attachSession(host, resumeId) {
5368
5921
  try {
5369
5922
  await credentialsSvc.unset(row.apiKeyRef);
5370
5923
  await settingsSvc.update(row.settingsNs, nestAtPath(row.settingsPath, {}), row.revision);
5924
+ await flushSettingsWatchers();
5371
5925
  store.setNotice(`Removed ${row.displayName}.`);
5372
- loadProviders();
5926
+ syncCredentialViews();
5373
5927
  } catch (error) {
5374
5928
  store.setNotice(`delete failed: ${error instanceof Error ? error.message : String(error)}`);
5375
5929
  }
5376
5930
  })();
5377
5931
  },
5378
- discoverModelsForDraft() {},
5379
- setActiveModel() {},
5932
+ clearApiKey(draft) {
5933
+ (async () => {
5934
+ const credentialsSvc = host.ctx.get("credentials");
5935
+ if (credentialsSvc === void 0) {
5936
+ store.setNotice("Credentials are not available in this profile.");
5937
+ return;
5938
+ }
5939
+ try {
5940
+ await credentialsSvc.unset(draft.apiKeyRef);
5941
+ await credentialsSvc.deleteRecord?.(loginRecordKey(draft.route));
5942
+ store.setNotice(`Removed the API key for ${draft.displayName || draft.route}.`);
5943
+ store.updateModelProfile({ view: "list" });
5944
+ syncCredentialViews();
5945
+ } catch (error) {
5946
+ store.setNotice(`Could not remove the key: ${error instanceof Error ? error.message : String(error)}`);
5947
+ }
5948
+ })();
5949
+ },
5950
+ discoverModelsForDraft(draft) {
5951
+ (async () => {
5952
+ const llmSvc = host.ctx.get("llm");
5953
+ if (llmSvc === void 0) {
5954
+ store.setNotice("Model discovery is not available in this profile.");
5955
+ return;
5956
+ }
5957
+ store.updateModelProfile({ busy: true });
5958
+ try {
5959
+ const request = {};
5960
+ if (!draft.isNew) request.provider = draft.route;
5961
+ if (draft.baseURL.trim() !== "") request.baseURL = draft.baseURL.trim();
5962
+ if (draft.api.trim() !== "") request.api = draft.api.trim();
5963
+ if (draft.apiKeyDraft.trim() !== "") request.apiKey = draft.apiKeyDraft.trim();
5964
+ const discovered = await llmSvc.discoverModels(draft.settingsNs, request);
5965
+ store.updateModelProfile({
5966
+ busy: false,
5967
+ discovered
5968
+ });
5969
+ } catch (error) {
5970
+ store.updateModelProfile({
5971
+ busy: false,
5972
+ discovered: []
5973
+ });
5974
+ store.setNotice(`Model discovery failed: ${error instanceof Error ? error.message : String(error)}`);
5975
+ }
5976
+ })();
5977
+ },
5978
+ setActiveModel(provider, model) {
5979
+ (async () => {
5980
+ try {
5981
+ await bridge.selectModel({
5982
+ sessionId: id,
5983
+ provider,
5984
+ model
5985
+ });
5986
+ store.setActiveModel({
5987
+ provider,
5988
+ model
5989
+ });
5990
+ try {
5991
+ await host.ctx.get("agentDefaultModel")?.saveSelection({
5992
+ provider,
5993
+ model
5994
+ });
5995
+ } catch {}
5996
+ store.setNotice(`Active model set to ${model} (${provider}).`);
5997
+ loadProviders();
5998
+ } catch (error) {
5999
+ store.setNotice(`Could not set active model: ${error instanceof Error ? error.message : String(error)}`);
6000
+ }
6001
+ })();
6002
+ },
5380
6003
  closeTrajectory() {
5381
6004
  store.closeOverlay();
5382
6005
  },
@@ -5612,7 +6235,7 @@ async function runAcryl(args, supplied = {}) {
5612
6235
  return;
5613
6236
  }
5614
6237
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
5615
- dependencies.write("acryl-tui: stdin and stdout must both be TTYs; use `acryl tui --json` for a headless probe");
6238
+ dependencies.write("acryl-cli: stdin and stdout must both be TTYs; use `acryl tui --json` for a headless probe");
5616
6239
  dependencies.exit(1);
5617
6240
  return;
5618
6241
  }