acryl-cli-linux-x64 0.1.31 → 0.1.35

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