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