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
@@ -2,9 +2,10 @@ import { createRequire } from "node:module";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { bootAcrylHarnessProfile, createAcrylSessionBridge } from "acryl-harness-runtime";
4
4
  import { execFile, spawn } from "node:child_process";
5
+ import { AuthorizationService, computeCredentialProjection } from "acryl-control";
5
6
  import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
6
7
  import { diffLines } from "diff";
7
- import { Container, Editor, HStack, Key, KeybindingsManager, ProcessTerminal, ScrollView, TUI_KEYBINDINGS, Text, TuiAltScreen, VStack, matchesKey, setKeybindings, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
8
+ import { Container, Editor, HStack, Key, KeybindingsManager, ProcessTerminal, ScrollView, TUI_KEYBINDINGS, Text, TuiAltScreen, VStack, fuzzyFilter, matchesKey, setKeybindings, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
8
9
  import { readdir } from "node:fs/promises";
9
10
  import { join, relative } from "node:path";
10
11
  import { ManualCompactionError } from "@deepseek-ai/dsh-compaction";
@@ -799,6 +800,7 @@ var TuiStore = class {
799
800
  title: void 0,
800
801
  stats: EMPTY_STATS,
801
802
  preset: void 0,
803
+ activeModel: void 0,
802
804
  streaming: void 0,
803
805
  pendingToolCalls: this.pendingToolCallsSnapshot(),
804
806
  shellRun: void 0,
@@ -909,6 +911,10 @@ var TuiStore = class {
909
911
  setPreset(preset) {
910
912
  this.set({ preset });
911
913
  }
914
+ /** Record a live `/model` switch so the status bar reflects it immediately. */
915
+ setActiveModel(activeModel) {
916
+ this.set({ activeModel });
917
+ }
912
918
  shellRunSeq = 0;
913
919
  /** Begin one local shell-escape run; its output accumulates via `appendShellOutput` until `finishShellRun` settles it into the transcript. */
914
920
  startShellRun(command) {
@@ -965,7 +971,6 @@ var TuiStore = class {
965
971
  kind: "login",
966
972
  login: {
967
973
  flows: void 0,
968
- selected: 0,
969
974
  signingIn: void 0,
970
975
  prompt: void 0,
971
976
  busy: true,
@@ -1089,6 +1094,36 @@ var TuiStore = class {
1089
1094
  }
1090
1095
  };
1091
1096
  //#endregion
1097
+ //#region src/tui/acrylMark.ts
1098
+ /**
1099
+ * The compact ACRYL wordmark shown in the fixed header, next to the pet.
1100
+ *
1101
+ * This is the real pixelized brand wordmark (`assets/pixelized/`), rendered
1102
+ * natively as half-block characters (each terminal row packs 2 pixel rows
1103
+ * via \u2580/\u2584/\u2588) instead of downsampled or replaced with a different mark \u2014 54
1104
+ * columns wide at the artwork's own resolution, not simplified. Generated
1105
+ * from the SVG's rect grid and verified by printing it before wiring into
1106
+ * the header \u2014 same proven-before-embedding convention as
1107
+ * `yly/compile-sprites.mjs`.
1108
+ * @module @tomowave/dsh-tui/tui/acrylMark
1109
+ */
1110
+ /** The plain (uncolored) rows of the ACRYL mark \u2014 11 lines, 54 columns. */
1111
+ const ACRYL_MARK_ROWS = [
1112
+ " ▄▀▀▀▀▀▀▀▀ ",
1113
+ " ▄▀ ▄▄▄▄▄▄▄▄▄ ",
1114
+ " ▄▄ ▄ ▀▄ ",
1115
+ " ▄█▀█▄ ▄▀ ▄▄▄▄▄█ ",
1116
+ " ▄█▀ ▀█▀▀▀▀█▄ ▀▄ ▄▀ ",
1117
+ " █▀ ▀▄ ▀█ ▀▄▄ ▄█ ",
1118
+ "▀▀ ▀ ▀█▄ ▄ ▄██▄▄▄▄▄▄▄▄▄▄",
1119
+ " ▀▀▀▀▀▀▀▀▀▀▀ ▀▀▄ ▄▀▀ ",
1120
+ " ▀▄▄▄▀ ",
1121
+ " █ ",
1122
+ " ▀ "
1123
+ ];
1124
+ /** ISO timestamp this build was produced at. */
1125
+ const BUILD_TIME = "2026-09-08T18:16:12.207Z";
1126
+ //#endregion
1092
1127
  //#region src/tui/statsFormat.ts
1093
1128
  /**
1094
1129
  * Compact token count: 517 / 12.2K / 517K / 1.2M (one decimal under three digits).
@@ -1242,7 +1277,7 @@ function stripSessionIdPrefix(id) {
1242
1277
  //#region src/tui/liveText.ts
1243
1278
  const dim = fg(theme.muted);
1244
1279
  const accent = fg(theme.accent);
1245
- const warning$1 = fg(theme.warning);
1280
+ const warning$2 = fg(theme.warning);
1246
1281
  function buildStatusBarText(params) {
1247
1282
  const { sessionId, provider, model, status, queuedCount, presetLabel, eventCount, spinnerChar } = params;
1248
1283
  const queuedSuffix = queuedCount > 0 ? ` · ${queuedCount} queued` : "";
@@ -1285,7 +1320,7 @@ const PERMISSION_COLORS = {
1285
1320
  */
1286
1321
  function buildUpdateHintText(currentVersion, latestVersion) {
1287
1322
  if (latestVersion === void 0) return "";
1288
- return warning$1(`⬆ ACRYL update available: v${currentVersion} → v${latestVersion}`) + dim(" (run `pnpm add -g @acryl/cli` to upgrade)");
1323
+ return warning$2(`⬆ ACRYL update available: v${currentVersion} → v${latestVersion}`) + dim(" (run `pnpm add -g @acryl/cli` to upgrade)");
1289
1324
  }
1290
1325
  function buildPermissionText(permission) {
1291
1326
  if (permission === void 0) return "";
@@ -2905,27 +2940,120 @@ function miniTextFieldInput(state, data) {
2905
2940
  cursor: state.cursor + data.length
2906
2941
  };
2907
2942
  }
2943
+ /**
2944
+ * Mask a value for display while typing, revealing the first/last couple of
2945
+ * characters instead of hiding it entirely — a fully-masked live field looks
2946
+ * indistinguishable from an empty one, giving no confirmation that anything
2947
+ * was actually typed. Length-preserving (unlike a static preview's `sk-p…9sZ4`
2948
+ * ellipsis form) so cursor-position math against the masked string stays
2949
+ * correct: every hidden character becomes exactly one `mask` character.
2950
+ */
2951
+ function partiallyMask(value, mask) {
2952
+ if (value.length <= 2) return mask.repeat(value.length);
2953
+ if (value.length <= 10) return `${value[0]}${mask.repeat(value.length - 2)}${value.at(-1)}`;
2954
+ return `${value.slice(0, 4)}${mask.repeat(value.length - 8)}${value.slice(-4)}`;
2955
+ }
2908
2956
  /** Render the field's text, optionally with an inverse-video cursor block at the cursor position. */
2909
2957
  function renderMiniTextField(state, cursorVisible, mask) {
2910
- const display = mask === void 0 ? state.value : mask.repeat(state.value.length);
2958
+ const display = mask === void 0 ? state.value : partiallyMask(state.value, mask);
2911
2959
  if (!cursorVisible) return display;
2912
2960
  return `${display.slice(0, state.cursor)}\x1b[7m${display[state.cursor] ?? " "}\x1b[0m${display.slice(state.cursor + 1)}`;
2913
2961
  }
2914
2962
  //#endregion
2963
+ //#region src/tui/listWindow.ts
2964
+ /**
2965
+ * Scrollable-list windowing shared by every overlay that paints a
2966
+ * selection-following list (`/login`'s provider list, `/model`'s picker and
2967
+ * provider list). Previously duplicated verbatim in `LoginOverlay.ts` and
2968
+ * `ModelProfileOverlay.ts` (specs/001-acryl-refactor-improvements-and-tech-debt, R7).
2969
+ * @module @tomowang/dsh-tui/tui/listWindow
2970
+ */
2971
+ /**
2972
+ * Rows available for a scrollable list body: terminal height minus the lines
2973
+ * every such screen spends on chrome (header/hint/notice/search — `chrome`
2974
+ * lines, caller-counted since it varies by screen). Long catalogs (~35
2975
+ * providers, matching that many models) previously rendered every row
2976
+ * unconditionally, pushing the key-legend hint line — the only place a
2977
+ * shortcut is documented — past the bottom of the terminal, invisible
2978
+ * without scrolling back. Every list-shaped view windows around the current
2979
+ * selection instead, so the hint line is always the last line printed and
2980
+ * always fits on screen.
2981
+ */
2982
+ function listWindow(terminalRows, chrome) {
2983
+ return Math.max(3, terminalRows - chrome);
2984
+ }
2985
+ /** The `[start, end)` slice of `count` items to show so `selected` stays visible within `maxVisible` rows, biased to keep it centered. */
2986
+ function visibleRange(count, selected, maxVisible) {
2987
+ if (count <= maxVisible) return {
2988
+ start: 0,
2989
+ end: count
2990
+ };
2991
+ const start = Math.max(0, Math.min(selected - Math.floor(maxVisible / 2), count - maxVisible));
2992
+ return {
2993
+ start,
2994
+ end: start + maxVisible
2995
+ };
2996
+ }
2997
+ //#endregion
2998
+ //#region src/tui/modelProfile/types.ts
2999
+ /**
3000
+ * The `apiKeyEnv` reference a provider route falls back to when its settings
3001
+ * profile names none, e.g. `my-proxy` -> `MY_PROXY_API_KEY`. Shared between
3002
+ * the overlay (recomputing it for a just-typed custom route before save) and
3003
+ * the host wiring (deriving it for every already-configured row), so the
3004
+ * naming rule lives in exactly one place.
3005
+ */
3006
+ function deriveApiKeyRef(route) {
3007
+ const upper = route.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
3008
+ return `${/^[A-Z_]/.test(upper) ? upper : `P_${upper}`}_API_KEY`;
3009
+ }
3010
+ /**
3011
+ * A short, identifying (never full) preview of a stored secret — first 4 and
3012
+ * last 4 characters, so the API-key field isn't blank-looking when a key is
3013
+ * already set and the user can tell *which* key it is without ever seeing
3014
+ * enough of it to be usable on its own. Short values (<= 10 chars, where that
3015
+ * split would show most of the string anyway) mask everything but the first
3016
+ * and last single character instead.
3017
+ */
3018
+ function maskKeyPreview(value) {
3019
+ if (value.length <= 10) return value.length <= 2 ? "*".repeat(value.length) : `${value[0]}${"*".repeat(value.length - 2)}${value.at(-1)}`;
3020
+ return `${value.slice(0, 4)}…${value.slice(-4)}`;
3021
+ }
3022
+ //#endregion
2915
3023
  //#region src/tui/modelProfile/ModelProfileOverlay.ts
2916
3024
  const bold$9 = (s) => `\x1b[1m${s}\x1b[0m`;
2917
3025
  const secondary$9 = fg(theme.secondary);
2918
3026
  const muted$10 = fg(theme.muted);
3027
+ const warning$1 = fg(theme.warning);
2919
3028
  const errorColor$5 = fg(theme.error);
2920
3029
  const invert$5 = (s) => `\x1b[7m${s}\x1b[0m`;
3030
+ /**
3031
+ * The only wire protocols `dsh-llm-pi-ai` can speak (`supportedProtocols()`),
3032
+ * matching the DSH Desktop GUI's "Custom provider" dialog's own dropdown
3033
+ * exactly. Free-text protocol entry let a typo reach `assertServiceable` as a
3034
+ * runtime save failure instead of being impossible to enter in the first
3035
+ * place, so this is a cycling choice, never a typed field.
3036
+ */
3037
+ const PROTOCOLS = [
3038
+ "openai-completions",
3039
+ "openai-responses",
3040
+ "anthropic-messages"
3041
+ ];
2921
3042
  var ModelProfileOverlay = class {
3043
+ tui;
2922
3044
  store;
2923
3045
  actions;
3046
+ mode = "picker";
3047
+ modelSearch = emptyMiniTextField();
3048
+ modelPickerCursor = 0;
3049
+ providerSearch = emptyMiniTextField();
3050
+ providerCursor = 0;
2924
3051
  confirmDelete;
3052
+ pendingShowModels = false;
2925
3053
  formKeySeen = -1;
2926
3054
  route = emptyMiniTextField();
2927
3055
  displayName = emptyMiniTextField();
2928
- api = emptyMiniTextField();
3056
+ api = PROTOCOLS[0];
2929
3057
  baseURL = emptyMiniTextField();
2930
3058
  apiKeyDraft = emptyMiniTextField();
2931
3059
  models = [];
@@ -2933,25 +3061,32 @@ var ModelProfileOverlay = class {
2933
3061
  focused = 0;
2934
3062
  modelDraftId = emptyMiniTextField();
2935
3063
  modelSelected = 0;
2936
- modelInputFocused = false;
2937
- constructor(store, actions) {
3064
+ constructor(tui, store, actions) {
3065
+ this.tui = tui;
2938
3066
  this.store = store;
2939
3067
  this.actions = actions;
2940
3068
  }
2941
3069
  invalidate() {}
3070
+ /**
3071
+ * The global one-line notice (`store.setNotice`), rendered inline here.
3072
+ * `/model` runs as a full-screen overlay that paints over the whole
3073
+ * terminal, so the notice dock underneath — where "Saved X."/"Active model
3074
+ * set to X." otherwise lands — is invisible for as long as this overlay
3075
+ * stays open (e.g. right after `saveProvider` returns to the list view
3076
+ * without closing the overlay). Surfacing it here is what makes a save
3077
+ * actually visible as "done" instead of the screen looking unchanged.
3078
+ */
3079
+ noticeLines() {
3080
+ const notice = this.store.getSnapshot().notice;
3081
+ return notice === void 0 ? [] : [muted$10(notice)];
3082
+ }
2942
3083
  textFields(draft) {
2943
- return draft.isNew ? [
3084
+ const base = draft.isNew ? [
2944
3085
  "route",
2945
3086
  "displayName",
2946
- "api",
2947
- "baseURL",
2948
- "apiKey"
2949
- ] : [
2950
- "displayName",
2951
- "api",
2952
- "baseURL",
2953
- "apiKey"
2954
- ];
3087
+ "baseURL"
3088
+ ] : ["displayName", "baseURL"];
3089
+ return draft.authMethod === "oauth" ? base : [...base, "apiKey"];
2955
3090
  }
2956
3091
  /** Reinitialize form-local state from the store's draft when `formKey` changes — the equivalent of the old `key={formKey}` remount. */
2957
3092
  syncFormState(mp) {
@@ -2961,24 +3096,29 @@ var ModelProfileOverlay = class {
2961
3096
  this.formKeySeen = mp.formKey;
2962
3097
  this.route = emptyMiniTextField(draft.route);
2963
3098
  this.displayName = emptyMiniTextField(draft.displayName);
2964
- this.api = emptyMiniTextField(draft.api);
3099
+ this.api = PROTOCOLS.includes(draft.api) ? draft.api : PROTOCOLS[0];
2965
3100
  this.baseURL = emptyMiniTextField(draft.baseURL);
2966
3101
  this.apiKeyDraft = emptyMiniTextField("");
2967
3102
  this.models = [...draft.models];
2968
- this.showModels = false;
3103
+ this.showModels = this.pendingShowModels;
3104
+ this.pendingShowModels = false;
2969
3105
  this.focused = 0;
2970
3106
  this.modelDraftId = emptyMiniTextField();
2971
3107
  this.modelSelected = 0;
2972
- this.modelInputFocused = false;
2973
3108
  }
2974
3109
  return draft;
2975
3110
  }
2976
3111
  buildDraft(draft) {
3112
+ const route = draft.isNew ? this.route.value.trim() : draft.route;
2977
3113
  return {
2978
3114
  ...draft,
2979
- route: draft.isNew ? this.route.value.trim() : draft.route,
3115
+ route,
3116
+ ...draft.isNew ? {
3117
+ settingsPath: ["providers", route],
3118
+ apiKeyRef: deriveApiKeyRef(route)
3119
+ } : {},
2980
3120
  displayName: this.displayName.value,
2981
- api: this.api.value,
3121
+ api: this.api,
2982
3122
  baseURL: this.baseURL.value,
2983
3123
  apiKeyDraft: this.apiKeyDraft.value,
2984
3124
  models: this.models
@@ -2989,111 +3129,223 @@ var ModelProfileOverlay = class {
2989
3129
  if (overlay.kind !== "modelProfile") return [];
2990
3130
  const mp = overlay.modelProfile;
2991
3131
  const draft = this.syncFormState(mp);
2992
- if (draft !== void 0) return this.showModels ? this.renderModelListEditor(mp) : this.renderForm(draft, mp);
2993
- return this.renderList(mp);
3132
+ if (draft !== void 0) {
3133
+ if (this.showModels) return this.renderModelListEditor(mp);
3134
+ return this.renderForm(draft, mp);
3135
+ }
3136
+ return this.mode === "picker" ? this.renderModelPicker(mp) : this.renderList(mp);
3137
+ }
3138
+ pickerItems(mp) {
3139
+ const items = [];
3140
+ for (const row of mp.providers ?? []) {
3141
+ if (!row.live) continue;
3142
+ for (const model of row.models) items.push({
3143
+ route: row.route,
3144
+ id: model.id,
3145
+ authMethod: row.authMethod
3146
+ });
3147
+ }
3148
+ return items;
3149
+ }
3150
+ filteredPickerItems(mp) {
3151
+ const items = this.pickerItems(mp);
3152
+ const query = this.modelSearch.value.trim();
3153
+ return query === "" ? items : fuzzyFilter([...items], query, (item) => `${item.id} ${item.route}`);
3154
+ }
3155
+ /**
3156
+ * pi.dev's `/model` picker (see Image #45): a flat, fuzzy-searchable list of
3157
+ * every model on a live (actually usable) route, not a per-provider drill-down.
3158
+ * Provider add/edit/delete moves to a secondary `p` mode instead of being the
3159
+ * default, since picking a model to make active is the far more common action.
3160
+ */
3161
+ renderModelPicker(mp) {
3162
+ const lines = [warning$1("Only showing models from configured providers. Use /login to add providers.")];
3163
+ lines.push(...this.noticeLines());
3164
+ if (mp.error !== void 0) lines.push(errorColor$5(mp.error));
3165
+ lines.push(`> ${renderMiniTextField(this.modelSearch, true)}`);
3166
+ if (mp.busy && mp.providers === void 0) lines.push(muted$10("Loading…"));
3167
+ const all = this.pickerItems(mp);
3168
+ const filtered = this.filteredPickerItems(mp);
3169
+ const chrome = lines.length + 2;
3170
+ const maxVisible = listWindow(this.tui.terminal.rows, chrome);
3171
+ const { start, end } = visibleRange(filtered.length, this.modelPickerCursor, maxVisible);
3172
+ filtered.slice(start, end).forEach((item, offset) => {
3173
+ const isSelected = start + offset === this.modelPickerCursor;
3174
+ const via = item.authMethod === "oauth" ? " oauth" : item.authMethod === "api-key" ? " api" : "";
3175
+ const text = `${isSelected ? "→ " : " "}${item.id} [${item.route}${via}]`;
3176
+ lines.push(isSelected ? invert$5(text) : text);
3177
+ });
3178
+ if (filtered.length > maxVisible) lines.push(muted$10(`(${this.modelPickerCursor + 1}/${filtered.length})`));
3179
+ if (all.length === 0 && mp.providers !== void 0) lines.push(muted$10("No models available yet — use /login to sign in to a provider."));
3180
+ else if (filtered.length === 0) lines.push(muted$10("No matching models."));
3181
+ lines.push(muted$10("type to search · ↑↓ select · enter set active · ctrl+p manage providers (add/remove models) · esc close"));
3182
+ return lines;
3183
+ }
3184
+ handleModelPickerInput(data, mp) {
3185
+ if (matchesKey(data, Key.escape)) {
3186
+ this.actions.closeModelProfile();
3187
+ return;
3188
+ }
3189
+ if (matchesKey(data, Key.ctrl("p"))) {
3190
+ this.mode = "providers";
3191
+ return;
3192
+ }
3193
+ const filtered = this.filteredPickerItems(mp);
3194
+ if (matchesKey(data, Key.up)) {
3195
+ this.modelPickerCursor = Math.max(0, this.modelPickerCursor - 1);
3196
+ return;
3197
+ }
3198
+ if (matchesKey(data, Key.down)) {
3199
+ this.modelPickerCursor = Math.min(Math.max(0, filtered.length - 1), this.modelPickerCursor + 1);
3200
+ return;
3201
+ }
3202
+ if (matchesKey(data, Key.enter)) {
3203
+ const item = filtered[this.modelPickerCursor];
3204
+ if (item !== void 0) {
3205
+ this.actions.setActiveModel(item.route, item.id);
3206
+ this.actions.closeModelProfile();
3207
+ }
3208
+ return;
3209
+ }
3210
+ const next = miniTextFieldInput(this.modelSearch, data);
3211
+ if (next !== void 0) {
3212
+ this.modelSearch = next;
3213
+ this.modelPickerCursor = 0;
3214
+ }
3215
+ }
3216
+ filteredProviders(mp) {
3217
+ const providers = mp.providers ?? [];
3218
+ const query = this.providerSearch.value.trim();
3219
+ return query === "" ? providers : fuzzyFilter([...providers], query, (row) => `${row.displayName} ${row.route}`);
2994
3220
  }
2995
3221
  renderList(mp) {
2996
- const { providers, selected, busy, error } = mp;
3222
+ const { providers, busy, error } = mp;
2997
3223
  const lines = [bold$9(secondary$9("Model providers"))];
3224
+ lines.push(...this.noticeLines());
2998
3225
  if (error !== void 0) lines.push(errorColor$5(error));
3226
+ lines.push(`> ${renderMiniTextField(this.providerSearch, true)}`);
2999
3227
  if (busy && providers === void 0) lines.push(muted$10("Loading…"));
3000
- providers?.forEach((row, index) => {
3228
+ const filtered = this.filteredProviders(mp);
3229
+ const chrome = lines.length + 2;
3230
+ const maxVisible = listWindow(this.tui.terminal.rows, chrome);
3231
+ const { start, end } = visibleRange(filtered.length, this.providerCursor, maxVisible);
3232
+ filtered.slice(start, end).forEach((row, offset) => {
3233
+ const index = start + offset;
3001
3234
  const marker = row.configured ? "● " : "○ ";
3002
3235
  const active = row.live ? " (active)" : "";
3003
- const noKey = row.apiKeyConfigured ? "" : " [no api key]";
3004
- const confirm = this.confirmDelete === index ? " — press d again to delete" : "";
3005
- const text = `${index === selected ? "› " : " "}${marker}${row.displayName}${active}${noKey}${confirm}`;
3006
- lines.push(index === selected ? invert$5(text) : text);
3236
+ const noKey = row.apiKeyConfigured ? ` [${row.authMethod === "oauth" ? "oauth" : "api key"}]` : " [no api key]";
3237
+ const confirm = this.confirmDelete === index ? " — press ctrl+x again to delete" : "";
3238
+ const text = `${index === this.providerCursor ? "› " : " "}${marker}${row.displayName}${active}${noKey}${confirm}`;
3239
+ lines.push(index === this.providerCursor ? invert$5(text) : text);
3007
3240
  });
3008
- if (providers?.length === 0) lines.push(muted$10("No providers configured yet — press a to add one."));
3009
- lines.push(muted$10("↑↓ select · enter edit · a add · d delete · s set active model · esc close"));
3241
+ if (filtered.length > maxVisible) lines.push(muted$10(`(${this.providerCursor + 1}/${filtered.length})`));
3242
+ if (providers?.length === 0) lines.push(muted$10("No providers configured yet ctrl+n to add one."));
3243
+ else if (filtered.length === 0) lines.push(muted$10("No matching providers."));
3244
+ 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"));
3010
3245
  return lines;
3011
3246
  }
3012
3247
  handleListInput(data, mp) {
3013
- const { providers, selected } = mp;
3014
3248
  if (matchesKey(data, Key.escape)) {
3015
- this.actions.closeModelProfile();
3249
+ this.mode = "picker";
3016
3250
  return;
3017
3251
  }
3018
- if (providers === void 0 || providers.length === 0) {
3019
- if (data === "a") this.actions.createProvider();
3252
+ if (matchesKey(data, Key.ctrl("n"))) {
3253
+ this.actions.createProvider();
3020
3254
  return;
3021
3255
  }
3256
+ const filtered = this.filteredProviders(mp);
3257
+ if (filtered.length === 0) return;
3022
3258
  if (matchesKey(data, Key.up)) {
3023
3259
  this.confirmDelete = void 0;
3024
- this.actions.selectProvider(Math.max(0, selected - 1));
3260
+ this.providerCursor = Math.max(0, this.providerCursor - 1);
3025
3261
  return;
3026
3262
  }
3027
3263
  if (matchesKey(data, Key.down)) {
3028
3264
  this.confirmDelete = void 0;
3029
- this.actions.selectProvider(Math.min(providers.length - 1, selected + 1));
3265
+ this.providerCursor = Math.min(filtered.length - 1, this.providerCursor + 1);
3030
3266
  return;
3031
3267
  }
3032
3268
  if (matchesKey(data, Key.enter)) {
3033
- this.actions.editProvider(providers[selected].route);
3269
+ this.actions.editProvider(filtered[this.providerCursor].route);
3034
3270
  return;
3035
3271
  }
3036
- if (data === "a") {
3037
- this.actions.createProvider();
3272
+ if (matchesKey(data, Key.ctrl("e"))) {
3273
+ this.pendingShowModels = true;
3274
+ this.actions.editProvider(filtered[this.providerCursor].route);
3038
3275
  return;
3039
3276
  }
3040
- if (data === "s") {
3041
- const row = providers[selected];
3277
+ if (matchesKey(data, Key.ctrl("a"))) {
3278
+ const row = filtered[this.providerCursor];
3042
3279
  const model = row.models[0];
3043
3280
  if (model !== void 0) this.actions.setActiveModel(row.route, model.id);
3044
3281
  return;
3045
3282
  }
3046
- if (data === "d") {
3047
- if (this.confirmDelete === selected) {
3283
+ if (matchesKey(data, Key.ctrl("x"))) {
3284
+ if (this.confirmDelete === this.providerCursor) {
3048
3285
  this.confirmDelete = void 0;
3049
- this.actions.deleteProvider(providers[selected]);
3050
- } else this.confirmDelete = selected;
3286
+ this.actions.deleteProvider(filtered[this.providerCursor]);
3287
+ } else this.confirmDelete = this.providerCursor;
3051
3288
  return;
3052
3289
  }
3053
3290
  this.confirmDelete = void 0;
3291
+ const next = miniTextFieldInput(this.providerSearch, data);
3292
+ if (next !== void 0) {
3293
+ this.providerSearch = next;
3294
+ this.providerCursor = 0;
3295
+ }
3054
3296
  }
3055
3297
  renderForm(draft, mp) {
3056
3298
  const textFields = this.textFields(draft);
3057
- const modelsRow = textFields.length;
3058
- const saveRow = textFields.length + 1;
3299
+ const protocolRow = textFields.length;
3300
+ const modelsRow = protocolRow + 1;
3301
+ const saveRow = protocolRow + 2;
3059
3302
  const fieldState = {
3060
3303
  route: this.route,
3061
3304
  displayName: this.displayName,
3062
- api: this.api,
3063
3305
  baseURL: this.baseURL,
3064
3306
  apiKey: this.apiKeyDraft
3065
3307
  };
3066
3308
  const labels = {
3067
- route: "Route",
3068
- displayName: "Name",
3069
- api: "Protocol",
3309
+ route: "Provider ID",
3310
+ displayName: "Display name",
3070
3311
  baseURL: "Base URL",
3071
- apiKey: draft.apiKeyConfigured ? "API key (set — leave blank to keep)" : "API key"
3312
+ apiKey: draft.apiKeyConfigured ? `API key (set${draft.apiKeyPreview === void 0 ? "" : `: ${draft.apiKeyPreview}`} — leave blank to keep)` : "API key"
3072
3313
  };
3073
- const lines = [bold$9(secondary$9(draft.isNew ? "Add provider" : `Edit ${draft.displayName || draft.route}`))];
3314
+ const lines = [bold$9(secondary$9(draft.isNew ? "Custom provider" : `Edit ${draft.displayName || draft.route}`))];
3315
+ lines.push(...this.noticeLines());
3074
3316
  if (mp.error !== void 0) lines.push(errorColor$5(mp.error));
3317
+ if (draft.authMethod === "oauth") lines.push(muted$10("(signed in via OAuth — no separate API key for this route)"));
3075
3318
  textFields.forEach((field, index) => {
3076
3319
  const isFocused = this.focused === index;
3077
3320
  const mask = field === "apiKey" ? "*" : void 0;
3078
3321
  const prefix = `${isFocused ? "› " : " "}${labels[field]}: `;
3079
3322
  lines.push(`${prefix}${renderMiniTextField(fieldState[field], isFocused, mask)}`);
3080
3323
  });
3324
+ const protocolFocused = this.focused === protocolRow;
3325
+ const protocolText = `${protocolFocused ? "› " : " "}API protocol: ‹ ${this.api} ›`;
3326
+ lines.push(protocolFocused ? invert$5(protocolText) : protocolText);
3081
3327
  const modelsText = `${this.focused === modelsRow ? "› " : " "}Models (${this.models.length}) — enter to edit`;
3082
3328
  lines.push(this.focused === modelsRow ? invert$5(modelsText) : modelsText);
3083
- const saveText = `${this.focused === saveRow ? "› " : " "}${mp.busy ? "Saving…" : "Save"}`;
3329
+ const saveText = `${this.focused === saveRow ? "› " : " "}${mp.busy ? "Saving…" : draft.isNew ? "Create provider" : "Save changes"}`;
3084
3330
  lines.push(this.focused === saveRow ? invert$5(saveText) : saveText);
3085
- lines.push(muted$10("tab/shift+tab move · enter confirm field / activate row · esc cancel"));
3331
+ const removeHint = draft.apiKeyConfigured ? " · ctrl+d sign out (remove key/OAuth grant)" : "";
3332
+ lines.push(muted$10(`tab/shift+tab move · ←→ cycle protocol · enter confirm/activate · esc cancel${removeHint}`));
3086
3333
  return lines;
3087
3334
  }
3088
3335
  handleFormInput(data, draft) {
3089
3336
  const textFields = this.textFields(draft);
3090
- const modelsRow = textFields.length;
3091
- const saveRow = textFields.length + 1;
3092
- const rowCount = textFields.length + 2;
3337
+ const protocolRow = textFields.length;
3338
+ const modelsRow = protocolRow + 1;
3339
+ const saveRow = protocolRow + 2;
3340
+ const rowCount = protocolRow + 3;
3093
3341
  if (matchesKey(data, Key.escape)) {
3094
3342
  this.actions.backToProviderList();
3095
3343
  return;
3096
3344
  }
3345
+ if (matchesKey(data, Key.ctrl("d")) && draft.apiKeyConfigured) {
3346
+ this.actions.clearApiKey(draft);
3347
+ return;
3348
+ }
3097
3349
  if (matchesKey(data, "shift+tab")) {
3098
3350
  this.focused = (this.focused - 1 + rowCount) % rowCount;
3099
3351
  return;
@@ -3102,6 +3354,12 @@ var ModelProfileOverlay = class {
3102
3354
  this.focused = (this.focused + 1) % rowCount;
3103
3355
  return;
3104
3356
  }
3357
+ if (this.focused === protocolRow && (matchesKey(data, Key.left) || matchesKey(data, Key.right))) {
3358
+ const direction = matchesKey(data, Key.left) ? -1 : 1;
3359
+ const currentIndex = PROTOCOLS.indexOf(this.api);
3360
+ this.api = PROTOCOLS[(currentIndex + direction + PROTOCOLS.length) % PROTOCOLS.length];
3361
+ return;
3362
+ }
3105
3363
  if (matchesKey(data, Key.enter) && this.focused === modelsRow) {
3106
3364
  this.showModels = true;
3107
3365
  return;
@@ -3110,7 +3368,7 @@ var ModelProfileOverlay = class {
3110
3368
  this.actions.saveProvider(this.buildDraft(draft));
3111
3369
  return;
3112
3370
  }
3113
- if (matchesKey(data, Key.enter) && this.focused < textFields.length) {
3371
+ if (matchesKey(data, Key.enter) && this.focused <= protocolRow) {
3114
3372
  this.focused = (this.focused + 1) % rowCount;
3115
3373
  return;
3116
3374
  }
@@ -3119,34 +3377,43 @@ var ModelProfileOverlay = class {
3119
3377
  const next = miniTextFieldInput({
3120
3378
  route: this.route,
3121
3379
  displayName: this.displayName,
3122
- api: this.api,
3123
3380
  baseURL: this.baseURL,
3124
3381
  apiKey: this.apiKeyDraft
3125
3382
  }[field], data);
3126
3383
  if (next === void 0) return;
3127
3384
  if (field === "route") this.route = next;
3128
3385
  else if (field === "displayName") this.displayName = next;
3129
- else if (field === "api") this.api = next;
3130
3386
  else if (field === "baseURL") this.baseURL = next;
3131
3387
  else this.apiKeyDraft = next;
3132
3388
  }
3133
3389
  }
3390
+ /**
3391
+ * No Tab-toggled focus, on purpose: a hidden "which of two things am I
3392
+ * typing into right now" mode with no visible indicator (the earlier
3393
+ * design) is exactly the kind of invisible-state bug already fixed
3394
+ * elsewhere in this file. Instead the "Add id" field is *always* live —
3395
+ * every printable key, paste, and left/right/backspace/delete/home/end
3396
+ * goes there, full stop, since ↑/↓ are the only keys `miniTextFieldInput`
3397
+ * never claims. That leaves ↑/↓ free to always drive the list selection
3398
+ * (for removal) at the same time, with no mode switch and no key that can
3399
+ * ever mean two different things depending on invisible state.
3400
+ */
3134
3401
  renderModelListEditor(mp) {
3135
3402
  const lines = [bold$9(secondary$9("Models"))];
3136
3403
  this.models.forEach((model, index) => {
3137
- const isSelected = !this.modelInputFocused && index === this.modelSelected;
3404
+ const isSelected = index === this.modelSelected;
3138
3405
  const text = `${isSelected ? "› " : " "}${model.id}${model.name === void 0 ? "" : ` — ${model.name}`}`;
3139
3406
  lines.push(isSelected ? invert$5(text) : text);
3140
3407
  });
3141
3408
  if (this.models.length === 0) lines.push(muted$10("No models yet."));
3142
- lines.push(`${this.modelInputFocused ? "› " : " "}Add id: ${renderMiniTextField(this.modelDraftId, this.modelInputFocused)}`);
3409
+ lines.push(`Add id: ${renderMiniTextField(this.modelDraftId, true)}`);
3143
3410
  if (mp.busy) lines.push(muted$10("Discovering…"));
3144
3411
  if (mp.discovered !== void 0) if (mp.discovered.length === 0) lines.push(muted$10("No models discovered."));
3145
3412
  else {
3146
- lines.push(muted$10("Discovered — tab to the id field and type one to adopt it:"));
3413
+ lines.push(muted$10("Discovered — type one into \"Add id\" above to adopt it:"));
3147
3414
  for (const model of mp.discovered) lines.push(muted$10(` ${model.id}${model.name === void 0 ? "" : ` — ${model.name}`}`));
3148
3415
  }
3149
- lines.push(muted$10("tab toggle list/input · ↑↓ select · x remove · g discover · esc back"));
3416
+ lines.push(muted$10("type + enter add · ↑↓ select · ctrl+x remove · ctrl+g discover · ctrl+s save · esc back (without saving)"));
3150
3417
  return lines;
3151
3418
  }
3152
3419
  addModel(id) {
@@ -3162,36 +3429,34 @@ var ModelProfileOverlay = class {
3162
3429
  this.showModels = false;
3163
3430
  return;
3164
3431
  }
3165
- if (matchesKey(data, Key.tab)) {
3166
- this.modelInputFocused = !this.modelInputFocused;
3432
+ if (matchesKey(data, Key.ctrl("s"))) {
3433
+ this.actions.saveProvider(this.buildDraft(draft));
3167
3434
  return;
3168
3435
  }
3169
- if (this.modelInputFocused) {
3170
- if (matchesKey(data, Key.enter)) {
3171
- this.addModel(this.modelDraftId.value);
3172
- return;
3173
- }
3174
- const next = miniTextFieldInput(this.modelDraftId, data);
3175
- if (next !== void 0) this.modelDraftId = next;
3436
+ if (matchesKey(data, Key.ctrl("g"))) {
3437
+ this.actions.discoverModelsForDraft(this.buildDraft(draft));
3176
3438
  return;
3177
3439
  }
3178
- if (data === "g") {
3179
- this.actions.discoverModelsForDraft(this.buildDraft(draft));
3440
+ if (matchesKey(data, Key.ctrl("x"))) {
3441
+ if (this.models.length === 0) return;
3442
+ this.models = this.models.filter((_, index) => index !== this.modelSelected);
3443
+ this.modelSelected = Math.max(0, Math.min(this.modelSelected, this.models.length - 1));
3180
3444
  return;
3181
3445
  }
3182
- if (this.models.length === 0) return;
3183
3446
  if (matchesKey(data, Key.up)) {
3184
- this.modelSelected = Math.max(0, this.modelSelected - 1);
3447
+ if (this.models.length > 0) this.modelSelected = Math.max(0, this.modelSelected - 1);
3185
3448
  return;
3186
3449
  }
3187
3450
  if (matchesKey(data, Key.down)) {
3188
- this.modelSelected = Math.min(this.models.length - 1, this.modelSelected + 1);
3451
+ if (this.models.length > 0) this.modelSelected = Math.min(this.models.length - 1, this.modelSelected + 1);
3189
3452
  return;
3190
3453
  }
3191
- if (data === "x") {
3192
- this.models = this.models.filter((_, index) => index !== this.modelSelected);
3193
- this.modelSelected = Math.max(0, Math.min(this.modelSelected, this.models.length - 1));
3454
+ if (matchesKey(data, Key.enter)) {
3455
+ this.addModel(this.modelDraftId.value);
3456
+ return;
3194
3457
  }
3458
+ const next = miniTextFieldInput(this.modelDraftId, data);
3459
+ if (next !== void 0) this.modelDraftId = next;
3195
3460
  }
3196
3461
  handleInput(data) {
3197
3462
  const overlay = this.store.getSnapshot().overlay;
@@ -3203,7 +3468,8 @@ var ModelProfileOverlay = class {
3203
3468
  else this.handleFormInput(data, draft);
3204
3469
  return;
3205
3470
  }
3206
- this.handleListInput(data, mp);
3471
+ if (this.mode === "picker") this.handleModelPickerInput(data, mp);
3472
+ else this.handleListInput(data, mp);
3207
3473
  }
3208
3474
  };
3209
3475
  //#endregion
@@ -3212,33 +3478,126 @@ const bold$8 = (s) => `\x1b[1m${s}\x1b[0m`;
3212
3478
  const secondary$8 = fg(theme.secondary);
3213
3479
  const muted$9 = fg(theme.muted);
3214
3480
  const errorColor$4 = fg(theme.error);
3481
+ const successColor = fg(theme.success);
3215
3482
  const invert$4 = (s) => `\x1b[7m${s}\x1b[0m`;
3483
+ /** A non-secret reminder that replacing an API-key prompt will overwrite an existing stored key. */
3484
+ function formatStoredApiKeyHint(preview) {
3485
+ return preview === void 0 ? void 0 : `Stored key: ${preview}`;
3486
+ }
3487
+ const AUTH_TYPES = ["oauth", "api-key"];
3488
+ const AUTH_TYPE_LABELS = {
3489
+ oauth: "Sign in with an account",
3490
+ "api-key": "Sign in with an API key"
3491
+ };
3216
3492
  var LoginOverlay = class {
3493
+ tui;
3217
3494
  store;
3218
3495
  actions;
3496
+ view = {
3497
+ kind: "authType",
3498
+ cursor: 0
3499
+ };
3219
3500
  promptField = emptyMiniTextField();
3220
3501
  promptCursor = 0;
3221
- constructor(store, actions) {
3502
+ constructor(tui, store, actions) {
3503
+ this.tui = tui;
3222
3504
  this.store = store;
3223
3505
  this.actions = actions;
3224
3506
  }
3225
3507
  invalidate() {}
3226
- renderList(login) {
3227
- const lines = [bold$8(secondary$8("Sign in"))];
3508
+ /**
3509
+ * The global one-line notice (`store.setNotice`), rendered inline here.
3510
+ * `/login` runs as a full-screen overlay that paints over the whole
3511
+ * terminal, so the notice dock underneath — where sign-in progress,
3512
+ * success ("Signed in to X."), and failure text otherwise land — is
3513
+ * completely hidden for as long as this overlay stays open. Surfacing it
3514
+ * here is what makes an OAuth callback's result actually visible instead
3515
+ * of the screen appearing to do nothing once the browser redirects back.
3516
+ */
3517
+ noticeLines() {
3518
+ const notice = this.store.getSnapshot().notice;
3519
+ return notice === void 0 ? [] : [muted$9(notice)];
3520
+ }
3521
+ /**
3522
+ * Pure: given the loaded flows, which single method (if any) they all
3523
+ * share — the chooser has nothing to offer when only one method type is
3524
+ * registered at all. Recomputed fresh every call (no cached/latched
3525
+ * result), so a later-refreshed flow set is always re-evaluated instead of
3526
+ * replaying a stale decision (specs/001-…, R6).
3527
+ */
3528
+ autoSkipAuthType(login) {
3529
+ if (login.flows === void 0) return void 0;
3530
+ const hasOAuth = login.flows.some((flow) => flow.methods.some((method) => method.id === "oauth"));
3531
+ return hasOAuth === login.flows.some((flow) => flow.methods.some((method) => method.id === "api-key")) ? void 0 : hasOAuth ? "oauth" : "api-key";
3532
+ }
3533
+ /**
3534
+ * Pure: the view to actually render/operate against. `this.view` stays the
3535
+ * untouched `authType` default until the user explicitly chooses (Enter on
3536
+ * the chooser) — until then, this derives the auto-skipped `list` view on
3537
+ * every call instead of mutating `this.view` from inside `render()`. The
3538
+ * first `handleInput` mutation against a derived (not-yet-explicit) `list`
3539
+ * view reassigns `this.view` directly (see `handleProviderListInput`), so
3540
+ * cursor/search state persists across renders once the user interacts.
3541
+ */
3542
+ effectiveView(login) {
3543
+ if (this.view.kind === "authType") {
3544
+ const auto = this.autoSkipAuthType(login);
3545
+ if (auto !== void 0) return {
3546
+ kind: "list",
3547
+ authType: auto,
3548
+ chooserSkipped: true,
3549
+ cursor: 0,
3550
+ searchQuery: emptyMiniTextField()
3551
+ };
3552
+ }
3553
+ return this.view;
3554
+ }
3555
+ filteredFlows(login, view) {
3556
+ const all = login.flows ?? [];
3557
+ const byType = view.authType === void 0 ? all : all.filter((flow) => flow.methods.some((method) => method.id === view.authType));
3558
+ const query = view.searchQuery.value.trim();
3559
+ return query === "" ? byType : fuzzyFilter([...byType], query, (flow) => flow.label);
3560
+ }
3561
+ renderAuthTypeChooser(login, view) {
3562
+ const lines = [bold$8(secondary$8("Select authentication method"))];
3563
+ lines.push(...this.noticeLines());
3564
+ if (login.error !== void 0) lines.push(errorColor$4(login.error));
3565
+ if (login.busy && login.flows === void 0) lines.push(muted$9("Loading…"));
3566
+ AUTH_TYPES.forEach((type, index) => {
3567
+ const text = `${index === view.cursor ? "› " : " "}${AUTH_TYPE_LABELS[type]}`;
3568
+ lines.push(index === view.cursor ? invert$4(text) : text);
3569
+ });
3570
+ lines.push(muted$9("↑↓ select · enter continue · esc close"));
3571
+ return lines;
3572
+ }
3573
+ renderProviderList(login, view) {
3574
+ const lines = [bold$8(secondary$8("Select provider to configure:"))];
3575
+ lines.push(...this.noticeLines());
3228
3576
  if (login.error !== void 0) lines.push(errorColor$4(login.error));
3577
+ lines.push(`> ${renderMiniTextField(view.searchQuery, true)}`);
3229
3578
  if (login.busy && login.flows === void 0) lines.push(muted$9("Loading…"));
3230
- login.flows?.forEach((flow, index) => {
3231
- const marker = flow.inFlight ? "· " : "○ ";
3579
+ const flows = this.filteredFlows(login, view);
3580
+ const chrome = lines.length + 2;
3581
+ const maxVisible = listWindow(this.tui.terminal.rows, chrome);
3582
+ const { start, end } = visibleRange(flows.length, view.cursor, maxVisible);
3583
+ flows.slice(start, end).forEach((flow, offset) => {
3584
+ const index = start + offset;
3585
+ const marker = flow.inFlight ? "· " : flow.configured ? "✓ " : "○ ";
3232
3586
  const signingIn = login.signingIn === flow.key ? " — signing in…" : "";
3233
- const text = `${index === login.selected ? " " : " "}${marker}${flow.label}${signingIn}`;
3234
- lines.push(index === login.selected ? invert$4(text) : text);
3587
+ const via = flow.authMethod === "oauth" ? " [oauth]" : flow.authMethod === "api-key" ? " [api]" : "";
3588
+ const readyNote = login.signingIn !== flow.key && flow.configured ? ` — ready to use${via}` : "";
3589
+ const text = `${index === view.cursor ? "› " : " "}${marker}${flow.label}${signingIn}${readyNote}`;
3590
+ lines.push(index === view.cursor ? invert$4(text) : flow.configured ? successColor(text) : text);
3235
3591
  });
3236
- if (login.flows?.length === 0) lines.push(muted$9("No sign-in providers are available in this profile."));
3237
- lines.push(muted$9("↑↓ select · enter sign in · esc close"));
3592
+ if (flows.length > maxVisible) lines.push(muted$9(`(${view.cursor + 1}/${flows.length})`));
3593
+ if (login.flows !== void 0 && flows.length === 0) lines.push(muted$9("No matching providers."));
3594
+ const back = view.chooserSkipped ? "esc close" : "esc back";
3595
+ lines.push(muted$9(`type to search · ↑↓ select · enter sign in (or edit key/models if already configured) · ctrl+p add custom provider · ${back}`));
3238
3596
  return lines;
3239
3597
  }
3240
3598
  renderPrompt(prompt) {
3241
3599
  const lines = [bold$8(secondary$8("Sign in"))];
3600
+ lines.push(...this.noticeLines());
3242
3601
  lines.push(prompt.message);
3243
3602
  if (prompt.kind === "select") {
3244
3603
  prompt.options.forEach((option, index) => {
@@ -3251,26 +3610,35 @@ var LoginOverlay = class {
3251
3610
  const mask = prompt.kind === "secret" ? "•" : void 0;
3252
3611
  const placeholder = prompt.placeholder ?? (prompt.kind === "secret" ? "API key" : "code");
3253
3612
  const field = this.promptField.value === "" ? `(${placeholder})` : renderMiniTextField(this.promptField, true, mask);
3613
+ const storedKeyHint = prompt.kind === "secret" ? formatStoredApiKeyHint(prompt.storedApiKeyPreview) : void 0;
3614
+ if (storedKeyHint !== void 0) lines.push(muted$9(storedKeyHint));
3254
3615
  lines.push(`> ${field}`);
3255
- lines.push(muted$9("type + enter submit · esc cancel"));
3616
+ lines.push(muted$9("escape/ctrl+c to cancel, enter to submit"));
3256
3617
  }
3257
3618
  return lines;
3258
3619
  }
3259
3620
  render(_width) {
3260
3621
  const overlay = this.store.getSnapshot().overlay;
3261
3622
  if (overlay.kind !== "login") return [];
3262
- const { prompt } = overlay.login;
3263
- return prompt === void 0 ? this.renderList(overlay.login) : this.renderPrompt(prompt);
3623
+ const { login } = overlay;
3624
+ if (login.prompt !== void 0) return this.renderPrompt(login.prompt);
3625
+ const view = this.effectiveView(login);
3626
+ return view.kind === "authType" ? this.renderAuthTypeChooser(login, view) : this.renderProviderList(login, view);
3264
3627
  }
3265
3628
  handleInput(data) {
3266
3629
  const overlay = this.store.getSnapshot().overlay;
3267
3630
  if (overlay.kind !== "login") return;
3268
- const { prompt } = overlay.login;
3269
- if (prompt !== void 0) this.handlePromptInput(data, prompt);
3270
- else this.handleListInput(data, overlay.login);
3631
+ const { login } = overlay;
3632
+ if (login.prompt !== void 0) {
3633
+ this.handlePromptInput(data, login.prompt);
3634
+ return;
3635
+ }
3636
+ const view = this.effectiveView(login);
3637
+ if (view.kind === "authType") this.handleAuthTypeChooserInput(data, view);
3638
+ else this.handleProviderListInput(data, login, view);
3271
3639
  }
3272
3640
  handlePromptInput(data, prompt) {
3273
- if (matchesKey(data, Key.escape)) {
3641
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
3274
3642
  this.actions.answerAuthorizationPrompt("");
3275
3643
  return;
3276
3644
  }
@@ -3293,22 +3661,79 @@ var LoginOverlay = class {
3293
3661
  const next = miniTextFieldInput(this.promptField, data);
3294
3662
  if (next !== void 0) this.promptField = next;
3295
3663
  }
3296
- handleListInput(data, login) {
3664
+ handleAuthTypeChooserInput(data, view) {
3297
3665
  if (matchesKey(data, Key.escape)) {
3298
3666
  this.actions.closeLogin();
3299
3667
  return;
3300
3668
  }
3301
- const flows = login.flows;
3302
- if (flows === void 0 || flows.length === 0) return;
3303
3669
  if (matchesKey(data, Key.up)) {
3304
- this.actions.selectLoginFlow(Math.max(0, login.selected - 1));
3670
+ this.view = {
3671
+ kind: "authType",
3672
+ cursor: Math.max(0, view.cursor - 1)
3673
+ };
3305
3674
  return;
3306
3675
  }
3307
3676
  if (matchesKey(data, Key.down)) {
3308
- this.actions.selectLoginFlow(Math.min(flows.length - 1, login.selected + 1));
3677
+ this.view = {
3678
+ kind: "authType",
3679
+ cursor: Math.min(AUTH_TYPES.length - 1, view.cursor + 1)
3680
+ };
3309
3681
  return;
3310
3682
  }
3311
- if (matchesKey(data, Key.enter)) this.actions.beginAuthorization(flows[login.selected].key);
3683
+ if (matchesKey(data, Key.enter)) this.view = {
3684
+ kind: "list",
3685
+ authType: AUTH_TYPES[view.cursor],
3686
+ chooserSkipped: false,
3687
+ cursor: 0,
3688
+ searchQuery: emptyMiniTextField()
3689
+ };
3690
+ }
3691
+ handleProviderListInput(data, login, view) {
3692
+ if (matchesKey(data, Key.escape)) {
3693
+ if (view.chooserSkipped) this.actions.closeLogin();
3694
+ else this.view = {
3695
+ kind: "authType",
3696
+ cursor: 0
3697
+ };
3698
+ return;
3699
+ }
3700
+ if (matchesKey(data, Key.ctrl("p"))) {
3701
+ this.actions.addCustomProvider();
3702
+ return;
3703
+ }
3704
+ const flows = this.filteredFlows(login, view);
3705
+ if (matchesKey(data, Key.up)) {
3706
+ this.view = {
3707
+ ...view,
3708
+ cursor: Math.max(0, view.cursor - 1)
3709
+ };
3710
+ return;
3711
+ }
3712
+ if (matchesKey(data, Key.down)) {
3713
+ this.view = {
3714
+ ...view,
3715
+ cursor: Math.min(Math.max(0, flows.length - 1), view.cursor + 1)
3716
+ };
3717
+ return;
3718
+ }
3719
+ if (matchesKey(data, Key.ctrl("e"))) {
3720
+ const flow = flows[view.cursor];
3721
+ if (flow !== void 0 && flow.configured && flow.key.startsWith("llm-pi-ai/")) this.actions.openProviderEditor(flow.key.slice(10));
3722
+ return;
3723
+ }
3724
+ if (matchesKey(data, Key.enter)) {
3725
+ const flow = flows[view.cursor];
3726
+ if (flow === void 0) return;
3727
+ if (flow.configured && flow.key.startsWith("llm-pi-ai/")) this.actions.openProviderEditor(flow.key.slice(10));
3728
+ else this.actions.beginAuthorization(flow.key, view.authType);
3729
+ return;
3730
+ }
3731
+ const next = miniTextFieldInput(view.searchQuery, data);
3732
+ if (next !== void 0) this.view = {
3733
+ ...view,
3734
+ searchQuery: next,
3735
+ cursor: 0
3736
+ };
3312
3737
  }
3313
3738
  };
3314
3739
  //#endregion
@@ -4593,8 +5018,8 @@ var TuiApp = class {
4593
5018
  const state = store.getSnapshot();
4594
5019
  return buildStatusBarText({
4595
5020
  sessionId: options.sessionId,
4596
- provider: options.provider,
4597
- model: options.model,
5021
+ provider: state.activeModel?.provider ?? options.provider,
5022
+ model: state.activeModel?.model ?? options.model,
4598
5023
  status: state.status,
4599
5024
  queuedCount: state.queued.length,
4600
5025
  presetLabel: state.preset?.current,
@@ -4622,29 +5047,36 @@ var TuiApp = class {
4622
5047
  updateHintText,
4623
5048
  statsLineText
4624
5049
  ], { gap: 0 });
4625
- const headerInfo = new DynamicText(() => {
5050
+ const headerMark = new DynamicText(() => ACRYL_MARK_ROWS.map((row) => fg(theme.primary)(row)).join("\n"));
5051
+ const header = new HStack([{
5052
+ component: this.pet,
5053
+ basis: 34,
5054
+ shrink: 0
5055
+ }, {
5056
+ component: headerMark,
5057
+ basis: "auto",
5058
+ grow: 1
5059
+ }], {
5060
+ gap: 1,
5061
+ align: "start"
5062
+ });
5063
+ const buildStamp = `build a980b38 · ${(/* @__PURE__ */ new Date(BUILD_TIME)).toLocaleTimeString()}`;
5064
+ const sessionInfo = new DynamicText(() => {
4626
5065
  const { provider, model, cwd } = options;
4627
5066
  return [
4628
- fg(theme.primary)("ACRYL"),
4629
- "",
4630
5067
  `${provider}/${model}`,
4631
- cwd
5068
+ cwd,
5069
+ fg(theme.muted)(buildStamp)
4632
5070
  ].join("\n");
4633
5071
  });
4634
5072
  const layoutRoot = new VStack([
4635
5073
  {
4636
- component: new HStack([{
4637
- component: this.pet,
4638
- basis: 34,
4639
- shrink: 0
4640
- }, {
4641
- component: headerInfo,
4642
- basis: "auto",
4643
- grow: 1
4644
- }], {
4645
- gap: 1,
4646
- align: "start"
4647
- }),
5074
+ component: header,
5075
+ basis: "auto",
5076
+ shrink: 0
5077
+ },
5078
+ {
5079
+ component: sessionInfo,
4648
5080
  basis: "auto",
4649
5081
  shrink: 0
4650
5082
  },
@@ -4743,8 +5175,8 @@ var TuiApp = class {
4743
5175
  const { store, actions, getTool, getToolCall } = this.options;
4744
5176
  switch (overlay.kind) {
4745
5177
  case "none": return;
4746
- case "modelProfile": return new ModelProfileOverlay(store, actions);
4747
- case "login": return new LoginOverlay(store, actions);
5178
+ case "modelProfile": return new ModelProfileOverlay(this.tui, store, actions);
5179
+ case "login": return new LoginOverlay(this.tui, store, actions);
4748
5180
  case "trajectory": return new TrajectoryOverlay(this.tui, store, actions, getTool);
4749
5181
  case "toolCards": return new ToolCardsOverlay(this.tui, store, actions, getTool, getToolCall);
4750
5182
  case "context": return new ContextOverlay(store, actions);
@@ -4867,14 +5299,8 @@ async function walkDirectory(cwd) {
4867
5299
  }
4868
5300
  return results;
4869
5301
  }
4870
- //#endregion
4871
- //#region src/tui/auth-guidance.ts
4872
- function logoutSuccessMessage(provider) {
4873
- return `Removed stored authentication for ${provider.trim() === "" ? "provider" : provider}.`;
4874
- }
4875
- function logoutNoneMessage() {
4876
- return "No active provider is selected; nothing to log out.";
4877
- }
5302
+ /** Canonical ACRYL version string used by the CLI and the release smoke checks. */
5303
+ const ACRYL_VERSION = createRequire(import.meta.url)("../package.json").version;
4878
5304
  //#endregion
4879
5305
  //#region src/tui-app/session.ts
4880
5306
  /**
@@ -4887,7 +5313,9 @@ function logoutNoneMessage() {
4887
5313
  * TUI shows the same capabilities the web surface does. `/clear` flushes the
4888
5314
  * current session and re-attaches a fresh one (durable history stays on disk).
4889
5315
  */
4890
- const TUI_VERSION = createRequire(import.meta.url)("../package.json").version;
5316
+ /** The credential-record scope `dsh-llm-pi-ai` writes a `/login` sign-in under (`credentialKey('llm-pi-ai', providerId)`). */
5317
+ const LOGIN_RECORD_SCOPE = "llm-pi-ai";
5318
+ const TUI_VERSION = ACRYL_VERSION;
4891
5319
  const PROMPT_HISTORY_LIMIT = 200;
4892
5320
  function failUnknown(status) {
4893
5321
  return status === "running" ? "running" : "idle";
@@ -4928,10 +5356,35 @@ function getAtPath(value, path) {
4928
5356
  }
4929
5357
  return current;
4930
5358
  }
4931
- /** Derive a POSIX-identifier credential ref from a provider route, e.g. `my-proxy` -> `MY_PROXY_API_KEY`. */
4932
- function deriveApiKeyRef(route) {
4933
- const upper = route.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
4934
- return `${/^[A-Z_]/.test(upper) ? upper : `P_${upper}`}_API_KEY`;
5359
+ /**
5360
+ * `dsh-llm-pi-ai`'s own credential record key under `LOGIN_RECORD_SCOPE`
5361
+ * (`credentialKey('llm-pi-ai', providerId)`, i.e. `"llm-pi-ai/<providerId>"`).
5362
+ * A `/login` sign-in (OAuth or API key) writes here through pi-ai's own
5363
+ * `CredentialStore`, entirely separate from the `apiKeyEnv` reference a
5364
+ * manually configured provider's settings profile points at.
5365
+ * `acryl-control`'s `CredentialProjection` computes this internally for the
5366
+ * read side; only `clearApiKey`'s explicit delete still needs it directly.
5367
+ */
5368
+ function loginRecordKey(providerId) {
5369
+ return `${LOGIN_RECORD_SCOPE}/${providerId}`;
5370
+ }
5371
+ /**
5372
+ * Wait out `ctx.settings`' asynchronous watcher queue after a write.
5373
+ *
5374
+ * `settingsSvc.update()`'s returned promise resolves once the write commits
5375
+ * to the settings document — but `dsh-llm-pi-ai`'s own reactive watcher (the
5376
+ * thing that actually rebuilds its live `Models` registry and re-registers
5377
+ * routes with `ctx.llm`) runs on a *separate* promise chained off that
5378
+ * commit, not before it settles. A caller that reads `ctx.llm.listModels()`
5379
+ * or `listProviders()` immediately after `update()` resolves races that
5380
+ * watcher and sees the pre-write state. `setImmediate` runs after the
5381
+ * Node microtask queue drains, which is exactly where that chained watcher
5382
+ * promise settles, so awaiting one flush is enough to see the write reflected.
5383
+ */
5384
+ function flushSettingsWatchers() {
5385
+ return new Promise((resolve) => {
5386
+ setImmediate(resolve);
5387
+ });
4935
5388
  }
4936
5389
  /** Open a URL in the platform's default browser (fire-and-forget). */
4937
5390
  function openBrowser(url) {
@@ -4996,11 +5449,46 @@ async function attachSession(host, resumeId) {
4996
5449
  const agent = host.ctx.agents?.get?.(SessionId(id));
4997
5450
  const session = agent?.session;
4998
5451
  const history = [];
4999
- async function loadProviders() {
5452
+ function credentialProjectionServices() {
5000
5453
  const settingsSvc = host.ctx.get("settings");
5001
5454
  const credentialsSvc = host.ctx.get("credentials");
5002
5455
  const llmSvc = host.ctx.get("llm");
5003
- if (settingsSvc === void 0 || credentialsSvc === void 0 || llmSvc === void 0) {
5456
+ if (settingsSvc === void 0 || credentialsSvc === void 0 || llmSvc === void 0) return void 0;
5457
+ return {
5458
+ settings: settingsSvc,
5459
+ credentials: credentialsSvc,
5460
+ llm: llmSvc
5461
+ };
5462
+ }
5463
+ async function credentialRows() {
5464
+ const services = credentialProjectionServices();
5465
+ if (services === void 0) return void 0;
5466
+ return computeCredentialProjection(services, {
5467
+ deriveApiKeyRef,
5468
+ loginRecordScope: LOGIN_RECORD_SCOPE
5469
+ });
5470
+ }
5471
+ /** Map the shared `CredentialProjectionRow` onto `/model`'s own `ProviderRow` presentation shape. */
5472
+ function toProviderRow(row) {
5473
+ return {
5474
+ route: row.route,
5475
+ displayName: row.displayName,
5476
+ settingsNs: row.settingsNs,
5477
+ settingsPath: row.settingsPath,
5478
+ configured: row.hasSettingsProfile,
5479
+ live: row.isLive,
5480
+ api: row.api,
5481
+ baseURL: row.baseURL,
5482
+ apiKeyRef: row.apiKeyRef,
5483
+ apiKeyConfigured: row.hasCredential,
5484
+ authMethod: row.authMethod,
5485
+ models: row.models,
5486
+ revision: row.revision
5487
+ };
5488
+ }
5489
+ async function loadProviders() {
5490
+ const rows = await credentialRows();
5491
+ if (rows === void 0) {
5004
5492
  store.updateModelProfile({
5005
5493
  providers: [],
5006
5494
  busy: false,
@@ -5008,40 +5496,64 @@ async function attachSession(host, resumeId) {
5008
5496
  });
5009
5497
  return;
5010
5498
  }
5011
- const configurable = llmSvc.listConfigurableProviders();
5012
- const live = new Set(llmSvc.listProviders().map((provider) => provider.id));
5013
- const descriptors = settingsSvc.describe({ redactSecrets: true });
5014
- const byNs = new Map(descriptors.map((descriptor) => [descriptor.ns, descriptor]));
5015
- const rows = [];
5016
- for (const entry of configurable) {
5017
- const descriptor = byNs.get(entry.settingsNs);
5018
- const value = descriptor === void 0 ? void 0 : getAtPath(descriptor.value, entry.settingsPath);
5019
- const userValue = descriptor === void 0 ? void 0 : getAtPath(descriptor.user, entry.settingsPath);
5020
- const apiKeyRef = value?.apiKeyEnv ?? deriveApiKeyRef(entry.provider);
5021
- const info = await credentialsSvc.describe(apiKeyRef);
5022
- rows.push({
5023
- route: entry.provider,
5024
- displayName: value?.displayName ?? entry.displayName,
5025
- settingsNs: entry.settingsNs,
5026
- settingsPath: entry.settingsPath,
5027
- configured: userValue !== void 0,
5028
- live: live.has(entry.provider),
5029
- api: value?.api,
5030
- baseURL: value?.baseURL,
5031
- apiKeyRef,
5032
- apiKeyConfigured: info.configured,
5033
- models: value?.models ?? [],
5034
- revision: descriptor?.revision
5035
- });
5036
- }
5499
+ const providers = rows.map(toProviderRow);
5037
5500
  store.updateModelProfile({
5038
- providers: rows,
5501
+ providers,
5039
5502
  busy: false,
5040
5503
  error: void 0,
5041
5504
  selected: 0
5042
5505
  });
5043
5506
  }
5044
- async function loadAuthorizationFlows() {
5507
+ function openEditFormForRow(row) {
5508
+ const overlay = store.getSnapshot().overlay;
5509
+ if (overlay.kind !== "modelProfile") return;
5510
+ const draft = {
5511
+ route: row.route,
5512
+ isNew: false,
5513
+ settingsNs: row.settingsNs,
5514
+ settingsPath: row.settingsPath,
5515
+ displayName: row.displayName,
5516
+ api: row.api ?? "",
5517
+ baseURL: row.baseURL ?? "",
5518
+ apiKeyRef: row.apiKeyRef,
5519
+ apiKeyConfigured: row.apiKeyConfigured,
5520
+ authMethod: row.authMethod,
5521
+ apiKeyDraft: "",
5522
+ apiKeyPreview: void 0,
5523
+ models: row.models,
5524
+ revision: row.revision
5525
+ };
5526
+ const formKey = overlay.modelProfile.formKey + 1;
5527
+ store.updateModelProfile({
5528
+ view: "form",
5529
+ draft,
5530
+ formKey
5531
+ });
5532
+ if (row.authMethod === "api-key") (async () => {
5533
+ const resolved = await host.ctx.get("credentials")?.resolve?.(row.apiKeyRef).catch(() => void 0);
5534
+ if (resolved?.value === void 0) return;
5535
+ const current = store.getSnapshot().overlay;
5536
+ if (current.kind !== "modelProfile" || current.modelProfile.formKey !== formKey) return;
5537
+ const currentDraft = current.modelProfile.draft;
5538
+ if (currentDraft === void 0) return;
5539
+ store.updateModelProfile({ draft: {
5540
+ ...currentDraft,
5541
+ apiKeyPreview: maskKeyPreview(resolved.value)
5542
+ } });
5543
+ })();
5544
+ }
5545
+ const routeActivationPort = { async ensureRouteActivated(providerId) {
5546
+ const settingsSvc = host.ctx.get("settings");
5547
+ const llmSvc = host.ctx.get("llm");
5548
+ if (settingsSvc === void 0 || llmSvc === void 0) return;
5549
+ const entry = llmSvc.listConfigurableProviders().find((candidate) => candidate.provider === providerId);
5550
+ if (entry === void 0) return;
5551
+ const descriptor = settingsSvc.describe({ redactSecrets: true }).find((candidate) => candidate.ns === entry.settingsNs);
5552
+ if ((descriptor === void 0 ? void 0 : getAtPath(descriptor.user, entry.settingsPath)) !== void 0) return;
5553
+ await settingsSvc.update(entry.settingsNs, nestAtPath(entry.settingsPath, {}), descriptor?.revision);
5554
+ await flushSettingsWatchers();
5555
+ } };
5556
+ async function loadLoginFlows() {
5045
5557
  const authSvc = host.ctx.get("authorization");
5046
5558
  if (authSvc === void 0) {
5047
5559
  store.updateLogin({
@@ -5052,7 +5564,17 @@ async function attachSession(host, resumeId) {
5052
5564
  return;
5053
5565
  }
5054
5566
  try {
5055
- const list = authSvc.list();
5567
+ const entries = authSvc.list();
5568
+ const rows = await credentialRows();
5569
+ const byKey = new Map((rows ?? []).map((row) => [loginRecordKey(row.route), row]));
5570
+ const list = entries.map((entry) => {
5571
+ const row = byKey.get(entry.key);
5572
+ return {
5573
+ ...entry,
5574
+ configured: row?.hasCredential ?? false,
5575
+ authMethod: row?.authMethod
5576
+ };
5577
+ });
5056
5578
  store.updateLogin({
5057
5579
  flows: list,
5058
5580
  busy: false,
@@ -5065,6 +5587,41 @@ async function attachSession(host, resumeId) {
5065
5587
  });
5066
5588
  }
5067
5589
  }
5590
+ function syncCredentialViews() {
5591
+ loadProviders();
5592
+ loadLoginFlows();
5593
+ }
5594
+ /** 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). */
5595
+ function openCustomProviderDraft() {
5596
+ const settingsNs = host.ctx.get("llm")?.listConfigurableProviders?.()[0]?.settingsNs;
5597
+ if (settingsNs === void 0) {
5598
+ store.setNotice("Adding a custom provider is not available in this profile.");
5599
+ return;
5600
+ }
5601
+ const draft = {
5602
+ route: "",
5603
+ isNew: true,
5604
+ settingsNs,
5605
+ settingsPath: [],
5606
+ displayName: "",
5607
+ api: "",
5608
+ baseURL: "",
5609
+ apiKeyRef: "",
5610
+ apiKeyConfigured: false,
5611
+ authMethod: void 0,
5612
+ apiKeyDraft: "",
5613
+ apiKeyPreview: void 0,
5614
+ models: [],
5615
+ revision: void 0
5616
+ };
5617
+ const overlay = store.getSnapshot().overlay;
5618
+ const formKey = overlay.kind === "modelProfile" ? overlay.modelProfile.formKey + 1 : 1;
5619
+ store.updateModelProfile({
5620
+ view: "form",
5621
+ draft,
5622
+ formKey
5623
+ });
5624
+ }
5068
5625
  async function loadAgentPresets() {
5069
5626
  const presetsSvc = host.ctx.get("agentPresets");
5070
5627
  if (presetsSvc === void 0) {
@@ -5107,7 +5664,9 @@ async function attachSession(host, resumeId) {
5107
5664
  });
5108
5665
  },
5109
5666
  cancel() {
5110
- bridge.cancel(id).catch(() => {});
5667
+ bridge.cancel(id).catch((error) => {
5668
+ store.setNotice(`Could not cancel: ${error instanceof Error ? error.message : String(error)}`);
5669
+ });
5111
5670
  },
5112
5671
  shutdown() {
5113
5672
  signal("exit");
@@ -5215,17 +5774,15 @@ async function attachSession(host, resumeId) {
5215
5774
  },
5216
5775
  login() {
5217
5776
  store.openLogin();
5218
- loadAuthorizationFlows();
5777
+ loadLoginFlows();
5219
5778
  },
5220
5779
  closeLogin() {
5221
5780
  store.closeOverlay();
5222
5781
  },
5223
- selectLoginFlow(index) {
5224
- store.updateLogin({ selected: index });
5225
- },
5226
- beginAuthorization(key) {
5782
+ beginAuthorization(key, method) {
5227
5783
  (async () => {
5228
5784
  const authSvc = host.ctx.get("authorization");
5785
+ const credentialsSvc = host.ctx.get("credentials");
5229
5786
  if (authSvc === void 0) {
5230
5787
  store.setNotice("Sign-in is not available in this profile.");
5231
5788
  return;
@@ -5233,6 +5790,8 @@ async function attachSession(host, resumeId) {
5233
5790
  const overlay = store.getSnapshot().overlay;
5234
5791
  const flow = overlay.kind === "login" ? overlay.login.flows?.find((entry) => entry.key === key) : void 0;
5235
5792
  if (flow === void 0) return;
5793
+ const existingRecord = (method ?? flow.methods[0]?.id) === "api-key" ? await credentialsSvc?.readRecord?.(flow.key) : void 0;
5794
+ const storedApiKeyPreview = typeof existingRecord?.key === "string" ? maskKeyPreview(existingRecord.key) : void 0;
5236
5795
  store.updateLogin({ signingIn: key });
5237
5796
  const interaction = {
5238
5797
  notify(notice) {
@@ -5257,8 +5816,13 @@ async function attachSession(host, resumeId) {
5257
5816
  label: option.label,
5258
5817
  description: option.description
5259
5818
  }))
5819
+ } : prompt.kind === "secret" ? {
5820
+ kind: "secret",
5821
+ message: prompt.message,
5822
+ placeholder: prompt.placeholder,
5823
+ storedApiKeyPreview
5260
5824
  } : {
5261
- kind: prompt.kind,
5825
+ kind: "text",
5262
5826
  message: prompt.message,
5263
5827
  placeholder: prompt.placeholder
5264
5828
  };
@@ -5271,16 +5835,20 @@ async function attachSession(host, resumeId) {
5271
5835
  });
5272
5836
  }
5273
5837
  };
5838
+ const authorizationService = new AuthorizationService({
5839
+ authorization: authSvc,
5840
+ routeActivation: routeActivationPort,
5841
+ onCredentialChanged: () => syncCredentialViews()
5842
+ });
5274
5843
  try {
5275
- if ((await authSvc.begin({
5844
+ if ((await authorizationService.begin({
5276
5845
  key,
5846
+ method,
5277
5847
  interaction
5278
- })).status === "authorized") {
5279
- store.setNotice(`Signed in to ${flow.label}.`);
5280
- loadAuthorizationFlows();
5281
- } else store.setNotice("Sign-in cancelled.");
5848
+ })).status === "authorized") store.setNotice(`Signed in to ${flow.label} — credentials saved, ready to use from /model.`);
5849
+ else store.updateLogin({ error: "Sign-in cancelled." });
5282
5850
  } catch (error) {
5283
- store.setNotice(`Sign-in failed: ${error instanceof Error ? error.message : String(error)}`);
5851
+ store.updateLogin({ error: `Sign-in failed: ${error instanceof Error ? error.message : String(error)}` });
5284
5852
  } finally {
5285
5853
  store.updateLogin({ signingIn: void 0 });
5286
5854
  }
@@ -5293,28 +5861,9 @@ async function attachSession(host, resumeId) {
5293
5861
  if (resolve !== void 0) resolve(value);
5294
5862
  },
5295
5863
  logout() {
5296
- (async () => {
5297
- const credentialsSvc = host.ctx.get("credentials");
5298
- if (credentialsSvc === void 0) {
5299
- store.setNotice("Credentials are not available in this profile.");
5300
- return;
5301
- }
5302
- const selection = host.ctx.get("agentDefaultModel")?.currentSelection?.();
5303
- const overlay = store.getSnapshot().overlay;
5304
- const rows = overlay.kind === "modelProfile" ? overlay.modelProfile.providers : void 0;
5305
- const row = rows?.find((entry) => entry.route === selection?.provider) ?? (rows !== void 0 && rows.length === 1 ? rows[0] : void 0);
5306
- if (row === void 0) {
5307
- store.setNotice(logoutNoneMessage());
5308
- return;
5309
- }
5310
- try {
5311
- await credentialsSvc.unset(row.apiKeyRef);
5312
- store.setNotice(logoutSuccessMessage(row.displayName));
5313
- loadProviders();
5314
- } catch (error) {
5315
- store.setNotice(`logout failed: ${error instanceof Error ? error.message : String(error)}`);
5316
- }
5317
- })();
5864
+ store.openModelProfile();
5865
+ store.setNotice("Select a provider, then Ctrl+D to sign out.");
5866
+ loadProviders();
5318
5867
  },
5319
5868
  openTrajectory() {
5320
5869
  store.openTrajectory();
@@ -5342,40 +5891,41 @@ async function attachSession(host, resumeId) {
5342
5891
  },
5343
5892
  backToProviderList() {
5344
5893
  store.updateModelProfile({ view: "list" });
5345
- },
5346
- selectProvider(index) {
5347
- store.updateModelProfile({ selected: index });
5894
+ syncCredentialViews();
5348
5895
  },
5349
5896
  createProvider() {
5350
- store.updateModelProfile({
5351
- view: "form",
5352
- draft: void 0
5353
- });
5897
+ openCustomProviderDraft();
5898
+ },
5899
+ addCustomProvider() {
5900
+ store.openModelProfile();
5901
+ loadProviders();
5902
+ openCustomProviderDraft();
5354
5903
  },
5355
5904
  editProvider(route) {
5356
5905
  const overlay = store.getSnapshot().overlay;
5357
5906
  if (overlay.kind !== "modelProfile") return;
5358
5907
  const row = overlay.modelProfile.providers?.find((entry) => entry.route === route);
5359
5908
  if (row === void 0) return;
5360
- const draft = {
5361
- route: row.route,
5362
- isNew: false,
5363
- settingsNs: row.settingsNs,
5364
- settingsPath: row.settingsPath,
5365
- displayName: row.displayName,
5366
- api: row.api ?? "",
5367
- baseURL: row.baseURL ?? "",
5368
- apiKeyRef: row.apiKeyRef,
5369
- apiKeyConfigured: row.apiKeyConfigured,
5370
- apiKeyDraft: "",
5371
- models: row.models,
5372
- revision: row.revision
5373
- };
5374
- store.updateModelProfile({
5375
- view: "form",
5376
- draft,
5377
- formKey: overlay.modelProfile.formKey + 1
5378
- });
5909
+ openEditFormForRow(row);
5910
+ },
5911
+ openProviderEditor(route) {
5912
+ (async () => {
5913
+ store.openModelProfile();
5914
+ const rows = await credentialRows();
5915
+ const providers = rows?.map(toProviderRow);
5916
+ store.updateModelProfile({
5917
+ providers: providers ?? [],
5918
+ busy: false,
5919
+ error: rows === void 0 ? "Model provider settings are not available in this profile." : void 0,
5920
+ selected: 0
5921
+ });
5922
+ const row = providers?.find((entry) => entry.route === route);
5923
+ if (row === void 0) {
5924
+ store.setNotice(`Provider "${route}" not found.`);
5925
+ return;
5926
+ }
5927
+ openEditFormForRow(row);
5928
+ })();
5379
5929
  },
5380
5930
  saveProvider(draft) {
5381
5931
  (async () => {
@@ -5385,20 +5935,25 @@ async function attachSession(host, resumeId) {
5385
5935
  store.setNotice("Provider settings are not available in this profile.");
5386
5936
  return;
5387
5937
  }
5938
+ if (draft.isNew && draft.route.trim() === "") {
5939
+ store.updateModelProfile({ error: "Provider ID is required." });
5940
+ return;
5941
+ }
5388
5942
  try {
5389
5943
  const key = draft.apiKeyDraft.trim();
5390
5944
  if (key !== "") await credentialsSvc.set(draft.apiKeyRef, key);
5391
5945
  const section = {
5392
- displayName: draft.displayName,
5393
- api: draft.api,
5394
- baseURL: draft.baseURL,
5946
+ ...draft.displayName.trim() === "" ? {} : { displayName: draft.displayName },
5947
+ ...draft.api.trim() === "" ? {} : { api: draft.api },
5948
+ ...draft.baseURL.trim() === "" ? {} : { baseURL: draft.baseURL },
5395
5949
  apiKeyEnv: draft.apiKeyRef,
5396
- models: draft.models
5950
+ ...draft.models.length === 0 ? {} : { models: draft.models }
5397
5951
  };
5398
5952
  await settingsSvc.update(draft.settingsNs, nestAtPath(draft.settingsPath, section), draft.revision);
5399
- store.setNotice(`Saved ${draft.displayName || draft.route}.`);
5953
+ await flushSettingsWatchers();
5954
+ store.setNotice(`Saved ${draft.displayName || draft.route} — credentials stored, ready to use from /model.`);
5400
5955
  store.updateModelProfile({ view: "list" });
5401
- loadProviders();
5956
+ syncCredentialViews();
5402
5957
  } catch (error) {
5403
5958
  store.setNotice(`save failed: ${error instanceof Error ? error.message : String(error)}`);
5404
5959
  }
@@ -5415,15 +5970,85 @@ async function attachSession(host, resumeId) {
5415
5970
  try {
5416
5971
  await credentialsSvc.unset(row.apiKeyRef);
5417
5972
  await settingsSvc.update(row.settingsNs, nestAtPath(row.settingsPath, {}), row.revision);
5973
+ await flushSettingsWatchers();
5418
5974
  store.setNotice(`Removed ${row.displayName}.`);
5419
- loadProviders();
5975
+ syncCredentialViews();
5420
5976
  } catch (error) {
5421
5977
  store.setNotice(`delete failed: ${error instanceof Error ? error.message : String(error)}`);
5422
5978
  }
5423
5979
  })();
5424
5980
  },
5425
- discoverModelsForDraft() {},
5426
- setActiveModel() {},
5981
+ clearApiKey(draft) {
5982
+ (async () => {
5983
+ const credentialsSvc = host.ctx.get("credentials");
5984
+ if (credentialsSvc === void 0) {
5985
+ store.setNotice("Credentials are not available in this profile.");
5986
+ return;
5987
+ }
5988
+ try {
5989
+ await credentialsSvc.unset(draft.apiKeyRef);
5990
+ await credentialsSvc.deleteRecord?.(loginRecordKey(draft.route));
5991
+ store.setNotice(`Removed the API key for ${draft.displayName || draft.route}.`);
5992
+ store.updateModelProfile({ view: "list" });
5993
+ syncCredentialViews();
5994
+ } catch (error) {
5995
+ store.setNotice(`Could not remove the key: ${error instanceof Error ? error.message : String(error)}`);
5996
+ }
5997
+ })();
5998
+ },
5999
+ discoverModelsForDraft(draft) {
6000
+ (async () => {
6001
+ const llmSvc = host.ctx.get("llm");
6002
+ if (llmSvc === void 0) {
6003
+ store.setNotice("Model discovery is not available in this profile.");
6004
+ return;
6005
+ }
6006
+ store.updateModelProfile({ busy: true });
6007
+ try {
6008
+ const request = {};
6009
+ if (!draft.isNew) request.provider = draft.route;
6010
+ if (draft.baseURL.trim() !== "") request.baseURL = draft.baseURL.trim();
6011
+ if (draft.api.trim() !== "") request.api = draft.api.trim();
6012
+ if (draft.apiKeyDraft.trim() !== "") request.apiKey = draft.apiKeyDraft.trim();
6013
+ const discovered = await llmSvc.discoverModels(draft.settingsNs, request);
6014
+ store.updateModelProfile({
6015
+ busy: false,
6016
+ discovered
6017
+ });
6018
+ } catch (error) {
6019
+ store.updateModelProfile({
6020
+ busy: false,
6021
+ discovered: []
6022
+ });
6023
+ store.setNotice(`Model discovery failed: ${error instanceof Error ? error.message : String(error)}`);
6024
+ }
6025
+ })();
6026
+ },
6027
+ setActiveModel(provider, model) {
6028
+ (async () => {
6029
+ try {
6030
+ await bridge.selectModel({
6031
+ sessionId: id,
6032
+ provider,
6033
+ model
6034
+ });
6035
+ store.setActiveModel({
6036
+ provider,
6037
+ model
6038
+ });
6039
+ try {
6040
+ await host.ctx.get("agentDefaultModel")?.saveSelection({
6041
+ provider,
6042
+ model
6043
+ });
6044
+ } catch {}
6045
+ store.setNotice(`Active model set to ${model} (${provider}).`);
6046
+ loadProviders();
6047
+ } catch (error) {
6048
+ store.setNotice(`Could not set active model: ${error instanceof Error ? error.message : String(error)}`);
6049
+ }
6050
+ })();
6051
+ },
5427
6052
  closeTrajectory() {
5428
6053
  store.closeOverlay();
5429
6054
  },