dsh-neotui 0.1.28 → 0.1.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "description": "Neo-TUI: mouse-driven terminal UI client for DeepSeek Harness (B-tier per dsh-tui-design.md)",
5
5
  "type": "module",
6
6
  "bin": {
package/src/panels.js CHANGED
@@ -1822,7 +1822,7 @@ export class SettingsPanel extends Widget {
1822
1822
  * a visible caret, full key isolation from NORMAL/INSERT routing, and paste
1823
1823
  * support — Enter commits, Esc cancels. */
1824
1824
  export class EditPopup extends Popup {
1825
- constructor(app, { title, value, onCommit }) {
1825
+ constructor(app, { title, value, onCommit, completions, masked, statusHint, placeholder }) {
1826
1826
  const w = Math.min(80, app.screen.w - 8);
1827
1827
  const h = Math.min(16, app.screen.h - 6);
1828
1828
  super({
@@ -1834,20 +1834,47 @@ export class EditPopup extends Popup {
1834
1834
  });
1835
1835
  this.app = app;
1836
1836
  this.onCommit = onCommit;
1837
+ this.completions = completions ?? null; // candidate strings for Tab 补全
1838
+ this.masked = masked ?? false; // secret value: never echo it
1839
+ this.statusHint = statusHint ?? null;
1837
1840
  this.input = new Input({
1838
1841
  x: this.x + 2, y: this.y + h - 2, w: w - 4, h: 1,
1839
- multi: true, maxLines: 4, app,
1840
- prompt: "> ", placeholder: "输入值…(Ctrl+Shift+V 粘贴,Enter 确定,Esc 取消)",
1842
+ multi: true, maxLines: 4, app, masked: this.masked,
1843
+ prompt: "> ", placeholder: placeholder ?? (completions?.length
1844
+ ? "Tab 补全候选 · Enter 确定 · Esc 取消 · Ctrl+Shift+V 粘贴"
1845
+ : "输入值…(Ctrl+Shift+V 粘贴,Enter 确定,Esc 取消)"),
1841
1846
  });
1842
- this.input.setValue(String(value ?? ""), { select: true });
1847
+ // the cursor starts at the END of the existing value: typing appends and
1848
+ // edits in place instead of wiping the original (modify, not replace)
1849
+ this.input.setValue(String(value ?? ""));
1843
1850
  this.#layout();
1844
1851
  }
1845
1852
  #layout() {
1846
1853
  const lines = [];
1847
- lines.push([{ t: " 当前值预览:", fg: K.DIM, underline: true }]);
1848
- const v = this.input.value;
1849
- if (v === "") lines.push([{ t: "(空)", fg: K.FAINT }]);
1850
- else for (const ln of v.split("\n").slice(0, 6)) lines.push([{ t: " " + truncate(ln, this.w - 6), fg: K.TXT }]);
1854
+ if (this.statusHint) lines.push([{ t: " " + this.statusHint, fg: K.DIM }]);
1855
+ if (this.masked) {
1856
+ lines.push([{ t: " 已输入:", fg: K.DIM, underline: true }]);
1857
+ const n = Array.from(this.input.value).length;
1858
+ lines.push(n === 0
1859
+ ? [{ t: "(未输入 — 留空保持现有密钥不变)", fg: K.FAINT }]
1860
+ : [{ t: " " + "•".repeat(Math.min(n, 40)) + (n > 40 ? "…" : ""), fg: K.TXT }, { t: `(${n} 字符)`, fg: K.FAINT }]);
1861
+ } else {
1862
+ lines.push([{ t: " 当前值预览:", fg: K.DIM, underline: true }]);
1863
+ const v = this.input.value;
1864
+ if (v === "") lines.push([{ t: "(空)", fg: K.FAINT }]);
1865
+ else for (const ln of v.split("\n").slice(0, 6)) lines.push([{ t: " " + truncate(ln, this.w - 6), fg: K.TXT }]);
1866
+ }
1867
+ if (this.completions?.length) {
1868
+ // every possible option is shown as a hint; the one matching the typed
1869
+ // prefix lights up, the exact current value is marked ✓
1870
+ const v = this.input.value.trim();
1871
+ const segs = [{ t: " 候选协议: ", fg: K.DIM }];
1872
+ this.completions.forEach((c, i) => {
1873
+ if (i > 0) segs.push({ t: " · ", fg: K.FAINT });
1874
+ segs.push({ t: c === v ? `✓${c}` : c, fg: c === v ? K.OK : (v !== "" && c.startsWith(v) ? K.ACCENT : K.DIM), bold: c === v });
1875
+ });
1876
+ lines.push(segs);
1877
+ }
1851
1878
  lines.push([{ t: "" }]);
1852
1879
  this.lines = lines;
1853
1880
  }
@@ -1857,6 +1884,23 @@ export class EditPopup extends Popup {
1857
1884
  }
1858
1885
  onKey(ev) {
1859
1886
  if (ev.type === "key" && ev.name === "escape") { this.app.closeOverlay(); this.app.focus(this.app.chat); return true; }
1887
+ if (ev.type === "key" && ev.name === "tab" && this.completions?.length) {
1888
+ // Tab 选取/补全: an exact current value cycles to the next candidate,
1889
+ // anything else completes to the first prefix match
1890
+ const v = this.input.value.trim();
1891
+ const all = this.completions;
1892
+ const i = all.indexOf(v);
1893
+ if (i >= 0) {
1894
+ this.input.setValue(all[(i + 1) % all.length]);
1895
+ } else {
1896
+ const m = all.find((c) => c.startsWith(v));
1897
+ if (m) this.input.setValue(m);
1898
+ else this.app.toast("没有匹配的候选协议");
1899
+ }
1900
+ this.#layout();
1901
+ this.app.redraw();
1902
+ return true;
1903
+ }
1860
1904
  if (ev.type === "key" && ev.name === "enter") {
1861
1905
  const v = this.input.value;
1862
1906
  this.app.closeOverlay();
@@ -1875,6 +1919,18 @@ export class EditPopup extends Popup {
1875
1919
  }
1876
1920
  }
1877
1921
 
1922
+ /** The wire protocols the pi-ai adapter accepts, most-reached first — the
1923
+ * same union the web settings page reads out of the namespace schema, so the
1924
+ * choices offered here cannot drift from the ones the host validates. */
1925
+ const API_PROTOCOLS = ["openai-completions", "openai-responses", "anthropic-messages"];
1926
+ /** Credential reference names must be POSIX shell identifiers. */
1927
+ const KEY_REF_OK = /^[A-Za-z_][A-Za-z0-9_]*$/;
1928
+ /** The web settings page's v1 convention: a provider route's key lives under
1929
+ * `<ROUTE_UPPER>_API_KEY`, and the profile records that as apiKeyEnv. */
1930
+ function deriveKeyRef(provider) {
1931
+ return `${provider.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY`;
1932
+ }
1933
+
1878
1934
  export class ModelPanel extends Widget {
1879
1935
  constructor(app) {
1880
1936
  super({ x: 30, y: 0, w: app.screen.w - 30, h: app.screen.h - 1 });
@@ -1886,7 +1942,7 @@ export class ModelPanel extends Widget {
1886
1942
  this.sel = 0; // list cursor (routes.length = the + 添加供应商 row)
1887
1943
  this.mode = "list"; // list | form
1888
1944
  this.formIdx = 0; // form item cursor
1889
- this.formItems = []; // {kind:"field"|"model"|"button", ...}
1945
+ this.formItems = []; // {kind:"field"|"model"|"button"|"key", ...}
1890
1946
  this.modelsSel = -1; // selected model row (shows its subfields)
1891
1947
  this.draftRoute = null; // the un-saved new provider's route (shows the rename field)
1892
1948
  this.editing = null; // { label, commit } while the inline editor is open
@@ -1897,6 +1953,8 @@ export class ModelPanel extends Widget {
1897
1953
  this.scanSel = new Set();
1898
1954
  this.scanCursor = 0;
1899
1955
  this.scanning = false;
1956
+ this.savedSnapshot = "{}"; // JSON of the last saved/loaded providers (dirty check)
1957
+ this.keyStatus = {}; // ref → {configured, writable, source} from credentials.describe
1900
1958
  const listW = 26;
1901
1959
  this.listView = new ScrollView({ x: this.x + 1, y: this.y + 1, w: listW, h: this.h - 2, showScrollbar: true });
1902
1960
  this.formView = new ScrollView({ x: this.x + listW + 1, y: this.y + 1, w: this.w - listW - 2, h: this.h - 2, showScrollbar: true });
@@ -1915,11 +1973,32 @@ export class ModelPanel extends Widget {
1915
1973
  this.revision = ns?.revision ?? 0;
1916
1974
  this.routes = Object.keys(this.providers);
1917
1975
  } catch (e) { this.app.toast(`模型配置加载失败: ${e.message}`); }
1976
+ this.savedSnapshot = JSON.stringify(this.providers);
1977
+ await this.#refreshKeys();
1918
1978
  this.loaded = true;
1919
1979
  this.modelsSel = -1;
1920
1980
  this.#rebuild();
1921
1981
  this.app.redraw();
1922
1982
  }
1983
+ /** One batched credentials.describe over every referenced key, exactly like
1984
+ * the web page's store join. Reads are structurally value-free: only the
1985
+ * configured/source/writable view ever reaches this panel. */
1986
+ async #refreshKeys() {
1987
+ try {
1988
+ // only well-formed references can cross the wire (the describe payload
1989
+ // validates each name); an ill-formed derived ref is skipped here and
1990
+ // reported by the row's edit guard instead
1991
+ const refs = [...new Set(this.routes.map((r) => this.#keyRef(r)).filter((ref) => KEY_REF_OK.test(ref)))];
1992
+ if (refs.length === 0) { this.keyStatus = {}; return; }
1993
+ const res = await this.app.api.call("credentials.describe", { refs });
1994
+ this.keyStatus = res?.credentials ?? {};
1995
+ } catch (e) { this.keyStatus = {}; }
1996
+ }
1997
+ /** The credential reference a profile names, or the web's derived default. */
1998
+ #keyRef(route) {
1999
+ const p = this.#profile(route);
2000
+ return (p.apiKeyEnv && p.apiKeyEnv.length > 0) ? p.apiKeyEnv : deriveKeyRef(route);
2001
+ }
1923
2002
  #route() { return this.routes[this.sel] ?? null; }
1924
2003
  #profile(route) { return route == null ? null : this.providers[route] ?? {}; }
1925
2004
  #formRows() {
@@ -1930,8 +2009,22 @@ export class ModelPanel extends Widget {
1930
2009
  // the route key only appears for a brand-new draft (rename once)
1931
2010
  if (this.draftRoute === route) items.push({ kind: "field", key: "route", label: "路由名", value: route });
1932
2011
  items.push({ kind: "field", key: "displayName", label: "显示名", value: p.displayName ?? "" });
2012
+ // the api protocol is a CHOICE in the web UI (a select over the namespace
2013
+ // schema's union), so here Tab cycles the options in the form and Enter
2014
+ // opens an edit buffer with every candidate shown as an autocomplete hint
2015
+ const api = p.api ?? "openai-completions";
2016
+ items.push({
2017
+ kind: "field", key: "api", label: "协议 api", value: api,
2018
+ cycle: API_PROTOCOLS.includes(api) ? API_PROTOCOLS : [api, ...API_PROTOCOLS],
2019
+ completions: API_PROTOCOLS, note: "Tab 切换 · Enter 输入",
2020
+ });
1933
2021
  items.push({ kind: "field", key: "baseURL", label: "baseURL", value: p.baseURL ?? "" });
1934
- items.push({ kind: "field", key: "apiKeyEnv", label: "apiKeyEnv", value: p.apiKeyEnv ?? "", note: "环境变量名(密钥不落盘)" });
2022
+ // the api key: web-synced handling the stored value is NEVER shown
2023
+ // (credentials.describe is structurally value-free), only its status dot;
2024
+ // Enter opens a masked, always-empty editor and a typed key travels one
2025
+ // way through credentials.set under the profile's reference
2026
+ const keyRef = this.#keyRef(route);
2027
+ items.push({ kind: "key", key: "apiKeyEnv", label: "API 密钥", ref: keyRef, action: () => this.#editKey(route, keyRef) });
1935
2028
  // models are NOT flat here: one 模型管理 entry summarizing the first
1936
2029
  // five, which opens its own sub-buffer (scan on top, model form below)
1937
2030
  const models = p.models ?? [];
@@ -1993,6 +2086,7 @@ export class ModelPanel extends Widget {
1993
2086
  if (route == null) {
1994
2087
  formLines.push([{ t: " 左侧 ↑/↓ 选择供应商,Enter 打开编辑", fg: K.FAINT }]);
1995
2088
  formLines.push([{ t: " 把光标移到底部的“+ 添加供应商”回车即可新建", fg: K.FAINT }]);
2089
+ formLines.push([{ t: " Esc 退出供应商配置", fg: K.FAINT }]);
1996
2090
  this.formItems = [];
1997
2091
  } else if (this.scanMode) {
1998
2092
  formLines.push([{ t: ` 扫描 ${truncate(this.#profile(route).baseURL ?? "", 44)} — 空格勾选,Enter 添加,↑/↓ 移动`, fg: K.ACCENT, bold: true }]);
@@ -2020,6 +2114,12 @@ export class ModelPanel extends Widget {
2020
2114
  if (it.kind === "field") {
2021
2115
  const v = it.value === "" || it.value == null ? "(空)" : String(it.value);
2022
2116
  t = ` ${cur ? "▸" : " "} ${it.label}: ${truncate(v, w - strWidth(it.label) - 6)}${it.note ? ` [${it.note}]` : ""}`;
2117
+ } else if (it.kind === "key") {
2118
+ // status dot + reference, NEVER the value (web-synced posture)
2119
+ const st = this.keyStatus?.[it.ref];
2120
+ const status = st?.configured ? "● 已配置" : "○ 未配置";
2121
+ const ro = st && st.writable === false ? " [只读]" : "";
2122
+ t = ` ${cur ? "▸" : " "} ${it.label}: ${status}${ro} (${it.ref})`;
2023
2123
  } else if (it.kind === "model") {
2024
2124
  const extras = [it.ctx != null ? `ctx ${it.ctx}` : "", it.max != null ? `max ${it.max}` : ""].filter(Boolean).join(" ");
2025
2125
  t = ` ${cur ? "▸" : " "} 模型 ${truncate(it.id || "(未命名)", 24)} ${truncate(it.name || "", 20)} ${truncate(extras, 24)}`;
@@ -2032,7 +2132,9 @@ export class ModelPanel extends Widget {
2032
2132
  formLines.push([{ t: ` ${truncate(it.sub, w - 8)}`, fg: K.FAINT, bg: T.BG2 }]);
2033
2133
  }
2034
2134
  }
2035
- formLines.push([{ t: isSub ? " ↑/↓ 移动 · Enter 编辑或执行 · Esc 返回" : " ↑/↓ 移动 · → 进入选项 · ← 返回列表 · Enter 编辑或执行 · Esc 退出", fg: K.FAINT }]);
2135
+ formLines.push([{ t: isSub
2136
+ ? " ↑/↓ 移动 · Enter 编辑或执行 · Esc 返回供应商"
2137
+ : " ↑/↓ 移动 · → 进入选项 · ← 返回列表 · Enter 编辑或执行 · Tab 切换选项 · Esc 返回列表", fg: K.FAINT }]);
2036
2138
  }
2037
2139
  this.formView.setLines(formLines);
2038
2140
  }
@@ -2044,34 +2146,88 @@ export class ModelPanel extends Widget {
2044
2146
  this.listView.render(screen);
2045
2147
  this.formView.render(screen);
2046
2148
  }
2047
- #startEdit(label, value, commit) {
2149
+ #startEdit(label, value, commit, completions) {
2048
2150
  // a REAL standalone edit buffer in the middle of the window: own caret,
2049
2151
  // isolated from NORMAL/INSERT routing, paste supported
2050
2152
  const popup = new EditPopup(this.app, {
2051
2153
  title: `编辑 ${label}`,
2052
2154
  value,
2155
+ completions,
2053
2156
  onCommit: (text) => { commit(text); this.#rebuild(); this.app.redraw(); },
2054
2157
  });
2055
2158
  this.app.overlay = popup;
2056
2159
  this.app.focus(popup.input);
2057
2160
  this.app.redraw();
2058
2161
  }
2059
- #activateItem() {
2060
- if (this.mode === "list") {
2061
- if (this.sel === this.routes.length) {
2062
- // 添加供应商
2063
- let name = "新供应商", i = 2;
2064
- while (this.providers[name] !== undefined) name = `新供应商${i++}`;
2065
- this.providers[name] = { displayName: "", api: "openai-completions", baseURL: "", apiKeyEnv: "", models: [] };
2066
- this.draftRoute = name;
2067
- this.routes = Object.keys(this.providers);
2068
- this.sel = this.routes.indexOf(name);
2069
- this.mode = "form";
2070
- this.formIdx = 0;
2162
+ /** The web's apiKey judgement, mirrored: empty is fine (keep), whitespace-only
2163
+ * and `NAME=value` / quoted forms fail, and the charset is printable ASCII. */
2164
+ #keyFailure(draft) {
2165
+ if (draft.length === 0) return null;
2166
+ const value = draft.trim();
2167
+ if (value.length === 0) return "密钥不能只是空白";
2168
+ if (/^[A-Z][A-Z0-9_]*=[^=]/.test(value)) return "密钥不能是 NAME=value 形式的环境变量行";
2169
+ if ((value[0] === '"' || value[0] === "'" || value[0] === "`") && value.length > 1 && value.endsWith(value[0])) return "密钥不要带引号";
2170
+ if (!/^[\x21-\x7E]+$/.test(value)) return "密钥只能包含可打印 ASCII 字符";
2171
+ return null;
2172
+ }
2173
+ /** Edit the API key value the web-synced way: a masked, always-empty editor
2174
+ * (the stored value is never read back), a non-empty commit travels one way
2175
+ * through credentials.set under the profile's reference, an empty commit
2176
+ * keeps the existing key. */
2177
+ #editKey(route, ref) {
2178
+ if (!KEY_REF_OK.test(ref)) {
2179
+ this.app.toast(`路由名 "${route}" 无法派生合法的密钥引用名,请先把路由名改成字母数字(如 my-gateway)`);
2180
+ return;
2181
+ }
2182
+ const st = this.keyStatus?.[ref];
2183
+ const popup = new EditPopup(this.app, {
2184
+ title: `设置 API 密钥 — ${ref}`,
2185
+ value: "",
2186
+ masked: true,
2187
+ statusHint: st?.configured ? "已有密钥 · 留空保持原值不变,输入新值则覆盖" : "尚未配置密钥 · 输入新值保存",
2188
+ placeholder: "输入新密钥…(留空=保持原值,Enter 确定,Esc 取消)",
2189
+ onCommit: async (text) => {
2190
+ const failure = this.#keyFailure(text);
2191
+ if (failure) { this.app.toast(failure); this.#rebuild(); this.app.redraw(); return; }
2192
+ const v = text.trim();
2193
+ if (v === "") { this.app.toast("未输入新密钥,保持原值不变"); this.#rebuild(); this.app.redraw(); return; }
2194
+ try {
2195
+ await this.app.api.call("credentials.set", { ref, value: v });
2196
+ this.app.toast(`密钥已写入 ${ref}`);
2197
+ const p = this.#profile(route);
2198
+ if (!p.apiKeyEnv) {
2199
+ // the web create flow records the derived reference in the profile;
2200
+ // persist it with the provider save
2201
+ p.apiKeyEnv = ref;
2202
+ this.app.toast(`已记录 apiKeyEnv · 点💾保存配置使供应商生效`);
2203
+ }
2204
+ await this.#refreshKeys();
2205
+ } catch (e) { this.app.toast(`密钥写入失败: ${e.message}`); }
2071
2206
  this.#rebuild();
2072
2207
  this.app.redraw();
2073
- return;
2074
- }
2208
+ },
2209
+ });
2210
+ this.app.overlay = popup;
2211
+ this.app.focus(popup.input);
2212
+ this.app.redraw();
2213
+ }
2214
+ #addProvider() {
2215
+ let name = "新供应商", i = 2;
2216
+ while (this.providers[name] !== undefined) name = `新供应商${i++}`;
2217
+ this.providers[name] = { displayName: "", api: "openai-completions", baseURL: "", apiKeyEnv: "", models: [] };
2218
+ this.draftRoute = name;
2219
+ this.routes = Object.keys(this.providers);
2220
+ this.sel = this.routes.indexOf(name);
2221
+ this.mode = "form";
2222
+ this.formIdx = 0;
2223
+ this.sub = null;
2224
+ this.modelsSel = -1;
2225
+ this.#rebuild();
2226
+ this.app.redraw();
2227
+ }
2228
+ #activateItem() {
2229
+ if (this.mode === "list") {
2230
+ if (this.sel === this.routes.length) { this.#addProvider(); return; }
2075
2231
  this.mode = "form";
2076
2232
  this.formIdx = 0;
2077
2233
  this.modelsSel = -1;
@@ -2087,14 +2243,9 @@ export class ModelPanel extends Widget {
2087
2243
  const route = this.#route();
2088
2244
  const p = this.#profile(route);
2089
2245
  if (it.kind === "field") {
2090
- if (it.cycle) {
2091
- // cycle through the allowed api values
2092
- const next = it.cycle[(it.cycle.indexOf(it.value) + 1) % it.cycle.length];
2093
- p[it.key] = next;
2094
- this.#rebuild();
2095
- this.app.redraw();
2096
- return;
2097
- }
2246
+ // Enter always opens the standalone edit buffer; Tab (handled in onKey)
2247
+ // cycles a field that declares cycle options, and the buffer itself
2248
+ // offers every completion as an autocomplete hint
2098
2249
  this.#startEdit(it.label, it.value, (text) => {
2099
2250
  if (it.key === "route") {
2100
2251
  // renaming the route key
@@ -2115,9 +2266,12 @@ export class ModelPanel extends Widget {
2115
2266
  const [, mi, field] = it.key.split(".");
2116
2267
  p.models[Number(mi)][field] = text;
2117
2268
  } else {
2269
+ if (it.key === "api" && !API_PROTOCOLS.includes(text.trim())) {
2270
+ this.app.toast(`注意:${text.trim() || "空"} 不是已知协议,保存时可能被拒绝`);
2271
+ }
2118
2272
  p[it.key] = text;
2119
2273
  }
2120
- });
2274
+ }, it.completions);
2121
2275
  return;
2122
2276
  }
2123
2277
  if (it.kind === "model") {
@@ -2126,7 +2280,7 @@ export class ModelPanel extends Widget {
2126
2280
  this.app.redraw();
2127
2281
  return;
2128
2282
  }
2129
- if (it.kind === "button") {
2283
+ if (it.kind === "button" || it.kind === "key") {
2130
2284
  it.action();
2131
2285
  this.app.redraw();
2132
2286
  return;
@@ -2172,8 +2326,58 @@ export class ModelPanel extends Widget {
2172
2326
  });
2173
2327
  this.revision = res?.revision ?? this.revision;
2174
2328
  this.draftRoute = null;
2329
+ this.savedSnapshot = JSON.stringify(this.providers);
2175
2330
  this.app.toast(`已保存 ${Object.keys(this.providers).length} 个供应商`);
2176
- } catch (e) { this.app.toast(`保存失败: ${e.message}`); }
2331
+ return true;
2332
+ } catch (e) { this.app.toast(`保存失败: ${e.message}`); return false; }
2333
+ }
2334
+ #dirty() { return JSON.stringify(this.providers) !== this.savedSnapshot; }
2335
+ /** Throw away the in-memory edits and restore the last saved/loaded state. */
2336
+ #discard() {
2337
+ this.providers = JSON.parse(this.savedSnapshot);
2338
+ this.routes = Object.keys(this.providers);
2339
+ this.draftRoute = null;
2340
+ this.modelsSel = -1;
2341
+ this.sub = null;
2342
+ this.sel = Math.min(this.sel, this.routes.length - 1);
2343
+ this.#rebuild();
2344
+ this.app.redraw();
2345
+ }
2346
+ /** Leave the provider form for another level. With unsaved changes this asks
2347
+ * 保存/不保存/取消 first; a failed save keeps the user on the form. */
2348
+ #leaveForm(after) {
2349
+ if (!this.#dirty()) { after(); return; }
2350
+ const w = Math.min(64, this.app.screen.w - 8);
2351
+ const popup = new Popup({
2352
+ x: Math.floor((this.app.screen.w - w) / 2), y: Math.floor((this.app.screen.h - 10) / 2),
2353
+ w, h: 10, title: "未保存的修改",
2354
+ lines: [
2355
+ [{ t: " 供应商配置有未保存的修改。", fg: K.TXT }],
2356
+ [{ t: " 返回供应商选择之前,要保存吗?", fg: K.TXT }],
2357
+ ],
2358
+ buttons: [
2359
+ { label: "💾 保存并返回", action: "save" },
2360
+ { label: "不保存", action: "discard" },
2361
+ { label: "取消", action: "cancel" },
2362
+ ],
2363
+ onAction: async (btn) => {
2364
+ this.app.closeOverlay();
2365
+ this.app.focus(this.app.chat);
2366
+ if (btn?.action === "cancel") return; // stay on the form
2367
+ if (btn?.action === "save") {
2368
+ const ok = await this.#save();
2369
+ if (!ok) return; // save failed: stay (toast shown)
2370
+ } else if (btn?.action === "discard") {
2371
+ this.#discard();
2372
+ } else {
2373
+ return; // Esc = __cancel__
2374
+ }
2375
+ after();
2376
+ },
2377
+ });
2378
+ this.app.overlay = popup;
2379
+ this.app.focus(popup);
2380
+ this.app.redraw();
2177
2381
  }
2178
2382
  async #deleteProvider() {
2179
2383
  const route = this.#route();
@@ -2288,7 +2492,16 @@ export class ModelPanel extends Widget {
2288
2492
  if (ev.name === "enter") { this.#activateItem(); return true; }
2289
2493
  return false;
2290
2494
  }
2291
- if (ev.name === "escape") { return false; } // App falls back to chat mode
2495
+ // Esc returns ONLY to the upper window, level by level: scan the
2496
+ // 模型管理 sub-buffer → the provider form → the provider list; from the
2497
+ // list it exits the page. Leaving the form with unsaved edits asks first.
2498
+ if (ev.name === "escape") {
2499
+ if (this.mode === "form") {
2500
+ this.#leaveForm(() => { this.mode = "list"; this.sub = null; this.#rebuild(); this.app.redraw(); });
2501
+ return true;
2502
+ }
2503
+ return false; // list level: App falls back to the upper window (chat/settings)
2504
+ }
2292
2505
  // dual-focus navigation: ↑/↓ move the cursor INSIDE the focused region —
2293
2506
  // the provider column in list focus, the option rows in form focus.
2294
2507
  // → enters the form, ← returns to the list.
@@ -2306,13 +2519,31 @@ export class ModelPanel extends Widget {
2306
2519
  this.app.redraw();
2307
2520
  return true;
2308
2521
  }
2522
+ // Tab in the form cycles a field that declares cycle options (the api
2523
+ // protocol: tab-selection like the web's <select>); on any other field it
2524
+ // walks to the next form item. Otherwise (list focus) it behaves like →.
2525
+ if (ev.name === "tab" && this.mode === "form" && this.sub == null) {
2526
+ const it = this.formItems[this.formIdx];
2527
+ if (it?.cycle?.length) {
2528
+ const cur = String(it.value ?? "");
2529
+ const idx = it.cycle.indexOf(cur);
2530
+ this.#profile(this.#route())[it.key] = it.cycle[(idx + 1) % it.cycle.length];
2531
+ } else if (this.formItems.length > 0) {
2532
+ this.formIdx = Math.min(this.formItems.length - 1, this.formIdx + 1);
2533
+ }
2534
+ this.#rebuild();
2535
+ this.app.redraw();
2536
+ return true;
2537
+ }
2309
2538
  if (ev.name === "right" || (ev.name === "char" && ev.key === "l" && !ev.ctrl) || ev.name === "tab") {
2310
2539
  if (this.#route() != null && this.mode !== "form") { this.mode = "form"; this.#rebuild(); }
2311
2540
  this.app.redraw();
2312
2541
  return true;
2313
2542
  }
2314
2543
  if (ev.name === "left" || (ev.name === "char" && ev.key === "h" && !ev.ctrl) || ev.name === "backtab") {
2315
- if (this.mode === "form") { this.mode = "list"; this.#rebuild(); }
2544
+ if (this.mode === "form") {
2545
+ this.#leaveForm(() => { this.mode = "list"; this.sub = null; this.#rebuild(); this.app.redraw(); });
2546
+ }
2316
2547
  this.app.redraw();
2317
2548
  return true;
2318
2549
  }
@@ -2325,8 +2556,30 @@ export class ModelPanel extends Widget {
2325
2556
  if (ev.kind !== "press" || ev.button !== 0) return false;
2326
2557
  if (ev.x < this.x + 26) {
2327
2558
  const idx = ev.y - this.listView.y + this.listView.scrollY;
2328
- // one click = select AND open (same as Enter)
2329
- if (idx >= 0 && idx <= this.routes.length) { this.sel = idx; this.#activateItem(); return true; }
2559
+ if (idx >= 0 && idx <= this.routes.length) {
2560
+ // one click = select AND open (same as Enter). Switching away from a
2561
+ // form with unsaved edits asks first, like every other exit path.
2562
+ if (this.mode === "form") {
2563
+ if (idx === this.sel) return true; // already editing this provider
2564
+ if (idx === this.routes.length) {
2565
+ this.#leaveForm(() => this.#addProvider());
2566
+ } else {
2567
+ this.#leaveForm(() => {
2568
+ this.sel = idx;
2569
+ this.mode = "form";
2570
+ this.formIdx = 0;
2571
+ this.modelsSel = -1;
2572
+ this.sub = null;
2573
+ this.#rebuild();
2574
+ this.app.redraw();
2575
+ });
2576
+ }
2577
+ return true;
2578
+ }
2579
+ this.sel = idx;
2580
+ this.#activateItem();
2581
+ return true;
2582
+ }
2330
2583
  return false;
2331
2584
  }
2332
2585
  if (this.scanMode) {
package/src/widgets.js CHANGED
@@ -268,6 +268,7 @@ export class Input extends Widget {
268
268
  this.allowEmptyEnter = opts.allowEmptyEnter ?? false;
269
269
  this.history = [];
270
270
  this.histIdx = -1;
271
+ this.masked = opts.masked ?? false; // secret fields render •••• instead of the value
271
272
  }
272
273
  #cps() { return Array.from(this.value); } // code points
273
274
  /** Visual rows for multi-line input: logical lines wrapped at the width. */
@@ -420,7 +421,8 @@ export class Input extends Widget {
420
421
  let start = 0;
421
422
  while (cx - start >= inner) start += Math.max(1, Math.floor(inner / 2));
422
423
  const visible = truncate(Array.from(this.value).slice(start).join(""), inner);
423
- screen.text(this.x + promptW, this.y, visible, { fg: this.fg, bg: this.bg });
424
+ const drawn = this.masked ? "•".repeat(Array.from(visible).length) : visible;
425
+ screen.text(this.x + promptW, this.y, drawn, { fg: this.fg, bg: this.bg });
424
426
  this.cursorCell = { x: this.x + promptW + Math.min(inner, Math.max(0, cx - start)), y: this.y };
425
427
  return;
426
428
  }
@@ -439,11 +441,12 @@ export class Input extends Widget {
439
441
  for (let ri = start; ri < Math.min(rows.length, start + h); ri++) {
440
442
  const r = rows[ri];
441
443
  const y = this.y + (ri - start);
444
+ const drawn = this.masked ? "•".repeat(Array.from(r.text).length) : r.text;
442
445
  if (ri === 0) {
443
446
  screen.text(this.x, y, this.prompt, { fg: T.ACCENT, bg: this.bg });
444
- screen.text(this.x + strWidth(this.prompt), y, r.text, { fg: this.fg, bg: this.bg });
447
+ screen.text(this.x + strWidth(this.prompt), y, drawn, { fg: this.fg, bg: this.bg });
445
448
  } else {
446
- screen.text(this.x + 1, y, r.text, { fg: this.fg, bg: this.bg });
449
+ screen.text(this.x + 1, y, drawn, { fg: this.fg, bg: this.bg });
447
450
  }
448
451
  }
449
452
  // drag-selection highlight: invert the selected columns per wrapped row