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 +1 -1
- package/src/panels.js +294 -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,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:
|
|
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
|
|
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: "
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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") {
|
|
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
|
-
|
|
2329
|
-
|
|
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
|
-
|
|
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
|