dsh-neotui 0.2.2 → 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("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
  }
@@ -1669,7 +1875,8 @@ export class JobsPanel extends Popup {
1669
1875
  for (let i = 0; i < this.subagents.length; i++) {
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
- lines.push([{ t: ` ${i === this.sel ? "▸" : " "} 🛰 ${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);
1878
+ const open = this.expanded.has(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);
1673
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); }
1674
1881
  }
1675
1882
  this.lines = lines; this.rowOf = rowOf; this.#ensureVisible(); return;
@@ -1720,10 +1927,10 @@ export class JobsPanel extends Popup {
1720
1927
  const current = this.page === "jobs" ? this.jobs : this.subagents;
1721
1928
  if (current.length === 0) return super.onKey(ev);
1722
1929
  if (ev.name === "up" || (ev.name === "char" && ev.key === "k" && !ev.ctrl)) {
1723
- 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;
1724
1931
  }
1725
1932
  if (ev.name === "down" || (ev.name === "char" && ev.key === "j" && !ev.ctrl)) {
1726
- 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;
1727
1934
  }
1728
1935
  if (ev.name === "enter" || (ev.name === "char" && ev.key === "l" && !ev.ctrl)) {
1729
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;
@@ -1751,25 +1958,157 @@ export class QueuePanel extends Popup {
1751
1958
  const items = app.queueItems ?? [];
1752
1959
  const w = Math.max(24, Math.min(84, app.screen.w - 4));
1753
1960
  const h = Math.max(7, Math.min(24, app.screen.h - 4));
1754
- 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 });
1755
- 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();
1756
1975
  }
1757
- 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) {
1758
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
+ }
1759
2053
  for (let i = 0; i < this.items.length; i++) {
1760
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;
1761
2058
  const text = partsText(item.message?.content).replace(/\s+/g, " ");
1762
- 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
+ }
1763
2071
  }
1764
- if (!this.items.length) lines.push([{ t: " (队列为空)", fg: K.FAINT }]);
2072
+ if (!this.items.length) { lines.push([{ t: " (队列为空)", fg: K.FAINT }]); rowOf.push(-1); }
1765
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();
1766
2103
  }
1767
2104
  syncItems(items) {
1768
2105
  const selectedId = this.items[this.sel]?.id;
1769
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);
1770
2109
  const next = selectedId ? this.items.findIndex((item) => item.id === selectedId) : -1;
1771
2110
  this.sel = next >= 0 ? next : Math.min(this.sel, Math.max(0, this.items.length - 1));
1772
- this.rebuild();
2111
+ this.rebuild(); this.#ensureSelected();
1773
2112
  }
1774
2113
  #errorCode(error) { return error?.code ?? error?.details?.code ?? error?.cause?.code; }
1775
2114
  async #mutate(kind, content) {
@@ -1792,21 +2131,58 @@ export class QueuePanel extends Popup {
1792
2131
  } finally { this.pending = false; this.rebuild(); this.app.redraw(); }
1793
2132
  }
1794
2133
  onKey(ev) {
1795
- const ch=ev.type==="text"?ev.text:ev.type==="key"&&ev.name==="char"?ev.key:null;
1796
- 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
+ }
1797
2150
  if (ev.type === "key") {
1798
2151
  if (ev.name === "escape") { this.app.closeOverlay(); return true; }
1799
- if (ev.name === "up") { this.dArmed=false;this.sel = Math.max(0, this.sel - 1); this.rebuild(); return true; }
1800
- 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
+ }
1801
2174
  }
1802
- this.dArmed=false;return false;
2175
+ this.dArmed = false;
2176
+ return false;
1803
2177
  }
1804
2178
  onMouse(ev) {
2179
+ if (super.onMouse(ev)) return true;
1805
2180
  if (ev.kind === "press" && ev.button === 0) {
1806
- const idx = ev.y - this.y - 1;
1807
- 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; }
1808
2184
  }
1809
- return super.onMouse(ev);
2185
+ return false;
1810
2186
  }
1811
2187
  }
1812
2188
 
@@ -1892,8 +2268,8 @@ export class GoalPanel extends Popup {
1892
2268
  onKey(ev) {
1893
2269
  if (ev.type === "key") {
1894
2270
  if (ev.name === "escape") { this.app.closeOverlay(); return true; }
1895
- if (ev.name === "up") { this.actionSel = Math.max(0, this.actionSel - 1); this.rebuild(); return true; }
1896
- 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; }
1897
2273
  if (ev.name === "enter") { this.actions[this.actionSel]?.run(); return true; }
1898
2274
  }
1899
2275
  return super.onKey(ev);
@@ -1949,7 +2325,7 @@ export class SettingsPanel extends Widget {
1949
2325
  this.writable = d.writable;
1950
2326
  } catch (e) {
1951
2327
  this.app.toast(`设置加载失败: ${e.message}`);
1952
- this.app.setMode("chat");
2328
+ this.app.closeFullBuffer?.() ?? this.app.setMode?.("chat");
1953
2329
  return;
1954
2330
  }
1955
2331
  // TUI-local settings ride the same tree editor, but persist to the TUI
@@ -1978,7 +2354,7 @@ export class SettingsPanel extends Widget {
1978
2354
  this.pendingOps = [];
1979
2355
  this.editing = false;
1980
2356
  const ns = this.namespaces[this.nsIdx];
1981
- if (ns.modelsEntry) { this.app.setMode("models"); return; }
2357
+ if (ns.modelsEntry) { (this.app.showModelsBuffer ? this.app.showModelsBuffer() : this.app.setMode?.("models")); return; }
1982
2358
  this.secrets = new Set((ns.secrets ?? []).map((s) => JSON.stringify(s.path ?? [])));
1983
2359
  this.rebuildRows();
1984
2360
  const items = this.namespaces.map((n) => ({
@@ -2081,7 +2457,7 @@ export class SettingsPanel extends Widget {
2081
2457
  return true;
2082
2458
  }
2083
2459
  if (ev.type !== "key") return false;
2084
- if (ev.name === "escape") { this.app.setMode("chat"); return true; }
2460
+ if (ev.name === "escape") { this.app.closeFullBuffer?.() ?? this.app.setMode?.("chat"); return true; }
2085
2461
  if (ev.ctrl && ev.key === "s") { this.save(); return true; }
2086
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);
2087
2463
  if (ev.name === "enter") {
@@ -2209,7 +2585,7 @@ export class EditPopup extends Popup {
2209
2585
  this.input.render(screen);
2210
2586
  }
2211
2587
  onKey(ev) {
2212
- 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; }
2213
2589
  if (ev.type === "key" && ev.name === "tab" && this.completions?.length) {
2214
2590
  // Tab 选取/补全: an exact current value cycles to the next candidate,
2215
2591
  // anything else completes to the first prefix match
@@ -2230,7 +2606,7 @@ export class EditPopup extends Popup {
2230
2606
  if (ev.type === "key" && ev.name === "enter") {
2231
2607
  const v = this.input.value;
2232
2608
  this.app.closeOverlay();
2233
- this.app.focus(this.app.chat);
2609
+ this.app.focus(this.app.fullBuffer ?? this.app.chat);
2234
2610
  this.onCommit?.(v);
2235
2611
  return true;
2236
2612
  }
@@ -2249,6 +2625,17 @@ export class EditPopup extends Popup {
2249
2625
  * same union the web settings page reads out of the namespace schema, so the
2250
2626
  * choices offered here cannot drift from the ones the host validates. */
2251
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]+)*$/;
2252
2639
  /** Credential reference names must be POSIX shell identifiers. */
2253
2640
  const KEY_REF_OK = /^[A-Za-z_][A-Za-z0-9_]*$/;
2254
2641
  /** The web settings page's v1 convention: a provider route's key lives under
@@ -2257,16 +2644,114 @@ function deriveKeyRef(provider) {
2257
2644
  return `${provider.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY`;
2258
2645
  }
2259
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
+
2260
2729
  export class ModelPanel extends Widget {
2261
2730
  constructor(app) {
2262
- 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) });
2263
2732
  this.app = app;
2264
- this.providers = {}; // route → profile (mirror of settings llm-pi-ai.providers)
2265
- this.revision = 0;
2266
- this.defaultModelRevision = 0;
2267
- 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
2268
2748
  this.loaded = false;
2749
+ this.writable = true;
2269
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
2270
2755
  this.sel = 0; // list cursor (routes.length = the + 添加供应商 row)
2271
2756
  this.mode = "list"; // list | form
2272
2757
  this.formIdx = 0; // form item cursor
@@ -2281,36 +2766,262 @@ export class ModelPanel extends Widget {
2281
2766
  this.scanSel = new Set();
2282
2767
  this.scanCursor = 0;
2283
2768
  this.scanning = false;
2284
- 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
2285
2771
  this.keyStatus = {}; // ref → {configured, writable, source} from credentials.describe
2286
- const listW = 26;
2287
- this.listView = new ScrollView({ x: this.x + 1, y: this.y + 1, w: listW, h: this.h - 2, showScrollbar: true });
2288
- 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 });
2289
2797
  }
2290
2798
  relayout(x, y, w, h) {
2291
- this.x = x; this.y = y; this.w = w; this.h = h;
2292
- const listW = 26;
2293
- this.listView.x = x + 1; this.listView.y = y + 1; this.listView.w = listW; this.listView.h = h - 2;
2294
- 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);
2295
2803
  }
2296
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;
2297
2814
  try {
2298
- const d = await this.app.api.call("settings.describe");
2299
- const ns = (d.namespaces ?? []).find((n) => n.ns === "llm-pi-ai");
2300
- const defaults = (d.namespaces ?? []).find((n) => n.ns === "agent-default-model");
2301
- 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
+ }
2302
2857
  this.revision = ns?.revision ?? 0;
2303
- this.defaultModel = defaults?.value ? { ...defaults.value } : null;
2304
- this.defaultModelRevision = defaults?.revision ?? 0;
2305
- 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;
2306
2865
  } catch (e) { this.app.toast(`模型配置加载失败: ${e.message}`); }
2307
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: [] };
2308
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("、")}`);
2309
2874
  this.loaded = true;
2310
2875
  this.modelsSel = -1;
2311
2876
  this.#rebuild();
2312
2877
  this.app.redraw();
2313
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
+ }
2314
3025
  /** One batched credentials.describe over every referenced key, exactly like
2315
3026
  * the web page's store join. Reads are structurally value-free: only the
2316
3027
  * configured/source/writable view ever reaches this panel. */
@@ -2319,55 +3030,129 @@ export class ModelPanel extends Widget {
2319
3030
  // only well-formed references can cross the wire (the describe payload
2320
3031
  // validates each name); an ill-formed derived ref is skipped here and
2321
3032
  // reported by the row's edit guard instead
2322
- 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)))];
2323
3037
  if (refs.length === 0) { this.keyStatus = {}; return; }
2324
3038
  const res = await this.app.api.call("credentials.describe", { refs });
2325
3039
  this.keyStatus = res?.credentials ?? {};
2326
3040
  } catch (e) { this.keyStatus = {}; }
2327
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
+ }
2328
3054
  /** The credential reference a profile names, or the web's derived default. */
2329
3055
  #keyRef(route) {
2330
3056
  const p = this.#profile(route);
2331
3057
  return (p.apiKeyEnv && p.apiKeyEnv.length > 0) ? p.apiKeyEnv : deriveKeyRef(route);
2332
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
+ }
2333
3063
  #route() { return this.routes[this.sel] ?? null; }
2334
- #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
+ }
2335
3099
  #formRows() {
2336
3100
  const route = this.#route();
2337
3101
  if (route == null) return [];
2338
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;
2339
3107
  const items = [];
2340
- // the route key only appears for a brand-new draft (rename once)
3108
+ // Only hand-declared custom routes own their route identity/display name.
2341
3109
  if (this.draftRoute === route) items.push({ kind: "field", key: "route", label: "路由名", value: route });
2342
- items.push({ kind: "field", key: "displayName", label: "显示名", value: p.displayName ?? "" });
3110
+ if (ownsIdentity) items.push({ kind: "field", key: "displayName", label: "显示名", value: p.displayName ?? "" });
2343
3111
  // the api protocol is a CHOICE in the web UI (a select over the namespace
2344
3112
  // schema's union), so here Tab cycles the options in the form and Enter
2345
3113
  // opens an edit buffer with every candidate shown as an autocomplete hint
2346
- const api = p.api ?? "openai-completions";
2347
- items.push({
2348
- kind: "field", key: "api", label: "协议 api", value: api,
2349
- cycle: API_PROTOCOLS.includes(api) ? API_PROTOCOLS : [api, ...API_PROTOCOLS],
2350
- completions: API_PROTOCOLS, note: "Tab 切换 · Enter 输入",
2351
- });
2352
- items.push({ kind: "field", key: "baseURL", label: "baseURL", value: p.baseURL ?? "" });
2353
- 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 切换" });
2354
- items.push({ kind: "field", key: "defaultContextWindow", label: "默认上下文", value: p.defaultContextWindow ?? "", numeric: true });
2355
- 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
+ }
2356
3136
  // the api key: web-synced handling — the stored value is NEVER shown
2357
3137
  // (credentials.describe is structurally value-free), only its status dot;
2358
- // Enter opens a masked, always-empty editor and a typed key travels one
2359
- // 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.
2360
3140
  const keyRef = this.#keyRef(route);
2361
- items.push({ kind: "key", key: "apiKeyEnv", label: "API 密钥", ref: keyRef, action: () => this.#editKey(route, keyRef) });
2362
- 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) });
2363
3143
  // models are NOT flat here: one 模型管理 entry summarizing the first
2364
3144
  // five, which opens its own sub-buffer (scan on top, model form below)
2365
3145
  const models = p.models ?? [];
2366
- const names = models.slice(0, 5).map((m) => m.id || "(未命名)").join(" · ");
2367
- items.push({ kind: "button", label: "模型管理", sub: names + (models.length > 5 ? " · …" : ""), action: () => this.#openModels() });
2368
- 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() });
2369
3149
  items.push({ kind: "button", label: "💾 保存配置", action: () => this.#save() });
2370
- 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
+ }
2371
3156
  return items;
2372
3157
  }
2373
3158
  /** The 模型管理 sub-buffer: scan first, then the model-info form rows. */
@@ -2375,8 +3160,14 @@ export class ModelPanel extends Widget {
2375
3160
  const route = this.#route();
2376
3161
  if (route == null) return [];
2377
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;
2378
3166
  const items = [];
2379
- 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
+ }
2380
3171
  const models = p.models ?? [];
2381
3172
  for (let mi = 0; mi < models.length; mi++) {
2382
3173
  const m = models[mi];
@@ -2386,15 +3177,31 @@ export class ModelPanel extends Widget {
2386
3177
  items.push({ kind: "field", key: `model.${mi}.name`, label: " 模型名", value: m.name ?? "" });
2387
3178
  items.push({ kind: "field", key: `model.${mi}.contextWindow`, label: " 上下文窗口", value: m.contextWindow ?? "", numeric: true });
2388
3179
  items.push({ kind: "field", key: `model.${mi}.maxTokens`, label: " 最大输出", value: m.maxTokens ?? "", numeric: true });
2389
- items.push({ kind: "choice", key: `model.${mi}.reasoningEnabled`, label: " 启用思考强度", value: m.reasoningEfforts === false ? "否" : "是", cycle: ["是", "否"] });
2390
- 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 表示关闭" : "留空=不提供" });
2391
- 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
+ }
2392
3200
  }
2393
3201
  }
2394
3202
  items.push({ kind: "button", label: "+ 添加模型", action: () => this.#addModel() });
2395
3203
  items.push({ kind: "button", label: "🗑 删除选中模型", action: () => this.#deleteModel() });
2396
- items.push({ kind: "button", label: "◉ 设为当前会话模型(选中模型)", action: () => this.#setDefaultModel() });
2397
- items.push({ kind: "button", label: "★ 设为默认 Agent/Subagent 目标", action: () => this.#setAgentDefault() });
3204
+ items.push({ kind: "button", label: "◉ 设为当前会话及后续 Agent 默认模型", action: () => this.#setDefaultModel() });
2398
3205
  return items;
2399
3206
  }
2400
3207
  #openModels() {
@@ -2409,36 +3216,89 @@ export class ModelPanel extends Widget {
2409
3216
  const listLines = [];
2410
3217
  for (let i = 0; i < this.routes.length; i++) {
2411
3218
  const r = this.routes[i];
2412
- const p = this.providers[r] ?? {};
3219
+ const p = this.#profile(r);
3220
+ const entry = this.#entry(r);
2413
3221
  const cur = i === this.sel;
2414
3222
  const editing = cur && this.mode === "form";
2415
3223
  listLines.push([{
2416
- t: ` ${cur ? "●" : " "} ${truncate(p.displayName || r, 18)}${editing ? " ✎" : ""}`,
3224
+ t: ` ${cur ? "●" : " "} ${truncate(inlineLabel(p.displayName || entry?.displayName || r), 18)}${editing ? " ✎" : ""}`,
2417
3225
  fg: cur ? T.SELFG : T.TXT, bg: cur ? (editing ? T.MENUSEL : T.SELBG) : T.BG2, bold: cur,
2418
3226
  }]);
2419
3227
  }
2420
3228
  const addCur = this.sel === this.routes.length;
2421
3229
  listLines.push([{ t: ` ${addCur ? "●" : " "} + 添加供应商`, fg: addCur ? T.SELFG : T.ACCENT, bg: addCur ? T.MENUSEL : T.BG2, bold: true }]);
2422
3230
  this.listView.setLines(listLines);
2423
- // 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.
2424
3236
  const route = this.#route();
2425
3237
  const formLines = [];
2426
- if (route == null) {
2427
- formLines.push([{ t: " 左侧 ↑/↓ 选择供应商,Enter 打开编辑", fg: K.FAINT }]);
2428
- formLines.push([{ t: " 把光标移到底部的“+ 添加供应商”回车即可新建", fg: K.FAINT }]);
2429
- 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 }]);
2430
3286
  this.formItems = [];
2431
3287
  } else if (this.scanMode) {
2432
- formLines.push([{ t: ` 扫描 ${truncate(this.#profile(route).baseURL ?? "", 44)} — 空格勾选,Enter 添加,↑/↓ 移动`, fg: K.ACCENT, bold: true }]);
2433
- 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;
2434
3291
  for (let i = 0; i < this.scanItems.length; i++) {
2435
3292
  const m = this.scanItems[i];
2436
3293
  const on = this.scanSel.has(m.id);
2437
3294
  const cur = i === this.scanCursor;
2438
- 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 });
2439
3297
  }
2440
- formLines.push([{ t: " Enter 添加选中 · Esc 取消扫描", fg: K.FAINT }]);
3298
+ pushForm([{ t: " Enter 添加选中 · Esc 取消扫描", fg: K.FAINT }]);
2441
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);
2442
3302
  } else {
2443
3303
  const isSub = this.sub != null;
2444
3304
  const items = isSub ? this.#subItems() : this.#formRows();
@@ -2446,41 +3306,49 @@ export class ModelPanel extends Widget {
2446
3306
  else this.formItems = items;
2447
3307
  const w = Math.max(30, this.formView.w - 4);
2448
3308
  const cursor = isSub ? this.sub.cursor : this.formIdx;
2449
- 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 }]);
2450
3311
  for (let i = 0; i < items.length; i++) {
2451
3312
  const it = items[i];
2452
3313
  const cur = i === cursor;
2453
3314
  let t;
2454
3315
  if (it.kind === "field" || it.kind === "choice") {
2455
- const v = it.value === "" || it.value == null ? "(空)" : String(it.value);
3316
+ const v = it.value === "" || it.value == null ? "(空)" : inlineLabel(it.value);
2456
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)}`;
2457
3320
  } else if (it.kind === "key") {
2458
3321
  // status dot + reference, NEVER the value (web-synced posture)
2459
3322
  const st = this.keyStatus?.[it.ref];
2460
- const status = st?.configured ? "● 已配置" : "○ 未配置";
3323
+ const status = it.pending ? "◐ 待保存" : st?.configured ? "● 已配置" : "○ 未配置";
2461
3324
  const ro = st && st.writable === false ? " [只读]" : "";
2462
3325
  t = ` ${cur ? "▸" : " "} ${it.label}: ${status}${ro} (${it.ref})`;
2463
3326
  } else if (it.kind === "model") {
2464
3327
  const extras = [it.ctx != null ? `ctx ${it.ctx}` : "", it.max != null ? `max ${it.max}` : ""].filter(Boolean).join(" ");
2465
- 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)}`;
2466
3329
  } else {
2467
3330
  t = ` ${cur ? "▸" : " "} ${it.label}`;
2468
3331
  }
2469
- 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 });
2470
3334
  // the 模型管理 preview: an indented, non-focusable summary line
2471
3335
  if (!isSub && it.kind === "button" && it.sub) {
2472
- 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 }]);
2473
3337
  }
2474
3338
  }
2475
- formLines.push([{ t: isSub
3339
+ pushForm([{ t: isSub
2476
3340
  ? " ↑/↓ 移动 · Enter 编辑或执行 · Esc 返回供应商"
2477
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);
2478
3344
  }
3345
+ if (!this.writable) pushForm([{ t: " 模型配置只读 · 可浏览、发现模型和切换当前会话模型", fg: K.WARN }]);
2479
3346
  this.formView.setLines(formLines);
3347
+ this.formView.scrollY = Math.max(0, Math.min(this.formView.scrollY, this.formView.maxScroll()));
2480
3348
  }
2481
3349
  render(screen) {
2482
3350
  screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", {});
2483
- const mid = this.x + 26;
3351
+ const mid = this.formView.x - 1;
2484
3352
  screen.vline(mid, this.y, this.y + this.h - 1, "│", { fg: T.BORDER });
2485
3353
  screen.text(this.x + 1, this.y, " 模型供应商", { fg: K.DIM });
2486
3354
  this.listView.render(screen);
@@ -2510,18 +3378,40 @@ export class ModelPanel extends Widget {
2510
3378
  if (!/^[\x21-\x7E]+$/.test(value)) return "密钥只能包含可打印 ASCII 字符";
2511
3379
  return null;
2512
3380
  }
2513
- /** Edit the API key value the web-synced way: a masked, always-empty editor
2514
- * (the stored value is never read back), a non-empty commit travels one way
2515
- * through credentials.set under the profile's reference, an empty commit
2516
- * keeps the existing key. */
2517
- #clearKey(ref) {
2518
- 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) => {
2519
- this.app.overlay = this;
2520
- if (btn.action !== "clear") { this.app.redraw(); return; }
2521
- try { await this.app.api.call("credentials.unset", { ref }); this.app.toast(`已清除 ${ref}`); await this.#refreshKeys(); this.#rebuild(); }
2522
- catch (e) { this.app.toast(`清除密钥失败: ${e.message}`); }
2523
- this.app.redraw();
2524
- } });
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
+ });
2525
3415
  this.app.overlay = confirm; this.app.redraw();
2526
3416
  }
2527
3417
  #editKey(route, ref) {
@@ -2530,6 +3420,7 @@ export class ModelPanel extends Widget {
2530
3420
  return;
2531
3421
  }
2532
3422
  const st = this.keyStatus?.[ref];
3423
+ if (st?.writable === false) { this.app.toast(`${ref} 为只读凭据`); return; }
2533
3424
  const popup = new EditPopup(this.app, {
2534
3425
  title: `设置 API 密钥 — ${ref}`,
2535
3426
  value: "",
@@ -2541,18 +3432,11 @@ export class ModelPanel extends Widget {
2541
3432
  if (failure) { this.app.toast(failure); this.#rebuild(); this.app.redraw(); return; }
2542
3433
  const v = text.trim();
2543
3434
  if (v === "") { this.app.toast("未输入新密钥,保持原值不变"); this.#rebuild(); this.app.redraw(); return; }
2544
- try {
2545
- await this.app.api.call("credentials.set", { ref, value: v });
2546
- this.app.toast(`密钥已写入 ${ref}`);
2547
- const p = this.#profile(route);
2548
- if (!p.apiKeyEnv) {
2549
- // the web create flow records the derived reference in the profile;
2550
- // persist it with the provider save
2551
- p.apiKeyEnv = ref;
2552
- this.app.toast(`已记录 apiKeyEnv · 点💾保存配置使供应商生效`);
2553
- }
2554
- await this.#refreshKeys();
2555
- } 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} · 可先用于自动发现`);
2556
3440
  this.#rebuild();
2557
3441
  this.app.redraw();
2558
3442
  },
@@ -2561,12 +3445,28 @@ export class ModelPanel extends Widget {
2561
3445
  this.app.focus(popup.input);
2562
3446
  this.app.redraw();
2563
3447
  }
2564
- #addProvider() {
2565
- let name = "新供应商", i = 2;
2566
- while (this.providers[name] !== undefined) name = `新供应商${i++}`;
2567
- 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);
2568
3467
  this.draftRoute = name;
2569
- this.routes = Object.keys(this.providers);
3468
+ this.addMode = false;
3469
+ this.#syncRoutes();
2570
3470
  this.sel = this.routes.indexOf(name);
2571
3471
  this.mode = "form";
2572
3472
  this.formIdx = 0;
@@ -2575,9 +3475,45 @@ export class ModelPanel extends Widget {
2575
3475
  this.#rebuild();
2576
3476
  this.app.redraw();
2577
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
+ }
2578
3513
  #activateItem() {
2579
3514
  if (this.mode === "list") {
2580
- if (this.sel === this.routes.length) { this.#addProvider(); return; }
3515
+ if (this.sel === this.routes.length) { this.#openAddProvider(); return; }
3516
+ this.addMode = false;
2581
3517
  this.mode = "form";
2582
3518
  this.formIdx = 0;
2583
3519
  this.modelsSel = -1;
@@ -2591,51 +3527,136 @@ export class ModelPanel extends Widget {
2591
3527
  const it = items[idx];
2592
3528
  if (!it) return;
2593
3529
  const route = this.#route();
2594
- 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; }
2595
3534
  if (it.kind === "field") {
2596
3535
  // Enter always opens the standalone edit buffer; Tab (handled in onKey)
2597
3536
  // cycles a field that declares cycle options, and the buffer itself
2598
3537
  // offers every completion as an autocomplete hint
2599
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);
2600
3547
  if (it.key === "route") {
2601
- // renaming the route key
2602
- const t = text.trim() || route;
2603
- 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
+ }
2604
3555
  if (t !== route) {
2605
- this.providers[t] = this.providers[route];
3556
+ const profile = this.providers[route];
3557
+ const oldDerivedRef = deriveKeyRef(route);
3558
+ this.providers[t] = profile;
2606
3559
  delete this.providers[route];
2607
- 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();
2608
3567
  this.sel = this.routes.indexOf(t);
2609
3568
  }
2610
3569
  } else if (it.numeric) {
2611
3570
  const n = text.trim() === "" ? undefined : Number(text);
2612
- if (n !== undefined && (!isFinite(n) || n <= 0)) { this.app.toast("请输入正数"); return; }
2613
3571
  if (it.key.startsWith("model.")) {
2614
- const [, mi, field] = it.key.split("."); p.models[Number(mi)][field] = n;
2615
- } 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;
2616
3578
  } else if (it.key.startsWith("model.")) {
2617
3579
  const [, mi, field, detail] = it.key.split(".");
2618
- const model = p.models[Number(mi)];
3580
+ const model = this.#models(route, { mutable: true })[Number(mi)];
2619
3581
  if (field === "reasoning") {
2620
- if (model.reasoningEfforts === false) model.reasoningEfforts = {};
3582
+ if (!model.reasoningEfforts || model.reasoningEfforts === false) model.reasoningEfforts = {};
2621
3583
  const value = text.trim();
2622
3584
  if (!value) delete model.reasoningEfforts[detail];
2623
- else model.reasoningEfforts[detail] = value === "null" ? null : value;
2624
- } 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;
2625
3619
  } else {
2626
- if (it.key === "api" && !API_PROTOCOLS.includes(text.trim())) this.app.toast(`注意:${text.trim() || "空"} 不是已知协议,保存时可能被拒绝`);
2627
- if (it.key === "__defaultReasoningEffort") {
2628
- if (!this.defaultModel || this.defaultModel.provider !== route) { this.app.toast("请先在模型管理中将本供应商的模型设为当前会话模型;默认 Agent 模型可在 Settings 修改"); return; }
2629
- this.defaultModel.reasoningEffort = text.trim() || undefined;
2630
- } 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);
2631
3624
  }
3625
+ this.#pruneEmptyDraft(route);
2632
3626
  }, it.completions);
2633
3627
  return;
2634
3628
  }
2635
3629
  if (it.kind === "choice") {
2636
- const [, mi, field, detail] = it.key.split("."); const model = p.models[Number(mi)];
2637
- if (field === "reasoningEnabled") model.reasoningEfforts = it.value === "" ? false : {};
2638
- 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
+ }
2639
3660
  this.#rebuild(); this.app.redraw(); return;
2640
3661
  }
2641
3662
  if (it.kind === "model") {
@@ -2650,22 +3671,32 @@ export class ModelPanel extends Widget {
2650
3671
  return;
2651
3672
  }
2652
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
+ }
2653
3685
  #addModel() {
2654
3686
  const route = this.#route();
2655
3687
  if (!route) return;
2656
- const p = this.#profile(route);
2657
- p.models ??= [];
2658
- p.models.push({ id: "", name: "" });
2659
- this.modelsSel = p.models.length - 1;
3688
+ const models = this.#models(route, { mutable: true });
3689
+ models.push({ id: "" });
3690
+ this.modelsSel = models.length - 1;
2660
3691
  this.#rebuild();
2661
3692
  this.app.redraw();
2662
3693
  }
2663
3694
  #deleteModel() {
2664
3695
  const route = this.#route();
2665
3696
  if (!route || this.modelsSel < 0) { this.app.toast("先选中一个模型"); return; }
2666
- const model = this.#profile(route).models[this.modelsSel];
3697
+ const model = this.#models(route)[this.modelsSel];
2667
3698
  this.#confirmDelete(`删除模型 ${model?.name || model?.id || "(未命名)"}?`, () => {
2668
- this.#profile(route).models.splice(this.modelsSel, 1);
3699
+ this.#models(route, { mutable: true }).splice(this.modelsSel, 1);
2669
3700
  this.modelsSel = -1;
2670
3701
  this.#rebuild(); this.app.redraw();
2671
3702
  });
@@ -2673,60 +3704,214 @@ export class ModelPanel extends Widget {
2673
3704
  async #setDefaultModel() {
2674
3705
  const route = this.#route();
2675
3706
  if (!route) return;
2676
- const p = this.#profile(route);
2677
- const m = p.models?.[this.modelsSel];
3707
+ const m = this.#models(route)[this.modelsSel];
2678
3708
  if (!m?.id) { this.app.toast("先选中一个模型"); return; }
2679
3709
  if (!this.app.currentSession) { this.app.toast("先打开一个会话"); return; }
2680
3710
  try {
2681
3711
  await this.app.api.call("session.selectModel", { sessionId: this.app.currentSession, provider: route, model: m.id });
2682
- this.app.updateModel();
2683
- 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 默认使用此模型`);
2684
3714
  } catch (e) { this.app.toast(`切换失败: ${e.message}`); }
2685
3715
  }
2686
- #setAgentDefault() {
2687
- const route = this.#route(), m = this.#profile(route)?.models?.[this.modelsSel];
2688
- if (!route || !m?.id) { this.app.toast("先选中一个模型"); return; }
2689
- this.defaultModel = { provider: route, model: m.id, ...(this.defaultModel?.reasoningEffort ? { reasoningEffort: this.defaultModel.reasoningEffort } : {}) };
2690
- this.app.toast(`默认 Agent/Subagent 目标设为 ${route}/${m.id},点💾保存配置`);
2691
- this.#rebuild(); this.app.redraw();
2692
- }
2693
- 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);
2694
3782
  try {
2695
- const res = await this.app.api.call("settings.mutate", {
2696
- ns: "llm-pi-ai",
2697
- ops: [{ op: "set", path: ["providers"], value: this.providers }],
2698
- expectedRevision: this.revision,
2699
- });
2700
- this.revision = res?.revision ?? this.revision;
2701
- this.draftRoute = null;
2702
- this.savedSnapshot = JSON.stringify(this.providers);
2703
- if (this.defaultModel) {
2704
- const profile = this.providers[this.defaultModel.provider];
2705
- if (!profile || !(profile.models ?? []).some((model) => model.id === this.defaultModel.model)) {
2706
- 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);
2707
3791
  }
2708
- try {
2709
- const def = await this.app.api.call("settings.mutate", { ns: "agent-default-model", ops: [{ op: "set", path: [], value: this.defaultModel }], expectedRevision: this.defaultModelRevision });
2710
- this.defaultModelRevision = def?.revision ?? this.defaultModelRevision;
2711
- } catch (e) {
2712
- 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);
2713
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);
2714
3837
  }
2715
- 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 密钥已保存" : "配置未变化");
2716
3842
  return true;
2717
- } 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
+ }
2718
3851
  }
2719
- #dirty() { return JSON.stringify(this.providers) !== this.savedSnapshot; }
2720
- /** Throw away the in-memory edits and restore the last saved/loaded state. */
2721
- #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
+ }
2722
3895
  this.providers = JSON.parse(this.savedSnapshot);
2723
- 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();
2724
3907
  this.draftRoute = null;
3908
+ this.addMode = false;
2725
3909
  this.modelsSel = -1;
2726
3910
  this.sub = null;
2727
- 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);
2728
3912
  this.#rebuild();
2729
3913
  this.app.redraw();
3914
+ return true;
2730
3915
  }
2731
3916
  /** Leave the provider form for another level. With unsaved changes this asks
2732
3917
  * 保存/不保存/取消 first; a failed save keeps the user on the form. */
@@ -2747,13 +3932,13 @@ export class ModelPanel extends Widget {
2747
3932
  ],
2748
3933
  onAction: async (btn) => {
2749
3934
  this.app.closeOverlay();
2750
- this.app.focus(this.app.chat);
3935
+ this.app.focus(this.app.fullBuffer ?? this.app.chat);
2751
3936
  if (btn?.action === "cancel") return; // stay on the form
2752
3937
  if (btn?.action === "save") {
2753
3938
  const ok = await this.#save();
2754
3939
  if (!ok) return; // save failed: stay (toast shown)
2755
3940
  } else if (btn?.action === "discard") {
2756
- this.#discard();
3941
+ if (!await this.#discard()) return;
2757
3942
  } else {
2758
3943
  return; // Esc = __cancel__
2759
3944
  }
@@ -2771,19 +3956,139 @@ export class ModelPanel extends Widget {
2771
3956
  w, h: Math.min(7, this.app.screen.h), title: "确认删除",
2772
3957
  lines: [[{ t: " " + prompt, fg: K.WARN }]],
2773
3958
  buttons: [{ label: "取消", action: "cancel" }, { label: "删除", action: "delete" }],
2774
- onAction: (btn) => { this.app.closeOverlay(); if (btn?.action === "delete") action(); },
3959
+ onAction: (btn) => { this.app.closeOverlay(); if (btn?.action === "delete") return action(); },
2775
3960
  });
2776
3961
  this.app.redraw();
2777
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
+ }
2778
3993
  async #deleteProvider() {
2779
3994
  const route = this.#route();
2780
3995
  if (!route) return;
3996
+ if (this.#dirty()) { this.app.toast("请先保存或放弃其他修改,再删除供应商"); return; }
2781
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
+ }
2782
4070
  delete this.providers[route];
2783
- this.routes = Object.keys(this.providers);
2784
- 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);
2785
4079
  this.modelsSel = -1;
2786
- 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
+ }
2787
4092
  this.#rebuild(); this.app.redraw();
2788
4093
  });
2789
4094
  }
@@ -2791,47 +4096,47 @@ export class ModelPanel extends Widget {
2791
4096
  const route = this.#route();
2792
4097
  if (!route) return;
2793
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
+ }
2794
4105
  const base = String(p.baseURL ?? "").replace(/\/+$/, "");
2795
- if (!base) { this.app.toast("先填写 baseURL"); return; }
2796
4106
  this.scanning = true;
2797
4107
  this.scanMode = true;
2798
4108
  this.scanItems = [];
2799
4109
  this.scanCursor = 0;
2800
4110
  this.#rebuild();
2801
4111
  this.app.redraw();
2802
- const key = p.apiKeyEnv ? process.env[p.apiKeyEnv] : null;
2803
- const headers = key ? { Authorization: `Bearer ${key}` } : {};
2804
- const tryFetch = async (dispatcher) => {
2805
- const res = await fetch(`${base}/models`, { headers, dispatcher });
2806
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
2807
- return res.json();
2808
- };
2809
4112
  try {
2810
- let body;
2811
- try { body = await tryFetch(undefined); }
2812
- catch (e0) {
2813
- // self-signed gateways: retry with TLS verification disabled
2814
- try {
2815
- const { Agent } = await import("undici");
2816
- body = await tryFetch(new Agent({ connect: { rejectUnauthorized: false } }));
2817
- } catch { throw e0; }
2818
- }
2819
- 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
+ });
2820
4122
  const seen = new Set();
2821
- const items = [];
2822
- for (const e of list) {
2823
- if (!e || typeof e !== "object") continue;
2824
- const id = String(e.id ?? e.name ?? "").trim();
2825
- 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 [];
2826
4127
  seen.add(id);
2827
- items.push({ id, name: e.name ?? e.id ?? id });
2828
- }
2829
- this.scanItems = items;
2830
- this.scanSel = new Set(items.map((m) => m.id));
2831
- if (items.length === 0) this.app.toast("扫描完成:未发现模型");
2832
- 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 添加`);
2833
4138
  } catch (e) {
2834
- this.app.toast(`扫描失败: ${e.message}`);
4139
+ this.app.toast(`扫描失败:${String(e.message ?? e).replace(/^[^:]+:\s*/, "")}`);
2835
4140
  this.scanMode = false;
2836
4141
  }
2837
4142
  this.scanning = false;
@@ -2841,13 +4146,17 @@ export class ModelPanel extends Widget {
2841
4146
  #scanCommit() {
2842
4147
  const route = this.#route();
2843
4148
  if (!route) return;
2844
- const p = this.#profile(route);
2845
- p.models ??= [];
2846
- 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));
2847
4152
  let added = 0;
2848
- for (const m of this.scanItems) {
2849
- if (!this.scanSel.has(m.id) || existing.has(m.id)) continue;
2850
- 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
+ });
2851
4160
  added++;
2852
4161
  }
2853
4162
  this.scanMode = false;
@@ -2857,10 +4166,26 @@ export class ModelPanel extends Widget {
2857
4166
  }
2858
4167
  onKey(ev) {
2859
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
+ }
2860
4185
  if (this.scanMode) {
2861
4186
  if (ev.name === "escape") { this.scanMode = false; this.#rebuild(); return true; }
2862
- if (ev.name === "up") { this.scanCursor = Math.max(0, this.scanCursor - 1); this.#rebuild(); this.app.redraw(); return true; }
2863
- 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; }
2864
4189
  if (ev.name === "char" && ev.key === " " && !ev.ctrl) {
2865
4190
  const m = this.scanItems[this.scanCursor];
2866
4191
  if (m) { if (this.scanSel.has(m.id)) this.scanSel.delete(m.id); else this.scanSel.add(m.id); }
@@ -2875,13 +4200,13 @@ export class ModelPanel extends Widget {
2875
4200
  // inside the 模型管理 sub-buffer: ↑/↓ walk its rows; Esc returns
2876
4201
  if (ev.name === "escape") { this.sub = null; this.#rebuild(); return true; }
2877
4202
  if (ev.name === "up" || (ev.name === "char" && ev.key === "k" && !ev.ctrl)) {
2878
- this.sub.cursor = Math.max(0, this.sub.cursor - 1);
4203
+ this.sub.cursor = wrapIndex(this.sub.cursor - 1, this.#subItems().length);
2879
4204
  this.#rebuild();
2880
4205
  this.app.redraw();
2881
4206
  return true;
2882
4207
  }
2883
4208
  if (ev.name === "down" || (ev.name === "char" && ev.key === "j" && !ev.ctrl)) {
2884
- 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);
2885
4210
  this.#rebuild();
2886
4211
  this.app.redraw();
2887
4212
  return true;
@@ -2897,21 +4222,21 @@ export class ModelPanel extends Widget {
2897
4222
  this.#leaveForm(() => { this.mode = "list"; this.sub = null; this.#rebuild(); this.app.redraw(); });
2898
4223
  return true;
2899
4224
  }
2900
- 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
2901
4226
  }
2902
4227
  // dual-focus navigation: ↑/↓ move the cursor INSIDE the focused region —
2903
4228
  // the provider column in list focus, the option rows in form focus.
2904
4229
  // → enters the form, ← returns to the list.
2905
4230
  if (ev.name === "up" || (ev.name === "char" && ev.key === "k" && !ev.ctrl)) {
2906
- if (this.mode === "list") this.sel = Math.max(0, this.sel - 1);
2907
- 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);
2908
4233
  this.#rebuild();
2909
4234
  this.app.redraw();
2910
4235
  return true;
2911
4236
  }
2912
4237
  if (ev.name === "down" || (ev.name === "char" && ev.key === "j" && !ev.ctrl)) {
2913
- if (this.mode === "list") this.sel = Math.min(this.routes.length, this.sel + 1);
2914
- 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);
2915
4240
  this.#rebuild();
2916
4241
  this.app.redraw();
2917
4242
  return true;
@@ -2921,12 +4246,17 @@ export class ModelPanel extends Widget {
2921
4246
  // walks to the next form item. Otherwise (list focus) it behaves like →.
2922
4247
  if (ev.name === "tab" && this.mode === "form" && this.sub == null) {
2923
4248
  const it = this.formItems[this.formIdx];
4249
+ if (it?.cycle?.length && !this.writable) { this.app.toast("模型配置为只读"); return true; }
2924
4250
  if (it?.cycle?.length) {
2925
4251
  const cur = String(it.value ?? "");
2926
4252
  const idx = it.cycle.indexOf(cur);
2927
- 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());
2928
4258
  } else if (this.formItems.length > 0) {
2929
- this.formIdx = Math.min(this.formItems.length - 1, this.formIdx + 1);
4259
+ this.formIdx = wrapIndex(this.formIdx + 1, this.formItems.length);
2930
4260
  }
2931
4261
  this.#rebuild();
2932
4262
  this.app.redraw();
@@ -2959,7 +4289,7 @@ export class ModelPanel extends Widget {
2959
4289
  if (this.mode === "form") {
2960
4290
  if (idx === this.sel) return true; // already editing this provider
2961
4291
  if (idx === this.routes.length) {
2962
- this.#leaveForm(() => this.#addProvider());
4292
+ this.#leaveForm(() => this.#openAddProvider());
2963
4293
  } else {
2964
4294
  this.#leaveForm(() => {
2965
4295
  this.sel = idx;
@@ -2979,25 +4309,40 @@ export class ModelPanel extends Widget {
2979
4309
  }
2980
4310
  return false;
2981
4311
  }
2982
- if (this.scanMode) {
2983
- const idx = ev.y - this.formView.y + this.formView.scrollY - 1;
2984
- if (idx >= 0 && idx < this.scanItems.length) {
2985
- this.scanCursor = idx;
2986
- const m = this.scanItems[idx];
2987
- if (this.scanSel.has(m.id)) this.scanSel.delete(m.id); else this.scanSel.add(m.id);
2988
- this.app.redraw();
2989
- return true;
2990
- }
2991
- 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;
2992
4320
  }
2993
- const idx = ev.y - this.formView.y + this.formView.scrollY;
2994
- if (this.sub != null) {
2995
- // the sub-buffer's title row is visual-only; rows below it are items
2996
- const itemIdx = idx - 1;
2997
- if (itemIdx >= 0 && itemIdx < this.#subItems().length) { this.sub.cursor = itemIdx; this.#activateItem(); return true; }
2998
- 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;
2999
4345
  }
3000
- if (idx >= 0 && idx < this.formItems.length) { this.formIdx = idx; this.mode = "form"; this.#activateItem(); return true; }
3001
4346
  return false;
3002
4347
  }
3003
4348
  }
@@ -3196,13 +4541,13 @@ export class SubagentPanel extends Widget {
3196
4541
  onKey(ev) {
3197
4542
  if (ev.type === "text") { this.input.insert(ev.text); this.app.redraw(); return true; }
3198
4543
  if (ev.type !== "key") return false;
3199
- if (ev.name === "escape") { this.app.setMode("chat"); return true; }
4544
+ if (ev.name === "escape") { this.app.closeFullBuffer?.() ?? this.app.setMode?.("chat"); return true; }
3200
4545
  if (ev.name === "char" && ev.key === "x" && !ev.ctrl) { this.interrupt(); return true; }
3201
4546
  if (ev.name === "char" && ev.key === "r" && !ev.ctrl) { this.selectChild(this.selIdx); return true; }
3202
4547
  if (ev.name === "up" || ev.name === "down") {
3203
4548
  if (this.entries.length === 0) return false;
3204
- const next = this.selIdx + (ev.name === "up" ? -1 : 1);
3205
- 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);
3206
4551
  return true;
3207
4552
  }
3208
4553
  if (this.input.onKey(ev)) { this.app.redraw(); return true; }
@@ -3293,11 +4638,11 @@ export class SkillsPanel extends Widget {
3293
4638
  }
3294
4639
  onKey(ev) {
3295
4640
  if (ev.type !== "key") return false;
3296
- if (ev.name === "escape") { this.app.setMode("chat"); return true; }
4641
+ if (ev.name === "escape") { this.app.closeFullBuffer?.() ?? this.app.setMode?.("chat"); return true; }
3297
4642
  if (ev.name === "up" || ev.name === "down") {
3298
4643
  if (this.skills.length === 0) return false;
3299
- const next = this.selIdx + (ev.name === "up" ? -1 : 1);
3300
- 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);
3301
4646
  return true;
3302
4647
  }
3303
4648
  if (ev.name === "char" && ev.key === "c" && !ev.ctrl && this.skills[this.selIdx]) {