dsh-neotui 0.1.21 → 0.1.22

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.21",
3
+ "version": "0.1.22",
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
@@ -111,6 +111,11 @@ export class Picker extends Widget {
111
111
 
112
112
  export function buildModelPicker(app) {
113
113
  const w = Math.min(70, app.screen.w - 4), h = Math.min(24, app.screen.h - 4);
114
+ const manageProviders = {
115
+ label: "⚙ 管理供应商…(填表式)", hint: "M",
116
+ provider: null, model: null,
117
+ action: () => { app.overlay = null; app.setMode("models"); },
118
+ };
114
119
  const selectModel = async (it) => {
115
120
  app.overlay = null; app.redraw();
116
121
  if (!app.currentSession) { app.toast("先打开一个会话"); return; }
@@ -126,6 +131,7 @@ export function buildModelPicker(app) {
126
131
  items: [],
127
132
  onCancel: () => { app.overlay = null; app.redraw(); },
128
133
  onPick: (it) => {
134
+ if (it === manageProviders) { manageProviders.action(); return; }
129
135
  const efforts = it.efforts ?? [];
130
136
  if (efforts.length > 0) {
131
137
  // second step: reasoning effort
@@ -160,7 +166,7 @@ export function buildModelPicker(app) {
160
166
  });
161
167
  }
162
168
  }
163
- picker.items = items;
169
+ picker.items = [manageProviders, ...items];
164
170
  app.redraw();
165
171
  }).catch((e) => app.toast(`模型列表失败: ${e.message}`));
166
172
  return picker;
@@ -1634,6 +1640,11 @@ export class SettingsPanel extends Widget {
1634
1640
  ns: "默认展开/折叠", applies: "live", local: true,
1635
1641
  value: { 思考块默认展开: fd.think, 工具块默认展开: fd.bash, 任务清单默认显示: fd.todos },
1636
1642
  });
1643
+ // the model provider manager opens its own big form buffer (CC Switch 式)
1644
+ this.namespaces.splice(2, 0, {
1645
+ ns: "模型供应商…(填表式)", applies: "live", local: true, modelsEntry: true,
1646
+ value: {},
1647
+ });
1637
1648
  this.selectNs(0);
1638
1649
  }
1639
1650
  selectNs(i) {
@@ -1641,6 +1652,7 @@ export class SettingsPanel extends Widget {
1641
1652
  this.pendingOps = [];
1642
1653
  this.editing = false;
1643
1654
  const ns = this.namespaces[this.nsIdx];
1655
+ if (ns.modelsEntry) { this.app.setMode("models"); return; }
1644
1656
  this.secrets = new Set((ns.secrets ?? []).map((s) => JSON.stringify(s.path ?? [])));
1645
1657
  this.rebuildRows();
1646
1658
  const items = this.namespaces.map((n) => ({
@@ -1804,6 +1816,425 @@ export class SettingsPanel extends Widget {
1804
1816
  }
1805
1817
  }
1806
1818
 
1819
+ // ---- Model provider management (CC Switch-style big form buffer) ----
1820
+
1821
+ const MODEL_APIS = ["openai-completions", "anthropic-messages", "google-genai"];
1822
+
1823
+ export class ModelPanel extends Widget {
1824
+ constructor(app) {
1825
+ super({ x: 30, y: 0, w: app.screen.w - 30, h: app.screen.h - 1 });
1826
+ this.app = app;
1827
+ this.providers = {}; // route → profile (mirror of settings llm-pi-ai.providers)
1828
+ this.revision = 0;
1829
+ this.loaded = false;
1830
+ this.routes = [];
1831
+ this.sel = 0; // list cursor (routes.length = the + 添加供应商 row)
1832
+ this.mode = "list"; // list | form
1833
+ this.formIdx = 0; // form item cursor
1834
+ this.formItems = []; // {kind:"field"|"model"|"button", ...}
1835
+ this.modelsSel = -1; // selected model row (shows its subfields)
1836
+ this.editing = null; // { label, commit } while the inline editor is open
1837
+ this.scanMode = false;
1838
+ this.scanItems = [];
1839
+ this.scanSel = new Set();
1840
+ this.scanCursor = 0;
1841
+ this.scanning = false;
1842
+ const listW = 26;
1843
+ this.listView = new ScrollView({ x: this.x + 1, y: this.y + 1, w: listW, h: this.h - 2, showScrollbar: true });
1844
+ this.formView = new ScrollView({ x: this.x + listW + 1, y: this.y + 1, w: this.w - listW - 2, h: this.h - 3, showScrollbar: true });
1845
+ this.input = new Input({ x: this.x + listW + 1, y: this.y + this.h - 2, w: this.w - listW - 2, h: 1, prompt: "值: ", placeholder: "Enter 提交 · Esc 取消" });
1846
+ }
1847
+ relayout(x, y, w, h) {
1848
+ this.x = x; this.y = y; this.w = w; this.h = h;
1849
+ const listW = 26;
1850
+ this.listView.x = x + 1; this.listView.y = y + 1; this.listView.w = listW; this.listView.h = h - 2;
1851
+ this.formView.x = x + listW + 1; this.formView.y = y + 1; this.formView.w = w - listW - 2; this.formView.h = h - 3;
1852
+ this.input.x = x + listW + 1; this.input.y = y + h - 2; this.input.w = w - listW - 2;
1853
+ }
1854
+ async load() {
1855
+ try {
1856
+ const d = await this.app.api.call("settings.describe");
1857
+ const ns = (d.namespaces ?? []).find((n) => n.ns === "llm-pi-ai");
1858
+ this.providers = { ...(ns?.value?.providers ?? {}) };
1859
+ this.revision = ns?.revision ?? 0;
1860
+ this.routes = Object.keys(this.providers);
1861
+ } catch (e) { this.app.toast(`模型配置加载失败: ${e.message}`); }
1862
+ this.loaded = true;
1863
+ this.modelsSel = -1;
1864
+ this.#rebuild();
1865
+ this.app.redraw();
1866
+ }
1867
+ #route() { return this.routes[this.sel] ?? null; }
1868
+ #profile(route) { return route == null ? null : this.providers[route] ?? {}; }
1869
+ #formRows() {
1870
+ const route = this.#route();
1871
+ if (route == null) return [];
1872
+ const p = this.#profile(route);
1873
+ const items = [];
1874
+ items.push({ kind: "field", key: "route", label: "路由名", value: route, editable: !(route in this.providers && this.routes.includes(route)) });
1875
+ items.push({ kind: "field", key: "displayName", label: "显示名", value: p.displayName ?? "" });
1876
+ items.push({ kind: "field", key: "api", label: "协议 api", value: p.api ?? "openai-completions", cycle: MODEL_APIS });
1877
+ items.push({ kind: "field", key: "baseURL", label: "baseURL", value: p.baseURL ?? "" });
1878
+ items.push({ kind: "field", key: "apiKeyEnv", label: "apiKeyEnv", value: p.apiKeyEnv ?? "", note: "环境变量名(密钥不落盘)" });
1879
+ const models = p.models ?? [];
1880
+ for (let mi = 0; mi < models.length; mi++) {
1881
+ const m = models[mi];
1882
+ items.push({ kind: "model", idx: mi, id: m.id ?? "", name: m.name ?? "", ctx: m.contextWindow ?? null, max: m.maxTokens ?? null });
1883
+ if (this.modelsSel === mi) {
1884
+ items.push({ kind: "field", key: `model.${mi}.id`, label: " 模型 id", value: m.id ?? "" });
1885
+ items.push({ kind: "field", key: `model.${mi}.name`, label: " 模型名", value: m.name ?? "" });
1886
+ items.push({ kind: "field", key: `model.${mi}.contextWindow`, label: " 上下文窗口", value: m.contextWindow ?? "", numeric: true });
1887
+ items.push({ kind: "field", key: `model.${mi}.maxTokens`, label: " 最大输出", value: m.maxTokens ?? "", numeric: true });
1888
+ }
1889
+ }
1890
+ items.push({ kind: "button", label: "+ 添加模型", action: () => this.#addModel() });
1891
+ items.push({ kind: "button", label: "🗑 删除选中模型", action: () => this.#deleteModel() });
1892
+ items.push({ kind: "button", label: "🔄 自动扫描模型(读 /models 端点)", action: () => this.#scan() });
1893
+ items.push({ kind: "button", label: "◉ 设为当前会话模型(选中模型)", action: () => this.#setDefaultModel() });
1894
+ items.push({ kind: "button", label: "💾 保存配置", action: () => this.#save() });
1895
+ items.push({ kind: "button", label: "🗑 删除供应商", action: () => this.#deleteProvider() });
1896
+ return items;
1897
+ }
1898
+ #rebuild() {
1899
+ // left list
1900
+ const listLines = [];
1901
+ for (let i = 0; i < this.routes.length; i++) {
1902
+ const r = this.routes[i];
1903
+ const p = this.providers[r] ?? {};
1904
+ const sel = i === this.sel && this.mode === "list";
1905
+ listLines.push([{ t: ` ${sel ? "▸" : " "} ${truncate(p.displayName || r, 18)}`, fg: sel ? T.SELFG : T.TXT, bg: sel ? T.MENUSEL : T.BG2, bold: sel }]);
1906
+ }
1907
+ const addSel = this.sel === this.routes.length && this.mode === "list";
1908
+ listLines.push([{ t: ` ${addSel ? "▸" : " "} + 添加供应商`, fg: addSel ? T.SELFG : T.ACCENT, bg: addSel ? T.MENUSEL : T.BG2, bold: true }]);
1909
+ this.listView.setLines(listLines);
1910
+ // right form
1911
+ const route = this.#route();
1912
+ const formLines = [];
1913
+ if (route == null) {
1914
+ formLines.push([{ t: " 选择左侧供应商,或“+ 添加供应商”新建(CC Switch 式填表)", fg: K.FAINT }]);
1915
+ this.formItems = [];
1916
+ } else if (this.scanMode) {
1917
+ formLines.push([{ t: ` 扫描 ${truncate(this.#profile(route).baseURL ?? "", 44)} — 空格勾选,Enter 添加,↑/↓ 移动`, fg: K.ACCENT, bold: true }]);
1918
+ if (this.scanning) formLines.push([{ t: " 扫描中…", fg: K.WARN }]);
1919
+ for (let i = 0; i < this.scanItems.length; i++) {
1920
+ const m = this.scanItems[i];
1921
+ const on = this.scanSel.has(m.id);
1922
+ const cur = i === this.scanCursor;
1923
+ formLines.push([{ t: ` ${cur ? "▸" : " "} [${on ? "x" : " "}] ${truncate(m.id, this.formView.w - 10)}`, fg: on ? K.OK : cur ? T.TXT : K.DIM, bg: cur ? T.MENUSEL : T.BG2 }]);
1924
+ }
1925
+ formLines.push([{ t: " Enter 添加选中 · Esc 取消扫描", fg: K.FAINT }]);
1926
+ this.formItems = [];
1927
+ } else {
1928
+ this.formItems = this.#formRows();
1929
+ const w = Math.max(30, this.formView.w - 4);
1930
+ for (let i = 0; i < this.formItems.length; i++) {
1931
+ const it = this.formItems[i];
1932
+ const cur = this.mode === "form" && i === this.formIdx;
1933
+ let t;
1934
+ if (it.kind === "field") {
1935
+ const v = it.value === "" || it.value == null ? "(空)" : String(it.value);
1936
+ t = ` ${cur ? "▸" : " "} ${it.label}: ${truncate(v, w - strWidth(it.label) - 6)}${it.note ? ` [${it.note}]` : ""}`;
1937
+ } else if (it.kind === "model") {
1938
+ const extras = [it.ctx != null ? `ctx ${it.ctx}` : "", it.max != null ? `max ${it.max}` : ""].filter(Boolean).join(" ");
1939
+ t = ` ${cur ? "▸" : " "} 模型 ${truncate(it.id || "(未命名)", 24)} ${truncate(it.name || "", 20)} ${truncate(extras, 24)}`;
1940
+ } else {
1941
+ t = ` ${cur ? "▸" : " "} ${it.label}`;
1942
+ }
1943
+ formLines.push([{ t: truncate(t, w), fg: cur ? T.SELFG : T.TXT, bg: cur ? T.MENUSEL : T.BG2 }]);
1944
+ }
1945
+ formLines.push([{ t: " Enter 编辑/执行 · ← 回列表 · Esc 退出面板", fg: K.FAINT }]);
1946
+ }
1947
+ this.formView.setLines(formLines);
1948
+ }
1949
+ render(screen) {
1950
+ screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", {});
1951
+ const mid = this.x + 26;
1952
+ screen.vline(mid, this.y, this.y + this.h - 1, "│", { fg: T.BORDER });
1953
+ screen.text(this.x + 1, this.y, " 模型供应商 — CC Switch 式", { fg: K.DIM });
1954
+ this.listView.render(screen);
1955
+ this.formView.render(screen);
1956
+ if (this.editing) {
1957
+ screen.text(this.x + 28, this.y + this.h - 3, `编辑 ${this.editing.label}`, { fg: K.WARN, bold: true });
1958
+ this.input.render(screen);
1959
+ }
1960
+ }
1961
+ #startEdit(label, value, commit) {
1962
+ this.editing = { label, commit };
1963
+ this.input.setValue(value ?? "", { select: true });
1964
+ this.app.redraw();
1965
+ }
1966
+ #commitEdit() {
1967
+ if (!this.editing) return;
1968
+ const { label, commit } = this.editing;
1969
+ this.editing = null;
1970
+ commit(this.input.value);
1971
+ this.#rebuild();
1972
+ this.app.redraw();
1973
+ }
1974
+ #activateItem() {
1975
+ if (this.mode === "list") {
1976
+ if (this.sel === this.routes.length) {
1977
+ // + 添加供应商
1978
+ let name = "新供应商", i = 2;
1979
+ while (this.providers[name] !== undefined) name = `新供应商${i++}`;
1980
+ this.providers[name] = { displayName: "", api: "openai-completions", baseURL: "", apiKeyEnv: "", models: [] };
1981
+ this.routes = Object.keys(this.providers);
1982
+ this.sel = this.routes.indexOf(name);
1983
+ this.mode = "form";
1984
+ this.formIdx = 0;
1985
+ this.#rebuild();
1986
+ this.app.redraw();
1987
+ return;
1988
+ }
1989
+ this.mode = "form";
1990
+ this.formIdx = 0;
1991
+ this.modelsSel = -1;
1992
+ this.#rebuild();
1993
+ this.app.redraw();
1994
+ return;
1995
+ }
1996
+ // form
1997
+ const it = this.formItems[this.formIdx];
1998
+ if (!it) return;
1999
+ const route = this.#route();
2000
+ const p = this.#profile(route);
2001
+ if (it.kind === "field") {
2002
+ if (it.cycle) {
2003
+ // cycle through the allowed api values
2004
+ const next = it.cycle[(it.cycle.indexOf(it.value) + 1) % it.cycle.length];
2005
+ p[it.key] = next;
2006
+ this.#rebuild();
2007
+ this.app.redraw();
2008
+ return;
2009
+ }
2010
+ this.#startEdit(it.label, it.value, (text) => {
2011
+ if (it.key === "route") {
2012
+ // renaming the route key
2013
+ const t = text.trim() || route;
2014
+ if (t !== route && this.providers[t] !== undefined) { this.app.toast(`路由 ${t} 已存在`); return; }
2015
+ if (t !== route) {
2016
+ this.providers[t] = this.providers[route];
2017
+ delete this.providers[route];
2018
+ this.routes = Object.keys(this.providers);
2019
+ this.sel = this.routes.indexOf(t);
2020
+ }
2021
+ } else if (it.numeric) {
2022
+ const n = text.trim() === "" ? undefined : Number(text);
2023
+ if (n !== undefined && !isFinite(n)) { this.app.toast("请输入数字"); return; }
2024
+ const [, mi, field] = it.key.split(".");
2025
+ p.models[Number(mi)][field] = n;
2026
+ } else if (it.key.startsWith("model.")) {
2027
+ const [, mi, field] = it.key.split(".");
2028
+ p.models[Number(mi)][field] = text;
2029
+ } else {
2030
+ p[it.key] = text;
2031
+ }
2032
+ });
2033
+ return;
2034
+ }
2035
+ if (it.kind === "model") {
2036
+ this.modelsSel = this.modelsSel === it.idx ? -1 : it.idx;
2037
+ this.#rebuild();
2038
+ this.app.redraw();
2039
+ return;
2040
+ }
2041
+ if (it.kind === "button") {
2042
+ it.action();
2043
+ this.app.redraw();
2044
+ return;
2045
+ }
2046
+ }
2047
+ #addModel() {
2048
+ const route = this.#route();
2049
+ if (!route) return;
2050
+ const p = this.#profile(route);
2051
+ p.models ??= [];
2052
+ p.models.push({ id: "", name: "" });
2053
+ this.modelsSel = p.models.length - 1;
2054
+ this.#rebuild();
2055
+ this.app.redraw();
2056
+ }
2057
+ #deleteModel() {
2058
+ const route = this.#route();
2059
+ if (!route || this.modelsSel < 0) { this.app.toast("先选中一个模型"); return; }
2060
+ this.#profile(route).models.splice(this.modelsSel, 1);
2061
+ this.modelsSel = -1;
2062
+ this.#rebuild();
2063
+ this.app.redraw();
2064
+ }
2065
+ async #setDefaultModel() {
2066
+ const route = this.#route();
2067
+ if (!route) return;
2068
+ const p = this.#profile(route);
2069
+ const m = p.models?.[this.modelsSel];
2070
+ if (!m?.id) { this.app.toast("先选中一个模型"); return; }
2071
+ if (!this.app.currentSession) { this.app.toast("先打开一个会话"); return; }
2072
+ try {
2073
+ await this.app.api.call("session.selectModel", { sessionId: this.app.currentSession, provider: route, model: m.id });
2074
+ this.app.updateModel();
2075
+ this.app.toast(`已切换 ${route}/${m.id}`);
2076
+ } catch (e) { this.app.toast(`切换失败: ${e.message}`); }
2077
+ }
2078
+ async #save() {
2079
+ try {
2080
+ const res = await this.app.api.call("settings.mutate", {
2081
+ ns: "llm-pi-ai",
2082
+ ops: [{ op: "set", path: ["providers"], value: this.providers }],
2083
+ expectedRevision: this.revision,
2084
+ });
2085
+ this.revision = res?.revision ?? this.revision;
2086
+ this.app.toast(`已保存 ${Object.keys(this.providers).length} 个供应商`);
2087
+ } catch (e) { this.app.toast(`保存失败: ${e.message}`); }
2088
+ }
2089
+ async #deleteProvider() {
2090
+ const route = this.#route();
2091
+ if (!route) return;
2092
+ delete this.providers[route];
2093
+ this.routes = Object.keys(this.providers);
2094
+ this.sel = Math.min(this.sel, this.routes.length - 1);
2095
+ this.modelsSel = -1;
2096
+ await this.#save();
2097
+ this.#rebuild();
2098
+ this.app.redraw();
2099
+ }
2100
+ async #scan() {
2101
+ const route = this.#route();
2102
+ if (!route) return;
2103
+ const p = this.#profile(route);
2104
+ const base = String(p.baseURL ?? "").replace(/\/+$/, "");
2105
+ if (!base) { this.app.toast("先填写 baseURL"); return; }
2106
+ this.scanning = true;
2107
+ this.scanMode = true;
2108
+ this.scanItems = [];
2109
+ this.scanCursor = 0;
2110
+ this.#rebuild();
2111
+ this.app.redraw();
2112
+ const key = p.apiKeyEnv ? process.env[p.apiKeyEnv] : null;
2113
+ const headers = key ? { Authorization: `Bearer ${key}` } : {};
2114
+ const tryFetch = async (dispatcher) => {
2115
+ const res = await fetch(`${base}/models`, { headers, dispatcher });
2116
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
2117
+ return res.json();
2118
+ };
2119
+ try {
2120
+ let body;
2121
+ try { body = await tryFetch(undefined); }
2122
+ catch (e0) {
2123
+ // self-signed gateways: retry with TLS verification disabled
2124
+ try {
2125
+ const { Agent } = await import("undici");
2126
+ body = await tryFetch(new Agent({ connect: { rejectUnauthorized: false } }));
2127
+ } catch { throw e0; }
2128
+ }
2129
+ const list = Array.isArray(body?.data) ? body.data : Array.isArray(body?.models) ? body.models : [];
2130
+ const seen = new Set();
2131
+ const items = [];
2132
+ for (const e of list) {
2133
+ if (!e || typeof e !== "object") continue;
2134
+ const id = String(e.id ?? e.name ?? "").trim();
2135
+ if (!id || seen.has(id)) continue;
2136
+ seen.add(id);
2137
+ items.push({ id, name: e.name ?? e.id ?? id });
2138
+ }
2139
+ this.scanItems = items;
2140
+ this.scanSel = new Set(items.map((m) => m.id));
2141
+ if (items.length === 0) this.app.toast("扫描完成:未发现模型");
2142
+ else this.app.toast(`发现 ${items.length} 个模型,空格勾选,Enter 添加`);
2143
+ } catch (e) {
2144
+ this.app.toast(`扫描失败: ${e.message}`);
2145
+ this.scanMode = false;
2146
+ }
2147
+ this.scanning = false;
2148
+ this.#rebuild();
2149
+ this.app.redraw();
2150
+ }
2151
+ #scanCommit() {
2152
+ const route = this.#route();
2153
+ if (!route) return;
2154
+ const p = this.#profile(route);
2155
+ p.models ??= [];
2156
+ const existing = new Set(p.models.map((m) => m.id));
2157
+ let added = 0;
2158
+ for (const m of this.scanItems) {
2159
+ if (!this.scanSel.has(m.id) || existing.has(m.id)) continue;
2160
+ p.models.push({ id: m.id, name: m.name });
2161
+ added++;
2162
+ }
2163
+ this.scanMode = false;
2164
+ this.app.toast(`已添加 ${added} 个模型(保存后生效)`);
2165
+ this.#rebuild();
2166
+ this.app.redraw();
2167
+ }
2168
+ onKey(ev) {
2169
+ if (ev.type !== "key") return false;
2170
+ if (this.editing) {
2171
+ if (ev.name === "escape") { this.editing = null; this.#rebuild(); return true; }
2172
+ if (ev.name === "enter") { this.#commitEdit(); return true; }
2173
+ const handled = this.input.onKey(ev);
2174
+ if (handled) this.app.redraw();
2175
+ return true;
2176
+ }
2177
+ if (this.scanMode) {
2178
+ if (ev.name === "escape") { this.scanMode = false; this.#rebuild(); return true; }
2179
+ if (ev.name === "up") { this.scanCursor = Math.max(0, this.scanCursor - 1); this.app.redraw(); return true; }
2180
+ if (ev.name === "down") { this.scanCursor = Math.min(this.scanItems.length - 1, this.scanCursor + 1); this.app.redraw(); return true; }
2181
+ if (ev.name === "char" && ev.key === " " && !ev.ctrl) {
2182
+ const m = this.scanItems[this.scanCursor];
2183
+ if (m) { if (this.scanSel.has(m.id)) this.scanSel.delete(m.id); else this.scanSel.add(m.id); }
2184
+ this.app.redraw();
2185
+ return true;
2186
+ }
2187
+ if (ev.name === "enter") { this.#scanCommit(); return true; }
2188
+ return false;
2189
+ }
2190
+ if (ev.name === "escape") { this.editing = null; return false; } // App falls back to chat mode
2191
+ if (ev.name === "left" || (ev.name === "char" && ev.key === "h" && !ev.ctrl)) { this.mode = "list"; this.#rebuild(); return true; }
2192
+ if (ev.name === "right" || (ev.name === "char" && ev.key === "l" && !ev.ctrl) || ev.name === "tab") {
2193
+ if (this.#route() != null) { this.mode = "form"; this.#rebuild(); }
2194
+ return true;
2195
+ }
2196
+ if (ev.name === "up" || (ev.name === "char" && ev.key === "k" && !ev.ctrl)) {
2197
+ if (this.mode === "list") this.sel = Math.max(0, this.sel - 1);
2198
+ else { this.formIdx = Math.max(0, this.formIdx - 1); this.#rebuild(); }
2199
+ this.app.redraw();
2200
+ return true;
2201
+ }
2202
+ if (ev.name === "down" || (ev.name === "char" && ev.key === "j" && !ev.ctrl)) {
2203
+ if (this.mode === "list") this.sel = Math.min(this.routes.length, this.sel + 1);
2204
+ else { this.formIdx = Math.min(Math.max(0, this.formItems.length - 1), this.formIdx + 1); this.#rebuild(); }
2205
+ this.app.redraw();
2206
+ return true;
2207
+ }
2208
+ if (ev.name === "enter") { this.#activateItem(); return true; }
2209
+ return false;
2210
+ }
2211
+ onMouse(ev) {
2212
+ if (ev.kind === "wheel-up") { this.formView.scroll(-3); return true; }
2213
+ if (ev.kind === "wheel-down") { this.formView.scroll(3); return true; }
2214
+ if (ev.kind !== "press" || ev.button !== 0) return false;
2215
+ if (this.editing && this.input.inside(ev.x, ev.y)) return this.input.onMouse(ev);
2216
+ if (ev.x < this.x + 26) {
2217
+ const idx = ev.y - this.listView.y + this.listView.scrollY;
2218
+ if (idx >= 0 && idx <= this.routes.length) { this.sel = idx; this.mode = "list"; this.#rebuild(); this.app.redraw(); return true; }
2219
+ return false;
2220
+ }
2221
+ if (this.scanMode) {
2222
+ const idx = ev.y - this.formView.y + this.formView.scrollY - 1;
2223
+ if (idx >= 0 && idx < this.scanItems.length) {
2224
+ this.scanCursor = idx;
2225
+ const m = this.scanItems[idx];
2226
+ if (this.scanSel.has(m.id)) this.scanSel.delete(m.id); else this.scanSel.add(m.id);
2227
+ this.app.redraw();
2228
+ return true;
2229
+ }
2230
+ return false;
2231
+ }
2232
+ const idx = ev.y - this.formView.y + this.formView.scrollY;
2233
+ if (idx >= 0 && idx < this.formItems.length) { this.formIdx = idx; this.mode = "form"; this.#activateItem(); return true; }
2234
+ return false;
2235
+ }
2236
+ }
2237
+
1807
2238
  function flattenJson(value, path, out, depth = 0) {
1808
2239
  if (depth === 0 && path.length === 0 && value !== null && typeof value === "object") {
1809
2240
  for (const k of Object.keys(value)) flattenJson(value[k], [k], out, 1);
package/src/views.js CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  Picker, buildCommandPalette, buildModelPicker, buildModePicker, buildPermissionPicker,
12
12
  modeName, permName, WorkspacePanel, TrajectoryPanel, DirPicker,
13
13
  ImagePopup, kittyCapable, buildGoalPopup, SettingsPanel, SubagentPanel,
14
- SkillsPanel, ControlPanel, JobsPanel, fmtMs,
14
+ SkillsPanel, ControlPanel, JobsPanel, ModelPanel, fmtMs,
15
15
  } from "./panels.js";
16
16
 
17
17
  import { T, themeName, cycleTheme } from "./theme.js";
@@ -1946,6 +1946,7 @@ export class App {
1946
1946
  this.workspacePanel = null;
1947
1947
  this.trajectoryPanel = null;
1948
1948
  this.settingsPanel = null;
1949
+ this.modelPanel = null;
1949
1950
  this.subagentPanel = null;
1950
1951
  this.skillsPanel = null;
1951
1952
 
@@ -1973,7 +1974,7 @@ export class App {
1973
1974
  this.sidebar.x = 0; this.sidebar.y = 0; this.sidebar.w = this.sidebarWidth; this.sidebar.h = this.screen.h - 1;
1974
1975
  this.searchInput.w = this.sidebarWidth;
1975
1976
  this.chat.resize(x, 1, w, mainH);
1976
- for (const p of [this.workspacePanel, this.trajectoryPanel, this.settingsPanel, this.subagentPanel, this.skillsPanel]) {
1977
+ for (const p of [this.workspacePanel, this.trajectoryPanel, this.settingsPanel, this.modelPanel, this.subagentPanel, this.skillsPanel]) {
1977
1978
  if (p?.relayout) p.relayout(x, 1, w, mainH);
1978
1979
  }
1979
1980
  this.status.y = this.screen.h - footerH;
@@ -2516,6 +2517,9 @@ export class App {
2516
2517
  } else if (mode === "settings") {
2517
2518
  if (!this.settingsPanel) this.settingsPanel = new SettingsPanel(this);
2518
2519
  this.settingsPanel.load();
2520
+ } else if (mode === "models") {
2521
+ if (!this.modelPanel) this.modelPanel = new ModelPanel(this);
2522
+ this.modelPanel.load();
2519
2523
  } else if (mode === "subagent") {
2520
2524
  if (!this.currentSession) { this.toast("先打开一个会话"); this.mode = "chat"; this.redraw(); return; }
2521
2525
  if (!this.subagentPanel) this.subagentPanel = new SubagentPanel(this);
@@ -2534,6 +2538,7 @@ export class App {
2534
2538
  case "workspace": return this.workspacePanel;
2535
2539
  case "trajectory": return this.trajectoryPanel;
2536
2540
  case "settings": return this.settingsPanel;
2541
+ case "models": return this.modelPanel;
2537
2542
  case "subagent": return this.subagentPanel;
2538
2543
  case "skills": return this.skillsPanel;
2539
2544
  default: return null;
@@ -2578,7 +2583,7 @@ export class App {
2578
2583
  this.closeOverlay();
2579
2584
  this.menu = null;
2580
2585
  this.popup = null;
2581
- for (const p of [this.workspacePanel, this.trajectoryPanel, this.settingsPanel, this.subagentPanel, this.skillsPanel]) {
2586
+ for (const p of [this.workspacePanel, this.trajectoryPanel, this.settingsPanel, this.modelPanel, this.subagentPanel, this.skillsPanel]) {
2582
2587
  if (p?.dispose) { try { p.dispose(); } catch {} }
2583
2588
  }
2584
2589
  this.workspacePanel = this.trajectoryPanel = this.settingsPanel = this.subagentPanel = this.skillsPanel = null;
@@ -2754,7 +2759,7 @@ export class App {
2754
2759
  }
2755
2760
 
2756
2761
  #panelLabel(mode) {
2757
- return { workspace: "工作区", settings: "设置", skills: "技能", subagent: "子代理" }[mode] ?? null;
2762
+ return { workspace: "工作区", settings: "设置", models: "模型供应商", skills: "技能", subagent: "子代理" }[mode] ?? null;
2758
2763
  }
2759
2764
 
2760
2765
  #renderTabBar(s) {
@@ -2784,7 +2789,7 @@ export class App {
2784
2789
  const seg = ` ${label} `;
2785
2790
  if (px >= tx && px < tx + strWidth(seg)) {
2786
2791
  if (id === this.mode && panelLabel && id !== "chat" && id !== "trajectory") { /* click active panel tab: stay */ return true; }
2787
- this.setMode(id === "chat" || id === "trajectory" ? id : "chat");
2792
+ this.setMode(id === "chat" || id === "trajectory" || this.#panelLabel(id) ? id : "chat");
2788
2793
  return true;
2789
2794
  }
2790
2795
  tx += strWidth(seg);