dsh-neotui 0.4.3 → 0.4.4

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 CHANGED
@@ -2,20 +2,23 @@
2
2
  import { Screen } from "./screen.js";
3
3
  import { renderMd, C } from "./md.js";
4
4
  import { truncate, strWidth, pad, bars, fmtDuration, fmtClock, fmtDateTime, graphemes, graphemeWidth, takeGraphemes, bytesLabel, prettyJson, looksLikeJson } from "./text.js";
5
- import { readFileSync, appendFileSync, mkdirSync, existsSync, statSync } from "node:fs";
6
- import { join, basename } from "node:path";
5
+ import { readFileSync, appendFileSync, mkdirSync, existsSync, statSync, unlinkSync, writeFileSync } from "node:fs";
7
6
  import { spawnSync } from "node:child_process";
7
+ import { fileURLToPath } from "node:url";
8
+ import { join, basename } from "node:path";
9
+ import { tmpdir, homedir } from "node:os";
8
10
  import { createRequire } from "node:module";
9
11
  import { Widget, ScrollView, Input, Popup, Menu, StatusBar, wrapIndex } from "./widgets.js";
10
12
  import { UploadPicker } from "./file-picker.js";
11
- import { userPrefix, saveTuiConfig, loadTuiConfig, userName, busyEnter, foldDefaults, keyBindings, tuiConfigFile, reloadTuiConfig, searchHistory, rememberSearchQuery } from "./config.js";
13
+ import { userPrefix, saveTuiConfig, loadTuiConfig, userName, busyEnter, foldDefaults, keyBindings, tuiConfigFile, reloadTuiConfig, searchHistory, rememberSearchQuery, promptHistory, rememberPrompt, prefixKeys } from "./config.js";
12
14
  import { bindingMatchFor, matchKeyBinding, CHAT_BINDING_ORDER, SIDEBAR_BINDING_ORDER, KEYBINDING_ORDER, INPUT_BINDING_ORDER, INPUT_EDIT_BINDING_ORDER } from "./keybindings.js";
13
- export { userPrefix, saveTuiConfig, loadTuiConfig, userName, busyEnter, foldDefaults } from "./config.js";
15
+ export { userPrefix, saveTuiConfig, loadTuiConfig, userName, busyEnter, foldDefaults, promptHistory, rememberPrompt } from "./config.js";
14
16
  import {
15
17
  Picker, buildCommandPalette, buildModelPicker, buildModePicker, buildPermissionPicker,
16
18
  modeName, permName, WorkspacePanel, TrajectoryPanel, DirPicker, AttachmentPanel,
17
- ImagePopup, kittyCapable, buildGoalPopup, GoalPanel, SettingsPanel, SubagentPanel,
18
- SkillsPanel, ControlPanel, JobsPanel, QueuePanel, ModelPanel, fmtMs, ThemePickerBuffer,
19
+ ImagePopup, kittyCapable, GoalPanel, SettingsPanel,
20
+ SkillsPanel, ControlPanel, QueuePanel, ModelPanel, fmtMs, ThemePickerBuffer,
21
+ SubagentPage, TasksPage,
19
22
  } from "./panels.js";
20
23
 
21
24
  import { T, themeName, cycleTheme } from "./theme.js";
@@ -1052,6 +1055,8 @@ class SidebarTree extends Widget {
1052
1055
  this.sel = 0;
1053
1056
  this.focused = false;
1054
1057
  this.collapsed = new Set(); // group keys
1058
+ this.preview = { open: false, sessionId: null, title: "", lines: [], loading: false, error: null };
1059
+ this.previewToken = 0;
1055
1060
  }
1056
1061
  setData(workspaces, sessions, archivedIds, currentSessionId) {
1057
1062
  const archived = new Set(archivedIds ?? []);
@@ -1230,11 +1235,68 @@ class SidebarTree extends Widget {
1230
1235
  }
1231
1236
  return false;
1232
1237
  }
1238
+ /** Attached preview card (not a modal): p toggles it (Tab switched to the
1239
+ * window toggle); it never steals focus — ↑/↓ still moves the tree and the
1240
+ * card follows the selection. */
1241
+ #togglePreview() {
1242
+ const row = this.currentRow();
1243
+ if (!row || row.kind !== "session") return;
1244
+ if (this.preview.open && this.preview.sessionId === row.session.sessionId) {
1245
+ this.preview.open = false;
1246
+ this.app.redraw();
1247
+ return;
1248
+ }
1249
+ this.preview.open = true;
1250
+ void this.#refreshPreview(row.session, true);
1251
+ }
1252
+ /** Load the preview for a session (token-guarded, throttled at 250ms). */
1253
+ async #refreshPreview(session, force = false) {
1254
+ const app = this.app;
1255
+ const token = ++this.previewToken;
1256
+ this.preview.sessionId = session.sessionId;
1257
+ this.preview.title = session.title ?? session.sessionId.slice(0, 8);
1258
+ this.preview.loading = true;
1259
+ this.preview.error = null;
1260
+ this.preview.lines = [];
1261
+ app.redraw();
1262
+ try {
1263
+ const res = await app.api.call("session.history", { sessionId: session.sessionId, maxMessages: 12 });
1264
+ if (token !== this.previewToken) return; // stale
1265
+ const lines = [];
1266
+ for (const item of (res?.events ?? [])) {
1267
+ const ev = item?.event ?? item;
1268
+ const event = ev?.event ?? ev;
1269
+ const type = event?.type ?? ev?.type;
1270
+ const data = event?.data ?? ev?.data ?? {};
1271
+ if (type === "user/message") {
1272
+ const d = data.message ?? data;
1273
+ const text = partsToText(data.content ?? d.content ?? []).replace(/\s+/g, " ").trim();
1274
+ lines.push(["你", truncate(text || data.id || "(无文本)", 28)]);
1275
+ } else if (type === "assistant/message") {
1276
+ const d = data.message ?? data;
1277
+ const text = partsToText(d.content ?? []).replace(/\s+/g, " ").trim();
1278
+ lines.push(["AI", truncate(text || "(无文本)", 28)]);
1279
+ } else if (type === "tool/call") {
1280
+ const name = (data.call ?? data).name ?? data.name ?? "tool";
1281
+ lines.push(["⚙", truncate(String(name), 28)]);
1282
+ }
1283
+ }
1284
+ this.preview.lines = lines;
1285
+ } catch (e) {
1286
+ if (token === this.previewToken) this.preview.error = e.message;
1287
+ }
1288
+ if (token === this.previewToken) { this.preview.loading = false; app.redraw(); }
1289
+ }
1290
+
1233
1291
  onKey(ev) {
1234
1292
  if (ev.type !== "key") return false;
1235
1293
  switch (ev.name) {
1236
- case "up": return this.move(-1);
1237
- case "down": return this.move(1);
1294
+ case "up":
1295
+ if (this.move(-1)) { if (this.preview.open) { const r = this.currentRow(); if (r?.kind === "session") void this.#refreshPreview(r.session); } return true; }
1296
+ return false;
1297
+ case "down":
1298
+ if (this.move(1)) { if (this.preview.open) { const r = this.currentRow(); if (r?.kind === "session") void this.#refreshPreview(r.session); } return true; }
1299
+ return false;
1238
1300
  case "pgup": this.scroll(-this.h); return true;
1239
1301
  case "pgdn": this.scroll(this.h); return true;
1240
1302
  case "home": this.sel = 0; this.#scrollToSel(); return true;
@@ -1243,7 +1305,7 @@ class SidebarTree extends Widget {
1243
1305
  const row = this.currentRow();
1244
1306
  if (!row) return false;
1245
1307
  if (row.kind === "group") { this.toggle(row.group); this.app.redraw(); }
1246
- else this.app.openSession(row.session.sessionId); // pane focus intentionally stays here
1308
+ else { this.preview.open = false; this.app.openSession(row.session.sessionId); } // pane focus intentionally stays here
1247
1309
  return true;
1248
1310
  }
1249
1311
  case "left": {
@@ -1263,6 +1325,13 @@ class SidebarTree extends Widget {
1263
1325
  case "char":
1264
1326
  if (ev.ctrl && ev.key === "r") return this.openCurrentMenu();
1265
1327
  if (ev.ctrl) return false;
1328
+ // p (no ctrl): toggle the attached preview card (Tab now switches the
1329
+ // focused window at the App layer, so the preview trigger moved here).
1330
+ if (ev.key === "p") {
1331
+ const row = this.currentRow();
1332
+ if (row?.kind === "session") { this.#togglePreview(); return true; }
1333
+ return false;
1334
+ }
1266
1335
  {
1267
1336
  const sbHit = bindingMatchFor(ev, keyBindings(), false, SIDEBAR_BINDING_ORDER);
1268
1337
  if (sbHit?.id === "insert") { this.app.focus(this.app.chat.input); this.app.redraw(); return true; }
@@ -1293,6 +1362,8 @@ class SidebarTree extends Widget {
1293
1362
  /** Slash commands offered by the input's candidate bar (Tab completes). */
1294
1363
  const SLASH_COMMANDS = [
1295
1364
  { name: "/reload", desc: "重新载入界面(不重启进程)" },
1365
+ { name: "/rewind", desc: "回退到某条提问之前(分支会话 + 原消息回填入输入框)" },
1366
+ { name: "/btw", hint: "<问题>", desc: "侧问:不打断主回合的单轮问题(HoST 无通道时由 TUI 直连)" },
1296
1367
  { name: "/restart", desc: "重启 TUI 加载新版本" },
1297
1368
  { name: "/model", desc: "切换模型" },
1298
1369
  { name: "/theme", desc: "切换配色主题" },
@@ -1332,7 +1403,7 @@ export class ChatView extends Widget {
1332
1403
  x: this.x, y: this.y + this.h - 2, w: this.w, h: 1,
1333
1404
  multi: true, maxLines: 6, app: this.app, commands: SLASH_COMMANDS,
1334
1405
  bg: T.PANEL,
1335
- placeholder: "输入消息…(Shift+Enter/Ctrl+J 换行,Ctrl+L 展开,↑/↓ 历史,Tab 补全 / 命令,Enter 发送)",
1406
+ placeholder: "输入消息…(Shift+Enter/Ctrl+J 换行,Ctrl+L 展开,↑/↓ 历史,Tab 补全 / 命令 / 文件,Enter 发送)",
1336
1407
  onEnter: (v) => this.send(v),
1337
1408
  onChange: () => this.inputChanged(),
1338
1409
  });
@@ -1553,6 +1624,8 @@ export class ChatView extends Widget {
1553
1624
  async open(sessionId, epoch = this.app.sessionEpoch, maxMessages = 80) {
1554
1625
  this.sessionId = sessionId;
1555
1626
  this.nodes = [];
1627
+ const cwd = this.app.sessions?.find((s) => s.sessionId === sessionId)?.cwd;
1628
+ if (cwd) this.input.fileRoot = cwd; // file-path completion base
1556
1629
  this.blockSel = -1;
1557
1630
  this.cursorMode = "block";
1558
1631
  this.visualAnchor = null;
@@ -1745,7 +1818,12 @@ export class ChatView extends Widget {
1745
1818
  send(text) {
1746
1819
  if (!this.sessionId) return;
1747
1820
  const trimmed = text.trim();
1821
+ if (!trimmed.startsWith("/")) rememberPrompt(trimmed);
1822
+ const hist = this.app.chat?.input?.history;
1823
+ if (Array.isArray(hist)) this.app.chat.input.history = [trimmed, ...hist.filter((x) => x !== trimmed)].slice(0, 50);
1748
1824
  if (trimmed === "/reload") { this.app.softReload(); return; }
1825
+ if (trimmed === "/rewind") { this.app.showRewindPicker(); return; }
1826
+ if (trimmed.startsWith("/btw")) { void this.app.sideQuestion(trimmed.slice(4).trim()); return; }
1749
1827
  if (trimmed === "/restart") { this.app.restartApp(); return; }
1750
1828
  if (trimmed === "/model") { this.app.overlay = buildModelPicker(this.app); this.app.redraw(); return; }
1751
1829
  if (trimmed === "/theme") { this.app.showThemePicker(); return; }
@@ -1758,6 +1836,25 @@ export class ChatView extends Widget {
1758
1836
  },
1759
1837
  });
1760
1838
  for (const e of errors) this.app.toast(`图片读取失败: ${e}`);
1839
+ // @-mentioned non-image files/directories attach as bounded text blocks
1840
+ // (Claude-Code parity; the token stays in the prompt as a reference).
1841
+ const atRoot = this.app.sessions?.find((s) => s.sessionId === this.sessionId)?.cwd ?? process.cwd();
1842
+ for (const m of trimmed.matchAll(/@([^\s@]+)/g)) {
1843
+ const ref = m[1];
1844
+ if (IMAGE_EXT.test(ref)) continue;
1845
+ const full = ref.startsWith("~") ? join(homedir(), ref.slice(1)) : join(atRoot, ref);
1846
+ if (!existsSync(full)) continue;
1847
+ const display = `@${ref}`;
1848
+ try {
1849
+ if (statSync(full).isDirectory()) {
1850
+ const names = readdirSync(full).slice(0, 200);
1851
+ parts.push({ type: "text", text: `\n<${display} 目录内容>\n${names.join("\n")}`.slice(0, 8192) });
1852
+ } else {
1853
+ const buf = readFileSync(full, "utf8");
1854
+ parts.push({ type: "text", text: `\n<${display} 文件内容>\n${buf}`.slice(0, 8192) });
1855
+ }
1856
+ } catch (e) { this.app.toast(`@${ref} 读取失败: ${e.message}`); }
1857
+ }
1761
1858
  const clipParts = this.clipboardImages.map(({ mediaType, data, name }) => ({ type: "image", mediaType, data, name }));
1762
1859
  const clipboardCount = clipParts.length;
1763
1860
  parts.push(...clipParts);
@@ -3035,8 +3132,24 @@ export class ChatView extends Widget {
3035
3132
  screen.text(Math.max(this.x + 1, this.x + this.w - 22), y, "Ctrl+O 附件管理器", { fg: K.FAINT, bg: T.BG2 });
3036
3133
  }
3037
3134
 
3038
- /** / command candidate bar above the input (↑/↓ cycle, Tab completes). */
3135
+ /** / command candidate bar above the input (↑/↓ cycle, Tab completes);
3136
+ * also shows file-path completion candidates. */
3039
3137
  #renderCmdBar(screen) {
3138
+ const inp = this.input;
3139
+ if (!inp.cmdOpen && inp.fileCands.length === 0 && inp.atCands.length === 0) return;
3140
+ if (inp.cmdOpen && inp.cmds.length > 0) { this.#renderCmdBarCommands(screen); return; }
3141
+ // file completion hint line
3142
+ const n = 1;
3143
+ const w = Math.min(this.view.w, 44);
3144
+ const y0 = Math.max(this.view.y, inp.y - n - 1);
3145
+ screen.fillRect(this.x, y0, this.x + w - 1, y0 + n - 1, " ", { bg: T.BG2 });
3146
+ const cands = inp.atCands.length ? inp.atCands : inp.fileCands;
3147
+ const shown = cands.slice(0, 6).map((n) => n.startsWith("@") ? n : n).join(" ");
3148
+ screen.text(this.x + 1, y0, `▸ ${truncate(shown, w - 3)}`, { fg: T.SELFG, bg: T.BG2 });
3149
+ return;
3150
+ }
3151
+
3152
+ #renderCmdBarCommands(screen) {
3040
3153
  const inp = this.input;
3041
3154
  if (!inp.cmdOpen || inp.cmds.length === 0) return;
3042
3155
  const n = Math.min(inp.cmds.length, 6);
@@ -3613,6 +3726,8 @@ export class ApprovalPopup extends Popup {
3613
3726
  // ---- App ----
3614
3727
 
3615
3728
  export class App {
3729
+ #workspacePicker = null; // Ctrl+W 工作区选择小 buffer(用于重命名行取选中项)
3730
+ #splitDrag = null; // { node, axis, rect } — dragging a split divider
3616
3731
  constructor({ screen, term, api, base, log, versionFetcher = latestNpmVersion, launcherAnime = false }) {
3617
3732
  this.screen = screen;
3618
3733
  this.term = term;
@@ -3629,6 +3744,15 @@ export class App {
3629
3744
  this.logoData = null; // decoded custom logo: { palette, grid }
3630
3745
  this.logoLoadError = null;
3631
3746
  this.brandShimmer = -1; // welcome wordmark diagonal sweep phase (-1 = idle)
3747
+ // /btw default transport: spawn the standalone helper (one-shot key read)
3748
+ const helper = fileURLToPath(new URL("side-question.mjs", import.meta.url));
3749
+ this.sideQuestionRunner = (q, ctx, helperPath = helper) => new Promise((resolve) => {
3750
+ try {
3751
+ const res = spawnSync(process.execPath, [helperPath, "--question", q, "--context", ctx], { timeout: 60000, encoding: "utf8", maxBuffer: 8 * 1024 * 1024 });
3752
+ if (res.status === 0) { try { const data = JSON.parse(res.stdout); resolve({ answer: data.answer, err: null }); return; } catch { resolve({ answer: null, err: "答案解析失败" }); return; } }
3753
+ resolve({ answer: null, err: (res.stderr || "子进程失败").split("\n")[0] });
3754
+ } catch (e) { resolve({ answer: null, err: String(e?.message ?? e) }); }
3755
+ });
3632
3756
  this.brandSweep0 = -1;
3633
3757
  this.brandShimmerNext = null;
3634
3758
  this.popup = null;
@@ -3663,7 +3787,16 @@ export class App {
3663
3787
  this.searchActive = false;
3664
3788
  this.overlay = null; // Picker / Popup / ImagePopup modal
3665
3789
  this.fullBuffer = null; // full-screen panel buffer (workspace/settings/models/subagent/skills)
3666
- this.mode = "chat"; // chat | workspace | trajectory
3790
+ this.mainTab = "chat"; // main-area tab: chat | trajectory | subagent | tasks (Shift+Tab cycles)
3791
+ this.mode = "chat"; // chat | trajectory (legacy mirror of mainTab)
3792
+ this.focusedWindow = "main"; // focused window: list (会话列表) | main (主窗口); Tab/Ctrl+Left+Right switch
3793
+ this.winTree = null; // split window tree (built lazily on the first split)
3794
+ this.focusedLeaf = null; // current leaf node of winTree
3795
+ this.lastMainLeaf = null; // Tab: list ↔ last focused main window
3796
+ this.pendingPrefix = null; // nvim-style pending layer (prefix tables)
3797
+ this.prefixTimeoutMs = 2000; // pending-layer timeout (tests may override)
3798
+ this.subagentPane = null; // lazy SubagentPage (tab 子代理)
3799
+ this.tasksPane = null; // lazy TasksPage (tab 后台任务)
3667
3800
  this.sidebarWanted = true;
3668
3801
  this.sidebarVisible = true; // auto-collapses on narrow terminals
3669
3802
  this.tooSmall = false;
@@ -3712,7 +3845,10 @@ export class App {
3712
3845
  this.sidebar.x = 0; this.sidebar.y = 0; this.sidebar.w = this.sidebarWidth; this.sidebar.h = this.screen.h - 1;
3713
3846
  this.searchInput.w = this.sidebarWidth;
3714
3847
  this.chat.resize(x, 1, w, mainH);
3715
- if (this.trajectoryPanel?.relayout) this.trajectoryPanel.relayout(x, 1, w, mainH);
3848
+ // The active main-area pane (trajectory/subagent/tasks) sits in exactly the
3849
+ // rect the ChatView occupies; non-active panes keep their last geometry.
3850
+ const pane = this.paneWidget();
3851
+ if (pane?.relayout) pane.relayout(x, 1, w, mainH);
3716
3852
  if (this.fullBuffer?.relayout) this.fullBuffer.relayout(0, 0, this.screen.w, this.screen.h);
3717
3853
  this.status.y = this.screen.h - footerH;
3718
3854
  this.status.h = footerH;
@@ -3736,30 +3872,354 @@ export class App {
3736
3872
 
3737
3873
  toggleChatTrajectory() {
3738
3874
  if (!this.currentSession) { this.toast("先打开一个会话"); return; }
3739
- this.setMode(this.mode === "trajectory" ? "chat" : "trajectory");
3740
- }
3741
-
3742
- /** tmux-style pane focus. The sequence wraps and skips unavailable panes. */
3743
- focusPane(delta) {
3744
- const panes = [];
3745
- if (this.sidebarVisible) panes.push("sidebar");
3746
- panes.push("chat");
3747
- if (this.currentSession) panes.push("trajectory");
3748
- const current = this.focused === this.sidebar ? "sidebar" : this.mode === "trajectory" ? "trajectory" : "chat";
3749
- const next = panes[wrapIndex(Math.max(0, panes.indexOf(current)) + delta, panes.length)];
3750
- if (next === "sidebar") {
3875
+ this.setTab(this.mainTab === "trajectory" ? "chat" : "trajectory");
3876
+ }
3877
+
3878
+ /** The main-area content rect — the exact rect the ChatView occupies. */
3879
+ mainAreaRect() {
3880
+ const x = this.sidebarVisible ? this.sidebarWidth : 0;
3881
+ const w = Math.max(1, this.screen.w - x);
3882
+ const footerH = Math.min(this.footerHeight(), Math.max(1, this.screen.h - 2));
3883
+ const mainH = Math.max(1, this.screen.h - 1 - footerH);
3884
+ return { x, y: 1, w, h: mainH };
3885
+ }
3886
+
3887
+ /** The currently-active non-chat pane widget (null when the chat tab is
3888
+ * active). Does NOT construct — panes are only grown by setTab(). */
3889
+ paneWidget() {
3890
+ if (this.mainTab === "trajectory") return this.trajectoryPanel;
3891
+ if (this.mainTab === "subagent") return this.subagentPane;
3892
+ if (this.mainTab === "tasks") return this.tasksPane;
3893
+ return null;
3894
+ }
3895
+ ensureTrajectoryPane() {
3896
+ if (!this.trajectoryPanel) this.trajectoryPanel = new TrajectoryPanel(this);
3897
+ return this.trajectoryPanel;
3898
+ }
3899
+ ensureSubagentPane() {
3900
+ if (!this.subagentPane) this.subagentPane = new SubagentPage(this);
3901
+ return this.subagentPane;
3902
+ }
3903
+ ensureTasksPane() {
3904
+ if (!this.tasksPane) this.tasksPane = new TasksPage(this);
3905
+ return this.tasksPane;
3906
+ }
3907
+
3908
+ /** Set the main-area tab (chat | trajectory | subagent | tasks) and focus
3909
+ * the main window. Non-chat panes are lazy-grown and seeded on first entry;
3910
+ * re-entry (including a cycle back) keeps their live state. */
3911
+ setTab(which) {
3912
+ this.mainTab = which;
3913
+ this.mode = which === "trajectory" ? "trajectory" : "chat";
3914
+ this.focusedWindow = "main";
3915
+ let pane = null;
3916
+ if (which === "trajectory") {
3917
+ pane = this.ensureTrajectoryPane();
3918
+ if (this.currentSession) pane.load(this.currentSession);
3919
+ } else if (which === "subagent") {
3920
+ pane = this.ensureSubagentPane();
3921
+ pane.onActivate?.();
3922
+ } else if (which === "tasks") {
3923
+ pane = this.ensureTasksPane();
3924
+ pane.onActivate?.();
3925
+ }
3926
+ this.layout();
3927
+ if (pane) this.focus(pane);
3928
+ else this.focus(this.chat);
3929
+ this.redraw();
3930
+ }
3931
+
3932
+ /** Window stops: list (会话列表) + main (主窗口). The list stop is omitted
3933
+ * when the sidebar is hidden. */
3934
+ windowStops() {
3935
+ return this.sidebarVisible ? ["list", "main"] : ["main"];
3936
+ }
3937
+
3938
+ /** tmux-style window focus over the 2-stop ring (list ↔ main, wrap). The
3939
+ * list stop is omitted when the sidebar is hidden. */
3940
+ focusWindow(delta) {
3941
+ if (!this.winTree) {
3942
+ const stops = this.windowStops();
3943
+ const idx = stops.indexOf(this.focusedWindow);
3944
+ const next = stops[wrapIndex(Math.max(0, idx) + delta, stops.length)];
3945
+ this.setFocusedWindow(next);
3946
+ return true;
3947
+ }
3948
+ const leaves = this.#leaves();
3949
+ const idx = leaves.indexOf(this.focusedLeaf ?? (this.focusedWindow === "list" ? this.#leafList() : this.#mainLeaves()[0]));
3950
+ const next = leaves[wrapIndex(Math.max(0, idx) + delta, leaves.length)];
3951
+ this.#focusLeaf(next);
3952
+ return true;
3953
+ }
3954
+
3955
+ /** Focus the given window. "list" focuses the session list; "main" focuses
3956
+ * whatever the active tab hosts (chat or the active pane). */
3957
+ setFocusedWindow(which) {
3958
+ if (this.winTree) {
3959
+ if (which === "list") this.#focusLeaf(this.#leafList());
3960
+ else this.#focusLeaf(this.lastMainLeaf ?? this.#mainLeaves()[0] ?? this.focusedLeaf);
3961
+ return;
3962
+ }
3963
+ this.focusedWindow = which;
3964
+ if (which === "list") {
3751
3965
  this.focus(this.sidebar);
3752
- } else if (next === "trajectory") {
3753
- this.setMode("trajectory");
3754
- this.focus(this.trajectoryPanel ?? this.chat);
3755
3966
  } else {
3756
- this.setMode("chat");
3757
- this.focus(this.chat);
3967
+ const pane = this.paneWidget();
3968
+ if (pane) this.focus(pane);
3969
+ else this.focus(this.chat);
3758
3970
  }
3759
3971
  this.redraw();
3972
+ }
3973
+
3974
+ /** Cycle the main-window tab: chat → trajectory → subagent → tasks → chat.
3975
+ * Tabs are always reachable regardless of an open session. */
3976
+ cycleTab(delta) {
3977
+ const tabs = ["chat", "trajectory", "subagent", "tasks"];
3978
+ const idx = tabs.indexOf(this.mainTab);
3979
+ const next = tabs[wrapIndex(Math.max(0, idx) + delta, tabs.length)];
3980
+ this.setTab(next);
3760
3981
  return true;
3761
3982
  }
3762
3983
 
3984
+ // ---- Window split tree + prefix layers ----
3985
+
3986
+ /** Lazy window tree: built when the first split happens (list + one main). */
3987
+ #ensureWinTree() {
3988
+ if (this.winTree) return;
3989
+ const main = { type: "leaf", kind: "main", id: "m1", sessionId: this.currentSession, mainTab: this.mainTab };
3990
+ const list = { type: "leaf", kind: "list", id: "list" };
3991
+ this.winTree = { type: "split", dir: "right", a: list, b: main };
3992
+ this.focusedLeaf = this.focusedWindow === "list" ? list : main;
3993
+ this.lastMainLeaf = main;
3994
+ }
3995
+ /** Leaves in visual order (left→right, top→bottom). */
3996
+ #leaves(node = this.winTree) {
3997
+ if (!node) return [];
3998
+ if (node.type === "leaf") return [node];
3999
+ const l = this.#leaves(node.a), r = this.#leaves(node.b);
4000
+ return (node.dir === "left" || node.dir === "up") ? [...r, ...l] : [...l, ...r];
4001
+ }
4002
+ #mainLeaves() { return this.#leaves().filter((l) => l.kind === "main"); }
4003
+ #leafList(node = this.winTree) { return this.#leaves(node).find((l) => l.kind === "list"); }
4004
+ #contains(node, target) {
4005
+ if (!node || node.type === "leaf") return node === target;
4006
+ return this.#contains(node.a, target) || this.#contains(node.b, target);
4007
+ }
4008
+ #leafDepth(node, target, d = 0) {
4009
+ if (node.type === "leaf") return d;
4010
+ if (this.#contains(node.a, target) || this.#contains(node.b, target)) {
4011
+ const d1 = node.a.type === "split" || node.a === target || this.#contains(node.a, target) ? this.#leafDepth(node.a, target, d + 1) : d + 1;
4012
+ return this.#leafDepth(this.#contains(node.a, target) ? node.a : node.b, target, d + 1);
4013
+ }
4014
+ return d;
4015
+ }
4016
+ /** Replace leaf with a node in the tree (mutating). */
4017
+ #replaceLeaf(leaf, withNode, node = this.winTree) {
4018
+ if (!node || node.type === "leaf") return;
4019
+ if (node.a === leaf) { node.a = withNode; return; }
4020
+ if (node.b === leaf) { node.b = withNode; return; }
4021
+ if (this.#contains(node.a, leaf)) this.#replaceLeaf(leaf, withNode, node.a);
4022
+ else if (this.#contains(node.b, leaf)) this.#replaceLeaf(leaf, withNode, node.b);
4023
+ }
4024
+ /** Prune the parent split of target; returns the new root. */
4025
+ #prune(node, target) {
4026
+ if (node.type !== "split") return node;
4027
+ if (node.a === target) return node.b;
4028
+ if (node.b === target) return node.a;
4029
+ if (this.#contains(node.a, target)) return { ...node, a: this.#prune(node.a, target) };
4030
+ if (this.#contains(node.b, target)) return { ...node, b: this.#prune(node.b, target) };
4031
+ return node;
4032
+ }
4033
+ /** Rect of a leaf; the main-area rect for main leaves, the sidebar slot for list. */
4034
+ #leafRect(leaf) {
4035
+ const main = this.mainAreaRect();
4036
+ if (leaf.kind === "list") return { x: 0, y: 0, w: this.sidebarVisible ? this.sidebarWidth : 0, h: this.screen.h - 1 };
4037
+ return main;
4038
+ }
4039
+ /** Recompute the leaf rects and render each leaf (focused leaf renders its real
4040
+ * content; others draw a placeholder cell). Returns the rect map. */
4041
+ #layoutLeaves() {
4042
+ const map = new Map();
4043
+ const main = this.mainAreaRect();
4044
+ const listRect = { x: 0, y: 0, w: this.sidebarVisible ? this.sidebarWidth : 0, h: this.screen.h - 1 };
4045
+ // simple recursive rect assignment with 1-cell separators
4046
+ const place = (node, rect, depth = 0) => {
4047
+ if (node.type === "leaf") { map.set(node, rect); return; }
4048
+ const border = 1;
4049
+ if (node.dir === "right" || node.dir === "left") {
4050
+ const total = Math.max(2, rect.w - border);
4051
+ const r = Math.min(0.8, Math.max(0.2, node.ratio ?? 0.5));
4052
+ const aW = Math.max(1, Math.round(total * r));
4053
+ const bW = Math.max(1, total - aW);
4054
+ if (node.dir === "right") {
4055
+ place(node.a, { ...rect, w: aW }, depth + 1);
4056
+ place(node.b, { x: rect.x + aW + border, y: rect.y, w: bW, h: rect.h }, depth + 1);
4057
+ } else {
4058
+ place(node.b, { ...rect, w: bW }, depth + 1);
4059
+ place(node.a, { x: rect.x + bW + border, y: rect.y, w: aW, h: rect.h }, depth + 1);
4060
+ }
4061
+ } else {
4062
+ const total = Math.max(2, rect.h - border);
4063
+ const r = Math.min(0.8, Math.max(0.2, node.ratio ?? 0.5));
4064
+ const aH = Math.max(1, Math.round(total * r));
4065
+ const bH = Math.max(1, total - aH);
4066
+ if (node.dir === "down") {
4067
+ place(node.a, { ...rect, h: aH }, depth + 1);
4068
+ place(node.b, { x: rect.x, y: rect.y + aH + border, w: rect.w, h: bH }, depth + 1);
4069
+ } else {
4070
+ place(node.b, { ...rect, h: bH }, depth + 1);
4071
+ place(node.a, { x: rect.x, y: rect.y + bH + border, w: rect.w, h: aH }, depth + 1);
4072
+ }
4073
+ }
4074
+ };
4075
+ if (!this.winTree) { map.set(null, main); return map; }
4076
+ place(this.winTree, this.winTree.b?.kind === "main" && this.#leafList() === this.winTree.a ? main : main);
4077
+ // The tree root's split spans the WHOLE area, but the list leaf must occupy
4078
+ // the sidebar slot and mains the remaining main area. Handle that by
4079
+ // placing the list specially when it is a direct child:
4080
+ if (this.winTree.type === "split" && this.winTree.a?.kind === "list") {
4081
+ map.set(this.winTree.a, listRect);
4082
+ place(this.winTree.b, main);
4083
+ } else {
4084
+ place(this.winTree, { x: this.sidebarVisible ? this.sidebarWidth : 0, y: 1, w: main.w, h: main.h });
4085
+ if (this.#leafList()) map.set(this.#leafList(), listRect);
4086
+ }
4087
+ return map;
4088
+ }
4089
+ /** Split the focused leaf in a direction; the new window is a main window
4090
+ * adopting a blank session. Guards: 8 windows, depth 4, min 40x12. */
4091
+ splitWindow(dir) {
4092
+ this.#ensureWinTree();
4093
+ const leaf = this.focusedLeaf ?? (this.focusedWindow === "list" ? this.#leafList() : this.#mainLeaves()[0]);
4094
+ if (!leaf) { this.toast("请先打开一个会话"); return false; }
4095
+ if (this.#leaves().length >= 8) { this.toast("窗口已达上限 (8)"); return false; }
4096
+ if (this.#leafDepth(this.winTree, leaf) >= 4) { this.toast("分屏嵌套已达深度上限 (4)"); return false; }
4097
+ const r = this.#leafRect(leaf);
4098
+ if ((dir === "right" || dir === "left") && r.w < 40 * 2 + 1) { this.toast("空间不足:分屏后各窗口需 ≥40 列"); return false; }
4099
+ if ((dir === "up" || dir === "down") && r.h < 12 * 2 + 1) { this.toast("空间不足:分屏后各窗口需 ≥12 行"); return false; }
4100
+ const newLeaf = { type: "leaf", kind: "main", id: `m${Date.now()}`, sessionId: null, mainTab: "chat" };
4101
+ const node = { type: "split", dir, a: leaf, b: newLeaf, ratio: 0.5 };
4102
+ this.#replaceLeaf(leaf, node);
4103
+ this.focusedLeaf = newLeaf;
4104
+ this.lastMainLeaf = newLeaf;
4105
+ this.focusedWindow = "main";
4106
+ void this.#adoptBlankSession(newLeaf);
4107
+ this.layout();
4108
+ this.redraw();
4109
+ return true;
4110
+ }
4111
+ /** Close the focused main window (p c). Never deletes the session; if no
4112
+ * main window remains, a fresh blank-session main window appears. */
4113
+ closeWindow() {
4114
+ if (!this.winTree) { this.toast("还没有可分屏窗口"); return false; }
4115
+ const leaf = this.focusedLeaf ?? (this.focusedWindow === "list" ? this.#leafList() : this.#mainLeaves()[0]);
4116
+ if (!leaf || leaf.kind === "list") { this.toast("不能关闭会话列表"); return false; }
4117
+ const closedSession = leaf.sessionId;
4118
+ this.winTree = this.#prune(this.winTree, leaf);
4119
+ const mains = this.#mainLeaves();
4120
+ if (mains.length === 0) {
4121
+ const blank = { type: "leaf", kind: "main", id: `m${Date.now()}`, sessionId: null, mainTab: "chat" };
4122
+ const list = this.#leafList();
4123
+ this.winTree = list ? { type: "split", dir: "right", a: list, b: blank } : blank;
4124
+ this.focusedLeaf = blank;
4125
+ this.lastMainLeaf = blank;
4126
+ void this.#adoptBlankSession(blank);
4127
+ } else {
4128
+ this.focusedLeaf = mains[0];
4129
+ this.lastMainLeaf = mains[0];
4130
+ if (mains[0].sessionId && mains[0].sessionId !== this.chat.sessionId) this.openSession(mains[0].sessionId);
4131
+ }
4132
+ this.toast(closedSession ? `已关闭窗口(会话保留在侧栏)` : "已关闭窗口");
4133
+ this.layout();
4134
+ this.redraw();
4135
+ return true;
4136
+ }
4137
+ /** A blank session for a new window (reuse drafts like newSessionIn). */
4138
+ async #adoptBlankSession(leaf) {
4139
+ try {
4140
+ const blanks = this.sessions.filter((s) => s.blank && !s.running && s.sessionId !== this.currentSession);
4141
+ const blank = blanks.find((s) => s.cwd === process.cwd()) ?? blanks[0];
4142
+ if (blank) { await this.refreshSessions(); leaf.sessionId = blank.sessionId; this.openSession(blank.sessionId); return; }
4143
+ const { sessionId } = await this.api.call("session.create", { cwd: process.cwd() });
4144
+ if (typeof sessionId !== "string" || !sessionId) throw new Error("Host 未返回会话 ID");
4145
+ await this.refreshSessions();
4146
+ leaf.sessionId = sessionId;
4147
+ this.openSession(sessionId);
4148
+ } catch (e) { this.toast(`新建会话失败: ${e.message}`); }
4149
+ }
4150
+ /** Focus the given leaf (tree-aware). */
4151
+ #focusLeaf(leaf) {
4152
+ if (!leaf) return;
4153
+ this.focusedLeaf = leaf;
4154
+ if (leaf.kind === "list") {
4155
+ this.focusedWindow = "list";
4156
+ this.focus(this.sidebar);
4157
+ } else {
4158
+ this.focusedWindow = "main";
4159
+ this.lastMainLeaf = leaf;
4160
+ const tab = leaf.mainTab ?? "chat";
4161
+ if (tab !== this.mainTab) this.setTab(tab);
4162
+ else { const pane = this.paneWidget(); this.focus(pane ?? this.chat); }
4163
+ if (leaf.sessionId && leaf.sessionId !== this.chat.sessionId) this.openSession(leaf.sessionId);
4164
+ }
4165
+ this.redraw();
4166
+ }
4167
+
4168
+ /** Hit-test a split separator position (within ±1). */
4169
+ #hitSplitSeparator(px, py) {
4170
+ const walk = (node, rect) => {
4171
+ if (node.type !== "split") return null;
4172
+ const border = 1;
4173
+ if (node.dir === "right" || node.dir === "left") {
4174
+ const total = Math.max(2, rect.w - border);
4175
+ const r = Math.min(0.8, Math.max(0.2, node.ratio ?? 0.5));
4176
+ const aW = Math.max(1, Math.round(total * r));
4177
+ const x = rect.x + aW;
4178
+ if (Math.abs(px - x) <= 1 && py >= rect.y && py < rect.y + rect.h) return { node, rect };
4179
+ // recurse into the child containing the pointer
4180
+ const child = px < x ? node.a : node.b;
4181
+ const childRect = px < x ? { ...rect, w: aW } : { x: rect.x + aW + border, y: rect.y, w: Math.max(1, total - aW), h: rect.h };
4182
+ return walk(child, childRect);
4183
+ }
4184
+ const total = Math.max(2, rect.h - border);
4185
+ const r = Math.min(0.8, Math.max(0.2, node.ratio ?? 0.5));
4186
+ const aH = Math.max(1, Math.round(total * r));
4187
+ const y = rect.y + aH;
4188
+ if (Math.abs(py - y) <= 1 && px >= rect.x && px < rect.x + rect.w) return { node, rect };
4189
+ const child = py < y ? node.a : node.b;
4190
+ const childRect = py < y ? { ...rect, h: aH } : { x: rect.x, y: rect.y + aH + border, w: rect.w, h: Math.max(1, total - aH) };
4191
+ return walk(child, childRect);
4192
+ };
4193
+ const rootRect = { x: this.sidebarVisible ? this.sidebarWidth : 0, y: 1, w: this.mainAreaRect().w, h: this.mainAreaRect().h };
4194
+ return walk(this.winTree, rootRect);
4195
+ }
4196
+
4197
+ /** Prefix rows for the panel reference page — THE SAME table the engine
4198
+ * dispatches (single source of truth; chars from prefixKeys config). */
4199
+ prefixRowsData() {
4200
+ const k = prefixKeys();
4201
+ const t = this.#prefixTables().main;
4202
+ const meta = [
4203
+ ["search", "跨会话全文搜索", "Ctrl+F 已释放"],
4204
+ ["history", "输入历史搜索(可筛选)", "最近 50 条提问"],
4205
+ ["rewind", "回退(分支会话 + 原消息回填)", "/rewind"],
4206
+ ["model", "切换模型", "Ctrl+M 已释放"],
4207
+ ["theme", "配色主题", "Ctrl+D 已释放"],
4208
+ ["permission", "权限模式(沙箱 + 审批)", "F8 已释放"],
4209
+ ["config", "打开配置文件(改 prefixKeys 前缀键)", "Ctrl+K"],
4210
+ ["help", "帮助(按场景)", "Shift+/ 或前缀 ?"],
4211
+ ["editor", "外部编辑器编辑输入", "$VISUAL · 非零退出保留原稿"],
4212
+ ["panes", "窗格/分屏子表", "p r/l/u/n/c(NORMAL 亦可用)"],
4213
+ ];
4214
+ return meta
4215
+ .filter(([action]) => t.entries[k[action]])
4216
+ .map(([action, desc, hint]) => [k[action], desc, hint, () => { this.closeOverlay(); t.entries[k[action]](); }]);
4217
+ }
4218
+
4219
+ /** Public window-tree inspection (tests/diagnostics). */
4220
+ splitLeaves() { return this.#leaves(); }
4221
+ mainLeafCount() { return this.#mainLeaves().length; }
4222
+
3763
4223
  async checkUpdates(target = null, notify = false) {
3764
4224
  const specs = {
3765
4225
  dsh: { package: "@deepseek-ai/dsh", current: this.dshVersion, label: "DeepSeek Harness" },
@@ -3830,6 +4290,11 @@ export class App {
3830
4290
  this.focused = w;
3831
4291
  if (this.sidebar) this.sidebar.focused = w === this.sidebar;
3832
4292
  if (this.chat?.input) this.chat.inputActive = w === this.chat.input;
4293
+ // The focused window follows what is actually focused: the session list when
4294
+ // the sidebar is focused, the main window for chat / its input / the active
4295
+ // tab's pane. Overlays (modal) never change the focused window.
4296
+ if (w === this.sidebar) this.focusedWindow = "list";
4297
+ else if (w === this.chat || w === this.chat?.input || w === this.paneWidget()) this.focusedWindow = "main";
3833
4298
  }
3834
4299
 
3835
4300
  openMenu(items, ev) {
@@ -3887,6 +4352,17 @@ export class App {
3887
4352
  }
3888
4353
 
3889
4354
  setStatus(msg) { this.statusMsg = msg; this.redraw(); }
4355
+ /** Live-frame fan-out into the active main-area pane. The subagent pane
4356
+ * reloads (throttled); the tasks pane repaints (it reads live data). */
4357
+ notifyPaneLive(kind) {
4358
+ if (kind === "subagent" && this.mainTab === "subagent") {
4359
+ const page = this.subagentPane;
4360
+ if (page && typeof page.reload === "function") page.reload();
4361
+ } else if (kind === "tasks" && this.mainTab === "tasks") {
4362
+ this.redraw();
4363
+ }
4364
+ }
4365
+
3890
4366
  setJobs(jobs, sessionId = null) {
3891
4367
  // snapshot buffered per session: the mux baseline arrives at connect
3892
4368
  // time, possibly before the chat opens that session
@@ -3904,7 +4380,7 @@ export class App {
3904
4380
  if (this.chat.sessionId) this.chat.pollTail();
3905
4381
  // session.list is expensive (~100ms); refresh the sidebar every ~5s, not
3906
4382
  // on every streaming poll.
3907
- if (ticks++ % 10 === 0) { this.refreshSessions(); this.refreshSubagentStats(); }
4383
+ if (ticks++ % 10 === 0) { this.refreshSessions(); this.refreshSubagentStats(); this.notifyPaneLive("subagent"); }
3908
4384
  const delay = this.chat.pollSlow ? 2000 : (this.chat.running ? 500 : 1500);
3909
4385
  this.pollTimer = setTimeout(tick, delay);
3910
4386
  };
@@ -3970,6 +4446,10 @@ export class App {
3970
4446
  }
3971
4447
  }
3972
4448
 
4449
+ /** Frame injection entry (tests & external callers): routes through the
4450
+ * private handler so the live fan-out is exercised exactly like the wire. */
4451
+ deliverFrame(frame) { this.#onFrame(frame); }
4452
+
3973
4453
  #onFrame(frame) {
3974
4454
  this.injectFrame(frame);
3975
4455
  }
@@ -3991,6 +4471,7 @@ export class App {
3991
4471
  if (frame.type === "session/queue") {
3992
4472
  this.queueItems = frame.items ?? [];
3993
4473
  if (this.overlay instanceof QueuePanel) this.overlay.syncItems(this.queueItems);
4474
+ this.notifyPaneLive("tasks");
3994
4475
  }
3995
4476
  this.chat.onFrame(frame);
3996
4477
  }
@@ -4002,6 +4483,7 @@ export class App {
4002
4483
  // connect-time baseline would otherwise be dropped by the filter
4003
4484
  // below and the footer would stick at "0已完成")
4004
4485
  this.setJobs(frame.jobs ?? [], frame.sessionId ?? null);
4486
+ this.notifyPaneLive("tasks");
4005
4487
  if (this.chat.sessionId === frame.sessionId) this.chat.onFrame(frame);
4006
4488
  break;
4007
4489
  case "session/projection": {
@@ -4017,6 +4499,7 @@ export class App {
4017
4499
  // Reflow immediately so the tail remains reachable above fixed docks.
4018
4500
  if (["todos", "goal", "subagent"].includes(frame.key)) this.chat.inputChanged();
4019
4501
  if (["todos", "goal"].includes(frame.key) && this.overlay instanceof GoalPanel) this.overlay.sync();
4502
+ if (frame.key === "subagent") this.notifyPaneLive("subagent");
4020
4503
  break;
4021
4504
  }
4022
4505
  case "approval/resolved":
@@ -4215,6 +4698,212 @@ export class App {
4215
4698
 
4216
4699
  /** Fork the whole session, or — with `atSeq` — everything up to that message
4217
4700
  * (web forkAt parity: branch from a chosen point of a completed turn). */
4701
+ /** /btw side question: a single-turn, tool-less answer from the session
4702
+ * context without interrupting the running turn. Runs the standalone
4703
+ * side-question.mjs in a child process (the API key never enters this
4704
+ * process); the answer shows in a scrollable popup. */
4705
+ async sideQuestion(question) {
4706
+ const q = String(question ?? "").trim();
4707
+ if (!q) { this.toast("/btw <问题> — 请输入问题"); return; }
4708
+ this.toast("侧问中…(单轮、无工具,不打断主回合)");
4709
+ // context: the most recent visible transcript lines, capped
4710
+ const context = (this.chat.nodes ?? [])
4711
+ .flatMap((n) => (n.blocks ?? []).map((b) => b.text ?? ""))
4712
+ .filter((t) => t.trim())
4713
+ .slice(-6).join("\n").slice(0, 6000);
4714
+ let answer = null, err = null;
4715
+ const run = this.sideQuestionRunner;
4716
+ if (run) {
4717
+ try { ({ answer, err } = await run(q, context)); } catch (e) { err = String(e?.message ?? e); }
4718
+ } else {
4719
+ err = "sideQuestionRunner 未配置";
4720
+ }
4721
+ if (err || answer == null) { this.toast(`侧问失败: ${err ?? "无返回"}`); return; }
4722
+ const lines = answer.split("\n").map((l) => truncate(l, Math.min(78, this.screen.w - 8)));
4723
+ const h = Math.min(Math.max(6, lines.length + 2), Math.max(8, this.screen.h - 6));
4724
+ this.overlay = new Popup({
4725
+ x: Math.max(0, Math.floor((this.screen.w - Math.min(80, this.screen.w - 4)) / 2)),
4726
+ y: Math.max(0, Math.floor(this.screen.h / 2) - Math.floor(h / 2)),
4727
+ w: Math.min(80, this.screen.w - 4), h,
4728
+ title: `侧问 · ${truncate(q, 40)} · ↑↓ 滚动 · Esc 关闭`,
4729
+ lines, scrollable: true, buttons: [],
4730
+ onAction: () => { this.overlay = null; this.focus(this.chat); this.redraw(); },
4731
+ });
4732
+ this.focus(this.overlay);
4733
+ this.redraw();
4734
+ }
4735
+
4736
+ /** Scenario help: `?` (Shift+/) routes by the focused pane; the prefix
4737
+ * page's `?` reuses the focus recorded when the panel opened. Scrollable
4738
+ * buffer, Esc returns to the source pane. */
4739
+ showHelp(forceFocus = null) {
4740
+ const focusTarget = forceFocus ?? this.focused;
4741
+ const scenario = focusTarget === this.sidebar ? "sidebar" : (focusTarget === this.chat || focusTarget === this.chat.input) ? "chat" : "global";
4742
+ const lines = [];
4743
+ const pushTitle = (t) => lines.push([{ t: " " + t, fg: T.PURPLE, bg: T.PANEL, bold: true }]);
4744
+ const pushRow = (a, d) => lines.push([{ t: ` ${a}`, fg: T.ACCENT, bg: T.PANEL, bold: true }, { t: ` ${d}`, fg: T.TXT, bg: T.PANEL }]);
4745
+ const kb = keyBindings();
4746
+ if (scenario === "sidebar") {
4747
+ pushTitle("会话树(侧栏)帮助");
4748
+ pushRow("↑/↓", "选择会话 / 分组 Enter 打开(组=折叠/展开)");
4749
+ pushRow("Space", "组: 折叠/展开并回到组顶点");
4750
+ pushRow("Tab", "会话预览卡片(纯开关,焦点不离开;↑↓ 跟随)");
4751
+ pushRow("Ctrl+R", "会话菜单(重命名/移动/归档/删除/分叉/导出)");
4752
+ pushRow("[ / ]", "把会话移入上一/下一分组");
4753
+ pushRow("Ctrl+N", "新建会话(优先复用空白草稿)");
4754
+ pushRow("Ctrl+O", "附件管理器 / 文件选择器");
4755
+ } else if (scenario === "chat") {
4756
+ pushTitle("对话场景帮助");
4757
+ pushRow("g / G", "跳到首个 / 最新正文块 ↑/↓ 滚动历史");
4758
+ pushRow("[ / ]", "上一 / 下一提问的终点 Ctrl+E 步跳");
4759
+ pushRow("t / b", "全部思考块 / 工具块 展开折叠");
4760
+ pushRow("v / Shift+V", "正文块选择(复制走 OSC 52)");
4761
+ pushRow("Ctrl+R", "块菜单(复制/展开/分叉/文件预览)");
4762
+ pushRow("Ctrl+B", "侧栏显示/隐藏");
4763
+ pushRow(`?`, `本帮助(${scenario}场景) 双击 Ctrl+C 退出`);
4764
+ pushTitle("对话中的常用命令");
4765
+ pushRow("", SLASH_COMMANDS.map((c) => `${c.name}${c.hint ? " " + c.hint : ""}`).join(" · "));
4766
+ pushRow("", "Tab:输入中补全路径 / /命令(候选栏 ↑↓ 循环)");
4767
+ } else {
4768
+ pushTitle("全局约定");
4769
+ pushRow("Esc 层级", "输入 → 关命令栏 → 面板;NORMAL Esc 中断回合,INSERT 不打断");
4770
+ pushRow("Ctrl+Space", "控制面板 → 前缀页(首屏)");
4771
+ pushTitle("前缀键(Ctrl+Space 后按)");
4772
+ for (const [k, d, hint] of ControlPanel.prototype.prefixRows()) pushRow(k, `${d}${hint ? " · " + hint : ""}`);
4773
+ pushRow("Ctrl+P / F9", "命令面板 / 模式选择 Ctrl+G 目标 · Ctrl+T 轨迹页");
4774
+ pushRow("Ctrl+K / Ctrl+H", "编辑配置 / 技能");
4775
+ pushRow("双击 Ctrl+C / Ctrl+Q", "退出 TUI");
4776
+ pushRow("? / 前缀页 ?", "本帮助(按场景)");
4777
+ }
4778
+ const h = Math.min(Math.max(8, lines.length + 2), Math.max(10, this.screen.h - 4));
4779
+ const popup = new Popup({
4780
+ x: Math.max(0, Math.floor((this.screen.w - Math.min(78, this.screen.w - 4)) / 2)),
4781
+ y: Math.max(0, Math.floor(this.screen.h / 2) - Math.floor(h / 2)),
4782
+ w: Math.min(78, this.screen.w - 4), h,
4783
+ title: `${scenario === "sidebar" ? "侧栏" : scenario === "chat" ? "对话" : "全局"}帮助 · ↑↓ 滚动 · Esc 返回`,
4784
+ lines, scrollable: true, buttons: [],
4785
+ onAction: () => { this.overlay = null; this.focus(focusTarget ?? this.chat); this.redraw(); },
4786
+ });
4787
+ this.overlay = popup; this.focus(popup); this.redraw();
4788
+ }
4789
+
4790
+ /** External editor for the input draft (Claude-Code Ctrl+G parity, prefix e):
4791
+ * $VISUAL/$EDITOR edits the current draft in a temp file; a clean exit
4792
+ * refills the input, a non-zero exit (:cq) keeps the original draft,
4793
+ * and clearing the file abandons the edit. */
4794
+ editExternal() {
4795
+ const input = this.chat.input;
4796
+ const editor = process.env.VISUAL || process.env.EDITOR || "";
4797
+ if (!editor) { this.toast("未设置 $VISUAL/$EDITOR,无法打开外部编辑器"); return; }
4798
+ const tmp = join(tmpdir(), `dsh-tui-draft-${process.pid}-${Date.now()}.txt`);
4799
+ try { writeFileSync(tmp, input.value, "utf8"); } catch (e) { this.toast(`草稿写入失败: ${e.message}`); return; }
4800
+ const runner = this.externalEditRunner ?? ((file) => {
4801
+ const res = spawnSync(editor, [file], { stdio: "inherit" });
4802
+ return res.status;
4803
+ });
4804
+ let status = 0;
4805
+ try { status = Number(runner(tmp) ?? 0); } catch (e) { status = 1; this.toast(`编辑器失败: ${e.message}`); }
4806
+ let content = null;
4807
+ try { content = readFileSync(tmp, "utf8"); } catch {}
4808
+ try { unlinkSync(tmp); } catch {}
4809
+ if (status !== 0) { this.toast("编辑器非零退出(:cq);保留原稿"); return; }
4810
+ if (content == null || content.trim() === "") { this.toast("内容为空:放弃编辑"); return; }
4811
+ const old = input.value;
4812
+ input.setValue(content.replace(/\n$/, ""));
4813
+ if (input.value === old) this.toast("内容未变化");
4814
+ else { this.focus(input); this.toast("已从外部编辑器回填"); }
4815
+ this.redraw();
4816
+ }
4817
+
4818
+ /** Input-history search: filterable picker over the last ~50 typed prompts
4819
+ * (persisted), Enter refills the input. Reached from the prefix page (h). */
4820
+ showHistorySearch() {
4821
+ const merged = [];
4822
+ const seen = new Set();
4823
+ for (const t of [...this.chat.input.history, ...promptHistory()]) {
4824
+ const s = String(t ?? "").trim();
4825
+ if (!s || seen.has(s)) continue;
4826
+ seen.add(s); merged.push(s);
4827
+ }
4828
+ if (!merged.length) { this.toast("还没有输入历史"); return; }
4829
+ const items = merged.slice(0, 50).map((t) => ({ label: truncate(t.replace(/\s+/g, " "), 56), hint: "回车填入输入框", text: t }));
4830
+ const w = Math.max(1, Math.min(76, this.screen.w - 4)), ph = Math.max(1, Math.min(14, this.screen.h - 4));
4831
+ this.overlay = new Picker({
4832
+ x: Math.floor((this.screen.w - w) / 2), y: Math.floor((this.screen.h - ph) / 2),
4833
+ w, h: ph, title: "输入历史(输入过滤 / ↑↓ 选择 / Esc 取消)",
4834
+ items,
4835
+ onPick: (it) => {
4836
+ this.closeOverlay();
4837
+ const input = this.chat.input;
4838
+ input.setValue(it.text);
4839
+ this.focus(input);
4840
+ this.redraw();
4841
+ },
4842
+ onCancel: () => this.closeOverlay(),
4843
+ });
4844
+ this.redraw();
4845
+ }
4846
+
4847
+ /** Rewind: pick a previous user message, fork the session before that turn,
4848
+ * reopen the branch and put the original message back into the input.
4849
+ * Reached from /rewind (deliberately not a double-Esc global). */
4850
+ showRewindPicker() {
4851
+ const nodes = this.chat.nodes ?? [];
4852
+ const messages = [];
4853
+ for (const node of nodes) {
4854
+ if (node?.kind !== "user" || node.firstSeq == null) continue;
4855
+ const text = (node.blocks ?? []).filter((b) => b?.type === "text").map((b) => b.text ?? "").join("").trim();
4856
+ if (!text) continue;
4857
+ messages.push({ seq: node.firstSeq, text });
4858
+ }
4859
+ if (!messages.length) { this.toast("当前会话没有可回退的用户提问"); return; }
4860
+ const items = [...messages].reverse().map((m, i) => ({
4861
+ label: `${String(messages.length - i).padStart(2, " ")} ${truncate(m.text.replace(/\s+/g, " "), 40)}`,
4862
+ hint: `第 ${m.seq} 步`,
4863
+ seq: m.seq, text: m.text,
4864
+ }));
4865
+ const w = Math.max(1, Math.min(76, this.screen.w - 4)), ph = Math.max(1, Math.min(14, this.screen.h - 4));
4866
+ this.overlay = new Picker({
4867
+ x: Math.floor((this.screen.w - w) / 2), y: Math.floor((this.screen.h - ph) / 2),
4868
+ w, h: ph, title: "回退到…(Esc 取消)",
4869
+ items,
4870
+ onPick: (it) => this.#confirmRewind(it),
4871
+ onCancel: () => this.closeOverlay(),
4872
+ });
4873
+ this.redraw();
4874
+ }
4875
+
4876
+ #confirmRewind(it) {
4877
+ const title = "确认真回溯?";
4878
+ const lines = [`将回退到这条提问所属回合开始之前,并创建分支会话:`, "", ` ${truncate(it.text, Math.min(56, this.screen.w - 10))}`, "", "原消息会放回输入框供修改重发(原会话不变)。"];
4879
+ const popup = new Popup({
4880
+ x: Math.max(0, Math.floor((this.screen.w - Math.min(72, this.screen.w - 4)) / 2)),
4881
+ y: Math.max(0, Math.floor(this.screen.h / 2) - 4), w: Math.min(72, this.screen.w - 4), h: lines.length + 3,
4882
+ title, lines, buttons: [{ label: "是 (y)", action: "yes" }, { label: "否 (n)", action: "no" }],
4883
+ onAction: (b) => {
4884
+ this.closeOverlay();
4885
+ if (b?.action === "yes") void this.#executeRewind(it);
4886
+ else this.redraw();
4887
+ },
4888
+ });
4889
+ this.overlay = popup; this.focus(popup); this.redraw();
4890
+ }
4891
+
4892
+ async #executeRewind(it) {
4893
+ const s = this.sessions.find((x) => x.sessionId === this.currentSession);
4894
+ if (!s) return;
4895
+ if (it.seq <= 1) { this.toast("第一条消息不能作为回退边界"); return; }
4896
+ if (this.chat.running || s.running) { this.cancelSession({ sessionId: s.sessionId }); this.toast("已请求中断当前回合…"); await new Promise((r) => setTimeout(r, 600)); }
4897
+ try {
4898
+ const { sessionId } = await this.api.call("session.fork", { sessionId: s.sessionId, atSeq: it.seq - 1 });
4899
+ await this.refreshSessions();
4900
+ this.openSession(sessionId);
4901
+ const input = this.chat.input;
4902
+ if (!input.value) { input.setValue(it.text); this.focus(input); }
4903
+ this.toast(`已回退并分支: ${sessionId.slice(0, 8)};原消息已回到输入框`);
4904
+ } catch (e) { this.toast(`回退失败: ${e.message}`); }
4905
+ }
4906
+
4218
4907
  async forkSession(s, atSeq = null) {
4219
4908
  try {
4220
4909
  const { sessionId } = await this.api.call("session.fork", { sessionId: s.sessionId, ...(atSeq != null ? { atSeq } : {}) });
@@ -4391,7 +5080,7 @@ export class App {
4391
5080
  this.chat.input?.setValue(this.draftsBySession.get(sessionId) ?? "");
4392
5081
  // A sidebar Enter intentionally preserves sidebar focus, but every
4393
5082
  // session-scoped panel must immediately follow the newly opened session.
4394
- if (this.mode === "trajectory" && this.trajectoryPanel) await this.trajectoryPanel.load(sessionId);
5083
+ if (this.mainTab === "trajectory" && this.trajectoryPanel) await this.trajectoryPanel.load(sessionId);
4395
5084
  else if (this.fullBuffer && this.fullBuffer === this.subagentPanel) this.subagentPanel.load(sessionId);
4396
5085
  else if (this.fullBuffer && this.fullBuffer === this.skillsPanel) this.skillsPanel.load?.(sessionId);
4397
5086
  if (epoch !== this.sessionEpoch || sessionId !== this.currentSession) return;
@@ -4433,20 +5122,17 @@ export class App {
4433
5122
  }
4434
5123
 
4435
5124
  setMode(mode) {
4436
- this.mode = mode === "trajectory" ? "trajectory" : "chat";
4437
- if (this.mode === "trajectory") {
4438
- if (!this.currentSession) { this.toast("先打开一个会话"); this.mode = "chat"; this.redraw(); return; }
4439
- if (!this.trajectoryPanel) this.trajectoryPanel = new TrajectoryPanel(this);
4440
- this.trajectoryPanel.load(this.currentSession);
4441
- }
4442
- const panel = this.panelForMode();
4443
- if (panel && this.focused !== this.sidebar) this.focus(panel);
4444
- else if (this.focused !== this.sidebar) this.focus(this.chat);
4445
- this.layout();
4446
- this.redraw();
5125
+ // Trajectory requires an open session (tab-click / chat-toggle path); the
5126
+ // tab cycle (cycleTab) reaches trajectory regardless.
5127
+ if (mode === "trajectory" && !this.currentSession) {
5128
+ this.toast("先打开一个会话");
5129
+ this.setTab("chat");
5130
+ return;
5131
+ }
5132
+ this.setTab(mode);
4447
5133
  }
4448
5134
 
4449
- panelForMode() { return this.mode === "trajectory" ? this.trajectoryPanel : null; }
5135
+ panelForMode() { return this.paneWidget(); }
4450
5136
 
4451
5137
  /** Full-screen modal buffers replace the old tab-page modes: they coexist
4452
5138
  * with the sidebar/chat/trajectory pane focus instead of fighting it. */
@@ -4465,6 +5151,56 @@ export class App {
4465
5151
  this.redraw();
4466
5152
  return true;
4467
5153
  }
5154
+ /** Ctrl+W: small workspace picker — choose the current session's workspace
5155
+ * (move), rename it, or add a new one from a path. */
5156
+ showWorkspacePicker() {
5157
+ const items = (this.workspaceItems ?? []).map((w) => ({
5158
+ label: truncate(w.title ?? w.path ?? w.workspaceId, 40),
5159
+ hint: w.path ?? "",
5160
+ workspace: w,
5161
+ }));
5162
+ items.push({ label: "✎ 重命名当前工作区…", hint: "选中后重命名所选工作区", rename: true });
5163
+ items.push({ label: "+ 新建工作区…", hint: "输入路径", create: true });
5164
+ const w = Math.max(1, Math.min(72, this.screen.w - 4)), ph = Math.max(1, Math.min(14, this.screen.h - 4));
5165
+ this.overlay = new Picker({
5166
+ x: Math.floor((this.screen.w - w) / 2), y: Math.floor((this.screen.h - ph) / 2),
5167
+ w, h: ph, title: "工作区 · Enter 选择/执行 · Esc 关闭",
5168
+ items,
5169
+ onPick: (it) => {
5170
+ this.closeOverlay();
5171
+ if (it.create) { this.addWorkspace(); return; }
5172
+ if (it.rename) { const ws = this.#workspacePicker?.items?.[this.#workspacePicker.sel]?.workspace; this.#beginWorkspaceRename(ws); return; }
5173
+ if (it.workspace) { void this.#moveCurrentToWorkspace(it.workspace); }
5174
+ },
5175
+ onCancel: () => this.closeOverlay(),
5176
+ });
5177
+ // remember selection index for the rename row
5178
+ this.#workspacePicker = this.overlay;
5179
+ this.focus(this.overlay);
5180
+ this.redraw();
5181
+ }
5182
+ /** Move the current session into a workspace (append at its end). */
5183
+ async #moveCurrentToWorkspace(ws) {
5184
+ if (!this.currentSession) { this.toast("先打开一个会话"); return; }
5185
+ if ((ws.sessionIds ?? []).includes(this.currentSession)) { this.toast("当前会话已在该工作区"); return; }
5186
+ try {
5187
+ await this.api.call("workspace.insertSessionBefore", { workspaceId: ws.workspaceId, sessionId: this.currentSession });
5188
+ await this.refreshSessions();
5189
+ this.toast(`已移动到工作区: ${truncate(ws.title ?? ws.path ?? ws.workspaceId, 20)}`);
5190
+ } catch (e) { this.toast(`移动失败: ${e.message}`); }
5191
+ }
5192
+ /** Begin the inline rename of a workspace (reuses the existing rename UI). */
5193
+ #beginWorkspaceRename(ws) {
5194
+ const group = this.sidebar.groups.find((g) => g.workspaceId === ws?.workspaceId) ?? null;
5195
+ if (!group) { this.toast("未找到该工作区"); return; }
5196
+ // the existing rename flow is driven by the sidebar group; reuse its input
5197
+ const input = new Input({ x: this.sidebar.x + 2, y: 1, w: this.sidebar.w - 4, h: 1, prompt: "新标题: ", allowEmptyEnter: true, onEnter: () => this.#commitWorkspaceRename(group, input), onKey: (ev) => { if (ev?.name === "escape") { this.#closeRename(); return true; } return Input.prototype?.onKey ? false : false; } });
5198
+ input.value = group.title ?? "";
5199
+ input.cursor = graphemes(input.value).length;
5200
+ this.renameInput = input;
5201
+ this.redraw();
5202
+ }
5203
+
4468
5204
  showWorkspaceBuffer() { if (!this.workspacePanel) this.workspacePanel = new WorkspacePanel(this); this.openFullBuffer(this.workspacePanel); this.workspacePanel.load(); }
4469
5205
  /** Open a local file path in the workspace panel's preview pane. */
4470
5206
  openWorkspaceFile(path) {
@@ -4476,11 +5212,7 @@ export class App {
4476
5212
  }
4477
5213
  showSettingsBuffer() { if (!this.settingsPanel) this.settingsPanel = new SettingsPanel(this); this.openFullBuffer(this.settingsPanel); this.settingsPanel.load(); }
4478
5214
  showModelsBuffer() { if (!this.modelPanel) this.modelPanel = new ModelPanel(this); this.openFullBuffer(this.modelPanel); this.modelPanel.load(); }
4479
- showSubagentBuffer() {
4480
- if (!this.currentSession) { this.toast("先打开一个会话"); return; }
4481
- if (!this.subagentPanel) this.subagentPanel = new SubagentPanel(this);
4482
- this.openFullBuffer(this.subagentPanel); this.subagentPanel.load(this.currentSession);
4483
- }
5215
+ showSubagentBuffer() { this.setTab("subagent"); }
4484
5216
  showSkillsBuffer() {
4485
5217
  if (!this.currentSession) { this.toast("先打开一个会话"); return; }
4486
5218
  if (!this.skillsPanel) this.skillsPanel = new SkillsPanel(this);
@@ -4512,7 +5244,7 @@ export class App {
4512
5244
  else this.toast("先打开一个会话");
4513
5245
  }
4514
5246
 
4515
- showJobs() { this.overlay = new JobsPanel(this); this.refreshSubagentStats(); this.redraw(); }
5247
+ showJobs() { this.setTab("tasks"); }
4516
5248
  async refreshSubagentStats(sessionId = this.currentSession) {
4517
5249
  if (!sessionId) return;
4518
5250
  try {
@@ -4523,8 +5255,8 @@ export class App {
4523
5255
  if (sessionId === this.currentSession) this.redraw();
4524
5256
  } catch {}
4525
5257
  }
4526
- showQueue() { this.overlay = new QueuePanel(this); this.redraw(); }
4527
- showGoal() { this.overlay = buildGoalPopup(this); this.redraw(); }
5258
+ showQueue() { this.setTab("tasks"); }
5259
+ showGoal() { this.overlay = new GoalPanel(this); this.focus(this.overlay); this.redraw(); }
4528
5260
  showModePicker() { this.overlay = buildModePicker(this); this.redraw(); }
4529
5261
  showThemePicker() { this.overlay = new ThemePickerBuffer(this); this.redraw(); }
4530
5262
 
@@ -4640,17 +5372,20 @@ export class App {
4640
5372
  this.popup = null;
4641
5373
  this.activePrompt = null;
4642
5374
  this.promptQueue = [];
4643
- for (const p of [this.workspacePanel, this.trajectoryPanel, this.settingsPanel, this.modelPanel, this.subagentPanel, this.skillsPanel]) {
5375
+ for (const p of [this.workspacePanel, this.trajectoryPanel, this.settingsPanel, this.modelPanel, this.subagentPanel, this.skillsPanel, this.subagentPane, this.tasksPane]) {
4644
5376
  if (p?.dispose) { try { p.dispose(); } catch {} }
4645
5377
  }
4646
5378
  this.workspacePanel = this.trajectoryPanel = this.settingsPanel = this.modelPanel = this.subagentPanel = this.skillsPanel = null;
5379
+ this.subagentPane = this.tasksPane = null;
4647
5380
  this.chat.cache.clear();
4648
5381
  this.chat.nodes = [];
4649
5382
  this.chat.collapsedBlocks.clear();
4650
5383
  this.chat.expanded.clear();
4651
5384
  this.chat.queueRebuild();
4652
5385
  this.chat.view.anchorLock = null;
5386
+ this.mainTab = "chat";
4653
5387
  this.mode = "chat";
5388
+ this.focusedWindow = "main";
4654
5389
  this.focus(this.chat);
4655
5390
  this.toast("正在重新加载…");
4656
5391
  await this.refreshSessions();
@@ -4809,21 +5544,28 @@ export class App {
4809
5544
  #runBinding(id, slot) {
4810
5545
  switch (id) {
4811
5546
  case "sessionFilter": this.startSearch(); this.redraw(); return true;
4812
- case "panel": this.overlay = new ControlPanel(this, { startPage: 0 }); this.redraw(); return true;
4813
- case "panePrev": this.focusPane(-1); return true;
4814
- case "paneNext": this.focusPane(1); return true;
5547
+ case "panel":
5548
+ this.focusBeforePanel = this.focused; // prefix ? 帮助回到此场景
5549
+ this.overlay = new ControlPanel(this, { startPage: 0 });
5550
+ this.redraw();
5551
+ return true;
5552
+ case "help": this.showHelp(); return true;
5553
+ case "panePrev": this.focusWindow(-1); return true;
5554
+ case "paneNext": this.focusWindow(1); return true;
5555
+ case "tabNext": this.#tabCycle(1); return true;
5556
+ case "tabPrev": this.#tabCycle(-1); return true;
4815
5557
  case "permissionRotate": this.rotatePermission(); return true;
4816
5558
  case "editConfig": this.editConfigFile(); return true;
4817
5559
  case "quit": this.stop(); return true;
4818
5560
  case "model": this.overlay = buildModelPicker(this); this.redraw(); return true;
4819
- case "trajectory": this.setMode("trajectory"); return true;
4820
- case "workspace": this.showWorkspaceBuffer(); return true;
5561
+ case "trajectory": this.setTab("trajectory"); return true;
5562
+ case "workspace": this.showWorkspacePicker(); return true;
4821
5563
  case "settings": this.showSettingsBuffer(); return true;
4822
- case "subagent": this.showSubagentBuffer(); return true;
5564
+ case "subagent": this.setTab("subagent"); return true;
4823
5565
  case "skills": this.showSkillsBuffer(); return true;
4824
5566
  case "goal": this.showGoal(); return true;
4825
- case "jobs": this.showJobs(); return true;
4826
- case "queue": this.showQueue(); return true;
5567
+ case "jobs": this.setTab("tasks"); return true;
5568
+ case "queue": this.setTab("tasks"); return true;
4827
5569
  case "busyEnter": {
4828
5570
  const next = busyEnter() === "queue" ? "steer" : "queue";
4829
5571
  saveTuiConfig({ busyEnter: next });
@@ -4834,7 +5576,7 @@ export class App {
4834
5576
  case "stepJump": this.quickJumpStep(); return true;
4835
5577
  case "sidebar": this.toggleSidebar(); return true;
4836
5578
  case "addWorkspace": this.addWorkspace(); return true;
4837
- case "commandPalette": this.overlay = new ControlPanel(this, { startPage: 1 }); this.redraw(); return true;
5579
+ case "commandPalette": this.overlay = new ControlPanel(this, { startPage: 2 }); this.redraw(); return true;
4838
5580
  case "modePicker": this.showModePicker(); return true;
4839
5581
  case "themePicker": this.showThemePicker(); return true;
4840
5582
  case "logoPicker": this.showLogoPicker(); return true;
@@ -4901,27 +5643,84 @@ export class App {
4901
5643
  }
4902
5644
 
4903
5645
  #modeTabs() {
4904
- // Ctrl+Left/Right pane targets. The other panels are full-screen buffers.
5646
+ // Main-window tab strip: 对话 | 轨迹 | 子代理 | 后台任务 (Shift+Tab cycles).
4905
5647
  return [
4906
5648
  ["chat", "对话"],
4907
5649
  ["trajectory", "轨迹"],
5650
+ ["subagent", "子代理"],
5651
+ ["tasks", "后台任务"],
4908
5652
  ];
4909
5653
  }
4910
5654
 
5655
+ /** Draw the split-tree chrome: the focused main leaf keeps its real content
5656
+ * (rendered above); every OTHER leaf gets a faint placeholder cell with its
5657
+ * window id + short session, and the separators get border chars. */
5658
+ #renderSplitOverlay(s) {
5659
+ const map = this.#layoutLeaves();
5660
+ const leaves = this.#leaves();
5661
+ const focused = this.focusedLeaf ?? (this.focusedWindow === "list" ? this.#leafList() : this.#mainLeaves()[0]);
5662
+ // separators: for each split node, draw the border line between a and b
5663
+ const drawSep = (node, rect) => {
5664
+ if (node.type === "leaf") return;
5665
+ const border = 1;
5666
+ if (node.dir === "right" || node.dir === "left") {
5667
+ const total = Math.max(2, rect.w - border);
5668
+ const aW = Math.max(1, Math.floor(total / 2));
5669
+ const x = rect.x + aW;
5670
+ for (let y = Math.max(0, rect.y); y < rect.y + rect.h; y++) s.put(x, y, "│", { fg: T.BORDER });
5671
+ } else {
5672
+ const total = Math.max(2, rect.h - border);
5673
+ const aH = Math.max(1, Math.floor(total / 2));
5674
+ const y = rect.y + aH;
5675
+ for (let x = Math.max(0, rect.x); x < rect.x + rect.w; x++) s.put(x, y, "─", { fg: T.BORDER });
5676
+ }
5677
+ drawSep(node.a, { ...rect, ...(node.dir === "right" || node.dir === "left" ? { w: Math.max(1, Math.floor(total / 2)) } : { h: Math.max(1, Math.floor(total / 2)) }) });
5678
+ drawSep(node.b, { ...rect, ...(node.dir === "right" || node.dir === "left" ? { x: rect.x + (node.dir === "right" ? Math.max(1, Math.floor(total / 2)) + border : 0), w: Math.max(1, total - Math.floor(total / 2)) } : { y: rect.y + (node.dir === "down" ? Math.max(1, Math.floor(total / 2)) + border : 0), h: Math.max(1, total - Math.floor(total / 2)) }) });
5679
+ };
5680
+ const rootRect = { x: this.sidebarVisible ? this.sidebarWidth : 0, y: 1, w: this.mainAreaRect().w, h: this.mainAreaRect().h };
5681
+ if (leaves.length > 2) drawSep(this.winTree, rootRect);
5682
+ // placeholders for non-focused leaves
5683
+ let n = 1;
5684
+ for (const leaf of leaves) {
5685
+ if (leaf.kind === "main") {
5686
+ const rect = map.get(leaf);
5687
+ if (leaf === focused || !rect || rect.w < 8 || rect.h < 3) continue;
5688
+ const label = `W${n} · ${leaf.sessionId ? leaf.sessionId.slice(0, 8) : "空白会话"} · Shift+Tab/Ctrl+←→ 切换`;
5689
+ s.fillRect(rect.x + 1, rect.y, rect.x + rect.w - 1, rect.y + rect.h - 1, " ", { bg: T.BG });
5690
+ s.text(rect.x + 2, rect.y + 1, truncate(label, Math.max(0, rect.w - 4)), { fg: T.FAINT, bg: T.BG });
5691
+ }
5692
+ n++;
5693
+ }
5694
+ // the focused leaf also gets a small W# marker at its top-left
5695
+ const frect = map.get(focused);
5696
+ if (frect && focused?.kind === "main") {
5697
+ const idx = leaves.indexOf(focused) + 1;
5698
+ s.text(frect.x + 1, frect.y, `W${idx}`, { fg: T.DIM, bg: T.PANEL });
5699
+ }
5700
+ }
5701
+
4911
5702
  #renderTabBar(s) {
4912
5703
  const x = this.sidebarVisible ? this.sidebarWidth : 0;
4913
5704
  const w = this.screen.w - x;
4914
5705
  s.fillRect(x, 0, x + w - 1, 0, " ", { bg: T.PANEL });
4915
5706
  const tabs = [...this.#modeTabs()];
4916
5707
  let tx = x;
4917
- const sidebarFocused = this.focused === this.sidebar;
4918
5708
  for (const [id, label] of tabs) {
4919
- const sel = !sidebarFocused && id === this.mode;
5709
+ const sel = id === this.mainTab;
4920
5710
  const seg = ` ${label} `;
4921
- s.text(tx, 0, seg, { fg: sel ? T.SELFG : T.DIM, bg: sel ? T.ACCENT : T.PANEL, attrs: sel ? 1 : 0 });
5711
+ // The active tab stays highlighted even while the list window is focused;
5712
+ // the strip simply indicates which tab would be active in the main window.
5713
+ const fg = sel ? T.SELFG : T.DIM;
5714
+ const bg = sel ? T.ACCENT : T.PANEL;
5715
+ s.text(tx, 0, seg, { fg, bg, attrs: sel ? 1 : 0 });
4922
5716
  tx += strWidth(seg);
4923
5717
  }
4924
- if (this.currentSession == null) s.text(x + w - 16, 0, "未选会话", { fg: T.FAINT, bg: T.PANEL });
5718
+ // Flow the key hint right after the tabs: it never overlaps the sidebar,
5719
+ // and clips at the right edge on narrow mains (Screen.text truncates).
5720
+ const hint = " Shift+Tab 切标签 · Tab/Ctrl+←→ 切窗口 ";
5721
+ s.text(tx, 0, truncate(hint, Math.max(0, x + w - tx)), { fg: T.FAINT, bg: T.PANEL });
5722
+ tx += strWidth(hint);
5723
+ if (this.currentSession == null) s.text(tx + 1, 0, "未选会话", { fg: T.FAINT, bg: T.PANEL });
4925
5724
  }
4926
5725
 
4927
5726
  #clickTab(px) {
@@ -4939,6 +5738,115 @@ export class App {
4939
5738
  return false;
4940
5739
  }
4941
5740
 
5741
+ /** Whether an editor (INSERT) surface owns the keyboard: the chat input or a
5742
+ * pane's own message input (subagent/tasks inputMode). */
5743
+ #inInsertMode() {
5744
+ if (this.focused === this.chat?.input) return true;
5745
+ return this.focused?.inputMode === true;
5746
+ }
5747
+
5748
+ /** Structural window/tab keys (NORMAL mode only; see dispatch). Returns true
5749
+ * when handled so the key never reaches a binding or the focused widget.
5750
+ * Ctrl+Left/Right (panePrev/paneNext) are editable bindings and route through
5751
+ * the binding matcher to focusWindow; here we only own Tab/Backtab. */
5752
+ #handleStructuralKey(ev) {
5753
+ if (ev.type !== "key") return false;
5754
+ if (this.#inInsertMode()) return false; // INSERT keeps editor semantics
5755
+ if (ev.name === "tab" && !ev.ctrl && !ev.alt) {
5756
+ // Toggle focused window: list ↔ main (fast toggle, same as the cycle).
5757
+ if (this.sidebarVisible) this.focusWindow(1);
5758
+ else this.setFocusedWindow("main");
5759
+ return true;
5760
+ }
5761
+ if (ev.name === "backtab" && !ev.ctrl && !ev.alt) {
5762
+ // Cycle the main-window tabs. From the list window, focus main first.
5763
+ if (this.focusedWindow !== "main") this.setFocusedWindow("main");
5764
+ this.cycleTab(1);
5765
+ return true;
5766
+ }
5767
+ return false;
5768
+ }
5769
+
5770
+ /** Tab cycling shared by Shift+Tab (structural) and Ctrl+H/L bindings:
5771
+ * from the list window, focus the main window first. */
5772
+ #tabCycle(delta) {
5773
+ if (this.focusedWindow !== "main") this.setFocusedWindow("main");
5774
+ this.cycleTab(delta);
5775
+ return true;
5776
+ }
5777
+
5778
+ // ---- chained prefix layers (nvim-style pending keys) ----
5779
+
5780
+ /** Generic pending-layer tables. The p table is the first user; future
5781
+ * g/z tables register here too. Entries: (ev) => run. */
5782
+ #prefixTables() {
5783
+ const k = prefixKeys();
5784
+ return {
5785
+ // 主表:Ctrl+Space 面板首屏与 NORMAL 前缀层共用(同一张表,同一语义)
5786
+ main: {
5787
+ hint: `${k.search}=搜索 ${k.model}=模型 ${k.theme}=主题 ${k.history}=历史 ${k.rewind}=回退 ${k.config}=配置 ${k.help}=帮助 ${k.editor}=编辑 ${k.permission}=权限 ${k.panes}=窗格 · Esc 取消`,
5788
+ entries: {
5789
+ [k.search]: () => { this.closeOverlay(); this.startSearch(); this.redraw(); },
5790
+ [k.model]: () => { this.closeOverlay(); this.overlay = buildModelPicker(this); this.redraw(); },
5791
+ [k.theme]: () => { this.closeOverlay(); this.showThemePicker(); },
5792
+ [k.history]: () => { this.closeOverlay(); this.showHistorySearch(); },
5793
+ [k.rewind]: () => { this.closeOverlay(); this.showRewindPicker(); },
5794
+ [k.config]: () => { this.closeOverlay(); this.editConfigFile(); },
5795
+ [k.help]: () => { this.closeOverlay(); this.showHelp(this.focusBeforePanel ?? null); },
5796
+ [k.editor]: () => { this.closeOverlay(); this.editExternal(); },
5797
+ [k.permission]: () => { this.closeOverlay(); this.showPermissionPicker(); },
5798
+ [k.panes]: () => { this.closeOverlay(); this.armPrefix("p"); },
5799
+ },
5800
+ },
5801
+ p: {
5802
+ hint: "p · r=右 l=左 u=上 n=下 c=关窗 · Esc/2s 取消",
5803
+ entries: {
5804
+ r: () => this.splitWindow("right"),
5805
+ l: () => this.splitWindow("left"),
5806
+ u: () => this.splitWindow("up"),
5807
+ n: () => this.splitWindow("down"),
5808
+ c: () => this.closeWindow(),
5809
+ },
5810
+ },
5811
+ };
5812
+ }
5813
+ /** Arm a pending layer programmatically (e.g. the panel chaining into p). */
5814
+ armPrefix(key) {
5815
+ const table = this.#prefixTables()[key];
5816
+ if (!table) return false;
5817
+ this.pendingPrefix = { table, key, timer: null };
5818
+ this.pendingPrefix.timer = setTimeout(() => {
5819
+ if (this.pendingPrefix?.key === key) this.#cancelPrefix();
5820
+ }, this.prefixTimeoutMs);
5821
+ this.redraw();
5822
+ return true;
5823
+ }
5824
+ #handlePrefixKey(ev) {
5825
+ if (ev.type !== "key") return false;
5826
+ const pending = this.pendingPrefix;
5827
+ if (pending) {
5828
+ if (ev.name === "escape") { this.#cancelPrefix(); return true; }
5829
+ const entry = pending.table.entries[ev.key];
5830
+ if (entry) { pending.table; this.#cancelPrefix(); entry(ev); return true; }
5831
+ this.#cancelPrefix("已取消 p 前缀");
5832
+ return true;
5833
+ }
5834
+ // Arm only from a main window, not in INSERT (list window: p = preview).
5835
+ if (ev.name === "char" && ev.key === "p" && !ev.ctrl && !ev.alt && !ev.shift
5836
+ && this.focusedWindow === "main" && !this.#inInsertMode()) {
5837
+ this.armPrefix("p");
5838
+ return true;
5839
+ }
5840
+ return false;
5841
+ }
5842
+ #cancelPrefix(message = "") {
5843
+ if (!this.pendingPrefix) return;
5844
+ if (this.pendingPrefix.timer) clearTimeout(this.pendingPrefix.timer);
5845
+ this.pendingPrefix = null;
5846
+ if (message) this.toast(message);
5847
+ this.redraw();
5848
+ }
5849
+
4942
5850
  // ---- dispatch ----
4943
5851
 
4944
5852
  onEvent(ev) {
@@ -5045,27 +5953,54 @@ export class App {
5045
5953
  return;
5046
5954
  }
5047
5955
 
5956
+ if (this.#handlePrefixKey(ev)) return;
5048
5957
  // tab bar clicks (row 0 of the main area)
5049
5958
  if (ev.type === "mouse" && ev.kind === "press" && ev.button === 0 && ev.y === 0 && ev.x >= (this.sidebarVisible ? this.sidebarWidth : 0)) {
5050
5959
  if (this.#clickTab(ev.x)) { this.redraw(); return; }
5051
5960
  }
5052
5961
  // mouse routes by position (click = focus + dispatch)
5053
- if (this.mode !== "chat") {
5054
- if (ev.type === "mouse" && this.sidebarVisible && this.sidebar.inside(ev.x, ev.y)) {
5055
- if (this.focused !== this.chat.input) this.focus(this.sidebar); // INSERT exits only via Esc
5056
- if (this.sidebar.onMouse(ev)) this.redraw();
5057
- return;
5058
- }
5059
- const panel = this.panelForMode();
5060
- const paneSwitch = ev.type === "key" && ev.ctrl && (ev.name === "left" || ev.name === "right");
5061
- if (panel && this.focused !== this.sidebar && !paneSwitch) {
5962
+ if (this.mainTab !== "chat") {
5963
+ const panel = this.paneWidget();
5964
+ if (panel && this.focused === panel) {
5965
+ // The pane owns keyboard while it is focused. Intercept the pane-level
5966
+ // close (q / Esc) and insert (i) keys BEFORE delegating — the panes'
5967
+ // own close handlers would close an overlay; here they switch back to
5968
+ // the chat tab. Tab/Shift+Tab (window/tab layer) and Ctrl+Left/Right
5969
+ // (panePrev/paneNext) never leak into a pane: they fall through to the
5970
+ // global shortcuts below and are switched at the window/tab layer.
5971
+ if (ev.type === "key") {
5972
+ if (ev.name === "escape" || (ev.name === "char" && ev.key === "q" && !ev.ctrl)) {
5973
+ this.setTab("chat");
5974
+ this.redraw();
5975
+ return;
5976
+ }
5977
+ if (bindingMatchFor(ev, keyBindings(), false, ["insert"])?.id === "insert") {
5978
+ // In the 子代理 pane, i belongs to the pane (focus its message
5979
+ // input); elsewhere i returns to chat and focuses the input.
5980
+ if (this.mainTab !== "subagent") {
5981
+ this.setTab("chat");
5982
+ this.focus(this.chat.input);
5983
+ this.redraw();
5984
+ return;
5985
+ }
5986
+ }
5987
+ } else if (ev.type === "text" && graphemes(ev.text).length === 1) {
5988
+ // Legacy terminals deliver q/i as text; mirror the key intercepts.
5989
+ const t = graphemes(ev.text)[0].toLowerCase();
5990
+ if (t === "q") { this.setTab("chat"); this.redraw(); return; }
5991
+ if (t === "i") { this.setTab("chat"); this.focus(this.chat.input); this.redraw(); return; }
5992
+ }
5062
5993
  const handled = ev.type === "key" || ev.type === "text" || ev.type === "paste" ? panel.onKey(ev) : panel.onMouse(ev);
5063
5994
  if (handled) { this.redraw(); return; }
5064
- // A visible modal panel owns non-global text/paste even when it declines
5065
- // the event; never leak it into the hidden chat/Input behind the panel.
5995
+ // A visible panel owns non-global text/paste even when it declines
5996
+ // the event; never leak it into the hidden chat/Input behind it.
5066
5997
  if (ev.type === "text" || ev.type === "paste") { this.redraw(); return; }
5998
+ // unhandled key events fall through to global shortcuts
5999
+ } else if (ev.type === "mouse" && this.sidebarVisible && this.sidebar.inside(ev.x, ev.y)) {
6000
+ if (this.focused !== this.chat.input) this.focus(this.sidebar); // INSERT exits only via Esc
6001
+ if (this.sidebar.onMouse(ev)) this.redraw();
6002
+ return;
5067
6003
  }
5068
- // unhandled key events fall through to global shortcuts
5069
6004
  }
5070
6005
  if (ev.type === "mouse") {
5071
6006
  // input drag-selection: the gesture continues across motion events
@@ -5075,6 +6010,24 @@ export class App {
5075
6010
  if (this.chat.input.onMouse(ev)) this.redraw();
5076
6011
  return;
5077
6012
  }
6013
+ // Split-window divider drag: press within ±1 of a split separator.
6014
+ if (this.winTree && ev.kind === "press" && ev.button === 0 && !this.draggingDivider) {
6015
+ const sep = this.#hitSplitSeparator(ev.x, ev.y);
6016
+ if (sep) { this.#splitDrag = sep; this.redraw(); return; }
6017
+ }
6018
+ if (this.#splitDrag) {
6019
+ const sd = this.#splitDrag;
6020
+ if (ev.kind === "drag" && ev.button === 0) {
6021
+ const { node, rect } = sd;
6022
+ const horizontal = node.dir === "right" || node.dir === "left";
6023
+ const span = horizontal ? Math.max(2, rect.w - 1) : Math.max(2, rect.h - 1);
6024
+ const pos = horizontal ? (ev.x - rect.x) : (ev.y - rect.y);
6025
+ node.ratio = Math.min(0.8, Math.max(0.2, pos / span));
6026
+ this.layout(); this.redraw();
6027
+ return;
6028
+ }
6029
+ if (ev.kind === "release" && ev.button === 0) { this.#splitDrag = null; this.redraw(); return; }
6030
+ }
5078
6031
  // Draggable sidebar divider: press on the boundary column (±1) starts a
5079
6032
  // resize; drag events (motion flag) update the width live.
5080
6033
  const divX = this.sidebarVisible ? this.sidebarWidth : -1;
@@ -5164,6 +6117,13 @@ export class App {
5164
6117
  this.redraw();
5165
6118
  return;
5166
6119
  }
6120
+ // Structural window/tab keys (NORMAL mode only — INSERT keeps editor
6121
+ // semantics: Tab still completes in the input, Ctrl+Left/Right are editor
6122
+ // motions). Tab toggles the focused window (list ↔ main); Backtab cycles
6123
+ // the main-window tabs (chat → trajectory → subagent → tasks). Ctrl+Left/
6124
+ // Right (panePrev/paneNext) are editable bindings matched below and also
6125
+ // switch the focused window.
6126
+ if (this.#handleStructuralKey(ev)) return;
5167
6127
  // Editable global bindings: two slots per function, resolved by the
5168
6128
  // keybindings registry (tui-config.json keyBindings.<id>).
5169
6129
  const hit = bindingMatchFor(ev, keyBindings(), false, KEYBINDING_ORDER);
@@ -5188,8 +6148,8 @@ export class App {
5188
6148
  // Esc in NORMAL mode interrupts a running turn (one press, regardless
5189
6149
  // of focus); otherwise it steps back toward the chat view.
5190
6150
  if (this.#interruptIfRunning()) return;
5191
- if (this.focused === this.sidebar) { this.focus(this.chat); this.redraw(); }
5192
- else if (this.mode !== "chat") this.setMode("chat");
6151
+ if (this.focused === this.sidebar) { this.setFocusedWindow("main"); this.focus(this.chat); this.redraw(); }
6152
+ else if (this.mainTab !== "chat") this.setTab("chat");
5193
6153
  return;
5194
6154
  }
5195
6155
  }
@@ -5970,6 +6930,7 @@ export class App {
5970
6930
  if (this.popup) this.popup.render(s);
5971
6931
  if (this.menu) this.menu.render(s);
5972
6932
  if (this.overlay) this.overlay.render(s);
6933
+ if (this.sidebar.preview.open) this.#renderPreviewCard(s);
5973
6934
  this.#renderToast(s);
5974
6935
  this.term.output.write(s.render() + "\x1b[?25l");
5975
6936
  return;
@@ -5982,8 +6943,14 @@ export class App {
5982
6943
  for (let y = 1; y < s.h - 1; y++) s.put(this.sidebar.w - 1, y, "│", { fg: T.BORDER });
5983
6944
  }
5984
6945
  const modePanel = this.panelForMode();
5985
- if (modePanel) modePanel.render(s);
5986
- else this.chat.render(s);
6946
+ if (modePanel) {
6947
+ // The active pane occupies exactly the chat-area rect; ensure it is
6948
+ // relaid there (it may have diverged after the last resize).
6949
+ const r = this.mainAreaRect();
6950
+ if (modePanel.relayout) modePanel.relayout(r.x, r.y, r.w, r.h);
6951
+ modePanel.render(s);
6952
+ } else this.chat.render(s);
6953
+ if (this.winTree) this.#renderSplitOverlay(s);
5987
6954
 
5988
6955
  // footer: multi-row powerline-style status
5989
6956
  const t = this.titleOf();
@@ -6002,6 +6969,7 @@ export class App {
6002
6969
  // Ctrl+Space (command panel) earns the prime spot right after the mode
6003
6970
  // badge — the shortcut every session needs to discover first.
6004
6971
  row0.left.push({ t: " Ctrl+Space 面板 ", fg: T.DIM, bg: T.STATUSBG });
6972
+ if (this.pendingPrefix) row0.left.push({ t: ` ${this.pendingPrefix.table.hint} `, fg: 0x000000, bg: T.WARN, bold: true });
6005
6973
  // Left badge: the session's permission/mode (e.g. "工作区写入/创造模式"),
6006
6974
  // which is far more meaningful than a static "工作区" label.
6007
6975
  const perm = this.projections.permissions?.currentValue;
@@ -6013,8 +6981,10 @@ export class App {
6013
6981
  if (rawGoal && !["complete", "completed", "cleared"].includes(rawGoal.phase)) {
6014
6982
  row0.left.push({ t: ` 🎯 ${truncate(rawGoal.objective ?? "目标", 14)} · Ctrl+G `, fg: 0x000000, bg: T.WARN, bold: true });
6015
6983
  }
6016
- if (this.sidebarVisible) row0.left.push({ t: " " + truncate(t || "(未选择会话)", 40) + " ", fg: T.TXT, bg: T.STATUSBG });
6017
- else row0.left.push({ t: " " + truncate(t || "(未选择会话)", 40) + " ", fg: T.TXT, bg: T.STATUSBG });
6984
+ const winTag = this.winTree ? `W${(this.splitLeaves().indexOf(this.focusedLeaf) + 1) || 1} · ` : "";
6985
+ const titleSeg = winTag + truncate((winTag ? t : t || "(未选择会话)"), 40);
6986
+ if (this.sidebarVisible) row0.left.push({ t: " " + titleSeg + " ", fg: T.TXT, bg: T.STATUSBG });
6987
+ else row0.left.push({ t: " " + titleSeg + " ", fg: T.TXT, bg: T.STATUSBG });
6018
6988
  if (cur?.running) row0.left.push({ t: " ●运行 ", fg: T.OK, bg: T.STATUSBG });
6019
6989
  // session elapsed/start: effective time (model+tool work, not wall clock)
6020
6990
  // right after the session name; start = the earliest event time loaded
@@ -6112,12 +7082,12 @@ export class App {
6112
7082
  });
6113
7083
  // Ctrl+J belongs beside the two activity summaries it opens, not alone
6114
7084
  // at the far-right edge (especially once the queue badge also appears).
6115
- row2.left.push({ t: " Ctrl+J 任务/子代理 ", fg: T.DIM, bg: T.STATUSBG });
7085
+ row2.left.push({ t: " 任务/子代理 · Ctrl+Space ", fg: T.DIM, bg: T.STATUSBG });
6116
7086
  if (sub) row2.left.push({
6117
7087
  t: ` ◇ ${truncate(sub.label ?? sub.mode ?? "子代理", 20)} `,
6118
7088
  fg: subStats.running > 0 ? T.WARN : subStats.completed > 0 ? T.OK : T.FAINT, bg: T.STATUSBG,
6119
7089
  });
6120
- if(this.queueItems.length)row2.left.push({t:` 有${this.queueItems.length}条命令正在排队 Ctrl+N查看详情 `,fg:0x000000,bg:T.WARN,bold:true});
7090
+ if(this.queueItems.length)row2.left.push({t:` 排队${this.queueItems.length}条 · Ctrl+Space→任务 查看 `,fg:0x000000,bg:T.WARN,bold:true});
6121
7091
  rows.push(row2);
6122
7092
  }
6123
7093
  this.status.rows = rows;
@@ -6144,6 +7114,32 @@ export class App {
6144
7114
  this.term.output.write(out + tail);
6145
7115
  }
6146
7116
 
7117
+ /** Attached preview card beside the sidebar: purely rendered state, never
7118
+ * a modal — the tree keeps focus and the card follows the selection. */
7119
+ #renderPreviewCard(s) {
7120
+ const p = this.sidebar.preview;
7121
+ const x = this.sidebarWidth + 1;
7122
+ const w = Math.min(34, Math.max(20, this.screen.w - x - 2));
7123
+ const y = 1, h = Math.max(5, this.screen.h - 2);
7124
+ s.fillRect(x, y, x + w - 1, y + h - 1, " ", { bg: T.PANEL });
7125
+ s.box(x, y, x + w - 1, y + h - 1, { fg: T.ACCENT, bg: T.PANEL }, ` 预览 · ${truncate(p.title ?? "", 14)} · Tab 关闭`);
7126
+ const rows = h - 4;
7127
+ if (p.loading) { s.text(x + 2, y + 2, "加载中…", { fg: T.FAINT, bg: T.PANEL }); }
7128
+ else if (p.error) { s.text(x + 2, y + 2, truncate(`读取失败: ${p.error}`, w - 6), { fg: T.ERR, bg: T.PANEL }); }
7129
+ else if (!p.lines.length) { s.text(x + 2, y + 2, "(还没有往来记录)", { fg: T.FAINT, bg: T.PANEL }); }
7130
+ else {
7131
+ for (let i = 0; i < rows; i++) {
7132
+ const [kind, text] = p.lines[i];
7133
+ if (!kind) break;
7134
+ const fg = kind === "你" ? T.OK : kind === "AI" ? T.TXT : T.PURPLE;
7135
+ s.text(x + 2, y + 2 + i, kind, { fg, bg: T.PANEL, bold: kind === "你", attrs: kind === "你" ? 1 : 0 });
7136
+ s.text(x + 5, y + 2 + i, truncate(text, w - 7), { fg: T.TXT, bg: T.PANEL });
7137
+ }
7138
+ if (p.lines.length > rows) s.text(x + w - 4, y + h - 1, `…${p.lines.length - rows} 更多`, { fg: T.FAINT, bg: T.PANEL });
7139
+ }
7140
+ s.text(x + 2, y + h - 1, "↑↓ 随选区更新 · Tab 关闭", { fg: T.FAINT, bg: T.PANEL });
7141
+ }
7142
+
6147
7143
  #renderToast(s) {
6148
7144
  if (!this.toastMsg) return;
6149
7145
  // Toasts land in the LOWER half (just above the input/footer) where the