dsh-neotui 0.2.3 → 0.3.0

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/src/panels.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // panels.js — Command palette, model picker, workspace browser, trajectory
2
2
  // timeline, jobs/goal panels, and the terminal image viewer (kitty graphics
3
3
  // protocol with external-viewer / chafa fallbacks).
4
- import { Widget, ScrollView, Input, Popup } from "./widgets.js";
5
- import { strWidth, truncate, pad } from "./text.js";
4
+ import { Widget, ScrollView, Input, Popup, wrapIndex } from "./widgets.js";
5
+ import { strWidth, truncate, pad, graphemes, graphemeWidth } from "./text.js";
6
6
  import { renderMd, C } from "./md.js";
7
7
  import { readdirSync, statSync, readFileSync, writeFileSync, existsSync } from "node:fs";
8
8
  import { tmpdir } from "node:os";
@@ -11,6 +11,7 @@ import { spawn, spawnSync, execFileSync } from "node:child_process";
11
11
 
12
12
  import { T, cycleTheme, themeName } from "./theme.js";
13
13
  import { loadTuiConfig, saveTuiConfig, userPrefix, userName, foldDefaults, keyBindings, setKeyBinding, resetKeyBinding } from "./config.js";
14
+ import { validateKeySpec, describeSpec } from "./keybindings.js";
14
15
  // Live theme accessor: K.K.DIM etc. resolve against the active palette at render time.
15
16
  const K = new Proxy({}, { get(_k, key) { return T[key]; } });
16
17
 
@@ -61,6 +62,7 @@ export class Picker extends Widget {
61
62
  return scored;
62
63
  }
63
64
  render(screen) {
65
+ if (this.input.value !== this.query) this.input.setValue(this.query);
64
66
  screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", { bg: T.BG2 });
65
67
  screen.box(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, { fg: K.ACCENT, bg: T.BG2 }, this.title);
66
68
  this.input.render(screen);
@@ -88,16 +90,16 @@ export class Picker extends Widget {
88
90
  if (idx >= 0 && idx < list.length) { this.onPick?.(list[idx]); return true; }
89
91
  return true;
90
92
  }
91
- if (ev.kind === "wheel-up") { this.sel = Math.max(0, this.sel - 1); return true; }
92
- if (ev.kind === "wheel-down") { this.sel = Math.min(Math.max(0, this.filtered().length - 1), this.sel + 1); return true; }
93
+ if (ev.kind === "wheel-up") { this.sel = wrapIndex(this.sel - 1, this.filtered().length); return true; }
94
+ if (ev.kind === "wheel-down") { this.sel = wrapIndex(this.sel + 1, this.filtered().length); return true; }
93
95
  return true;
94
96
  }
95
97
  onKey(ev) {
96
98
  if (ev.type === "text") { this.query += ev.text; this.sel = 0; return true; }
97
99
  if (ev.type !== "key") return false;
98
100
  switch (ev.name) {
99
- case "up": this.sel = Math.max(0, this.sel - 1); return true;
100
- case "down": this.sel = Math.min(Math.max(0, this.filtered().length - 1), this.sel + 1); return true;
101
+ case "up": this.sel = wrapIndex(this.sel - 1, this.filtered().length); return true;
102
+ case "down": this.sel = wrapIndex(this.sel + 1, this.filtered().length); return true;
101
103
  case "enter": { const l = this.filtered(); if (l[this.sel]) { this.onPick?.(l[this.sel]); } return true; }
102
104
  case "escape": this.onCancel?.(); return true;
103
105
  case "backspace": this.query = this.query.slice(0, -1); this.sel = 0; return true;
@@ -107,69 +109,205 @@ export class Picker extends Widget {
107
109
  }
108
110
  }
109
111
 
110
- // ---- Model picker ----
112
+ // ---- Model picker: provider folders → model files ----
111
113
 
112
- export function buildModelPicker(app) {
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
- };
119
- const selectModel = async (it) => {
120
- app.overlay = null; app.redraw();
121
- if (!app.currentSession) { app.toast("先打开一个会话"); return; }
114
+ export class ModelPickerBuffer extends Widget {
115
+ constructor(app) {
116
+ const w = Math.max(1, Math.min(88, app.screen.w - 4)), h = Math.max(1, Math.min(28, app.screen.h - 4));
117
+ super({ x: Math.floor((app.screen.w - w) / 2), y: Math.floor((app.screen.h - h) / 2), w, h });
118
+ this.app = app;
119
+ this.title = "选择模型";
120
+ this.query = "";
121
+ this.filtering = false; // "/" enters filter mode, Ctrl+/ exits (like the other buffers)
122
+ this.sel = 0;
123
+ this.scroll = 0;
124
+ this.collapsed = new Set(); // folded provider ids
125
+ this.items = []; // provider groups [{provider,name,models:[…]}]
126
+ this.rows = []; // flattened tree rows ({kind:"provider"|"model"|"manage"})
127
+ this.loading = false;
128
+ this.manageRow = { kind: "manage" };
129
+ this.input = new Input({ x: this.x + 1, y: this.y + 1, w: this.w - 2, h: 1, prompt: "❯ ", placeholder: "输入以筛选模型…", bg: T.BG2 });
130
+ this.#load();
131
+ }
132
+ async #load() {
133
+ this.loading = true;
122
134
  try {
123
- await app.api.call("session.selectModel", { sessionId: app.currentSession, provider: it.provider, model: it.model, ...(it.effort ? { reasoningEffort: it.effort } : {}) });
124
- app.updateModel();
125
- app.toast(`已切换 ${it.provider}/${it.model}${it.effort ? ` (${it.effort})` : ""}`);
126
- } catch (e) { app.toast(`切换失败: ${e.message}`); }
127
- };
128
- const picker = new Picker({
129
- x: Math.floor((app.screen.w - w) / 2), y: Math.floor((app.screen.h - h) / 2),
130
- w, h, title: "选择模型",
131
- items: [],
132
- onCancel: () => { app.overlay = null; app.redraw(); },
133
- onPick: (it) => {
134
- if (it === manageProviders) { manageProviders.action(); return; }
135
- const efforts = it.efforts ?? [];
136
- if (efforts.length > 0) {
137
- // second step: reasoning effort
138
- const w2 = Math.min(60, app.screen.w - 4), h2 = Math.min(efforts.length + 4, app.screen.h - 4);
139
- app.overlay = new Picker({
140
- x: Math.floor((app.screen.w - w2) / 2), y: Math.floor((app.screen.h - h2) / 2),
141
- w: w2, h: h2, title: `思考强度 — ${it.model}`,
142
- items: efforts.map((e) => ({
143
- label: e.name ?? e.id, hint: e.id === it.defaultEffort ? "默认" : (e.description ?? "").slice(0, 28),
144
- provider: it.provider, model: it.model, effort: e.id,
145
- })),
146
- onCancel: () => { app.overlay = picker; app.redraw(); },
147
- onPick: (eff) => selectModel(eff),
148
- });
149
- app.redraw();
150
- } else {
151
- selectModel(it);
135
+ const { groups } = await this.app.api.call("llm.models");
136
+ this.items = (groups ?? []).map((g) => ({
137
+ provider: g.id, name: g.name ?? g.id,
138
+ models: (g.models ?? []).map((m) => ({ id: m.id, name: m.name ?? m.id, description: m.description ?? "", efforts: m.reasoning?.efforts ?? [], defaultEffort: m.reasoning?.defaultEffort, provider: g.id })),
139
+ }));
140
+ const cur = this.app.currentModel;
141
+ this.collapsed = new Set(this.items.map((g) => g.provider).filter((p) => p !== cur?.provider));
142
+ this.#rebuildRows();
143
+ const idx = this.rows.findIndex((r) => r.kind === "model" && r.model.provider === cur?.provider && r.model.id === cur?.model);
144
+ this.sel = idx >= 0 ? idx : 0;
145
+ } catch (e) { this.app.toast?.(`模型列表失败: ${e.message}`); }
146
+ this.loading = false;
147
+ this.#clampScroll();
148
+ this.app.redraw?.();
149
+ }
150
+ filteredMatches() {
151
+ if (!this.query) return null;
152
+ const q = this.query.toLowerCase();
153
+ return this.items.flatMap((g) => g.models.filter((m) => fuzzyScore(q, `${m.id} ${m.name} ${m.description}`) > 0).map((model) => ({ group: g, model })));
154
+ }
155
+ #rebuildRows() {
156
+ const rows = [];
157
+ const filtered = this.filteredMatches();
158
+ if (filtered) {
159
+ // Filter mode: every provider with a hit renders expanded.
160
+ const byProvider = new Map();
161
+ for (const { group, model } of filtered) {
162
+ if (!byProvider.has(group.provider)) byProvider.set(group.provider, []);
163
+ byProvider.get(group.provider).push(model);
152
164
  }
153
- },
154
- });
155
- app.api.call("llm.models").then(({ groups, failures }) => {
156
- const items = [];
157
- for (const g of groups) {
158
- for (const m of g.models) {
159
- items.push({
160
- label: `${g.id}/${m.id}`,
161
- hint: m.name ?? m.id,
162
- provider: g.id, model: m.id,
163
- efforts: m.reasoning?.efforts ?? [],
164
- defaultEffort: m.reasoning?.defaultEffort,
165
- keywords: `${m.description ?? ""} ${g.name}`,
166
- });
165
+ for (const group of this.items) {
166
+ const models = byProvider.get(group.provider);
167
+ if (!models) continue;
168
+ rows.push({ kind: "provider", group, count: models.length });
169
+ for (const model of models) rows.push({ kind: "model", group, model });
167
170
  }
171
+ } else {
172
+ for (const group of this.items) {
173
+ const open = !this.collapsed.has(group.provider);
174
+ rows.push({ kind: "provider", group, count: group.models.length });
175
+ if (open) for (const model of group.models) rows.push({ kind: "model", group, model });
176
+ }
177
+ rows.push(this.manageRow);
168
178
  }
169
- picker.items = [manageProviders, ...items];
170
- app.redraw();
171
- }).catch((e) => app.toast(`模型列表失败: ${e.message}`));
172
- return picker;
179
+ this.rows = rows;
180
+ if (this.sel >= rows.length) this.sel = Math.max(0, rows.length - 1);
181
+ }
182
+ #clampScroll() {
183
+ const lh = Math.max(1, this.h - 3);
184
+ if (this.sel < this.scroll) this.scroll = this.sel;
185
+ else if (this.sel >= this.scroll + lh) this.scroll = this.sel - lh + 1;
186
+ this.scroll = Math.max(0, this.scroll);
187
+ }
188
+ #toggleGroup(provider) {
189
+ if (this.query) return; // filter mode is always expanded
190
+ if (this.collapsed.has(provider)) this.collapsed.delete(provider);
191
+ else this.collapsed.add(provider);
192
+ this.#rebuildRows();
193
+ const idx = this.rows.findIndex((r) => r.kind === "provider" && r.group.provider === provider);
194
+ if (idx >= 0) this.sel = idx; // keep the cursor on the folder so Space toggles in place
195
+ this.#clampScroll();
196
+ this.app.redraw?.();
197
+ }
198
+ async #selectModel(entry) {
199
+ const it = { provider: entry.model.provider, model: entry.model.id, efforts: entry.model.efforts, defaultEffort: entry.model.defaultEffort };
200
+ const efforts = it.efforts ?? [];
201
+ if (efforts.length > 0) {
202
+ const w2 = Math.max(1, Math.min(60, this.app.screen.w - 4)), h2 = Math.max(1, Math.min(efforts.length + 4, this.app.screen.h - 4));
203
+ this.app.overlay = new Picker({
204
+ x: Math.floor((this.app.screen.w - w2) / 2), y: Math.floor((this.app.screen.h - h2) / 2),
205
+ w: w2, h: h2, title: `思考强度 — ${it.model}`,
206
+ items: efforts.map((e) => ({ label: e.name ?? e.id, hint: e.id === it.defaultEffort ? "默认" : (e.description ?? "").slice(0, 28), provider: it.provider, model: it.model, effort: e.id })),
207
+ onCancel: () => { this.app.overlay = this; this.app.redraw(); },
208
+ onPick: (eff) => this.#commitModel({ provider: eff.provider, model: eff.model, effort: eff.effort }),
209
+ });
210
+ this.app.redraw();
211
+ return;
212
+ }
213
+ await this.#commitModel(it);
214
+ }
215
+ async #commitModel(it) {
216
+ this.app.overlay = null; this.app.redraw?.();
217
+ if (!this.app.currentSession) { this.app.toast?.("先打开一个会话"); return; }
218
+ try {
219
+ await this.app.api.call("session.selectModel", { sessionId: this.app.currentSession, provider: it.provider, model: it.model, ...(it.effort ? { reasoningEffort: it.effort } : {}) });
220
+ this.app.updateModel?.();
221
+ this.app.toast?.(`已切换 ${it.provider}/${it.model}${it.effort ? ` (${it.effort})` : ""}`);
222
+ } catch (e) { this.app.toast?.(`切换失败: ${e.message}`); }
223
+ }
224
+ #activate() {
225
+ const row = this.rows[this.sel];
226
+ if (!row) return;
227
+ if (row.kind === "manage") {
228
+ this.app.overlay = null;
229
+ (typeof this.app.showModelsBuffer === "function" ? this.app.showModelsBuffer() : this.app.setMode?.("models"));
230
+ return;
231
+ }
232
+ if (row.kind === "provider") { this.#toggleGroup(row.group.provider); return; }
233
+ if (row.kind === "model") void this.#selectModel(row);
234
+ }
235
+ render(screen) {
236
+ screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", { bg: T.BG2 });
237
+ screen.box(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, { fg: K.ACCENT, bg: T.BG2 }, this.w >= 44 ? " 选择模型 · / 筛选 · Ctrl+/ 退出 · Space 展开 · Enter 确认 " : " 选择模型 ");
238
+ this.input.prompt = this.filtering ? "/ " : "❯ ";
239
+ this.input.setValue(this.filtering ? this.query : "");
240
+ this.input.render(screen);
241
+ const lh = Math.max(1, this.h - 3);
242
+ this.#clampScroll();
243
+ for (let i = 0; i < lh; i++) {
244
+ const idx = this.scroll + i;
245
+ const row = this.rows[idx];
246
+ const y = this.y + 2 + i;
247
+ if (!row) { screen.hline(this.x + 1, this.x + this.w - 2, y, " ", { bg: T.BG2 }); continue; }
248
+ const sel = idx === this.sel;
249
+ const cur = this.app.currentModel;
250
+ screen.fillRect(this.x + 1, y, this.x + this.w - 2, y, " ", { bg: sel ? T.MENUSEL : T.BG2 });
251
+ const style = { fg: sel ? 0xffffff : K.TXT, bg: sel ? T.MENUSEL : T.BG2, attrs: sel ? 1 : 0 };
252
+ let text;
253
+ if (row.kind === "manage") text = "⚙ 管理供应商…";
254
+ else if (row.kind === "provider") {
255
+ const open = this.query ? true : !this.collapsed.has(row.group.provider);
256
+ text = `${open ? "▾" : "▸"} 📁 ${row.group.name} (${row.count})`;
257
+ } else {
258
+ const mark = cur?.provider === row.model.provider && cur?.model === row.model.id ? "●" : "○";
259
+ text = ` ${mark} ${row.model.id}${row.model.name !== row.model.id ? ` ${row.model.name}` : ""}`;
260
+ }
261
+ screen.text(this.x + 2, y, truncate(text, this.w - 4), row.kind === "provider" ? { fg: K.ACCENT, bg: sel ? T.MENUSEL : T.BG2, attrs: sel ? 1 : 0 } : style);
262
+ }
263
+ }
264
+ onMouse(ev) {
265
+ if (ev.kind === "press" && ev.button === 0) {
266
+ if (ev.y === this.y + 1) { this.input.onMouse(ev); return true; }
267
+ const idx = this.scroll + (ev.y - this.y - 2);
268
+ if (idx >= 0 && idx < this.rows.length) { this.sel = idx; this.#activate(); }
269
+ return true;
270
+ }
271
+ if (ev.kind === "wheel-up") { this.sel = wrapIndex(this.sel - 1, this.rows.length); this.#clampScroll(); this.app.redraw?.(); return true; }
272
+ if (ev.kind === "wheel-down") { this.sel = wrapIndex(this.sel + 1, this.rows.length); this.#clampScroll(); this.app.redraw?.(); return true; }
273
+ return true;
274
+ }
275
+ onKey(ev) {
276
+ if (ev.type === "text") {
277
+ // Legacy terminals: "/" as text enters filter mode; typed text filters.
278
+ if (this.filtering) { this.query += ev.text; this.sel = 0; this.#rebuildRows(); this.app.redraw?.(); return true; }
279
+ if (ev.text === "/") { this.filtering = true; this.query = ""; this.#rebuildRows(); this.app.redraw?.(); return true; }
280
+ return true;
281
+ }
282
+ if (ev.type !== "key") return false;
283
+ if (ev.ctrl && ev.name === "char" && ev.key === "/") { this.filtering = false; this.query = ""; this.#rebuildRows(); this.app.redraw?.(); return true; }
284
+ switch (ev.name) {
285
+ case "up": this.sel = wrapIndex(this.sel - 1, this.rows.length); this.#clampScroll(); this.app.redraw?.(); return true;
286
+ case "down": this.sel = wrapIndex(this.sel + 1, this.rows.length); this.#clampScroll(); this.app.redraw?.(); return true;
287
+ case "pgup": this.scroll = Math.max(0, this.scroll - Math.max(1, this.h - 3)); return true;
288
+ case "pgdn": this.scroll = this.scroll + Math.max(1, this.h - 3); return true;
289
+ case "enter": this.#activate(); return true;
290
+ case "escape":
291
+ if (this.filtering) { this.filtering = false; this.query = ""; this.#rebuildRows(); this.app.redraw?.(); return true; }
292
+ this.app.overlay = null; this.app.redraw?.(); return true;
293
+ case "backspace":
294
+ if (!this.filtering) return true;
295
+ this.query = this.query.slice(0, -1); this.sel = 0; this.#rebuildRows(); this.app.redraw?.(); return true;
296
+ case "char":
297
+ if (ev.key === " " && !ev.ctrl) { const row = this.rows[this.sel]; if (row?.kind === "provider") this.#toggleGroup(row.group.provider); else if (row?.kind === "model") this.#toggleGroup(row.group.provider); return true; }
298
+ if (!ev.ctrl) {
299
+ if (ev.key === "/") { this.filtering = true; this.query = ""; this.sel = 0; this.#rebuildRows(); this.app.redraw?.(); return true; }
300
+ if (this.filtering) { this.query += ev.text ?? ev.key; this.sel = 0; this.#rebuildRows(); this.app.redraw?.(); }
301
+ return true;
302
+ }
303
+ return false;
304
+ }
305
+ return false;
306
+ }
307
+ }
308
+
309
+ export function buildModelPicker(app) {
310
+ return new ModelPickerBuffer(app);
173
311
  }
174
312
 
175
313
  // ---- Mode (agent preset) & permission pickers ----
@@ -182,7 +320,7 @@ export function permName(id) { return PERM_NAMES[id] ?? id; }
182
320
 
183
321
  /** Four-mode selector: the shipped agent presets (standard/code/minimal/cordis). */
184
322
  export function buildModePicker(app) {
185
- const w = Math.min(66, app.screen.w - 4), h = Math.min(18, app.screen.h - 4);
323
+ const w = Math.max(1, Math.min(66, app.screen.w - 4)), h = Math.max(1, Math.min(18, app.screen.h - 4));
186
324
  const picker = new Picker({
187
325
  x: Math.floor((app.screen.w - w) / 2), y: Math.floor((app.screen.h - h) / 2),
188
326
  w, h, title: "模式(Agent 预设)",
@@ -208,7 +346,7 @@ export function buildPermissionPicker(app) {
208
346
  const perms = app.projections.permissions;
209
347
  const options = (perms?.options ?? []).filter((o) => o.value !== "custom");
210
348
  const current = perms?.currentValue;
211
- const w = Math.min(60, app.screen.w - 4), h = Math.min(options.length + 4, 16);
349
+ const w = Math.max(1, Math.min(60, app.screen.w - 4)), h = Math.max(1, Math.min(options.length + 4, 16, app.screen.h - 4));
212
350
  return new Picker({
213
351
  x: Math.floor((app.screen.w - w) / 2), y: Math.floor((app.screen.h - h) / 2),
214
352
  w, h, title: "权限(沙箱 + 审批)",
@@ -237,8 +375,8 @@ export class ArchivePanel extends Popup {
237
375
  onKey(ev) {
238
376
  if (ev.type !== "key") return false; const items = this.items(), item = items[this.sel];
239
377
  if (ev.name === "escape") { this.app.closeOverlay(); return true; }
240
- if (ev.name === "up" || (ev.name === "char" && ev.key === "k")) { this.sel = Math.max(0, this.sel - 1); this.rebuild(); return true; }
241
- if (ev.name === "down" || (ev.name === "char" && ev.key === "j")) { this.sel = Math.min(items.length - 1, this.sel + 1); this.rebuild(); return true; }
378
+ if (ev.name === "up" || (ev.name === "char" && ev.key === "k")) { this.sel = wrapIndex(this.sel - 1, items.length); this.rebuild(); return true; }
379
+ if (ev.name === "down" || (ev.name === "char" && ev.key === "j")) { this.sel = wrapIndex(this.sel + 1, items.length); this.rebuild(); return true; }
242
380
  if (ev.name === "enter" && item) { this.app.closeOverlay(); this.app.openSession(item.sessionId); return true; }
243
381
  if (ev.name === "char" && ev.key === "y" && item) { this.app.copyText(item.sessionId); return true; }
244
382
  if (ev.name === "char" && ev.key === "e" && item) { this.app.exportSession(item); return true; }
@@ -284,8 +422,8 @@ export class PresetPanel extends Popup {
284
422
  onKey(ev) {
285
423
  if (ev.type !== "key") return false;
286
424
  if (ev.name === "escape") { this.app.closeOverlay(); return true; }
287
- if (ev.name === "up" || (ev.name === "char" && ev.key === "k")) { this.sel = Math.max(0, this.sel - 1); this.read(); return true; }
288
- if (ev.name === "down" || (ev.name === "char" && ev.key === "j")) { this.sel = Math.min(this.items.length - 1, this.sel + 1); this.read(); return true; }
425
+ if (ev.name === "up" || (ev.name === "char" && ev.key === "k")) { this.sel = wrapIndex(this.sel - 1, this.items.length); this.read(); return true; }
426
+ if (ev.name === "down" || (ev.name === "char" && ev.key === "j")) { this.sel = wrapIndex(this.sel + 1, this.items.length); this.read(); return true; }
289
427
  if (ev.name === "enter") { this.read(); return true; }
290
428
  if (ev.name === "char" && ev.key === "c") { this.#copy(); return true; }
291
429
  if (ev.name === "char" && ev.key === "o") { this.#open(); return true; }
@@ -302,13 +440,13 @@ export function buildCommandPalette(app) {
302
440
  { label: "新建会话", hint: "n", action: () => app.newSession(), keywords: "new session create" },
303
441
  { label: "新建工作区…", action: () => app.addWorkspace(), keywords: "new workspace create directory" },
304
442
  { label: "打开会话…", hint: "o", action: () => app.openSessionPicker(), keywords: "open session" },
305
- { label: "搜索会话", hint: "/", action: () => app.startSearch(), keywords: "search find" },
443
+ { label: "跨会话全文搜索", hint: "Ctrl+F /", action: () => app.startSearch(), keywords: "search find full text" },
306
444
  { label: "重命名当前会话", action: () => app.renameCurrent(), keywords: "rename title" },
307
445
  { label: "切换模型", hint: "m", action: () => { app.overlay = buildModelPicker(app); app.redraw(); }, keywords: "model provider llm" },
308
446
  { label: "模式(Agent 预设)", action: () => app.showModePicker(), keywords: "mode preset standard code minimal cordis" },
309
447
  { label: "管理 Agent 预设", action: () => { app.overlay = new PresetPanel(app); app.redraw(); }, keywords: "preset inspect copy edit delete" },
310
448
  { label: "权限(沙箱 + 审批)", action: () => app.showPermissionPicker(), keywords: "permission sandbox read-only write full access" },
311
- { label: "工作区文件", hint: "w", action: () => app.setMode("workspace"), keywords: "workspace files tree" },
449
+ { label: "工作区文件", hint: "w", action: () => (app.showWorkspaceBuffer ? app.showWorkspaceBuffer() : app.setMode?.("workspace")), keywords: "workspace files tree" },
312
450
  { label: "轨迹视图", hint: "t", action: () => app.setMode("trajectory"), keywords: "trajectory timeline trace" },
313
451
  { label: "任务列表", hint: "j", action: () => app.showJobs(), keywords: "jobs tasks" },
314
452
  { label: "目标状态", hint: "g", action: () => app.showGoal(), keywords: "goal objective" },
@@ -338,6 +476,10 @@ export class WorkspacePanel extends Widget {
338
476
  this.treeScroll = new ScrollView({ x: this.x + 1, y: this.y + 1, w: Math.floor(this.w / 2), h: this.h - 2, showScrollbar: true });
339
477
  this.preview = new ScrollView({ x: this.x + Math.floor(this.w / 2) + 1, y: this.y + 1, w: this.w - Math.floor(this.w / 2) - 2, h: this.h - 2, showScrollbar: true });
340
478
  this.previewPath = null;
479
+ this.query = "";
480
+ this.searchSel = 0;
481
+ this.searchResults = [];
482
+ this.searchScroll = 0;
341
483
  }
342
484
  relayout(x, y, w, h) {
343
485
  this.x = x; this.y = y; this.w = w; this.h = h;
@@ -360,7 +502,7 @@ export class WorkspacePanel extends Widget {
360
502
  this.rebuildTree();
361
503
  } catch (e) {
362
504
  this.app.toast(`工作区加载失败: ${e.message}`);
363
- this.app.setMode("chat");
505
+ this.app.closeFullBuffer?.() ?? this.app.setMode?.("chat");
364
506
  }
365
507
  }
366
508
  expand(node) {
@@ -516,12 +658,12 @@ export class WorkspacePanel extends Widget {
516
658
  if (ev.type !== "key") return false;
517
659
  if (ev.name === "escape") {
518
660
  if (this.query) { this.query = ""; this.app.redraw(); return true; }
519
- this.app.setMode("chat");
661
+ this.app.closeFullBuffer?.() ?? this.app.setMode?.("chat");
520
662
  return true;
521
663
  }
522
664
  if (ev.name === "backspace") { this.query = this.query.slice(0, -1); this.app.redraw(); return true; }
523
- if (ev.name === "down" && this.query) { this.searchSel = Math.min((this.searchResults?.length ?? 1) - 1, (this.searchSel ?? 0) + 1); this.app.redraw(); return true; }
524
- if (ev.name === "up" && this.query) { this.searchSel = Math.max(0, (this.searchSel ?? 0) - 1); this.app.redraw(); return true; }
665
+ if (ev.name === "down" && this.query) { this.searchSel = wrapIndex((this.searchSel ?? 0) + 1, this.searchResults?.length ?? 0); this.app.redraw(); return true; }
666
+ if (ev.name === "up" && this.query) { this.searchSel = wrapIndex((this.searchSel ?? 0) - 1, this.searchResults?.length ?? 0); this.app.redraw(); return true; }
525
667
  if (ev.name === "enter" && this.query && this.searchResults?.length) { this.previewFile(this.searchResults[this.searchSel ?? 0]); return true; }
526
668
  if (ev.name === "up" || ev.name === "down" || ev.name === "pgup" || ev.name === "pgdn") return this.treeScroll.onKey?.(ev) ?? false;
527
669
  return false;
@@ -591,8 +733,8 @@ export class DirPicker extends Widget {
591
733
  if (ev.type !== "key") return false;
592
734
  const items = this.#items();
593
735
  switch (ev.name) {
594
- case "up": this.sel = Math.max(0, this.sel - 1); return true;
595
- case "down": this.sel = Math.min(items.length - 1, this.sel + 1); return true;
736
+ case "up": this.sel = wrapIndex(this.sel - 1, items.length); return true;
737
+ case "down": this.sel = wrapIndex(this.sel + 1, items.length); return true;
596
738
  case "enter": {
597
739
  const it = items[this.sel];
598
740
  if (it.kind === "pick") { this.onPick?.(this.path); return true; }
@@ -601,8 +743,8 @@ export class DirPicker extends Widget {
601
743
  }
602
744
  case "backspace": if (this.parentPath) { this.path = this.parentPath; this.load(); } return true;
603
745
  case "char":
604
- if (ev.key === "j" && !ev.ctrl) { this.sel = Math.min(items.length - 1, this.sel + 1); return true; }
605
- if (ev.key === "k" && !ev.ctrl) { this.sel = Math.max(0, this.sel - 1); return true; }
746
+ if (ev.key === "j" && !ev.ctrl) { this.sel = wrapIndex(this.sel + 1, items.length); return true; }
747
+ if (ev.key === "k" && !ev.ctrl) { this.sel = wrapIndex(this.sel - 1, items.length); return true; }
606
748
  if (ev.key === "h" && !ev.ctrl) { if (this.parentPath) { this.path = this.parentPath; this.load(); } return true; }
607
749
  if (ev.key === "l" && !ev.ctrl) {
608
750
  const it = items[this.sel];
@@ -627,8 +769,8 @@ export class DirPicker extends Widget {
627
769
  }
628
770
  return true;
629
771
  }
630
- if (ev.kind === "wheel-up") { this.sel = Math.max(0, this.sel - 1); return true; }
631
- if (ev.kind === "wheel-down") { this.sel = Math.min(this.#items().length - 1, this.sel + 1); return true; }
772
+ if (ev.kind === "wheel-up") { this.sel = wrapIndex(this.sel - 1, this.#items().length); return true; }
773
+ if (ev.kind === "wheel-down") { this.sel = wrapIndex(this.sel + 1, this.#items().length); return true; }
632
774
  return true;
633
775
  }
634
776
  }
@@ -640,7 +782,7 @@ export class AttachmentPanel extends Widget {
640
782
  openItem(external=false){const a=this.items()[this.sel];if(!a)return;if(external){if(!a.path){this.app.toast("这不是本地文件,无法用默认程序定位");return;}try{const cmd=process.platform==="darwin"?"open":"xdg-open";spawn(cmd,[a.path],{detached:true,stdio:"ignore"}).unref();}catch(e){this.app.toast(`打开失败: ${e.message}`);}return;}if(a.mediaType?.startsWith("image/"))this.app.openImage(a,{all:this.items(),index:this.sel,returnTo:this});else this.app.toast(a.path?`文件: ${a.path}`:"这不是本地文件");}
641
783
  remove(){const a=this.items()[this.sel];if(!a)return;this.app.chat.attachments.splice(this.sel,1);this.app.chat.clipboardImages=this.app.chat.clipboardImages.filter(x=>x.id!==a.id);this.sel=Math.max(0,Math.min(this.sel,this.items().length-1));this.app.chat.inputChanged();this.app.redraw();}
642
784
  render(s){s.fillRect(this.x,this.y,this.x+this.w-1,this.y+this.h-1," ",{bg:T.BG2});s.box(this.x,this.y,this.x+this.w-1,this.y+this.h-1,{fg:K.ACCENT,bg:T.BG2},"附件管理器");const items=this.items();for(let i=0;i<Math.min(items.length,this.h-3);i++){const a=items[i],on=i===this.sel,y=this.y+1+i;s.fillRect(this.x+1,y,this.x+this.w-2,y," ",{bg:on?T.MENUSEL:T.BG2});s.text(this.x+2,y,truncate(`${a.mediaType?.startsWith("image/")?"󰋩":"󰈔"} ${a.name}`,this.w-6),{fg:on?T.SELFG:K.TXT,bg:on?T.MENUSEL:T.BG2});}if(!items.length)s.text(this.x+2,this.y+2,"暂无附件",{fg:K.FAINT,bg:T.BG2});s.text(this.x+2,this.y+this.h-1,"Enter 查看 · Shift+Enter/双击 默认程序 · dd 移除 · Esc 退出",{fg:K.FAINT,bg:T.BG2});}
643
- onKey(ev){const ch=ev.type==="text"?ev.text:ev.type==="key"&&ev.name==="char"?ev.key:null;if(ch==="d"){if(this.dArmed){this.dArmed=false;this.remove();}else{this.dArmed=true;this.app.toast("再按 d 删除附件");}return true;}if(ev.type!=="key"){this.dArmed=false;return false;}if(ev.name==="escape"){this.close();return true;}if(ev.name==="up"||(ev.name==="char"&&ev.key==="k")){this.dArmed=false;this.sel=Math.max(0,this.sel-1);return true;}if(ev.name==="down"||(ev.name==="char"&&ev.key==="j")){this.dArmed=false;this.sel=Math.min(this.items().length-1,this.sel+1);return true;}if(ev.name==="enter"){this.dArmed=false;this.openItem(!!ev.shift);return true;}this.dArmed=false;return false;}
785
+ onKey(ev){const ch=ev.type==="text"?ev.text:ev.type==="key"&&ev.name==="char"?ev.key:null;if(ch==="d"){if(this.dArmed){this.dArmed=false;this.remove();}else{this.dArmed=true;this.app.toast("再按 d 删除附件");}return true;}if(ev.type!=="key"){this.dArmed=false;return false;}if(ev.name==="escape"){this.close();return true;}if(ev.name==="up"||(ev.name==="char"&&ev.key==="k")){this.dArmed=false;this.sel=wrapIndex(this.sel-1,this.items().length);return true;}if(ev.name==="down"||(ev.name==="char"&&ev.key==="j")){this.dArmed=false;this.sel=wrapIndex(this.sel+1,this.items().length);return true;}if(ev.name==="enter"){this.dArmed=false;this.openItem(!!ev.shift);return true;}this.dArmed=false;return false;}
644
786
  onMouse(ev){if(ev.kind==="press"&&ev.button===0){const i=ev.y-this.y-1;if(i>=0&&i<this.items().length){const now=Date.now();this.sel=i;if(this.lastClick&&now-this.lastClick<400)this.openItem(true);this.lastClick=now;}return true;}return false;}
645
787
  }
646
788
 
@@ -653,8 +795,8 @@ export class FilePicker extends Widget {
653
795
  items() { return [{ name: "..", dir: true }, ...this.entries.map((e) => ({ name: e.name, dir: e.isDirectory() }))]; }
654
796
  activate() { const it = this.items()[this.sel]; if (!it) return; const path = it.name === ".." ? dirname(this.path) : join(this.path, it.name); if (it.dir) { this.path = path; this.load(); } else this.onPick?.(path); }
655
797
  render(s) { s.fillRect(this.x,this.y,this.x+this.w-1,this.y+this.h-1," ",{bg:T.BG2}); s.box(this.x,this.y,this.x+this.w-1,this.y+this.h-1,{fg:K.ACCENT,bg:T.BG2},"Yazi 风格文件选择"); s.text(this.x+2,this.y+1,truncate(this.path,this.w-4),{fg:K.DIM,bg:T.BG2}); const items=this.items(), n=this.h-4; if(this.sel<this.scroll)this.scroll=this.sel; if(this.sel>=this.scroll+n)this.scroll=this.sel-n+1; for(let i=0;i<n;i++){const idx=this.scroll+i,it=items[idx];if(!it)continue;const on=idx===this.sel,y=this.y+2+i;s.fillRect(this.x+1,y,this.x+this.w-2,y," ",{bg:on?T.MENUSEL:T.BG2});s.text(this.x+2,y,`${it.dir?"▸":"·"} ${truncate(it.name,this.w-7)}${it.dir?"/":""}`,{fg:on?T.SELFG:it.dir?K.ACCENT:K.TXT,bg:on?T.MENUSEL:T.BG2});} s.text(this.x+2,this.y+this.h-1,"↑↓/jk 选择 · Enter/l 打开 · h上级 · Esc取消",{fg:K.FAINT,bg:T.BG2}); }
656
- onKey(ev){if(ev.type!=="key")return false;const n=this.items().length;if(ev.name==="escape"){this.onCancel?.();return true;}if(ev.name==="up"||(ev.name==="char"&&ev.key==="k")){this.sel=Math.max(0,this.sel-1);return true;}if(ev.name==="down"||(ev.name==="char"&&ev.key==="j")){this.sel=Math.min(n-1,this.sel+1);return true;}if(ev.name==="enter"||(ev.name==="char"&&ev.key==="l")){this.activate();return true;}if(ev.name==="backspace"||(ev.name==="char"&&ev.key==="h")){this.path=dirname(this.path);this.load();return true;}return false;}
657
- onMouse(ev){if(ev.kind==="press"&&ev.button===0){const idx=this.scroll+ev.y-this.y-2;if(idx>=0&&idx<this.items().length){this.sel=idx;this.activate();}return true;}if(ev.kind==="wheel-up"){this.sel=Math.max(0,this.sel-1);return true;}if(ev.kind==="wheel-down"){this.sel=Math.min(this.items().length-1,this.sel+1);return true;}return false;}
798
+ onKey(ev){if(ev.type!=="key")return false;const n=this.items().length;if(ev.name==="escape"){this.onCancel?.();return true;}if(ev.name==="up"||(ev.name==="char"&&ev.key==="k")){this.sel=wrapIndex(this.sel-1,n);return true;}if(ev.name==="down"||(ev.name==="char"&&ev.key==="j")){this.sel=wrapIndex(this.sel+1,n);return true;}if(ev.name==="enter"||(ev.name==="char"&&ev.key==="l")){this.activate();return true;}if(ev.name==="backspace"||(ev.name==="char"&&ev.key==="h")){this.path=dirname(this.path);this.load();return true;}return false;}
799
+ onMouse(ev){if(ev.kind==="press"&&ev.button===0){const idx=this.scroll+ev.y-this.y-2;if(idx>=0&&idx<this.items().length){this.sel=idx;this.activate();}return true;}if(ev.kind==="wheel-up"){this.sel=wrapIndex(this.sel-1,this.items().length);return true;}if(ev.kind==="wheel-down"){this.sel=wrapIndex(this.sel+1,this.items().length);return true;}return false;}
658
800
  }
659
801
 
660
802
  // ---- Trajectory view ----
@@ -672,6 +814,8 @@ export class TrajectoryPanel extends Widget {
672
814
  this.allEvents = [];
673
815
  this.sessionId = null;
674
816
  this.expandedSteps = new Set(); // step identity keys rendered 详细 (expanded)
817
+ this.selectedStepKey = null; // stable first-event seq of the keyboard-selected step
818
+ this.visibleStepIndices = []; // current render order for circular ↑/↓ navigation
675
819
  this.flashKey = null; // step key just jumped to (brief highlight)
676
820
  this.flashUntil = 0;
677
821
  this.loadPromise = null; // dedupes concurrent load(currentSession)
@@ -714,42 +858,46 @@ export class TrajectoryPanel extends Widget {
714
858
  return e.type;
715
859
  }
716
860
 
717
- /** Right-click menu entry: the old event-detail picker for one step. */
718
- #showDetail(si) {
719
- const step = this.steps[si];
720
- if (!step) return;
721
- const t0 = step.events[0]?.time, t1 = step.events[step.events.length - 1]?.time;
722
- const items = step.events.map((e, i) => ({ label: truncate(this.#eventSummary(e), 60), hint: `#${e.seq}`, idx: i, event: e }));
723
- if (t0 && t1) items.unshift({ label: `⏱ 步骤 ${step.step} · ${step.events.length} 事件 · ${fmtMs(t1 - t0)}`, hint: "", idx: -1, event: null });
724
- const w = Math.min(78, this.app.screen.w - 8), h = Math.min(22, this.app.screen.h - 4);
725
- this.app.overlay = new Picker({
726
- x: Math.floor((this.app.screen.w - w) / 2), y: Math.floor((this.app.screen.h - h) / 2),
727
- w, h, title: "轨迹详情", items,
728
- onCancel: () => this.app.closeOverlay(),
729
- onPick: (it) => {
730
- if (!it.event) { this.app.closeOverlay(); return; }
731
- const e = it.event;
732
- const d = e.data ?? {};
733
- let body = "";
734
- if (e.type === "tool/result") body = String(d.message?.content?.[0]?.content?.map((c) => c.text ?? "").join("\n") ?? JSON.stringify(d));
735
- else if (e.type === "tool/call") body = String(d.arguments ?? "");
736
- else if (e.type === "assistant/message") body = String(d.message?.content?.map((c) => c.text ?? "").join("\n") ?? "");
737
- else body = JSON.stringify(d, null, 2);
738
- const lines = body.split("\n").slice(0, 40).map((l) => [{ t: truncate(l, w - 6), fg: K.TXT }]);
739
- const pw = Math.min(80, this.app.screen.w - 4);
740
- const ph = Math.min(lines.length + 5, 24);
741
- this.app.overlay = new Popup({
742
- x: Math.floor((this.app.screen.w - pw) / 2), y: Math.floor((this.app.screen.h - ph) / 2),
743
- w: pw, h: ph, title: `#${e.seq} ${e.type}`,
744
- lines: [[{ t: "" }], ...lines], buttons: [{ label: "关闭", action: "close" }],
745
- onAction: () => this.app.closeOverlay(),
746
- });
747
- this.app.redraw();
748
- },
749
- });
861
+ #selectedIndex() {
862
+ if (this.steps.length === 0) return -1;
863
+ let index = this.steps.findIndex((step) => this.stepKey(step) === this.selectedStepKey);
864
+ if (index < 0) {
865
+ index = this.visibleStepIndices[0] ?? this.steps.length - 1;
866
+ this.selectedStepKey = this.stepKey(this.steps[index]);
867
+ }
868
+ return index;
869
+ }
870
+ #moveSelection(delta) {
871
+ if (this.visibleStepIndices.length === 0) return false;
872
+ const current = this.#selectedIndex();
873
+ let pos = this.visibleStepIndices.indexOf(current);
874
+ if (pos < 0) pos = this.visibleStepIndices.length - 1;
875
+ const index = this.visibleStepIndices[wrapIndex(pos + delta, this.visibleStepIndices.length)];
876
+ this.selectedStepKey = this.stepKey(this.steps[index]);
877
+ const line = this.stepLines.indexOf(index);
878
+ if (line < this.view.scrollY) this.view.scrollY = line;
879
+ else if (line >= this.view.scrollY + this.view.h) this.view.scrollY = Math.max(0, line - this.view.h + 1);
880
+ this.buildLines();
750
881
  this.app.redraw();
882
+ return true;
883
+ }
884
+ #menuItems(si) {
885
+ const step = this.steps[si];
886
+ const key = step ? this.stepKey(step) : null;
887
+ const currentIndex = () => this.steps.findIndex((candidate) => this.stepKey(candidate) === key);
888
+ const open = step && this.expandedSteps.has(key);
889
+ return step ? [
890
+ { label: open ? "折叠(简略)" : "展开(详细)", action: () => { const index = currentIndex(); if (index >= 0) this.#toggleStep(index); } },
891
+ { label: "转跳对话", action: () => { const index = currentIndex(); if (index >= 0) this.app.jumpToChatStep(index); } },
892
+ ] : [];
893
+ }
894
+ openSelectedMenu() {
895
+ const si = this.#selectedIndex();
896
+ if (si < 0) return false;
897
+ const line = this.stepLines.indexOf(si);
898
+ this.app.openMenu(this.#menuItems(si), { x: this.view.x + 4, y: this.view.y + Math.max(0, line - this.view.scrollY) });
899
+ return true;
751
900
  }
752
-
753
901
  #toggleStep(si) {
754
902
  const step = this.steps[si];
755
903
  if (!step) return;
@@ -768,9 +916,10 @@ export class TrajectoryPanel extends Widget {
768
916
  this.app.redraw();
769
917
  }
770
918
 
771
- /** Stable identity of a step across loadOlder re-segmentation: the seq of
772
- * its first event (step indexes shift when older steps are prepended). */
773
- stepKey(step) { return step.events[0]?.seq ?? `step-${step.step}`; }
919
+ /** Stable identity across prepends: prefer the step/turn start sequence.
920
+ * A history page may begin in the middle of a step; its leading fragment
921
+ * then merges into the real start when the previous page arrives. */
922
+ stepKey(step) { return step.startSeq ?? step.events[0]?.seq ?? `step-${step.step}`; }
774
923
 
775
924
  /** Step index whose events carry the given message id (-1 when absent). */
776
925
  indexOfMessage(messageId) {
@@ -831,6 +980,7 @@ export class TrajectoryPanel extends Widget {
831
980
  if (si < 0 || si >= this.steps.length) return;
832
981
  const key = this.stepKey(this.steps[si]);
833
982
  this.expandedSteps.add(key);
983
+ this.selectedStepKey = key;
834
984
  this.flashKey = key;
835
985
  this.flashUntil = Date.now() + 3000;
836
986
  // load older pages until at least 20 steps sit above the target
@@ -966,6 +1116,13 @@ export class TrajectoryPanel extends Widget {
966
1116
  this.stats = null;
967
1117
  this.hasMore = false;
968
1118
  this.minSeq = null;
1119
+ this.expandedSteps.clear();
1120
+ this.selectedStepKey = null;
1121
+ this.visibleStepIndices = [];
1122
+ this.flashKey = null; this.flashUntil = 0;
1123
+ this.winSeqLo = null; this.winSeqHi = null;
1124
+ this.query = "";
1125
+ this.view.scrollY = 0;
969
1126
  this.app.setStatus("加载轨迹…");
970
1127
  try {
971
1128
  // One bounded call for the recent steps (maxMessages = model messages =
@@ -975,7 +1132,9 @@ export class TrajectoryPanel extends Widget {
975
1132
  this.stats = h.projections?.values?.sessionStats ?? null;
976
1133
  this.minSeq = h.events[0]?.event?.seq ?? null;
977
1134
  this.hasMore = h.hasMore;
978
- this.allEvents = h.events;
1135
+ const bySeq = new Map();
1136
+ for (const wrapped of h.events ?? []) { const seq = wrapped?.event?.seq; if (seq != null) bySeq.set(seq, wrapped); }
1137
+ this.allEvents = [...bySeq.values()].sort((a, b) => a.event.seq - b.event.seq);
979
1138
  this.build();
980
1139
  } catch (e) { this.app.toast(`轨迹加载失败: ${e.message}`); }
981
1140
  this.loading = false;
@@ -994,10 +1153,22 @@ export class TrajectoryPanel extends Widget {
994
1153
  if (this.sessionId !== sessionId || this.loadToken !== token) { this.loadingOlder = false; return; }
995
1154
  if (h.events.length === 0) { this.hasMore = false; }
996
1155
  else {
1156
+ const previousMinSeq = this.minSeq;
997
1157
  this.minSeq = h.events[0]?.event?.seq ?? this.minSeq;
998
- this.hasMore = h.hasMore;
999
- this.allEvents = [...h.events, ...this.allEvents].sort((a, b) => a.event.seq - b.event.seq);
1158
+ this.hasMore = h.hasMore && this.minSeq < previousMinSeq;
1159
+ const bySeq = new Map();
1160
+ for (const wrapped of [...h.events, ...this.allEvents]) {
1161
+ const seq = wrapped?.event?.seq;
1162
+ if (seq == null) continue;
1163
+ bySeq.set(seq, wrapped);
1164
+ }
1165
+ const selectedEventSeq = this.steps.find((step) => this.stepKey(step) === this.selectedStepKey)?.events[0]?.seq ?? null;
1166
+ const expandedEventSeqs = [...this.expandedSteps].map((key) => this.steps.find((step) => this.stepKey(step) === key)?.events[0]?.seq).filter((seq) => seq != null);
1167
+ this.allEvents = [...bySeq.values()].sort((a, b) => a.event.seq - b.event.seq);
1000
1168
  this.build();
1169
+ const keyForEvent = (seq) => { const step = this.steps.find((candidate) => candidate.events.some((event) => event.seq === seq)); return step ? this.stepKey(step) : null; };
1170
+ if (selectedEventSeq != null) this.selectedStepKey = keyForEvent(selectedEventSeq);
1171
+ this.expandedSteps = new Set(expandedEventSeqs.map(keyForEvent).filter((key) => key != null));
1001
1172
  }
1002
1173
  } catch (e) { this.app.toast(`加载更早失败: ${e.message}`); }
1003
1174
  this.loadingOlder = false;
@@ -1006,18 +1177,33 @@ export class TrajectoryPanel extends Widget {
1006
1177
  this.app.redraw();
1007
1178
  }
1008
1179
  build() {
1009
- // Segment on step/start: each model message is one step (the web view's
1010
- // trajectory node), which is what maxMessages actually pages over.
1180
+ // Segment on step/start: turn/start is turn metadata, not a model step.
1181
+ // Keep leading pre-step/page fragments so events remain inspectable and
1182
+ // merge them into the first real step rather than fabricating phantom rows.
1011
1183
  const steps = [];
1012
1184
  let cur = null;
1185
+ let pending = [];
1013
1186
  for (const { event } of this.allEvents) {
1014
1187
  const d = event.data ?? {};
1015
- if (event.type === "step/start" || event.type === "turn/start") {
1188
+ if (event.type === "turn/start") {
1016
1189
  if (cur && cur.events.length) steps.push(cur);
1017
- cur = { events: [], step: d.step ?? steps.length + 1, turn: d.turn };
1190
+ else if (pending.length) steps.push({ events: pending, step: "?", startSeq: null, partial: true });
1191
+ cur = null; pending = [event];
1192
+ } else if (event.type === "step/start") {
1193
+ if (cur && cur.events.length) steps.push(cur);
1194
+ if (pending.length && pending[0]?.type !== "turn/start") {
1195
+ steps.push({ events: pending, step: "?", startSeq: null, partial: true });
1196
+ pending = [];
1197
+ }
1198
+ cur = { events: [...pending, event], step: d.step ?? steps.length + 1, turn: d.turn ?? pending[0]?.data?.turn, startSeq: event.seq };
1199
+ pending = [];
1200
+ } else if (!cur) {
1201
+ pending.push(event);
1202
+ } else {
1203
+ cur.events.push(event);
1018
1204
  }
1019
- if (cur) cur.events.push(event);
1020
1205
  }
1206
+ if (!cur && pending.length) cur = { events: pending, step: "?", turn: pending.find((event) => event.type === "turn/start")?.data?.turn, startSeq: null, partial: true };
1021
1207
  if (cur && cur.events.length) steps.push(cur);
1022
1208
  // Keep every loaded step: Home/End navigation pages across the whole
1023
1209
  // session, so older steps must survive until `r` re-fetches fresh.
@@ -1035,19 +1221,26 @@ export class TrajectoryPanel extends Widget {
1035
1221
  }
1036
1222
  const winIdxLo = this.steps.findIndex((s) => this.stepKey(s) === loSeq);
1037
1223
  const winIdxHi = this.steps.findIndex((s) => this.stepKey(s) === hiSeq);
1038
- const loStepNum = this.steps[winIdxLo]?.step ?? "?";
1039
- const hiStepNum = this.steps[winIdxHi]?.step ?? "?";
1224
+ if (this.steps.length && (winIdxLo < 0 || winIdxHi < 0)) {
1225
+ loSeq = this.stepKey(this.steps[Math.max(0, this.steps.length - 20)]);
1226
+ hiSeq = this.stepKey(this.steps[this.steps.length - 1]);
1227
+ this.winSeqLo = loSeq; this.winSeqHi = hiSeq;
1228
+ }
1229
+ const safeLo = this.steps.findIndex((s) => this.stepKey(s) === loSeq);
1230
+ const safeHi = this.steps.findIndex((s) => this.stepKey(s) === hiSeq);
1231
+ const loStepNum = this.steps[safeLo]?.step ?? "?";
1232
+ const hiStepNum = this.steps[safeHi]?.step ?? "?";
1040
1233
  const lines = [];
1041
- lines.push([{ t: "轨迹 — 步骤时间轴(左键展开/折叠 · PgUp/PgDn 上下加载 · Home/End 首尾 · Ctrl+E 转跳 · r 刷新)", fg: K.ACCENT, bold: true }]);
1234
+ lines.push([{ t: "轨迹 — ↑↓ 选择 · Ctrl+↑↓ 滚动 · Space 展开 · Enter 转跳对话 · Ctrl+R 菜单 · PgUp/PgDn 加载", fg: K.ACCENT, bold: true }]);
1042
1235
  if (this.hasMore) lines.push([{ t: "▲ 更早步骤(点击 / PgUp 向上加载 10 步)", fg: K.FAINT }]);
1043
1236
  else lines.push([{ t: "" }]);
1044
1237
  const st = this.stats;
1045
1238
  if (st) {
1046
1239
  lines.push([{ t: `回合 ${st.turns} · 步骤 ${st.steps} · LLM ${fmtMs(st.llmMs)} · 工具 ${fmtMs(st.toolMs)}`, fg: K.DIM }]);
1047
1240
  }
1048
- lines.push([{ t: `窗口 #${winIdxLo + 1}–#${winIdxHi + 1}(已加载 ${this.steps.length}${this.hasMore ? "+" : ""})· step ${loStepNum}–${hiStepNum}${this.winSeqLo == null ? "(跟随最新)" : ""}:`, fg: K.DIM, underline: true }]);
1241
+ lines.push([{ t: `窗口 #${safeLo + 1}–#${safeHi + 1}(已加载 ${this.steps.length}${this.hasMore ? "+" : ""})· step ${loStepNum}–${hiStepNum}${this.winSeqLo == null ? "(跟随最新)" : ""}:`, fg: K.DIM, underline: true }]);
1049
1242
  this.stepLines = [];
1050
- const list = this.query
1243
+ const list = (this.query
1051
1244
  ? this.steps.filter((t) => t.events.some((e) => {
1052
1245
  const d = e.data ?? {};
1053
1246
  const hay = `${e.type} ${d.name ?? ""} ${typeof d.content === "string" ? d.content : ""}`.toLowerCase();
@@ -1056,22 +1249,29 @@ export class TrajectoryPanel extends Widget {
1056
1249
  : this.steps.filter((s) => {
1057
1250
  const k = this.stepKey(s);
1058
1251
  return k >= loSeq && k <= hiSeq;
1059
- });
1060
- for (const step of list.reverse()) {
1252
+ })).reverse();
1253
+ this.visibleStepIndices = list.map((step) => this.steps.indexOf(step));
1254
+ if (this.visibleStepIndices.length && !this.visibleStepIndices.some((si) => this.stepKey(this.steps[si]) === this.selectedStepKey)) {
1255
+ // The newest step is the first rendered row because trajectory is reverse chronological.
1256
+ this.selectedStepKey = this.stepKey(this.steps[this.visibleStepIndices[0]]);
1257
+ }
1258
+ for (const step of list) {
1061
1259
  const si = this.steps.indexOf(step);
1062
1260
  const tools = [...new Set(step.events.filter((e) => e.type === "tool/call").map((e) => e.data?.name))];
1063
- const hasResult = step.events.some((e) => e.type === "tool/result");
1064
1261
  const hasReasoning = step.events.some((e) => e.type === "assistant/chunk" && e.data?.chunk?.blockType === "reasoning");
1065
1262
  const t0 = step.events[0]?.time, t1 = step.events[step.events.length - 1]?.time;
1066
1263
  // deep-dive style live timer: the newest step ticks while the turn runs
1067
1264
  const isLiveTail = this.app.chat?.running && this.winSeqLo == null && si === this.steps.length - 1;
1068
1265
  const dur = isLiveTail ? `⏱${fmtMs(Date.now() - (t0 ?? Date.now()))}` : (t0 && t1 ? fmtMs(t1 - t0) : "—");
1069
- const bg = tools.length ? (hasResult ? T.TOOLOK : T.TOOLBG) : hasReasoning ? T.THINKBG : T.CARD;
1266
+ // Tool-heavy timelines stay neutral: gray reveals the clickable step
1267
+ // range without turning every successful bash call into a green slab.
1268
+ const bg = tools.length ? T.CARD : hasReasoning ? T.THINKBG : T.CARD;
1070
1269
  const summary = tools.slice(0, 3).join(",") || (hasReasoning ? "模型推理" : "纯文本");
1071
1270
  const open = this.expandedSteps.has(this.stepKey(step)); // 详细
1072
1271
  const flash = this.flashKey === this.stepKey(step) && Date.now() < this.flashUntil;
1073
1272
  const rowBg = flash ? T.ACCENT : bg;
1074
- const label = `${open ? "▾" : "▸"} step ${String(step.step).padStart(3)} ${pad(dur, 8)} ${summary} ${open ? "[折叠]" : "[展开]"}`;
1273
+ const selected = this.stepKey(step) === this.selectedStepKey;
1274
+ const label = `${selected ? "=>" : " "} ${open ? "▾" : "▸"} step ${String(step.step).padStart(3)} ${pad(dur, 8)} ${summary} ${open ? "[折叠]" : "[展开]"}`;
1075
1275
  const segs = [{ t: label, fg: flash ? T.SELFG : K.TXT, bg: rowBg, bold: true }];
1076
1276
  const fill = w - strWidth(label);
1077
1277
  if (fill > 0) segs.push({ t: " ".repeat(fill), bg: rowBg });
@@ -1081,7 +1281,9 @@ export class TrajectoryPanel extends Widget {
1081
1281
  // 详细 mode = deep dive: the step's events inline under its color
1082
1282
  // block, each with its OWN duration (web-style Δ timer: time since
1083
1283
  // the previous event; the first is measured from the step start).
1084
- const evs = step.events.slice(0, 12);
1284
+ // Expanded means complete: every event stays reachable by ordinary
1285
+ // viewport scrolling, replacing the removed duplicate detail picker.
1286
+ const evs = step.events;
1085
1287
  let prev = null;
1086
1288
  for (const e of evs) {
1087
1289
  const dt = prev != null && e.time != null ? ` Δ${fmtMs(e.time - prev)}` : "";
@@ -1089,10 +1291,6 @@ export class TrajectoryPanel extends Widget {
1089
1291
  this.stepLines[lines.length - 1] = si;
1090
1292
  prev = e.time;
1091
1293
  }
1092
- if (step.events.length > evs.length) {
1093
- lines.push([{ t: ` …共 ${step.events.length} 个事件(右键 → 查看详情)`, fg: K.FAINT, bg }]);
1094
- this.stepLines[lines.length - 1] = si;
1095
- }
1096
1294
  }
1097
1295
  }
1098
1296
  this.view.setLines(lines);
@@ -1120,15 +1318,18 @@ export class TrajectoryPanel extends Widget {
1120
1318
  }
1121
1319
  /** Re-fetch the tail window while following the live turn. */
1122
1320
  async #refreshTail() {
1123
- if (!this.sessionId) return;
1321
+ if (!this.sessionId || this.loadingOlder) return;
1124
1322
  const sessionId = this.sessionId;
1125
1323
  const token = this.loadToken;
1126
1324
  this.refreshing = true;
1127
1325
  try {
1128
1326
  const h = await this.app.api.call("session.history", { sessionId, maxMessages: 20 });
1129
1327
  if (this.sessionId !== sessionId || this.loadToken !== token) { this.refreshing = false; return; }
1130
- this.allEvents = h.events;
1131
- this.minSeq = h.events[0]?.event?.seq ?? this.minSeq;
1328
+ if (this.loadingOlder || this.winSeqLo != null) { this.refreshing = false; return; }
1329
+ const bySeq = new Map();
1330
+ for (const wrapped of h.events ?? []) { const seq = wrapped?.event?.seq; if (seq != null) bySeq.set(seq, wrapped); }
1331
+ this.allEvents = [...bySeq.values()].sort((a, b) => a.event.seq - b.event.seq);
1332
+ this.minSeq = this.allEvents[0]?.event?.seq ?? this.minSeq;
1132
1333
  this.hasMore = h.hasMore;
1133
1334
  this.stats = h.projections?.values?.sessionStats ?? this.stats;
1134
1335
  this.build();
@@ -1144,12 +1345,9 @@ export class TrajectoryPanel extends Widget {
1144
1345
  const si = this.stepLines[y];
1145
1346
  const step = si !== undefined ? this.steps[si] : null;
1146
1347
  if (step) {
1147
- const open = this.expandedSteps.has(this.stepKey(step));
1148
- this.app.openMenu([
1149
- { label: open ? "折叠(简略)" : "展开(详细)", action: () => this.#toggleStep(si) },
1150
- { label: "转跳对话", action: () => this.app.jumpToChatStep(si) },
1151
- { label: "查看详情", action: () => this.#showDetail(si) },
1152
- ], ev);
1348
+ this.selectedStepKey = this.stepKey(step);
1349
+ this.buildLines();
1350
+ this.app.openMenu(this.#menuItems(si), ev);
1153
1351
  return true;
1154
1352
  }
1155
1353
  if (this.hasMore && y === 1) {
@@ -1168,6 +1366,9 @@ export class TrajectoryPanel extends Widget {
1168
1366
  return false;
1169
1367
  }
1170
1368
  onKey(ev) {
1369
+ // Legacy terminals deliver an unmodified Space as text rather than a key
1370
+ // event. It is a trajectory action, not a hidden-query character.
1371
+ if (ev.type === "text" && ev.text === " ") { const si = this.#selectedIndex(); if (si >= 0) this.#toggleStep(si); return true; }
1171
1372
  if (ev.type === "text") { this.query += ev.text; this.buildLines(); this.app.redraw(); return true; }
1172
1373
  if (ev.type !== "key") return false;
1173
1374
  if (ev.name === "escape") {
@@ -1176,9 +1377,15 @@ export class TrajectoryPanel extends Widget {
1176
1377
  return true;
1177
1378
  }
1178
1379
  if (ev.name === "backspace") { this.query = this.query.slice(0, -1); this.buildLines(); this.app.redraw(); return true; }
1380
+ if (ev.ctrl && (ev.name === "up" || ev.name === "down")) { this.view.scroll(ev.name === "up" ? -1 : 1); this.app.redraw(); return true; }
1381
+ if (ev.name === "up" || ev.name === "down") return this.#moveSelection(ev.name === "up" ? -1 : 1);
1382
+ if (ev.name === "char" && ev.key === " " && !ev.ctrl) { const si = this.#selectedIndex(); if (si >= 0) this.#toggleStep(si); return true; }
1383
+ if (ev.name === "enter") { const si = this.#selectedIndex(); if (si >= 0) this.app.jumpToChatStep(si); return true; }
1384
+ if (ev.name === "char" && ev.key === "r" && ev.ctrl) return this.openSelectedMenu();
1179
1385
  if (ev.name === "char" && ev.key === "r" && !ev.ctrl) {
1180
1386
  this.winSeqLo = this.winSeqHi = null;
1181
1387
  this.steps = [];
1388
+ this.selectedStepKey = null;
1182
1389
  this.load(this.sessionId);
1183
1390
  return true;
1184
1391
  }
@@ -1186,7 +1393,6 @@ export class TrajectoryPanel extends Widget {
1186
1393
  if (ev.name === "pgdn") { this.extendDown(); return true; }
1187
1394
  if (ev.name === "home") { this.gotoHome(); return true; }
1188
1395
  if (ev.name === "end") { this.gotoEnd(); return true; }
1189
- if (ev.name === "up" || ev.name === "down") return this.view.onKey(ev);
1190
1396
  return false;
1191
1397
  }
1192
1398
  }
@@ -1431,7 +1637,7 @@ export class ControlPanel extends Widget {
1431
1637
  this.loadCommands();
1432
1638
  this.loadPlugins();
1433
1639
  }
1434
- editShortcut(id){const back=this,b=keyBindings()[id];const input=new Input({x:this.x+8,y:this.y+7,w:this.w-16,h:1,prompt:'JSON: ',allowEmptyEnter:true,onEnter(value){try{const parsed=JSON.parse(value);if(!["normal","insert","all"].includes(parsed.mode)||typeof parsed.key!=="string"||!parsed.key.trim())throw new Error('需要 {"mode":"normal|insert|all","key":"..."}');if(!setKeyBinding(id,parsed))throw new Error('写入配置失败');back.app.overlay=back;back.app.focus(back);back.app.toast('快捷键已保存');}catch(e){input.setValue(value);back.app.toast(`语法错误: ${e.message}`);}}});input.setValue(JSON.stringify(b));const pop=new Popup({x:this.x+6,y:this.y+5,w:this.w-12,h:6,title:`编辑 tui-config.json · keyBindings.${id}`,lines:[`配置项: keyBindings.${id}`],buttons:[]});pop.render=(s)=>{Popup.prototype.render.call(pop,s);input.render(s);};pop.onKey=(ev)=>{if(ev.type==='key'&&ev.name==='escape'){back.app.overlay=back;back.app.focus(back);return true;}return input.onKey(ev);};this.app.overlay=pop;this.app.focus(input);}
1640
+ editShortcut(id){const back=this,b=keyBindings()[id];const input=new Input({x:this.x+8,y:this.y+7,w:this.w-16,h:1,prompt:'JSON: ',allowEmptyEnter:true,onEnter(value){try{const parsed=JSON.parse(value);if(!["normal","insert","all"].includes(parsed.mode)||typeof parsed.key!=="string"||!parsed.key.trim())throw new Error('需要 {"mode":"normal|insert|all","key":"..."[, "key2":"..."]}');const k2=typeof parsed.key2==="string"?parsed.key2.trim():"";const vk=validateKeySpec(parsed.key);if(!vk.ok)throw new Error(`key: ${vk.reason}`);if(k2){const vk2=validateKeySpec(k2);if(!vk2.ok)throw new Error(`key2: ${vk2.reason}`);}if(!setKeyBinding(id,{mode:parsed.mode,key:parsed.key.trim(),key2:k2}))throw new Error('写入配置失败');back.app.overlay=back;back.app.focus(back);back.app.toast('快捷键已保存');}catch(e){input.setValue(value);back.app.toast(`语法错误: ${e.message}`);}}});input.setValue(JSON.stringify(b));const pop=new Popup({x:this.x+6,y:this.y+5,w:this.w-12,h:7,title:`编辑 tui-config.json · keyBindings.${id}`,lines:[`配置项: keyBindings.${id} · 两个槽位 key(主)/ key2(备)`,`示例: {"mode":"normal","key":"Ctrl+F","key2":"/"}`],buttons:[]});pop.render=(s)=>{Popup.prototype.render.call(pop,s);input.render(s);};pop.onKey=(ev)=>{if(ev.type==='key'&&ev.name==='escape'){back.app.overlay=back;back.app.focus(back);return true;}return input.onKey(ev);};this.app.overlay=pop;this.app.focus(input);}
1435
1641
  async loadCommands() {
1436
1642
  try {
1437
1643
  const agentId = this.app.currentSession;
@@ -1451,9 +1657,9 @@ export class ControlPanel extends Widget {
1451
1657
  }
1452
1658
  shortcutItems() {
1453
1659
  const b=keyBindings();
1454
- const row=(id,desc)=>[`${(b[id]?.mode??"all").toUpperCase()}\t${b[id]?.key??""}`,desc,null,id];
1660
+ const row=(id,desc)=>[`${(b[id]?.mode??"all").toUpperCase()}\t${describeSpec(b[id]?.key)}\t${describeSpec(b[id]?.key2)}`,desc,null,id];
1455
1661
  return [
1456
- row("think","思考块 展开/折叠"),row("tools","工具块 展开/折叠"),row("insert","进入输入"),row("leaveInsert","退出输入"),row("sessionFilter","筛选会话"),row("newSession","新建会话"),row("top","滚动到顶"),row("bottom","滚动到底"),row("prevQuestion","上一提问的终点"),row("nextQuestion","下一提问的终点"),row("expandInput","输入栏 展开/折叠"),row("copyInput","复制输入栏选区"),row("panel","控制面板"),row("model","切换模型"),row("trajectory","轨迹视图"),row("homeSwitch","对话/轨迹切换"),row("permissionRotate","权限模式轮换"),row("workspace","工作区"),row("settings","设置"),row("subagent","子代理"),row("skills","技能"),row("goal","目标"),row("jobs","后台任务"),row("stepJump","步骤转跳"),row("sidebar","侧栏显示/隐藏"),row("quit","退出"),
1662
+ row("think","思考块 展开/折叠"),row("tools","工具块 展开/折叠"),row("insert","进入输入"),row("leaveInsert","退出输入"),row("sessionFilter","跨会话搜索"),row("newSession","新建会话"),row("top","跳到首个正文块"),row("bottom","跳到最新正文块"),row("prevQuestion","上一提问的终点"),row("nextQuestion","下一提问的终点"),row("expandInput","输入栏 展开/折叠"),row("copyInput","复制输入栏选区"),row("panel","控制面板"),row("model","切换模型"),row("trajectory","轨迹视图"),row("homeSwitch","pane 焦点切换"),row("permissionRotate","权限模式轮换"),row("workspace","工作区"),row("settings","设置"),row("subagent","子代理"),row("skills","技能"),row("goal","目标"),row("jobs","后台任务"),row("queue","后台队列"),row("busyEnter","运行中 Enter 策略"),row("attachments","附件管理"),row("stepJump","步骤转跳"),row("sidebar","侧栏显示/隐藏"),row("editConfig","编辑配置文件(默认编辑器)"),row("quit","退出"),
1457
1663
  ];
1458
1664
  }
1459
1665
  items() {
@@ -1475,7 +1681,7 @@ export class ControlPanel extends Widget {
1475
1681
  ["模型管理(含思考强度)", "切换模型并选择思考强度", () => { this.app.overlay = buildModelPicker(this.app); }],
1476
1682
  ["模式(Agent 预设)", "标准 / PTC / 极简 / 创造", () => { this.app.overlay = buildModePicker(this.app); this.app.redraw(); }],
1477
1683
  ["权限(沙箱 + 审批)", "只读 / 工作区写入 / 完全访问", () => { this.app.overlay = buildPermissionPicker(this.app); this.app.redraw(); }],
1478
- ["完整设置(JSON 编辑器)", "所有命名空间的原始值", () => { this.app.closeOverlay(); this.app.setMode("settings"); }],
1684
+ ["完整设置(JSON 编辑器)", "所有命名空间的原始值", () => { this.app.closeOverlay(); this.app.showSettingsBuffer ? this.app.showSettingsBuffer() : this.app.setMode?.("settings"); }],
1479
1685
  ["切换主题", "dark / light / gruvbox", () => { cycleTheme(); this.app.toast(`主题: ${themeName()}`); }],
1480
1686
  ["侧栏显示/隐藏", "nvim 式整体收起", () => this.app.toggleSidebar()],
1481
1687
  ["导出当前会话日志", "下载 ZIP", () => { const sess = this.app.sessions.find((x) => x.sessionId === this.app.currentSession); if (sess) { this.app.closeOverlay(); this.app.exportSession(sess); } }],
@@ -1505,7 +1711,7 @@ export class ControlPanel extends Widget {
1505
1711
  if (this.sel < this.scroll) this.scroll = this.sel;
1506
1712
  else if (this.sel >= this.scroll + visible) this.scroll = this.sel - visible + 1;
1507
1713
  this.scroll = Math.max(0, Math.min(Math.max(0, items.length - visible), this.scroll));
1508
- if(this.page===0){s.text(this.x+2,this.y+1,"MODE",{fg:T.PURPLE,bg:T.PANEL,attrs:1});s.text(this.x+13,this.y+1,"KEY",{fg:T.ACCENT,bg:T.PANEL,attrs:1});s.text(this.x+32,this.y+1,"FUNCTION",{fg:T.OK,bg:T.PANEL,attrs:1});}
1714
+ if(this.page===0){s.text(this.x+2,this.y+1,"MODE",{fg:T.PURPLE,bg:T.PANEL,attrs:1});s.text(this.x+13,this.y+1,"KEY1",{fg:T.ACCENT,bg:T.PANEL,attrs:1});s.text(this.x+31,this.y+1,"KEY2",{fg:T.ACCENT,bg:T.PANEL,attrs:1});s.text(this.x+49,this.y+1,"FUNCTION",{fg:T.OK,bg:T.PANEL,attrs:1});}
1509
1715
  if(this.page===3&&this.pluginFilter){s.text(this.x+2,this.y+1,`/ ${this.pluginQuery}`,{fg:T.ACCENT,bg:T.PANEL,attrs:1});}
1510
1716
  for (let i = 0; i < visible; i++) {
1511
1717
  const idx = this.scroll + i;
@@ -1514,7 +1720,7 @@ export class ControlPanel extends Widget {
1514
1720
  const sel = idx === this.sel;
1515
1721
  s.fillRect(this.x + 1, this.y + 2 + i, this.x + this.w - 2, this.y + 2 + i, " ", { bg: sel ? T.MENUSEL : T.PANEL });
1516
1722
  const label = it[0];
1517
- if(this.page===0){const [mode,key]=label.split("\t");s.text(this.x+2,this.y+2+i,pad(mode,9),{fg:T.PURPLE,bg:sel?T.MENUSEL:T.PANEL,attrs:sel?1:0});s.text(this.x+13,this.y+2+i,pad(truncate(key,17),18),{fg:T.ACCENT,bg:sel?T.MENUSEL:T.PANEL,attrs:sel?1:0});s.text(this.x+32,this.y+2+i,truncate(it[1],this.w-35),{fg:T.OK,bg:sel?T.MENUSEL:T.PANEL,attrs:sel?1:0});}
1723
+ if(this.page===0){const [mode,key1,key2]=label.split("\t");s.text(this.x+2,this.y+2+i,pad(mode,9),{fg:T.PURPLE,bg:sel?T.MENUSEL:T.PANEL,attrs:sel?1:0});s.text(this.x+13,this.y+2+i,pad(truncate(key1,16),17),{fg:T.ACCENT,bg:sel?T.MENUSEL:T.PANEL,attrs:sel?1:0});s.text(this.x+31,this.y+2+i,pad(truncate(key2,16),17),{fg:T.ACCENT,bg:sel?T.MENUSEL:T.PANEL,attrs:sel?1:0});s.text(this.x+49,this.y+2+i,truncate(it[1],this.w-52),{fg:T.OK,bg:sel?T.MENUSEL:T.PANEL,attrs:sel?1:0});}
1518
1724
  else{s.text(this.x + 2, this.y + 2 + i, truncate(label, this.w - 34), { fg: sel ? T.BOLD : T.TXT, bg: sel ? T.MENUSEL : T.PANEL, attrs: sel ? 1 : 0 });if (it[1]) s.text(this.x + this.w - 30, this.y + 2 + i, truncate(it[1], 28), { fg: T.FAINT, bg: sel ? T.MENUSEL : T.PANEL });}
1519
1725
  }
1520
1726
  s.text(this.x + 2, this.y + this.h - 1, this.page===0?"↑↓ 选择 · Enter 编辑 · Shift+Tab 轮换模式 · Alt+Enter 恢复默认 · Esc 关闭":this.page===3?`/ 筛选插件 · Ctrl+/ 清除 · ↑↓ 选择 · Esc 关闭${this.pluginQuery?` · ${this.pluginQuery}`:""}`:"↑↓ 选择 · Enter 执行 · Esc 关闭", { fg: T.FAINT });
@@ -1537,8 +1743,8 @@ export class ControlPanel extends Widget {
1537
1743
  if (ev.name === "backtab" || ev.name === "left") { this.page = (this.page + this.pages.length - 1) % this.pages.length; this.sel = 0; this.app.redraw(); return true; }
1538
1744
  if (ev.name === "pgup" || ev.name === "home") { this.sel = 0; this.app.redraw(); return true; }
1539
1745
  if (ev.name === "pgdn" || ev.name === "end") { this.sel = this.items().length - 1; this.app.redraw(); return true; }
1540
- if (ev.name === "up") { this.sel = Math.max(0, this.sel - 1); this.app.redraw(); return true; }
1541
- if (ev.name === "down") { this.sel = Math.min(this.items().length - 1, this.sel + 1); this.app.redraw(); return true; }
1746
+ if (ev.name === "up") { this.sel = wrapIndex(this.sel - 1, this.items().length); this.app.redraw(); return true; }
1747
+ if (ev.name === "down") { this.sel = wrapIndex(this.sel + 1, this.items().length); this.app.redraw(); return true; }
1542
1748
  if (ev.name === "enter") {
1543
1749
  const it = this.items()[this.sel];
1544
1750
  if(this.page===0&&it?.[3]){this.editShortcut(it[3]);return true;}
@@ -1558,7 +1764,7 @@ export class ControlPanel extends Widget {
1558
1764
  tx += wTab;
1559
1765
  }
1560
1766
  // sub-page tabs (on 设置)
1561
- if (this.page === 2) {
1767
+ if (this.page === 2 && Array.isArray(this.subPages)) {
1562
1768
  let sx = this.x + 2 + strWidth(" 快捷键 命令 设置 ");
1563
1769
  for (let i = 0; i < this.subPages.length; i++) {
1564
1770
  const wTab = strWidth(` ${this.subPages[i]} `);
@@ -1578,8 +1784,8 @@ export class ControlPanel extends Widget {
1578
1784
  return true;
1579
1785
  }
1580
1786
  }
1581
- if (ev.kind === "wheel-up") { this.sel = Math.max(0, this.sel - 1); this.app.redraw(); return true; }
1582
- if (ev.kind === "wheel-down") { this.sel = Math.min(this.items().length - 1, this.sel + 1); this.app.redraw(); return true; }
1787
+ if (ev.kind === "wheel-up") { this.sel = wrapIndex(this.sel - 1, this.items().length); this.app.redraw(); return true; }
1788
+ if (ev.kind === "wheel-down") { this.sel = wrapIndex(this.sel + 1, this.items().length); this.app.redraw(); return true; }
1583
1789
  return true;
1584
1790
  }
1585
1791
  }
@@ -1670,7 +1876,7 @@ export class JobsPanel extends Popup {
1670
1876
  const child = this.subagents[i], bg = i === this.sel ? T.MENUSEL : T.BG2;
1671
1877
  const status = child.activity ?? child.status ?? child.mode ?? "idle";
1672
1878
  const open = this.expanded.has(i);
1673
- lines.push([{ t: ` ${open ? "▾" : "▸"} 🛰 ${truncate(child.label ?? child.sessionId ?? child.id ?? "子代理", 42)} `, fg: K.TXT, bg, bold: i === this.sel }, { t: status, fg: status === "running" ? K.WARN : K.DIM, bg }]); rowOf.push(i);
1879
+ lines.push([{ t: ` ${open ? "▾" : "▸"} ◇ ${truncate(child.label ?? child.sessionId ?? child.id ?? "子代理", 42)} `, fg: K.TXT, bg, bold: i === this.sel }, { t: status, fg: status === "running" ? K.WARN : K.DIM, bg }]); rowOf.push(i);
1674
1880
  if (this.expanded.has(i)) for (const [key, value] of Object.entries(child)) { lines.push([{ t: ` ${key}: ${truncate(typeof value === "object" ? JSON.stringify(value) : value, this.w - 16)}`, fg: K.DIM }]); rowOf.push(-1); }
1675
1881
  }
1676
1882
  this.lines = lines; this.rowOf = rowOf; this.#ensureVisible(); return;
@@ -1721,10 +1927,10 @@ export class JobsPanel extends Popup {
1721
1927
  const current = this.page === "jobs" ? this.jobs : this.subagents;
1722
1928
  if (current.length === 0) return super.onKey(ev);
1723
1929
  if (ev.name === "up" || (ev.name === "char" && ev.key === "k" && !ev.ctrl)) {
1724
- this.sel = Math.max(0, this.sel - 1); this.rebuild(); return true;
1930
+ this.sel = wrapIndex(this.sel - 1, current.length); this.rebuild(); return true;
1725
1931
  }
1726
1932
  if (ev.name === "down" || (ev.name === "char" && ev.key === "j" && !ev.ctrl)) {
1727
- this.sel = Math.min(current.length - 1, this.sel + 1); this.rebuild(); return true;
1933
+ this.sel = wrapIndex(this.sel + 1, current.length); this.rebuild(); return true;
1728
1934
  }
1729
1935
  if (ev.name === "enter" || (ev.name === "char" && ev.key === "l" && !ev.ctrl)) {
1730
1936
  if (current[this.sel]) { if (this.expanded.has(this.sel)) this.expanded.delete(this.sel); else this.expanded.add(this.sel); this.rebuild(); } return true;
@@ -1752,25 +1958,157 @@ export class QueuePanel extends Popup {
1752
1958
  const items = app.queueItems ?? [];
1753
1959
  const w = Math.max(24, Math.min(84, app.screen.w - 4));
1754
1960
  const h = Math.max(7, Math.min(24, app.screen.h - 4));
1755
- super({ x: Math.max(0, Math.floor((app.screen.w - w) / 2)), y: Math.max(0, Math.floor((app.screen.h - h) / 2)), w, h, title: "排队命令 · ↑↓ 选择 · dd 删除 · Esc 关闭", lines: [], buttons: [], scrollable: true });
1756
- this.app = app; this.items = items; this.sel = 0; this.pending = false; this.dArmed=false; this.rebuild();
1961
+ super({
1962
+ x: Math.max(0, Math.floor((app.screen.w - w) / 2)), y: Math.max(0, Math.floor((app.screen.h - h) / 2)), w, h,
1963
+ title: "排队命令 · j/k 选择 · Enter 展开 · PgUp/PgDn 滚动 · ? 帮助",
1964
+ lines: [], buttons: [], scrollable: true,
1965
+ });
1966
+ this.app = app;
1967
+ this.items = items;
1968
+ this.sel = 0;
1969
+ this.pending = false;
1970
+ this.dArmed = false;
1971
+ this.helpVisible = false;
1972
+ this.expanded = new Set(); // stable queue item keys, survives mux refresh/reorder
1973
+ this.rowOf = []; // rendered line → queue item index
1974
+ this.rebuild();
1757
1975
  }
1758
- rebuild() {
1976
+ #itemKey(item, index = this.items.indexOf(item)) {
1977
+ return String(item?.id ?? item?.message?.id ?? `${item?.placement ?? "queue"}:${index}:${partsText(item?.message?.content).slice(0, 80)}`);
1978
+ }
1979
+ #wrap(label, value, fg = K.TXT) {
1980
+ const rows = [];
1981
+ const firstHead = ` ${label}: `;
1982
+ const nextHead = " ".repeat(strWidth(firstHead));
1983
+ const width = Math.max(8, this.w - 4 - strWidth(firstHead));
1984
+ let first = true;
1985
+ for (const raw of String(value ?? "").split("\n")) {
1986
+ if (raw === "") {
1987
+ rows.push([{ t: first ? firstHead : nextHead, fg: K.DIM }]);
1988
+ first = false;
1989
+ continue;
1990
+ }
1991
+ let line = "", used = 0;
1992
+ for (const ch of graphemes(raw)) {
1993
+ const cw = graphemeWidth(ch);
1994
+ if (line && used + cw > width) {
1995
+ rows.push([{ t: first ? firstHead : nextHead, fg: K.DIM }, { t: line, fg }]);
1996
+ first = false; line = ""; used = 0;
1997
+ }
1998
+ line += ch; used += cw;
1999
+ }
2000
+ rows.push([{ t: first ? firstHead : nextHead, fg: K.DIM }, { t: line, fg }]);
2001
+ first = false;
2002
+ if (rows.length >= 180) break;
2003
+ }
2004
+ return rows;
2005
+ }
2006
+ #content(item) {
2007
+ const texts = [], extras = [];
2008
+ const walk = (content) => {
2009
+ if (typeof content === "string") { texts.push(content); return; }
2010
+ if (!Array.isArray(content)) return;
2011
+ for (const part of content) {
2012
+ if (!part || typeof part !== "object") continue;
2013
+ if (part.type === "text" && typeof part.text === "string") texts.push(part.text);
2014
+ else {
2015
+ const identity = part.name ?? part.fileName ?? part.attachmentId ?? part.id ?? part.mediaType ?? part.url ?? "";
2016
+ extras.push(`[${part.type ?? "内容"}]${identity ? ` ${identity}` : ""}`);
2017
+ }
2018
+ if (Array.isArray(part.content)) walk(part.content);
2019
+ }
2020
+ };
2021
+ walk(item?.message?.content);
2022
+ return { text: texts.join("\n"), extras };
2023
+ }
2024
+ #detailLines(item) {
1759
2025
  const lines = [];
2026
+ const placement = item.placement === "queued" ? "排队(下一回合)"
2027
+ : item.placement === "steering" ? "追加到当前回合"
2028
+ : item.placement === "context" ? "只读上下文" : (item.placement ?? "未知");
2029
+ lines.push(...this.#wrap("ID", item.id ?? "(无)", K.DIM));
2030
+ lines.push(...this.#wrap("位置", placement, K.DIM));
2031
+ const source = item.message?.source?.kind ?? item.source?.kind;
2032
+ if (source) lines.push(...this.#wrap("来源", source, K.DIM));
2033
+ for (const key of ["createdAt", "updatedAt", "clientTimeZone"]) {
2034
+ if (item[key] != null) lines.push(...this.#wrap(key, item[key], K.DIM));
2035
+ }
2036
+ const content = this.#content(item);
2037
+ lines.push(...this.#wrap("内容", content.text || "(无文本内容)"));
2038
+ for (const extra of content.extras) lines.push(...this.#wrap("附件", extra, K.ACCENT));
2039
+ if (lines.length > 200) return [...lines.slice(0, 200), [{ t: " …详情超过 200 行,已截断", fg: K.FAINT }]];
2040
+ return lines;
2041
+ }
2042
+ rebuild() {
2043
+ const lines = [], rowOf = [];
2044
+ if (this.helpVisible) {
2045
+ for (const text of [
2046
+ " 键盘优先:j/k 或 ↑/↓ 选择命令;Enter/→/l 展开;←/h 折叠",
2047
+ " PgUp/PgDn 或 Ctrl+B/F 整页滚动;Ctrl+U/D 半页滚动",
2048
+ " Ctrl+Y/E 或 Shift+↑/↓ 逐行滚动;Home/End 到详情首尾",
2049
+ " dd 删除当前命令;? 隐藏帮助;q/Esc 关闭",
2050
+ ]) { lines.push([{ t: text, fg: K.DIM }]); rowOf.push(-1); }
2051
+ lines.push([{ t: "" }]); rowOf.push(-1);
2052
+ }
1760
2053
  for (let i = 0; i < this.items.length; i++) {
1761
2054
  const item = this.items[i];
2055
+ const key = this.#itemKey(item, i);
2056
+ const open = this.expanded.has(key);
2057
+ const selected = i === this.sel;
1762
2058
  const text = partsText(item.message?.content).replace(/\s+/g, " ");
1763
- lines.push([{ t: `${i === this.sel ? "▸" : " "} ${item.placement === "queued" ? "⏳" : item.placement === "steering" ? "↪" : "ℹ"} ${truncate(text || item.id, this.w - 8)}`, fg: i === this.sel ? T.SELFG : K.TXT, bg: i === this.sel ? T.MENUSEL : -1 }]);
2059
+ const placement = item.placement === "queued" ? "⏳" : item.placement === "steering" ? "↪" : "ℹ";
2060
+ lines.push([{
2061
+ t: `${selected ? "▸" : " "} ${open ? "▾" : "▸"} ${placement} ${truncate(text || item.id, this.w - 10)}`,
2062
+ fg: selected ? T.SELFG : K.TXT, bg: selected ? T.MENUSEL : -1, bold: selected,
2063
+ }]);
2064
+ rowOf.push(i);
2065
+ if (open) {
2066
+ for (const line of this.#detailLines(item)) {
2067
+ lines.push(line.map((seg) => ({ ...seg, bg: selected ? T.MENUSEL : seg.bg })));
2068
+ rowOf.push(i);
2069
+ }
2070
+ }
1764
2071
  }
1765
- if (!this.items.length) lines.push([{ t: " (队列为空)", fg: K.FAINT }]);
2072
+ if (!this.items.length) { lines.push([{ t: " (队列为空)", fg: K.FAINT }]); rowOf.push(-1); }
1766
2073
  this.lines = lines;
2074
+ this.rowOf = rowOf;
2075
+ this.scrollY = Math.max(0, Math.min(this.scrollY, this.maxScroll()));
2076
+ }
2077
+ #ensureSelected() {
2078
+ const row = this.rowOf.findIndex((idx) => idx === this.sel);
2079
+ if (row < 0) return;
2080
+ if (row < this.scrollY) this.scrollY = row;
2081
+ else if (row >= this.scrollY + this.contentRows()) this.scrollY = Math.max(0, row - this.contentRows() + 1);
2082
+ }
2083
+ /** Detail scrolling is intentionally independent from queue selection.
2084
+ * j/k chooses a command; these operations move only the viewport. */
2085
+ #scrollBy(delta) {
2086
+ this.dArmed = false;
2087
+ this.scrollY = Math.max(0, Math.min(this.maxScroll(), this.scrollY + delta));
2088
+ this.app.redraw();
2089
+ return true;
2090
+ }
2091
+ #scrollTo(position) {
2092
+ this.dArmed = false;
2093
+ this.scrollY = position === "end" ? this.maxScroll() : 0;
2094
+ this.app.redraw();
2095
+ return true;
2096
+ }
2097
+ #toggle(index = this.sel) {
2098
+ const item = this.items[index];
2099
+ if (!item) return;
2100
+ const key = this.#itemKey(item, index);
2101
+ if (this.expanded.has(key)) this.expanded.delete(key); else this.expanded.add(key);
2102
+ this.rebuild(); this.#ensureSelected(); this.app.redraw();
1767
2103
  }
1768
2104
  syncItems(items) {
1769
2105
  const selectedId = this.items[this.sel]?.id;
1770
2106
  this.items = items ?? [];
2107
+ const live = new Set(this.items.map((item, i) => this.#itemKey(item, i)));
2108
+ for (const key of [...this.expanded]) if (!live.has(key)) this.expanded.delete(key);
1771
2109
  const next = selectedId ? this.items.findIndex((item) => item.id === selectedId) : -1;
1772
2110
  this.sel = next >= 0 ? next : Math.min(this.sel, Math.max(0, this.items.length - 1));
1773
- this.rebuild();
2111
+ this.rebuild(); this.#ensureSelected();
1774
2112
  }
1775
2113
  #errorCode(error) { return error?.code ?? error?.details?.code ?? error?.cause?.code; }
1776
2114
  async #mutate(kind, content) {
@@ -1793,21 +2131,58 @@ export class QueuePanel extends Popup {
1793
2131
  } finally { this.pending = false; this.rebuild(); this.app.redraw(); }
1794
2132
  }
1795
2133
  onKey(ev) {
1796
- const ch=ev.type==="text"?ev.text:ev.type==="key"&&ev.name==="char"?ev.key:null;
1797
- if(ch==="d"){if(this.dArmed){this.dArmed=false;this.#mutate("remove");}else{this.dArmed=true;this.app.toast("再按 d 删除这条排队命令");}return true;}
2134
+ const ch = ev.type === "text" ? ev.text : ev.type === "key" && ev.name === "char" ? ev.key : null;
2135
+ // Plain character bindings only. Modified d belongs to Ctrl+D half-page
2136
+ // scrolling and must never arm the destructive dd sequence.
2137
+ const plain = !ev.ctrl && !ev.alt && !ev.shift;
2138
+ if (plain && ch === "q") { this.app.closeOverlay(); return true; }
2139
+ if (!ev.ctrl && !ev.alt && ch === "?") {
2140
+ this.helpVisible = !this.helpVisible;
2141
+ this.dArmed = false;
2142
+ this.rebuild(); this.scrollY = 0; this.app.redraw();
2143
+ return true;
2144
+ }
2145
+ if (plain && ch === "d") {
2146
+ if (this.dArmed) { this.dArmed = false; this.#mutate("remove"); }
2147
+ else { this.dArmed = true; this.app.toast("再按 d 删除这条排队命令"); }
2148
+ return true;
2149
+ }
1798
2150
  if (ev.type === "key") {
1799
2151
  if (ev.name === "escape") { this.app.closeOverlay(); return true; }
1800
- if (ev.name === "up") { this.dArmed=false;this.sel = Math.max(0, this.sel - 1); this.rebuild(); return true; }
1801
- if (ev.name === "down") { this.dArmed=false;this.sel = Math.min(this.items.length - 1, this.sel + 1); this.rebuild(); return true; }
2152
+ // Keyboard-first detail scrolling. Selection is intentionally unchanged.
2153
+ if (ev.name === "pgup" || (ev.ctrl && ev.key === "b")) return this.#scrollBy(-this.contentRows());
2154
+ if (ev.name === "pgdn" || (ev.ctrl && ev.key === "f")) return this.#scrollBy(this.contentRows());
2155
+ if (ev.ctrl && ev.key === "u") return this.#scrollBy(-Math.max(1, Math.floor(this.contentRows() / 2)));
2156
+ if (ev.ctrl && ev.key === "d") return this.#scrollBy(Math.max(1, Math.floor(this.contentRows() / 2)));
2157
+ if ((ev.ctrl && ev.key === "y") || (ev.name === "up" && ev.shift)) return this.#scrollBy(-1);
2158
+ if ((ev.ctrl && ev.key === "e") || (ev.name === "down" && ev.shift)) return this.#scrollBy(1);
2159
+ if (ev.name === "home") return this.#scrollTo("home");
2160
+ if (ev.name === "end") return this.#scrollTo("end");
2161
+ if (ev.name === "enter" || ev.name === "right" || (ev.name === "char" && ev.key === "l" && plain)) { this.dArmed = false; this.#toggle(); return true; }
2162
+ if (ev.name === "left" || (ev.name === "char" && ev.key === "h" && plain)) {
2163
+ this.dArmed = false;
2164
+ const item = this.items[this.sel], key = this.#itemKey(item, this.sel);
2165
+ if (item && this.expanded.delete(key)) { this.rebuild(); this.#ensureSelected(); this.app.redraw(); }
2166
+ return true;
2167
+ }
2168
+ if ((ev.name === "up" && !ev.shift) || (ev.name === "char" && ev.key === "k" && plain)) {
2169
+ this.dArmed = false; this.sel = wrapIndex(this.sel - 1, this.items.length); this.rebuild(); this.#ensureSelected(); return true;
2170
+ }
2171
+ if ((ev.name === "down" && !ev.shift) || (ev.name === "char" && ev.key === "j" && plain)) {
2172
+ this.dArmed = false; this.sel = wrapIndex(this.sel + 1, this.items.length); this.rebuild(); this.#ensureSelected(); return true;
2173
+ }
1802
2174
  }
1803
- this.dArmed=false;return false;
2175
+ this.dArmed = false;
2176
+ return false;
1804
2177
  }
1805
2178
  onMouse(ev) {
2179
+ if (super.onMouse(ev)) return true;
1806
2180
  if (ev.kind === "press" && ev.button === 0) {
1807
- const idx = ev.y - this.y - 1;
1808
- if (idx >= 0 && idx < this.items.length) { this.sel = idx; this.rebuild(); this.app.redraw(); return true; }
2181
+ const row = ev.y - this.y - 1 + this.scrollY;
2182
+ const idx = this.rowOf[row];
2183
+ if (idx >= 0) { this.sel = idx; this.#toggle(idx); return true; }
1809
2184
  }
1810
- return super.onMouse(ev);
2185
+ return false;
1811
2186
  }
1812
2187
  }
1813
2188
 
@@ -1893,8 +2268,8 @@ export class GoalPanel extends Popup {
1893
2268
  onKey(ev) {
1894
2269
  if (ev.type === "key") {
1895
2270
  if (ev.name === "escape") { this.app.closeOverlay(); return true; }
1896
- if (ev.name === "up") { this.actionSel = Math.max(0, this.actionSel - 1); this.rebuild(); return true; }
1897
- if (ev.name === "down") { this.actionSel = Math.min(this.actions.length - 1, this.actionSel + 1); this.rebuild(); return true; }
2271
+ if (ev.name === "up") { this.actionSel = wrapIndex(this.actionSel - 1, this.actions.length); this.rebuild(); return true; }
2272
+ if (ev.name === "down") { this.actionSel = wrapIndex(this.actionSel + 1, this.actions.length); this.rebuild(); return true; }
1898
2273
  if (ev.name === "enter") { this.actions[this.actionSel]?.run(); return true; }
1899
2274
  }
1900
2275
  return super.onKey(ev);
@@ -1950,7 +2325,7 @@ export class SettingsPanel extends Widget {
1950
2325
  this.writable = d.writable;
1951
2326
  } catch (e) {
1952
2327
  this.app.toast(`设置加载失败: ${e.message}`);
1953
- this.app.setMode("chat");
2328
+ this.app.closeFullBuffer?.() ?? this.app.setMode?.("chat");
1954
2329
  return;
1955
2330
  }
1956
2331
  // TUI-local settings ride the same tree editor, but persist to the TUI
@@ -1979,7 +2354,7 @@ export class SettingsPanel extends Widget {
1979
2354
  this.pendingOps = [];
1980
2355
  this.editing = false;
1981
2356
  const ns = this.namespaces[this.nsIdx];
1982
- if (ns.modelsEntry) { this.app.setMode("models"); return; }
2357
+ if (ns.modelsEntry) { (this.app.showModelsBuffer ? this.app.showModelsBuffer() : this.app.setMode?.("models")); return; }
1983
2358
  this.secrets = new Set((ns.secrets ?? []).map((s) => JSON.stringify(s.path ?? [])));
1984
2359
  this.rebuildRows();
1985
2360
  const items = this.namespaces.map((n) => ({
@@ -2082,7 +2457,7 @@ export class SettingsPanel extends Widget {
2082
2457
  return true;
2083
2458
  }
2084
2459
  if (ev.type !== "key") return false;
2085
- if (ev.name === "escape") { this.app.setMode("chat"); return true; }
2460
+ if (ev.name === "escape") { this.app.closeFullBuffer?.() ?? this.app.setMode?.("chat"); return true; }
2086
2461
  if (ev.ctrl && ev.key === "s") { this.save(); return true; }
2087
2462
  if (ev.name === "up" || ev.name === "down" || ev.name === "pgup" || ev.name === "pgdn") return this.tree.scroll(ev.name === "up" || ev.name === "pgup" ? -3 : 3);
2088
2463
  if (ev.name === "enter") {
@@ -2210,7 +2585,7 @@ export class EditPopup extends Popup {
2210
2585
  this.input.render(screen);
2211
2586
  }
2212
2587
  onKey(ev) {
2213
- if (ev.type === "key" && ev.name === "escape") { this.app.closeOverlay(); this.app.focus(this.app.chat); return true; }
2588
+ if (ev.type === "key" && ev.name === "escape") { this.app.closeOverlay(); this.app.focus(this.app.fullBuffer ?? this.app.chat); return true; }
2214
2589
  if (ev.type === "key" && ev.name === "tab" && this.completions?.length) {
2215
2590
  // Tab 选取/补全: an exact current value cycles to the next candidate,
2216
2591
  // anything else completes to the first prefix match
@@ -2231,7 +2606,7 @@ export class EditPopup extends Popup {
2231
2606
  if (ev.type === "key" && ev.name === "enter") {
2232
2607
  const v = this.input.value;
2233
2608
  this.app.closeOverlay();
2234
- this.app.focus(this.app.chat);
2609
+ this.app.focus(this.app.fullBuffer ?? this.app.chat);
2235
2610
  this.onCommit?.(v);
2236
2611
  return true;
2237
2612
  }
@@ -2250,6 +2625,17 @@ export class EditPopup extends Popup {
2250
2625
  * same union the web settings page reads out of the namespace schema, so the
2251
2626
  * choices offered here cannot drift from the ones the host validates. */
2252
2627
  const API_PROTOCOLS = ["openai-completions", "openai-responses", "anthropic-messages"];
2628
+ /** pi-ai reasoning levels the adapter schema accepts, in escalation order. */
2629
+ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
2630
+ /** Request modalities the pi-ai adapter schema accepts (audio is NOT one of
2631
+ * them: the adapter rejects any profile that tries to declare it). */
2632
+ const INPUT_MODALITIES = ["text", "image"];
2633
+ /** Adapter fallback when neither a model nor its installed catalog declares input. */
2634
+ const DEFAULT_INPUT_MODALITIES = ["text"];
2635
+ /** compat.thinkingFormat spellings the adapter accepts on openai-completions. */
2636
+ const THINKING_FORMATS = ["openai", "deepseek", "openrouter", "together", "zai", "qwen", "string-thinking", "ant-ling"];
2637
+ /** New custom routes follow the web editor's unambiguous identifier grammar. */
2638
+ const ROUTE_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
2253
2639
  /** Credential reference names must be POSIX shell identifiers. */
2254
2640
  const KEY_REF_OK = /^[A-Za-z_][A-Za-z0-9_]*$/;
2255
2641
  /** The web settings page's v1 convention: a provider route's key lives under
@@ -2258,16 +2644,114 @@ function deriveKeyRef(provider) {
2258
2644
  return `${provider.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY`;
2259
2645
  }
2260
2646
 
2647
+ /** Keep untrusted catalog/profile text inside one terminal row. */
2648
+ function inlineLabel(value) {
2649
+ return String(value ?? "").replace(/[\x00-\x1F\x7F]/g, "?");
2650
+ }
2651
+
2652
+ function isRecord(value) {
2653
+ return value !== null && typeof value === "object" && !Array.isArray(value);
2654
+ }
2655
+
2656
+ function cloneConfig(value) {
2657
+ return JSON.parse(JSON.stringify(value));
2658
+ }
2659
+
2660
+ /** Remove paths owned by the stored user layer, leaving inherited fallback. */
2661
+ function withoutOwned(resolved, owned) {
2662
+ if (!isRecord(resolved)) return {};
2663
+ const result = {};
2664
+ const stored = isRecord(owned) ? owned : {};
2665
+ for (const [key, value] of Object.entries(resolved)) {
2666
+ if (!Object.hasOwn(stored, key)) result[key] = value;
2667
+ else if (isRecord(value) && isRecord(stored[key])) {
2668
+ const child = withoutOwned(value, stored[key]);
2669
+ if (Object.keys(child).length > 0) result[key] = child;
2670
+ }
2671
+ }
2672
+ return result;
2673
+ }
2674
+
2675
+ function mergeConfig(inherited, draft) {
2676
+ const result = { ...(isRecord(inherited) ? inherited : {}) };
2677
+ for (const [key, value] of Object.entries(isRecord(draft) ? draft : {})) {
2678
+ result[key] = isRecord(value) && isRecord(result[key]) ? mergeConfig(result[key], value) : value;
2679
+ }
2680
+ return result;
2681
+ }
2682
+
2683
+ /** Read one settings subtree without enumerating or serializing Host objects. */
2684
+ function configAt(value, path) {
2685
+ let current = value;
2686
+ for (const key of path ?? []) {
2687
+ if (!isRecord(current) && !Array.isArray(current)) return undefined;
2688
+ current = current[key];
2689
+ }
2690
+ return current;
2691
+ }
2692
+
2693
+ /** Minimal field operations rooted at an arbitrary provider settings address. */
2694
+ function profileOps(base, before, after) {
2695
+ const previous = isRecord(before) ? before : {};
2696
+ const next = isRecord(after) ? after : {};
2697
+ const ops = [];
2698
+ for (const [field, value] of Object.entries(next)) {
2699
+ if (JSON.stringify(previous[field]) !== JSON.stringify(value)) ops.push({ op: "set", path: [...base, field], value });
2700
+ }
2701
+ for (const field of Object.keys(previous)) if (!(field in next)) ops.push({ op: "unset", path: [...base, field] });
2702
+ return ops;
2703
+ }
2704
+
2705
+ /** Minimal user-layer operations for the provider fields this panel changed. */
2706
+ function providerOps(before, after, wholeRoutes = new Set()) {
2707
+ const ops = [];
2708
+ for (const [route, profile] of Object.entries(after)) {
2709
+ const previous = before[route];
2710
+ if (previous === undefined && wholeRoutes.has(route)) {
2711
+ ops.push({ op: "set", path: ["providers", route], value: profile });
2712
+ continue;
2713
+ }
2714
+ const priorFields = previous ?? {};
2715
+ for (const [field, value] of Object.entries(profile)) {
2716
+ if (JSON.stringify(priorFields[field]) === JSON.stringify(value)) continue;
2717
+ ops.push({ op: "set", path: ["providers", route, field], value });
2718
+ }
2719
+ for (const field of Object.keys(priorFields)) {
2720
+ if (!(field in profile)) ops.push({ op: "unset", path: ["providers", route, field] });
2721
+ }
2722
+ }
2723
+ for (const route of Object.keys(before)) {
2724
+ if (!(route in after)) ops.push({ op: "unset", path: ["providers", route] });
2725
+ }
2726
+ return ops;
2727
+ }
2728
+
2261
2729
  export class ModelPanel extends Widget {
2262
2730
  constructor(app) {
2263
- super({ x: 30, y: 0, w: app.screen.w - 30, h: app.screen.h - 1 });
2731
+ super({ x: Math.min(30, Math.max(0, app.screen.w - 1)), y: 0, w: Math.max(1, app.screen.w - 30), h: Math.max(1, app.screen.h - 1) });
2264
2732
  this.app = app;
2265
- this.providers = {}; // route → profile (mirror of settings llm-pi-ai.providers)
2266
- this.revision = 0;
2267
- this.defaultModelRevision = 0;
2268
- this.defaultModel = null;
2733
+ this.providers = {}; // llm-pi-ai route → user-layer profile draft
2734
+ this.resolvedProviders = {}; // llm-pi-ai route → effective profile received from Host
2735
+ this.inheritedProviders = {}; // llm-pi-ai fields not owned by loaded user layer
2736
+ this.baseProviders = {}; // composition-owned llm-pi-ai profiles
2737
+ this.directory = []; // Host llm.providers entries (official + catalog + declared)
2738
+ this.namespaceViews = new Map(); // every settings namespace addressed by the directory
2739
+ this.configuredDirectory = new Set(); // configured provider identities in this draft
2740
+ this.initialConfiguredDirectory = new Set(); // discard target
2741
+ this.externalDrafts = new Map(); // non-pi-ai provider → user-layer profile draft
2742
+ this.externalInherited = new Map(); // non-pi-ai provider → effective fields below user layer
2743
+ this.externalUserConfigured = new Set(); // routes with an actual user-layer settings subtree
2744
+ this.externalSnapshots = new Map(); // provider → fully successful save point
2745
+ this.externalHostSnapshots = new Map(); // provider → settings confirmed by Host
2746
+ this.revisions = new Map(); // settings namespace → CAS revision
2747
+ this.revision = 0; // llm-pi-ai compatibility alias used by existing paths
2269
2748
  this.loaded = false;
2749
+ this.writable = true;
2270
2750
  this.routes = [];
2751
+ this.addMode = false; // Host-directory chooser; custom is its final row
2752
+ this.addItems = [];
2753
+ this.addCursor = 0;
2754
+ this.materializeRoutes = new Set(); // dormant catalog routes selected but not saved
2271
2755
  this.sel = 0; // list cursor (routes.length = the + 添加供应商 row)
2272
2756
  this.mode = "list"; // list | form
2273
2757
  this.formIdx = 0; // form item cursor
@@ -2282,36 +2766,262 @@ export class ModelPanel extends Widget {
2282
2766
  this.scanSel = new Set();
2283
2767
  this.scanCursor = 0;
2284
2768
  this.scanning = false;
2285
- this.savedSnapshot = "{}"; // JSON of the last saved/loaded providers (dirty check)
2769
+ this.savedSnapshot = "{}"; // last fully successful llm-pi-ai providers+credentials save point
2770
+ this.hostSnapshot = "{}"; // llm-pi-ai provider settings last confirmed by the Host
2286
2771
  this.keyStatus = {}; // ref → {configured, writable, source} from credentials.describe
2287
- const listW = 26;
2288
- this.listView = new ScrollView({ x: this.x + 1, y: this.y + 1, w: listW, h: this.h - 2, showScrollbar: true });
2289
- this.formView = new ScrollView({ x: this.x + listW + 1, y: this.y + 1, w: this.w - listW - 2, h: this.h - 2, showScrollbar: true });
2772
+ this.pendingProbeKeys = new Map(); // route → write-only key draft for discovery and save
2773
+ // Journal managed cleanup on the App and in tui-config before provider
2774
+ // deletion. Closing/recreating this panel cannot lose the retry surface;
2775
+ // this map contains references and errors, never credential values.
2776
+ if (!(app.pendingModelCredentialCleanups instanceof Map)) {
2777
+ const savedCleanups = loadTuiConfig().pendingModelCredentialCleanups;
2778
+ app.pendingModelCredentialCleanups = new Map((Array.isArray(savedCleanups) ? savedCleanups : []).flatMap((item) => {
2779
+ const ref = typeof item?.ref === "string" ? item.ref : "";
2780
+ const route = typeof item?.route === "string" ? item.route : "";
2781
+ // Only this panel's route-derived managed credentials may ever enter the
2782
+ // automatic cleanup path. Existing Host configs may predate the route
2783
+ // grammar enforced for new drafts, so do not reject legacy route names.
2784
+ if (!route || ref !== deriveKeyRef(route)) return [];
2785
+ return [[ref, {
2786
+ route,
2787
+ error: typeof item.error === "string" ? item.error : "等待重试",
2788
+ reconcile: item.reconcile === true,
2789
+ }]];
2790
+ }));
2791
+ }
2792
+ this.pendingCredentialCleanups = app.pendingModelCredentialCleanups; // ref → {route, error}
2793
+ this.formClickMap = []; // rendered form line → item, scan result, or cleanup action
2794
+ const listW = Math.max(1, Math.min(26, this.w - 3));
2795
+ this.listView = new ScrollView({ x: this.x + 1, y: this.y + 1, w: listW, h: Math.max(1, this.h - 2), showScrollbar: true });
2796
+ this.formView = new ScrollView({ x: this.x + listW + 1, y: this.y + 1, w: Math.max(1, this.w - listW - 2), h: Math.max(1, this.h - 2), showScrollbar: true });
2290
2797
  }
2291
2798
  relayout(x, y, w, h) {
2292
- this.x = x; this.y = y; this.w = w; this.h = h;
2293
- const listW = 26;
2294
- this.listView.x = x + 1; this.listView.y = y + 1; this.listView.w = listW; this.listView.h = h - 2;
2295
- this.formView.x = x + listW + 1; this.formView.y = y + 1; this.formView.w = w - listW - 2; this.formView.h = h - 2;
2799
+ this.x = x; this.y = y; this.w = Math.max(1, w); this.h = Math.max(1, h);
2800
+ const listW = Math.max(1, Math.min(26, this.w - 3));
2801
+ this.listView.x = x + 1; this.listView.y = y + 1; this.listView.w = listW; this.listView.h = Math.max(1, this.h - 2);
2802
+ this.formView.x = x + listW + 1; this.formView.y = y + 1; this.formView.w = Math.max(1, this.w - listW - 2); this.formView.h = Math.max(1, this.h - 2);
2296
2803
  }
2297
2804
  async load() {
2805
+ if (this.loaded && this.#dirty()) {
2806
+ this.app.toast("模型配置仍有未保存修改");
2807
+ this.#rebuild();
2808
+ this.app.redraw();
2809
+ return;
2810
+ }
2811
+ this.pendingProbeKeys.clear();
2812
+ let described = false;
2813
+ let providerState = null;
2298
2814
  try {
2299
- const d = await this.app.api.call("settings.describe");
2300
- const ns = (d.namespaces ?? []).find((n) => n.ns === "llm-pi-ai");
2301
- const defaults = (d.namespaces ?? []).find((n) => n.ns === "agent-default-model");
2302
- this.providers = { ...(ns?.value?.providers ?? {}) };
2815
+ // WebUI parity: the Host directory is the source of truth for official,
2816
+ // catalog, and declared provider identities. settings.describe alone can
2817
+ // only reveal routes that are already configured.
2818
+ const [d, listing] = await Promise.all([
2819
+ this.app.api.call("settings.describe"),
2820
+ this.app.api.call("llm.providers").catch(() => ({ providers: [] })),
2821
+ ]);
2822
+ this.directory = (listing?.providers ?? []).filter((entry) => entry && typeof entry.provider === "string");
2823
+ this.namespaceViews = new Map((d.namespaces ?? []).map((view) => [view.ns, view]));
2824
+ this.revisions = new Map((d.namespaces ?? []).map((view) => [view.ns, view.revision ?? 0]));
2825
+ const ns = this.namespaceViews.get("llm-pi-ai");
2826
+ const hasLayerView = ns && (Object.hasOwn(ns, "user") || Object.hasOwn(ns, "base"));
2827
+ const configured = hasLayerView ? ns.user?.providers : ns?.value?.providers;
2828
+ this.providers = { ...(configured ?? {}) };
2829
+ this.resolvedProviders = { ...(ns?.value?.providers ?? this.providers) };
2830
+ this.baseProviders = { ...(ns?.base?.providers ?? {}) };
2831
+ this.inheritedProviders = Object.fromEntries(Object.entries(this.resolvedProviders).map(([route, profile]) => [
2832
+ route,
2833
+ mergeConfig(withoutOwned(profile, this.providers[route]), this.baseProviders[route]),
2834
+ ]));
2835
+ this.configuredDirectory.clear();
2836
+ this.configuredDirectory = new Set(this.directory.filter((entry) => this.#configuredEntry(entry)).map((entry) => entry.provider));
2837
+ this.initialConfiguredDirectory = new Set(this.configuredDirectory);
2838
+ this.externalDrafts = new Map();
2839
+ this.externalInherited = new Map();
2840
+ this.externalUserConfigured = new Set();
2841
+ this.externalSnapshots = new Map();
2842
+ this.externalHostSnapshots = new Map();
2843
+ for (const entry of this.directory) {
2844
+ if (entry.settingsNs === "llm-pi-ai") continue;
2845
+ const view = this.namespaceViews.get(entry.settingsNs);
2846
+ if (!view) continue;
2847
+ const stored = configAt(view.user, entry.settingsPath);
2848
+ if (stored !== undefined && entry.settingsPath.length > 0) this.externalUserConfigured.add(entry.provider);
2849
+ const draft = cloneConfig(stored ?? {});
2850
+ const inherited = mergeConfig(withoutOwned(configAt(view.value, entry.settingsPath), stored), configAt(view.base, entry.settingsPath));
2851
+ const snapshot = JSON.stringify(draft);
2852
+ this.externalDrafts.set(entry.provider, draft);
2853
+ this.externalInherited.set(entry.provider, inherited);
2854
+ this.externalSnapshots.set(entry.provider, snapshot);
2855
+ this.externalHostSnapshots.set(entry.provider, snapshot);
2856
+ }
2303
2857
  this.revision = ns?.revision ?? 0;
2304
- this.defaultModel = defaults?.value ? { ...defaults.value } : null;
2305
- this.defaultModelRevision = defaults?.revision ?? 0;
2306
- this.routes = Object.keys(this.providers);
2858
+ this.writable = d.writable !== false;
2859
+ this.materializeRoutes.clear();
2860
+ this.addMode = false;
2861
+ this.#syncRoutes();
2862
+ this.sel = Math.max(0, Math.min(this.sel, this.routes.length));
2863
+ providerState = this.#providerStateFromDescription(d);
2864
+ described = providerState !== null;
2307
2865
  } catch (e) { this.app.toast(`模型配置加载失败: ${e.message}`); }
2308
2866
  this.savedSnapshot = JSON.stringify(this.providers);
2867
+ this.hostSnapshot = this.savedSnapshot;
2868
+ const cleanup = described
2869
+ ? await this.#retryPendingCredentialCleanups({ notify: false, providerState })
2870
+ : { completed: [], failed: [] };
2309
2871
  await this.#refreshKeys();
2872
+ if (cleanup.failed.length > 0) this.#showCredentialCleanupFailure(cleanup.failed[0]);
2873
+ else if (cleanup.completed.length > 0) this.app.toast(`已清理托管密钥 ${cleanup.completed.join("、")}`);
2310
2874
  this.loaded = true;
2311
2875
  this.modelsSel = -1;
2312
2876
  this.#rebuild();
2313
2877
  this.app.redraw();
2314
2878
  }
2879
+ #persistCredentialCleanups() {
2880
+ return saveTuiConfig({
2881
+ pendingModelCredentialCleanups: [...this.pendingCredentialCleanups].map(([ref, task]) => ({
2882
+ ref,
2883
+ route: task.route,
2884
+ error: task.error,
2885
+ ...(task.reconcile ? { reconcile: true } : {}),
2886
+ })),
2887
+ });
2888
+ }
2889
+ #providerStateFromDescription(description) {
2890
+ const ns = (description?.namespaces ?? []).find((item) => item.ns === "llm-pi-ai");
2891
+ if (!isRecord(ns?.value) || !isRecord(ns.value.providers)) return null;
2892
+ const providers = ns.value.providers;
2893
+ const routes = new Set(Object.keys(providers));
2894
+ const refs = new Set(Object.entries(providers).map(([route, profile]) => {
2895
+ const configured = isRecord(profile) && typeof profile.apiKeyEnv === "string" ? profile.apiKeyEnv : "";
2896
+ return configured || deriveKeyRef(route);
2897
+ }));
2898
+ return { routes, refs };
2899
+ }
2900
+ #providerStateFromProfiles(providers) {
2901
+ return this.#providerStateFromDescription({ namespaces: [{ ns: "llm-pi-ai", value: { providers } }] });
2902
+ }
2903
+ #cleanupRouteReserved(route) {
2904
+ return [...this.pendingCredentialCleanups.values()].some((task) => task.route === route);
2905
+ }
2906
+ async #retryPendingCredentialCleanups({ onlyRef = null, notify = true, providerState = null } = {}) {
2907
+ const targets = [...this.pendingCredentialCleanups].filter(([ref]) => onlyRef === null || ref === onlyRef);
2908
+ if (targets.length === 0) return { completed: [], preserved: [], failed: [], persisted: true };
2909
+ const before = new Map([...this.pendingCredentialCleanups].map(([ref, task]) => [ref, { ...task }]));
2910
+ const completed = [], preserved = [], failed = [];
2911
+ let state = providerState;
2912
+ if (state === null) {
2913
+ try {
2914
+ state = this.#providerStateFromDescription(await this.app.api.call("settings.describe"));
2915
+ if (state === null) throw new Error("llm-pi-ai 配置暂不可用");
2916
+ } catch (error) {
2917
+ const message = `无法核对 Host 模型配置: ${String(error?.message ?? error).slice(0, 500)}`;
2918
+ for (const [ref, task] of targets) {
2919
+ const failure = { ref, route: task.route, error: message, reconcile: task.reconcile === true };
2920
+ this.pendingCredentialCleanups.set(ref, { route: task.route, error: message, reconcile: task.reconcile === true });
2921
+ failed.push(failure);
2922
+ }
2923
+ }
2924
+ }
2925
+ if (state !== null) {
2926
+ for (const [ref, task] of targets) {
2927
+ // Any effective Host profile may reuse this reference. In that case the
2928
+ // old deletion task resolves by preservation, never by an unset.
2929
+ if (state.refs.has(ref)) {
2930
+ this.pendingCredentialCleanups.delete(ref);
2931
+ preserved.push(ref);
2932
+ continue;
2933
+ }
2934
+ // A pre-mutation journal survives ambiguous transport failures. If its
2935
+ // route now exists with another reference, do not guess whether it was
2936
+ // recreated; require an explicit keep decision.
2937
+ if (task.reconcile && state.routes.has(task.route)) {
2938
+ const message = `路由 ${task.route} 仍存在,无法自动确认旧密钥可清理`;
2939
+ const failure = { ref, route: task.route, error: message, reconcile: true };
2940
+ this.pendingCredentialCleanups.set(ref, { route: task.route, error: message, reconcile: true });
2941
+ failed.push(failure);
2942
+ continue;
2943
+ }
2944
+ try {
2945
+ await this.app.api.call("credentials.unset", { ref });
2946
+ this.pendingCredentialCleanups.delete(ref);
2947
+ completed.push(ref);
2948
+ } catch (error) {
2949
+ const message = String(error?.message ?? error).slice(0, 500);
2950
+ const failure = { ref, route: task.route, error: message };
2951
+ this.pendingCredentialCleanups.set(ref, { route: task.route, error: message });
2952
+ failed.push(failure);
2953
+ }
2954
+ }
2955
+ }
2956
+ const persisted = this.#persistCredentialCleanups();
2957
+ if (!persisted) {
2958
+ this.pendingCredentialCleanups.clear();
2959
+ for (const [ref, task] of before) this.pendingCredentialCleanups.set(ref, task);
2960
+ completed.length = 0;
2961
+ preserved.length = 0;
2962
+ failed.length = 0;
2963
+ for (const [ref, task] of targets) failed.push({
2964
+ ref,
2965
+ route: task.route,
2966
+ error: "待清理密钥状态无法写入 tui-config.json",
2967
+ reconcile: task.reconcile === true,
2968
+ });
2969
+ }
2970
+ if (notify) {
2971
+ if (failed.length > 0) this.app.toast(`托管密钥清理失败: ${failed[0].error}`);
2972
+ else if (completed.length > 0) this.app.toast(`已清理托管密钥 ${completed.join("、")}`);
2973
+ else if (preserved.length > 0) this.app.toast(`凭据 ${preserved.join("、")} 已被新配置使用,已保留`);
2974
+ }
2975
+ return { completed, preserved, failed, persisted };
2976
+ }
2977
+ #showCredentialCleanupFailure(task) {
2978
+ const current = this.pendingCredentialCleanups.get(task.ref);
2979
+ if (!current) return;
2980
+ const ref = task.ref;
2981
+ const error = current.error || task.error || "未知错误";
2982
+ const w = Math.max(30, Math.min(72, this.app.screen.w - 4));
2983
+ this.app.overlay = new Popup({
2984
+ x: Math.max(0, Math.floor((this.app.screen.w - w) / 2)),
2985
+ y: Math.max(0, Math.floor(this.app.screen.h / 2) - 4),
2986
+ w, h: Math.min(9, this.app.screen.h), title: "托管密钥待清理",
2987
+ lines: [
2988
+ [{ t: current.reconcile
2989
+ ? ` 供应商 ${inlineLabel(current.route)} 的删除结果待核对,${ref} 暂不清理。`
2990
+ : ` 供应商 ${inlineLabel(current.route)} 已删除,但 ${ref} 尚未清理。`, fg: K.WARN }],
2991
+ [{ t: ` ${truncate(inlineLabel(error), w - 4)}`, fg: K.DIM }],
2992
+ [{ t: " 可立即重试;保留密钥会停止后续自动清理。", fg: K.TXT }],
2993
+ ],
2994
+ buttons: [
2995
+ { label: "稍后", action: "later" },
2996
+ { label: "重试清理", action: "retry" },
2997
+ { label: "保留密钥", action: "keep" },
2998
+ ],
2999
+ onAction: async (button) => {
3000
+ if (button?.action === "retry") {
3001
+ this.app.closeOverlay();
3002
+ const result = await this.#retryPendingCredentialCleanups({ onlyRef: ref });
3003
+ await this.#refreshKeys();
3004
+ if (result.failed.length > 0) this.#showCredentialCleanupFailure(result.failed[0]);
3005
+ } else if (button?.action === "keep") {
3006
+ const saved = this.pendingCredentialCleanups.get(ref);
3007
+ this.pendingCredentialCleanups.delete(ref);
3008
+ if (!this.#persistCredentialCleanups()) {
3009
+ this.pendingCredentialCleanups.set(ref, saved);
3010
+ this.app.toast("无法保存保留决定,清理任务仍待处理");
3011
+ } else {
3012
+ this.app.closeOverlay();
3013
+ this.app.toast(`已保留 ${ref},不会再自动清理`);
3014
+ }
3015
+ } else {
3016
+ this.app.closeOverlay();
3017
+ this.app.toast(`${ref} 仍待清理`);
3018
+ }
3019
+ this.#rebuild();
3020
+ this.app.redraw();
3021
+ },
3022
+ });
3023
+ this.app.redraw();
3024
+ }
2315
3025
  /** One batched credentials.describe over every referenced key, exactly like
2316
3026
  * the web page's store join. Reads are structurally value-free: only the
2317
3027
  * configured/source/writable view ever reaches this panel. */
@@ -2320,55 +3030,129 @@ export class ModelPanel extends Widget {
2320
3030
  // only well-formed references can cross the wire (the describe payload
2321
3031
  // validates each name); an ill-formed derived ref is skipped here and
2322
3032
  // reported by the row's edit guard instead
2323
- const refs = [...new Set(this.routes.map((r) => this.#keyRef(r)).filter((ref) => KEY_REF_OK.test(ref)))];
3033
+ const refs = [...new Set([
3034
+ ...this.routes.map((r) => this.#keyRef(r)),
3035
+ ...this.directory.filter((entry) => this.configuredDirectory.has(entry.provider)).map((entry) => this.#keyRef(entry.provider)),
3036
+ ].filter((ref) => KEY_REF_OK.test(ref)))];
2324
3037
  if (refs.length === 0) { this.keyStatus = {}; return; }
2325
3038
  const res = await this.app.api.call("credentials.describe", { refs });
2326
3039
  this.keyStatus = res?.credentials ?? {};
2327
3040
  } catch (e) { this.keyStatus = {}; }
2328
3041
  }
3042
+ #entry(route) { return this.directory.find((entry) => entry.provider === route); }
3043
+ #namespace(route) { return this.#entry(route)?.settingsNs ?? "llm-pi-ai"; }
3044
+ #configuredEntry(entry) {
3045
+ if (this.configuredDirectory.has(entry.provider)) return true;
3046
+ const view = this.namespaceViews.get(entry.settingsNs);
3047
+ if (!view) return false;
3048
+ // A root-addressed adapter is not automatically configured merely because
3049
+ // its namespace exists. Official/active rows are host-owned; optional root
3050
+ // adapters need an actual user-layer section before they leave the chooser.
3051
+ if (entry.settingsPath.length === 0) return entry.active === true || configAt(view.user, entry.settingsPath) !== undefined;
3052
+ return configAt(view.value, entry.settingsPath) !== undefined;
3053
+ }
2329
3054
  /** The credential reference a profile names, or the web's derived default. */
2330
3055
  #keyRef(route) {
2331
3056
  const p = this.#profile(route);
2332
3057
  return (p.apiKeyEnv && p.apiKeyEnv.length > 0) ? p.apiKeyEnv : deriveKeyRef(route);
2333
3058
  }
3059
+ #syncRoutes() {
3060
+ const configuredDirectory = this.directory.filter((entry) => this.configuredDirectory.has(entry.provider)).map((entry) => entry.provider);
3061
+ this.routes = [...new Set([...configuredDirectory, ...Object.keys(this.resolvedProviders), ...Object.keys(this.providers)])];
3062
+ }
2334
3063
  #route() { return this.routes[this.sel] ?? null; }
2335
- #profile(route) { return route == null ? null : this.providers[route] ?? {}; }
3064
+ #draftProfile(route) {
3065
+ if (route == null) return null;
3066
+ if (this.externalDrafts.has(route)) return this.externalDrafts.get(route);
3067
+ this.providers[route] ??= {};
3068
+ return this.providers[route];
3069
+ }
3070
+ #profile(route) {
3071
+ if (route == null) return null;
3072
+ if (this.externalDrafts.has(route)) return mergeConfig(this.externalInherited.get(route), this.externalDrafts.get(route));
3073
+ const effective = mergeConfig(this.inheritedProviders[route], this.providers[route]);
3074
+ const entry = this.#entry(route);
3075
+ // Dormant Host catalog entries are intentionally absent from settings, but
3076
+ // the directory still owns their display identity. Do not materialize this
3077
+ // fallback into the saved profile.
3078
+ if (!effective.displayName && entry?.declared !== true) effective.displayName = entry?.displayName;
3079
+ return effective;
3080
+ }
3081
+ #models(route, { mutable = false } = {}) {
3082
+ const draft = mutable ? this.#draftProfile(route) : (this.externalDrafts.has(route) ? this.externalDrafts.get(route) : this.providers[route]);
3083
+ if (mutable && !Array.isArray(draft.models)) draft.models = cloneConfig(this.#profile(route).models ?? []);
3084
+ return draft?.models ?? this.#profile(route).models ?? [];
3085
+ }
3086
+ #pruneEmptyDraft(route) {
3087
+ if (this.externalDrafts.has(route) || this.materializeRoutes.has(route)) return;
3088
+ if (!Object.hasOwn(JSON.parse(this.hostSnapshot), route) && Object.keys(this.providers[route] ?? {}).length === 0) {
3089
+ delete this.providers[route];
3090
+ }
3091
+ }
3092
+ #stripCompat(route) {
3093
+ const profile = this.#draftProfile(route);
3094
+ delete profile.compat;
3095
+ if (this.#models(route).some((model) => model.compat !== undefined)) {
3096
+ for (const model of this.#models(route, { mutable: true })) delete model.compat;
3097
+ }
3098
+ }
2336
3099
  #formRows() {
2337
3100
  const route = this.#route();
2338
3101
  if (route == null) return [];
2339
3102
  const p = this.#profile(route);
3103
+ const entry = this.#entry(route);
3104
+ const officialDeepSeek = entry?.settingsNs === "llm-deepseek";
3105
+ const catalogRoute = entry?.settingsNs === "llm-pi-ai" && entry.declared !== true;
3106
+ const ownsIdentity = !officialDeepSeek && !catalogRoute;
2340
3107
  const items = [];
2341
- // the route key only appears for a brand-new draft (rename once)
3108
+ // Only hand-declared custom routes own their route identity/display name.
2342
3109
  if (this.draftRoute === route) items.push({ kind: "field", key: "route", label: "路由名", value: route });
2343
- items.push({ kind: "field", key: "displayName", label: "显示名", value: p.displayName ?? "" });
3110
+ if (ownsIdentity) items.push({ kind: "field", key: "displayName", label: "显示名", value: p.displayName ?? "" });
2344
3111
  // the api protocol is a CHOICE in the web UI (a select over the namespace
2345
3112
  // schema's union), so here Tab cycles the options in the form and Enter
2346
3113
  // opens an edit buffer with every candidate shown as an autocomplete hint
2347
- const api = p.api ?? "openai-completions";
2348
- items.push({
2349
- kind: "field", key: "api", label: "协议 api", value: api,
2350
- cycle: API_PROTOCOLS.includes(api) ? API_PROTOCOLS : [api, ...API_PROTOCOLS],
2351
- completions: API_PROTOCOLS, note: "Tab 切换 · Enter 输入",
2352
- });
2353
- items.push({ kind: "field", key: "baseURL", label: "baseURL", value: p.baseURL ?? "" });
2354
- items.push({ kind: "field", key: "reasoning", label: "默认思考强度", value: p.reasoning ?? "", cycle: ["off", "minimal", "low", "medium", "high", "xhigh"], completions: ["off", "minimal", "low", "medium", "high", "xhigh"], note: "可选 · Tab 切换" });
2355
- items.push({ kind: "field", key: "defaultContextWindow", label: "默认上下文", value: p.defaultContextWindow ?? "", numeric: true });
2356
- items.push({ kind: "field", key: "defaultMaxTokens", label: "默认最大输出", value: p.defaultMaxTokens ?? "", numeric: true });
3114
+ const api = p.api ?? "";
3115
+ if (ownsIdentity) {
3116
+ items.push({
3117
+ kind: "field", key: "api", label: "协议 api", value: api,
3118
+ cycle: API_PROTOCOLS.includes(api) ? API_PROTOCOLS : ["", ...(api ? [api] : []), ...API_PROTOCOLS],
3119
+ completions: API_PROTOCOLS, note: "Tab 切换",
3120
+ });
3121
+ }
3122
+ items.push({ kind: "field", key: "baseURL", label: "baseURL", value: p.baseURL ?? "", note: officialDeepSeek ? "留空=https://api.deepseek.com" : catalogRoute ? "留空=提供方默认" : undefined });
3123
+ if (ownsIdentity) {
3124
+ items.push({ kind: "field", key: "reasoning", label: "默认思考强度", value: p.reasoning ?? "", cycle: ["", ...THINKING_LEVELS], completions: THINKING_LEVELS, note: "留空=模型默认 · Tab 切换" });
3125
+ items.push({ kind: "field", key: "defaultContextWindow", label: "默认上下文", value: p.defaultContextWindow ?? "", numeric: true });
3126
+ items.push({ kind: "field", key: "defaultMaxTokens", label: "默认最大输出", value: p.defaultMaxTokens ?? "", numeric: true });
3127
+ // route-level input fallback: only the modalities the pi-ai adapter schema accepts.
3128
+ for (const modality of INPUT_MODALITIES) {
3129
+ items.push({ kind: "choice", key: `defaultInput.${modality}`, label: `默认输入 ${modality}`, value: (p.defaultInput ?? DEFAULT_INPUT_MODALITIES).includes(modality) ? "✓" : "·" });
3130
+ }
3131
+ if (api === "openai-completions") {
3132
+ items.push({ kind: "field", key: "compat.thinkingFormat", label: "compat.thinkingFormat", value: p.compat?.thinkingFormat ?? "", completions: THINKING_FORMATS, note: "可选 · Tab 补全" });
3133
+ items.push({ kind: "field", key: "compat.supportsReasoningEffort", label: "compat.supportsReasoningEffort", value: p.compat?.supportsReasoningEffort == null ? "" : String(p.compat.supportsReasoningEffort), completions: ["true", "false"], note: "可选 · true/false" });
3134
+ }
3135
+ }
2357
3136
  // the api key: web-synced handling — the stored value is NEVER shown
2358
3137
  // (credentials.describe is structurally value-free), only its status dot;
2359
- // Enter opens a masked, always-empty editor and a typed key travels one
2360
- // way through credentials.set under the profile's reference
3138
+ // Enter opens a masked, always-empty editor. The typed value remains a
3139
+ // write-only draft until save persists it through credentials.set.
2361
3140
  const keyRef = this.#keyRef(route);
2362
- items.push({ kind: "key", key: "apiKeyEnv", label: "API 密钥", ref: keyRef, action: () => this.#editKey(route, keyRef) });
2363
- if (this.keyStatus?.[keyRef]?.configured) items.push({ kind: "button", label: "清除 API 密钥…", action: () => this.#clearKey(keyRef) });
3141
+ items.push({ kind: "key", key: "apiKeyEnv", label: "API 密钥", ref: keyRef, pending: this.pendingProbeKeys.has(route), action: () => this.#editKey(route, keyRef) });
3142
+ if (this.writable && this.keyStatus?.[keyRef]?.configured && this.keyStatus[keyRef].writable === true) items.push({ kind: "button", label: "清除 API 密钥…", action: () => this.#clearKey(route, keyRef) });
2364
3143
  // models are NOT flat here: one 模型管理 entry summarizing the first
2365
3144
  // five, which opens its own sub-buffer (scan on top, model form below)
2366
3145
  const models = p.models ?? [];
2367
- const names = models.slice(0, 5).map((m) => m.id || "(未命名)").join(" · ");
2368
- items.push({ kind: "button", label: "模型管理", sub: names + (models.length > 5 ? " · …" : ""), action: () => this.#openModels() });
2369
- items.push({ kind: "field", key: "__defaultReasoningEffort", label: "默认 Agent 思考强度", value: this.defaultModel?.provider === route ? (this.defaultModel.reasoningEffort ?? "") : "", note: this.defaultModel?.provider === route ? `默认模型 ${this.defaultModel.model}` : "先将本路由模型设为默认" });
3146
+ const names = models.slice(0, 5).map((m) => inlineLabel(m.id || "(未命名)")).join(" · ");
3147
+ const inheritedCatalog = models.length === 0 && (officialDeepSeek || catalogRoute);
3148
+ items.push({ kind: "button", label: "模型管理", sub: inheritedCatalog ? "使用 Host 内置模型目录" : names + (models.length > 5 ? " · …" : ""), action: () => this.#openModels() });
2370
3149
  items.push({ kind: "button", label: "💾 保存配置", action: () => this.#save() });
2371
- items.push({ kind: "button", label: "🗑 删除供应商", action: () => this.#deleteProvider() });
3150
+ const externalUserConfig = !officialDeepSeek && this.externalUserConfigured.has(route);
3151
+ if (externalUserConfig) {
3152
+ items.push({ kind: "button", label: "🗑 取消配置提供方", action: () => this.#unconfigureExternalProvider() });
3153
+ } else if (!officialDeepSeek && Object.hasOwn(this.providers, route) && !Object.hasOwn(this.baseProviders, route)) {
3154
+ items.push({ kind: "button", label: catalogRoute ? "🗑 取消配置提供方" : "🗑 删除供应商", action: () => this.#deleteProvider() });
3155
+ }
2372
3156
  return items;
2373
3157
  }
2374
3158
  /** The 模型管理 sub-buffer: scan first, then the model-info form rows. */
@@ -2376,8 +3160,14 @@ export class ModelPanel extends Widget {
2376
3160
  const route = this.#route();
2377
3161
  if (route == null) return [];
2378
3162
  const p = this.#profile(route);
3163
+ const entry = this.#entry(route);
3164
+ const officialDeepSeek = entry?.settingsNs === "llm-deepseek";
3165
+ const catalogRoute = entry?.settingsNs === "llm-pi-ai" && entry.declared !== true;
2379
3166
  const items = [];
2380
- items.push({ kind: "button", label: "🔄 自动扫描可用模型(读 /models 端点)", action: () => this.#scan() });
3167
+ if (!officialDeepSeek) items.push({ kind: "button", label: "🔄 自动发现可用模型", action: () => this.#scan() });
3168
+ if ((officialDeepSeek || catalogRoute) && Object.hasOwn(this.#draftProfile(route), "models")) {
3169
+ items.push({ kind: "button", label: "↺ 恢复 Host 内置模型目录", action: () => this.#resetModels() });
3170
+ }
2381
3171
  const models = p.models ?? [];
2382
3172
  for (let mi = 0; mi < models.length; mi++) {
2383
3173
  const m = models[mi];
@@ -2387,15 +3177,31 @@ export class ModelPanel extends Widget {
2387
3177
  items.push({ kind: "field", key: `model.${mi}.name`, label: " 模型名", value: m.name ?? "" });
2388
3178
  items.push({ kind: "field", key: `model.${mi}.contextWindow`, label: " 上下文窗口", value: m.contextWindow ?? "", numeric: true });
2389
3179
  items.push({ kind: "field", key: `model.${mi}.maxTokens`, label: " 最大输出", value: m.maxTokens ?? "", numeric: true });
2390
- items.push({ kind: "choice", key: `model.${mi}.reasoningEnabled`, label: " 启用思考强度", value: m.reasoningEfforts === false ? "否" : "是", cycle: ["是", "否"] });
2391
- for (const level of ["off", "minimal", "low", "medium", "high", "xhigh"]) items.push({ kind: "field", key: `model.${mi}.reasoning.${level}`, label: ` ${level}`, value: m.reasoningEfforts === false ? "" : (m.reasoningEfforts?.[level] ?? (level === "off" && Object.hasOwn(m.reasoningEfforts ?? {}, level) ? "null" : "")), note: level === "off" ? "null 表示关闭" : "留空=不提供" });
2392
- for (const modality of ["text", "image", "audio"]) items.push({ kind: "choice", key: `model.${mi}.input.${modality}`, label: ` 输入 ${modality}`, value: (m.input ?? ["text"]).includes(modality) ? "✓" : "·", cycle: ["✓", "·"] });
3180
+ if (!officialDeepSeek && !catalogRoute) {
3181
+ const reasoningState = m.reasoningEfforts === undefined ? "继承" : m.reasoningEfforts === false ? "关闭" : "自定义";
3182
+ items.push({ kind: "choice", key: `model.${mi}.reasoningMode`, label: " 思考能力", value: reasoningState, cycle: ["继承", "关闭", "自定义"] });
3183
+ if (reasoningState === "自定义") {
3184
+ for (const level of THINKING_LEVELS) {
3185
+ const declared = Object.hasOwn(m.reasoningEfforts, level);
3186
+ const value = declared && m.reasoningEfforts[level] === null ? "null" : declared ? m.reasoningEfforts[level] : "";
3187
+ items.push({ kind: "field", key: `model.${mi}.reasoning.${level}`, label: ` ${level}`, value, note: level === "off" ? "null 表示关闭" : "至少填写一种非 off 强度" });
3188
+ }
3189
+ }
3190
+ const inputState = m.input === undefined || m.input.length === 0 ? "继承" : "自定义";
3191
+ items.push({ kind: "choice", key: `model.${mi}.inputMode`, label: " 输入能力", value: inputState, cycle: ["继承", "自定义"] });
3192
+ if (inputState === "自定义") {
3193
+ for (const modality of INPUT_MODALITIES) items.push({ kind: "choice", key: `model.${mi}.input.${modality}`, label: ` ${modality}`, value: m.input.includes(modality) ? "✓" : "·", cycle: ["✓", "·"] });
3194
+ }
3195
+ if (p.api === "openai-completions") {
3196
+ items.push({ kind: "field", key: `model.${mi}.compat.thinkingFormat`, label: " compat.thinkingFormat", value: m.compat?.thinkingFormat ?? "", completions: THINKING_FORMATS, note: "可选 · Tab 补全" });
3197
+ items.push({ kind: "field", key: `model.${mi}.compat.supportsReasoningEffort`, label: " compat.supportsReasoningEffort", value: m.compat?.supportsReasoningEffort == null ? "" : String(m.compat.supportsReasoningEffort), completions: ["true", "false"], note: "可选 · true/false" });
3198
+ }
3199
+ }
2393
3200
  }
2394
3201
  }
2395
3202
  items.push({ kind: "button", label: "+ 添加模型", action: () => this.#addModel() });
2396
3203
  items.push({ kind: "button", label: "🗑 删除选中模型", action: () => this.#deleteModel() });
2397
- items.push({ kind: "button", label: "◉ 设为当前会话模型(选中模型)", action: () => this.#setDefaultModel() });
2398
- items.push({ kind: "button", label: "★ 设为默认 Agent/Subagent 目标", action: () => this.#setAgentDefault() });
3204
+ items.push({ kind: "button", label: "◉ 设为当前会话及后续 Agent 默认模型", action: () => this.#setDefaultModel() });
2399
3205
  return items;
2400
3206
  }
2401
3207
  #openModels() {
@@ -2410,36 +3216,89 @@ export class ModelPanel extends Widget {
2410
3216
  const listLines = [];
2411
3217
  for (let i = 0; i < this.routes.length; i++) {
2412
3218
  const r = this.routes[i];
2413
- const p = this.providers[r] ?? {};
3219
+ const p = this.#profile(r);
3220
+ const entry = this.#entry(r);
2414
3221
  const cur = i === this.sel;
2415
3222
  const editing = cur && this.mode === "form";
2416
3223
  listLines.push([{
2417
- t: ` ${cur ? "●" : " "} ${truncate(p.displayName || r, 18)}${editing ? " ✎" : ""}`,
3224
+ t: ` ${cur ? "●" : " "} ${truncate(inlineLabel(p.displayName || entry?.displayName || r), 18)}${editing ? " ✎" : ""}`,
2418
3225
  fg: cur ? T.SELFG : T.TXT, bg: cur ? (editing ? T.MENUSEL : T.SELBG) : T.BG2, bold: cur,
2419
3226
  }]);
2420
3227
  }
2421
3228
  const addCur = this.sel === this.routes.length;
2422
3229
  listLines.push([{ t: ` ${addCur ? "●" : " "} + 添加供应商`, fg: addCur ? T.SELFG : T.ACCENT, bg: addCur ? T.MENUSEL : T.BG2, bold: true }]);
2423
3230
  this.listView.setLines(listLines);
2424
- // right form
3231
+ this.listView.scrollY = Math.max(0, Math.min(this.listView.maxScroll(), this.sel < this.listView.scrollY ? this.sel : this.sel >= this.listView.scrollY + this.listView.h ? this.sel - this.listView.h + 1 : this.listView.scrollY));
3232
+
3233
+ // Keep a target beside every rendered row. Titles, previews and help text
3234
+ // deliberately map to null, so extra visual rows cannot shift mouse clicks
3235
+ // onto the following form action.
2425
3236
  const route = this.#route();
2426
3237
  const formLines = [];
2427
- if (route == null) {
2428
- formLines.push([{ t: " 左侧 ↑/↓ 选择供应商,Enter 打开编辑", fg: K.FAINT }]);
2429
- formLines.push([{ t: " 把光标移到底部的“+ 添加供应商”回车即可新建", fg: K.FAINT }]);
2430
- formLines.push([{ t: " Esc 退出供应商配置", fg: K.FAINT }]);
3238
+ this.formClickMap = [];
3239
+ const pushForm = (line, target = null) => {
3240
+ formLines.push(line);
3241
+ this.formClickMap.push(target);
3242
+ };
3243
+ for (const [ref, task] of this.pendingCredentialCleanups) {
3244
+ pushForm([{
3245
+ t: truncate(` ⚠ ${ref} 待清理 (${inlineLabel(task.error)}) · [c 处理]`, Math.max(20, this.formView.w - 4)),
3246
+ fg: K.WARN, bold: true,
3247
+ }], { type: "cleanup", ref });
3248
+ }
3249
+ if (this.addMode) {
3250
+ const selectedAdd = this.addItems[this.addCursor];
3251
+ pushForm([{ t: " 添加提供方 — Host 可用目录", fg: K.ACCENT, bold: true }]);
3252
+ pushForm([{ t: " ↑/↓ 或 j/k 循环选择 · Enter 添加 · Esc 返回", fg: K.FAINT }]);
3253
+ // Fixed preview: selection changes never require opening a provider just
3254
+ // to learn whether it is official, catalog-backed, or fully custom.
3255
+ if (selectedAdd?.custom) {
3256
+ pushForm([{ t: " 预览 自定义提供方", fg: K.ACCENT, bold: true }]);
3257
+ pushForm([{ t: " 手动填写路由、协议、baseURL 与至少一个模型", fg: K.TXT }]);
3258
+ pushForm([{ t: " API 密钥写入 <ROUTE>_API_KEY;支持模型发现(协议允许时)", fg: K.DIM }]);
3259
+ } else if (selectedAdd?.entry) {
3260
+ const entry = selectedAdd.entry;
3261
+ const kind = entry.settingsNs === "llm-deepseek" ? "官方适配器" : entry.declared === true ? "已声明提供方" : "Host 内置目录";
3262
+ const address = `${entry.settingsNs}${entry.settingsPath.length ? ` · ${entry.settingsPath.join(".")}` : " · 根配置"}`;
3263
+ pushForm([{ t: ` 预览 ${truncate(inlineLabel(entry.displayName || entry.provider), 32)} [${kind}]`, fg: entry.settingsNs === "llm-deepseek" ? K.OK : K.ACCENT, bold: true }]);
3264
+ pushForm([{ t: ` 路由 ${truncate(inlineLabel(entry.provider), 30)} · ${entry.active ? "当前已激活" : "添加后激活"}`, fg: K.TXT }]);
3265
+ pushForm([{ t: ` ${truncate(address, Math.max(20, this.formView.w - 10))}`, fg: K.DIM }]);
3266
+ const resolvedProfile = isRecord(configAt(this.namespaceViews.get(entry.settingsNs)?.value, entry.settingsPath)) ? configAt(this.namespaceViews.get(entry.settingsNs)?.value, entry.settingsPath) : {};
3267
+ const credentialRef = typeof resolvedProfile.apiKeyEnv === "string" && resolvedProfile.apiKeyEnv ? resolvedProfile.apiKeyEnv : deriveKeyRef(entry.provider);
3268
+ pushForm([{ t: ` 模型/协议/默认端点由 Host 提供 · 密钥 ${credentialRef}`, fg: K.FAINT }]);
3269
+ }
3270
+ pushForm([{ t: "" }]);
3271
+ const addStartLine = formLines.length;
3272
+ for (let i = 0; i < this.addItems.length; i++) {
3273
+ const item = this.addItems[i], cur = i === this.addCursor;
3274
+ const meta = item.custom ? "手动填写端点/协议/模型" : item.entry.settingsNs === "llm-deepseek" ? "官方" : "内置目录";
3275
+ pushForm([{ t: ` ${cur ? "▸" : " "} ${truncate(inlineLabel(item.label), 30)} [${meta}]`, fg: cur ? T.SELFG : item.custom ? K.ACCENT : T.TXT, bg: cur ? T.MENUSEL : T.BG2, bold: cur }], { type: "add", index: i });
3276
+ }
3277
+ this.formItems = [];
3278
+ const cursorLine = addStartLine + this.addCursor;
3279
+ if (cursorLine < this.formView.scrollY) this.formView.scrollY = cursorLine;
3280
+ else if (cursorLine >= this.formView.scrollY + this.formView.h) this.formView.scrollY = Math.max(0, cursorLine - this.formView.h + 1);
3281
+ } else if (route == null) {
3282
+ pushForm([{ t: " 左侧 ↑/↓ 选择供应商,Enter 打开编辑", fg: K.FAINT }]);
3283
+ pushForm([{ t: " “+ 添加供应商”先显示 Host 官方/内置目录,末项为自定义提供方", fg: K.FAINT }]);
3284
+ pushForm([{ t: " 高级字段(modelOverrides/headers/重试/超时/transport)在 设置 中编辑", fg: K.FAINT }]);
3285
+ pushForm([{ t: " Esc 退出供应商配置", fg: K.FAINT }]);
2431
3286
  this.formItems = [];
2432
3287
  } else if (this.scanMode) {
2433
- formLines.push([{ t: ` 扫描 ${truncate(this.#profile(route).baseURL ?? "", 44)} — 空格勾选,Enter 添加,↑/↓ 移动`, fg: K.ACCENT, bold: true }]);
2434
- if (this.scanning) formLines.push([{ t: " 扫描中…", fg: K.WARN }]);
3288
+ pushForm([{ t: ` 扫描 ${truncate(inlineLabel(this.#profile(route).baseURL), 44)} — 空格勾选,Enter 添加,↑/↓ 移动`, fg: K.ACCENT, bold: true }]);
3289
+ if (this.scanning) pushForm([{ t: " 扫描中…", fg: K.WARN }]);
3290
+ let cursorLine = null;
2435
3291
  for (let i = 0; i < this.scanItems.length; i++) {
2436
3292
  const m = this.scanItems[i];
2437
3293
  const on = this.scanSel.has(m.id);
2438
3294
  const cur = i === this.scanCursor;
2439
- 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 }]);
3295
+ if (cur) cursorLine = formLines.length;
3296
+ pushForm([{ t: ` ${cur ? "▸" : " "} [${on ? "x" : " "}] ${truncate(inlineLabel(m.id), this.formView.w - 10)}`, fg: on ? K.OK : cur ? T.TXT : K.DIM, bg: cur ? T.MENUSEL : T.BG2 }], { type: "scan", index: i });
2440
3297
  }
2441
- formLines.push([{ t: " Enter 添加选中 · Esc 取消扫描", fg: K.FAINT }]);
3298
+ pushForm([{ t: " Enter 添加选中 · Esc 取消扫描", fg: K.FAINT }]);
2442
3299
  this.formItems = [];
3300
+ if (cursorLine != null && cursorLine < this.formView.scrollY) this.formView.scrollY = cursorLine;
3301
+ else if (cursorLine != null && cursorLine >= this.formView.scrollY + this.formView.h) this.formView.scrollY = Math.max(0, cursorLine - this.formView.h + 1);
2443
3302
  } else {
2444
3303
  const isSub = this.sub != null;
2445
3304
  const items = isSub ? this.#subItems() : this.#formRows();
@@ -2447,41 +3306,49 @@ export class ModelPanel extends Widget {
2447
3306
  else this.formItems = items;
2448
3307
  const w = Math.max(30, this.formView.w - 4);
2449
3308
  const cursor = isSub ? this.sub.cursor : this.formIdx;
2450
- if (isSub) formLines.push([{ t: ` 模型管理 — ${truncate(this.#profile(route).displayName || route, 30)} (Esc 返回)`, fg: K.ACCENT, bold: true }]);
3309
+ let cursorLine = null;
3310
+ if (isSub) pushForm([{ t: ` 模型管理 — ${truncate(inlineLabel(this.#profile(route).displayName || route), 30)} (Esc 返回)`, fg: K.ACCENT, bold: true }]);
2451
3311
  for (let i = 0; i < items.length; i++) {
2452
3312
  const it = items[i];
2453
3313
  const cur = i === cursor;
2454
3314
  let t;
2455
3315
  if (it.kind === "field" || it.kind === "choice") {
2456
- const v = it.value === "" || it.value == null ? "(空)" : String(it.value);
3316
+ const v = it.value === "" || it.value == null ? "(空)" : inlineLabel(it.value);
2457
3317
  t = ` ${cur ? "▸" : " "} ${it.label}: ${truncate(v, w - strWidth(it.label) - 6)}${it.note ? ` [${it.note}]` : ""}`;
3318
+ } else if (it.kind === "notice") {
3319
+ t = ` ${it.label}: ${truncate(inlineLabel(it.value), w - strWidth(it.label) - 6)}`;
2458
3320
  } else if (it.kind === "key") {
2459
3321
  // status dot + reference, NEVER the value (web-synced posture)
2460
3322
  const st = this.keyStatus?.[it.ref];
2461
- const status = st?.configured ? "● 已配置" : "○ 未配置";
3323
+ const status = it.pending ? "◐ 待保存" : st?.configured ? "● 已配置" : "○ 未配置";
2462
3324
  const ro = st && st.writable === false ? " [只读]" : "";
2463
3325
  t = ` ${cur ? "▸" : " "} ${it.label}: ${status}${ro} (${it.ref})`;
2464
3326
  } else if (it.kind === "model") {
2465
3327
  const extras = [it.ctx != null ? `ctx ${it.ctx}` : "", it.max != null ? `max ${it.max}` : ""].filter(Boolean).join(" ");
2466
- t = ` ${cur ? "▸" : " "} 模型 ${truncate(it.id || "(未命名)", 24)} ${truncate(it.name || "", 20)} ${truncate(extras, 24)}`;
3328
+ t = ` ${cur ? "▸" : " "} 模型 ${truncate(inlineLabel(it.id || "(未命名)"), 24)} ${truncate(inlineLabel(it.name || ""), 20)} ${truncate(extras, 24)}`;
2467
3329
  } else {
2468
3330
  t = ` ${cur ? "▸" : " "} ${it.label}`;
2469
3331
  }
2470
- formLines.push([{ t: truncate(t, w), fg: cur ? T.SELFG : T.TXT, bg: cur ? T.MENUSEL : T.BG2 }]);
3332
+ if (cur) cursorLine = formLines.length;
3333
+ pushForm([{ t: truncate(t, w), fg: cur ? T.SELFG : T.TXT, bg: cur ? T.MENUSEL : T.BG2 }], { type: "item", index: i, sub: isSub });
2471
3334
  // the 模型管理 preview: an indented, non-focusable summary line
2472
3335
  if (!isSub && it.kind === "button" && it.sub) {
2473
- formLines.push([{ t: ` ${truncate(it.sub, w - 8)}`, fg: K.FAINT, bg: T.BG2 }]);
3336
+ pushForm([{ t: ` ${truncate(it.sub, w - 8)}`, fg: K.FAINT, bg: T.BG2 }]);
2474
3337
  }
2475
3338
  }
2476
- formLines.push([{ t: isSub
3339
+ pushForm([{ t: isSub
2477
3340
  ? " ↑/↓ 移动 · Enter 编辑或执行 · Esc 返回供应商"
2478
3341
  : " ↑/↓ 移动 · → 进入选项 · ← 返回列表 · Enter 编辑或执行 · Tab 切换选项 · Esc 返回列表", fg: K.FAINT }]);
3342
+ if (cursorLine != null && cursorLine < this.formView.scrollY) this.formView.scrollY = cursorLine;
3343
+ else if (cursorLine != null && cursorLine >= this.formView.scrollY + this.formView.h) this.formView.scrollY = Math.max(0, cursorLine - this.formView.h + 1);
2479
3344
  }
3345
+ if (!this.writable) pushForm([{ t: " 模型配置只读 · 可浏览、发现模型和切换当前会话模型", fg: K.WARN }]);
2480
3346
  this.formView.setLines(formLines);
3347
+ this.formView.scrollY = Math.max(0, Math.min(this.formView.scrollY, this.formView.maxScroll()));
2481
3348
  }
2482
3349
  render(screen) {
2483
3350
  screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", {});
2484
- const mid = this.x + 26;
3351
+ const mid = this.formView.x - 1;
2485
3352
  screen.vline(mid, this.y, this.y + this.h - 1, "│", { fg: T.BORDER });
2486
3353
  screen.text(this.x + 1, this.y, " 模型供应商", { fg: K.DIM });
2487
3354
  this.listView.render(screen);
@@ -2511,18 +3378,40 @@ export class ModelPanel extends Widget {
2511
3378
  if (!/^[\x21-\x7E]+$/.test(value)) return "密钥只能包含可打印 ASCII 字符";
2512
3379
  return null;
2513
3380
  }
2514
- /** Edit the API key value the web-synced way: a masked, always-empty editor
2515
- * (the stored value is never read back), a non-empty commit travels one way
2516
- * through credentials.set under the profile's reference, an empty commit
2517
- * keeps the existing key. */
2518
- #clearKey(ref) {
2519
- const confirm = new Popup({ x: Math.max(1, Math.floor(this.app.screen.w / 2) - 28), y: Math.max(1, Math.floor(this.app.screen.h / 2) - 3), w: Math.min(56, this.app.screen.w - 2), h: 7, title: "清除 API 密钥", lines: [[{ t: ` 确认从凭据存储中清除 ${ref}?`, fg: K.WARN }]], buttons: [{ label: "取消", action: "cancel" }, { label: "确认清除", action: "clear" }], onAction: async (btn) => {
2520
- this.app.overlay = this;
2521
- if (btn.action !== "clear") { this.app.redraw(); return; }
2522
- try { await this.app.api.call("credentials.unset", { ref }); this.app.toast(`已清除 ${ref}`); await this.#refreshKeys(); this.#rebuild(); }
2523
- catch (e) { this.app.toast(`清除密钥失败: ${e.message}`); }
2524
- this.app.redraw();
2525
- } });
3381
+ /** Edit the API key value the web-synced way: a masked, always-empty editor.
3382
+ * The stored value is never read back; a non-empty commit stays write-only
3383
+ * until #save persists it, while an empty commit keeps the existing key. */
3384
+ #clearKey(route, ref) {
3385
+ const users = this.routes.filter((candidate) => this.#keyRef(candidate) === ref);
3386
+ const others = users.filter((candidate) => candidate !== route);
3387
+ const pending = users.filter((candidate) => this.pendingProbeKeys.has(candidate));
3388
+ const lines = [
3389
+ [{ t: ` ${inlineLabel(ref)} 是凭据存储中的全局引用。`, fg: K.WARN }],
3390
+ ...(others.length > 0 ? [[{ t: ` 其他引用者: ${truncate(others.map(inlineLabel).join("、"), 52)}`, fg: K.WARN }]] : []),
3391
+ ...(ref !== deriveKeyRef(route) ? [[{ t: " 这是自定义引用,可能还被面板外配置使用。", fg: K.WARN }]] : []),
3392
+ ...(pending.length > 0 ? [[{ t: ` ${pending.length} 个待保存密钥草稿也会取消。`, fg: K.TXT }]] : []),
3393
+ [{ t: " 清除后,所有引用者将立即失去此密钥。", fg: K.TXT }],
3394
+ ];
3395
+ const w = Math.max(32, Math.min(64, this.app.screen.w - 4));
3396
+ const h = Math.min(lines.length + 4, this.app.screen.h);
3397
+ const confirm = new Popup({
3398
+ x: Math.max(0, Math.floor((this.app.screen.w - w) / 2)),
3399
+ y: Math.max(0, Math.floor((this.app.screen.h - h) / 2)),
3400
+ w, h, title: "全局清除 API 密钥", lines,
3401
+ buttons: [{ label: "取消", action: "cancel" }, { label: "全局清除", action: "clear" }],
3402
+ onAction: async (btn) => {
3403
+ this.app.closeOverlay();
3404
+ if (btn.action !== "clear") { this.app.redraw(); return; }
3405
+ try {
3406
+ await this.app.api.call("credentials.unset", { ref });
3407
+ for (const candidate of users) this.pendingProbeKeys.delete(candidate);
3408
+ this.app.toast(`已全局清除 ${ref}`);
3409
+ await this.#refreshKeys();
3410
+ this.#rebuild();
3411
+ } catch (e) { this.app.toast(`清除密钥失败: ${e.message}`); }
3412
+ this.app.redraw();
3413
+ },
3414
+ });
2526
3415
  this.app.overlay = confirm; this.app.redraw();
2527
3416
  }
2528
3417
  #editKey(route, ref) {
@@ -2531,6 +3420,7 @@ export class ModelPanel extends Widget {
2531
3420
  return;
2532
3421
  }
2533
3422
  const st = this.keyStatus?.[ref];
3423
+ if (st?.writable === false) { this.app.toast(`${ref} 为只读凭据`); return; }
2534
3424
  const popup = new EditPopup(this.app, {
2535
3425
  title: `设置 API 密钥 — ${ref}`,
2536
3426
  value: "",
@@ -2542,18 +3432,11 @@ export class ModelPanel extends Widget {
2542
3432
  if (failure) { this.app.toast(failure); this.#rebuild(); this.app.redraw(); return; }
2543
3433
  const v = text.trim();
2544
3434
  if (v === "") { this.app.toast("未输入新密钥,保持原值不变"); this.#rebuild(); this.app.redraw(); return; }
2545
- try {
2546
- await this.app.api.call("credentials.set", { ref, value: v });
2547
- this.app.toast(`密钥已写入 ${ref}`);
2548
- const p = this.#profile(route);
2549
- if (!p.apiKeyEnv) {
2550
- // the web create flow records the derived reference in the profile;
2551
- // persist it with the provider save
2552
- p.apiKeyEnv = ref;
2553
- this.app.toast(`已记录 apiKeyEnv · 点💾保存配置使供应商生效`);
2554
- }
2555
- await this.#refreshKeys();
2556
- } catch (e) { this.app.toast(`密钥写入失败: ${e.message}`); }
3435
+ // Like the web editor, keep the typed value only in this write-only
3436
+ // draft. #save persists settings first, then writes the credential.
3437
+ this.pendingProbeKeys.set(route, v);
3438
+ if (!this.#profile(route)?.apiKeyEnv) this.#draftProfile(route).apiKeyEnv = ref;
3439
+ this.app.toast(`密钥待保存到 ${ref} · 可先用于自动发现`);
2557
3440
  this.#rebuild();
2558
3441
  this.app.redraw();
2559
3442
  },
@@ -2562,12 +3445,28 @@ export class ModelPanel extends Widget {
2562
3445
  this.app.focus(popup.input);
2563
3446
  this.app.redraw();
2564
3447
  }
2565
- #addProvider() {
2566
- let name = "新供应商", i = 2;
2567
- while (this.providers[name] !== undefined) name = `新供应商${i++}`;
2568
- this.providers[name] = { displayName: "", api: "openai-completions", baseURL: "", apiKeyEnv: "", models: [] };
3448
+ #openAddProvider() {
3449
+ if (!this.writable) { this.app.toast("模型配置为只读"); return; }
3450
+ const configured = new Set(this.routes);
3451
+ const entries = this.directory.filter((entry) => entry.settingsNs && !configured.has(entry.provider));
3452
+ this.addItems = [
3453
+ ...entries.map((entry) => ({ entry, label: entry.displayName || entry.provider })),
3454
+ { custom: true, label: "自定义提供方" },
3455
+ ];
3456
+ this.addCursor = 0;
3457
+ this.addMode = true;
3458
+ this.mode = "list";
3459
+ this.#rebuild();
3460
+ this.app.redraw();
3461
+ }
3462
+ #addCustomProvider() {
3463
+ let name = "new-provider", i = 2;
3464
+ while (this.routes.includes(name) || this.#cleanupRouteReserved(name) || this.pendingCredentialCleanups.has(deriveKeyRef(name))) name = `new-provider-${i++}`;
3465
+ this.providers[name] = { api: "openai-completions", defaultInput: [...DEFAULT_INPUT_MODALITIES], models: [] };
3466
+ this.configuredDirectory.add(name);
2569
3467
  this.draftRoute = name;
2570
- this.routes = Object.keys(this.providers);
3468
+ this.addMode = false;
3469
+ this.#syncRoutes();
2571
3470
  this.sel = this.routes.indexOf(name);
2572
3471
  this.mode = "form";
2573
3472
  this.formIdx = 0;
@@ -2576,9 +3475,45 @@ export class ModelPanel extends Widget {
2576
3475
  this.#rebuild();
2577
3476
  this.app.redraw();
2578
3477
  }
3478
+ #addDirectoryProvider(entry) {
3479
+ if (!entry || !this.namespaceViews.has(entry.settingsNs)) {
3480
+ this.app.toast("该提供方的设置 namespace 当前不可用");
3481
+ return;
3482
+ }
3483
+ this.configuredDirectory.add(entry.provider);
3484
+ if (entry.settingsNs === "llm-pi-ai") {
3485
+ this.providers[entry.provider] ??= {};
3486
+ this.inheritedProviders[entry.provider] = {};
3487
+ this.materializeRoutes.add(entry.provider);
3488
+ } else {
3489
+ const view = this.namespaceViews.get(entry.settingsNs);
3490
+ const stored = configAt(view?.user, entry.settingsPath);
3491
+ this.externalDrafts.set(entry.provider, cloneConfig(stored ?? {}));
3492
+ if (stored !== undefined && entry.settingsPath.length > 0) this.externalUserConfigured.add(entry.provider);
3493
+ this.externalInherited.set(entry.provider, mergeConfig(withoutOwned(configAt(view?.value, entry.settingsPath), stored), configAt(view?.base, entry.settingsPath)));
3494
+ this.externalSnapshots.set(entry.provider, JSON.stringify(this.externalDrafts.get(entry.provider)));
3495
+ this.externalHostSnapshots.set(entry.provider, JSON.stringify(this.externalDrafts.get(entry.provider)));
3496
+ }
3497
+ this.addMode = false;
3498
+ this.#syncRoutes();
3499
+ this.sel = this.routes.indexOf(entry.provider);
3500
+ this.mode = "form";
3501
+ this.formIdx = 0;
3502
+ this.modelsSel = -1;
3503
+ this.sub = null;
3504
+ this.#rebuild();
3505
+ this.app.redraw();
3506
+ }
3507
+ #activateAddItem() {
3508
+ const item = this.addItems[this.addCursor];
3509
+ if (!item) return;
3510
+ if (item.custom) this.#addCustomProvider();
3511
+ else this.#addDirectoryProvider(item.entry);
3512
+ }
2579
3513
  #activateItem() {
2580
3514
  if (this.mode === "list") {
2581
- if (this.sel === this.routes.length) { this.#addProvider(); return; }
3515
+ if (this.sel === this.routes.length) { this.#openAddProvider(); return; }
3516
+ this.addMode = false;
2582
3517
  this.mode = "form";
2583
3518
  this.formIdx = 0;
2584
3519
  this.modelsSel = -1;
@@ -2592,51 +3527,136 @@ export class ModelPanel extends Widget {
2592
3527
  const it = items[idx];
2593
3528
  if (!it) return;
2594
3529
  const route = this.#route();
2595
- const p = this.#profile(route);
3530
+ const effective = this.#profile(route);
3531
+ const settingsMutation = it.kind === "field" || it.kind === "choice" || it.kind === "key"
3532
+ || (it.kind === "button" && /保存配置|删除供应商|取消配置提供方|添加模型|删除选中模型|恢复 Host 内置模型目录|清除 API 密钥/.test(it.label));
3533
+ if (!this.writable && settingsMutation) { this.app.toast("模型配置为只读"); return; }
2596
3534
  if (it.kind === "field") {
2597
3535
  // Enter always opens the standalone edit buffer; Tab (handled in onKey)
2598
3536
  // cycles a field that declares cycle options, and the buffer itself
2599
3537
  // offers every completion as an autocomplete hint
2600
3538
  this.#startEdit(it.label, it.value, (text) => {
3539
+ if (it.key === "api" && text.trim() && !API_PROTOCOLS.includes(text.trim())) { this.app.toast(`协议 ${text.trim()} 不受支持`); return; }
3540
+ if (it.key === "reasoning" && text.trim() && !THINKING_LEVELS.includes(text.trim())) { this.app.toast(`思考强度 ${text.trim()} 不受支持`); return; }
3541
+ if (it.numeric) {
3542
+ const candidate = text.trim() === "" ? undefined : Number(text);
3543
+ if (candidate !== undefined && (!Number.isInteger(candidate) || candidate <= 0)) { this.app.toast("请输入正整数"); return; }
3544
+ }
3545
+ if (it.key !== "route" && text.trim() === String(it.value ?? "").trim()) return;
3546
+ const p = this.#draftProfile(route);
2601
3547
  if (it.key === "route") {
2602
- // renaming the route key
2603
- const t = text.trim() || route;
2604
- if (t !== route && this.providers[t] !== undefined) { this.app.toast(`路由 ${t} 已存在`); return; }
3548
+ const t = text.trim();
3549
+ if (!ROUTE_PATTERN.test(t)) { this.app.toast("路由名须以小写字母开头,只能包含小写字母、数字和单连字符"); return; }
3550
+ if (t !== route && this.routes.includes(t)) { this.app.toast(`路由 ${t} 已存在`); return; }
3551
+ if (t !== route && (this.#cleanupRouteReserved(t) || this.pendingCredentialCleanups.has(deriveKeyRef(t)))) {
3552
+ this.app.toast(`路由 ${t} 的托管密钥仍待处理,请先完成清理`);
3553
+ return;
3554
+ }
2605
3555
  if (t !== route) {
2606
- this.providers[t] = this.providers[route];
3556
+ const profile = this.providers[route];
3557
+ const oldDerivedRef = deriveKeyRef(route);
3558
+ this.providers[t] = profile;
2607
3559
  delete this.providers[route];
2608
- this.routes = Object.keys(this.providers);
3560
+ if (profile.apiKeyEnv === oldDerivedRef) profile.apiKeyEnv = deriveKeyRef(t);
3561
+ if (this.pendingProbeKeys.has(route)) {
3562
+ this.pendingProbeKeys.set(t, this.pendingProbeKeys.get(route));
3563
+ this.pendingProbeKeys.delete(route);
3564
+ }
3565
+ this.draftRoute = t;
3566
+ this.#syncRoutes();
2609
3567
  this.sel = this.routes.indexOf(t);
2610
3568
  }
2611
3569
  } else if (it.numeric) {
2612
3570
  const n = text.trim() === "" ? undefined : Number(text);
2613
- if (n !== undefined && (!isFinite(n) || n <= 0)) { this.app.toast("请输入正数"); return; }
2614
3571
  if (it.key.startsWith("model.")) {
2615
- const [, mi, field] = it.key.split("."); p.models[Number(mi)][field] = n;
2616
- } else p[it.key] = n;
3572
+ const [, mi, field] = it.key.split(".");
3573
+ const model = this.#models(route, { mutable: true })[Number(mi)];
3574
+ if (n === undefined) delete model[field];
3575
+ else model[field] = n;
3576
+ } else if (n === undefined) delete p[it.key];
3577
+ else p[it.key] = n;
2617
3578
  } else if (it.key.startsWith("model.")) {
2618
3579
  const [, mi, field, detail] = it.key.split(".");
2619
- const model = p.models[Number(mi)];
3580
+ const model = this.#models(route, { mutable: true })[Number(mi)];
2620
3581
  if (field === "reasoning") {
2621
- if (model.reasoningEfforts === false) model.reasoningEfforts = {};
3582
+ if (!model.reasoningEfforts || model.reasoningEfforts === false) model.reasoningEfforts = {};
2622
3583
  const value = text.trim();
2623
3584
  if (!value) delete model.reasoningEfforts[detail];
2624
- else model.reasoningEfforts[detail] = value === "null" ? null : value;
2625
- } else model[field] = text;
3585
+ else if (value === "null") {
3586
+ if (detail !== "off") { this.app.toast("只有 off 强度可以使用 null"); return; }
3587
+ model.reasoningEfforts[detail] = null;
3588
+ } else model.reasoningEfforts[detail] = value;
3589
+ } else if (field === "compat") {
3590
+ model.compat ??= {};
3591
+ const value = text.trim();
3592
+ if (!value) delete model.compat[detail];
3593
+ else if (detail === "supportsReasoningEffort") {
3594
+ if (value !== "true" && value !== "false") { this.app.toast("请输入 true 或 false,或留空删除"); return; }
3595
+ model.compat[detail] = value === "true";
3596
+ } else {
3597
+ if (!THINKING_FORMATS.includes(value)) { this.app.toast("请选择有效的 thinkingFormat"); return; }
3598
+ model.compat[detail] = value;
3599
+ }
3600
+ if (Object.keys(model.compat).length === 0) delete model.compat;
3601
+ } else {
3602
+ const value = text.trim();
3603
+ if (!value && field !== "id") delete model[field];
3604
+ else model[field] = value;
3605
+ }
3606
+ } else if (it.key.startsWith("compat.")) {
3607
+ const field = it.key.slice("compat.".length);
3608
+ p.compat ??= {};
3609
+ const value = text.trim();
3610
+ if (!value) delete p.compat[field];
3611
+ else if (field === "supportsReasoningEffort") {
3612
+ if (value !== "true" && value !== "false") { this.app.toast("请输入 true 或 false,或留空删除"); return; }
3613
+ p.compat[field] = value === "true";
3614
+ } else {
3615
+ if (!THINKING_FORMATS.includes(value)) { this.app.toast("请选择有效的 thinkingFormat"); return; }
3616
+ p.compat[field] = value;
3617
+ }
3618
+ if (Object.keys(p.compat).length === 0) delete p.compat;
2626
3619
  } else {
2627
- if (it.key === "api" && !API_PROTOCOLS.includes(text.trim())) this.app.toast(`注意:${text.trim() || "空"} 不是已知协议,保存时可能被拒绝`);
2628
- if (it.key === "__defaultReasoningEffort") {
2629
- if (!this.defaultModel || this.defaultModel.provider !== route) { this.app.toast("请先在模型管理中将本供应商的模型设为当前会话模型;默认 Agent 模型可在 Settings 修改"); return; }
2630
- this.defaultModel.reasoningEffort = text.trim() || undefined;
2631
- } else p[it.key] = text;
3620
+ const value = text.trim();
3621
+ if (!value) delete p[it.key];
3622
+ else p[it.key] = value;
3623
+ if (it.key === "api" && this.#profile(route).api !== "openai-completions") this.#stripCompat(route);
2632
3624
  }
3625
+ this.#pruneEmptyDraft(route);
2633
3626
  }, it.completions);
2634
3627
  return;
2635
3628
  }
2636
3629
  if (it.kind === "choice") {
2637
- const [, mi, field, detail] = it.key.split("."); const model = p.models[Number(mi)];
2638
- if (field === "reasoningEnabled") model.reasoningEfforts = it.value === "是" ? false : {};
2639
- else if (field === "input") { const set = new Set(model.input ?? ["text"]); set.has(detail) ? set.delete(detail) : set.add(detail); model.input = [...set]; }
3630
+ if (it.key.startsWith("defaultInput.")) {
3631
+ const modality = it.key.slice("defaultInput.".length);
3632
+ const set = new Set(effective.defaultInput ?? DEFAULT_INPUT_MODALITIES);
3633
+ if (set.has(modality)) {
3634
+ if (set.size === 1) { this.app.toast("defaultInput 至少需要一种模态"); return; }
3635
+ set.delete(modality);
3636
+ } else set.add(modality);
3637
+ this.#draftProfile(route).defaultInput = INPUT_MODALITIES.filter((item) => set.has(item));
3638
+ } else if (it.key.startsWith("model.")) {
3639
+ const [, mi, field, detail] = it.key.split(".");
3640
+ if (field === "input") {
3641
+ const current = this.#models(route)[Number(mi)];
3642
+ if (current.input?.includes(detail) && current.input.length === 1) { this.app.toast("模型输入能力至少需要一种模态"); return; }
3643
+ }
3644
+ const model = this.#models(route, { mutable: true })[Number(mi)];
3645
+ if (field === "reasoningMode") {
3646
+ const next = it.value === "继承" ? "关闭" : it.value === "关闭" ? "自定义" : "继承";
3647
+ if (next === "继承") delete model.reasoningEfforts;
3648
+ else if (next === "关闭") model.reasoningEfforts = false;
3649
+ else model.reasoningEfforts = { medium: "medium" };
3650
+ } else if (field === "inputMode") {
3651
+ if (it.value === "继承") model.input = [...DEFAULT_INPUT_MODALITIES];
3652
+ else delete model.input;
3653
+ } else if (field === "input") {
3654
+ const set = new Set(model.input);
3655
+ if (set.has(detail)) set.delete(detail);
3656
+ else set.add(detail);
3657
+ model.input = INPUT_MODALITIES.filter((item) => set.has(item));
3658
+ }
3659
+ }
2640
3660
  this.#rebuild(); this.app.redraw(); return;
2641
3661
  }
2642
3662
  if (it.kind === "model") {
@@ -2651,22 +3671,32 @@ export class ModelPanel extends Widget {
2651
3671
  return;
2652
3672
  }
2653
3673
  }
3674
+ #resetModels() {
3675
+ const route = this.#route();
3676
+ if (!route) return;
3677
+ if (!this.writable) { this.app.toast("模型配置为只读"); return; }
3678
+ const profile = this.#draftProfile(route);
3679
+ delete profile.models;
3680
+ this.modelsSel = -1;
3681
+ this.app.toast("已恢复 Host 内置模型目录(保存后生效)");
3682
+ this.#rebuild();
3683
+ this.app.redraw();
3684
+ }
2654
3685
  #addModel() {
2655
3686
  const route = this.#route();
2656
3687
  if (!route) return;
2657
- const p = this.#profile(route);
2658
- p.models ??= [];
2659
- p.models.push({ id: "", name: "" });
2660
- this.modelsSel = p.models.length - 1;
3688
+ const models = this.#models(route, { mutable: true });
3689
+ models.push({ id: "" });
3690
+ this.modelsSel = models.length - 1;
2661
3691
  this.#rebuild();
2662
3692
  this.app.redraw();
2663
3693
  }
2664
3694
  #deleteModel() {
2665
3695
  const route = this.#route();
2666
3696
  if (!route || this.modelsSel < 0) { this.app.toast("先选中一个模型"); return; }
2667
- const model = this.#profile(route).models[this.modelsSel];
3697
+ const model = this.#models(route)[this.modelsSel];
2668
3698
  this.#confirmDelete(`删除模型 ${model?.name || model?.id || "(未命名)"}?`, () => {
2669
- this.#profile(route).models.splice(this.modelsSel, 1);
3699
+ this.#models(route, { mutable: true }).splice(this.modelsSel, 1);
2670
3700
  this.modelsSel = -1;
2671
3701
  this.#rebuild(); this.app.redraw();
2672
3702
  });
@@ -2674,60 +3704,214 @@ export class ModelPanel extends Widget {
2674
3704
  async #setDefaultModel() {
2675
3705
  const route = this.#route();
2676
3706
  if (!route) return;
2677
- const p = this.#profile(route);
2678
- const m = p.models?.[this.modelsSel];
3707
+ const m = this.#models(route)[this.modelsSel];
2679
3708
  if (!m?.id) { this.app.toast("先选中一个模型"); return; }
2680
3709
  if (!this.app.currentSession) { this.app.toast("先打开一个会话"); return; }
2681
3710
  try {
2682
3711
  await this.app.api.call("session.selectModel", { sessionId: this.app.currentSession, provider: route, model: m.id });
2683
- this.app.updateModel();
2684
- this.app.toast(`已切换 ${route}/${m.id}`);
3712
+ if (typeof this.app.updateModel === "function") await this.app.updateModel();
3713
+ this.app.toast(`已切换 ${route}/${m.id},后续 Agent/Subagent 默认使用此模型`);
2685
3714
  } catch (e) { this.app.toast(`切换失败: ${e.message}`); }
2686
3715
  }
2687
- #setAgentDefault() {
2688
- const route = this.#route(), m = this.#profile(route)?.models?.[this.modelsSel];
2689
- if (!route || !m?.id) { this.app.toast("先选中一个模型"); return; }
2690
- this.defaultModel = { provider: route, model: m.id, ...(this.defaultModel?.reasoningEffort ? { reasoningEffort: this.defaultModel.reasoningEffort } : {}) };
2691
- this.app.toast(`默认 Agent/Subagent 目标设为 ${route}/${m.id},点💾保存配置`);
2692
- this.#rebuild(); this.app.redraw();
2693
- }
2694
- async #save() {
3716
+ async #save({ savePendingKeys = true } = {}) {
3717
+ if (!this.writable) { this.app.toast("模型配置为只读"); return false; }
3718
+ const route = this.#route();
3719
+ // Official/non-pi providers own their own settings namespace. Save their
3720
+ // minimal profile ops there, then persist the write-only credential using
3721
+ // the same two-step posture as the WebUI.
3722
+ if (route && this.externalDrafts.has(route)) return this.#saveExternal(route, { savePendingKeys });
3723
+ const persisted = JSON.parse(this.hostSnapshot);
3724
+ for (const [route, profile] of Object.entries(this.providers)) {
3725
+ // An empty profile is meaningful for a Host catalog route: it activates
3726
+ // the provider while inheriting protocol, endpoint, and models.
3727
+ if (!Object.hasOwn(persisted, route) && Object.keys(profile).length === 0 && !this.pendingProbeKeys.has(route) && !this.materializeRoutes.has(route)) delete this.providers[route];
3728
+ }
3729
+ for (const [route, profile] of Object.entries(this.providers)) {
3730
+ if (!route.trim()) { this.app.toast("保存失败:供应商路由名不能为空"); return false; }
3731
+ if (this.draftRoute === route && !ROUTE_PATTERN.test(route)) { this.app.toast("保存失败:新供应商路由名格式无效"); return false; }
3732
+ const entry = this.#entry(route);
3733
+ // declared is advisory; absent/unknown must not be guessed as custom.
3734
+ const declared = entry?.declared === true || this.draftRoute === route;
3735
+ if (profile.displayName !== undefined && !String(profile.displayName).trim()) { this.app.toast(`保存失败:${route} 的显示名不能为空`); return false; }
3736
+ if (profile.baseURL !== undefined && !String(profile.baseURL).trim()) { this.app.toast(`保存失败:${route} 的 baseURL 不能为空`); return false; }
3737
+ if (profile.apiKeyEnv !== undefined && !KEY_REF_OK.test(profile.apiKeyEnv)) { this.app.toast(`保存失败:${route} 的密钥引用无效`); return false; }
3738
+ if (profile.api !== undefined && !API_PROTOCOLS.includes(profile.api)) { this.app.toast(`保存失败:${route} 的协议不受支持`); return false; }
3739
+ // Like WebUI's CustomProviderCard, a hand-declared route cannot inherit
3740
+ // these three facts from the installed catalog.
3741
+ if (declared && !API_PROTOCOLS.includes(profile.api)) { this.app.toast(`保存失败:${route} 的自定义提供方必须选择 API 协议`); return false; }
3742
+ if (declared && !String(profile.baseURL ?? "").trim()) { this.app.toast(`保存失败:${route} 的自定义提供方必须填写 baseURL`); return false; }
3743
+ if (declared && (!Array.isArray(profile.models) || profile.models.length === 0)) { this.app.toast(`保存失败:${route} 的自定义提供方至少需要一个模型`); return false; }
3744
+ for (const model of profile.models ?? []) {
3745
+ if (!String(model.id ?? "").trim()) { this.app.toast(`保存失败:${route} 有未填写 id 的模型`); return false; }
3746
+ if (model.reasoningEfforts && model.reasoningEfforts !== false) {
3747
+ const declared = Object.entries(model.reasoningEfforts);
3748
+ if (declared.some(([level, wire]) => level !== "off" && (typeof wire !== "string" || wire.length === 0))) {
3749
+ this.app.toast(`保存失败:${route}/${model.id} 的非 off 思考强度必须填写 wire 值`); return false;
3750
+ }
3751
+ if (!declared.some(([level, wire]) => level !== "off" && typeof wire === "string" && wire.length > 0)) {
3752
+ this.app.toast(`保存失败:${route}/${model.id} 的自定义思考能力至少需要一种非 off 强度`); return false;
3753
+ }
3754
+ }
3755
+ }
3756
+ }
3757
+ let settingsChanged = false;
3758
+ const confirmed = JSON.parse(this.savedSnapshot);
3759
+ if (JSON.stringify(this.providers) !== this.hostSnapshot || this.materializeRoutes.size > 0) {
3760
+ try {
3761
+ const wholeRoutes = new Set([...(this.draftRoute ? [this.draftRoute] : []), ...this.materializeRoutes]);
3762
+ const ops = providerOps(JSON.parse(this.hostSnapshot), this.providers, wholeRoutes);
3763
+ const res = await this.app.api.call("settings.mutate", {
3764
+ ns: "llm-pi-ai",
3765
+ ops,
3766
+ expectedRevision: this.revision,
3767
+ });
3768
+ this.revision = res?.revision ?? this.revision;
3769
+ this.draftRoute = null;
3770
+ this.materializeRoutes.clear();
3771
+ this.hostSnapshot = JSON.stringify(this.providers);
3772
+ this.initialConfiguredDirectory = new Set(this.configuredDirectory);
3773
+ settingsChanged = true;
3774
+ } catch (e) { this.app.toast(`保存失败: ${e.message}`); return false; }
3775
+ }
3776
+ for (const route of new Set([...Object.keys(confirmed), ...Object.keys(this.providers)])) {
3777
+ if (this.pendingProbeKeys.has(route)) continue;
3778
+ if (Object.hasOwn(this.providers, route)) confirmed[route] = this.providers[route];
3779
+ else delete confirmed[route];
3780
+ }
3781
+ this.savedSnapshot = JSON.stringify(confirmed);
2695
3782
  try {
2696
- const res = await this.app.api.call("settings.mutate", {
2697
- ns: "llm-pi-ai",
2698
- ops: [{ op: "set", path: ["providers"], value: this.providers }],
2699
- expectedRevision: this.revision,
2700
- });
2701
- this.revision = res?.revision ?? this.revision;
2702
- this.draftRoute = null;
2703
- this.savedSnapshot = JSON.stringify(this.providers);
2704
- if (this.defaultModel) {
2705
- const profile = this.providers[this.defaultModel.provider];
2706
- if (!profile || !(profile.models ?? []).some((model) => model.id === this.defaultModel.model)) {
2707
- this.app.toast("供应商已保存;默认 Agent 模型引用已失效,请选择一个现有模型后重试"); return false;
3783
+ if (savePendingKeys) {
3784
+ for (const [route, value] of [...this.pendingProbeKeys]) {
3785
+ const ref = this.#keyRef(route);
3786
+ await this.app.api.call("credentials.set", { ref, value });
3787
+ this.pendingProbeKeys.delete(route);
3788
+ if (Object.hasOwn(this.providers, route)) confirmed[route] = this.providers[route];
3789
+ else delete confirmed[route];
3790
+ this.savedSnapshot = JSON.stringify(confirmed);
2708
3791
  }
2709
- try {
2710
- const def = await this.app.api.call("settings.mutate", { ns: "agent-default-model", ops: [{ op: "set", path: [], value: this.defaultModel }], expectedRevision: this.defaultModelRevision });
2711
- this.defaultModelRevision = def?.revision ?? this.defaultModelRevision;
2712
- } catch (e) {
2713
- this.app.toast(`供应商已保存;默认 Agent 模型保存失败: ${e.message}`); return false;
3792
+ }
3793
+ await this.#refreshKeys();
3794
+ this.app.toast(settingsChanged
3795
+ ? `已保存 ${Object.keys(this.providers).length} 个供应商`
3796
+ : savePendingKeys ? "API 密钥已保存" : "配置未变化");
3797
+ return true;
3798
+ } catch (e) {
3799
+ await this.#refreshKeys();
3800
+ this.app.toast(`${settingsChanged ? "供应商已保存;" : ""}API 密钥保存失败: ${e.message}`);
3801
+ return false;
3802
+ }
3803
+ }
3804
+ async #saveExternal(route, { savePendingKeys = true } = {}) {
3805
+ const entry = this.#entry(route);
3806
+ const draft = this.externalDrafts.get(route) ?? {};
3807
+ const hostSnapshot = this.externalHostSnapshots.get(route) ?? "{}";
3808
+ const savedSnapshot = this.externalSnapshots.get(route) ?? "{}";
3809
+ for (const model of draft.models ?? []) {
3810
+ if (!String(model.id ?? "").trim()) { this.app.toast(`保存失败:${route} 有未填写 id 的模型`); return false; }
3811
+ for (const field of ["contextWindow", "maxTokens"]) {
3812
+ if (model[field] !== undefined && (!Number.isInteger(model[field]) || model[field] <= 0)) {
3813
+ this.app.toast(`保存失败:${route}/${model.id} 的 ${field} 必须是正整数`); return false;
3814
+ }
3815
+ }
3816
+ }
3817
+ let settingsChanged = false;
3818
+ if (JSON.stringify(draft) !== hostSnapshot) {
3819
+ try {
3820
+ const ops = profileOps(entry.settingsPath, JSON.parse(hostSnapshot), draft);
3821
+ if (ops.length > 0) {
3822
+ const res = await this.app.api.call("settings.mutate", {
3823
+ ns: entry.settingsNs,
3824
+ ops,
3825
+ expectedRevision: this.revisions.get(entry.settingsNs) ?? 0,
3826
+ });
3827
+ this.revisions.set(entry.settingsNs, res?.revision ?? this.revisions.get(entry.settingsNs) ?? 0);
2714
3828
  }
3829
+ this.externalHostSnapshots.set(route, JSON.stringify(draft));
3830
+ settingsChanged = true;
3831
+ } catch (e) { this.app.toast(`保存失败: ${e.message}`); return false; }
3832
+ }
3833
+ try {
3834
+ if (savePendingKeys && this.pendingProbeKeys.has(route)) {
3835
+ await this.app.api.call("credentials.set", { ref: this.#keyRef(route), value: this.pendingProbeKeys.get(route) });
3836
+ this.pendingProbeKeys.delete(route);
2715
3837
  }
2716
- this.app.toast(`已保存 ${Object.keys(this.providers).length} 个供应商及默认模型参数`);
3838
+ this.externalSnapshots.set(route, JSON.stringify(draft));
3839
+ this.initialConfiguredDirectory.add(route);
3840
+ await this.#refreshKeys();
3841
+ this.app.toast(settingsChanged ? `已保存 ${entry.displayName || route}` : savePendingKeys ? "API 密钥已保存" : "配置未变化");
2717
3842
  return true;
2718
- } catch (e) { this.app.toast(`保存失败: ${e.message}`); return false; }
3843
+ } catch (e) {
3844
+ // Profile changes already confirmed by Host remain the host snapshot, but
3845
+ // the fully successful save point waits for the credential write.
3846
+ this.externalSnapshots.set(route, savedSnapshot);
3847
+ await this.#refreshKeys();
3848
+ this.app.toast(`${settingsChanged ? "供应商已保存;" : ""}API 密钥保存失败: ${e.message}`);
3849
+ return false;
3850
+ }
2719
3851
  }
2720
- #dirty() { return JSON.stringify(this.providers) !== this.savedSnapshot; }
2721
- /** Throw away the in-memory edits and restore the last saved/loaded state. */
2722
- #discard() {
3852
+ #dirty() {
3853
+ if (JSON.stringify(this.providers) !== this.savedSnapshot || this.materializeRoutes.size > 0 || this.pendingProbeKeys.size > 0) return true;
3854
+ for (const [route, draft] of this.externalDrafts) if (JSON.stringify(draft) !== (this.externalSnapshots.get(route) ?? "{}")) return true;
3855
+ return false;
3856
+ }
3857
+ /** Throw away the in-memory edits and restore the last fully successful state. */
3858
+ async #discard() {
3859
+ // Compensate any external namespace whose profile write succeeded before a
3860
+ // credential write failed, mirroring the pi-ai rollback below.
3861
+ for (const [route, hostSnapshot] of this.externalHostSnapshots) {
3862
+ const savedSnapshot = this.externalSnapshots.get(route) ?? "{}";
3863
+ if (hostSnapshot === savedSnapshot) continue;
3864
+ const entry = this.#entry(route);
3865
+ try {
3866
+ const ops = profileOps(entry.settingsPath, JSON.parse(hostSnapshot), JSON.parse(savedSnapshot));
3867
+ if (ops.length > 0) {
3868
+ const res = await this.app.api.call("settings.mutate", {
3869
+ ns: entry.settingsNs,
3870
+ ops,
3871
+ expectedRevision: this.revisions.get(entry.settingsNs) ?? 0,
3872
+ });
3873
+ this.revisions.set(entry.settingsNs, res?.revision ?? this.revisions.get(entry.settingsNs) ?? 0);
3874
+ }
3875
+ this.externalHostSnapshots.set(route, savedSnapshot);
3876
+ } catch (e) {
3877
+ this.app.toast(`放弃修改失败: ${e.message}`);
3878
+ return false;
3879
+ }
3880
+ }
3881
+ if (this.hostSnapshot !== this.savedSnapshot) {
3882
+ try {
3883
+ const target = JSON.parse(this.savedSnapshot);
3884
+ const ops = providerOps(JSON.parse(this.hostSnapshot), target);
3885
+ if (ops.length > 0) {
3886
+ const res = await this.app.api.call("settings.mutate", { ns: "llm-pi-ai", ops, expectedRevision: this.revision });
3887
+ this.revision = res?.revision ?? this.revision;
3888
+ }
3889
+ this.hostSnapshot = this.savedSnapshot;
3890
+ } catch (e) {
3891
+ this.app.toast(`放弃修改失败: ${e.message}`);
3892
+ return false;
3893
+ }
3894
+ }
2723
3895
  this.providers = JSON.parse(this.savedSnapshot);
2724
- this.routes = Object.keys(this.providers);
3896
+ // External namespaces are normally credential-only edits; restore their
3897
+ // fully successful in-memory save points as well. (A compensated Host
3898
+ // rollback is only needed for the legacy pi-ai partial-save path above.)
3899
+ for (const [route, snapshot] of this.externalSnapshots) {
3900
+ this.externalDrafts.set(route, JSON.parse(snapshot));
3901
+ this.externalHostSnapshots.set(route, snapshot);
3902
+ }
3903
+ this.configuredDirectory = new Set(this.initialConfiguredDirectory);
3904
+ this.materializeRoutes.clear();
3905
+ this.pendingProbeKeys.clear();
3906
+ this.#syncRoutes();
2725
3907
  this.draftRoute = null;
3908
+ this.addMode = false;
2726
3909
  this.modelsSel = -1;
2727
3910
  this.sub = null;
2728
- this.sel = Math.min(this.sel, this.routes.length - 1);
3911
+ this.sel = this.routes.length === 0 ? 0 : Math.min(this.sel, this.routes.length - 1);
2729
3912
  this.#rebuild();
2730
3913
  this.app.redraw();
3914
+ return true;
2731
3915
  }
2732
3916
  /** Leave the provider form for another level. With unsaved changes this asks
2733
3917
  * 保存/不保存/取消 first; a failed save keeps the user on the form. */
@@ -2748,13 +3932,13 @@ export class ModelPanel extends Widget {
2748
3932
  ],
2749
3933
  onAction: async (btn) => {
2750
3934
  this.app.closeOverlay();
2751
- this.app.focus(this.app.chat);
3935
+ this.app.focus(this.app.fullBuffer ?? this.app.chat);
2752
3936
  if (btn?.action === "cancel") return; // stay on the form
2753
3937
  if (btn?.action === "save") {
2754
3938
  const ok = await this.#save();
2755
3939
  if (!ok) return; // save failed: stay (toast shown)
2756
3940
  } else if (btn?.action === "discard") {
2757
- this.#discard();
3941
+ if (!await this.#discard()) return;
2758
3942
  } else {
2759
3943
  return; // Esc = __cancel__
2760
3944
  }
@@ -2772,19 +3956,139 @@ export class ModelPanel extends Widget {
2772
3956
  w, h: Math.min(7, this.app.screen.h), title: "确认删除",
2773
3957
  lines: [[{ t: " " + prompt, fg: K.WARN }]],
2774
3958
  buttons: [{ label: "取消", action: "cancel" }, { label: "删除", action: "delete" }],
2775
- onAction: (btn) => { this.app.closeOverlay(); if (btn?.action === "delete") action(); },
3959
+ onAction: (btn) => { this.app.closeOverlay(); if (btn?.action === "delete") return action(); },
2776
3960
  });
2777
3961
  this.app.redraw();
2778
3962
  }
3963
+ async #unconfigureExternalProvider() {
3964
+ const route = this.#route();
3965
+ const entry = this.#entry(route);
3966
+ if (!route || !entry || entry.settingsNs === "llm-pi-ai" || entry.settingsPath.length === 0 || !this.externalUserConfigured.has(route)) return;
3967
+ if (this.#dirty()) { this.app.toast("请先保存或放弃其他修改,再取消配置提供方"); return; }
3968
+ this.#confirmDelete(`取消配置 ${entry.displayName || route}?这会移除该提供方的用户层设置,但不会清除全局 API 密钥。`, async () => {
3969
+ try {
3970
+ const res = await this.app.api.call("settings.mutate", {
3971
+ ns: entry.settingsNs,
3972
+ ops: [{ op: "unset", path: entry.settingsPath }],
3973
+ expectedRevision: this.revisions.get(entry.settingsNs) ?? 0,
3974
+ });
3975
+ this.revisions.set(entry.settingsNs, res?.revision ?? this.revisions.get(entry.settingsNs) ?? 0);
3976
+ } catch (e) { this.app.toast(`取消配置失败: ${e.message}`); return; }
3977
+ this.externalUserConfigured.delete(route);
3978
+ this.externalDrafts.set(route, {});
3979
+ this.externalSnapshots.set(route, "{}");
3980
+ this.externalHostSnapshots.set(route, "{}");
3981
+ this.externalInherited.set(route, {});
3982
+ this.configuredDirectory.delete(route);
3983
+ this.initialConfiguredDirectory.delete(route);
3984
+ this.pendingProbeKeys.delete(route);
3985
+ this.#syncRoutes();
3986
+ this.sel = this.routes.length === 0 ? 0 : Math.min(this.sel, this.routes.length - 1);
3987
+ this.modelsSel = -1; this.sub = null;
3988
+ await this.#refreshKeys();
3989
+ this.app.toast(`已取消配置 ${entry.displayName || route};全局 API 密钥未改变`);
3990
+ this.#rebuild(); this.app.redraw();
3991
+ });
3992
+ }
2779
3993
  async #deleteProvider() {
2780
3994
  const route = this.#route();
2781
3995
  if (!route) return;
3996
+ if (this.#dirty()) { this.app.toast("请先保存或放弃其他修改,再删除供应商"); return; }
2782
3997
  this.#confirmDelete(`删除供应商 ${route}?此操作会立即保存。`, async () => {
3998
+ const profile = this.#profile(route);
3999
+ const ref = this.#keyRef(route);
4000
+ const managedCredential = profile.apiKeyEnv === deriveKeyRef(route)
4001
+ && this.keyStatus?.[ref]?.configured === true
4002
+ && this.keyStatus[ref].writable === true;
4003
+ if (managedCredential) {
4004
+ // Journal first. If the process exits after the Host mutation but before
4005
+ // credentials.unset, the next ModelPanel load can safely finish it.
4006
+ this.pendingCredentialCleanups.set(ref, { route, error: "等待确认供应商删除", reconcile: true });
4007
+ if (!this.#persistCredentialCleanups()) {
4008
+ this.pendingCredentialCleanups.delete(ref);
4009
+ this.app.toast("删除失败: 无法记录托管密钥清理任务");
4010
+ return;
4011
+ }
4012
+ }
4013
+ let providerState = null;
4014
+ try {
4015
+ const res = await this.app.api.call("settings.mutate", {
4016
+ ns: "llm-pi-ai",
4017
+ ops: [{ op: "unset", path: ["providers", route] }],
4018
+ expectedRevision: this.revision,
4019
+ });
4020
+ this.revision = res?.revision ?? this.revision;
4021
+ providerState = this.#providerStateFromProfiles(res?.value?.providers ?? Object.fromEntries(
4022
+ Object.entries(this.resolvedProviders).filter(([candidate]) => candidate !== route),
4023
+ ));
4024
+ } catch (e) {
4025
+ const conflict = e?.code === "settings-conflict";
4026
+ if (managedCredential && conflict) {
4027
+ const task = this.pendingCredentialCleanups.get(ref);
4028
+ this.pendingCredentialCleanups.delete(ref);
4029
+ if (!this.#persistCredentialCleanups()) this.pendingCredentialCleanups.set(ref, task);
4030
+ this.app.toast(`删除失败: ${e.message}`);
4031
+ this.#rebuild();
4032
+ this.app.redraw();
4033
+ return;
4034
+ }
4035
+ if (!managedCredential) {
4036
+ this.app.toast(`删除失败: ${e.message}`);
4037
+ return;
4038
+ }
4039
+ this.pendingCredentialCleanups.set(ref, { route, error: `等待核对删除结果: ${String(e?.message ?? e).slice(0, 500)}`, reconcile: true });
4040
+ this.#persistCredentialCleanups();
4041
+ try {
4042
+ providerState = this.#providerStateFromDescription(await this.app.api.call("settings.describe"));
4043
+ } catch {}
4044
+ if (providerState === null) {
4045
+ const failure = { ref, route, error: this.pendingCredentialCleanups.get(ref).error, reconcile: true };
4046
+ this.app.toast(`删除结果待核对: ${e.message}`);
4047
+ this.#showCredentialCleanupFailure(failure);
4048
+ this.#rebuild();
4049
+ this.app.redraw();
4050
+ return;
4051
+ }
4052
+ if (providerState.routes.has(route)) {
4053
+ if (providerState.refs.has(ref)) {
4054
+ const task = this.pendingCredentialCleanups.get(ref);
4055
+ this.pendingCredentialCleanups.delete(ref);
4056
+ if (!this.#persistCredentialCleanups()) this.pendingCredentialCleanups.set(ref, task);
4057
+ this.app.toast(`删除失败: ${e.message}`);
4058
+ } else {
4059
+ const failure = { ref, route, error: `路由 ${route} 仍存在,无法自动确认旧密钥可清理`, reconcile: true };
4060
+ this.pendingCredentialCleanups.set(ref, { route, error: failure.error, reconcile: true });
4061
+ this.#persistCredentialCleanups();
4062
+ this.app.toast(`删除结果待核对: ${e.message}`);
4063
+ this.#showCredentialCleanupFailure(failure);
4064
+ }
4065
+ this.#rebuild();
4066
+ this.app.redraw();
4067
+ return;
4068
+ }
4069
+ }
2783
4070
  delete this.providers[route];
2784
- this.routes = Object.keys(this.providers);
2785
- this.sel = Math.min(this.sel, this.routes.length - 1);
4071
+ delete this.resolvedProviders[route];
4072
+ delete this.inheritedProviders[route];
4073
+ this.configuredDirectory.delete(route);
4074
+ this.initialConfiguredDirectory.delete(route);
4075
+ this.materializeRoutes.delete(route);
4076
+ this.pendingProbeKeys.delete(route);
4077
+ this.#syncRoutes();
4078
+ this.sel = this.routes.length === 0 ? 0 : Math.min(this.sel, this.routes.length - 1);
2786
4079
  this.modelsSel = -1;
2787
- await this.#save();
4080
+ this.savedSnapshot = JSON.stringify(this.providers);
4081
+ this.hostSnapshot = this.savedSnapshot;
4082
+ const cleanup = managedCredential
4083
+ ? await this.#retryPendingCredentialCleanups({ onlyRef: ref, notify: false, providerState })
4084
+ : { completed: [], failed: [] };
4085
+ await this.#refreshKeys();
4086
+ if (cleanup.failed.length > 0) {
4087
+ this.app.toast(`供应商已删除;托管密钥待清理: ${cleanup.failed[0].error}`);
4088
+ this.#showCredentialCleanupFailure(cleanup.failed[0]);
4089
+ } else {
4090
+ this.app.toast(`已删除供应商 ${route}`);
4091
+ }
2788
4092
  this.#rebuild(); this.app.redraw();
2789
4093
  });
2790
4094
  }
@@ -2792,47 +4096,47 @@ export class ModelPanel extends Widget {
2792
4096
  const route = this.#route();
2793
4097
  if (!route) return;
2794
4098
  const p = this.#profile(route);
4099
+ const entry = this.#entry(route);
4100
+ const declared = entry?.declared === true || this.draftRoute === route;
4101
+ if (declared && p.api === "anthropic-messages") {
4102
+ this.app.toast("anthropic-messages 不支持自动列出模型,请手动添加模型 ID");
4103
+ return;
4104
+ }
2795
4105
  const base = String(p.baseURL ?? "").replace(/\/+$/, "");
2796
- if (!base) { this.app.toast("先填写 baseURL"); return; }
2797
4106
  this.scanning = true;
2798
4107
  this.scanMode = true;
2799
4108
  this.scanItems = [];
2800
4109
  this.scanCursor = 0;
2801
4110
  this.#rebuild();
2802
4111
  this.app.redraw();
2803
- const key = p.apiKeyEnv ? process.env[p.apiKeyEnv] : null;
2804
- const headers = key ? { Authorization: `Bearer ${key}` } : {};
2805
- const tryFetch = async (dispatcher) => {
2806
- const res = await fetch(`${base}/models`, { headers, dispatcher });
2807
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
2808
- return res.json();
2809
- };
2810
4112
  try {
2811
- let body;
2812
- try { body = await tryFetch(undefined); }
2813
- catch (e0) {
2814
- // self-signed gateways: retry with TLS verification disabled
2815
- try {
2816
- const { Agent } = await import("undici");
2817
- body = await tryFetch(new Agent({ connect: { rejectUnauthorized: false } }));
2818
- } catch { throw e0; }
2819
- }
2820
- const list = Array.isArray(body?.data) ? body.data : Array.isArray(body?.models) ? body.models : [];
4113
+ // The Host owns protocol handling and stored credentials. Keeping the
4114
+ // request on this path avoids exposing secrets or weakening TLS locally.
4115
+ const res = await this.app.api.call("llm.discoverModels", {
4116
+ settingsNs: this.#namespace(route),
4117
+ provider: route,
4118
+ ...(p.api ? { api: p.api } : {}),
4119
+ ...(base ? { baseURL: base } : {}),
4120
+ ...(this.pendingProbeKeys.has(route) ? { apiKey: this.pendingProbeKeys.get(route) } : {}),
4121
+ });
2821
4122
  const seen = new Set();
2822
- const items = [];
2823
- for (const e of list) {
2824
- if (!e || typeof e !== "object") continue;
2825
- const id = String(e.id ?? e.name ?? "").trim();
2826
- if (!id || seen.has(id)) continue;
4123
+ this.scanItems = (res?.models ?? []).flatMap((entry) => {
4124
+ if (!entry || typeof entry !== "object") return [];
4125
+ const id = String(entry.id ?? "").trim();
4126
+ if (!id || seen.has(id)) return [];
2827
4127
  seen.add(id);
2828
- items.push({ id, name: e.name ?? e.id ?? id });
2829
- }
2830
- this.scanItems = items;
2831
- this.scanSel = new Set(items.map((m) => m.id));
2832
- if (items.length === 0) this.app.toast("扫描完成:未发现模型");
2833
- else this.app.toast(`发现 ${items.length} 个模型,空格勾选,Enter 添加`);
4128
+ return [{
4129
+ id,
4130
+ ...(typeof entry.name === "string" && entry.name ? { name: entry.name } : {}),
4131
+ ...(Number.isInteger(entry.contextWindow) && entry.contextWindow > 0 ? { contextWindow: entry.contextWindow } : {}),
4132
+ ...(Number.isInteger(entry.maxTokens) && entry.maxTokens > 0 ? { maxTokens: entry.maxTokens } : {}),
4133
+ }];
4134
+ });
4135
+ this.scanSel = new Set(this.scanItems.map((model) => model.id));
4136
+ if (this.scanItems.length === 0) this.app.toast("扫描完成:未发现模型");
4137
+ else this.app.toast(`发现 ${this.scanItems.length} 个模型,空格勾选,Enter 添加`);
2834
4138
  } catch (e) {
2835
- this.app.toast(`扫描失败: ${e.message}`);
4139
+ this.app.toast(`扫描失败:${String(e.message ?? e).replace(/^[^:]+:\s*/, "")}`);
2836
4140
  this.scanMode = false;
2837
4141
  }
2838
4142
  this.scanning = false;
@@ -2842,13 +4146,17 @@ export class ModelPanel extends Widget {
2842
4146
  #scanCommit() {
2843
4147
  const route = this.#route();
2844
4148
  if (!route) return;
2845
- const p = this.#profile(route);
2846
- p.models ??= [];
2847
- const existing = new Set(p.models.map((m) => m.id));
4149
+ if (!this.writable) { this.scanMode = false; this.app.toast("模型配置为只读,未添加发现结果"); this.#rebuild(); this.app.redraw(); return; }
4150
+ const existing = new Set(this.#models(route).map((m) => m.id));
4151
+ const selected = this.scanItems.filter((model) => this.scanSel.has(model.id) && !existing.has(model.id));
2848
4152
  let added = 0;
2849
- for (const m of this.scanItems) {
2850
- if (!this.scanSel.has(m.id) || existing.has(m.id)) continue;
2851
- p.models.push({ id: m.id, name: m.name });
4153
+ for (const m of selected) {
4154
+ this.#models(route, { mutable: true }).push({
4155
+ id: m.id,
4156
+ ...(m.name != null ? { name: m.name } : {}),
4157
+ ...(m.contextWindow != null ? { contextWindow: m.contextWindow } : {}),
4158
+ ...(m.maxTokens != null ? { maxTokens: m.maxTokens } : {}),
4159
+ });
2852
4160
  added++;
2853
4161
  }
2854
4162
  this.scanMode = false;
@@ -2858,10 +4166,26 @@ export class ModelPanel extends Widget {
2858
4166
  }
2859
4167
  onKey(ev) {
2860
4168
  if (ev.type !== "key") return false;
4169
+ if (ev.name === "char" && ev.key === "c" && !ev.ctrl && this.pendingCredentialCleanups.size > 0) {
4170
+ const [ref, task] = this.pendingCredentialCleanups.entries().next().value;
4171
+ this.#showCredentialCleanupFailure({ ref, ...task });
4172
+ return true;
4173
+ }
4174
+ if (this.addMode) {
4175
+ if (ev.name === "escape") { this.addMode = false; this.#rebuild(); this.app.redraw(); return true; }
4176
+ if (ev.name === "up" || (ev.name === "char" && ev.key === "k" && !ev.ctrl)) {
4177
+ this.addCursor = wrapIndex(this.addCursor - 1, this.addItems.length); this.#rebuild(); this.app.redraw(); return true;
4178
+ }
4179
+ if (ev.name === "down" || (ev.name === "char" && ev.key === "j" && !ev.ctrl)) {
4180
+ this.addCursor = wrapIndex(this.addCursor + 1, this.addItems.length); this.#rebuild(); this.app.redraw(); return true;
4181
+ }
4182
+ if (ev.name === "enter") { this.#activateAddItem(); return true; }
4183
+ return false;
4184
+ }
2861
4185
  if (this.scanMode) {
2862
4186
  if (ev.name === "escape") { this.scanMode = false; this.#rebuild(); return true; }
2863
- if (ev.name === "up") { this.scanCursor = Math.max(0, this.scanCursor - 1); this.#rebuild(); this.app.redraw(); return true; }
2864
- if (ev.name === "down") { this.scanCursor = Math.min(this.scanItems.length - 1, this.scanCursor + 1); this.#rebuild(); this.app.redraw(); return true; }
4187
+ if (ev.name === "up") { this.scanCursor = wrapIndex(this.scanCursor - 1, this.scanItems.length); this.#rebuild(); this.app.redraw(); return true; }
4188
+ if (ev.name === "down") { this.scanCursor = wrapIndex(this.scanCursor + 1, this.scanItems.length); this.#rebuild(); this.app.redraw(); return true; }
2865
4189
  if (ev.name === "char" && ev.key === " " && !ev.ctrl) {
2866
4190
  const m = this.scanItems[this.scanCursor];
2867
4191
  if (m) { if (this.scanSel.has(m.id)) this.scanSel.delete(m.id); else this.scanSel.add(m.id); }
@@ -2876,13 +4200,13 @@ export class ModelPanel extends Widget {
2876
4200
  // inside the 模型管理 sub-buffer: ↑/↓ walk its rows; Esc returns
2877
4201
  if (ev.name === "escape") { this.sub = null; this.#rebuild(); return true; }
2878
4202
  if (ev.name === "up" || (ev.name === "char" && ev.key === "k" && !ev.ctrl)) {
2879
- this.sub.cursor = Math.max(0, this.sub.cursor - 1);
4203
+ this.sub.cursor = wrapIndex(this.sub.cursor - 1, this.#subItems().length);
2880
4204
  this.#rebuild();
2881
4205
  this.app.redraw();
2882
4206
  return true;
2883
4207
  }
2884
4208
  if (ev.name === "down" || (ev.name === "char" && ev.key === "j" && !ev.ctrl)) {
2885
- this.sub.cursor = Math.min(Math.max(0, this.#subItems().length - 1), this.sub.cursor + 1);
4209
+ this.sub.cursor = wrapIndex(this.sub.cursor + 1, this.#subItems().length);
2886
4210
  this.#rebuild();
2887
4211
  this.app.redraw();
2888
4212
  return true;
@@ -2898,21 +4222,21 @@ export class ModelPanel extends Widget {
2898
4222
  this.#leaveForm(() => { this.mode = "list"; this.sub = null; this.#rebuild(); this.app.redraw(); });
2899
4223
  return true;
2900
4224
  }
2901
- return false; // list level: App falls back to the upper window (chat/settings)
4225
+ return false; // list level: App closes the full-screen buffer on the unhandled Escape
2902
4226
  }
2903
4227
  // dual-focus navigation: ↑/↓ move the cursor INSIDE the focused region —
2904
4228
  // the provider column in list focus, the option rows in form focus.
2905
4229
  // → enters the form, ← returns to the list.
2906
4230
  if (ev.name === "up" || (ev.name === "char" && ev.key === "k" && !ev.ctrl)) {
2907
- if (this.mode === "list") this.sel = Math.max(0, this.sel - 1);
2908
- else this.formIdx = Math.max(0, this.formIdx - 1);
4231
+ if (this.mode === "list") this.sel = wrapIndex(this.sel - 1, this.routes.length + 1);
4232
+ else this.formIdx = wrapIndex(this.formIdx - 1, this.formItems.length);
2909
4233
  this.#rebuild();
2910
4234
  this.app.redraw();
2911
4235
  return true;
2912
4236
  }
2913
4237
  if (ev.name === "down" || (ev.name === "char" && ev.key === "j" && !ev.ctrl)) {
2914
- if (this.mode === "list") this.sel = Math.min(this.routes.length, this.sel + 1);
2915
- else this.formIdx = Math.min(Math.max(0, this.formItems.length - 1), this.formIdx + 1);
4238
+ if (this.mode === "list") this.sel = wrapIndex(this.sel + 1, this.routes.length + 1);
4239
+ else this.formIdx = wrapIndex(this.formIdx + 1, this.formItems.length);
2916
4240
  this.#rebuild();
2917
4241
  this.app.redraw();
2918
4242
  return true;
@@ -2922,12 +4246,17 @@ export class ModelPanel extends Widget {
2922
4246
  // walks to the next form item. Otherwise (list focus) it behaves like →.
2923
4247
  if (ev.name === "tab" && this.mode === "form" && this.sub == null) {
2924
4248
  const it = this.formItems[this.formIdx];
4249
+ if (it?.cycle?.length && !this.writable) { this.app.toast("模型配置为只读"); return true; }
2925
4250
  if (it?.cycle?.length) {
2926
4251
  const cur = String(it.value ?? "");
2927
4252
  const idx = it.cycle.indexOf(cur);
2928
- this.#profile(this.#route())[it.key] = it.cycle[(idx + 1) % it.cycle.length];
4253
+ const value = it.cycle[(idx + 1) % it.cycle.length];
4254
+ const profile = this.#draftProfile(this.#route());
4255
+ if ((it.key === "api" || it.key === "reasoning") && value === "") delete profile[it.key];
4256
+ else profile[it.key] = value;
4257
+ if (it.key === "api" && value !== "openai-completions") this.#stripCompat(this.#route());
2929
4258
  } else if (this.formItems.length > 0) {
2930
- this.formIdx = Math.min(this.formItems.length - 1, this.formIdx + 1);
4259
+ this.formIdx = wrapIndex(this.formIdx + 1, this.formItems.length);
2931
4260
  }
2932
4261
  this.#rebuild();
2933
4262
  this.app.redraw();
@@ -2960,7 +4289,7 @@ export class ModelPanel extends Widget {
2960
4289
  if (this.mode === "form") {
2961
4290
  if (idx === this.sel) return true; // already editing this provider
2962
4291
  if (idx === this.routes.length) {
2963
- this.#leaveForm(() => this.#addProvider());
4292
+ this.#leaveForm(() => this.#openAddProvider());
2964
4293
  } else {
2965
4294
  this.#leaveForm(() => {
2966
4295
  this.sel = idx;
@@ -2980,25 +4309,40 @@ export class ModelPanel extends Widget {
2980
4309
  }
2981
4310
  return false;
2982
4311
  }
2983
- if (this.scanMode) {
2984
- const idx = ev.y - this.formView.y + this.formView.scrollY - 1;
2985
- if (idx >= 0 && idx < this.scanItems.length) {
2986
- this.scanCursor = idx;
2987
- const m = this.scanItems[idx];
2988
- if (this.scanSel.has(m.id)) this.scanSel.delete(m.id); else this.scanSel.add(m.id);
2989
- this.app.redraw();
2990
- return true;
2991
- }
2992
- return false;
4312
+ if (!this.formView.inside(ev.x, ev.y)) return false;
4313
+ const line = ev.y - this.formView.y + this.formView.scrollY;
4314
+ const target = this.formClickMap[line];
4315
+ if (!target) return true;
4316
+ if (target.type === "add" && this.addMode) {
4317
+ this.addCursor = target.index;
4318
+ this.#activateAddItem();
4319
+ return true;
2993
4320
  }
2994
- const idx = ev.y - this.formView.y + this.formView.scrollY;
2995
- if (this.sub != null) {
2996
- // the sub-buffer's title row is visual-only; rows below it are items
2997
- const itemIdx = idx - 1;
2998
- if (itemIdx >= 0 && itemIdx < this.#subItems().length) { this.sub.cursor = itemIdx; this.#activateItem(); return true; }
2999
- return false;
4321
+ if (target.type === "cleanup") {
4322
+ const task = this.pendingCredentialCleanups.get(target.ref);
4323
+ if (task) this.#showCredentialCleanupFailure({ ref: target.ref, ...task });
4324
+ return true;
4325
+ }
4326
+ if (target.type === "scan") {
4327
+ const m = this.scanItems[target.index];
4328
+ if (!m) return false;
4329
+ this.scanCursor = target.index;
4330
+ if (this.scanSel.has(m.id)) this.scanSel.delete(m.id); else this.scanSel.add(m.id);
4331
+ this.#rebuild();
4332
+ this.app.redraw();
4333
+ return true;
4334
+ }
4335
+ if (target.type === "item" && target.sub && this.sub != null) {
4336
+ this.sub.cursor = target.index;
4337
+ this.#activateItem();
4338
+ return true;
4339
+ }
4340
+ if (target.type === "item" && !target.sub && this.sub == null && target.index < this.formItems.length) {
4341
+ this.formIdx = target.index;
4342
+ this.mode = "form";
4343
+ this.#activateItem();
4344
+ return true;
3000
4345
  }
3001
- if (idx >= 0 && idx < this.formItems.length) { this.formIdx = idx; this.mode = "form"; this.#activateItem(); return true; }
3002
4346
  return false;
3003
4347
  }
3004
4348
  }
@@ -3197,13 +4541,13 @@ export class SubagentPanel extends Widget {
3197
4541
  onKey(ev) {
3198
4542
  if (ev.type === "text") { this.input.insert(ev.text); this.app.redraw(); return true; }
3199
4543
  if (ev.type !== "key") return false;
3200
- if (ev.name === "escape") { this.app.setMode("chat"); return true; }
4544
+ if (ev.name === "escape") { this.app.closeFullBuffer?.() ?? this.app.setMode?.("chat"); return true; }
3201
4545
  if (ev.name === "char" && ev.key === "x" && !ev.ctrl) { this.interrupt(); return true; }
3202
4546
  if (ev.name === "char" && ev.key === "r" && !ev.ctrl) { this.selectChild(this.selIdx); return true; }
3203
4547
  if (ev.name === "up" || ev.name === "down") {
3204
4548
  if (this.entries.length === 0) return false;
3205
- const next = this.selIdx + (ev.name === "up" ? -1 : 1);
3206
- if (next >= 0 && next < this.entries.length) { this.selectChild(next); }
4549
+ const next = wrapIndex(this.selIdx + (ev.name === "up" ? -1 : 1), this.entries.length);
4550
+ this.selectChild(next);
3207
4551
  return true;
3208
4552
  }
3209
4553
  if (this.input.onKey(ev)) { this.app.redraw(); return true; }
@@ -3294,11 +4638,11 @@ export class SkillsPanel extends Widget {
3294
4638
  }
3295
4639
  onKey(ev) {
3296
4640
  if (ev.type !== "key") return false;
3297
- if (ev.name === "escape") { this.app.setMode("chat"); return true; }
4641
+ if (ev.name === "escape") { this.app.closeFullBuffer?.() ?? this.app.setMode?.("chat"); return true; }
3298
4642
  if (ev.name === "up" || ev.name === "down") {
3299
4643
  if (this.skills.length === 0) return false;
3300
- const next = this.selIdx + (ev.name === "up" ? -1 : 1);
3301
- if (next >= 0 && next < this.skills.length) this.select(next);
4644
+ const next = wrapIndex(this.selIdx + (ev.name === "up" ? -1 : 1), this.skills.length);
4645
+ this.select(next);
3302
4646
  return true;
3303
4647
  }
3304
4648
  if (ev.name === "char" && ev.key === "c" && !ev.ctrl && this.skills[this.selIdx]) {