dsh-neotui 0.1.29 → 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 +1 -1
- package/src/panels.js +291 -41
- package/src/widgets.js +6 -3
package/package.json
CHANGED
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,10 +1834,15 @@ 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:
|
|
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
1847
|
// the cursor starts at the END of the existing value: typing appends and
|
|
1843
1848
|
// edits in place instead of wiping the original (modify, not replace)
|
|
@@ -1846,10 +1851,30 @@ export class EditPopup extends Popup {
|
|
|
1846
1851
|
}
|
|
1847
1852
|
#layout() {
|
|
1848
1853
|
const lines = [];
|
|
1849
|
-
lines.push([{ t: "
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
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
|
+
}
|
|
1853
1878
|
lines.push([{ t: "" }]);
|
|
1854
1879
|
this.lines = lines;
|
|
1855
1880
|
}
|
|
@@ -1859,6 +1884,23 @@ export class EditPopup extends Popup {
|
|
|
1859
1884
|
}
|
|
1860
1885
|
onKey(ev) {
|
|
1861
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
|
+
}
|
|
1862
1904
|
if (ev.type === "key" && ev.name === "enter") {
|
|
1863
1905
|
const v = this.input.value;
|
|
1864
1906
|
this.app.closeOverlay();
|
|
@@ -1877,6 +1919,18 @@ export class EditPopup extends Popup {
|
|
|
1877
1919
|
}
|
|
1878
1920
|
}
|
|
1879
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
|
+
|
|
1880
1934
|
export class ModelPanel extends Widget {
|
|
1881
1935
|
constructor(app) {
|
|
1882
1936
|
super({ x: 30, y: 0, w: app.screen.w - 30, h: app.screen.h - 1 });
|
|
@@ -1888,7 +1942,7 @@ export class ModelPanel extends Widget {
|
|
|
1888
1942
|
this.sel = 0; // list cursor (routes.length = the + 添加供应商 row)
|
|
1889
1943
|
this.mode = "list"; // list | form
|
|
1890
1944
|
this.formIdx = 0; // form item cursor
|
|
1891
|
-
this.formItems = []; // {kind:"field"|"model"|"button", ...}
|
|
1945
|
+
this.formItems = []; // {kind:"field"|"model"|"button"|"key", ...}
|
|
1892
1946
|
this.modelsSel = -1; // selected model row (shows its subfields)
|
|
1893
1947
|
this.draftRoute = null; // the un-saved new provider's route (shows the rename field)
|
|
1894
1948
|
this.editing = null; // { label, commit } while the inline editor is open
|
|
@@ -1899,6 +1953,8 @@ export class ModelPanel extends Widget {
|
|
|
1899
1953
|
this.scanSel = new Set();
|
|
1900
1954
|
this.scanCursor = 0;
|
|
1901
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
|
|
1902
1958
|
const listW = 26;
|
|
1903
1959
|
this.listView = new ScrollView({ x: this.x + 1, y: this.y + 1, w: listW, h: this.h - 2, showScrollbar: true });
|
|
1904
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 });
|
|
@@ -1917,11 +1973,32 @@ export class ModelPanel extends Widget {
|
|
|
1917
1973
|
this.revision = ns?.revision ?? 0;
|
|
1918
1974
|
this.routes = Object.keys(this.providers);
|
|
1919
1975
|
} catch (e) { this.app.toast(`模型配置加载失败: ${e.message}`); }
|
|
1976
|
+
this.savedSnapshot = JSON.stringify(this.providers);
|
|
1977
|
+
await this.#refreshKeys();
|
|
1920
1978
|
this.loaded = true;
|
|
1921
1979
|
this.modelsSel = -1;
|
|
1922
1980
|
this.#rebuild();
|
|
1923
1981
|
this.app.redraw();
|
|
1924
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
|
+
}
|
|
1925
2002
|
#route() { return this.routes[this.sel] ?? null; }
|
|
1926
2003
|
#profile(route) { return route == null ? null : this.providers[route] ?? {}; }
|
|
1927
2004
|
#formRows() {
|
|
@@ -1932,9 +2009,22 @@ export class ModelPanel extends Widget {
|
|
|
1932
2009
|
// the route key only appears for a brand-new draft (rename once)
|
|
1933
2010
|
if (this.draftRoute === route) items.push({ kind: "field", key: "route", label: "路由名", value: route });
|
|
1934
2011
|
items.push({ kind: "field", key: "displayName", label: "显示名", value: p.displayName ?? "" });
|
|
1935
|
-
|
|
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
|
+
});
|
|
1936
2021
|
items.push({ kind: "field", key: "baseURL", label: "baseURL", value: p.baseURL ?? "" });
|
|
1937
|
-
|
|
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) });
|
|
1938
2028
|
// models are NOT flat here: one 模型管理 entry summarizing the first
|
|
1939
2029
|
// five, which opens its own sub-buffer (scan on top, model form below)
|
|
1940
2030
|
const models = p.models ?? [];
|
|
@@ -1996,6 +2086,7 @@ export class ModelPanel extends Widget {
|
|
|
1996
2086
|
if (route == null) {
|
|
1997
2087
|
formLines.push([{ t: " 左侧 ↑/↓ 选择供应商,Enter 打开编辑", fg: K.FAINT }]);
|
|
1998
2088
|
formLines.push([{ t: " 把光标移到底部的“+ 添加供应商”回车即可新建", fg: K.FAINT }]);
|
|
2089
|
+
formLines.push([{ t: " Esc 退出供应商配置", fg: K.FAINT }]);
|
|
1999
2090
|
this.formItems = [];
|
|
2000
2091
|
} else if (this.scanMode) {
|
|
2001
2092
|
formLines.push([{ t: ` 扫描 ${truncate(this.#profile(route).baseURL ?? "", 44)} — 空格勾选,Enter 添加,↑/↓ 移动`, fg: K.ACCENT, bold: true }]);
|
|
@@ -2023,6 +2114,12 @@ export class ModelPanel extends Widget {
|
|
|
2023
2114
|
if (it.kind === "field") {
|
|
2024
2115
|
const v = it.value === "" || it.value == null ? "(空)" : String(it.value);
|
|
2025
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})`;
|
|
2026
2123
|
} else if (it.kind === "model") {
|
|
2027
2124
|
const extras = [it.ctx != null ? `ctx ${it.ctx}` : "", it.max != null ? `max ${it.max}` : ""].filter(Boolean).join(" ");
|
|
2028
2125
|
t = ` ${cur ? "▸" : " "} 模型 ${truncate(it.id || "(未命名)", 24)} ${truncate(it.name || "", 20)} ${truncate(extras, 24)}`;
|
|
@@ -2035,7 +2132,9 @@ export class ModelPanel extends Widget {
|
|
|
2035
2132
|
formLines.push([{ t: ` ${truncate(it.sub, w - 8)}`, fg: K.FAINT, bg: T.BG2 }]);
|
|
2036
2133
|
}
|
|
2037
2134
|
}
|
|
2038
|
-
formLines.push([{ t: isSub
|
|
2135
|
+
formLines.push([{ t: isSub
|
|
2136
|
+
? " ↑/↓ 移动 · Enter 编辑或执行 · Esc 返回供应商"
|
|
2137
|
+
: " ↑/↓ 移动 · → 进入选项 · ← 返回列表 · Enter 编辑或执行 · Tab 切换选项 · Esc 返回列表", fg: K.FAINT }]);
|
|
2039
2138
|
}
|
|
2040
2139
|
this.formView.setLines(formLines);
|
|
2041
2140
|
}
|
|
@@ -2047,34 +2146,88 @@ export class ModelPanel extends Widget {
|
|
|
2047
2146
|
this.listView.render(screen);
|
|
2048
2147
|
this.formView.render(screen);
|
|
2049
2148
|
}
|
|
2050
|
-
#startEdit(label, value, commit) {
|
|
2149
|
+
#startEdit(label, value, commit, completions) {
|
|
2051
2150
|
// a REAL standalone edit buffer in the middle of the window: own caret,
|
|
2052
2151
|
// isolated from NORMAL/INSERT routing, paste supported
|
|
2053
2152
|
const popup = new EditPopup(this.app, {
|
|
2054
2153
|
title: `编辑 ${label}`,
|
|
2055
2154
|
value,
|
|
2155
|
+
completions,
|
|
2056
2156
|
onCommit: (text) => { commit(text); this.#rebuild(); this.app.redraw(); },
|
|
2057
2157
|
});
|
|
2058
2158
|
this.app.overlay = popup;
|
|
2059
2159
|
this.app.focus(popup.input);
|
|
2060
2160
|
this.app.redraw();
|
|
2061
2161
|
}
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
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}`); }
|
|
2074
2206
|
this.#rebuild();
|
|
2075
2207
|
this.app.redraw();
|
|
2076
|
-
|
|
2077
|
-
|
|
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; }
|
|
2078
2231
|
this.mode = "form";
|
|
2079
2232
|
this.formIdx = 0;
|
|
2080
2233
|
this.modelsSel = -1;
|
|
@@ -2090,14 +2243,9 @@ export class ModelPanel extends Widget {
|
|
|
2090
2243
|
const route = this.#route();
|
|
2091
2244
|
const p = this.#profile(route);
|
|
2092
2245
|
if (it.kind === "field") {
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
p[it.key] = next;
|
|
2097
|
-
this.#rebuild();
|
|
2098
|
-
this.app.redraw();
|
|
2099
|
-
return;
|
|
2100
|
-
}
|
|
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
|
|
2101
2249
|
this.#startEdit(it.label, it.value, (text) => {
|
|
2102
2250
|
if (it.key === "route") {
|
|
2103
2251
|
// renaming the route key
|
|
@@ -2118,9 +2266,12 @@ export class ModelPanel extends Widget {
|
|
|
2118
2266
|
const [, mi, field] = it.key.split(".");
|
|
2119
2267
|
p.models[Number(mi)][field] = text;
|
|
2120
2268
|
} else {
|
|
2269
|
+
if (it.key === "api" && !API_PROTOCOLS.includes(text.trim())) {
|
|
2270
|
+
this.app.toast(`注意:${text.trim() || "空"} 不是已知协议,保存时可能被拒绝`);
|
|
2271
|
+
}
|
|
2121
2272
|
p[it.key] = text;
|
|
2122
2273
|
}
|
|
2123
|
-
});
|
|
2274
|
+
}, it.completions);
|
|
2124
2275
|
return;
|
|
2125
2276
|
}
|
|
2126
2277
|
if (it.kind === "model") {
|
|
@@ -2129,7 +2280,7 @@ export class ModelPanel extends Widget {
|
|
|
2129
2280
|
this.app.redraw();
|
|
2130
2281
|
return;
|
|
2131
2282
|
}
|
|
2132
|
-
if (it.kind === "button") {
|
|
2283
|
+
if (it.kind === "button" || it.kind === "key") {
|
|
2133
2284
|
it.action();
|
|
2134
2285
|
this.app.redraw();
|
|
2135
2286
|
return;
|
|
@@ -2175,8 +2326,58 @@ export class ModelPanel extends Widget {
|
|
|
2175
2326
|
});
|
|
2176
2327
|
this.revision = res?.revision ?? this.revision;
|
|
2177
2328
|
this.draftRoute = null;
|
|
2329
|
+
this.savedSnapshot = JSON.stringify(this.providers);
|
|
2178
2330
|
this.app.toast(`已保存 ${Object.keys(this.providers).length} 个供应商`);
|
|
2179
|
-
|
|
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();
|
|
2180
2381
|
}
|
|
2181
2382
|
async #deleteProvider() {
|
|
2182
2383
|
const route = this.#route();
|
|
@@ -2291,7 +2492,16 @@ export class ModelPanel extends Widget {
|
|
|
2291
2492
|
if (ev.name === "enter") { this.#activateItem(); return true; }
|
|
2292
2493
|
return false;
|
|
2293
2494
|
}
|
|
2294
|
-
|
|
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
|
+
}
|
|
2295
2505
|
// dual-focus navigation: ↑/↓ move the cursor INSIDE the focused region —
|
|
2296
2506
|
// the provider column in list focus, the option rows in form focus.
|
|
2297
2507
|
// → enters the form, ← returns to the list.
|
|
@@ -2309,13 +2519,31 @@ export class ModelPanel extends Widget {
|
|
|
2309
2519
|
this.app.redraw();
|
|
2310
2520
|
return true;
|
|
2311
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
|
+
}
|
|
2312
2538
|
if (ev.name === "right" || (ev.name === "char" && ev.key === "l" && !ev.ctrl) || ev.name === "tab") {
|
|
2313
2539
|
if (this.#route() != null && this.mode !== "form") { this.mode = "form"; this.#rebuild(); }
|
|
2314
2540
|
this.app.redraw();
|
|
2315
2541
|
return true;
|
|
2316
2542
|
}
|
|
2317
2543
|
if (ev.name === "left" || (ev.name === "char" && ev.key === "h" && !ev.ctrl) || ev.name === "backtab") {
|
|
2318
|
-
if (this.mode === "form") {
|
|
2544
|
+
if (this.mode === "form") {
|
|
2545
|
+
this.#leaveForm(() => { this.mode = "list"; this.sub = null; this.#rebuild(); this.app.redraw(); });
|
|
2546
|
+
}
|
|
2319
2547
|
this.app.redraw();
|
|
2320
2548
|
return true;
|
|
2321
2549
|
}
|
|
@@ -2328,8 +2556,30 @@ export class ModelPanel extends Widget {
|
|
|
2328
2556
|
if (ev.kind !== "press" || ev.button !== 0) return false;
|
|
2329
2557
|
if (ev.x < this.x + 26) {
|
|
2330
2558
|
const idx = ev.y - this.listView.y + this.listView.scrollY;
|
|
2331
|
-
|
|
2332
|
-
|
|
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
|
+
}
|
|
2333
2583
|
return false;
|
|
2334
2584
|
}
|
|
2335
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
|
-
|
|
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,
|
|
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,
|
|
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
|