dsh-neotui 0.0.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/LICENSE +21 -0
- package/README.md +133 -0
- package/bin/dsh-tui.js +130 -0
- package/package.json +35 -0
- package/src/api.js +140 -0
- package/src/index.js +54 -0
- package/src/md.js +374 -0
- package/src/panels.js +1464 -0
- package/src/screen.js +215 -0
- package/src/term.js +270 -0
- package/src/text.js +110 -0
- package/src/theme.js +90 -0
- package/src/views.js +2403 -0
- package/src/widgets.js +567 -0
package/src/panels.js
ADDED
|
@@ -0,0 +1,1464 @@
|
|
|
1
|
+
// panels.js — Command palette, model picker, workspace browser, trajectory
|
|
2
|
+
// timeline, jobs/goal panels, and the terminal image viewer (kitty graphics
|
|
3
|
+
// protocol with external-viewer / chafa fallbacks).
|
|
4
|
+
import { Widget, ScrollView, Input, Popup } from "./widgets.js";
|
|
5
|
+
import { strWidth, truncate, pad } from "./text.js";
|
|
6
|
+
import { renderMd, C } from "./md.js";
|
|
7
|
+
import { readdirSync, statSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join, basename, extname } from "node:path";
|
|
10
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
11
|
+
|
|
12
|
+
import { T, cycleTheme, themeName } from "./theme.js";
|
|
13
|
+
// Live theme accessor: K.K.DIM etc. resolve against the active palette at render time.
|
|
14
|
+
const K = new Proxy({}, { get(_k, key) { return T[key]; } });
|
|
15
|
+
|
|
16
|
+
// ---- fuzzy matcher ----
|
|
17
|
+
|
|
18
|
+
export function fuzzyScore(query, text) {
|
|
19
|
+
const q = query.toLowerCase();
|
|
20
|
+
const t = text.toLowerCase();
|
|
21
|
+
if (!q) return 1;
|
|
22
|
+
if (t.includes(q)) return 1000 + (1000 - t.indexOf(q)) - t.length / 10;
|
|
23
|
+
let qi = 0, score = 0, streak = 0, firstHit = true;
|
|
24
|
+
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
|
|
25
|
+
if (t[ti] === q[qi]) {
|
|
26
|
+
score += 10 + streak * 6 + (firstHit ? 5 : 0) + (ti === 0 || /[\s\-_/.]/.test(t[ti - 1]) ? 8 : 0);
|
|
27
|
+
streak++;
|
|
28
|
+
qi++;
|
|
29
|
+
firstHit = false;
|
|
30
|
+
} else {
|
|
31
|
+
streak = 0;
|
|
32
|
+
score -= 0.5;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return qi === q.length ? score : -1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---- Picker: floating fuzzy selector (mouse + keyboard) ----
|
|
39
|
+
|
|
40
|
+
export class Picker extends Widget {
|
|
41
|
+
constructor({ x, y, w, h, title, items, onPick, onCancel, placeholder = "输入以筛选…" }) {
|
|
42
|
+
super({ x, y, w, h });
|
|
43
|
+
this.title = title;
|
|
44
|
+
this.items = items; // { label, hint?, action, keywords? }
|
|
45
|
+
this.onPick = onPick;
|
|
46
|
+
this.onCancel = onCancel;
|
|
47
|
+
this.placeholder = placeholder;
|
|
48
|
+
this.query = "";
|
|
49
|
+
this.sel = 0;
|
|
50
|
+
this.scroll = 0;
|
|
51
|
+
this.input = new Input({ x: x + 1, y: y + 1, w: w - 2, h: 1, prompt: "❯ ", placeholder, bg: T.BG2 });
|
|
52
|
+
}
|
|
53
|
+
filtered() {
|
|
54
|
+
const scored = this.items
|
|
55
|
+
.map((it) => ({ it, s: fuzzyScore(this.query, `${it.label} ${it.hint ?? ""} ${it.keywords ?? ""}`) }))
|
|
56
|
+
.filter((e) => e.s > 0 || this.query === "")
|
|
57
|
+
.sort((a, b) => b.s - a.s)
|
|
58
|
+
.map((e) => e.it);
|
|
59
|
+
if (this.sel >= scored.length) this.sel = Math.max(0, scored.length - 1);
|
|
60
|
+
return scored;
|
|
61
|
+
}
|
|
62
|
+
render(screen) {
|
|
63
|
+
screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", { bg: T.BG2 });
|
|
64
|
+
screen.box(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, { fg: K.ACCENT, bg: T.BG2 }, this.title);
|
|
65
|
+
this.input.render(screen);
|
|
66
|
+
const list = this.filtered();
|
|
67
|
+
const lh = this.h - 3;
|
|
68
|
+
if (this.sel < this.scroll) this.scroll = this.sel;
|
|
69
|
+
if (this.sel >= this.scroll + lh) this.scroll = this.sel - lh + 1;
|
|
70
|
+
for (let i = 0; i < lh; i++) {
|
|
71
|
+
const idx = this.scroll + i;
|
|
72
|
+
const it = list[idx];
|
|
73
|
+
const y = this.y + 2 + i;
|
|
74
|
+
if (!it) { screen.hline(this.x + 1, this.x + this.w - 2, y, " ", { bg: T.BG2 }); continue; }
|
|
75
|
+
const sel = idx === this.sel;
|
|
76
|
+
screen.fillRect(this.x + 1, y, this.x + this.w - 2, y, " ", { bg: sel ? T.MENUSEL : T.BG2 });
|
|
77
|
+
const hint = it.hint ? " " + it.hint : "";
|
|
78
|
+
screen.text(this.x + 2, y, truncate(it.label, this.w - 4 - strWidth(hint)), { fg: sel ? 0xffffff : K.TXT, bg: sel ? T.MENUSEL : T.BG2, attrs: sel ? 1 : 0 });
|
|
79
|
+
if (it.hint) screen.text(this.x + this.w - 2 - strWidth(hint), y, hint, { fg: K.DIM, bg: sel ? T.MENUSEL : T.BG2 });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
onMouse(ev) {
|
|
83
|
+
if (ev.kind === "press" && ev.button === 0) {
|
|
84
|
+
const idx = this.scroll + (ev.y - this.y - 2);
|
|
85
|
+
const list = this.filtered();
|
|
86
|
+
if (ev.y === this.y + 1) { this.input.onMouse(ev); return true; }
|
|
87
|
+
if (idx >= 0 && idx < list.length) { this.onPick?.(list[idx]); return true; }
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
if (ev.kind === "wheel-up") { this.sel = Math.max(0, this.sel - 1); return true; }
|
|
91
|
+
if (ev.kind === "wheel-down") { this.sel = Math.min(Math.max(0, this.filtered().length - 1), this.sel + 1); return true; }
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
onKey(ev) {
|
|
95
|
+
if (ev.type === "text") { this.query += ev.text; this.sel = 0; return true; }
|
|
96
|
+
if (ev.type !== "key") return false;
|
|
97
|
+
switch (ev.name) {
|
|
98
|
+
case "up": this.sel = Math.max(0, this.sel - 1); return true;
|
|
99
|
+
case "down": this.sel = Math.min(Math.max(0, this.filtered().length - 1), this.sel + 1); return true;
|
|
100
|
+
case "enter": { const l = this.filtered(); if (l[this.sel]) { this.onPick?.(l[this.sel]); } return true; }
|
|
101
|
+
case "escape": this.onCancel?.(); return true;
|
|
102
|
+
case "backspace": this.query = this.query.slice(0, -1); this.sel = 0; return true;
|
|
103
|
+
case "char": if (!ev.ctrl) { this.query += ev.text; this.sel = 0; return true; } return false;
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---- Model picker ----
|
|
110
|
+
|
|
111
|
+
export function buildModelPicker(app) {
|
|
112
|
+
const w = Math.min(70, app.screen.w - 4), h = Math.min(24, app.screen.h - 4);
|
|
113
|
+
const selectModel = async (it) => {
|
|
114
|
+
app.overlay = null; app.redraw();
|
|
115
|
+
if (!app.currentSession) { app.toast("先打开一个会话"); return; }
|
|
116
|
+
try {
|
|
117
|
+
await app.api.call("session.selectModel", { sessionId: app.currentSession, provider: it.provider, model: it.model, ...(it.effort ? { reasoningEffort: it.effort } : {}) });
|
|
118
|
+
app.updateModel();
|
|
119
|
+
app.toast(`已切换 ${it.provider}/${it.model}${it.effort ? ` (${it.effort})` : ""}`);
|
|
120
|
+
} catch (e) { app.toast(`切换失败: ${e.message}`); }
|
|
121
|
+
};
|
|
122
|
+
const picker = new Picker({
|
|
123
|
+
x: Math.floor((app.screen.w - w) / 2), y: Math.floor((app.screen.h - h) / 2),
|
|
124
|
+
w, h, title: "选择模型",
|
|
125
|
+
items: [],
|
|
126
|
+
onCancel: () => { app.overlay = null; app.redraw(); },
|
|
127
|
+
onPick: (it) => {
|
|
128
|
+
const efforts = it.efforts ?? [];
|
|
129
|
+
if (efforts.length > 0) {
|
|
130
|
+
// second step: reasoning effort
|
|
131
|
+
const w2 = Math.min(60, app.screen.w - 4), h2 = Math.min(efforts.length + 4, app.screen.h - 4);
|
|
132
|
+
app.overlay = new Picker({
|
|
133
|
+
x: Math.floor((app.screen.w - w2) / 2), y: Math.floor((app.screen.h - h2) / 2),
|
|
134
|
+
w: w2, h: h2, title: `思考强度 — ${it.model}`,
|
|
135
|
+
items: efforts.map((e) => ({
|
|
136
|
+
label: e.name ?? e.id, hint: e.id === it.defaultEffort ? "默认" : (e.description ?? "").slice(0, 28),
|
|
137
|
+
provider: it.provider, model: it.model, effort: e.id,
|
|
138
|
+
})),
|
|
139
|
+
onCancel: () => { app.overlay = picker; app.redraw(); },
|
|
140
|
+
onPick: (eff) => selectModel(eff),
|
|
141
|
+
});
|
|
142
|
+
app.redraw();
|
|
143
|
+
} else {
|
|
144
|
+
selectModel(it);
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
app.api.call("llm.models").then(({ groups, failures }) => {
|
|
149
|
+
const items = [];
|
|
150
|
+
for (const g of groups) {
|
|
151
|
+
for (const m of g.models) {
|
|
152
|
+
items.push({
|
|
153
|
+
label: `${g.id}/${m.id}`,
|
|
154
|
+
hint: m.name ?? m.id,
|
|
155
|
+
provider: g.id, model: m.id,
|
|
156
|
+
efforts: m.reasoning?.efforts ?? [],
|
|
157
|
+
defaultEffort: m.reasoning?.defaultEffort,
|
|
158
|
+
keywords: `${m.description ?? ""} ${g.name}`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
picker.items = items;
|
|
163
|
+
app.redraw();
|
|
164
|
+
}).catch((e) => app.toast(`模型列表失败: ${e.message}`));
|
|
165
|
+
return picker;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---- Mode (agent preset) & permission pickers ----
|
|
169
|
+
|
|
170
|
+
export const MODE_NAMES = { standard: "标准模式", code: "PTC 模式", minimal: "极简模式", cordis: "创造模式" };
|
|
171
|
+
export const PERM_NAMES = { "read-only": "只读", "workspace-write": "工作区写入", "danger-full-access": "完全访问" };
|
|
172
|
+
|
|
173
|
+
export function modeName(id) { return MODE_NAMES[id] ?? id; }
|
|
174
|
+
export function permName(id) { return PERM_NAMES[id] ?? id; }
|
|
175
|
+
|
|
176
|
+
/** Four-mode selector: the shipped agent presets (standard/code/minimal/cordis). */
|
|
177
|
+
export function buildModePicker(app) {
|
|
178
|
+
const w = Math.min(66, app.screen.w - 4), h = Math.min(18, app.screen.h - 4);
|
|
179
|
+
const picker = new Picker({
|
|
180
|
+
x: Math.floor((app.screen.w - w) / 2), y: Math.floor((app.screen.h - h) / 2),
|
|
181
|
+
w, h, title: "模式(Agent 预设)",
|
|
182
|
+
items: [],
|
|
183
|
+
onCancel: () => { app.overlay = null; app.redraw(); },
|
|
184
|
+
onPick: (it) => { app.overlay = null; app.redraw(); app.selectPreset(it.id); },
|
|
185
|
+
});
|
|
186
|
+
app.api.call("agentPreset.list").then(({ presets }) => {
|
|
187
|
+
const cur = app.sessions.find((s) => s.sessionId === app.currentSession)?.agentPreset;
|
|
188
|
+
picker.items = presets.filter((p) => !p.broken).map((p) => ({
|
|
189
|
+
label: `${p.id === cur ? "●" : p.isDefault ? "◐" : "○"} ${modeName(p.id)}`,
|
|
190
|
+
hint: p.id === cur ? "当前" : p.isDefault ? "默认" : p.id,
|
|
191
|
+
id: p.id,
|
|
192
|
+
keywords: `${p.id} ${p.description ?? ""}`,
|
|
193
|
+
}));
|
|
194
|
+
app.redraw();
|
|
195
|
+
}).catch((e) => app.toast(`模式列表失败: ${e.message}`));
|
|
196
|
+
return picker;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Three-permission selector: read-only / workspace-write / danger-full-access. */
|
|
200
|
+
export function buildPermissionPicker(app) {
|
|
201
|
+
const perms = app.projections.permissions;
|
|
202
|
+
const options = (perms?.options ?? []).filter((o) => o.value !== "custom");
|
|
203
|
+
const current = perms?.currentValue;
|
|
204
|
+
const w = Math.min(60, app.screen.w - 4), h = Math.min(options.length + 4, 16);
|
|
205
|
+
return new Picker({
|
|
206
|
+
x: Math.floor((app.screen.w - w) / 2), y: Math.floor((app.screen.h - h) / 2),
|
|
207
|
+
w, h, title: "权限(沙箱 + 审批)",
|
|
208
|
+
items: options.map((o) => ({
|
|
209
|
+
label: `${o.value === current ? "●" : "○"} ${permName(o.value)}`,
|
|
210
|
+
hint: o.value === current ? "当前" : o.value,
|
|
211
|
+
value: o.value,
|
|
212
|
+
keywords: o.value,
|
|
213
|
+
})),
|
|
214
|
+
onCancel: () => { app.overlay = null; app.redraw(); },
|
|
215
|
+
onPick: (it) => { app.overlay = null; app.redraw(); app.switchPermission(it.value); },
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---- Command palette ----
|
|
220
|
+
|
|
221
|
+
export function buildCommandPalette(app) {
|
|
222
|
+
const w = Math.min(70, app.screen.w - 4), h = Math.min(26, app.screen.h - 4);
|
|
223
|
+
const items = [
|
|
224
|
+
{ label: "新建会话", hint: "n", action: () => app.newSession(), keywords: "new session create" },
|
|
225
|
+
{ label: "打开会话…", hint: "o", action: () => app.openSessionPicker(), keywords: "open session" },
|
|
226
|
+
{ label: "搜索会话", hint: "/", action: () => app.startSearch(), keywords: "search find" },
|
|
227
|
+
{ label: "重命名当前会话", action: () => app.renameCurrent(), keywords: "rename title" },
|
|
228
|
+
{ label: "切换模型", hint: "m", action: () => { app.overlay = buildModelPicker(app); app.redraw(); }, keywords: "model provider llm" },
|
|
229
|
+
{ label: "模式(Agent 预设)", action: () => app.showModePicker(), keywords: "mode preset standard code minimal cordis" },
|
|
230
|
+
{ label: "权限(沙箱 + 审批)", action: () => app.showPermissionPicker(), keywords: "permission sandbox read-only write full access" },
|
|
231
|
+
{ label: "工作区文件", hint: "w", action: () => app.setMode("workspace"), keywords: "workspace files tree" },
|
|
232
|
+
{ label: "轨迹视图", hint: "t", action: () => app.setMode("trajectory"), keywords: "trajectory timeline trace" },
|
|
233
|
+
{ label: "任务列表", hint: "j", action: () => app.showJobs(), keywords: "jobs tasks" },
|
|
234
|
+
{ label: "目标状态", hint: "g", action: () => app.showGoal(), keywords: "goal objective" },
|
|
235
|
+
{ label: "刷新会话列表", action: () => app.refreshSessions(), keywords: "refresh reload" },
|
|
236
|
+
{ label: "切换主题", action: () => { cycleTheme(); app.toast(`主题: ${themeName()}`); }, keywords: "theme color" },
|
|
237
|
+
{ label: "复制当前会话 ID", action: () => app.copyText(app.currentSession ?? ""), keywords: "copy id" },
|
|
238
|
+
{ label: "退出", hint: "q", action: () => app.stop(), keywords: "quit exit" },
|
|
239
|
+
];
|
|
240
|
+
return new Picker({
|
|
241
|
+
x: Math.floor((app.screen.w - w) / 2), y: Math.floor((app.screen.h - h) / 2),
|
|
242
|
+
w, h, title: "命令", items,
|
|
243
|
+
onCancel: () => { app.overlay = null; app.redraw(); },
|
|
244
|
+
onPick: (it) => { app.overlay = null; it.action(); app.redraw(); },
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---- Workspace browser ----
|
|
249
|
+
|
|
250
|
+
export class WorkspacePanel extends Widget {
|
|
251
|
+
constructor(app) {
|
|
252
|
+
super({ x: 30, y: 0, w: app.screen.w - 30, h: app.screen.h - 1 });
|
|
253
|
+
this.app = app;
|
|
254
|
+
this.workspaces = [];
|
|
255
|
+
this.tree = []; // { depth, name, path, isDir, open, children? }
|
|
256
|
+
this.treeScroll = new ScrollView({ x: this.x + 1, y: this.y + 1, w: Math.floor(this.w / 2), h: this.h - 2, showScrollbar: true });
|
|
257
|
+
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 });
|
|
258
|
+
this.previewPath = null;
|
|
259
|
+
}
|
|
260
|
+
relayout(x, y, w, h) {
|
|
261
|
+
this.x = x; this.y = y; this.w = w; this.h = h;
|
|
262
|
+
const half = Math.floor(w / 2);
|
|
263
|
+
this.treeScroll.x = x + 1; this.treeScroll.y = y + 1; this.treeScroll.w = half; this.treeScroll.h = h - 2;
|
|
264
|
+
this.preview.x = x + half + 1; this.preview.y = y + 1; this.preview.w = w - half - 2; this.preview.h = h - 2;
|
|
265
|
+
}
|
|
266
|
+
async load() {
|
|
267
|
+
this.query = "";
|
|
268
|
+
this.searchSel = 0;
|
|
269
|
+
this.searchResults = [];
|
|
270
|
+
try {
|
|
271
|
+
const { items } = await this.app.api.call("workspace.list");
|
|
272
|
+
this.workspaces = items;
|
|
273
|
+
const tree = [];
|
|
274
|
+
for (const ws of items) {
|
|
275
|
+
tree.push({ depth: 0, name: `▣ ${ws.title}`, path: ws.path, isDir: true, open: false, ws: true });
|
|
276
|
+
}
|
|
277
|
+
this.tree = tree;
|
|
278
|
+
this.rebuildTree();
|
|
279
|
+
} catch (e) {
|
|
280
|
+
this.app.toast(`工作区加载失败: ${e.message}`);
|
|
281
|
+
this.app.setMode("chat");
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
expand(node) {
|
|
285
|
+
node.open = !node.open;
|
|
286
|
+
this.rebuildTree();
|
|
287
|
+
}
|
|
288
|
+
rebuildTree() {
|
|
289
|
+
const out = [];
|
|
290
|
+
const walk = (nodes) => {
|
|
291
|
+
for (const n of nodes) {
|
|
292
|
+
out.push(n);
|
|
293
|
+
if (n.isDir && n.open && n.children) walk(n.children);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
walk(this.tree);
|
|
297
|
+
this.treeLines = out.map((n) => {
|
|
298
|
+
const indent = " ".repeat(n.depth);
|
|
299
|
+
const icon = n.isDir ? (n.open ? "▾" : "▸") : "·";
|
|
300
|
+
const segs = [{ t: `${indent}${icon} ${n.name}`, fg: n.isDir ? K.ACCENT : K.TXT, bold: n.ws }];
|
|
301
|
+
return segs;
|
|
302
|
+
});
|
|
303
|
+
this.treeScroll.setLines(this.treeLines);
|
|
304
|
+
this.app.redraw();
|
|
305
|
+
}
|
|
306
|
+
async fillChildren(node) {
|
|
307
|
+
try {
|
|
308
|
+
const entries = readdirSync(node.path, { withFileTypes: true })
|
|
309
|
+
.filter((d) => !d.name.startsWith(".") && d.name !== "node_modules")
|
|
310
|
+
.sort((a, b) => (a.isDirectory() === b.isDirectory() ? a.name.localeCompare(b.name) : a.isDirectory() ? -1 : 1));
|
|
311
|
+
node.children = entries.map((d) => ({
|
|
312
|
+
depth: node.depth + 1,
|
|
313
|
+
name: d.name,
|
|
314
|
+
path: join(node.path, d.name),
|
|
315
|
+
isDir: d.isDirectory(),
|
|
316
|
+
open: false,
|
|
317
|
+
children: d.isDirectory() ? [] : null,
|
|
318
|
+
}));
|
|
319
|
+
} catch { node.children = []; }
|
|
320
|
+
}
|
|
321
|
+
onMouse(ev) {
|
|
322
|
+
if (ev.x >= this.x + 1 && ev.x < this.x + Math.floor(this.w / 2)) {
|
|
323
|
+
const idx = this.treeScroll.scrollY + (ev.y - this.treeScroll.y);
|
|
324
|
+
const node = this.treeLinesNode(idx);
|
|
325
|
+
if (node) {
|
|
326
|
+
if (ev.kind === "press" && ev.button === 0) {
|
|
327
|
+
if (node.isDir) {
|
|
328
|
+
if (!node.open && (!node.children || node.children.length === 0)) { this.fillChildren(node); }
|
|
329
|
+
this.expand(node);
|
|
330
|
+
} else if (!node.ws) this.previewFile(node.path);
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
if (ev.kind === "wheel-up" || ev.kind === "wheel-down") return this.treeScroll.onMouse(ev);
|
|
334
|
+
}
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
return this.preview.onMouse(ev);
|
|
338
|
+
}
|
|
339
|
+
treeLinesNode(idx) {
|
|
340
|
+
let i = 0;
|
|
341
|
+
const find = (nodes) => {
|
|
342
|
+
for (const n of nodes) {
|
|
343
|
+
if (i === idx) return n;
|
|
344
|
+
i++;
|
|
345
|
+
if (n.isDir && n.open && n.children) {
|
|
346
|
+
const r = find(n.children);
|
|
347
|
+
if (r) return r;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return null;
|
|
351
|
+
};
|
|
352
|
+
return find(this.tree);
|
|
353
|
+
}
|
|
354
|
+
previewFile(path) {
|
|
355
|
+
this.previewPath = path;
|
|
356
|
+
try {
|
|
357
|
+
const st = statSync(path);
|
|
358
|
+
if (st.size > 256 * 1024) {
|
|
359
|
+
this.preview.setLines([[{ t: `文件过大(${Math.round(st.size / 1024)}KB),仅预览前 256KB`, fg: K.WARN }]]);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const text = readFileSync(path, "utf8");
|
|
363
|
+
const lang = extname(path).slice(1);
|
|
364
|
+
const lines = [];
|
|
365
|
+
lines.push([{ t: basename(path), fg: K.ACCENT, bold: true, underline: true }]);
|
|
366
|
+
lines.push([{ t: "" }]);
|
|
367
|
+
const codeLines = text.split("\n").slice(0, 300);
|
|
368
|
+
let inFence = false;
|
|
369
|
+
for (const cl of codeLines) {
|
|
370
|
+
if (cl.trim().startsWith("```")) { inFence = !inFence; lines.push([{ t: cl, fg: K.FAINT }]); continue; }
|
|
371
|
+
if (inFence) lines.push([{ t: truncate(cl, this.preview.w - 2), fg: K.DIM, code: true }]);
|
|
372
|
+
else lines.push([{ t: truncate(cl, this.preview.w - 2), fg: K.TXT }]);
|
|
373
|
+
}
|
|
374
|
+
this.preview.setLines(lines);
|
|
375
|
+
this.app.redraw();
|
|
376
|
+
} catch (e) {
|
|
377
|
+
this.preview.setLines([[{ t: `读取失败: ${e.message}`, fg: K.ERR }]]);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
render(screen) {
|
|
381
|
+
screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", {});
|
|
382
|
+
const mid = this.x + Math.floor(this.w / 2);
|
|
383
|
+
screen.put(mid, this.y, "┬", { fg: T.BORDER });
|
|
384
|
+
screen.vline(mid, this.y + 1, this.y + this.h - 1);
|
|
385
|
+
screen.text(this.x + 1, this.y, ` 工作区 (${this.workspaces.length}) — 点击目录展开,/ 搜索文件`, { fg: K.DIM });
|
|
386
|
+
if (this.query) {
|
|
387
|
+
const results = [];
|
|
388
|
+
const walk = (nodes) => {
|
|
389
|
+
for (const n of nodes) {
|
|
390
|
+
if (!n.ws && !n.isDir && n.name.toLowerCase().includes(this.query.toLowerCase())) results.push(n.path);
|
|
391
|
+
if (n.children) walk(n.children);
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
walk(this.tree);
|
|
395
|
+
this.searchResults = results;
|
|
396
|
+
for (let i = 0; i < Math.min(this.h - 2, results.length); i++) {
|
|
397
|
+
const sel = i === this.searchSel;
|
|
398
|
+
screen.fillRect(this.x + 1, this.y + 2 + i, mid - 2, this.y + 2 + i, " ", { bg: sel ? K.MENUSEL : -1 });
|
|
399
|
+
screen.text(this.x + 2, this.y + 2 + i, truncate("⚲ " + basename(results[i]), mid - 6), { fg: sel ? K.BOLD : K.TXT, bg: sel ? K.MENUSEL : -1 });
|
|
400
|
+
}
|
|
401
|
+
screen.text(this.x + 1, this.y + this.h - 1, ` 匹配 ${results.length} 个文件 · Esc 退出搜索`, { fg: K.FAINT });
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
this.treeScroll.render(screen);
|
|
405
|
+
this.preview.render(screen);
|
|
406
|
+
}
|
|
407
|
+
onKey(ev) {
|
|
408
|
+
if (ev.type === "text") { this.query += ev.text; this.searchSel = 0; this.app.redraw(); return true; }
|
|
409
|
+
if (ev.type !== "key") return false;
|
|
410
|
+
if (ev.name === "escape") {
|
|
411
|
+
if (this.query) { this.query = ""; this.app.redraw(); return true; }
|
|
412
|
+
this.app.setMode("chat");
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
415
|
+
if (ev.name === "backspace") { this.query = this.query.slice(0, -1); this.app.redraw(); return true; }
|
|
416
|
+
if (ev.name === "down" && this.query) { this.searchSel = Math.min((this.searchResults?.length ?? 1) - 1, (this.searchSel ?? 0) + 1); this.app.redraw(); return true; }
|
|
417
|
+
if (ev.name === "up" && this.query) { this.searchSel = Math.max(0, (this.searchSel ?? 0) - 1); this.app.redraw(); return true; }
|
|
418
|
+
if (ev.name === "enter" && this.query && this.searchResults?.length) { this.previewFile(this.searchResults[this.searchSel ?? 0]); return true; }
|
|
419
|
+
if (ev.name === "up" || ev.name === "down" || ev.name === "pgup" || ev.name === "pgdn") return this.treeScroll.onKey?.(ev) ?? false;
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ---- Trajectory view ----
|
|
425
|
+
|
|
426
|
+
export class TrajectoryPanel extends Widget {
|
|
427
|
+
constructor(app) {
|
|
428
|
+
super({ x: 30, y: 1, w: app.screen.w - 30, h: app.screen.h - 2 });
|
|
429
|
+
this.app = app;
|
|
430
|
+
this.steps = [];
|
|
431
|
+
this.stats = null;
|
|
432
|
+
this.loading = false;
|
|
433
|
+
this.loadingOlder = false;
|
|
434
|
+
this.hasMore = false;
|
|
435
|
+
this.minSeq = null;
|
|
436
|
+
this.allEvents = [];
|
|
437
|
+
this.sessionId = null;
|
|
438
|
+
this.view = new ScrollView({ x: this.x, y: this.y, w: this.w, h: this.h, showScrollbar: true, onClick: (y) => this.#clickLine(y) });
|
|
439
|
+
this.stepLines = [];
|
|
440
|
+
this.query = "";
|
|
441
|
+
}
|
|
442
|
+
#clickLine(lineIdx) {
|
|
443
|
+
if (this.hasMore && lineIdx === 1) { this.loadOlder(); return true; }
|
|
444
|
+
const si = this.stepLines[lineIdx];
|
|
445
|
+
if (si === undefined) return false;
|
|
446
|
+
const step = this.steps[si];
|
|
447
|
+
if (!step) return false;
|
|
448
|
+
const t0 = step.events[0]?.time, t1 = step.events[step.events.length - 1]?.time;
|
|
449
|
+
const items = step.events.map((e, i) => {
|
|
450
|
+
const d = e.data ?? {};
|
|
451
|
+
let summary = e.type;
|
|
452
|
+
if (e.type === "user/message") summary = `❯ ${String(d.content?.[0]?.text ?? "").slice(0, 40)}`;
|
|
453
|
+
else if (e.type === "tool/call") summary = `⚙ ${d.name ?? "tool"} ${String(d.arguments ?? "").slice(0, 30)}`;
|
|
454
|
+
else if (e.type === "tool/result") summary = "↳ 结果";
|
|
455
|
+
else if (e.type === "assistant/message") summary = `◉ ${String(d.message?.content?.find((c) => c.type === "text")?.text ?? "").slice(0, 40)}`;
|
|
456
|
+
else if (e.type === "assistant/chunk") {
|
|
457
|
+
const ch = d.chunk ?? {};
|
|
458
|
+
summary = ch.type === "text-delta" ? String(ch.delta ?? "").slice(0, 40) : `[${ch.blockType ?? ch.type}]`;
|
|
459
|
+
}
|
|
460
|
+
return { label: truncate(summary, 60), hint: `#${e.seq}`, idx: i, event: e };
|
|
461
|
+
});
|
|
462
|
+
if (t0 && t1) items.unshift({ label: `⏱ 步骤 ${step.step} · ${step.events.length} 事件 · ${fmtMs(t1 - t0)}`, hint: "", idx: -1, event: null });
|
|
463
|
+
const w = Math.min(78, this.app.screen.w - 8), h = Math.min(22, this.app.screen.h - 4);
|
|
464
|
+
this.app.overlay = new Picker({
|
|
465
|
+
x: Math.floor((this.app.screen.w - w) / 2), y: Math.floor((this.app.screen.h - h) / 2),
|
|
466
|
+
w, h, title: "轨迹详情", items,
|
|
467
|
+
onCancel: () => this.app.closeOverlay(),
|
|
468
|
+
onPick: (it) => {
|
|
469
|
+
if (!it.event) { this.app.closeOverlay(); return; }
|
|
470
|
+
const e = it.event;
|
|
471
|
+
const d = e.data ?? {};
|
|
472
|
+
let body = "";
|
|
473
|
+
if (e.type === "tool/result") body = String(d.message?.content?.[0]?.content?.map((c) => c.text ?? "").join("\n") ?? JSON.stringify(d));
|
|
474
|
+
else if (e.type === "tool/call") body = String(d.arguments ?? "");
|
|
475
|
+
else if (e.type === "assistant/message") body = String(d.message?.content?.map((c) => c.text ?? "").join("\n") ?? "");
|
|
476
|
+
else body = JSON.stringify(d, null, 2);
|
|
477
|
+
const lines = body.split("\n").slice(0, 40).map((l) => [{ t: truncate(l, w - 6), fg: K.TXT }]);
|
|
478
|
+
// centered detail popup (never the stale top-left corner)
|
|
479
|
+
const pw = Math.min(80, this.app.screen.w - 4);
|
|
480
|
+
const ph = Math.min(lines.length + 5, 24);
|
|
481
|
+
this.app.overlay = new Popup({
|
|
482
|
+
x: Math.floor((this.app.screen.w - pw) / 2), y: Math.floor((this.app.screen.h - ph) / 2),
|
|
483
|
+
w: pw, h: ph, title: `#${e.seq} ${e.type}`,
|
|
484
|
+
lines: [[{ t: "" }], ...lines], buttons: [{ label: "关闭", action: "close" }],
|
|
485
|
+
onAction: () => this.app.closeOverlay(),
|
|
486
|
+
});
|
|
487
|
+
this.app.redraw();
|
|
488
|
+
},
|
|
489
|
+
});
|
|
490
|
+
this.app.redraw();
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
relayout(x, y, w, h) {
|
|
494
|
+
this.x = x; this.y = y; this.w = w; this.h = h;
|
|
495
|
+
this.view.x = x; this.view.y = y; this.view.w = w; this.view.h = h;
|
|
496
|
+
this.buildLines();
|
|
497
|
+
}
|
|
498
|
+
async load(sessionId) {
|
|
499
|
+
this.sessionId = sessionId;
|
|
500
|
+
this.loading = true;
|
|
501
|
+
this.steps = [];
|
|
502
|
+
this.allEvents = [];
|
|
503
|
+
this.stats = null;
|
|
504
|
+
this.hasMore = false;
|
|
505
|
+
this.minSeq = null;
|
|
506
|
+
this.app.setStatus("加载轨迹…");
|
|
507
|
+
try {
|
|
508
|
+
// One bounded call for the recent steps (maxMessages = model messages =
|
|
509
|
+
// steps). Instant, and older steps load on demand via PgUp/click.
|
|
510
|
+
const h = await this.app.api.call("session.history", { sessionId, maxMessages: 40 });
|
|
511
|
+
this.stats = h.projections?.values?.sessionStats ?? null;
|
|
512
|
+
this.minSeq = h.events[0]?.event?.seq ?? null;
|
|
513
|
+
this.hasMore = h.hasMore;
|
|
514
|
+
this.allEvents = h.events;
|
|
515
|
+
this.build();
|
|
516
|
+
} catch (e) { this.app.toast(`轨迹加载失败: ${e.message}`); }
|
|
517
|
+
this.loading = false;
|
|
518
|
+
this.app.setStatus("");
|
|
519
|
+
this.buildLines();
|
|
520
|
+
this.app.redraw();
|
|
521
|
+
}
|
|
522
|
+
async loadOlder() {
|
|
523
|
+
if (!this.hasMore || this.loadingOlder || this.minSeq == null) return;
|
|
524
|
+
this.loadingOlder = true;
|
|
525
|
+
this.app.setStatus("加载更早轨迹…");
|
|
526
|
+
try {
|
|
527
|
+
const h = await this.app.api.call("session.history", { sessionId: this.sessionId, beforeSeq: this.minSeq, maxMessages: 40 });
|
|
528
|
+
if (h.events.length === 0) { this.hasMore = false; }
|
|
529
|
+
else {
|
|
530
|
+
this.minSeq = h.events[0]?.event?.seq ?? this.minSeq;
|
|
531
|
+
this.hasMore = h.hasMore;
|
|
532
|
+
this.allEvents = [...h.events, ...this.allEvents].sort((a, b) => a.event.seq - b.event.seq);
|
|
533
|
+
this.build();
|
|
534
|
+
}
|
|
535
|
+
} catch (e) { this.app.toast(`加载更早失败: ${e.message}`); }
|
|
536
|
+
this.loadingOlder = false;
|
|
537
|
+
this.app.setStatus("");
|
|
538
|
+
this.buildLines();
|
|
539
|
+
this.app.redraw();
|
|
540
|
+
}
|
|
541
|
+
build() {
|
|
542
|
+
// Segment on step/start: each model message is one step (the web view's
|
|
543
|
+
// trajectory node), which is what maxMessages actually pages over.
|
|
544
|
+
const steps = [];
|
|
545
|
+
let cur = null;
|
|
546
|
+
for (const { event } of this.allEvents) {
|
|
547
|
+
const d = event.data ?? {};
|
|
548
|
+
if (event.type === "step/start" || event.type === "turn/start") {
|
|
549
|
+
if (cur && cur.events.length) steps.push(cur);
|
|
550
|
+
cur = { events: [], step: d.step ?? steps.length + 1, turn: d.turn };
|
|
551
|
+
}
|
|
552
|
+
if (cur) cur.events.push(event);
|
|
553
|
+
}
|
|
554
|
+
if (cur && cur.events.length) steps.push(cur);
|
|
555
|
+
this.steps = steps.slice(-600);
|
|
556
|
+
}
|
|
557
|
+
buildLines() {
|
|
558
|
+
const w = Math.max(40, this.w - 2);
|
|
559
|
+
const lines = [];
|
|
560
|
+
lines.push([{ t: "轨迹 — 步骤时间轴(每行一个色块,点击查看详情)", fg: K.ACCENT, bold: true }]);
|
|
561
|
+
if (this.hasMore) lines.push([{ t: "▲ 更早步骤(点击 / PgUp 加载)", fg: K.FAINT }]);
|
|
562
|
+
else lines.push([{ t: "" }]);
|
|
563
|
+
const st = this.stats;
|
|
564
|
+
if (st) {
|
|
565
|
+
lines.push([{ t: `回合 ${st.turns} · 步骤 ${st.steps} · LLM ${fmtMs(st.llmMs)} · 工具 ${fmtMs(st.toolMs)}`, fg: K.DIM }]);
|
|
566
|
+
}
|
|
567
|
+
lines.push([{ t: `步骤(已加载 ${this.steps.length}${this.hasMore ? "+" : ""}):`, fg: K.DIM, underline: true }]);
|
|
568
|
+
this.stepLines = [];
|
|
569
|
+
const list = this.query
|
|
570
|
+
? this.steps.filter((t) => t.events.some((e) => {
|
|
571
|
+
const d = e.data ?? {};
|
|
572
|
+
const hay = `${e.type} ${d.name ?? ""} ${typeof d.content === "string" ? d.content : ""}`.toLowerCase();
|
|
573
|
+
return hay.includes(this.query.toLowerCase());
|
|
574
|
+
}))
|
|
575
|
+
: this.steps;
|
|
576
|
+
for (const step of list.slice(-40).reverse()) {
|
|
577
|
+
const si = this.steps.indexOf(step);
|
|
578
|
+
const tools = [...new Set(step.events.filter((e) => e.type === "tool/call").map((e) => e.data?.name))];
|
|
579
|
+
const hasResult = step.events.some((e) => e.type === "tool/result");
|
|
580
|
+
const hasReasoning = step.events.some((e) => e.type === "assistant/chunk" && e.data?.chunk?.blockType === "reasoning");
|
|
581
|
+
const t0 = step.events[0]?.time, t1 = step.events[step.events.length - 1]?.time;
|
|
582
|
+
const dur = t0 && t1 ? fmtMs(t1 - t0) : "—";
|
|
583
|
+
const bg = tools.length ? (hasResult ? T.TOOLOK : T.TOOLBG) : hasReasoning ? T.THINKBG : T.CARD;
|
|
584
|
+
const summary = tools.slice(0, 3).join(",") || (hasReasoning ? "模型推理" : "纯文本");
|
|
585
|
+
const label = ` step ${String(step.step).padStart(3)} ${pad(dur, 8)} ${summary}`;
|
|
586
|
+
const segs = [{ t: label, fg: K.TXT, bg, bold: true }];
|
|
587
|
+
const fill = w - strWidth(label);
|
|
588
|
+
if (fill > 0) segs.push({ t: " ".repeat(fill), bg });
|
|
589
|
+
lines.push(segs);
|
|
590
|
+
this.stepLines[lines.length - 1] = si;
|
|
591
|
+
}
|
|
592
|
+
this.view.setLines(lines);
|
|
593
|
+
}
|
|
594
|
+
render(screen) {
|
|
595
|
+
screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", {});
|
|
596
|
+
if (this.loading && this.steps.length === 0) {
|
|
597
|
+
screen.text(this.x + 2, this.y + 1, "加载轨迹…", { fg: K.FAINT });
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
this.view.render(screen);
|
|
601
|
+
}
|
|
602
|
+
onMouse(ev) { return this.view.onMouse(ev); }
|
|
603
|
+
onKey(ev) {
|
|
604
|
+
if (ev.type === "text") { this.query += ev.text; this.buildLines(); this.app.redraw(); return true; }
|
|
605
|
+
if (ev.type !== "key") return false;
|
|
606
|
+
if (ev.name === "escape") {
|
|
607
|
+
if (this.query) { this.query = ""; this.buildLines(); this.app.redraw(); return true; }
|
|
608
|
+
this.app.setMode("chat");
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
if (ev.name === "backspace") { this.query = this.query.slice(0, -1); this.buildLines(); this.app.redraw(); return true; }
|
|
612
|
+
if (ev.name === "pgup") { if (this.view.scrollY === 0 && this.hasMore) { this.loadOlder(); return true; } return this.view.scroll(-this.view.h); }
|
|
613
|
+
if (ev.name === "pgdn" || ev.name === "up" || ev.name === "down") return this.view.onKey(ev);
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function fmtMs(ms) {
|
|
619
|
+
if (ms == null || isNaN(ms)) return "—";
|
|
620
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
621
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
622
|
+
return `${(ms / 60000).toFixed(1)}m`;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// ---- Terminal image viewer (kitty graphics / external viewer / chafa) ----
|
|
626
|
+
|
|
627
|
+
export function kittyCapable(env = process.env) {
|
|
628
|
+
if (env.KITTY_WINDOW_ID || env.TERM_PROGRAM === "WezTerm" || env.TERM_PROGRAM === "foot" || env.TERM === "xterm-kitty") return true;
|
|
629
|
+
if (env.DSH_TUI_NO_KITTY) return false;
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
export class ImagePopup extends Popup {
|
|
634
|
+
constructor({ app, ref, sessionId, refs = null, index = 0 }) {
|
|
635
|
+
const w = Math.min(80, app.screen.w - 4), h = Math.min(24, app.screen.h - 4);
|
|
636
|
+
super({
|
|
637
|
+
x: Math.floor((app.screen.w - w) / 2), y: Math.floor((app.screen.h - h) / 2),
|
|
638
|
+
w, h, title: `🖼 ${truncate(ref?.name ?? "image", 50)}`,
|
|
639
|
+
lines: [[{ t: "加载中…", fg: K.DIM }]],
|
|
640
|
+
buttons: [
|
|
641
|
+
{ label: "打开查看器", action: "viewer" },
|
|
642
|
+
{ label: "关闭", action: "close" },
|
|
643
|
+
],
|
|
644
|
+
onAction: (btn) => {
|
|
645
|
+
if (btn.action === "close" || btn.action === "__cancel__") app.closeOverlay();
|
|
646
|
+
else if (btn.action === "viewer") this.openExternal();
|
|
647
|
+
},
|
|
648
|
+
});
|
|
649
|
+
this.app = app;
|
|
650
|
+
this.refs = (refs && refs.length > 0) ? refs : [ref];
|
|
651
|
+
this.index = Math.min(index, this.refs.length - 1);
|
|
652
|
+
this.ref = this.refs[this.index];
|
|
653
|
+
this.sessionId = sessionId;
|
|
654
|
+
this.data = null;
|
|
655
|
+
this.imageKey = "";
|
|
656
|
+
this.load();
|
|
657
|
+
}
|
|
658
|
+
#show(idx) {
|
|
659
|
+
this.index = (idx + this.refs.length) % this.refs.length;
|
|
660
|
+
this.ref = this.refs[this.index];
|
|
661
|
+
this.data = null;
|
|
662
|
+
this.chafaTmp = null;
|
|
663
|
+
this.lines = [[{ t: "加载中…", fg: K.DIM }]];
|
|
664
|
+
this.app.redraw();
|
|
665
|
+
this.load();
|
|
666
|
+
}
|
|
667
|
+
galleryTitle() {
|
|
668
|
+
const nm = this.ref?.name ?? "image";
|
|
669
|
+
const dims = this.ref?.width ? ` · ${this.ref.width}×${this.ref.height}` : "";
|
|
670
|
+
return `🖼 ${truncate(nm, 40)}${this.refs.length > 1 ? ` (${this.index + 1}/${this.refs.length})` : ""}${dims}`;
|
|
671
|
+
}
|
|
672
|
+
onKey(ev) {
|
|
673
|
+
if (ev.type === "key" && ev.name === "left" && this.refs.length > 1) { this.#show(this.index - 1); return true; }
|
|
674
|
+
if (ev.type === "key" && ev.name === "right" && this.refs.length > 1) { this.#show(this.index + 1); return true; }
|
|
675
|
+
return super.onKey(ev);
|
|
676
|
+
}
|
|
677
|
+
async load() {
|
|
678
|
+
try {
|
|
679
|
+
if (!this.sessionId || !this.ref?.attachmentId) throw new Error("无附件引用");
|
|
680
|
+
const res = await this.app.api.call("session.attachment", { sessionId: this.sessionId, attachmentId: this.ref.attachmentId });
|
|
681
|
+
this.data = Buffer.from(res.data ?? "", "base64");
|
|
682
|
+
this.title = this.galleryTitle();
|
|
683
|
+
this.lines = [[{ t: `${res.attachment.mediaType} · ${res.attachment.width}×${res.attachment.height} · ${Math.round(this.data.length / 1024)}KB`, fg: K.DIM }]];
|
|
684
|
+
if (this.refs.length > 1) this.lines.push([{ t: "←/→ 切换图片", fg: K.FAINT }]);
|
|
685
|
+
this.renderImage();
|
|
686
|
+
} catch (e) {
|
|
687
|
+
this.lines = [[{ t: `加载失败: ${e.message}`, fg: K.ERR }]];
|
|
688
|
+
}
|
|
689
|
+
this.app.redraw();
|
|
690
|
+
}
|
|
691
|
+
renderImage() {
|
|
692
|
+
if (kittyCapable()) {
|
|
693
|
+
this.kittyLines = 0;
|
|
694
|
+
this.kittyCols = 0;
|
|
695
|
+
// mark: kitty transmission happens in App after frame render (raster overlay)
|
|
696
|
+
this.imageKey = `${this.data.length}:${Date.now()}`;
|
|
697
|
+
this.app.toast("kitty 图形协议显示");
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
// non-kitty: try chafa for an in-terminal preview
|
|
701
|
+
if (this.tryChafa()) return;
|
|
702
|
+
this.lines = [
|
|
703
|
+
[{ t: "终端不支持图形协议;使用「打开查看器」按钮,或安装 chafa 获得字符预览", fg: K.DIM }],
|
|
704
|
+
];
|
|
705
|
+
}
|
|
706
|
+
tryChafa() {
|
|
707
|
+
try {
|
|
708
|
+
const tmp = join(tmpdir(), `dsh-tui-${Date.now()}.${extname(this.ref?.name ?? "img") || "png"}`);
|
|
709
|
+
writeFileSync(tmp, this.data);
|
|
710
|
+
const out = spawnSyncSafe("chafa", ["--format", "symbols", "--size", `${Math.min(70, this.w - 6)}x${Math.max(4, this.h - 6)}`, tmp], 4000);
|
|
711
|
+
if (out) {
|
|
712
|
+
this.lines = out.split("\n").map((l) => [{ t: truncate(l, this.w - 4), fg: K.TXT }]);
|
|
713
|
+
this.chafaTmp = tmp;
|
|
714
|
+
return true;
|
|
715
|
+
}
|
|
716
|
+
} catch {}
|
|
717
|
+
return false;
|
|
718
|
+
}
|
|
719
|
+
openExternal() {
|
|
720
|
+
try {
|
|
721
|
+
const ext = extname(this.ref?.name ?? "img") || ".png";
|
|
722
|
+
const tmp = join(tmpdir(), `dsh-tui-${Date.now()}${ext}`);
|
|
723
|
+
writeFileSync(tmp, this.data ?? Buffer.alloc(0));
|
|
724
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
725
|
+
const args = process.platform === "win32" ? ["/c", "start", "", tmp] : [tmp];
|
|
726
|
+
spawn(cmd, args, { detached: true, stdio: "ignore" }).unref();
|
|
727
|
+
this.app.toast(`已在查看器中打开: ${tmp}`);
|
|
728
|
+
} catch (e) {
|
|
729
|
+
this.app.toast(`打开失败: ${e.message}`);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
kittyTransmit() {
|
|
733
|
+
// kitty graphics protocol: transmit + place. Returns ANSI or "".
|
|
734
|
+
if (!this.data || !kittyCapable()) return "";
|
|
735
|
+
const w = Math.min(70, this.w - 4), h = Math.max(4, this.h - 5);
|
|
736
|
+
const b64 = this.data.toString("base64");
|
|
737
|
+
const chunks = [];
|
|
738
|
+
for (let i = 0; i < b64.length; i += 4096) chunks.push(b64.slice(i, i + 4096));
|
|
739
|
+
const payload = chunks.map((c, i) => `\x1b_Ga=${i === 0 ? "T" : "f"},m=${i === chunks.length - 1 ? 0 : 1};${c}\x1b\\`).join("");
|
|
740
|
+
// place at popup position with column/row fit
|
|
741
|
+
const place = `\x1b_Ga=p,s=${w},v=${h},c=${w},r=${h},q=2;${this.imageKey}\x1b\\`;
|
|
742
|
+
return payload + place;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function spawnSyncSafe(cmd, args, timeoutMs) {
|
|
747
|
+
try {
|
|
748
|
+
return execFileSync(cmd, args, { timeout: timeoutMs, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
749
|
+
} catch { return null; }
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// ---- ControlPanel: leader panel (快捷键 / 命令 / 设置,Tab 翻页;设置内 Shift+Tab 次级翻页) ----
|
|
753
|
+
|
|
754
|
+
const DEFAULT_COMMANDS = [
|
|
755
|
+
{ name: "compact", description: "Compact older conversation history", input: { hint: "" } },
|
|
756
|
+
{ name: "export", description: "Download this Session log as a ZIP archive", input: { hint: "" } },
|
|
757
|
+
{ name: "feedback", description: "record feedback about this session", input: { hint: "<text>" } },
|
|
758
|
+
{ name: "goal", description: "set or view the goal for a long-running task", input: { hint: "[<objective>|clear|edit <objective>|pause|resume]" } },
|
|
759
|
+
{ name: "permission", description: "Switch the permission preset (sandbox mode + approval policy)", input: { hint: "<preset>" } },
|
|
760
|
+
{ name: "plan", description: "Enter or leave plan mode", input: { hint: "[off|message]" } },
|
|
761
|
+
];
|
|
762
|
+
|
|
763
|
+
export class ControlPanel extends Widget {
|
|
764
|
+
constructor(app, { startPage = 0 } = {}) {
|
|
765
|
+
const w = Math.min(74, app.screen.w - 4);
|
|
766
|
+
const h = Math.min(24, app.screen.h - 4);
|
|
767
|
+
super({ x: Math.floor((app.screen.w - w) / 2), y: Math.floor((app.screen.h - h) / 2), w, h });
|
|
768
|
+
this.app = app;
|
|
769
|
+
this.pages = ["快捷键", "命令", "设置"];
|
|
770
|
+
this.page = startPage;
|
|
771
|
+
this.subPages = ["常规", "插件"];
|
|
772
|
+
this.subPage = 0;
|
|
773
|
+
this.sel = 0;
|
|
774
|
+
this.commands = DEFAULT_COMMANDS;
|
|
775
|
+
this.plugins = null;
|
|
776
|
+
this.pluginError = null;
|
|
777
|
+
this.loadCommands();
|
|
778
|
+
this.loadPlugins();
|
|
779
|
+
}
|
|
780
|
+
async loadCommands() {
|
|
781
|
+
try {
|
|
782
|
+
const agentId = this.app.currentSession;
|
|
783
|
+
if (agentId) {
|
|
784
|
+
const cmds = await this.app.api.rpcCall("commands/list", { agentId });
|
|
785
|
+
if (Array.isArray(cmds) && cmds.length) this.commands = cmds;
|
|
786
|
+
}
|
|
787
|
+
} catch {}
|
|
788
|
+
this.app.redraw();
|
|
789
|
+
}
|
|
790
|
+
async loadPlugins() {
|
|
791
|
+
try {
|
|
792
|
+
const res = await this.app.api.rpcCall("pluginInventory/list", {});
|
|
793
|
+
this.plugins = res.entries ?? [];
|
|
794
|
+
} catch (e) { this.pluginError = e.message; }
|
|
795
|
+
this.app.redraw();
|
|
796
|
+
}
|
|
797
|
+
shortcutItems() {
|
|
798
|
+
return [
|
|
799
|
+
["t", "思考块 展开/折叠", () => this.app.chat.onKey({ type: "key", name: "char", key: "t", text: "t", ctrl: false, alt: false, shift: false })],
|
|
800
|
+
["b", "工具块 展开/折叠", () => this.app.chat.onKey({ type: "key", name: "char", key: "b", text: "b", ctrl: false, alt: false, shift: false })],
|
|
801
|
+
["i", "进入输入", () => this.app.focus(this.app.chat.input)],
|
|
802
|
+
["Esc", "退出输入", () => { this.app.closeOverlay(); this.app.focus(this.app.chat); }],
|
|
803
|
+
["/", "搜索会话", () => { this.app.closeOverlay(); this.app.startSearch(); }],
|
|
804
|
+
["n", "新建会话", () => this.app.newSession()],
|
|
805
|
+
["g g", "滚动到顶", () => { this.app.closeOverlay(); this.app.chat.view.scrollY = 0; }],
|
|
806
|
+
["G", "滚动到底", () => { this.app.closeOverlay(); this.app.chat.view.scrollY = this.app.chat.view.maxScroll(); }],
|
|
807
|
+
["Ctrl+P", "控制面板", () => { this.page = 1; this.sel = 0; this.app.redraw(); }],
|
|
808
|
+
["Ctrl+M", "切换模型", () => { this.app.overlay = buildModelPicker(this.app); }],
|
|
809
|
+
["Ctrl+T", "轨迹视图", () => { this.app.closeOverlay(); this.app.setMode("trajectory"); }],
|
|
810
|
+
["Shift+Tab", "主页 对话/轨迹 切换", () => { this.app.closeOverlay(); this.app.toggleChatTrajectory(); }],
|
|
811
|
+
["Ctrl+W", "工作区", () => { this.app.closeOverlay(); this.app.setMode("workspace"); }],
|
|
812
|
+
["Ctrl+S", "设置", () => { this.app.closeOverlay(); this.app.setMode("settings"); }],
|
|
813
|
+
["Ctrl+A", "子代理", () => { this.app.closeOverlay(); this.app.setMode("subagent"); }],
|
|
814
|
+
["Ctrl+K", "技能", () => { this.app.closeOverlay(); this.app.setMode("skills"); }],
|
|
815
|
+
["Ctrl+G", "目标", () => this.app.showGoal()],
|
|
816
|
+
["Ctrl+J", "任务", () => this.app.showJobs()],
|
|
817
|
+
["Ctrl+B", "侧栏 显示/隐藏", () => this.app.toggleSidebar()],
|
|
818
|
+
["Ctrl+Q", "退出", () => this.app.stop()],
|
|
819
|
+
];
|
|
820
|
+
}
|
|
821
|
+
items() {
|
|
822
|
+
if (this.page === 0) return this.shortcutItems();
|
|
823
|
+
if (this.page === 1) {
|
|
824
|
+
return this.commands.map((c) => [
|
|
825
|
+
`/${c.name}${c.input?.hint ? " " + c.input.hint : ""}`,
|
|
826
|
+
c.description,
|
|
827
|
+
() => {
|
|
828
|
+
this.app.closeOverlay();
|
|
829
|
+
this.app.focus(this.app.chat.input);
|
|
830
|
+
this.app.chat.input.setValue(`/${c.name} `);
|
|
831
|
+
this.app.redraw();
|
|
832
|
+
},
|
|
833
|
+
]);
|
|
834
|
+
}
|
|
835
|
+
// 设置 page with sub-pages
|
|
836
|
+
if (this.subPage === 0) {
|
|
837
|
+
return [
|
|
838
|
+
["模型管理(含思考强度)", "切换模型并选择思考强度", () => { this.app.overlay = buildModelPicker(this.app); }],
|
|
839
|
+
["模式(Agent 预设)", "标准 / PTC / 极简 / 创造", () => { this.app.overlay = buildModePicker(this.app); this.app.redraw(); }],
|
|
840
|
+
["权限(沙箱 + 审批)", "只读 / 工作区写入 / 完全访问", () => { this.app.overlay = buildPermissionPicker(this.app); this.app.redraw(); }],
|
|
841
|
+
["完整设置(JSON 编辑器)", "所有命名空间的原始值", () => { this.app.closeOverlay(); this.app.setMode("settings"); }],
|
|
842
|
+
["切换主题", "dark / light / gruvbox", () => { cycleTheme(); this.app.toast(`主题: ${themeName()}`); }],
|
|
843
|
+
["侧栏显示/隐藏", "nvim 式整体收起", () => this.app.toggleSidebar()],
|
|
844
|
+
["导出当前会话日志", "下载 ZIP", () => { const sess = this.app.sessions.find((x) => x.sessionId === this.app.currentSession); if (sess) { this.app.closeOverlay(); this.app.exportSession(sess); } }],
|
|
845
|
+
["复制会话 ID", "", () => this.app.copyText(this.app.currentSession ?? "")],
|
|
846
|
+
];
|
|
847
|
+
}
|
|
848
|
+
if (this.plugins) {
|
|
849
|
+
return this.plugins.map((pl) => [`${pl.enabled ? "●" : "○"} ${pl.moduleName}`, pl.fiberPhase ?? "", null]);
|
|
850
|
+
}
|
|
851
|
+
return [[this.pluginError ?? "插件清单加载中…", "", null]];
|
|
852
|
+
}
|
|
853
|
+
render(screen) {
|
|
854
|
+
const s = screen;
|
|
855
|
+
s.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", { bg: T.PANEL });
|
|
856
|
+
s.box(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, { fg: T.ACCENT, bg: T.PANEL }, " 控制面板");
|
|
857
|
+
let tx = this.x + 2;
|
|
858
|
+
this.pages.forEach((name, i) => {
|
|
859
|
+
const sel = i === this.page;
|
|
860
|
+
s.text(tx, this.y, ` ${name} `, { fg: sel ? T.SELFG : T.DIM, bg: sel ? T.ACCENT : -1, attrs: sel ? 1 : 0 });
|
|
861
|
+
tx += strWidth(` ${name} `);
|
|
862
|
+
});
|
|
863
|
+
// sub-page tabs when on 设置
|
|
864
|
+
if (this.page === 2) {
|
|
865
|
+
let sx = this.x + 2 + strWidth(" 快捷键 命令 设置 ");
|
|
866
|
+
this.subPages.forEach((name, i) => {
|
|
867
|
+
const sel = i === this.subPage;
|
|
868
|
+
s.text(sx, this.y, ` ${name} `, { fg: sel ? T.BOLD : T.FAINT, bg: sel ? T.MENUSEL : -1, attrs: sel ? 1 : 0 });
|
|
869
|
+
sx += strWidth(` ${name} `);
|
|
870
|
+
});
|
|
871
|
+
s.text(this.x + this.w - 24, this.y, "Shift+Tab 次级", { fg: T.FAINT });
|
|
872
|
+
} else {
|
|
873
|
+
s.text(this.x + this.w - 12, this.y, "Tab 翻页", { fg: T.FAINT });
|
|
874
|
+
}
|
|
875
|
+
const items = this.items();
|
|
876
|
+
if (this.sel >= items.length) this.sel = Math.max(0, items.length - 1);
|
|
877
|
+
for (let i = 0; i < Math.min(this.h - 3, items.length); i++) {
|
|
878
|
+
const it = items[i];
|
|
879
|
+
const sel = i === this.sel;
|
|
880
|
+
s.fillRect(this.x + 1, this.y + 2 + i, this.x + this.w - 2, this.y + 2 + i, " ", { bg: sel ? T.MENUSEL : T.PANEL });
|
|
881
|
+
const label = it[0];
|
|
882
|
+
s.text(this.x + 2, this.y + 2 + i, truncate(label, this.w - 34), { fg: sel ? T.BOLD : (this.page === 0 ? T.ACCENT : T.TXT), bg: sel ? T.MENUSEL : T.PANEL, attrs: sel ? 1 : 0 });
|
|
883
|
+
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 });
|
|
884
|
+
}
|
|
885
|
+
s.text(this.x + 2, this.y + this.h - 1, "↑↓ 选择 · Enter 执行 · Esc 关闭", { fg: T.FAINT });
|
|
886
|
+
}
|
|
887
|
+
onKey(ev) {
|
|
888
|
+
if (ev.type !== "key") return false;
|
|
889
|
+
if (ev.name === "escape") { this.app.closeOverlay(); return true; }
|
|
890
|
+
if (ev.name === "tab") {
|
|
891
|
+
this.page = (this.page + 1) % this.pages.length;
|
|
892
|
+
this.sel = 0;
|
|
893
|
+
this.app.redraw();
|
|
894
|
+
return true;
|
|
895
|
+
}
|
|
896
|
+
if (ev.name === "backtab") {
|
|
897
|
+
if (this.page === 2) {
|
|
898
|
+
this.subPage = (this.subPage + 1) % this.subPages.length;
|
|
899
|
+
this.sel = 0;
|
|
900
|
+
this.app.redraw();
|
|
901
|
+
} else {
|
|
902
|
+
this.page = (this.page + this.pages.length - 1) % this.pages.length;
|
|
903
|
+
this.sel = 0;
|
|
904
|
+
this.app.redraw();
|
|
905
|
+
}
|
|
906
|
+
return true;
|
|
907
|
+
}
|
|
908
|
+
if (ev.name === "pgup" || ev.name === "left") { this.sel = 0; this.app.redraw(); return true; }
|
|
909
|
+
if (ev.name === "pgdn" || ev.name === "right") { this.sel = this.items().length - 1; this.app.redraw(); return true; }
|
|
910
|
+
if (ev.name === "up") { this.sel = Math.max(0, this.sel - 1); this.app.redraw(); return true; }
|
|
911
|
+
if (ev.name === "down") { this.sel = Math.min(this.items().length - 1, this.sel + 1); this.app.redraw(); return true; }
|
|
912
|
+
if (ev.name === "enter") {
|
|
913
|
+
const it = this.items()[this.sel];
|
|
914
|
+
if (it && it[2]) { it[2](); this.app.redraw(); }
|
|
915
|
+
return true;
|
|
916
|
+
}
|
|
917
|
+
return false;
|
|
918
|
+
}
|
|
919
|
+
onMouse(ev) {
|
|
920
|
+
if (ev.kind === "press" && ev.button === 0) {
|
|
921
|
+
if (ev.y === this.y) {
|
|
922
|
+
// top page tabs
|
|
923
|
+
let tx = this.x + 2;
|
|
924
|
+
for (let i = 0; i < this.pages.length; i++) {
|
|
925
|
+
const wTab = strWidth(` ${this.pages[i]} `);
|
|
926
|
+
if (ev.x >= tx && ev.x < tx + wTab) { this.page = i; this.sel = 0; this.app.redraw(); return true; }
|
|
927
|
+
tx += wTab;
|
|
928
|
+
}
|
|
929
|
+
// sub-page tabs (on 设置)
|
|
930
|
+
if (this.page === 2) {
|
|
931
|
+
let sx = this.x + 2 + strWidth(" 快捷键 命令 设置 ");
|
|
932
|
+
for (let i = 0; i < this.subPages.length; i++) {
|
|
933
|
+
const wTab = strWidth(` ${this.subPages[i]} `);
|
|
934
|
+
if (ev.x >= sx && ev.x < sx + wTab) { this.subPage = i; this.sel = 0; this.app.redraw(); return true; }
|
|
935
|
+
sx += wTab;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
return true;
|
|
939
|
+
}
|
|
940
|
+
const idx = ev.y - this.y - 2;
|
|
941
|
+
const items = this.items();
|
|
942
|
+
if (idx >= 0 && idx < items.length && idx < this.h - 3) {
|
|
943
|
+
this.sel = idx;
|
|
944
|
+
const it = items[idx];
|
|
945
|
+
if (it && it[2]) { it[2](); this.app.redraw(); }
|
|
946
|
+
else this.app.redraw();
|
|
947
|
+
return true;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
if (ev.kind === "wheel-up") { this.sel = Math.max(0, this.sel - 1); this.app.redraw(); return true; }
|
|
951
|
+
if (ev.kind === "wheel-down") { this.sel = Math.min(this.items().length - 1, this.sel + 1); this.app.redraw(); return true; }
|
|
952
|
+
return true;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// ---- Jobs & goal popups ----
|
|
957
|
+
|
|
958
|
+
export function buildJobsPopup(app) {
|
|
959
|
+
const jobs = app.jobs ?? [];
|
|
960
|
+
const lines = jobs.length === 0
|
|
961
|
+
? [[{ t: "(当前没有任务帧)", fg: K.FAINT }]]
|
|
962
|
+
: jobs.map((j) => {
|
|
963
|
+
const icon = j.status === "running" ? "⚙" : j.status === "completed" ? "✓" : j.status === "failed" ? "✗" : "·";
|
|
964
|
+
const color = j.status === "running" ? K.WARN : j.status === "completed" ? K.OK : j.status === "failed" ? K.ERR : K.DIM;
|
|
965
|
+
return [{ t: ` ${icon} ${truncate(j.kind, 14)}`, fg: color, bold: true }, { t: ` ${truncate(j.label, 44)}`, fg: K.TXT }, { t: ` ${j.status}`, fg: K.DIM }];
|
|
966
|
+
});
|
|
967
|
+
return new Popup({
|
|
968
|
+
x: 6, y: 3, w: Math.min(80, app.screen.w - 12), h: Math.min(jobs.length + 4, 20), title: "任务",
|
|
969
|
+
lines: [[{ t: "" }], ...lines],
|
|
970
|
+
buttons: [{ label: "关闭", action: "close" }],
|
|
971
|
+
onAction: () => app.closeOverlay(),
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
export function buildGoalPopup(app) {
|
|
976
|
+
const goal = app.goalData?.goal ?? app.goalData;
|
|
977
|
+
const todos = app.todos ?? [];
|
|
978
|
+
const lines = [];
|
|
979
|
+
if (!goal) lines.push([{ t: "(当前会话没有目标)", fg: K.FAINT }]);
|
|
980
|
+
else {
|
|
981
|
+
lines.push([{ t: ` 目标: ${goal.objective ?? goal}`, fg: K.TXT }]);
|
|
982
|
+
if (goal.phase) lines.push([{ t: ` 阶段: ${goal.phase}`, fg: K.DIM }]);
|
|
983
|
+
if (goal.id) lines.push([{ t: ` id: ${goal.id}`, fg: K.FAINT }]);
|
|
984
|
+
}
|
|
985
|
+
if (todos.length) {
|
|
986
|
+
lines.push([{ t: "" }, { t: " 任务清单:", fg: K.ACCENT, bold: true }]);
|
|
987
|
+
for (const t of todos.slice(0, 16)) {
|
|
988
|
+
const icon = t.status === "completed" ? "✓" : t.status === "in_progress" ? "◉" : "○";
|
|
989
|
+
const color = t.status === "completed" ? K.OK : t.status === "in_progress" ? K.WARN : K.DIM;
|
|
990
|
+
lines.push([{ t: ` ${icon} ${truncate(t.content, 60)}`, fg: color }]);
|
|
991
|
+
}
|
|
992
|
+
if (todos.length > 16) lines.push([{ t: ` …共 ${todos.length} 项`, fg: K.FAINT }]);
|
|
993
|
+
}
|
|
994
|
+
return new Popup({
|
|
995
|
+
x: 6, y: 3, w: Math.min(80, app.screen.w - 12), h: Math.min(lines.length + 5, 24), title: "目标",
|
|
996
|
+
lines: [[{ t: "" }], ...lines],
|
|
997
|
+
buttons: [{ label: "关闭", action: "close" }],
|
|
998
|
+
onAction: () => app.closeOverlay(),
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// ---- Settings panel (generic JSON-tree editor over settings.describe/mutate) ----
|
|
1003
|
+
|
|
1004
|
+
const TYPE_COLORS = new Proxy({}, {
|
|
1005
|
+
get(_t, key) {
|
|
1006
|
+
const map = { string: "STRING", number: "NUMBER", boolean: "LINK", object: "DIM", array: "DIM", null: "FAINT" };
|
|
1007
|
+
return T[map[key] ?? key];
|
|
1008
|
+
},
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
export class SettingsPanel extends Widget {
|
|
1012
|
+
constructor(app) {
|
|
1013
|
+
super({ x: 30, y: 0, w: app.screen.w - 30, h: app.screen.h - 1 });
|
|
1014
|
+
this.app = app;
|
|
1015
|
+
this.namespaces = [];
|
|
1016
|
+
this.nsIdx = 0;
|
|
1017
|
+
this.rows = []; // { path: string[], value, type, display }
|
|
1018
|
+
this.pendingOps = [];
|
|
1019
|
+
this.editing = false;
|
|
1020
|
+
this.editPath = null;
|
|
1021
|
+
this.secrets = new Set();
|
|
1022
|
+
const listW = 26;
|
|
1023
|
+
this.nsList = new ScrollView({ x: this.x + 1, y: this.y + 1, w: listW, h: this.h - 2, showScrollbar: true });
|
|
1024
|
+
this.tree = new ScrollView({ x: this.x + listW + 1, y: this.y + 1, w: this.w - listW - 2, h: this.h - 3, showScrollbar: true });
|
|
1025
|
+
this.input = new Input({ x: this.x + listW + 1, y: this.y + this.h - 2, w: this.w - listW - 2, h: 1, prompt: "值: ", placeholder: "输入新值,Enter 暂存,Esc 取消" });
|
|
1026
|
+
}
|
|
1027
|
+
relayout(x, y, w, h) {
|
|
1028
|
+
this.x = x; this.y = y; this.w = w; this.h = h;
|
|
1029
|
+
const listW = 26;
|
|
1030
|
+
this.nsList.x = x + 1; this.nsList.y = y + 1; this.nsList.w = listW; this.nsList.h = h - 2;
|
|
1031
|
+
this.tree.x = x + listW + 1; this.tree.y = y + 1; this.tree.w = w - listW - 2; this.tree.h = h - 3;
|
|
1032
|
+
this.input.x = x + listW + 1; this.input.y = y + h - 2; this.input.w = w - listW - 2;
|
|
1033
|
+
}
|
|
1034
|
+
async load() {
|
|
1035
|
+
try {
|
|
1036
|
+
const d = await this.app.api.call("settings.describe");
|
|
1037
|
+
this.namespaces = d.namespaces ?? [];
|
|
1038
|
+
this.writable = d.writable;
|
|
1039
|
+
this.selectNs(0);
|
|
1040
|
+
} catch (e) {
|
|
1041
|
+
this.app.toast(`设置加载失败: ${e.message}`);
|
|
1042
|
+
this.app.setMode("chat");
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
selectNs(i) {
|
|
1046
|
+
this.nsIdx = Math.max(0, Math.min(this.namespaces.length - 1, i));
|
|
1047
|
+
this.pendingOps = [];
|
|
1048
|
+
this.editing = false;
|
|
1049
|
+
const ns = this.namespaces[this.nsIdx];
|
|
1050
|
+
this.secrets = new Set((ns.secrets ?? []).map((s) => JSON.stringify(s.path ?? [])));
|
|
1051
|
+
this.rebuildRows();
|
|
1052
|
+
const items = this.namespaces.map((n) => ({
|
|
1053
|
+
text: n.ns,
|
|
1054
|
+
sub: n.applies === "live" ? "live" : "重启生效",
|
|
1055
|
+
badge: n.applies === "live" ? "" : "↻",
|
|
1056
|
+
data: n,
|
|
1057
|
+
}));
|
|
1058
|
+
this.nsList.setLines(items.map((it) => it.lines ?? this.nsRow(it)));
|
|
1059
|
+
this.nsItems = items;
|
|
1060
|
+
this.app.redraw();
|
|
1061
|
+
}
|
|
1062
|
+
nsRow(it) {
|
|
1063
|
+
return [{ t: `${it.badge ? it.badge + " " : ""}${truncate(it.text, 20)}`, fg: 0xd4d8dd, bold: false }, { t: " " + it.sub, fg: 0x8b939e }];
|
|
1064
|
+
}
|
|
1065
|
+
rebuildRows() {
|
|
1066
|
+
const ns = this.namespaces[this.nsIdx];
|
|
1067
|
+
if (!ns) { this.rows = []; this.tree.setLines([]); return; }
|
|
1068
|
+
const value = applyOps(ns.value, this.pendingOps);
|
|
1069
|
+
const rows = [];
|
|
1070
|
+
flattenJson(value, [], rows);
|
|
1071
|
+
this.rows = rows;
|
|
1072
|
+
this.tree.setLines(rows.map((r) => this.rowLine(r)));
|
|
1073
|
+
}
|
|
1074
|
+
rowLine(r) {
|
|
1075
|
+
const p = r.path.join(".");
|
|
1076
|
+
const vt = typeof r.value;
|
|
1077
|
+
let v;
|
|
1078
|
+
if (r.value === null) v = "null";
|
|
1079
|
+
else if (vt === "object") v = Array.isArray(r.value) ? `[${Object.keys(r.value).length}]` : `{${Object.keys(r.value).length}}`;
|
|
1080
|
+
else v = String(r.value);
|
|
1081
|
+
if (this.secrets.has(JSON.stringify(r.path))) v = "•••••";
|
|
1082
|
+
const segs = [{ t: p, fg: K.TXT }];
|
|
1083
|
+
if (!(vt === "object" && r.value !== null)) segs.push({ t: " = ", fg: K.FAINT }, { t: v, fg: TYPE_COLORS[vt] ?? K.TXT, bold: vt !== "string" });
|
|
1084
|
+
return segs;
|
|
1085
|
+
}
|
|
1086
|
+
currentNs() { return this.namespaces[this.nsIdx]; }
|
|
1087
|
+
render(screen) {
|
|
1088
|
+
screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", {});
|
|
1089
|
+
const mid = this.x + 26;
|
|
1090
|
+
screen.vline(mid, this.y, this.y + this.h - 1, "│", { fg: T.BORDER });
|
|
1091
|
+
screen.text(this.x + 1, this.y, " 设置 — 点击值编辑,Ctrl+S 保存,Esc 返回", { fg: K.DIM });
|
|
1092
|
+
this.nsList.render(screen);
|
|
1093
|
+
const ns = this.currentNs();
|
|
1094
|
+
if (ns) {
|
|
1095
|
+
screen.text(this.x + 28, this.y, ` ${ns.ns} rev${ns.revision} ${this.writable === false ? "(只读)" : ""}`, { fg: K.ACCENT, bold: true });
|
|
1096
|
+
const pend = this.pendingOps.length ? ` ⚠ ${this.pendingOps.length} 项待保存` : "";
|
|
1097
|
+
if (pend) screen.text(this.x + 28 + strWidth(` ${ns.ns} rev${ns.revision} `), this.y, pend, { fg: K.WARN });
|
|
1098
|
+
}
|
|
1099
|
+
this.tree.render(screen);
|
|
1100
|
+
if (this.editing) {
|
|
1101
|
+
screen.hline(this.x + 27, this.x + this.w - 1, this.y + this.h - 3, "─", { fg: 0x3a424c });
|
|
1102
|
+
screen.text(this.x + 28, this.y + this.h - 3, `编辑 ${this.editPath.join(".")}`, { fg: K.WARN, bold: true });
|
|
1103
|
+
this.input.render(screen);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
onMouse(ev) {
|
|
1107
|
+
if (ev.x < this.x + 26) {
|
|
1108
|
+
if (ev.kind === "press" && ev.button === 0) {
|
|
1109
|
+
const idx = ev.y - this.nsList.y + this.nsList.scrollY;
|
|
1110
|
+
if (idx >= 0 && idx < this.namespaces.length) { this.selectNs(idx); return true; }
|
|
1111
|
+
}
|
|
1112
|
+
return this.nsList.onMouse(ev);
|
|
1113
|
+
}
|
|
1114
|
+
if (this.editing && this.input.inside(ev.x, ev.y)) return this.input.onMouse(ev);
|
|
1115
|
+
if (ev.kind === "press" && ev.button === 0) {
|
|
1116
|
+
const idx = this.tree.scrollY + (ev.y - this.tree.y);
|
|
1117
|
+
const row = this.rows[idx];
|
|
1118
|
+
if (row) {
|
|
1119
|
+
if (typeof row.value === "boolean") {
|
|
1120
|
+
this.pendingOps.push({ op: "set", path: row.path, value: !row.value });
|
|
1121
|
+
this.rebuildRows();
|
|
1122
|
+
return true;
|
|
1123
|
+
}
|
|
1124
|
+
if (typeof row.value === "string" || typeof row.value === "number" || row.value === null) {
|
|
1125
|
+
this.editPath = row.path;
|
|
1126
|
+
this.editing = true;
|
|
1127
|
+
this.input.setValue(row.value === null ? "" : String(row.value), { select: row.value !== null });
|
|
1128
|
+
return true;
|
|
1129
|
+
}
|
|
1130
|
+
return false;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
return false;
|
|
1134
|
+
}
|
|
1135
|
+
onKey(ev) {
|
|
1136
|
+
if (this.editing) {
|
|
1137
|
+
if (ev.type === "key" && ev.name === "escape") { this.editing = false; this.rebuildRows(); return true; }
|
|
1138
|
+
if (ev.type === "key" && ev.name === "enter") {
|
|
1139
|
+
const typed = this.input.value;
|
|
1140
|
+
this.pendingOps.push({ op: "set", path: this.editPath, value: parseScalar(typed) });
|
|
1141
|
+
this.editing = false;
|
|
1142
|
+
this.input.setValue("");
|
|
1143
|
+
this.rebuildRows();
|
|
1144
|
+
return true;
|
|
1145
|
+
}
|
|
1146
|
+
const handled = this.input.onKey(ev);
|
|
1147
|
+
if (handled) this.app.redraw();
|
|
1148
|
+
return true;
|
|
1149
|
+
}
|
|
1150
|
+
if (ev.type !== "key") return false;
|
|
1151
|
+
if (ev.name === "escape") { this.app.setMode("chat"); return true; }
|
|
1152
|
+
if (ev.ctrl && ev.key === "s") { this.save(); return true; }
|
|
1153
|
+
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);
|
|
1154
|
+
if (ev.name === "enter") {
|
|
1155
|
+
const idx = this.tree.scrollY;
|
|
1156
|
+
const row = this.rows[idx];
|
|
1157
|
+
if (row && (typeof row.value === "string" || typeof row.value === "number")) {
|
|
1158
|
+
this.editPath = row.path; this.editing = true; this.input.setValue(String(row.value), { select: true });
|
|
1159
|
+
return true;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
return false;
|
|
1163
|
+
}
|
|
1164
|
+
async save() {
|
|
1165
|
+
const ns = this.currentNs();
|
|
1166
|
+
if (!ns || this.pendingOps.length === 0) { this.app.toast("没有待保存的修改"); return; }
|
|
1167
|
+
try {
|
|
1168
|
+
await this.app.api.call("settings.mutate", { ns: ns.ns, ops: this.pendingOps, expectedRevision: ns.revision });
|
|
1169
|
+
this.pendingOps = [];
|
|
1170
|
+
this.app.toast(`已保存 ${ns.ns}`);
|
|
1171
|
+
await this.load();
|
|
1172
|
+
} catch (e) { this.app.toast(`保存失败: ${e.message}`); }
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function flattenJson(value, path, out, depth = 0) {
|
|
1177
|
+
if (depth === 0 && path.length === 0 && value !== null && typeof value === "object") {
|
|
1178
|
+
for (const k of Object.keys(value)) flattenJson(value[k], [k], out, 1);
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
if (depth > 6) { out.push({ path, value: value === null ? null : String(value).slice(0, 80), type: typeof value }); return; }
|
|
1182
|
+
if (value !== null && typeof value === "object") {
|
|
1183
|
+
out.push({ path, value, type: Array.isArray(value) ? "array" : "object" });
|
|
1184
|
+
for (const k of Object.keys(value)) {
|
|
1185
|
+
flattenJson(value[k], [...path, k], out, depth + 1);
|
|
1186
|
+
}
|
|
1187
|
+
} else {
|
|
1188
|
+
out.push({ path, value, type: value === null ? "null" : typeof value });
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function applyOps(base, ops) {
|
|
1193
|
+
const value = JSON.parse(JSON.stringify(base ?? {}));
|
|
1194
|
+
for (const op of ops) {
|
|
1195
|
+
if (op.op === "set") {
|
|
1196
|
+
let cur = value;
|
|
1197
|
+
for (let i = 0; i < op.path.length - 1; i++) {
|
|
1198
|
+
cur[op.path[i]] ??= {};
|
|
1199
|
+
cur = cur[op.path[i]];
|
|
1200
|
+
}
|
|
1201
|
+
cur[op.path[op.path.length - 1]] = op.value;
|
|
1202
|
+
} else if (op.op === "unset") {
|
|
1203
|
+
let cur = value;
|
|
1204
|
+
for (let i = 0; i < op.path.length - 1; i++) {
|
|
1205
|
+
if (typeof cur[op.path[i]] !== "object" || cur[op.path[i]] === null) break;
|
|
1206
|
+
cur = cur[op.path[i]];
|
|
1207
|
+
}
|
|
1208
|
+
delete cur[op.path[op.path.length - 1]];
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
return value;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
function parseScalar(s) {
|
|
1215
|
+
const t = s.trim();
|
|
1216
|
+
if (t === "true") return true;
|
|
1217
|
+
if (t === "false") return false;
|
|
1218
|
+
if (t === "null" || t === "") return null;
|
|
1219
|
+
if (/^-?\d+(\.\d+)?$/.test(t)) return Number(t);
|
|
1220
|
+
return s;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// ---- Subagent panel ----
|
|
1224
|
+
|
|
1225
|
+
export class SubagentPanel extends Widget {
|
|
1226
|
+
constructor(app) {
|
|
1227
|
+
super({ x: 30, y: 0, w: app.screen.w - 30, h: app.screen.h - 1 });
|
|
1228
|
+
this.app = app;
|
|
1229
|
+
this.parentId = null;
|
|
1230
|
+
this.entries = [];
|
|
1231
|
+
this.selIdx = 0;
|
|
1232
|
+
this.log = [];
|
|
1233
|
+
const listW = 30;
|
|
1234
|
+
this.list = new ScrollView({ x: this.x + 1, y: this.y + 1, w: listW, h: this.h - 3, showScrollbar: true });
|
|
1235
|
+
this.view = new ScrollView({ x: this.x + listW + 1, y: this.y + 1, w: this.w - listW - 2, h: this.h - 3, showScrollbar: true, autoScroll: true });
|
|
1236
|
+
this.input = new Input({ x: this.x + listW + 1, y: this.y + this.h - 2, w: this.w - listW - 2, h: 1, placeholder: "给选中子代理发消息…(continuable)", onEnter: (v) => this.send(v) });
|
|
1237
|
+
}
|
|
1238
|
+
relayout(x, y, w, h) {
|
|
1239
|
+
this.x = x; this.y = y; this.w = w; this.h = h;
|
|
1240
|
+
const listW = 30;
|
|
1241
|
+
this.list.x = x + 1; this.list.y = y + 1; this.list.w = listW; this.list.h = h - 3;
|
|
1242
|
+
this.view.x = x + listW + 1; this.view.y = y + 1; this.view.w = w - listW - 2; this.view.h = h - 3;
|
|
1243
|
+
this.input.x = x + listW + 1; this.input.y = y + h - 2; this.input.w = w - listW - 2;
|
|
1244
|
+
}
|
|
1245
|
+
async load(parentId) {
|
|
1246
|
+
this.parentId = parentId;
|
|
1247
|
+
try {
|
|
1248
|
+
const res = await this.app.api.call("subagent.list", { parentSessionId: parentId });
|
|
1249
|
+
this.entries = res.entries ?? [];
|
|
1250
|
+
this.parentAvailable = res.parentAvailable;
|
|
1251
|
+
} catch (e) {
|
|
1252
|
+
this.entries = [];
|
|
1253
|
+
this.app.toast(`子代理列表失败: ${e.message}`);
|
|
1254
|
+
}
|
|
1255
|
+
this.selIdx = 0;
|
|
1256
|
+
this.#rebuildList();
|
|
1257
|
+
await this.selectChild(0);
|
|
1258
|
+
}
|
|
1259
|
+
#rebuildList() {
|
|
1260
|
+
const lines = this.entries.length === 0
|
|
1261
|
+
? [[{ t: "(当前会话没有子代理)", fg: K.FAINT }], [{ t: "子代理由 agent 的 subagent 工具创建", fg: K.FAINT }]]
|
|
1262
|
+
: this.entries.map((e) => [
|
|
1263
|
+
{ t: `${e.activity === "running" ? "●" : "○"} `, fg: e.activity === "running" ? K.OK : K.FAINT },
|
|
1264
|
+
{ t: truncate(e.label ?? e.id.slice(0, 8), 22), fg: K.TXT, bold: true },
|
|
1265
|
+
{ t: " " + e.mode, fg: K.DIM },
|
|
1266
|
+
]);
|
|
1267
|
+
this.list.setLines(lines);
|
|
1268
|
+
}
|
|
1269
|
+
async selectChild(i) {
|
|
1270
|
+
if (i < 0 || i >= this.entries.length) { this.view.setLines([[{ t: "选择左侧子代理查看历史", fg: K.FAINT }]]); this.selIdx = Math.max(0, i); return; }
|
|
1271
|
+
this.selIdx = i;
|
|
1272
|
+
const child = this.entries[i];
|
|
1273
|
+
this.view.setLines([[{ t: `加载 ${child.id.slice(0, 8)} 历史…`, fg: K.DIM }]]);
|
|
1274
|
+
try {
|
|
1275
|
+
const h = await this.app.api.call("subagent.history", {
|
|
1276
|
+
parentSessionId: this.parentId,
|
|
1277
|
+
childSessionId: child.id,
|
|
1278
|
+
mode: child.mode,
|
|
1279
|
+
maxMessages: 100,
|
|
1280
|
+
});
|
|
1281
|
+
const lines = [[{ t: `${child.label ?? child.id} — ${h.events.length} 事件`, fg: K.ACCENT, bold: true }], [{ t: "" }]];
|
|
1282
|
+
for (const { event } of h.events.slice(-200)) {
|
|
1283
|
+
const d = event.data ?? {};
|
|
1284
|
+
let summary = "";
|
|
1285
|
+
switch (event.type) {
|
|
1286
|
+
case "user/message": summary = "❯ " + String(partsText(d.content)).slice(0, 90); break;
|
|
1287
|
+
case "assistant/message": summary = "◉ " + String(partsText(d.message?.content)).slice(0, 90); break;
|
|
1288
|
+
case "assistant/chunk": {
|
|
1289
|
+
const ch = d.chunk ?? {};
|
|
1290
|
+
if (ch.type === "text-delta") summary = "▸ " + String(ch.delta ?? "").slice(0, 90);
|
|
1291
|
+
else if (ch.type === "block-start") summary = `▸ [${ch.blockType}]`;
|
|
1292
|
+
else summary = "▸ …";
|
|
1293
|
+
break;
|
|
1294
|
+
}
|
|
1295
|
+
case "tool/call": summary = `⚙ ${d.name ?? "tool"} ${String(d.arguments ?? "").slice(0, 60)}`; break;
|
|
1296
|
+
case "tool/result": summary = "↳ 结果 " + String(partsText(d.message?.content)).slice(0, 60); break;
|
|
1297
|
+
case "step/start": summary = `— step ${d.step ?? ""}`; break;
|
|
1298
|
+
case "step/end": summary = "— step end"; break;
|
|
1299
|
+
default: summary = event.type;
|
|
1300
|
+
}
|
|
1301
|
+
lines.push([{ t: `#${event.seq}`, fg: K.FAINT }, { t: " " + truncate(summary, this.view.w - 14), fg: K.TXT }]);
|
|
1302
|
+
}
|
|
1303
|
+
this.view.setLines(lines);
|
|
1304
|
+
} catch (e) {
|
|
1305
|
+
this.view.setLines([[{ t: `历史加载失败: ${e.message}`, fg: K.ERR }]]);
|
|
1306
|
+
}
|
|
1307
|
+
this.app.redraw();
|
|
1308
|
+
}
|
|
1309
|
+
async send(text) {
|
|
1310
|
+
const child = this.entries[this.selIdx];
|
|
1311
|
+
if (!child) { this.app.toast("先选择子代理"); return; }
|
|
1312
|
+
try {
|
|
1313
|
+
await this.app.api.call("subagent.prompt", {
|
|
1314
|
+
parentSessionId: this.parentId,
|
|
1315
|
+
childSessionId: child.id,
|
|
1316
|
+
mode: "continuable",
|
|
1317
|
+
content: [{ type: "text", text }],
|
|
1318
|
+
clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
1319
|
+
});
|
|
1320
|
+
this.app.toast(`已发送给 ${child.id.slice(0, 8)}`);
|
|
1321
|
+
} catch (e) { this.app.toast(`发送失败: ${e.message}`); }
|
|
1322
|
+
}
|
|
1323
|
+
async interrupt() {
|
|
1324
|
+
const child = this.entries[this.selIdx];
|
|
1325
|
+
if (!child) return;
|
|
1326
|
+
try {
|
|
1327
|
+
await this.app.api.call("subagent.interrupt", { parentSessionId: this.parentId, childSessionId: child.id, mode: "continuable" });
|
|
1328
|
+
this.app.toast("已请求中断");
|
|
1329
|
+
this.load(this.parentId);
|
|
1330
|
+
} catch (e) { this.app.toast(`中断失败: ${e.message}`); }
|
|
1331
|
+
}
|
|
1332
|
+
render(screen) {
|
|
1333
|
+
screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", {});
|
|
1334
|
+
const mid = this.x + 30;
|
|
1335
|
+
screen.vline(mid, this.y, this.y + this.h - 1, "│", { fg: T.BORDER });
|
|
1336
|
+
screen.text(this.x + 1, this.y, " 子代理 — 点击选择,x 中断,Esc 返回", { fg: K.DIM });
|
|
1337
|
+
this.list.render(screen);
|
|
1338
|
+
this.view.render(screen);
|
|
1339
|
+
screen.hline(this.x + 31, this.x + this.w - 1, this.y + this.h - 2, "─", { fg: 0x3a424c });
|
|
1340
|
+
this.input.render(screen);
|
|
1341
|
+
}
|
|
1342
|
+
onMouse(ev) {
|
|
1343
|
+
if (ev.x < this.x + 30) {
|
|
1344
|
+
if (ev.kind === "press" && ev.button === 0) {
|
|
1345
|
+
const idx = ev.y - this.list.y + this.list.scrollY;
|
|
1346
|
+
if (idx >= 0 && idx < this.entries.length) { this.selectChild(idx); return true; }
|
|
1347
|
+
}
|
|
1348
|
+
return this.list.onMouse(ev);
|
|
1349
|
+
}
|
|
1350
|
+
if (this.input.inside(ev.x, ev.y)) return this.input.onMouse(ev);
|
|
1351
|
+
return this.view.onMouse(ev);
|
|
1352
|
+
}
|
|
1353
|
+
onKey(ev) {
|
|
1354
|
+
if (ev.type === "text") { this.input.insert(ev.text); this.app.redraw(); return true; }
|
|
1355
|
+
if (ev.type !== "key") return false;
|
|
1356
|
+
if (ev.name === "escape") { this.app.setMode("chat"); return true; }
|
|
1357
|
+
if (ev.name === "char" && ev.key === "x" && !ev.ctrl) { this.interrupt(); return true; }
|
|
1358
|
+
if (ev.name === "char" && ev.key === "r" && !ev.ctrl) { this.selectChild(this.selIdx); return true; }
|
|
1359
|
+
if (ev.name === "up" || ev.name === "down") {
|
|
1360
|
+
if (this.entries.length === 0) return false;
|
|
1361
|
+
const next = this.selIdx + (ev.name === "up" ? -1 : 1);
|
|
1362
|
+
if (next >= 0 && next < this.entries.length) { this.selectChild(next); }
|
|
1363
|
+
return true;
|
|
1364
|
+
}
|
|
1365
|
+
if (this.input.onKey(ev)) { this.app.redraw(); return true; }
|
|
1366
|
+
return false;
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
function partsText(content) {
|
|
1371
|
+
if (typeof content === "string") return content;
|
|
1372
|
+
if (!Array.isArray(content)) return "";
|
|
1373
|
+
const texts = [];
|
|
1374
|
+
const walk = (arr) => {
|
|
1375
|
+
for (const p of arr) {
|
|
1376
|
+
if (!p || typeof p !== "object") continue;
|
|
1377
|
+
if (p.type === "text" && typeof p.text === "string") texts.push(p.text);
|
|
1378
|
+
else if (Array.isArray(p.content)) walk(p.content);
|
|
1379
|
+
}
|
|
1380
|
+
};
|
|
1381
|
+
walk(content);
|
|
1382
|
+
return texts.join(" ");
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// ---- Skills panel ----
|
|
1386
|
+
|
|
1387
|
+
export class SkillsPanel extends Widget {
|
|
1388
|
+
constructor(app) {
|
|
1389
|
+
super({ x: 30, y: 0, w: app.screen.w - 30, h: app.screen.h - 1 });
|
|
1390
|
+
this.app = app;
|
|
1391
|
+
this.skills = [];
|
|
1392
|
+
this.selIdx = 0;
|
|
1393
|
+
this.list = new ScrollView({ x: this.x + 1, y: this.y + 1, w: 30, h: this.h - 2, showScrollbar: true });
|
|
1394
|
+
this.detail = new ScrollView({ x: this.x + 32, y: this.y + 1, w: this.w - 33, h: this.h - 2, showScrollbar: true });
|
|
1395
|
+
}
|
|
1396
|
+
relayout(x, y, w, h) {
|
|
1397
|
+
this.x = x; this.y = y; this.w = w; this.h = h;
|
|
1398
|
+
this.list.x = x + 1; this.list.y = y + 1; this.list.w = 30; this.list.h = h - 2;
|
|
1399
|
+
this.detail.x = x + 32; this.detail.y = y + 1; this.detail.w = w - 33; this.detail.h = h - 2;
|
|
1400
|
+
}
|
|
1401
|
+
async load() {
|
|
1402
|
+
try {
|
|
1403
|
+
const r = await this.app.api.call("skill.list", { sessionId: this.app.currentSession });
|
|
1404
|
+
this.skills = r.skills ?? [];
|
|
1405
|
+
} catch (e) {
|
|
1406
|
+
this.skills = [];
|
|
1407
|
+
this.app.toast(`技能加载失败: ${e.message}`);
|
|
1408
|
+
}
|
|
1409
|
+
this.select(0);
|
|
1410
|
+
}
|
|
1411
|
+
select(i) {
|
|
1412
|
+
this.selIdx = Math.max(0, Math.min(this.skills.length - 1, i));
|
|
1413
|
+
this.list.setLines(this.skills.map((k) => [
|
|
1414
|
+
{ t: k.modelInvocable ? "⚡" : " ", fg: k.modelInvocable ? K.WARN : K.FAINT },
|
|
1415
|
+
{ t: " " + truncate(k.name, 26), fg: K.TXT, bold: true },
|
|
1416
|
+
]));
|
|
1417
|
+
const k = this.skills[this.selIdx];
|
|
1418
|
+
if (!k) { this.detail.setLines([[{ t: "(本会话没有可用技能)", fg: K.FAINT }]]); this.app.redraw(); return; }
|
|
1419
|
+
const lines = [];
|
|
1420
|
+
lines.push([{ t: k.name, fg: K.ACCENT, bold: true, underline: true }]);
|
|
1421
|
+
if (k.modelInvocable) lines.push([{ t: "⚡ 模型可主动调用", fg: K.WARN }]);
|
|
1422
|
+
lines.push([{ t: "" }]);
|
|
1423
|
+
for (const ln of renderMd(k.description ?? "", this.detail.w - 2)) lines.push(ln);
|
|
1424
|
+
if (k.whenToUse) {
|
|
1425
|
+
lines.push([{ t: "" }, { t: "何时使用:", fg: K.DIM, underline: true }]);
|
|
1426
|
+
for (const ln of renderMd(k.whenToUse, this.detail.w - 2)) lines.push(ln);
|
|
1427
|
+
}
|
|
1428
|
+
lines.push([{ t: "" }, { t: "按 c 复制技能名 · Esc 返回", fg: K.FAINT }]);
|
|
1429
|
+
this.detail.setLines(lines);
|
|
1430
|
+
this.app.redraw();
|
|
1431
|
+
}
|
|
1432
|
+
render(screen) {
|
|
1433
|
+
screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", {});
|
|
1434
|
+
screen.vline(this.x + 31, this.y, this.y + this.h - 1, "│", { fg: K.BORDER });
|
|
1435
|
+
screen.text(this.x + 1, this.y, ` 技能 (${this.skills.length}) — 点击查看详情`, { fg: K.DIM });
|
|
1436
|
+
this.list.render(screen);
|
|
1437
|
+
this.detail.render(screen);
|
|
1438
|
+
}
|
|
1439
|
+
onMouse(ev) {
|
|
1440
|
+
if (ev.x < this.x + 31) {
|
|
1441
|
+
if (ev.kind === "press" && ev.button === 0) {
|
|
1442
|
+
const idx = ev.y - this.list.y + this.list.scrollY;
|
|
1443
|
+
if (idx >= 0 && idx < this.skills.length) { this.select(idx); return true; }
|
|
1444
|
+
}
|
|
1445
|
+
return this.list.onMouse(ev);
|
|
1446
|
+
}
|
|
1447
|
+
return this.detail.onMouse(ev);
|
|
1448
|
+
}
|
|
1449
|
+
onKey(ev) {
|
|
1450
|
+
if (ev.type !== "key") return false;
|
|
1451
|
+
if (ev.name === "escape") { this.app.setMode("chat"); return true; }
|
|
1452
|
+
if (ev.name === "up" || ev.name === "down") {
|
|
1453
|
+
if (this.skills.length === 0) return false;
|
|
1454
|
+
const next = this.selIdx + (ev.name === "up" ? -1 : 1);
|
|
1455
|
+
if (next >= 0 && next < this.skills.length) this.select(next);
|
|
1456
|
+
return true;
|
|
1457
|
+
}
|
|
1458
|
+
if (ev.name === "char" && ev.key === "c" && !ev.ctrl && this.skills[this.selIdx]) {
|
|
1459
|
+
this.app.copyText(this.skills[this.selIdx].name);
|
|
1460
|
+
return true;
|
|
1461
|
+
}
|
|
1462
|
+
return false;
|
|
1463
|
+
}
|
|
1464
|
+
}
|