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/src/views.js ADDED
@@ -0,0 +1,2403 @@
1
+ // views.js — App composition: session list + chat timeline + approvals + status.
2
+ import { Screen } from "./screen.js";
3
+ import { renderMd, C } from "./md.js";
4
+ import { truncate, strWidth, bars } from "./text.js";
5
+ import { readFileSync } from "node:fs";
6
+ import { Widget, List, ScrollView, Input, Popup, Menu, StatusBar } from "./widgets.js";
7
+ import {
8
+ Picker, buildCommandPalette, buildModelPicker, buildModePicker, buildPermissionPicker,
9
+ modeName, permName, WorkspacePanel, TrajectoryPanel,
10
+ ImagePopup, kittyCapable, buildJobsPopup, buildGoalPopup, SettingsPanel, SubagentPanel,
11
+ SkillsPanel, ControlPanel,
12
+ } from "./panels.js";
13
+
14
+ import { T, themeName } from "./theme.js";
15
+ // Live theme accessor: K.K.DIM etc. resolve against the active palette at render time.
16
+ const K = new Proxy({}, { get(_k, key) { return T[key]; } });
17
+
18
+ // ---- Tool card renderers (host-computed view models) ----
19
+
20
+ function renderToolCard(view, width, expanded) {
21
+ const card = view?.view ?? {};
22
+ const lines = [];
23
+ const title = truncate(card.title ?? card.name ?? "tool", width - 4);
24
+ lines.push([
25
+ { t: "▸ ", fg: K.ACCENT, bold: true },
26
+ { t: title, fg: K.TXT, bold: true },
27
+ ]);
28
+ const pushText = (label, text, fg) => {
29
+ const segs = [{ t: label, fg: K.DIM }];
30
+ const content = String(text ?? "");
31
+ for (const ln of content.split("\n")) {
32
+ lines.push([...segs, { t: truncate(ln, width - 6 - strWidth(label)), fg: fg ?? K.TXT }]);
33
+ if (lines.length > 40) break;
34
+ }
35
+ };
36
+ switch (card.card) {
37
+ case "diff": {
38
+ for (const d of card.diffs ?? []) {
39
+ lines.push([{ t: " " + truncate(d.path ?? "", width - 6), fg: K.ACCENT, underline: true }]);
40
+ if (d.oldText == null) {
41
+ lines.push([{ t: " + 新建文件", fg: K.OK }]);
42
+ } else if (d.newText == null) {
43
+ lines.push([{ t: " - 删除文件", fg: K.ERR }]);
44
+ }
45
+ const oldLines = (d.oldText ?? "").split("\n");
46
+ const newLines = (d.newText ?? "").split("\n");
47
+ // simple LCS-less side-by-side fallback: unified-ish diff
48
+ if (!expanded && (oldLines.length + newLines.length) > 6) {
49
+ lines.push([{ t: ` ─ ${oldLines.length} 行改动(点击展开)`, fg: K.FAINT }]);
50
+ } else {
51
+ const max = Math.max(oldLines.length, newLines.length);
52
+ for (let i = 0; i < Math.min(max, expanded ? 200 : 6); i++) {
53
+ const o = oldLines[i], n = newLines[i];
54
+ if (o === n) {
55
+ lines.push([{ t: " ", fg: K.FAINT }, { t: truncate(o ?? "", width - 8), fg: K.DIM }]);
56
+ } else {
57
+ if (o !== undefined) lines.push([{ t: " - ", fg: K.ERR }, { t: truncate(o, width - 8), fg: T.PINK }]);
58
+ if (n !== undefined) lines.push([{ t: " + ", fg: K.OK }, { t: truncate(n, width - 8), fg: T.GREENG }]);
59
+ }
60
+ if (lines.length > 60) break;
61
+ }
62
+ }
63
+ }
64
+ break;
65
+ }
66
+ default: {
67
+ // generic card: dump text-like fields
68
+ for (const key of ["output", "text", "stdout", "stderr", "result", "detail", "message", "summary"]) {
69
+ if (card[key] !== undefined) {
70
+ pushText(` ${key}: `, card[key]);
71
+ break;
72
+ }
73
+ }
74
+ // section lists
75
+ for (const sec of card.sections ?? []) {
76
+ if (sec?.label) lines.push([{ t: ` ${sec.label}`, fg: K.ACCENT, bold: true }]);
77
+ for (const row of sec?.rows ?? sec?.items ?? []) {
78
+ const r = typeof row === "string" ? row : row?.text ?? row?.label ?? JSON.stringify(row);
79
+ lines.push([{ t: " " + truncate(r, width - 7), fg: K.TXT }]);
80
+ }
81
+ }
82
+ if (card.exitCode !== undefined && card.exitCode !== 0) {
83
+ lines.push([{ t: ` exit: ${card.exitCode}`, fg: K.ERR }]);
84
+ }
85
+ break;
86
+ }
87
+ }
88
+ return lines;
89
+ }
90
+
91
+ function jsonPreview(args, width, expanded) {
92
+ let s;
93
+ try { s = JSON.stringify(JSON.parse(args ?? "{}"), null, 1); } catch { s = String(args ?? ""); }
94
+ return s.split("\n").slice(0, expanded ? 30 : 4).map((l) => [{ t: " " + truncate(l, width - 4), fg: K.DIM, code: true }]);
95
+ }
96
+
97
+ function diffText(oldText, newText, width) {
98
+ const lines = [];
99
+ const o = (oldText ?? "").split("\n"), n = (newText ?? "").split("\n");
100
+ const max = Math.max(o.length, n.length);
101
+ for (let i = 0; i < Math.min(max, 120); i++) {
102
+ const a = o[i], b = n[i];
103
+ if (a === b) lines.push([{ t: " " + truncate(a ?? "", width - 4), fg: K.DIM }]);
104
+ else {
105
+ if (a !== undefined) lines.push([{ t: "- " + truncate(a, width - 4), fg: T.RED }]);
106
+ if (b !== undefined) lines.push([{ t: "+ " + truncate(b, width - 4), fg: T.GREEN }]);
107
+ }
108
+ }
109
+ return lines;
110
+ }
111
+
112
+ // ---- Chat node model ----
113
+
114
+ function nodeForEvents(events, log) {
115
+ const nodes = [];
116
+ const cur = () => nodes[nodes.length - 1];
117
+ for (const { event, view } of events) {
118
+ const d = event.data ?? {};
119
+ switch (event.type) {
120
+ case "user/message": {
121
+ const text = partsToText(d.content ?? d.message?.content);
122
+ const images = partsToImages(d.content ?? d.message?.content);
123
+ const id = d.id ?? null;
124
+ if (text !== null) nodes.push({ kind: "user", text, images, id });
125
+ else if (images) nodes.push({ kind: "user", text: "", images, id });
126
+ break;
127
+ }
128
+ case "assistant/message": {
129
+ const parts = d.message?.content ?? [];
130
+ const blocks = [];
131
+ for (const p of parts) {
132
+ if (p.type === "text") blocks.push({ kind: "text", text: p.text ?? "" });
133
+ else if (p.type === "reasoning") blocks.push({ kind: "reasoning", text: p.text ?? "" });
134
+ // tool-call content parts are SKIPPED: the tool/call event that
135
+ // immediately follows carries the callId and args, and tool/result
136
+ // attaches to that block. Emitting one here too doubles every bash.
137
+ else if (p.type === "tool-call") { /* handled by tool/call event */ }
138
+ else blocks.push({ kind: "other", text: JSON.stringify(p).slice(0, 500) });
139
+ }
140
+ const images = partsToImages(d.message?.content);
141
+ const id = d.message?.id ?? null;
142
+ const last = cur();
143
+ if (last && last.kind === "assistant" && last.streaming !== false) {
144
+ last.blocks = blocks;
145
+ last.images = images ?? last.images;
146
+ last.id = id ?? last.id;
147
+ last.streaming = false;
148
+ } else {
149
+ nodes.push({ kind: "assistant", blocks, images, id, streaming: false });
150
+ }
151
+ break;
152
+ }
153
+ case "assistant/chunk": {
154
+ const ch = d.chunk ?? {};
155
+ let node = cur();
156
+ if (!node || node.kind !== "assistant" || node.finalized) {
157
+ node = { kind: "assistant", blocks: [], streaming: true, finalized: false };
158
+ nodes.push(node);
159
+ }
160
+ node.streaming = true;
161
+ if (ch.type === "block-start") {
162
+ const kind = ch.blockType === "tool-call" ? "tool" : ch.blockType ?? "text";
163
+ node.blocks[ch.index ?? 0] = { kind, text: "", args: kind === "tool" ? "" : undefined, streaming: true, startedAt: event.time ?? Date.now() };
164
+ } else if (ch.type === "text-delta") {
165
+ const b = node.blocks[ch.index ?? 0];
166
+ if (b) b.text = (b.text ?? "") + (ch.delta ?? "");
167
+ } else if (ch.type === "reasoning-delta") {
168
+ const b = node.blocks[ch.index ?? 0];
169
+ if (b) b.text = (b.text ?? "") + (ch.text ?? "");
170
+ } else if (ch.type === "tool-call-delta") {
171
+ const b = node.blocks[ch.index ?? 0];
172
+ if (b) {
173
+ if (ch.name !== undefined) b.name = ch.name;
174
+ if (ch.id !== undefined) b.callId = ch.id;
175
+ if (ch.argumentsDelta !== undefined) b.args = (b.args ?? "") + ch.argumentsDelta;
176
+ }
177
+ } else if (ch.type === "block-end") {
178
+ const b = node.blocks[ch.index ?? 0];
179
+ if (b) b.streaming = false;
180
+ }
181
+ break;
182
+ }
183
+ case "tool/call": {
184
+ const callId = d.callId;
185
+ // Refresh the block the streaming/assistant path already emitted for
186
+ // this callId (authoritative name/args/view win), rather than deduping
187
+ // it away or minting a duplicate — one card per call, always.
188
+ let block = null;
189
+ for (const nd of nodes) {
190
+ if (nd.kind !== "assistant") continue;
191
+ block = nd.blocks.find((b) => b.kind === "tool" && b.callId === callId);
192
+ if (block) break;
193
+ }
194
+ if (block) {
195
+ if (d.name !== undefined) block.name = d.name;
196
+ if (d.arguments !== undefined) block.args = d.arguments;
197
+ if (view?.view !== undefined) block.view = view.view;
198
+ block.result = null;
199
+ break;
200
+ }
201
+ let node = cur();
202
+ if (!node || node.kind !== "assistant") {
203
+ node = { kind: "assistant", blocks: [], streaming: true, finalized: false };
204
+ nodes.push(node);
205
+ }
206
+ node.blocks.push({ kind: "tool", name: d.name, args: d.arguments, callId, view: view?.view, result: null });
207
+ break;
208
+ }
209
+ case "tool/result": {
210
+ const callId = d.message?.source?.callId;
211
+ const text = partsToText(d.message?.content);
212
+ const node = nodes.findLast((nd) => nd.kind === "assistant" && nd.blocks.some((b) => b.kind === "tool" && b.callId === callId));
213
+ const block = node?.blocks.find((b) => b.kind === "tool" && b.callId === callId);
214
+ if (block) block.result = text ?? JSON.stringify(d).slice(0, 400);
215
+ break;
216
+ }
217
+ case "step/end": {
218
+ const node = cur();
219
+ if (node && node.kind === "assistant") { node.streaming = false; node.finalized = true; }
220
+ break;
221
+ }
222
+ case "session/title": {
223
+ nodes.push({ kind: "title", text: d.title ?? "" });
224
+ break;
225
+ }
226
+ case "compaction": {
227
+ nodes.push({ kind: "system", text: "⟳ " + (d.message ?? d.reason ?? "上下文压缩") });
228
+ break;
229
+ }
230
+ default: {
231
+ // known benign control events: silently ignored
232
+ const KNOWN = new Set(["turn/start", "turn/end", "step/start", "step/end", "todo/write",
233
+ "agent/inbox/spliced", "request/header", "request/context", "permission/preset",
234
+ "sandbox/mode", "approval/policy", "session/title-llm-request", "command/done", "command/failed"]);
235
+ if (!KNOWN.has(event.type) && !SEEN_TYPES.has(event.type)) {
236
+ SEEN_TYPES.add(event.type);
237
+ log(`[chat] unknown event type: ${event.type}`);
238
+ }
239
+ }
240
+ }
241
+ }
242
+ return nodes;
243
+ }
244
+
245
+ const SEEN_TYPES = new Set();
246
+
247
+ function partsToImages(content) {
248
+ if (!Array.isArray(content)) return null;
249
+ const refs = [];
250
+ const walk = (arr) => {
251
+ for (const p of arr) {
252
+ if (!p || typeof p !== "object") continue;
253
+ if (p.type === "image" && p.attachment && typeof p.attachment === "object") refs.push(p.attachment);
254
+ else if (Array.isArray(p.content)) walk(p.content);
255
+ }
256
+ };
257
+ walk(content);
258
+ return refs.length ? refs : null;
259
+ }
260
+
261
+ function partsToText(content) {
262
+ if (!Array.isArray(content)) return typeof content === "string" ? content : null;
263
+ const texts = [];
264
+ const walk = (arr) => {
265
+ for (const p of arr) {
266
+ if (!p || typeof p !== "object") continue;
267
+ if (p.type === "text" && typeof p.text === "string") texts.push(p.text);
268
+ else if (Array.isArray(p.content)) walk(p.content);
269
+ }
270
+ };
271
+ walk(content);
272
+ return texts.length ? texts.join("\n") : null;
273
+ }
274
+
275
+ // ---- image attachment input: @/abs/path.png tokens in the message ----
276
+ const IMAGE_EXT = /\.(png|jpe?g|webp|gif)$/i;
277
+ const MEDIA_TYPES = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", gif: "image/gif" };
278
+
279
+ /** Parse "@path" tokens; returns {parts, images, errors} where parts mix text/image. */
280
+ export function buildPromptParts(text, { readFile = null } = {}) {
281
+ const parts = [];
282
+ const images = [];
283
+ const errors = [];
284
+ const re = /@([^\s@]+)/g;
285
+ let last = 0;
286
+ let m;
287
+ while ((m = re.exec(text)) !== null) {
288
+ const path = m[1];
289
+ const ext = IMAGE_EXT.exec(path);
290
+ if (!ext) continue; // not an image path → leave as plain text
291
+ const mediaType = MEDIA_TYPES[ext[1].toLowerCase()];
292
+ if (!mediaType) continue;
293
+ try {
294
+ const data = readFile(path);
295
+ if (data === null) throw new Error("文件不存在");
296
+ parts.push({ type: "text", text: text.slice(last, m.index) });
297
+ parts.push({ type: "image", mediaType, data, name: path.split("/").pop() });
298
+ images.push(path);
299
+ last = m.index + m[0].length;
300
+ } catch (e) {
301
+ errors.push(`${path}: ${e.message}`);
302
+ }
303
+ }
304
+ parts.push({ type: "text", text: text.slice(last) });
305
+ return { parts, images, errors };
306
+ }
307
+
308
+ // ---- SidebarTree: web-style workspace(folder) → session(file) tree, collapsible ----
309
+
310
+ class SidebarTree extends Widget {
311
+ constructor(app) {
312
+ super({ x: 0, y: 0, w: 30, h: app.screen.h - 1 });
313
+ this.app = app;
314
+ this.groups = []; // [{ kind:'group', key, title, path, workspaceId, collapsed, sessions: [...] }]
315
+ this.rows = []; // flattened rows: { kind:'group'|'session', group?, session? }
316
+ this.scrollY = 0;
317
+ this.sel = 0;
318
+ this.focused = false;
319
+ this.collapsed = new Set(); // group keys
320
+ }
321
+ setData(workspaces, sessions, archivedIds, currentSessionId) {
322
+ const archived = new Set(archivedIds ?? []);
323
+ // Blank drafts are noise (empty sessions minted by "new session" clicks);
324
+ // hide them except the one currently open, which the user is working in.
325
+ const visible = (s) => !s.blank || s.sessionId === currentSessionId;
326
+ const byId = new Map(sessions.map((s) => [s.sessionId, s]));
327
+ const groups = [];
328
+ const accounted = new Set();
329
+ for (const ws of workspaces) {
330
+ const members = [];
331
+ for (const id of ws.sessionIds ?? []) {
332
+ const s = byId.get(id);
333
+ if (s === undefined || archived.has(id) || !visible(s)) continue;
334
+ accounted.add(id);
335
+ members.push(s);
336
+ }
337
+ groups.push({
338
+ kind: "group", key: `ws:${ws.workspaceId}`, title: ws.title, path: ws.path,
339
+ workspaceId: ws.workspaceId, sessions: members,
340
+ });
341
+ }
342
+ const stray = sessions.filter((s) => !accounted.has(s.sessionId) && !archived.has(s.sessionId) && visible(s));
343
+ if (stray.length > 0) {
344
+ groups.push({ kind: "group", key: "ws:", title: "未分组", path: null, workspaceId: null, sessions: stray });
345
+ }
346
+ // groups default to expanded; explicit collapses persist across refreshes
347
+ this.groups = groups;
348
+ this.#flatten();
349
+ this.sel = Math.min(this.sel, Math.max(0, this.rows.length - 1));
350
+ this.app.redraw();
351
+ }
352
+ #flatten() {
353
+ const rows = [];
354
+ for (const g of this.groups) {
355
+ rows.push({ kind: "group", group: g });
356
+ if (!this.collapsed.has(g.key)) {
357
+ for (const sess of g.sessions) rows.push({ kind: "session", group: g, session: sess });
358
+ }
359
+ }
360
+ this.rows = rows;
361
+ }
362
+ toggle(group) {
363
+ if (this.collapsed.has(group.key)) this.collapsed.delete(group.key);
364
+ else this.collapsed.add(group.key);
365
+ this.#flatten();
366
+ }
367
+ collapseAll() { for (const g of this.groups) this.collapsed.add(g.key); this.#flatten(); }
368
+ expandAll() { this.collapsed.clear(); this.#flatten(); }
369
+ #rowTitle(sess) {
370
+ return sess.projections?.values?.title ?? (sess.blank ? "(空白会话)" : sess.sessionId.slice(0, 8));
371
+ }
372
+ render(screen) {
373
+ screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", {});
374
+ const w = this.w - 1;
375
+ // header: workspace title
376
+ screen.text(this.x, this.y, truncate("▣ 工作区", w - 2), { fg: T.ACCENT, attrs: 1 });
377
+ screen.hline(this.x, this.x + w, this.y + 1, "─", { fg: T.BORDER });
378
+ const listTop = this.y + 2;
379
+ for (let i = 0; i < this.h - 2; i++) {
380
+ const idx = this.scrollY + i;
381
+ const row = this.rows[idx];
382
+ const y = listTop + i;
383
+ if (!row) { screen.hline(this.x, this.x + w, y, " ", {}); continue; }
384
+ const sel = idx === this.sel;
385
+ if (row.kind === "group") {
386
+ const g = row.group;
387
+ const open = !this.collapsed.has(g.key);
388
+ const hasRun = g.sessions.some((s) => s.running);
389
+ screen.text(this.x, y, truncate(`${open ? "▾" : "▸"} ${g.title} (${g.sessions.length})`, w - 2),
390
+ { fg: sel && this.focused ? K.BOLD : K.DIM, bg: sel && this.focused ? K.MENUSEL : -1, attrs: sel && this.focused ? 1 : 0 });
391
+ if (hasRun) screen.text(this.x + w - 1, y, "●", { fg: K.OK, bg: sel && this.focused ? K.MENUSEL : -1 });
392
+ } else {
393
+ const s = row.session;
394
+ const indent = " ";
395
+ const badge = s.running ? "●" : s.blank ? "○" : " ";
396
+ const title = truncate(this.#rowTitle(s), w - 4);
397
+ const segs = [
398
+ { t: indent + badge + " ", fg: s.running ? K.OK : K.FAINT, bg: sel && this.focused ? K.MENUSEL : -1 },
399
+ { t: title, fg: sel && this.focused ? K.BOLD : K.TXT, bg: sel && this.focused ? K.MENUSEL : -1, attrs: sel && this.focused ? 1 : 0 },
400
+ ];
401
+ let px = this.x;
402
+ for (const seg of segs) {
403
+ const tx = truncate(seg.t, this.x + w - px);
404
+ screen.text(px, y, tx, {
405
+ fg: seg.fg, bg: seg.bg ?? -1, attrs: seg.attrs ?? 0,
406
+ });
407
+ px += strWidth(tx);
408
+ }
409
+ }
410
+ }
411
+ // scrollbar (below the 2-row header)
412
+ const listH = this.h - 2;
413
+ if (this.rows.length > listH) {
414
+ const total = Math.max(1, this.rows.length);
415
+ const thumbH = Math.max(1, Math.floor(listH * listH / total));
416
+ const thumbY = Math.floor((listH - 2) * this.scrollY / Math.max(1, this.rows.length - listH));
417
+ for (let i = 0; i < listH; i++) {
418
+ const inThumb = i >= 1 + thumbY && i < 1 + thumbY + thumbH;
419
+ const inTrack = i >= 1 && i < listH - 1;
420
+ screen.put(this.x + this.w - 1, this.y + 2 + i, inThumb ? "█" : inTrack ? "░" : " ", { fg: inThumb ? K.SCROLLTHUMB : K.SCROLLTRACK });
421
+ }
422
+ }
423
+ }
424
+ maxScroll() { return Math.max(0, this.rows.length - (this.h - 2)); }
425
+ scroll(dy) { this.scrollY = Math.max(0, Math.min(this.maxScroll(), this.scrollY + dy)); }
426
+ #scrollToSel() {
427
+ if (this.sel < this.scrollY) this.scrollY = this.sel;
428
+ else if (this.sel >= this.scrollY + this.h - 2) this.scrollY = this.sel - (this.h - 2) + 1;
429
+ }
430
+ move(delta) {
431
+ if (this.rows.length === 0) return false;
432
+ const next = Math.max(0, Math.min(this.rows.length - 1, this.sel + delta));
433
+ if (next === this.sel) return false;
434
+ this.sel = next;
435
+ this.#scrollToSel();
436
+ return true;
437
+ }
438
+ currentRow() { return this.rows[this.sel] ?? null; }
439
+ onMouse(ev) {
440
+ if (ev.kind === "wheel-up") { this.scroll(-3); return true; }
441
+ if (ev.kind === "wheel-down") { this.scroll(3); return true; }
442
+ if (ev.kind === "press" && ev.button === 0) {
443
+ const idx = this.scrollY + (ev.y - this.y - 2);
444
+ const row = this.rows[idx];
445
+ if (!row) return false;
446
+ this.sel = idx;
447
+ if (row.kind === "group") {
448
+ this.toggle(row.group);
449
+ this.app.redraw();
450
+ } else {
451
+ this.app.openSession(row.session.sessionId);
452
+ }
453
+ return true;
454
+ }
455
+ if (ev.kind === "press" && ev.button === 2) {
456
+ const idx = this.scrollY + (ev.y - this.y - 2);
457
+ const row = this.rows[idx];
458
+ if (!row) return false;
459
+ this.sel = idx;
460
+ if (row.kind === "group") {
461
+ const items = [
462
+ { label: "新建会话", action: () => this.app.newSessionIn(row.group) },
463
+ { label: "折叠全部", action: () => { this.collapseAll(); this.app.redraw(); } },
464
+ { label: "展开全部", action: () => { this.expandAll(); this.app.redraw(); } },
465
+ ];
466
+ if (row.group.workspaceId) items.push({ label: "重命名工作区", action: () => this.app.renameWorkspace(row.group) });
467
+ this.app.openMenu(items, ev);
468
+ } else {
469
+ this.app.sessionMenu({ data: row.session }, ev);
470
+ }
471
+ return true;
472
+ }
473
+ return false;
474
+ }
475
+ onKey(ev) {
476
+ if (ev.type !== "key") return false;
477
+ switch (ev.name) {
478
+ case "up": return this.move(-1);
479
+ case "down": return this.move(1);
480
+ case "pgup": this.scroll(-this.h); return true;
481
+ case "pgdn": this.scroll(this.h); return true;
482
+ case "home": this.sel = 0; this.#scrollToSel(); return true;
483
+ case "end": this.sel = this.rows.length - 1; this.#scrollToSel(); return true;
484
+ case "enter": {
485
+ const row = this.currentRow();
486
+ if (!row) return false;
487
+ if (row.kind === "group") { this.toggle(row.group); this.app.redraw(); }
488
+ else this.app.openSession(row.session.sessionId);
489
+ return true;
490
+ }
491
+ case "left": {
492
+ const row = this.currentRow();
493
+ if (row?.kind === "group" && !this.collapsed.has(row.group.key)) { this.toggle(row.group); this.app.redraw(); return true; }
494
+ if (row?.kind === "session") { this.sel = this.rows.findLastIndex((r, i) => i <= this.sel && r.kind === "group"); this.#scrollToSel(); return true; }
495
+ return false;
496
+ }
497
+ case "right": {
498
+ const row = this.currentRow();
499
+ if (row?.kind === "group" && this.collapsed.has(row.group.key)) { this.toggle(row.group); this.app.redraw(); return true; }
500
+ if (row?.kind === "group") { this.sel = Math.min(this.rows.length - 1, this.sel + 1); this.#scrollToSel(); return true; }
501
+ return false;
502
+ }
503
+ case "char":
504
+ if (!ev.ctrl && ev.key === "n") { const r = this.currentRow(); if (r?.kind === "group") { this.app.newSessionIn(r.group); return true; } return false; }
505
+ if (!ev.ctrl && (ev.key === "[" || ev.key === "]")) {
506
+ const r = this.currentRow();
507
+ if (r?.kind === "session") { this.app.moveSession(r.session, ev.key === "[" ? -1 : 1); return true; }
508
+ return false;
509
+ }
510
+ return false;
511
+ }
512
+ return false;
513
+ }
514
+ }
515
+
516
+ // ---- ChatView ----
517
+
518
+ export class ChatView extends Widget {
519
+ constructor(opts) {
520
+ super(opts);
521
+ this.app = opts.app;
522
+ this.sessionId = null;
523
+ this.title = "";
524
+ this.nodes = [];
525
+ this.lines = [];
526
+ this.expanded = new Set(); // node indexes (user-message full text)
527
+ this.expandedTools = new Set();
528
+ this.collapsedBlocks = new Set(); // per-block COLLAPSE (default expanded): `${realIdx}:${bi}`
529
+ this.thinkMode = "expanded"; // think blocks: expanded by default (t toggles)
530
+ this.bashMode = "expanded"; // tool blocks: expanded | collapsed (b toggles)
531
+ this.running = false;
532
+ this.hasMore = false;
533
+ this.loadingOlder = false;
534
+ this.minSeq = null;
535
+ this.view = new ScrollView({
536
+ x: this.x, y: this.y, w: this.w, h: this.h - 2,
537
+ autoScroll: true, title: "",
538
+ onClick: (y, ev) => this.#clickLine(y, ev),
539
+ });
540
+ this.input = new Input({
541
+ x: this.x, y: this.y + this.h - 2, w: this.w, h: 1,
542
+ multi: true, maxLines: 6,
543
+ placeholder: "输入消息…(Ctrl+J 换行,Enter 发送)",
544
+ onEnter: (v) => this.send(v),
545
+ onChange: () => this.#inputChanged(),
546
+ });
547
+ this.contextNode = null;
548
+ this.rebuildQueued = false;
549
+ this.cache = new Map(); // node render cache: key → { lines, marks }
550
+ this.cardRanges = []; // absolute line ranges of card-backed message blocks
551
+ this.welcomeModes = []; // absolute row y → agent preset id (welcome screen)
552
+ this.pressY = null;
553
+ this.selStart = null;
554
+ this.selEnd = null;
555
+ }
556
+
557
+ /** Queue a rebuild; flushed on the next frame render (throttles streaming). */
558
+ /** Merge freshly arrived events (mux frames or poll results) into the tail. */
559
+ mergeEvents(entries) {
560
+ const nodes = nodeForEvents(entries, this.app.log);
561
+ if (nodes.length === 0) return;
562
+ const last = nodes[nodes.length - 1];
563
+ const mine = this.nodes[this.nodes.length - 1];
564
+ // Only merge into the ACTIVE streaming node; a newly started turn is pushed
565
+ // as its own node (merging into a finalized turn would overwrite it).
566
+ if (last.kind === "assistant" && mine && mine.kind === "assistant" && mine.streaming) {
567
+ mine.blocks = last.blocks;
568
+ mine.images = last.images ?? mine.images;
569
+ mine.id = last.id ?? mine.id;
570
+ mine.streaming = last.streaming;
571
+ mine.finalized = false;
572
+ } else {
573
+ this.nodes.push(...nodes);
574
+ this.expanded.add(this.nodes.length - 1);
575
+ }
576
+ this.running = this.nodes.some((n) => n.kind === "assistant" && n.streaming);
577
+ this.queueRebuild();
578
+ }
579
+
580
+ /** Poll the tail of the open session (mux live path is unreliable). */
581
+ async pollTail() {
582
+ if (!this.sessionId || this.polling) return;
583
+ this.polling = true;
584
+ try {
585
+ const hist = await this.app.api.call("session.history", { sessionId: this.sessionId, maxMessages: 1 });
586
+ const events = hist.events ?? [];
587
+ const fresh = events.filter((e) => e.event.seq > (this.lastSeq ?? -1));
588
+ if (fresh.length === 0) { this.polling = false; return; }
589
+ this.lastSeq = fresh[fresh.length - 1].event.seq;
590
+ if (fresh.length > 4000) this.pollSlow = true;
591
+ this.syncTail(events);
592
+ } catch {
593
+ // transient poll failure — next tick retries
594
+ }
595
+ this.polling = false;
596
+ }
597
+
598
+ /** Idempotently re-derive the tail node(s) from the complete last message.
599
+ * Dedup by message id so already-loaded nodes are updated, never duplicated. */
600
+ syncTail(events) {
601
+ const maxSeq = events[events.length - 1]?.event?.seq ?? 0;
602
+ if (maxSeq <= (this.lastSyncedSeq ?? -1)) return;
603
+ this.lastSyncedSeq = maxSeq;
604
+ const nodes = nodeForEvents(events, this.app.log);
605
+ const lastAssistant = [...nodes].reverse().find((n) => n.kind === "assistant");
606
+ if (!lastAssistant) {
607
+ // Only user messages arrived (assistant hasn't replied yet): add new ones.
608
+ for (const n of nodes) {
609
+ if (n.kind === "user" && n.id && !this.nodes.some((x) => x.id === n.id)) {
610
+ this.nodes.push(n);
611
+ this.expanded.add(this.nodes.length - 1);
612
+ }
613
+ }
614
+ this.queueRebuild();
615
+ this.app.redraw();
616
+ return;
617
+ }
618
+ // Preserve per-block start timestamps across re-derivations so the think
619
+ // running-time counter keeps counting from the real block start, not from
620
+ // whichever poll first saw the block-start event.
621
+ const inheritStarts = (oldBlocks, newBlocks) => {
622
+ for (let bi = 0; bi < (newBlocks ?? []).length; bi++) {
623
+ const nb = newBlocks[bi];
624
+ if (nb && nb.startedAt === undefined) {
625
+ nb.startedAt = oldBlocks?.[bi]?.startedAt ?? Date.now();
626
+ }
627
+ }
628
+ };
629
+ // Already-loaded assistant (finalized turns carry a message id)?
630
+ const byId = lastAssistant.id ? [...this.nodes].reverse().find((n) => n.kind === "assistant" && n.id === lastAssistant.id) : null;
631
+ if (byId) {
632
+ inheritStarts(byId.blocks, lastAssistant.blocks);
633
+ byId.blocks = lastAssistant.blocks;
634
+ byId.streaming = lastAssistant.streaming;
635
+ byId.finalized = !lastAssistant.streaming;
636
+ } else {
637
+ const mine = this.nodes[this.nodes.length - 1];
638
+ if (mine?.kind === "assistant" && mine.streaming) {
639
+ // active streaming turn: replace blocks by position
640
+ inheritStarts(mine.blocks, lastAssistant.blocks);
641
+ mine.blocks = lastAssistant.blocks;
642
+ mine.images = lastAssistant.images ?? mine.images;
643
+ mine.id = lastAssistant.id ?? mine.id;
644
+ mine.streaming = lastAssistant.streaming;
645
+ mine.finalized = !lastAssistant.streaming;
646
+ } else {
647
+ // new turn: push only nodes that are not already present (dedup by id)
648
+ for (const n of nodes) {
649
+ if (n.kind !== "user" && n.kind !== "assistant") continue;
650
+ const dup = n.id && this.nodes.some((x) => x.id === n.id);
651
+ if (!dup) {
652
+ this.nodes.push(n);
653
+ this.expanded.add(this.nodes.length - 1);
654
+ }
655
+ }
656
+ }
657
+ }
658
+ this.running = this.nodes.some((n) => n.kind === "assistant" && n.streaming);
659
+ this.queueRebuild();
660
+ this.app.redraw();
661
+ }
662
+
663
+ jumpToNode(idx) {
664
+ if (idx < 0 || idx >= this.nodes.length) return;
665
+ for (let li = 0; li < this.lineMap.length; li++) {
666
+ if (this.lineMap[li]?.nodeIdx === idx) {
667
+ this.view.scrollY = Math.max(0, li - 2);
668
+ this.app.redraw();
669
+ return;
670
+ }
671
+ }
672
+ }
673
+
674
+ queueRebuild() { this.rebuildQueued = true; }
675
+ flushRebuild() {
676
+ if (this.rebuildQueued) {
677
+ this.rebuildQueued = false;
678
+ this.#rebuild();
679
+ }
680
+ }
681
+
682
+ #inputChanged() {
683
+ // Multi-line input grew/shrunk → reflow view vs input, keep the tail visible.
684
+ const ih = this.input.height();
685
+ const prevIh = this.input.h;
686
+ this.input.h = ih;
687
+ this.view.h = this.h - ih - 1;
688
+ this.input.y = this.y + this.h - ih;
689
+ if (ih !== prevIh) this.app.layout();
690
+ this.app.redraw();
691
+ }
692
+
693
+ resize(x, y, w, h) {
694
+ this.x = x; this.y = y; this.w = w; this.h = h;
695
+ const ih = this.input.height();
696
+ this.input.h = ih;
697
+ this.view.x = x; this.view.y = y; this.view.w = w; this.view.h = h - ih - 1;
698
+ this.input.x = x; this.input.y = y + h - ih; this.input.w = w;
699
+ this.cache.clear();
700
+ this.#rebuild();
701
+ }
702
+
703
+ async open(sessionId) {
704
+ this.sessionId = sessionId;
705
+ this.nodes = [];
706
+ this.expanded.clear();
707
+ this.expandedTools.clear();
708
+ this.collapsedBlocks.clear();
709
+ this.hasMore = false;
710
+ this.minSeq = null;
711
+ this.cache.clear();
712
+ this.app.setStatus(`加载会话 ${sessionId.slice(0, 8)}…`);
713
+ try {
714
+ const hist = await this.app.api.call("session.history", { sessionId });
715
+ this.minSeq = hist.events[0]?.event?.seq ?? null;
716
+ this.lastSeq = hist.events[hist.events.length - 1]?.event?.seq ?? null;
717
+ this.lastSyncedSeq = -1;
718
+ this.pollSlow = false;
719
+ this.hasMore = hist.hasMore;
720
+ this.nodes = nodeForEvents(hist.events, this.app.log);
721
+ this.title = hist.projections?.values?.title ?? this.title;
722
+ if (hist.projections?.values) {
723
+ this.app.projections = { ...this.app.projections, ...hist.projections.values };
724
+ }
725
+ } catch (e) {
726
+ this.nodes = [{ kind: "system", text: `加载失败: ${e.message}` }];
727
+ }
728
+ this.#rebuild();
729
+ }
730
+
731
+ async loadOlder() {
732
+ if (!this.hasMore || this.loadingOlder || this.minSeq == null) return;
733
+ this.loadingOlder = true;
734
+ this.app.setStatus("加载更早记录…");
735
+ try {
736
+ const hist = await this.app.api.call("session.history", { sessionId: this.sessionId, beforeSeq: this.minSeq, maxMessages: 20 });
737
+ if (hist.events.length === 0) { this.hasMore = false; }
738
+ else {
739
+ const before = this.lines.length;
740
+ this.minSeq = hist.events[0]?.event?.seq ?? this.minSeq;
741
+ this.hasMore = hist.hasMore;
742
+ const more = nodeForEvents(hist.events, this.app.log);
743
+ this.nodes = [...more, ...this.nodes];
744
+ }
745
+ } catch (e) {
746
+ this.app.toast(`加载更早失败: ${e.message}`);
747
+ }
748
+ this.loadingOlder = false;
749
+ this.#rebuild();
750
+ }
751
+
752
+ onFrame(frame) {
753
+ if (frame.sessionId && frame.sessionId !== this.sessionId) return;
754
+ switch (frame.type) {
755
+ case "session/event": {
756
+ this.mergeEvents([frame]);
757
+ break;
758
+ }
759
+ case "session/title": this.title = frame.title ?? this.title; this.queueRebuild(); break;
760
+ case "session/jobs": {
761
+ this.running = (frame.jobs ?? []).some((j) => j.status === "running");
762
+ this.app.setJobs(frame.jobs ?? []);
763
+ break;
764
+ }
765
+ case "session/subscribed": {
766
+ if (this.minSeq == null) this.minSeq = frame.lastSeq;
767
+ break;
768
+ }
769
+ }
770
+ }
771
+
772
+ send(text) {
773
+ if (!this.sessionId) return;
774
+ const trimmed = text.trim();
775
+ if (!trimmed) return;
776
+ const { parts, images, errors } = buildPromptParts(trimmed, {
777
+ readFile: (p) => {
778
+ try { return readFileSync(p, "base64"); } catch { return null; }
779
+ },
780
+ });
781
+ for (const e of errors) this.app.toast(`图片读取失败: ${e}`);
782
+ this.app.log(`[chat] prompt → ${this.sessionId.slice(0, 8)}: ${truncate(trimmed, 60)}${images.length ? ` (+${images.length} 图)` : ""}`);
783
+ this.app.api.call("session.prompt", {
784
+ sessionId: this.sessionId,
785
+ mode: "queue",
786
+ content: parts.filter((p) => p.type === "image" || (p.text ?? "").trim() !== ""),
787
+ clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
788
+ }).then((res) => {
789
+ if (res.command?.text) this.app.toast(res.command.text);
790
+ }).catch((e) => this.app.toast(`发送失败: ${e.message}`));
791
+ }
792
+
793
+ #clickLine(y, ev) {
794
+ // map rendered line back to node via cached line→node map
795
+ const info = this.lineMap?.[y];
796
+ if (info) {
797
+ if (info.imgIdx !== undefined) {
798
+ const node = this.nodes[info.nodeIdx];
799
+ const ref = node?.images?.[info.imgIdx];
800
+ if (ref) { this.app.openImage(ref, { all: node.images, index: info.imgIdx }); return true; }
801
+ }
802
+ const node = this.nodes[info.nodeIdx];
803
+ if (node?.kind === "assistant" && info.blockIdx !== null) {
804
+ const b = node.blocks[info.blockIdx];
805
+ if (b && (b.kind === "tool" || b.kind === "reasoning" || b.kind === "other")) {
806
+ const key = `${info.nodeIdx}:${info.blockIdx}`;
807
+ if (b.kind === "reasoning") {
808
+ // clean two-state override: expand ⇄ collapse (never a no-op click)
809
+ const open = this.expanded.has(key) || (!this.collapsedBlocks.has(key) && this.thinkMode === "expanded");
810
+ if (open) { this.expanded.delete(key); this.collapsedBlocks.add(key); }
811
+ else { this.collapsedBlocks.delete(key); this.expanded.add(key); }
812
+ } else {
813
+ if (this.collapsedBlocks.has(key)) this.collapsedBlocks.delete(key);
814
+ else this.collapsedBlocks.add(key);
815
+ }
816
+ this.#rebuild();
817
+ return true;
818
+ }
819
+ }
820
+ if (node && (node.kind === "assistant" || node.kind === "user")) {
821
+ if (this.expanded.has(info.nodeIdx)) this.expanded.delete(info.nodeIdx);
822
+ else this.expanded.add(info.nodeIdx);
823
+ this.#rebuild();
824
+ return true;
825
+ }
826
+ }
827
+ return false;
828
+ }
829
+
830
+ #rebuild() {
831
+ const w = Math.max(20, this.view.w - 2);
832
+ const lines = [];
833
+ const lineMap = [];
834
+ this.cardRanges = [];
835
+ const mark = (nodeIdx, blockIdx = null) => lineMap.push({ nodeIdx, blockIdx });
836
+ const markImg = (nodeIdx, imgIdx) => lineMap.push({ nodeIdx, imgIdx });
837
+ // render only the tail of very long sessions; earlier nodes load via pagination
838
+ const MAX_NODES = 150;
839
+ const skipCount = this.nodes.length - MAX_NODES;
840
+ const nodes = skipCount > 0 ? this.nodes.slice(skipCount) : this.nodes;
841
+ lines.push([{ t: truncate(this.title || this.sessionId?.slice(0, 8) || "", w - 2), fg: K.DIM }]);
842
+ mark(-1);
843
+ if (this.hasMore) { lines.push([{ t: "▲ 更早的记录", fg: K.FAINT }]); mark(-1); }
844
+ if (skipCount > 0) { lines.push([{ t: `… 更早 ${skipCount} 条记录(PgUp 加载)`, fg: K.FAINT }]); mark(-1); }
845
+
846
+ const MAX_TEXT = 4000;
847
+ for (let ni = 0; ni < nodes.length; ni++) {
848
+ const node = nodes[ni];
849
+ const realIdx = ni + skipCount;
850
+ // node-level render cache: only the streaming tail (and toggled nodes) re-render
851
+ const expKey = this.expanded.has(realIdx) ? "1" : "0";
852
+ const blockKeys = node.kind === "assistant" && node.blocks
853
+ ? node.blocks.map((b, bi) => {
854
+ const key = `${realIdx}:${bi}`;
855
+ if (this.collapsedBlocks.has(key)) return "c";
856
+ if (this.expanded.has(key)) return "e";
857
+ return ".";
858
+ }).join("")
859
+ : "";
860
+ const ckey = `${realIdx}|${w}|${expKey}|${blockKeys}|${this.thinkMode}|${this.bashMode}|${node.streaming ? "s" : "f"}|${themeName()}`;
861
+ // Streaming nodes re-render every frame: their text grows without any
862
+ // change to the cache key, so caching them freezes the live think/tool/text.
863
+ const hit = node.streaming ? undefined : this.cache.get(ckey);
864
+ if (hit) {
865
+ for (const [rs, re, bg] of hit.cards ?? []) {
866
+ this.cardRanges.push([lines.length + rs, lines.length + re, bg]);
867
+ }
868
+ for (const ln of hit.lines) lines.push(ln);
869
+ for (const mk of hit.marks) lineMap.push({ ...mk });
870
+ continue;
871
+ }
872
+ const cacheStart = lines.length;
873
+ const markStart = lineMap.length;
874
+ const nodeCards = []; // relative card ranges: [relStart, relEnd, bg]
875
+ // Begin a block card: every line pushed until endCard() carries bgName's
876
+ // background (pi-style per-block blocks), and a blank line separates cards.
877
+ let openCard = null;
878
+ const beginCard = (bgName) => { openCard = { start: lines.length, bg: T[bgName] }; };
879
+ const endCard = () => {
880
+ if (openCard === null) return;
881
+ const card = openCard;
882
+ openCard = null;
883
+ const end = lines.length - 1;
884
+ if (end >= card.start) {
885
+ for (let li = card.start; li <= end; li++) {
886
+ lines[li] = lines[li].map((g) => ({ ...g, bg: g.bg ?? card.bg }));
887
+ }
888
+ nodeCards.push([card.start - cacheStart, end - cacheStart, card.bg]);
889
+ this.cardRanges.push([card.start, end, card.bg]);
890
+ }
891
+ };
892
+ const sep = () => { endCard(); lines.push([{ t: "" }]); mark(realIdx); };
893
+ const renderNode = () => {
894
+ switch (node.kind) {
895
+ case "title": lines.push([{ t: "✦ " + truncate(node.text, w - 4), fg: K.DIM, italic: true }]); mark(realIdx); break;
896
+ case "system": lines.push([{ t: truncate(node.text, w - 2), fg: K.WARN }]); mark(realIdx); break;
897
+ case "user": {
898
+ const isExp = this.expanded.has(realIdx);
899
+ const text = node.text ?? "";
900
+ const shown = isExp ? text : text.slice(0, 2000);
901
+ beginCard("USERBG");
902
+ lines.push([{ t: "▎ ", fg: K.OK }]);
903
+ mark(realIdx);
904
+ for (const ln of renderMd(shown, w - 4)) { lines.push([{ t: " " }, ...ln]); mark(realIdx); }
905
+ if (!isExp && text.length > 2000) lines.push([{ t: " …", fg: K.FAINT }]);
906
+ if (node.images) {
907
+ for (let ii = 0; ii < node.images.length; ii++) {
908
+ const img = node.images[ii];
909
+ lines.push([{ t: " 🖼 " + truncate(img.name ?? img.attachmentId ?? "image", w - 12) + (img.width ? ` (${img.width}×${img.height})` : "") + " — 点击查看", fg: T.PURPLE }]);
910
+ markImg(realIdx, ii);
911
+ }
912
+ }
913
+ sep();
914
+ break;
915
+ }
916
+ case "assistant": {
917
+ const blocks = node.blocks ?? [];
918
+ if (node.streaming) {
919
+ lines.push([{ t: "◌ 生成中…", fg: K.FAINT }]);
920
+ mark(realIdx);
921
+ }
922
+ if (blocks.length === 0) { lines.push([{ t: " …", fg: K.FAINT }]); mark(realIdx); break; }
923
+ for (let bi = 0; bi < blocks.length; bi++) {
924
+ const b = blocks[bi];
925
+ if (b.kind === "reasoning") {
926
+ const key = `${realIdx}:${bi}`;
927
+ const manuallyCollapsed = this.collapsedBlocks.has(key);
928
+ const manuallyExpanded = this.expanded.has(key);
929
+ const open = manuallyExpanded || (!manuallyCollapsed && this.thinkMode === "expanded");
930
+ beginCard("THINKBG");
931
+ const thinkMeta = b.streaming
932
+ ? ` · ${fmtElapsed(Date.now() - (b.startedAt ?? Date.now()))}`
933
+ : `(${b.text?.length ?? 0} 字)`;
934
+ lines.push([{ t: "💭 思考" + (b.streaming ? "…" : "") + thinkMeta + (open ? " [t 折叠]" : " [t 展开]"), fg: K.FAINT }]);
935
+ mark(realIdx, bi);
936
+ if (open) {
937
+ for (const ln of renderMd(truncateText(b.text, MAX_TEXT), w - 4)) { lines.push([{ t: " " }, ...ln]); mark(realIdx, bi); }
938
+ } else {
939
+ // collapsed: three-line preview
940
+ for (const ln of renderMd(truncateText(b.text, 400), w - 4).slice(0, 3)) {
941
+ lines.push([{ t: " " }, ...ln.map((g) => ({ ...g, fg: K.FAINT }))]);
942
+ mark(realIdx, bi);
943
+ }
944
+ }
945
+ sep();
946
+ } else if (b.kind === "tool") {
947
+ const key = `${realIdx}:${bi}`;
948
+ const open = this.bashMode !== "collapsed" && !this.collapsedBlocks.has(key);
949
+ const exitCode = b.view?.view?.exitCode;
950
+ const status = b.result == null && !b.done ? "TOOLBG" : exitCode !== undefined && exitCode !== 0 ? "TOOLERR" : "TOOLOK";
951
+ const glyph = b.result == null && !b.done ? "⏳" : exitCode !== undefined && exitCode !== 0 ? "✗" : "✓";
952
+ const card = b.view ? renderToolCard(b.view, w, open) : [];
953
+ beginCard(status);
954
+ lines.push([
955
+ { t: open ? "▾ " : "▸ ", fg: K.ACCENT },
956
+ { t: ` ${b.name ?? "tool"}`, fg: K.TXT, bold: true },
957
+ { t: ` ${glyph}`, fg: status === "TOOLOK" ? K.OK : status === "TOOLERR" ? K.ERR : K.WARN },
958
+ ]);
959
+ mark(realIdx, bi);
960
+ if (!open) {
961
+ const summary = toolSummary(b);
962
+ if (summary) {
963
+ lines.push([{ t: " " + truncate(summary, w - 6), fg: K.FAINT }]);
964
+ mark(realIdx, bi);
965
+ }
966
+ }
967
+ if (open) {
968
+ for (const ln of card) { lines.push(ln); mark(realIdx, bi); }
969
+ if (b.args) {
970
+ for (const ln of jsonPreview(b.args, w, open)) { lines.push(ln); mark(realIdx, bi); }
971
+ }
972
+ if (b.result != null) {
973
+ lines.push([{ t: " 结果:", fg: K.DIM, underline: true }]);
974
+ mark(realIdx, bi);
975
+ const rl = truncateText(b.result, 4000).split("\n");
976
+ for (const r of rl.slice(0, 30)) { lines.push([{ t: " " + truncate(r, w - 4), fg: K.DIM }]); mark(realIdx, bi); }
977
+ if (rl.length > 30) lines.push([{ t: ` …共 ${rl.length} 行`, fg: K.FAINT }]);
978
+ }
979
+ }
980
+ sep();
981
+ } else if (b.kind === "other") {
982
+ beginCard("THINKBG");
983
+ lines.push([{ t: " " + truncate(b.text, w - 4), fg: K.DIM }]);
984
+ mark(realIdx, bi);
985
+ sep();
986
+ } else {
987
+ beginCard("CARD");
988
+ const text = b.text ?? "";
989
+ for (const ln of renderMd(text, w - 4)) { lines.push([{ t: " " }, ...ln]); mark(realIdx); }
990
+ mark(realIdx, bi);
991
+ sep();
992
+ }
993
+ }
994
+ if (node.images) {
995
+ for (let ii = 0; ii < node.images.length; ii++) {
996
+ const img = node.images[ii];
997
+ beginCard("CARD");
998
+ lines.push([{ t: " 🖼 " + truncate(img.name ?? img.attachmentId ?? "image", w - 12) + (img.width ? ` (${img.width}×${img.height})` : "") + " — 点击查看", fg: T.PURPLE }]);
999
+ markImg(realIdx, ii);
1000
+ sep();
1001
+ }
1002
+ }
1003
+ break;
1004
+ }
1005
+ default:
1006
+ lines.push([{ t: " " + truncate(JSON.stringify(node).slice(0, 100), w - 4), fg: K.FAINT }]);
1007
+ mark(realIdx);
1008
+ }
1009
+ }
1010
+ renderNode();
1011
+ endCard();
1012
+ lines.push([{ t: "" }]);
1013
+ mark(realIdx);
1014
+ if (!node.streaming) this.cache.set(ckey, {
1015
+ lines: lines.slice(cacheStart),
1016
+ marks: lineMap.slice(markStart),
1017
+ cards: nodeCards,
1018
+ });
1019
+ if (this.cache.size > 400) {
1020
+ for (const k of this.cache.keys()) { this.cache.delete(k); if (this.cache.size <= 300) break; }
1021
+ }
1022
+ }
1023
+ const q = this.app.searchQuery;
1024
+ if (q) {
1025
+ const lower = q.toLowerCase();
1026
+ lines = lines.map((ln) => ln.flatMap((seg) => {
1027
+ if (!seg.t) return [seg];
1028
+ const low = seg.t.toLowerCase();
1029
+ if (!low.includes(lower)) return [seg];
1030
+ const parts = [];
1031
+ let idx = 0;
1032
+ while (true) {
1033
+ const i = low.indexOf(lower, idx);
1034
+ if (i === -1) { if (idx < seg.t.length) parts.push({ ...seg, t: seg.t.slice(idx) }); break; }
1035
+ if (i > idx) parts.push({ ...seg, t: seg.t.slice(idx, i) });
1036
+ parts.push({ ...seg, t: seg.t.slice(i, i + q.length), bg: T.WARN, fg: T.SELFG });
1037
+ idx = i + q.length;
1038
+ }
1039
+ return parts;
1040
+ }));
1041
+ }
1042
+ this.lines = lines;
1043
+ this.lineMap = lineMap;
1044
+ this.view.setLines(lines);
1045
+ }
1046
+
1047
+ /** Blank session: whale logo + mode selection prompt (no conversation yet). */
1048
+ #renderWelcome(screen) {
1049
+ const x = this.view.x;
1050
+ const cx = x + Math.max(0, Math.floor((this.view.w - 40) / 2));
1051
+ let y = this.view.y + 1;
1052
+ const put = (t, fg, bold) => { if (y < this.view.y + this.view.h) { screen.text(cx, y, t, { fg, attrs: bold ? 1 : 0 }); } y++; };
1053
+ put("", 0, false);
1054
+ put(" DeepSeek Harness", T.HEADING, true);
1055
+ put(" dsh-neotui", T.FAINT, false);
1056
+ put("", 0, false);
1057
+ if (this.app.currentSession == null) {
1058
+ put(" 打开一个会话开始,或 Ctrl+N 新建", T.DIM, false);
1059
+ return;
1060
+ }
1061
+ put(" 请选择模式(F9 或点击下方,选择后立即生效):", T.WARN, true);
1062
+ put("", 0, false);
1063
+ this.welcomeModes = [];
1064
+ const presets = [
1065
+ ["standard", "标准模式", "完整编码 Agent(文件/Shell/检索/Skills/目标/子代理)"],
1066
+ ["code", "PTC 模式", "标准模式能力 + Code Mode SDK 单程序多步操作"],
1067
+ ["minimal", "极简模式", "仅持久 bash 与 str_replace_editor 双工具"],
1068
+ ["cordis", "创造模式", "标准模式 + 运行时检查/插件实验/预设创作"],
1069
+ ];
1070
+ for (const [id, name, desc] of presets) {
1071
+ if (y < this.view.y + this.view.h) {
1072
+ screen.text(cx, y, ` ○ ${name}`, { fg: T.ACCENT, attrs: 1 });
1073
+ screen.text(cx + 2 + strWidth(`○ ${name}`) + 1, y, truncate(desc, this.view.w - 20), { fg: T.DIM });
1074
+ this.welcomeModes[y] = id;
1075
+ }
1076
+ y++;
1077
+ }
1078
+ }
1079
+
1080
+ render(screen) {
1081
+ // Blank session: show the welcome + mode prompt instead of empty chat.
1082
+ const isBlank = this.app.sessions.find((s) => s.sessionId === this.sessionId)?.blank ?? false;
1083
+ if (this.nodes.length === 0 && isBlank) {
1084
+ this.#renderWelcome(screen);
1085
+ screen.hline(this.x, this.x + this.w - 1, this.input.y - 1, "─", { fg: T.BORDER2 });
1086
+ this.input.render(screen);
1087
+ return;
1088
+ }
1089
+ // per-block card backgrounds (pi-style): fill each block's rows with its bg
1090
+ if (this.cardRanges.length) {
1091
+ const y0 = this.view.y;
1092
+ const top = this.view.scrollY;
1093
+ const bottom = this.view.scrollY + this.view.h - 1;
1094
+ for (const [a, b, bg] of this.cardRanges) {
1095
+ const va = Math.max(a, top);
1096
+ const vb = Math.min(b, bottom);
1097
+ if (vb >= va) {
1098
+ screen.fillRect(this.view.x, y0 + (va - top), this.view.x + this.view.w - 2, y0 + (vb - top), " ", { bg });
1099
+ }
1100
+ }
1101
+ }
1102
+ this.view.render(screen);
1103
+ if (this.selStart !== null && this.selEnd !== null) {
1104
+ const y0 = Math.max(this.view.scrollY, this.selStart);
1105
+ const y1 = Math.min(this.view.scrollY + this.view.h - 1, this.selEnd);
1106
+ if (y1 >= y0) {
1107
+ screen.invertRect(this.view.x, this.view.y + (y0 - this.view.scrollY), this.view.x + this.view.w - 2, this.view.y + (y1 - this.view.scrollY));
1108
+ }
1109
+ }
1110
+ screen.hline(this.x, this.x + this.w - 1, this.input.y - 1, "─", { fg: T.BORDER2 });
1111
+ this.input.render(screen);
1112
+ }
1113
+
1114
+ onMouse(ev) {
1115
+ if (this.input.inside(ev.x, ev.y)) {
1116
+ // INSERT mode is keyboard-only; only position the cursor if already there.
1117
+ if (this.app.focused === this.input) return this.input.onMouse(ev);
1118
+ return true;
1119
+ }
1120
+ if (this.view.inside(ev.x, ev.y)) {
1121
+ this.app.focus(this);
1122
+ // Welcome-screen mode click: select the preset under the cursor.
1123
+ if (this.nodes.length === 0 && ev.kind === "press" && ev.button === 0) {
1124
+ const id = this.welcomeModes[ev.y];
1125
+ if (id) { this.app.selectPreset(id); return true; }
1126
+ }
1127
+ if (ev.kind === "wheel-up" && this.view.scrollY === 0 && this.hasMore) { this.loadOlder(); return true; }
1128
+ if (ev.kind === "press" && ev.button === 0) {
1129
+ this.pressY = ev.y - this.view.y + this.view.scrollY;
1130
+ return true;
1131
+ }
1132
+ if (ev.kind === "drag" && ev.button === 0 && this.pressY !== null) {
1133
+ const y = ev.y - this.view.y + this.view.scrollY;
1134
+ if (Math.abs(y - this.pressY) >= 1) {
1135
+ this.selStart = Math.min(this.pressY, y);
1136
+ this.selEnd = Math.max(this.pressY, y);
1137
+ this.app.redraw();
1138
+ }
1139
+ return true;
1140
+ }
1141
+ if (ev.kind === "release" && ev.button === 0 && this.pressY !== null) {
1142
+ const wasPress = this.pressY;
1143
+ this.pressY = null;
1144
+ if (this.selStart !== null && this.selEnd !== null) {
1145
+ const text = this.lines.slice(this.selStart, this.selEnd + 1).map((l) => l.map((g) => g.t).join("")).join("\n");
1146
+ const rows = this.selEnd - this.selStart + 1;
1147
+ this.selStart = this.selEnd = null;
1148
+ this.app.copyText(text);
1149
+ this.app.toast(`已复制 ${rows} 行`);
1150
+ return true;
1151
+ }
1152
+ this.selStart = this.selEnd = null;
1153
+ this.#clickLine(wasPress, ev);
1154
+ return true;
1155
+ }
1156
+ if (ev.kind === "press" && ev.button === 2) {
1157
+ const y = ev.y - this.view.y + this.view.scrollY;
1158
+ const info = this.lineMap?.[y];
1159
+ if (info) {
1160
+ const node = this.nodes[info.nodeIdx];
1161
+ const items = [
1162
+ { label: "复制消息", action: () => this.app.copyNode(info.nodeIdx) },
1163
+ { label: "展开 / 折叠", action: () => this.#clickLine(y, ev) },
1164
+ ];
1165
+ if (node?.kind === "assistant" && node.id) {
1166
+ const fb = this.app.feedbackMap.get(node.id);
1167
+ const cur = fb?.rating === "positive" ? " ✓已好评" : fb?.rating === "negative" ? " ✓已差评" : "";
1168
+ items.push({ label: "👍 好评" + cur, action: () => this.app.feedback(node.id, "positive") });
1169
+ items.push({ label: "👎 差评" + cur, action: () => this.app.feedback(node.id, "negative") });
1170
+ if (fb) items.push({ label: "删除反馈", action: () => this.app.deleteFeedback(node.id) });
1171
+ }
1172
+ items.push({ label: "加载更早记录", action: () => this.loadOlder() });
1173
+ this.app.openMenu(items, ev);
1174
+ }
1175
+ return true;
1176
+ }
1177
+ return this.view.onMouse(ev);
1178
+ }
1179
+ return false;
1180
+ }
1181
+
1182
+ onKey(ev) {
1183
+ if (ev.type === "text") {
1184
+ this.app.focus(this.input);
1185
+ this.input.insert(ev.text);
1186
+ return true;
1187
+ }
1188
+ if (ev.type !== "key") return false;
1189
+ if (this.app.focused === this.input) return false;
1190
+ if (ev.name !== "char" || ev.key !== "g") this.gKey = false;
1191
+ switch (ev.name) {
1192
+ case "up": return this.view.scroll(-3);
1193
+ case "down": return this.view.scroll(3);
1194
+ case "pgup": if (this.view.scrollY === 0) { this.loadOlder(); return true; } return this.view.scroll(-this.view.h);
1195
+ case "pgdn": return this.view.scroll(this.view.h);
1196
+ }
1197
+ if (ev.name === "char" && ev.key === "g" && !ev.ctrl) {
1198
+ if (this.gKey) { this.gKey = false; this.view.scrollY = 0; return true; }
1199
+ this.gKey = true;
1200
+ this.app.toast("再按 g 回顶");
1201
+ return true;
1202
+ }
1203
+ if (ev.name === "char" && ev.key === "G") { this.view.scrollY = this.view.maxScroll(); return true; }
1204
+ if (ev.name === "escape" && this.app.searchQuery) { this.app.searchQuery = null; this.queueRebuild(); return true; }
1205
+ if (ev.name === "escape" && this.selStart !== null) { this.selStart = this.selEnd = null; this.app.redraw(); return true; }
1206
+ if (ev.name === "char" && ev.key === "i" && !ev.ctrl) { this.app.focus(this.input); return true; }
1207
+ if (ev.name === "char" && ev.key === "/" && !ev.ctrl) { this.app.startSearch(); return true; }
1208
+ if (ev.name === "char" && ev.key === "b" && !ev.ctrl) {
1209
+ this.bashMode = this.bashMode === "collapsed" ? "expanded" : "collapsed";
1210
+ this.collapsedBlocks.clear();
1211
+ this.app.toast(this.bashMode === "collapsed" ? "工具块:折叠(b 展开)" : "工具块:展开(b 折叠)");
1212
+ this.queueRebuild();
1213
+ return true;
1214
+ }
1215
+ if (ev.name === "char" && ev.key === "t" && !ev.ctrl) {
1216
+ this.thinkMode = this.thinkMode === "collapsed" ? "expanded" : "collapsed";
1217
+ this.expanded.clear();
1218
+ this.collapsedBlocks.clear();
1219
+ this.app.toast(this.thinkMode === "expanded" ? "思考块:全部展开" : "思考块:折叠(t 切换)");
1220
+ this.queueRebuild();
1221
+ return true;
1222
+ }
1223
+ return false;
1224
+ }
1225
+ }
1226
+
1227
+ function toolSummary(b) {
1228
+ // Prefer the human description of what the tool did (the agent's `description`
1229
+ // argument), then the host card title, then the raw command — mirrors the web.
1230
+ if (b.args) {
1231
+ try {
1232
+ const a = JSON.parse(b.args);
1233
+ if (typeof a === "object" && a !== null) {
1234
+ const desc = a.description ?? a.summary ?? a.title ?? a.path ?? a.query ?? a.content ?? a.name;
1235
+ if (desc) return String(desc).slice(0, 120);
1236
+ return a.command ?? null;
1237
+ }
1238
+ return String(a).slice(0, 120);
1239
+ } catch {
1240
+ return String(b.args).slice(0, 120);
1241
+ }
1242
+ }
1243
+ if (b.view?.view?.title) return b.view.view.title;
1244
+ return null;
1245
+ }
1246
+
1247
+ function fmtElapsed(ms) {
1248
+ if (ms == null || isNaN(ms) || ms < 0) return "0.0s";
1249
+ if (ms < 1000) return `${(ms / 1000).toFixed(1)}s`;
1250
+ if (ms < 60000) return `${(ms / 1000).toFixed(0)}s`;
1251
+ return `${Math.floor(ms / 60000)}m${Math.floor((ms % 60000) / 1000)}s`;
1252
+ }
1253
+
1254
+ function fmtTokens(n) {
1255
+ if (n == null || isNaN(n)) return "0";
1256
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
1257
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + "k";
1258
+ return String(Math.round(n));
1259
+ }
1260
+
1261
+ function truncateText(s, n) {
1262
+ s = String(s ?? "");
1263
+ return s.length > n ? s.slice(0, n) + "\n…(截断)" : s;
1264
+ }
1265
+
1266
+ // ---- Question popup ----
1267
+
1268
+ export class QuestionPopup extends Popup {
1269
+ constructor({ app, frame }) {
1270
+ const questions = frame.questions ?? [];
1271
+ const w = Math.max(40, app.screen.w - 16);
1272
+ const h = Math.min(app.screen.h - 4, questions.length * 5 + 8);
1273
+ super({
1274
+ x: Math.floor((app.screen.w - w) / 2), y: Math.floor((app.screen.h - h) / 2),
1275
+ w, h, title: "❓ 需要你的回答",
1276
+ lines: ["", ...questions.map((q) => q.question ?? q.id)],
1277
+ buttons: [
1278
+ { label: "回答…", action: "custom" },
1279
+ { label: "跳过", action: "cancel" },
1280
+ ],
1281
+ });
1282
+ this.app = app;
1283
+ this.frame = frame;
1284
+ this.questions = questions;
1285
+ this.selection = questions.map((q) => (q.multiSelect ? [] : []));
1286
+ this.custom = "";
1287
+ this.mode = "choose"; // choose | options | input
1288
+ this.selIdx = 0;
1289
+ this.#layout();
1290
+ }
1291
+
1292
+ #layout() {
1293
+ const lines = [];
1294
+ for (const q of this.questions) lines.push(["", { t: q.header ?? "", fg: K.ACCENT, bold: true }]);
1295
+ this.qLines = lines;
1296
+ this.lines = lines;
1297
+ }
1298
+
1299
+ render(screen) {
1300
+ super.render(screen);
1301
+ let ly = this.y + 1;
1302
+ for (const q of this.questions) {
1303
+ screen.text(this.x + 2, ly, truncate(`▎ ${q.header ?? ""}`, this.w - 4), { fg: K.ACCENT, bold: true });
1304
+ ly++;
1305
+ screen.text(this.x + 2, ly, truncate(q.question ?? "", this.w - 4), { fg: K.TXT });
1306
+ ly++;
1307
+ const opts = q.options ?? [];
1308
+ if (opts.length) {
1309
+ for (let i = 0; i < opts.length && ly < this.y + this.h - 2; i++) {
1310
+ const sel = this.selIdx === i;
1311
+ const label = truncate(` ${sel ? "●" : "○"} ${opts[i].label}`, this.w - 6);
1312
+ screen.text(this.x + 2, ly, label, { fg: sel ? 0xffffff : K.TXT, bg: sel ? 0x3a4a5c : -1 });
1313
+ ly++;
1314
+ }
1315
+ } else {
1316
+ screen.text(this.x + 2, ly, " (自由输入)", { fg: K.FAINT });
1317
+ ly++;
1318
+ }
1319
+ ly++;
1320
+ }
1321
+ }
1322
+
1323
+ onMouse(ev) {
1324
+ if (ev.kind === "press" && ev.button === 0) {
1325
+ let ly = this.y + 1;
1326
+ for (const q of this.questions) {
1327
+ ly += 2;
1328
+ const opts = q.options ?? [];
1329
+ for (let i = 0; i < opts.length; i++, ly++) {
1330
+ if (ev.y === ly && ev.x >= this.x + 2 && ev.x < this.x + this.w - 2) {
1331
+ this.selIdx = i;
1332
+ this.answers = this.questions.map((qq, qi) => ({ id: qq.id, selected: qi === this.questions.indexOf(q) ? [opts[i].label] : [], custom: "" }));
1333
+ this.#submit();
1334
+ return true;
1335
+ }
1336
+ }
1337
+ ly++;
1338
+ }
1339
+ return super.onMouse(ev);
1340
+ }
1341
+ return super.onMouse(ev);
1342
+ }
1343
+
1344
+ onKey(ev) {
1345
+ if (ev.type === "text") { this.custom += ev.text; return true; }
1346
+ if (ev.type !== "key") return false;
1347
+ switch (ev.name) {
1348
+ case "up": this.selIdx = Math.max(0, this.selIdx - 1); return true;
1349
+ case "down": this.selIdx++; return true;
1350
+ case "enter": this.#submit(); return true;
1351
+ case "escape": this.answers = null; this.#submit(); return true;
1352
+ }
1353
+ return false;
1354
+ }
1355
+
1356
+ #submit() {
1357
+ const value = {
1358
+ sessionId: this.frame.sessionId,
1359
+ answer: {
1360
+ answers: this.answers ?? this.questions.map((q) => {
1361
+ const opts = q.options ?? [];
1362
+ const sel = opts[this.selIdx] ? [opts[this.selIdx].label] : [];
1363
+ return { id: q.id, selected: sel, custom: this.custom || undefined };
1364
+ }),
1365
+ },
1366
+ };
1367
+ this.app.api.respond(this.frame.rpcId, value).catch((e) => this.app.toast(`回答失败: ${e.message}`));
1368
+ this.app.closePopup();
1369
+ }
1370
+ }
1371
+
1372
+ // ---- Approval popup ----
1373
+
1374
+ export class ApprovalPopup extends Popup {
1375
+ constructor({ app, frame }) {
1376
+ super({
1377
+ x: Math.floor((app.screen.w - 56) / 2), y: Math.floor(app.screen.h / 2) - 3,
1378
+ w: 56, h: 7, title: "⚠ 工具需要授权",
1379
+ lines: ["", ` ${truncate(frame.toolName ?? "tool", 48)}` + (frame.reason ? ` — ${truncate(frame.reason, 30)}` : "")],
1380
+ buttons: [
1381
+ { label: "允许一次", action: "allowed-once" },
1382
+ { label: "拒绝", action: "rejected" },
1383
+ ],
1384
+ });
1385
+ this.app = app;
1386
+ this.frame = frame;
1387
+ this.btnIdx = 0;
1388
+ }
1389
+ onAction(btn) {
1390
+ if (btn.action === "__cancel__") { this.app.closePopup(); return; }
1391
+ const value = { sessionId: this.frame.sessionId, approvalId: this.frame.approvalId, outcome: btn.action };
1392
+ this.app.api.respond(this.frame.rpcId, value).catch((e) => this.app.toast(`审批失败: ${e.message}`));
1393
+ this.app.closePopup();
1394
+ }
1395
+ onKey(ev) {
1396
+ if (ev.type !== "key") return false;
1397
+ if (ev.name === "char" && (ev.key === "y" || ev.key === "Y") && !ev.ctrl) { this.onAction(this.buttons[0]); return true; }
1398
+ if (ev.name === "char" && (ev.key === "n" || ev.key === "N") && !ev.ctrl) { this.onAction(this.buttons[1]); return true; }
1399
+ return super.onKey(ev);
1400
+ }
1401
+ }
1402
+
1403
+ // ---- App ----
1404
+
1405
+ export class App {
1406
+ constructor({ screen, term, api, base, log }) {
1407
+ this.screen = screen;
1408
+ this.term = term;
1409
+ this.api = api;
1410
+ this.log = log ?? (() => {});
1411
+ this.popup = null;
1412
+ this.menu = null;
1413
+ this.toastMsg = null;
1414
+ this.toastUntil = 0;
1415
+ this.jobs = [];
1416
+ this.focused = null;
1417
+ this.provider = "";
1418
+ this.model = "";
1419
+ this.currentModel = null; // session-scoped { provider, model, reasoningEffort }
1420
+ this.connState = "connecting";
1421
+ this.tokenUsage = null;
1422
+ this.sessions = [];
1423
+ this.currentSession = null;
1424
+ this.searchActive = false;
1425
+ this.overlay = null; // Picker / Popup / ImagePopup modal
1426
+ this.mode = "chat"; // chat | workspace | trajectory
1427
+ this.sidebarVisible = true; // Ctrl+B hides the whole session pane (nvim-style)
1428
+ this.feedbackMap = new Map(); // messageId → {rating, version}
1429
+ this.searchQuery = null; // active find-in-conversation term (highlight)
1430
+ this.findQuery = null; // term being typed in the find picker
1431
+ this.projections = {}; // goal/todos/plan/sessionStats/contextPressure/…
1432
+ this.workspacePanel = null;
1433
+ this.trajectoryPanel = null;
1434
+ this.settingsPanel = null;
1435
+ this.subagentPanel = null;
1436
+ this.skillsPanel = null;
1437
+
1438
+ this.sidebar = new SidebarTree(this);
1439
+ this.sidebar.w = 30;
1440
+ this.sidebar.h = screen.h - 1;
1441
+ this.searchInput = new Input({ x: 0, y: 0, w: 30, h: 1, prompt: "/ ", placeholder: "搜索会话…" });
1442
+ this.chat = new ChatView({ x: 30, y: 0, w: screen.w - 30, h: screen.h - 1, app: this });
1443
+ this.status = new StatusBar({ x: 0, y: screen.h - 1, w: screen.w, h: 1 });
1444
+ this.focus(this.chat);
1445
+ this.layout();
1446
+ }
1447
+
1448
+ footerHeight() {
1449
+ return 2 + (this.jobs?.length ? 1 : 0);
1450
+ }
1451
+
1452
+ layout() {
1453
+ const x = this.sidebarVisible ? 30 : 0;
1454
+ const w = this.screen.w - x;
1455
+ const footerH = this.footerHeight();
1456
+ const mainH = this.screen.h - 1 - footerH;
1457
+ this.sidebar.x = 0; this.sidebar.y = 0; this.sidebar.w = 30; this.sidebar.h = this.screen.h - 1;
1458
+ this.chat.resize(x, 1, w, mainH);
1459
+ for (const p of [this.workspacePanel, this.trajectoryPanel, this.settingsPanel, this.subagentPanel, this.skillsPanel]) {
1460
+ if (p?.relayout) p.relayout(x, 1, w, mainH);
1461
+ }
1462
+ this.status.y = this.screen.h - footerH;
1463
+ this.status.h = footerH;
1464
+ this.status.w = this.screen.w;
1465
+ }
1466
+
1467
+ resize(w, h) {
1468
+ this.screen.resize(w, h);
1469
+ this.layout();
1470
+ this.redraw();
1471
+ }
1472
+
1473
+ toggleChatTrajectory() {
1474
+ if (!this.currentSession) { this.toast("先打开一个会话"); return; }
1475
+ this.setMode(this.mode === "trajectory" ? "chat" : "trajectory");
1476
+ }
1477
+
1478
+ toggleSidebar() {
1479
+ this.sidebarVisible = !this.sidebarVisible;
1480
+ this.layout();
1481
+ this.toast(this.sidebarVisible ? "侧栏显示(Ctrl+B 隐藏)" : "侧栏隐藏(Ctrl+B 恢复)");
1482
+ if (this.sidebarVisible) this.focus(this.sidebar);
1483
+ else this.focus(this.chat);
1484
+ this.redraw();
1485
+ }
1486
+
1487
+ focus(w) {
1488
+ this.focused = w;
1489
+ if (this.sidebar) this.sidebar.focused = w === this.sidebar;
1490
+ if (this.chat?.input) this.chat.inputActive = w === this.chat.input;
1491
+ }
1492
+
1493
+ openMenu(items, ev) {
1494
+ const w = Math.max(16, Math.min(40, ...items.map((i) => strWidth(i.label) + 6)));
1495
+ const h = items.length + 2;
1496
+ const x = Math.min(ev.x, this.screen.w - w);
1497
+ const y = Math.min(ev.y, this.screen.h - h - 1);
1498
+ this.menu = new Menu({ x, y, w, h, items, onAction: (it) => { this.menu = null; if (it) it.action?.(); this.redraw(); } });
1499
+ this.redraw();
1500
+ }
1501
+
1502
+ closePopup() { this.popup = null; this.redraw(); }
1503
+
1504
+ toast(msg) {
1505
+ this.toastMsg = msg;
1506
+ this.toastUntil = Date.now() + 3000;
1507
+ this.redraw();
1508
+ }
1509
+
1510
+ setStatus(msg) { this.statusMsg = msg; this.redraw(); }
1511
+ setJobs(jobs) { this.jobs = jobs; this.layout(); this.redraw(); }
1512
+
1513
+ #startPolling() {
1514
+ this.pollTimer = setInterval(() => {
1515
+ if (this.chat.sessionId) this.chat.pollTail();
1516
+ this.refreshSessions();
1517
+ }, this.chat.pollSlow ? 2000 : (this.chat.running ? 500 : 1500));
1518
+ }
1519
+
1520
+ async init() {
1521
+ try {
1522
+ const host = await this.api.call("host.describe");
1523
+ this.provider = host.provider ?? "";
1524
+ this.model = host.model ?? "";
1525
+ } catch (e) { this.log(`[app] host.describe: ${e.message}`); }
1526
+ await this.refreshSessions();
1527
+ this.api.connectMux();
1528
+ this.api.connectHost();
1529
+ this.api.onFrame = (frame) => this.#onFrame(frame);
1530
+ this.api.onHostFrame = (frame) => this.#onHostFrame(frame);
1531
+ this.api.onStateChange = (s) => { this.connState = s; this.redraw(); };
1532
+ this.#startPolling();
1533
+ }
1534
+
1535
+ async refreshSessions() {
1536
+ try {
1537
+ const [list, workspaces] = await Promise.all([
1538
+ this.api.call("session.list"),
1539
+ this.api.call("workspace.list").catch(() => ({ items: [], archivedSessionIds: [] })),
1540
+ ]);
1541
+ this.sessions = [...list.items].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
1542
+ this.workspaceItems = workspaces.items ?? [];
1543
+ this.sidebar.setData(this.workspaceItems, this.sessions, workspaces.archivedSessionIds ?? [], this.currentSession);
1544
+ this.redraw();
1545
+ } catch (e) {
1546
+ this.toast(`会话列表加载失败: ${e.message}`);
1547
+ }
1548
+ }
1549
+
1550
+ #onFrame(frame) {
1551
+ this.injectFrame(frame);
1552
+ }
1553
+
1554
+ /** Public entry for frame injection (scripted tests, future RPC). */
1555
+ injectFrame(frame) {
1556
+ switch (frame.type) {
1557
+ case "question/requested": {
1558
+ this.popup = new QuestionPopup({ app: this, frame: { ...frame, rpcId: frame.__rpcId } });
1559
+ break;
1560
+ }
1561
+ case "approval/requested": {
1562
+ this.popup = new ApprovalPopup({ app: this, frame: { ...frame, rpcId: frame.__rpcId } });
1563
+ break;
1564
+ }
1565
+ case "session/event":
1566
+ case "session/title":
1567
+ case "session/jobs":
1568
+ case "session/subscribed":
1569
+ case "session/queue":
1570
+ if (this.chat.sessionId === frame.sessionId) {
1571
+ this.chat.onFrame(frame);
1572
+ }
1573
+ // refresh list on title updates
1574
+ if (frame.type === "session/title") this.refreshSessions();
1575
+ break;
1576
+ case "session/projection":
1577
+ this.projections[frame.key] = frame.value;
1578
+ if (frame.key === "tokenUsage") this.tokenUsage = frame.value;
1579
+ break;
1580
+ case "approval/resolved":
1581
+ case "question/resolved":
1582
+ case "stream/error":
1583
+ this.log(`[frame] ${frame.type}`);
1584
+ break;
1585
+ default:
1586
+ this.log(`[frame] unknown: ${frame.type}`);
1587
+ }
1588
+ this.redraw();
1589
+ }
1590
+
1591
+ #onHostFrame(frame) {
1592
+ if (frame.type === "host/session-added" || frame.type === "host/session-removed") {
1593
+ this.refreshSessions();
1594
+ }
1595
+ this.redraw();
1596
+ }
1597
+
1598
+ sessionMenu(item, ev) {
1599
+ const s = item.data;
1600
+ this.openMenu([
1601
+ { label: "打开", action: () => this.openSession(s.sessionId) },
1602
+ { label: "重命名", action: () => this.renameSession(s) },
1603
+ { label: "上移", action: () => this.moveSession(s, -1) },
1604
+ { label: "下移", action: () => this.moveSession(s, 1) },
1605
+ { label: s.running ? "停止运行" : "继续对话", action: () => s.running ? this.cancelSession(s) : this.openSession(s.sessionId) },
1606
+ { label: "复制会话 ID", action: () => this.copyText(s.sessionId) },
1607
+ { label: "分叉会话", action: () => this.forkSession(s) },
1608
+ { label: "导出日志 (zip)", action: () => this.exportSession(s) },
1609
+ { label: "新建会话", action: () => this.newSession() },
1610
+ ], ev);
1611
+ }
1612
+
1613
+ /** Move a session up/down within its workspace (durable display order). */
1614
+ async moveSession(sess, delta) {
1615
+ const ws = this.workspaceItems?.find((w) => (w.sessionIds ?? []).includes(sess.sessionId));
1616
+ if (!ws) { this.toast("该会话不在工作区内"); return; }
1617
+ const ids = ws.sessionIds;
1618
+ const idx = ids.indexOf(sess.sessionId);
1619
+ if (idx < 0) return;
1620
+ const target = idx + delta;
1621
+ if (target < 0 || target >= ids.length) return;
1622
+ // insert before ids[target] for up; before ids[target+1] (or append) for down
1623
+ const beforeSessionId = delta === -1 ? ids[target] : (target + 1 < ids.length ? ids[target + 1] : undefined);
1624
+ try {
1625
+ await this.api.call("workspace.insertSessionBefore", {
1626
+ workspaceId: ws.workspaceId, sessionId: sess.sessionId,
1627
+ ...(beforeSessionId !== undefined ? { beforeSessionId } : {}),
1628
+ });
1629
+ await this.refreshSessions();
1630
+ } catch (e) { this.toast(`移动失败: ${e.message}`); }
1631
+ }
1632
+
1633
+ renameSession(s) {
1634
+ this.closeOverlay();
1635
+ const input = new Input({ x: 2, y: this.screen.h - 3, w: this.screen.w - 4, h: 1, prompt: "标题: ", allowEmptyEnter: true, onEnter: () => this.#commitRename(s, input) });
1636
+ input.setValue(s.projections?.values?.title ?? "", { select: true });
1637
+ this.renameInput = input;
1638
+ this.popup = new Popup({
1639
+ x: 1, y: this.screen.h - 4, w: this.screen.w - 2, h: 3, title: "重命名会话",
1640
+ lines: [], buttons: [{ label: "保存", action: "save" }, { label: "取消", action: "cancel" }],
1641
+ onAction: (btn) => {
1642
+ if (btn.action === "save") this.#commitRename(s, input);
1643
+ else this.#closeRename();
1644
+ },
1645
+ });
1646
+ this.focus(input);
1647
+ this.redraw();
1648
+ }
1649
+
1650
+ #commitRename(s, input) {
1651
+ const title = input.value.trim();
1652
+ if (title === "") { this.toast("标题不能为空"); return; }
1653
+ this.api.call("session.rename", { sessionId: s.sessionId, title })
1654
+ .then(() => { this.#closeRename(); this.refreshSessions(); })
1655
+ .catch((e) => this.toast(`重命名失败: ${e.message}`));
1656
+ }
1657
+
1658
+ #closeRename() {
1659
+ this.popup = null;
1660
+ this.renameInput = null;
1661
+ this.focus(this.chat);
1662
+ this.redraw();
1663
+ }
1664
+
1665
+ renameWorkspace(group) {
1666
+ this.closeOverlay();
1667
+ const input = new Input({ x: 2, y: this.screen.h - 3, w: this.screen.w - 4, h: 1, prompt: "工作区: ", allowEmptyEnter: true, onEnter: () => this.#commitWorkspaceRename(group, input) });
1668
+ input.setValue(group.title, { select: true });
1669
+ this.renameInput = input;
1670
+ this.focus(input);
1671
+ this.popup = new Popup({
1672
+ x: 1, y: this.screen.h - 4, w: this.screen.w - 2, h: 3, title: "重命名工作区",
1673
+ lines: [], buttons: [{ label: "保存", action: "save" }, { label: "取消", action: "cancel" }],
1674
+ onAction: (btn) => {
1675
+ if (btn.action === "save") this.#commitWorkspaceRename(group, input);
1676
+ else this.#closeRename();
1677
+ },
1678
+ });
1679
+ this.redraw();
1680
+ }
1681
+
1682
+ #commitWorkspaceRename(group, input) {
1683
+ const title = input.value.trim();
1684
+ if (title === "") { this.toast("标题不能为空"); return; }
1685
+ this.api.call("workspace.rename", { workspaceId: group.workspaceId, title })
1686
+ .then(() => { this.#closeRename(); this.refreshSessions(); })
1687
+ .catch((e) => this.toast(`重命名失败: ${e.message}`));
1688
+ }
1689
+
1690
+ async forkSession(s) {
1691
+ try {
1692
+ const { sessionId } = await this.api.call("session.fork", { sessionId: s.sessionId });
1693
+ await this.refreshSessions();
1694
+ this.openSession(sessionId);
1695
+ this.toast(`已分叉: ${sessionId.slice(0, 8)}`);
1696
+ } catch (e) { this.toast(`分叉失败: ${e.message}`); }
1697
+ }
1698
+
1699
+ async loadFeedback() {
1700
+ if (!this.currentSession) return;
1701
+ try {
1702
+ const res = await this.api.rpcCall("messageFeedback/list", { sessionId: this.currentSession });
1703
+ this.feedbackMap = new Map();
1704
+ for (const item of res.items ?? []) this.feedbackMap.set(item.messageId, item);
1705
+ } catch { this.feedbackMap = new Map(); }
1706
+ }
1707
+
1708
+ async feedback(messageId, rating) {
1709
+ const existing = this.feedbackMap?.get(messageId);
1710
+ try {
1711
+ const item = await this.api.rpcCall("messageFeedback/put", {
1712
+ sessionId: this.currentSession, messageId, rating, ifVersion: existing?.version ?? null,
1713
+ });
1714
+ this.feedbackMap = this.feedbackMap ?? new Map();
1715
+ this.feedbackMap.set(messageId, item);
1716
+ this.toast(rating === "positive" ? "已记录 👍" : "已记录 👎");
1717
+ } catch (e) { this.toast(`反馈失败: ${e.message}`); }
1718
+ }
1719
+
1720
+ async deleteFeedback(messageId) {
1721
+ const existing = this.feedbackMap?.get(messageId);
1722
+ if (!existing) return;
1723
+ try {
1724
+ await this.api.rpcCall("messageFeedback/delete", { sessionId: this.currentSession, messageId, ifVersion: existing.version });
1725
+ this.feedbackMap.delete(messageId);
1726
+ this.toast("已删除反馈");
1727
+ } catch (e) { this.toast(`删除反馈失败: ${e.message}`); }
1728
+ }
1729
+
1730
+ findInConversation() {
1731
+ if (!this.chat.nodes.length) { this.toast("没有会话内容"); return; }
1732
+ this.findQuery = null;
1733
+ const items = [];
1734
+ this.chat.nodes.forEach((node, i) => {
1735
+ let text = "";
1736
+ if (node.kind === "user") text = node.text ?? "";
1737
+ else if (node.kind === "assistant") {
1738
+ text = (node.blocks ?? []).map((b) => (b.kind === "text" ? b.text : b.kind === "tool" ? `[${b.name}]` : "")).join(" ");
1739
+ }
1740
+ if (text.trim()) items.push({ label: truncate(text.replace(/\s+/g, " "), 60), hint: node.kind, idx: i, keywords: text });
1741
+ });
1742
+ const w = Math.min(70, this.screen.w - 4), h = Math.min(20, this.screen.h - 4);
1743
+ let picker;
1744
+ picker = new Picker({
1745
+ x: Math.floor((this.screen.w - w) / 2), y: Math.floor((this.screen.h - h) / 2),
1746
+ w, h, title: "会话内搜索", items,
1747
+ onCancel: () => this.closeOverlay(),
1748
+ onPick: (it) => { this.searchQuery = picker.query || null; this.closeOverlay(); this.chat.jumpToNode(it.idx); },
1749
+ });
1750
+ this.overlay = picker;
1751
+ this.redraw();
1752
+ }
1753
+
1754
+ async exportSession(s) {
1755
+ this.toast("导出中…");
1756
+ try {
1757
+ const res = await fetch(`${this.api.base}/api/session.export?sessionId=${encodeURIComponent(s.sessionId)}`);
1758
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1759
+ const buf = Buffer.from(await res.arrayBuffer());
1760
+ const { writeFileSync } = await import("node:fs");
1761
+ const { join } = await import("node:path");
1762
+ const file = join(process.cwd(), `session-${s.sessionId.slice(0, 8)}-${Date.now()}.zip`);
1763
+ writeFileSync(file, buf);
1764
+ this.toast(`已导出 ${Math.round(buf.length / 1024)}KB → ${file}`);
1765
+ } catch (e) { this.toast(`导出失败: ${e.message}`); }
1766
+ }
1767
+
1768
+ async cancelSession(s) {
1769
+ await this.api.call("session.cancel", { sessionId: s.sessionId }).catch((e) => this.toast(e.message));
1770
+ this.refreshSessions();
1771
+ }
1772
+
1773
+ async newSessionIn(group = null) {
1774
+ // Reuse an existing empty draft instead of minting a fresh blank session on
1775
+ // every "new session" click (this is how the meaningless blank sessions pile up).
1776
+ const blank = this.sessions.find((s) => s.blank && !s.running && s.sessionId !== this.currentSession);
1777
+ if (blank && (group?.workspaceId == null || this.#sessionInWorkspace(blank.sessionId, group.workspaceId))) {
1778
+ await this.refreshSessions();
1779
+ this.openSession(blank.sessionId);
1780
+ this.toast("已打开空白会话(复用草稿)");
1781
+ return;
1782
+ }
1783
+ this.toast("创建会话…");
1784
+ try {
1785
+ const payload = group?.workspaceId != null
1786
+ ? { workspaceId: group.workspaceId }
1787
+ : { cwd: group?.path ?? process.cwd() };
1788
+ const { sessionId } = await this.api.call("session.create", payload);
1789
+ await this.refreshSessions();
1790
+ this.openSession(sessionId);
1791
+ } catch (e) { this.toast(`创建失败: ${e.message}`); }
1792
+ }
1793
+
1794
+ #sessionInWorkspace(sessionId, workspaceId) {
1795
+ const ws = this.workspaceItems?.find((w) => w.workspaceId === workspaceId);
1796
+ return ws?.sessionIds?.includes(sessionId) ?? false;
1797
+ }
1798
+
1799
+ async newSession() { return this.newSessionIn(null); }
1800
+
1801
+ async openSession(sessionId) {
1802
+ this.currentSession = sessionId;
1803
+ await this.chat.open(sessionId);
1804
+ this.loadFeedback();
1805
+ this.updateModel();
1806
+ this.redraw();
1807
+ }
1808
+
1809
+ /** Read the session's own model selection (provider/model/reasoning effort). */
1810
+ async updateModel() {
1811
+ if (!this.currentSession) { this.currentModel = null; return; }
1812
+ try {
1813
+ const res = await this.api.call("session.models", { sessionId: this.currentSession });
1814
+ this.currentModel = res.current ?? null;
1815
+ } catch { this.currentModel = null; }
1816
+ }
1817
+
1818
+ copyText(text) {
1819
+ // OSC 52 clipboard write
1820
+ const b64 = Buffer.from(text).toString("base64");
1821
+ this.term.output.write(`\x1b]52;c;${b64}\x07`);
1822
+ this.toast("已复制到剪贴板(若终端支持 OSC 52)");
1823
+ }
1824
+
1825
+ copyNode(nodeIdx) {
1826
+ const node = this.chat.nodes[nodeIdx];
1827
+ if (!node) return;
1828
+ const text = node.kind === "user" ? node.text : node.blocks?.map((b) => b.text ?? "").join("\n");
1829
+ this.copyText(text ?? "");
1830
+ }
1831
+
1832
+ setMode(mode) {
1833
+ this.mode = mode;
1834
+ if (mode === "workspace") {
1835
+ if (!this.workspacePanel) {
1836
+ this.workspacePanel = new WorkspacePanel(this);
1837
+ this.workspacePanel.load();
1838
+ } else {
1839
+ this.workspacePanel.load();
1840
+ }
1841
+ } else if (mode === "trajectory") {
1842
+ if (!this.currentSession) { this.toast("先打开一个会话"); this.mode = "chat"; this.redraw(); return; }
1843
+ if (!this.trajectoryPanel) this.trajectoryPanel = new TrajectoryPanel(this);
1844
+ this.trajectoryPanel.load(this.currentSession);
1845
+ } else if (mode === "settings") {
1846
+ if (!this.settingsPanel) this.settingsPanel = new SettingsPanel(this);
1847
+ this.settingsPanel.load();
1848
+ } else if (mode === "subagent") {
1849
+ if (!this.currentSession) { this.toast("先打开一个会话"); this.mode = "chat"; this.redraw(); return; }
1850
+ if (!this.subagentPanel) this.subagentPanel = new SubagentPanel(this);
1851
+ this.subagentPanel.load(this.currentSession);
1852
+ } else if (mode === "skills") {
1853
+ if (!this.currentSession) { this.toast("先打开一个会话"); this.mode = "chat"; this.redraw(); return; }
1854
+ if (!this.skillsPanel) this.skillsPanel = new SkillsPanel(this);
1855
+ this.skillsPanel.load();
1856
+ }
1857
+ this.layout();
1858
+ this.redraw();
1859
+ }
1860
+
1861
+ panelForMode() {
1862
+ switch (this.mode) {
1863
+ case "workspace": return this.workspacePanel;
1864
+ case "trajectory": return this.trajectoryPanel;
1865
+ case "settings": return this.settingsPanel;
1866
+ case "subagent": return this.subagentPanel;
1867
+ case "skills": return this.skillsPanel;
1868
+ default: return null;
1869
+ }
1870
+ }
1871
+
1872
+ closeOverlay() { this.overlay = null; this.redraw(); }
1873
+
1874
+ openSessionPicker() {
1875
+ const w = Math.min(70, this.screen.w - 4), h = Math.min(20, this.screen.h - 4);
1876
+ this.overlay = new Picker({
1877
+ x: Math.floor((this.screen.w - w) / 2), y: Math.floor((this.screen.h - h) / 2),
1878
+ w, h, title: "打开会话",
1879
+ items: this.sessions.map((ss) => ({
1880
+ label: ss.projections?.values?.title ?? ss.sessionId.slice(0, 8),
1881
+ hint: ss.origin === "subagent" ? "子代理" : ss.cwd ?? "",
1882
+ action: () => this.openSession(ss.sessionId),
1883
+ keywords: ss.sessionId,
1884
+ })),
1885
+ onCancel: () => this.closeOverlay(),
1886
+ onPick: (it) => { this.overlay = null; it.action(); this.redraw(); },
1887
+ });
1888
+ this.redraw();
1889
+ }
1890
+
1891
+ renameCurrent() {
1892
+ const s = this.sessions.find((x) => x.sessionId === this.currentSession);
1893
+ if (s) this.renameSession(s);
1894
+ else this.toast("先打开一个会话");
1895
+ }
1896
+
1897
+ showJobs() { this.overlay = buildJobsPopup(this); this.redraw(); }
1898
+ showGoal() { this.overlay = buildGoalPopup(this); this.redraw(); }
1899
+ showModePicker() { this.overlay = buildModePicker(this); this.redraw(); }
1900
+ showPermissionPicker() { this.overlay = buildPermissionPicker(this); this.redraw(); }
1901
+
1902
+ /** Select one of the four agent presets (modes). */
1903
+ async selectPreset(id) {
1904
+ if (!this.currentSession) { this.toast("先打开一个会话"); return; }
1905
+ const sess = this.sessions.find((s) => s.sessionId === this.currentSession);
1906
+ if (sess && !sess.blank) {
1907
+ this.toast(`当前会话已开始(模式固定);已设为新会话默认`);
1908
+ this.setDefaultPreset(id);
1909
+ return;
1910
+ }
1911
+ try {
1912
+ await this.api.call("agentPreset.select", { sessionId: this.currentSession, agentPreset: id });
1913
+ this.toast(`模式已切换: ${modeName(id)}`);
1914
+ this.refreshSessions();
1915
+ } catch (e) {
1916
+ if (e.code === "agent-preset-locked") { this.toast("会话已开始,模式固定;已设为新会话默认"); this.setDefaultPreset(id); }
1917
+ else this.toast(`切换失败: ${e.message}`);
1918
+ }
1919
+ }
1920
+
1921
+ async setDefaultPreset(id) {
1922
+ try {
1923
+ const d = await this.api.call("settings.describe");
1924
+ const ns = (d.namespaces ?? []).find((n) => n.ns === "agent-presets");
1925
+ if (!ns) { this.toast("此部署不支持设置默认模式"); return; }
1926
+ await this.api.call("settings.mutate", { ns: "agent-presets", ops: [{ op: "set", path: ["default"], value: id }], expectedRevision: ns.revision });
1927
+ this.toast(`新会话默认模式: ${modeName(id)}`);
1928
+ } catch (e) { this.toast(`设置默认模式失败: ${e.message}`); }
1929
+ }
1930
+
1931
+ /** Switch the current session's permission preset (three-way). */
1932
+ switchPermission(preset) {
1933
+ if (!this.currentSession) { this.toast("先打开一个会话"); return; }
1934
+ const current = this.projections.permissions?.currentValue;
1935
+ if (preset === current) return;
1936
+ if (preset === "danger-full-access") {
1937
+ const w = Math.min(64, this.screen.w - 4);
1938
+ this.overlay = new Popup({
1939
+ x: Math.floor((this.screen.w - w) / 2), y: Math.floor(this.screen.h / 2) - 3,
1940
+ w, h: 7, title: "确认启用完全访问?",
1941
+ lines: ["", " 减少确认步骤,可直接执行敏感操作、文件修改或外部命令。"],
1942
+ buttons: [{ label: "取消", action: "cancel" }, { label: "启用", action: "confirm" }],
1943
+ onAction: (btn) => {
1944
+ this.closeOverlay();
1945
+ if (btn.action === "confirm") this.doSwitchPermission(preset);
1946
+ },
1947
+ });
1948
+ this.redraw();
1949
+ return;
1950
+ }
1951
+ this.doSwitchPermission(preset);
1952
+ }
1953
+
1954
+ async doSwitchPermission(preset) {
1955
+ try {
1956
+ const res = await this.api.rpcCall("commands/execute", { agentId: this.currentSession, line: `/permission ${preset}` });
1957
+ const text = res?.result?.text ?? "";
1958
+ this.toast(`权限已切换: ${text || permName(preset)}`);
1959
+ } catch (e) { this.toast(`权限切换失败: ${e.message}`); }
1960
+ }
1961
+
1962
+ /** F8: cycle read-only → workspace-write → danger-full-access (full access gates). */
1963
+ rotatePermission() {
1964
+ const order = ["read-only", "workspace-write", "danger-full-access"];
1965
+ const cur = this.projections.permissions?.currentValue;
1966
+ const idx = order.indexOf(cur);
1967
+ const next = order[(idx + 1) % order.length];
1968
+ this.switchPermission(next);
1969
+ }
1970
+
1971
+ openImage(ref, opts = {}) {
1972
+ this.overlay = new ImagePopup({ app: this, ref, sessionId: this.currentSession, refs: opts.all, index: opts.index ?? 0 });
1973
+ this.redraw();
1974
+ }
1975
+
1976
+ get goalData() { return this.projections.goal; }
1977
+ get todos() { return this.projections.todos; }
1978
+ get goalText() {
1979
+ const g = this.projections.goal?.goal ?? this.projections.goal;
1980
+ return typeof g === "string" ? g : (g?.objective ?? null);
1981
+ }
1982
+
1983
+ #modeTabs() {
1984
+ // The two Shift+Tab flip targets; panels surface as an extra active tab.
1985
+ return [
1986
+ ["chat", "对话"],
1987
+ ["trajectory", "轨迹"],
1988
+ ];
1989
+ }
1990
+
1991
+ #panelLabel(mode) {
1992
+ return { workspace: "工作区", settings: "设置", skills: "技能", subagent: "子代理" }[mode] ?? null;
1993
+ }
1994
+
1995
+ #renderTabBar(s) {
1996
+ const x = this.sidebarVisible ? 30 : 0;
1997
+ const w = this.screen.w - x;
1998
+ s.fillRect(x, 0, x + w - 1, 0, " ", { bg: T.PANEL });
1999
+ const tabs = [...this.#modeTabs()];
2000
+ const panelLabel = this.#panelLabel(this.mode);
2001
+ if (panelLabel) tabs.push([this.mode, panelLabel]);
2002
+ let tx = x;
2003
+ for (const [id, label] of tabs) {
2004
+ const sel = id === this.mode || (id === "chat" && this.mode !== "trajectory" && !panelLabel);
2005
+ const seg = ` ${label} `;
2006
+ s.text(tx, 0, seg, { fg: sel ? T.SELFG : T.DIM, bg: sel ? T.ACCENT : T.PANEL, attrs: sel ? 1 : 0 });
2007
+ tx += strWidth(seg);
2008
+ }
2009
+ if (this.currentSession == null) s.text(x + w - 16, 0, "未选会话", { fg: T.FAINT, bg: T.PANEL });
2010
+ const modeLabel = this.focused === this.chat?.input ? " INSERT " : " NORMAL ";
2011
+ const modeColor = this.focused === this.chat?.input ? T.OK : T.FAINT;
2012
+ s.text(x + w - strWidth(modeLabel), 0, modeLabel, { fg: modeColor, bg: T.PANEL, attrs: 1 });
2013
+ }
2014
+
2015
+ #clickTab(px) {
2016
+ const x = this.sidebarVisible ? 30 : 0;
2017
+ const tabs = [...this.#modeTabs()];
2018
+ const panelLabel = this.#panelLabel(this.mode);
2019
+ if (panelLabel) tabs.push([this.mode, panelLabel]);
2020
+ let tx = x;
2021
+ for (const [id, label] of tabs) {
2022
+ const seg = ` ${label} `;
2023
+ if (px >= tx && px < tx + strWidth(seg)) {
2024
+ if (id === this.mode && panelLabel && id !== "chat" && id !== "trajectory") { /* click active panel tab: stay */ return true; }
2025
+ this.setMode(id === "chat" || id === "trajectory" ? id : "chat");
2026
+ return true;
2027
+ }
2028
+ tx += strWidth(seg);
2029
+ }
2030
+ return false;
2031
+ }
2032
+
2033
+ // ---- dispatch ----
2034
+
2035
+ onEvent(ev) {
2036
+ if (ev.type === "resize") { this.resize(ev.w, ev.h); return; }
2037
+ if (this.swallowRelease && ev.type === "mouse" && ev.kind === "release") {
2038
+ this.swallowRelease = false;
2039
+ return; // a press just closed an overlay; eat its matching release
2040
+ }
2041
+ this.swallowRelease = false;
2042
+ // The rename/workspace inline editor owns the keyboard while it is open,
2043
+ // so typed text reaches the input (the popup below only handles buttons).
2044
+ if (this.renameInput) {
2045
+ if (ev.type === "key" && ev.name === "escape") { this.#closeRename(); return; }
2046
+ if (ev.type === "key" || ev.type === "text") { this.renameInput.onKey(ev); }
2047
+ this.redraw();
2048
+ return;
2049
+ }
2050
+ if (this.popup) {
2051
+ const before = this.popup;
2052
+ if (ev.type === "key" || ev.type === "text") this.popup.onKey(ev);
2053
+ else if (ev.type === "mouse") {
2054
+ this.popup.onMouse(ev);
2055
+ if (ev.kind === "press" && this.popup !== before) this.swallowRelease = true;
2056
+ }
2057
+ this.redraw();
2058
+ return;
2059
+ }
2060
+ if (this.menu) {
2061
+ const before = this.menu;
2062
+ if (ev.type === "key") this.menu.onKey(ev);
2063
+ else if (ev.type === "mouse") {
2064
+ if (ev.kind === "press" && ev.button === 0 && !this.menu.inside(ev.x, ev.y)) { this.menu = null; this.swallowRelease = true; }
2065
+ else {
2066
+ this.menu.onMouse(ev);
2067
+ if (ev.kind === "press" && this.menu !== before) this.swallowRelease = true;
2068
+ }
2069
+ }
2070
+ this.redraw();
2071
+ return;
2072
+ }
2073
+ if (this.overlay) {
2074
+ const before = this.overlay;
2075
+ if (ev.type === "key" || ev.type === "text") this.overlay.onKey(ev);
2076
+ else if (ev.type === "mouse") {
2077
+ // a press outside the modal closes it (Esc equivalent) and never
2078
+ // leaks through to the pane underneath.
2079
+ if (ev.kind === "press" && ev.button === 0 && !this.overlay.inside(ev.x, ev.y)) {
2080
+ if (typeof this.overlay.onCancel === "function") this.overlay.onCancel();
2081
+ else if (typeof this.overlay.onAction === "function") this.overlay.onAction({ label: "__cancel__", action: "__cancel__" }, -1);
2082
+ this.swallowRelease = true;
2083
+ } else {
2084
+ this.overlay.onMouse(ev);
2085
+ if (ev.kind === "press" && this.overlay !== before) this.swallowRelease = true;
2086
+ }
2087
+ }
2088
+ this.redraw();
2089
+ return;
2090
+ }
2091
+
2092
+ // tab bar clicks (row 0 of the main area)
2093
+ if (ev.type === "mouse" && ev.kind === "press" && ev.button === 0 && ev.y === 0 && ev.x >= (this.sidebarVisible ? 30 : 0)) {
2094
+ if (this.#clickTab(ev.x)) { this.redraw(); return; }
2095
+ }
2096
+ // mouse routes by position (click = focus + dispatch)
2097
+ if (this.mode !== "chat") {
2098
+ if (ev.type === "mouse" && this.sidebarVisible && this.sidebar.inside(ev.x, ev.y)) {
2099
+ this.focus(this.sidebar);
2100
+ if (this.sidebar.onMouse(ev)) this.redraw();
2101
+ return;
2102
+ }
2103
+ const panel = this.panelForMode();
2104
+ if (panel) {
2105
+ const handled = ev.type === "key" || ev.type === "text" ? panel.onKey(ev) : panel.onMouse(ev);
2106
+ if (handled) { this.redraw(); return; }
2107
+ }
2108
+ // unhandled keys fall through to global shortcuts
2109
+ }
2110
+ if (ev.type === "mouse") {
2111
+ // Pure mouse motion must never change focus/mode (vim-style: INSERT is
2112
+ // keyboard-only). It reaches just the already-focused widget (drag select).
2113
+ if (ev.motion) {
2114
+ if (this.focused?.onMouse?.(ev)) this.redraw();
2115
+ return;
2116
+ }
2117
+ if (this.sidebarVisible && this.sidebar.inside(ev.x, ev.y)) {
2118
+ this.focus(this.sidebar);
2119
+ if (this.sidebar.onMouse(ev)) this.redraw();
2120
+ } else if (this.chat.input.inside(ev.x, ev.y)) {
2121
+ // Clicking the input only positions the cursor while already in INSERT
2122
+ // mode; it never enters INSERT on its own (i / Esc control the mode).
2123
+ if (this.focused === this.chat.input) {
2124
+ if (this.chat.input.onMouse(ev)) this.redraw();
2125
+ } else if (ev.kind === "press") {
2126
+ this.toast("按 i 进入输入(vim 式)");
2127
+ }
2128
+ } else if (this.chat.inside(ev.x, ev.y)) {
2129
+ this.focus(this.chat);
2130
+ if (this.chat.onMouse(ev)) this.redraw();
2131
+ } else if (this.focused?.onMouse(ev)) {
2132
+ this.redraw();
2133
+ }
2134
+ return;
2135
+ }
2136
+ // global keys
2137
+ if (ev.type === "key") {
2138
+ if (this.searchActive && (ev.ctrl && ev.key === " " || ev.name === "f7")) {
2139
+ this.overlay = new ControlPanel(this, { startPage: 0 });
2140
+ this.redraw();
2141
+ return;
2142
+ }
2143
+ if (this.searchActive) {
2144
+ this.#onSearchKey(ev);
2145
+ this.redraw();
2146
+ return;
2147
+ }
2148
+ if ((ev.ctrl && ev.key === " ") || ev.name === "f7") {
2149
+ this.overlay = new ControlPanel(this, { startPage: 0 });
2150
+ this.redraw();
2151
+ return;
2152
+ }
2153
+ if (ev.name === "backtab" && !ev.ctrl) {
2154
+ if (this.mode === "trajectory") this.setMode("chat");
2155
+ else if (this.mode === "chat") this.setMode("trajectory");
2156
+ this.redraw();
2157
+ return;
2158
+ }
2159
+ if (ev.ctrl && ev.key === "q") { this.stop(); return; }
2160
+ if (ev.ctrl && ev.key === "n") { this.focus(this.chat); this.redraw(); return; }
2161
+ if (ev.ctrl && ev.key === "b") { this.toggleSidebar(); return; }
2162
+ if (ev.ctrl && ev.key === "p") { this.overlay = new ControlPanel(this, { startPage: 1 }); this.redraw(); return; }
2163
+ if (ev.ctrl && ev.key === "m") { this.overlay = buildModelPicker(this); this.redraw(); return; }
2164
+ if (ev.name === "f8") { this.rotatePermission(); return; }
2165
+ if (ev.name === "f9") { this.showModePicker(); return; }
2166
+ if (ev.ctrl && ev.key === "w") { this.setMode("workspace"); return; }
2167
+ if (ev.ctrl && ev.key === "t") { this.setMode("trajectory"); return; }
2168
+ if (ev.ctrl && ev.key === "j") { this.showJobs(); return; }
2169
+ if (ev.ctrl && ev.key === "g") { this.showGoal(); return; }
2170
+ if (ev.ctrl && ev.key === "f") { this.findInConversation(); return; }
2171
+ if (ev.ctrl && ev.key === "s") { this.setMode("settings"); return; }
2172
+ if (ev.ctrl && ev.key === "a") { this.setMode("subagent"); return; }
2173
+ if (ev.ctrl && ev.key === "k") { this.setMode("skills"); return; }
2174
+ if (ev.name === "char" && ev.key === "/" && !ev.ctrl && this.focused !== this.chat.input) { this.startSearch(); this.redraw(); return; }
2175
+ if (ev.name === "char" && ev.key === "n" && !ev.ctrl && this.focused === this.sidebar) { this.newSession(); return; }
2176
+ if (ev.name === "escape") {
2177
+ if (this.focused === this.chat.input) {
2178
+ this.focus(this.chat);
2179
+ this.toast("已退出输入(i 重新进入)");
2180
+ this.redraw();
2181
+ } else if (this.focused === this.sidebar) { this.focus(this.chat); this.redraw(); }
2182
+ return;
2183
+ }
2184
+ if (ev.name === "char" && ev.key === "i" && this.focused === this.sidebar) { this.focus(this.chat.input); this.redraw(); return; }
2185
+ }
2186
+ // nvim-style normal mode: single chars are shortcuts (chat first, then the
2187
+ // focused pane). Multi-char text (paste/IME) still types into the input.
2188
+ if (ev.type === "text" && this.focused !== this.chat.input) {
2189
+ if (this.searchActive) {
2190
+ this.searchInput.onKey(ev);
2191
+ this.#refreshSearch();
2192
+ this.redraw();
2193
+ return;
2194
+ }
2195
+ if (ev.text.length === 1) {
2196
+ const asKey = { type: "key", name: "char", key: ev.text, text: ev.text, ctrl: false, alt: false, shift: false };
2197
+ if (this.chat.onKey(asKey)) { this.redraw(); return; }
2198
+ if (this.focused && this.focused !== this.chat.input && this.focused.onKey(asKey)) { this.redraw(); return; }
2199
+ this.toast("按 i 进入输入");
2200
+ return;
2201
+ }
2202
+ this.focus(this.chat.input);
2203
+ this.chat.input.onKey(ev);
2204
+ this.redraw();
2205
+ return;
2206
+ }
2207
+ // focused widget
2208
+ if (this.focused) {
2209
+ const handled = ev.type === "mouse" ? this.focused.onMouse(ev) : this.focused.onKey(ev);
2210
+ if (handled) this.redraw();
2211
+ }
2212
+ }
2213
+
2214
+ startSearch() {
2215
+ this.searchActive = true;
2216
+ this.searchInput.setValue("");
2217
+ this.focus(this.searchInput);
2218
+ }
2219
+
2220
+ #refreshSearch() {
2221
+ const input = this.searchInput;
2222
+ if (input.value.trim()) {
2223
+ this.api.call("session.search", { query: input.value }).then(({ items }) => {
2224
+ this.searchResults = items;
2225
+ this.redraw();
2226
+ }).catch(() => {});
2227
+ } else this.searchResults = null;
2228
+ }
2229
+
2230
+ #onSearchKey(ev) {
2231
+ const input = this.searchInput;
2232
+ if (ev.type === "key" && ev.name === "escape") { this.searchActive = false; this.focus(this.sidebar); this.refreshSessions(); return; }
2233
+ if (ev.type === "key" && ev.name === "enter") { this.searchActive = false; this.focus(this.sidebar); return; }
2234
+ const handled = input.onKey(ev);
2235
+ if (handled) this.#refreshSearch();
2236
+ }
2237
+
2238
+ #renderSearchResults(s) {
2239
+ for (let i = 0; i < Math.min(this.sidebar.h, this.searchResults.length); i++) {
2240
+ const it = this.searchResults[i];
2241
+ s.text(this.sidebar.x, this.sidebar.y + i, truncate(`⚲ ${it.snippet}`, this.sidebar.w - 2), { fg: K.TXT });
2242
+ }
2243
+ if (this.searchResults.length === 0) s.text(this.sidebar.x, this.sidebar.y, "无结果", { fg: K.FAINT });
2244
+ }
2245
+
2246
+ redraw() {
2247
+ this.dirty = true;
2248
+ }
2249
+
2250
+ // ---- main loop ----
2251
+
2252
+ run() {
2253
+ const tick = () => {
2254
+ try {
2255
+ if (this.dirty) {
2256
+ this.dirty = false;
2257
+ this.renderFrame();
2258
+ }
2259
+ if (this.toastMsg && Date.now() > this.toastUntil) { this.toastMsg = null; this.dirty = true; }
2260
+ } catch (e) {
2261
+ this.log("render error (kept running):", e);
2262
+ this.dirty = true;
2263
+ }
2264
+ this.timer = setTimeout(tick, 33);
2265
+ };
2266
+ tick();
2267
+ }
2268
+
2269
+ /** Force one render (also used by the scripted test harness). */
2270
+ renderFrame() {
2271
+ this.chat.flushRebuild();
2272
+ const s = this.screen;
2273
+ s.clear(T.BG);
2274
+ this.#renderTabBar(s);
2275
+ if (this.sidebarVisible) {
2276
+ if (this.searchActive) {
2277
+ this.searchInput.render(s);
2278
+ this.sidebar.y = 1; this.sidebar.h = s.h - 2;
2279
+ } else {
2280
+ this.sidebar.y = 0; this.sidebar.h = s.h - 1;
2281
+ }
2282
+ this.sidebar.render(s);
2283
+ s.put(this.sidebar.w - 1, 0, "│", { fg: T.BORDER });
2284
+ for (let y = 1; y < s.h - 1; y++) s.put(this.sidebar.w - 1, y, "│", { fg: T.BORDER });
2285
+ }
2286
+ const modePanel = this.panelForMode();
2287
+ if (modePanel) modePanel.render(s);
2288
+ else this.chat.render(s);
2289
+
2290
+ // footer: multi-row powerline-style status
2291
+ const t = this.titleOf();
2292
+ const cur = this.sessions.find((x) => x.sessionId === this.currentSession);
2293
+ const ws = this.sessions.length
2294
+ ? (this.projections?.values?.title ? "" : "")
2295
+ : "";
2296
+ const footerH = this.footerHeight();
2297
+ const rows = [];
2298
+ // ── row 0: identity ──
2299
+ const row0 = { left: [], right: [] };
2300
+ // Left badge: the session's permission/mode (e.g. "工作区写入/创造模式"),
2301
+ // which is far more meaningful than a static "工作区" label.
2302
+ const perm = this.projections.permissions?.currentValue;
2303
+ const preset = this.sessions.find((s) => s.sessionId === this.currentSession)?.agentPreset;
2304
+ const badge = perm && preset ? `${permName(perm)}/${modeName(preset)}`
2305
+ : perm ? permName(perm) : preset ? modeName(preset) : "未选会话";
2306
+ row0.left.push({ t: ` ${badge} `, fg: T.SELFG, bg: T.ACCENT, bold: true });
2307
+ if (this.sidebarVisible) row0.left.push({ t: " " + truncate(t || "(未选择会话)", 40) + " ", fg: T.TXT, bg: T.STATUSBG });
2308
+ else row0.left.push({ t: " " + truncate(t || "(未选择会话)", 40) + " ", fg: T.TXT, bg: T.STATUSBG });
2309
+ if (cur?.running) row0.left.push({ t: " ●运行 ", fg: T.OK, bg: T.STATUSBG });
2310
+ if (this.goalText) row0.right.push({ t: " 🎯" + truncate(this.goalText, 22) + " ", fg: T.SELFG, bg: T.WARN, bold: true });
2311
+ const plan = this.projections.plan;
2312
+ if (plan?.active || plan?.pending) row0.right.push({ t: plan.active ? " ✎计划中 " : " ✎计划待审 ", fg: T.SELFG, bg: T.ACCENT2 });
2313
+ const sub = this.projections.subagent;
2314
+ if (sub && typeof sub === "object" && (sub.running ?? sub.active)) row0.right.push({ t: " ⚑子代理 ", fg: T.SELFG, bg: T.PURPLE });
2315
+ const m = this.currentModel;
2316
+ const modelLabel = m
2317
+ ? `${m.provider}/${m.model}${m.reasoningEffort ? `@${m.reasoningEffort}` : ""}`
2318
+ : `${this.provider}/${this.model}`;
2319
+ row0.right.push({ t: ` ${modelLabel} `, fg: T.DIM, bg: T.STATUSBG });
2320
+ if (this.connState !== "connected") row0.right.push({ t: " ⚠离线 ", fg: T.SELFG, bg: T.ERR, bold: true });
2321
+ rows.push(row0);
2322
+ // ── row 1: usage (context meter + tokens) ──
2323
+ const row1 = { left: [], right: [] };
2324
+ const ctx = this.projections.contextPressure;
2325
+ if (ctx && ctx.contextWindow) {
2326
+ const pct = Math.round(100 * ctx.pressureTokens / ctx.contextWindow);
2327
+ const color = pct > 90 ? T.ERR : pct > 60 ? T.WARN : T.OK;
2328
+ const meter = bars(Array(10).fill(ctx.pressureTokens / ctx.contextWindow), 10);
2329
+ row1.left.push({ t: " " + meter + " ", fg: color, bg: T.STATUSBG });
2330
+ row1.left.push({ t: ` ctx ${pct}% ${fmtTokens(ctx.pressureTokens)}/${fmtTokens(ctx.contextWindow)} `, fg: color, bg: T.STATUSBG });
2331
+ }
2332
+ const tu = this.projections.tokenUsage ?? this.tokenUsage;
2333
+ if (tu) {
2334
+ const out = tu.outputTokens ?? 0;
2335
+ const cacheRead = tu.cacheReadTokens ?? 0;
2336
+ const cache = cacheRead + (tu.cacheWriteTokens ?? 0);
2337
+ const uncached = tu.uncachedInputTokens ?? 0;
2338
+ const total = out + cache + uncached;
2339
+ row1.right.push({ t: ` 入 ${fmtTokens(uncached)} `, fg: T.DIM, bg: T.STATUSBG });
2340
+ row1.right.push({ t: ` 出 ${fmtTokens(out)} `, fg: T.OK, bg: T.STATUSBG });
2341
+ row1.right.push({ t: ` 缓存 ${fmtTokens(cache)} `, fg: T.ACCENT, bg: T.STATUSBG });
2342
+ const hit = total > 0 ? Math.round(100 * cacheRead / total) : 0;
2343
+ row1.right.push({ t: ` 命中${hit}% `, fg: T.FAINT, bg: T.STATUSBG });
2344
+ row1.right.push({ t: ` 共 ${fmtTokens(total)} `, fg: T.BOLD, bg: T.STATUSBG, bold: true });
2345
+ }
2346
+ // full working directory
2347
+ const cwd = this.currentSession ? (this.sessions.find((x) => x.sessionId === this.currentSession)?.cwd) : process.cwd();
2348
+ if (cwd) row1.left.push({ t: ` ${cwd} `, fg: T.FAINT, bg: T.STATUSBG });
2349
+ const stats = this.projections.sessionStats;
2350
+ if (stats) {
2351
+ if (stats.steps) row1.right.push({ t: ` ⚙${stats.steps}步 `, fg: T.FAINT, bg: T.STATUSBG });
2352
+ if (stats.turns) row1.right.push({ t: ` ${stats.turns}回合 `, fg: T.FAINT, bg: T.STATUSBG });
2353
+ if (stats.ttftMs) row1.right.push({ t: ` 首响${Math.round(stats.ttftMs / stats.ttftSteps)}ms `, fg: T.FAINT, bg: T.STATUSBG });
2354
+ }
2355
+ rows.push(row1);
2356
+ // ── row 2: jobs (only when present) ──
2357
+ if (this.jobs?.length) {
2358
+ const running = this.jobs.filter((j) => j.status === "running");
2359
+ const row2 = { left: [], right: [] };
2360
+ for (const j of this.jobs.slice(0, 4)) {
2361
+ const icon = j.status === "running" ? "⚙" : j.status === "completed" ? "✓" : j.status === "failed" ? "✗" : "·";
2362
+ const fg = j.status === "running" ? T.WARN : j.status === "completed" ? T.OK : j.status === "failed" ? T.ERR : T.DIM;
2363
+ row2.left.push({ t: ` ${icon} ${truncate(j.label ?? j.kind, 22)} `, fg, bg: T.STATUSBG });
2364
+ }
2365
+ if (running.length) row2.right.push({ t: ` ${running.length} 运行中 `, fg: T.WARN, bg: T.STATUSBG });
2366
+ rows.push(row2);
2367
+ }
2368
+ this.status.rows = rows;
2369
+ this.status.render(s);
2370
+
2371
+ if (this.popup) this.popup.render(s);
2372
+ if (this.menu) this.menu.render(s);
2373
+ if (this.overlay) this.overlay.render(s);
2374
+ if (this.toastMsg) {
2375
+ const w = strWidth(this.toastMsg) + 4;
2376
+ const x0 = Math.max(0, Math.floor(s.w / 2 - w / 2));
2377
+ const x1 = Math.min(s.w - 1, Math.floor(s.w / 2 + w / 2));
2378
+ s.fillRect(x0, 0, x1, 0, " ", { bg: T.BORDER });
2379
+ s.text(x0 + 2, 0, truncate(this.toastMsg, s.w - 4), { fg: T.BOLD, bg: T.BORDER });
2380
+ }
2381
+ if (this.renameInput && this.popup) this.renameInput.render(s);
2382
+
2383
+ const out = s.render();
2384
+ let tail = "";
2385
+ if (this.overlay && typeof this.overlay.kittyTransmit === "function" && kittyCapable()) {
2386
+ tail = this.overlay.kittyTransmit();
2387
+ }
2388
+ this.term.output.write(out + tail);
2389
+ }
2390
+
2391
+ titleOf() {
2392
+ const s = this.sessions.find((x) => x.sessionId === this.currentSession);
2393
+ if (s) return s.projections?.values?.title ?? s.sessionId.slice(0, 8);
2394
+ return "(未选择会话)";
2395
+ }
2396
+
2397
+ stop() {
2398
+ if (this.pollTimer) clearInterval(this.pollTimer);
2399
+ this.term.stop();
2400
+ this.api.close();
2401
+ process.exit(0);
2402
+ }
2403
+ }