dsh-neotui 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/views.js CHANGED
@@ -1,13 +1,15 @@
1
1
  // views.js — App composition: session list + chat timeline + approvals + status.
2
2
  import { Screen } from "./screen.js";
3
3
  import { renderMd, C } from "./md.js";
4
- import { truncate, strWidth, bars, fmtDuration, fmtClock, fmtDateTime, graphemes, graphemeWidth } from "./text.js";
4
+ import { truncate, strWidth, pad, bars, fmtDuration, fmtClock, fmtDateTime, graphemes, graphemeWidth } from "./text.js";
5
5
  import { readFileSync, appendFileSync, mkdirSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { spawnSync } from "node:child_process";
8
- import { Widget, List, ScrollView, Input, Popup, Menu, StatusBar } from "./widgets.js";
8
+ import { createRequire } from "node:module";
9
+ import { Widget, ScrollView, Input, Popup, Menu, StatusBar, wrapIndex } from "./widgets.js";
9
10
  import { UploadPicker } from "./file-picker.js";
10
- import { userPrefix, saveTuiConfig, loadTuiConfig, userName, busyEnter, foldDefaults, keyBindings, DEFAULT_KEYBINDINGS } from "./config.js";
11
+ import { userPrefix, saveTuiConfig, loadTuiConfig, userName, busyEnter, foldDefaults, keyBindings, tuiConfigFile, reloadTuiConfig } from "./config.js";
12
+ import { bindingMatchFor, matchKeyBinding, CHAT_BINDING_ORDER, SIDEBAR_BINDING_ORDER, KEYBINDING_ORDER } from "./keybindings.js";
11
13
  export { userPrefix, saveTuiConfig, loadTuiConfig, userName, busyEnter, foldDefaults } from "./config.js";
12
14
  import {
13
15
  Picker, buildCommandPalette, buildModelPicker, buildModePicker, buildPermissionPicker,
@@ -20,6 +22,67 @@ import { T, themeName, cycleTheme } from "./theme.js";
20
22
  // Live theme accessor: K.K.DIM etc. resolve against the active palette at render time.
21
23
  const K = new Proxy({}, { get(_k, key) { return T[key]; } });
22
24
 
25
+ const require = createRequire(import.meta.url);
26
+ export const TUI_VERSION = (() => {
27
+ try { return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version; }
28
+ catch { return "unknown"; }
29
+ })();
30
+ let dshVersionCache = null;
31
+ export function installedDshVersion(run = spawnSync) {
32
+ if (dshVersionCache) return dshVersionCache;
33
+ if (process.env.DSH_VERSION) return (dshVersionCache = process.env.DSH_VERSION.replace(/^v/, ""));
34
+ try {
35
+ const file = require.resolve("@deepseek-ai/dsh/package.json");
36
+ const version = JSON.parse(readFileSync(file, "utf8")).version;
37
+ if (version) return (dshVersionCache = version);
38
+ } catch {}
39
+ try {
40
+ const result = run("dsh", ["--version"], { encoding: "utf8", timeout: 2000 });
41
+ const version = String(result.stdout ?? "").trim().replace(/^v/, "");
42
+ if (result.status === 0 && version) return (dshVersionCache = version);
43
+ } catch {}
44
+ return (dshVersionCache = "unknown");
45
+ }
46
+
47
+ async function latestNpmVersion(name) {
48
+ const controller = new AbortController();
49
+ const timer = setTimeout(() => controller.abort(), 5000);
50
+ try {
51
+ const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`, {
52
+ headers: { accept: "application/json" }, signal: controller.signal,
53
+ });
54
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
55
+ const body = await response.json();
56
+ if (typeof body?.version !== "string" || !body.version) throw new Error("缺少版本号");
57
+ return body.version;
58
+ } finally { clearTimeout(timer); }
59
+ }
60
+
61
+ /** Semver comparison for update checks. Returns null for an unparseable value,
62
+ * otherwise -1/0/1 for left older/equal/newer than right. In particular, a
63
+ * locally newer build must never be advertised as "可更新" to an older npm tag. */
64
+ function compareSemver(left, right) {
65
+ const parse = (value) => {
66
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(value ?? ""));
67
+ if (!match) return null;
68
+ return { core: match.slice(1, 4).map(Number), pre: match[4]?.split(".") ?? [] };
69
+ };
70
+ const a = parse(left), b = parse(right);
71
+ if (!a || !b) return null;
72
+ for (let i = 0; i < 3; i++) if (a.core[i] !== b.core[i]) return a.core[i] > b.core[i] ? 1 : -1;
73
+ if (a.pre.length === 0 || b.pre.length === 0) return a.pre.length === b.pre.length ? 0 : a.pre.length === 0 ? 1 : -1;
74
+ const n = Math.max(a.pre.length, b.pre.length);
75
+ for (let i = 0; i < n; i++) {
76
+ if (a.pre[i] === undefined || b.pre[i] === undefined) return a.pre[i] === b.pre[i] ? 0 : a.pre[i] === undefined ? -1 : 1;
77
+ if (a.pre[i] === b.pre[i]) continue;
78
+ const ai = /^\d+$/.test(a.pre[i]), bi = /^\d+$/.test(b.pre[i]);
79
+ if (ai && bi) return Number(a.pre[i]) > Number(b.pre[i]) ? 1 : -1;
80
+ if (ai !== bi) return ai ? -1 : 1;
81
+ return a.pre[i] > b.pre[i] ? 1 : -1;
82
+ }
83
+ return 0;
84
+ }
85
+
23
86
  // ---- Tool card renderers (host-computed view models) ----
24
87
 
25
88
  function renderToolCard(view, width, expanded) {
@@ -46,9 +109,14 @@ function renderToolCard(view, width, expanded) {
46
109
  const rows = output.split("\n");
47
110
  const cap = expanded ? 200 : 8;
48
111
  for (const row of rows.slice(0, cap)) lines.push([{ t: " " + truncate(row, width - 4), fg: K.TXT, code: true }]);
49
- if (rows.length > cap) lines.push([{ t: ` …隐藏 ${rows.length - cap} 行(点击展开)`, fg: K.FAINT }]);
50
- if (card.signal) lines.push([{ t: ` signal ${card.signal}`, fg: K.ERR, bold: true }]);
51
- else if (card.exitCode != null) lines.push([{ t: ` exit ${card.exitCode}`, fg: card.exitCode === 0 ? K.OK : K.ERR, bold: card.exitCode !== 0 }]);
112
+ if (rows.length > cap) lines.push([{ t: expanded
113
+ ? ` …其余 ${rows.length - cap} 行超过详情上限`
114
+ : ` …隐藏 ${rows.length - cap} 行(点击展开)`, fg: K.FAINT }]);
115
+ // Keep terminal cards visually neutral at high tool-call frequency: a
116
+ // non-zero exit is still explicit text, but no longer introduces a red
117
+ // block/label that dominates the transcript.
118
+ if (card.signal) lines.push([{ t: ` signal ${card.signal}`, fg: K.WARN, bold: true }]);
119
+ else if (card.exitCode != null) lines.push([{ t: ` exit ${card.exitCode}`, fg: card.exitCode === 0 ? K.OK : K.WARN, bold: card.exitCode !== 0 }]);
52
120
  else if (card.running) lines.push([{ t: " ● 运行中", fg: K.WARN }]);
53
121
  break;
54
122
  }
@@ -421,7 +489,25 @@ function applyEvent(nodes, event, view, log, state = null) {
421
489
  export function nodeForEvents(events, log) {
422
490
  const nodes = [];
423
491
  const state = { step: null };
424
- for (const { event, view } of events) applyEvent(nodes, event, view, log, state);
492
+ for (const { event, view } of events) {
493
+ const before = nodes.length;
494
+ applyEvent(nodes, event, view, log, state);
495
+ // Durable search/jump anchor: the event that created each derived node.
496
+ const seq = event?.seq;
497
+ if (Number.isFinite(seq)) {
498
+ if (nodes.length > before) {
499
+ // The event appended one or more sibling nodes: never leak its seq into
500
+ // the previous sibling (that would make search jumps land one early).
501
+ for (let i = before; i < nodes.length; i++) {
502
+ if (nodes[i].firstSeq == null) nodes[i].firstSeq = seq;
503
+ nodes[i].lastSeq = seq;
504
+ }
505
+ }
506
+ // Mutation-only events keep the node's original anchor. A later tool
507
+ // result or turn/end may mutate a non-tail node, so assigning that seq to
508
+ // the array tail would create another false jump range.
509
+ }
510
+ }
425
511
  return nodes;
426
512
  }
427
513
 
@@ -705,6 +791,8 @@ class SidebarTree extends Widget {
705
791
  this.groups = groups;
706
792
  this.#flatten();
707
793
  this.sel = Math.min(this.sel, Math.max(0, this.rows.length - 1));
794
+ this.scrollY = Math.max(0, Math.min(this.scrollY, this.maxScroll()));
795
+ this.#scrollToSel();
708
796
  this.app.redraw();
709
797
  }
710
798
  #flatten() {
@@ -721,17 +809,21 @@ class SidebarTree extends Widget {
721
809
  if (this.collapsed.has(group.key)) this.collapsed.delete(group.key);
722
810
  else this.collapsed.add(group.key);
723
811
  this.#flatten();
812
+ this.sel = Math.min(this.sel, Math.max(0, this.rows.length - 1));
813
+ this.scrollY = Math.max(0, Math.min(this.scrollY, this.maxScroll()));
814
+ this.#scrollToSel();
724
815
  }
725
- collapseAll() { for (const g of this.groups) this.collapsed.add(g.key); this.#flatten(); }
726
- expandAll() { this.collapsed.clear(); this.#flatten(); }
816
+ collapseAll() { for (const g of this.groups) this.collapsed.add(g.key); this.#flatten(); this.sel = Math.min(this.sel, Math.max(0, this.rows.length - 1)); this.scrollY = Math.max(0, Math.min(this.scrollY, this.maxScroll())); this.#scrollToSel(); }
817
+ expandAll() { this.collapsed.clear(); this.#flatten(); this.sel = Math.min(this.sel, Math.max(0, this.rows.length - 1)); this.scrollY = Math.max(0, Math.min(this.scrollY, this.maxScroll())); this.#scrollToSel(); }
727
818
  #rowTitle(sess) {
728
819
  return sess.projections?.values?.title ?? (sess.blank ? "(空白会话)" : sess.sessionId.slice(0, 8));
729
820
  }
730
821
  render(screen) {
731
822
  screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", {});
732
823
  const w = this.w - 1;
733
- // header: workspace title
734
- screen.text(this.x, this.y, truncate("▣ 工作区", w - 2), { fg: T.ACCENT, attrs: 1 });
824
+ // Header itself becomes the pane-focus badge. When focused, chat/trajectory
825
+ // tabs deliberately relinquish their highlight to this label.
826
+ screen.text(this.x, this.y, truncate("▣ 工作区", w - 2), { fg: this.focused ? T.SELFG : T.ACCENT, bg: this.focused ? T.ACCENT : -1, attrs: 1 });
735
827
  screen.hline(this.x, this.x + w, this.y + 1, "─", { fg: T.BORDER });
736
828
  const listTop = this.y + 2;
737
829
  for (let i = 0; i < this.h - 2; i++) {
@@ -787,13 +879,37 @@ class SidebarTree extends Widget {
787
879
  }
788
880
  move(delta) {
789
881
  if (this.rows.length === 0) return false;
790
- const next = Math.max(0, Math.min(this.rows.length - 1, this.sel + delta));
791
- if (next === this.sel) return false;
882
+ const next = wrapIndex(this.sel + delta, this.rows.length);
792
883
  this.sel = next;
793
884
  this.#scrollToSel();
794
885
  return true;
795
886
  }
796
887
  currentRow() { return this.rows[this.sel] ?? null; }
888
+ #menuFor(row) {
889
+ if (!row) return [
890
+ { label: "新建工作区…", action: () => this.app.addWorkspace() },
891
+ { label: "新建会话", action: () => this.app.newSessionIn(null) },
892
+ ];
893
+ if (row.kind === "session") return null;
894
+ const items = [
895
+ { label: "新建会话", action: () => this.app.newSessionIn(row.group) },
896
+ { label: "新建工作区…", action: () => this.app.addWorkspace() },
897
+ { label: "折叠全部", action: () => { this.collapseAll(); this.app.redraw(); } },
898
+ { label: "展开全部", action: () => { this.expandAll(); this.app.redraw(); } },
899
+ ];
900
+ if (row.group.workspaceId) {
901
+ items.push({ label: "重命名工作区", action: () => this.app.renameWorkspace(row.group) });
902
+ items.push({ label: "删除工作区…", action: () => this.app.deleteWorkspace(row.group) });
903
+ }
904
+ return items;
905
+ }
906
+ openCurrentMenu() {
907
+ const row = this.currentRow();
908
+ const ev = { x: this.x + 2, y: this.y + 2 + Math.max(0, this.sel - this.scrollY) };
909
+ if (row?.kind === "session") this.app.sessionMenu({ data: row.session }, ev);
910
+ else this.app.openMenu(this.#menuFor(row), ev);
911
+ return true;
912
+ }
797
913
  onMouse(ev) {
798
914
  if (ev.kind === "wheel-up") { this.scroll(-3); return true; }
799
915
  if (ev.kind === "wheel-down") { this.scroll(3); return true; }
@@ -813,30 +929,10 @@ class SidebarTree extends Widget {
813
929
  if (ev.kind === "press" && ev.button === 2) {
814
930
  const idx = this.scrollY + (ev.y - this.y - 2);
815
931
  const row = this.rows[idx];
816
- if (!row) {
817
- // right-click on empty sidebar space → workspace-level actions
818
- this.app.openMenu([
819
- { label: "新建工作区…", action: () => this.app.addWorkspace() },
820
- { label: "新建会话", action: () => this.app.newSessionIn(null) },
821
- ], ev);
822
- return true;
823
- }
932
+ if (!row) { this.app.openMenu(this.#menuFor(null), ev); return true; }
824
933
  this.sel = idx;
825
- if (row.kind === "group") {
826
- const items = [
827
- { label: "新建会话", action: () => this.app.newSessionIn(row.group) },
828
- { label: "新建工作区…", action: () => this.app.addWorkspace() },
829
- { label: "折叠全部", action: () => { this.collapseAll(); this.app.redraw(); } },
830
- { label: "展开全部", action: () => { this.expandAll(); this.app.redraw(); } },
831
- ];
832
- if (row.group.workspaceId) {
833
- items.push({ label: "重命名工作区", action: () => this.app.renameWorkspace(row.group) });
834
- items.push({ label: "删除工作区…", action: () => this.app.deleteWorkspace(row.group) });
835
- }
836
- this.app.openMenu(items, ev);
837
- } else {
838
- this.app.sessionMenu({ data: row.session }, ev);
839
- }
934
+ if (row.kind === "session") this.app.sessionMenu({ data: row.session }, ev);
935
+ else this.app.openMenu(this.#menuFor(row), ev);
840
936
  return true;
841
937
  }
842
938
  return false;
@@ -849,28 +945,45 @@ class SidebarTree extends Widget {
849
945
  case "pgup": this.scroll(-this.h); return true;
850
946
  case "pgdn": this.scroll(this.h); return true;
851
947
  case "home": this.sel = 0; this.#scrollToSel(); return true;
852
- case "end": this.sel = this.rows.length - 1; this.#scrollToSel(); return true;
948
+ case "end": this.sel = Math.max(0, this.rows.length - 1); this.#scrollToSel(); return true;
853
949
  case "enter": {
854
950
  const row = this.currentRow();
855
951
  if (!row) return false;
856
952
  if (row.kind === "group") { this.toggle(row.group); this.app.redraw(); }
857
- else this.app.openSession(row.session.sessionId);
953
+ else this.app.openSession(row.session.sessionId); // pane focus intentionally stays here
858
954
  return true;
859
955
  }
860
956
  case "left": {
957
+ if (ev.ctrl) return false;
861
958
  const row = this.currentRow();
862
959
  if (row?.kind === "group" && !this.collapsed.has(row.group.key)) { this.toggle(row.group); this.app.redraw(); return true; }
863
960
  if (row?.kind === "session") { this.sel = this.rows.findLastIndex((r, i) => i <= this.sel && r.kind === "group"); this.#scrollToSel(); return true; }
864
961
  return false;
865
962
  }
866
963
  case "right": {
964
+ if (ev.ctrl) return false;
867
965
  const row = this.currentRow();
868
966
  if (row?.kind === "group" && this.collapsed.has(row.group.key)) { this.toggle(row.group); this.app.redraw(); return true; }
869
967
  if (row?.kind === "group") { this.sel = Math.min(this.rows.length - 1, this.sel + 1); this.#scrollToSel(); return true; }
870
968
  return false;
871
969
  }
872
970
  case "char":
873
- if (!ev.ctrl && ev.key === "n") { const r = this.currentRow(); if (r?.kind === "group") { this.app.newSessionIn(r.group); return true; } return false; }
971
+ if (ev.ctrl && ev.key === "r") return this.openCurrentMenu();
972
+ if (ev.ctrl) return false;
973
+ {
974
+ const sbHit = bindingMatchFor(ev, keyBindings(), false, SIDEBAR_BINDING_ORDER);
975
+ if (sbHit?.id === "insert") { this.app.focus(this.app.chat.input); this.app.redraw(); return true; }
976
+ if (sbHit?.id === "newSession") { const r = this.currentRow(); if (r?.kind === "group") this.app.newSessionIn(r.group); else this.app.newSession(); return true; }
977
+ }
978
+ if (ev.key === " ") {
979
+ const row = this.currentRow();
980
+ if (row?.kind === "group") { this.toggle(row.group); this.app.redraw(); return true; }
981
+ if (row?.kind === "session") {
982
+ const groupIndex = this.rows.findLastIndex((candidate, index) => index <= this.sel && candidate.kind === "group");
983
+ this.toggle(row.group); this.sel = Math.max(0, groupIndex); this.#scrollToSel(); this.app.redraw(); return true;
984
+ }
985
+ return false;
986
+ }
874
987
  if (!ev.ctrl && (ev.key === "[" || ev.key === "]")) {
875
988
  const r = this.currentRow();
876
989
  if (r?.kind === "session") { this.app.moveSession(r.session, ev.key === "[" ? -1 : 1); return true; }
@@ -907,13 +1020,11 @@ export class ChatView extends Widget {
907
1020
  this.nodes = [];
908
1021
  this.lines = [];
909
1022
  this.expanded = new Set(); // node indexes (user-message full text)
910
- this.expandedTools = new Set();
911
1023
  this.collapsedBlocks = new Set(); // per-block COLLAPSE (default expanded): `${realIdx}:${bi}`
912
1024
  const fd = foldDefaults();
913
1025
  this.thinkMode = fd.think ? "expanded" : "collapsed"; // t toggles
914
1026
  this.bashMode = fd.bash ? "expanded" : "collapsed"; // b toggles
915
1027
  this.todosVisible = fd.todos; // Shift+T toggles
916
- this.todoSeen = false; // once seen, the todo box keeps its height
917
1028
  this.running = false;
918
1029
  this.hasMore = false;
919
1030
  this.loadingOlder = false;
@@ -937,6 +1048,8 @@ export class ChatView extends Widget {
937
1048
  this.cache = new Map(); // node render cache: key → { lines, marks }
938
1049
  this.cardRanges = []; // absolute line ranges of card-backed message blocks
939
1050
  this.welcomeModes = []; // absolute row y → agent preset id (welcome screen)
1051
+ this.welcomeModeIds = ["standard", "code", "minimal", "cordis"];
1052
+ this.welcomeModeSel = 0;
940
1053
  this.pressY = null;
941
1054
  this.pressInfo = null; // hit identity locked at press time
942
1055
  this.pressCtx = null;
@@ -945,7 +1058,14 @@ export class ChatView extends Widget {
945
1058
  this.selEnd = null;
946
1059
  this.selAnchor = null;
947
1060
  this.selFocus = null;
948
- this.selectionMode = "character";
1061
+ // Mouse selection is permanently free/character-based. Keyboard selection
1062
+ // has its own Vim-like read-only cursor modes.
1063
+ this.blockItems = [];
1064
+ this.blockSel = -1;
1065
+ this.cursorMode = "block"; // block | normal | visual | visual-line
1066
+ this.cursor = { line: 0, col: 0 };
1067
+ this.visualAnchor = null;
1068
+ this.bindingPending = null; // in-progress two-press chord ({id, slot, part})
949
1069
  this.clipboardImages = [];
950
1070
  this.attachments = [];
951
1071
  this.stepState = { step: null }; // step/start tracking for the mux merge path
@@ -1085,6 +1205,8 @@ export class ChatView extends Widget {
1085
1205
  for (let li = 0; li < this.lineMap.length; li++) {
1086
1206
  if (this.lineMap[li]?.nodeIdx === idx) {
1087
1207
  this.view.anchorLock = null; this.view.follow = false; this.view.scrollY = Math.max(0, li - 2);
1208
+ const block = this.blockItems.findIndex((item) => item.nodeIdx === idx);
1209
+ if (block >= 0) { this.blockSel = block; this.cursor = { line: this.blockItems[block].headerLine, col: 0 }; }
1088
1210
  this.app.redraw();
1089
1211
  return true;
1090
1212
  }
@@ -1100,20 +1222,15 @@ export class ChatView extends Widget {
1100
1222
  }
1101
1223
  }
1102
1224
 
1103
- /** Height of the collapsible todo block. FROZEN at the max (1 header + 6
1104
- * items) once the session has ever shown todos: the harness updates the
1105
- * list in the background while the user reads, and a height that tracks
1106
- * the count reflows the whole layout every time (the idle 2-line shifts). */
1225
+ /** Height of the collapsible todo block: one framed row per visible task,
1226
+ * capped at six items. Short lists must not reserve blank body rows. */
1107
1227
  todoHeight() {
1108
1228
  const todos = this.app.todos;
1109
1229
  const subagent = this.app.projections.subagent;
1110
- let h = 0;
1111
- if (subagent) h++;
1112
- if (!todos || todos.length === 0) { this.todoSeen = false; return h; }
1113
- this.todoSeen = true;
1114
- // The task dock owns a framed header and footer. Folded keeps the framed
1115
- // two-row strip visible rather than blending one text row into transcript.
1116
- return h + (this.todosVisible ? 8 : 2);
1230
+ const subagentRows = subagent ? 1 : 0;
1231
+ if (!todos || todos.length === 0) return subagentRows;
1232
+ // Header + actual body rows + footer. Folded retains only the frame.
1233
+ return subagentRows + (this.todosVisible ? Math.min(todos.length, 6) + 2 : 2);
1117
1234
  }
1118
1235
 
1119
1236
  divingNode() { return [...this.nodes].reverse().find((node) => node.kind === "turn-progress") ?? null; }
@@ -1143,18 +1260,21 @@ export class ChatView extends Widget {
1143
1260
  this.#rebuild();
1144
1261
  }
1145
1262
 
1146
- async open(sessionId, epoch = this.app.sessionEpoch) {
1263
+ async open(sessionId, epoch = this.app.sessionEpoch, maxMessages = 80) {
1147
1264
  this.sessionId = sessionId;
1148
1265
  this.nodes = [];
1266
+ this.welcomeModeSel = 0;
1267
+ this.blockSel = -1;
1268
+ this.cursorMode = "block";
1269
+ this.visualAnchor = null;
1149
1270
  this.expanded.clear();
1150
- this.expandedTools.clear();
1151
1271
  this.collapsedBlocks.clear();
1152
1272
  this.hasMore = false;
1153
1273
  this.minSeq = null;
1154
1274
  this.cache.clear();
1155
1275
  this.app.setStatus(`加载会话 ${sessionId.slice(0, 8)}…`);
1156
1276
  try {
1157
- const hist = await this.app.api.call("session.history", { sessionId });
1277
+ const hist = await this.app.api.call("session.history", { sessionId, maxMessages });
1158
1278
  if (this.sessionId !== sessionId || this.app.sessionEpoch !== epoch) return;
1159
1279
  this.minSeq = hist.events[0]?.event?.seq ?? null;
1160
1280
  this.lastSeq = hist.events[hist.events.length - 1]?.event?.seq ?? null;
@@ -1178,7 +1298,7 @@ export class ChatView extends Widget {
1178
1298
  this.view.follow = true;
1179
1299
  }
1180
1300
 
1181
- async loadOlder(onDone = null) {
1301
+ async loadOlder(onDone = null, maxMessages = 20) {
1182
1302
  if (!this.hasMore || this.loadingOlder || this.minSeq == null) { if (!this.hasMore) this.app.toast("已加载到会话开头"); if (onDone) queueMicrotask(onDone); return; }
1183
1303
  const sessionId = this.sessionId;
1184
1304
  const epoch = this.app.sessionEpoch;
@@ -1187,7 +1307,7 @@ export class ChatView extends Widget {
1187
1307
  const oldLength = this.lines.length;
1188
1308
  this.app.setStatus("加载更早记录…");
1189
1309
  try {
1190
- const hist = await this.app.api.call("session.history", { sessionId, beforeSeq: this.minSeq, maxMessages: 20 });
1310
+ const hist = await this.app.api.call("session.history", { sessionId, beforeSeq: this.minSeq, maxMessages });
1191
1311
  if (this.sessionId !== sessionId || this.app.sessionEpoch !== epoch) { this.loadingOlder = false; return; }
1192
1312
  if (hist.events.length === 0) { this.hasMore = false; }
1193
1313
  else {
@@ -1198,6 +1318,24 @@ export class ChatView extends Widget {
1198
1318
  this.hasMore = hist.hasMore && this.minSeq < previousMinSeq;
1199
1319
  this.#noteEarliest(hist.events);
1200
1320
  const more = nodeForEvents(hist.events, this.app.log);
1321
+ const shift = more.length;
1322
+ if (shift > 0) {
1323
+ const shiftedExpanded = new Set();
1324
+ for (const key of this.expanded) {
1325
+ if (typeof key === "number") shiftedExpanded.add(key + shift);
1326
+ else if (typeof key === "string" && /^(\d+):(\d+)$/.test(key)) {
1327
+ const [, ni, bi] = /^(\d+):(\d+)$/.exec(key); shiftedExpanded.add(`${Number(ni) + shift}:${bi}`);
1328
+ } else shiftedExpanded.add(key); // dispatch ids are stable callIds
1329
+ }
1330
+ const shiftedCollapsed = new Set();
1331
+ for (const key of this.collapsedBlocks) {
1332
+ const match = /^(\d+):(\d+)$/.exec(String(key));
1333
+ shiftedCollapsed.add(match ? `${Number(match[1]) + shift}:${match[2]}` : key);
1334
+ }
1335
+ this.expanded = shiftedExpanded;
1336
+ this.collapsedBlocks = shiftedCollapsed;
1337
+ this.cache.clear();
1338
+ }
1201
1339
  this.nodes = [...more, ...this.nodes];
1202
1340
  }
1203
1341
  } catch (e) {
@@ -1304,7 +1442,7 @@ export class ChatView extends Widget {
1304
1442
  const trimmed = text.trim();
1305
1443
  if (trimmed === "/reload") { this.app.softReload(); return; }
1306
1444
  if (trimmed === "/restart") { this.app.restartApp(); return; }
1307
- if (trimmed === "/model") { this.app.showModePicker(); return; }
1445
+ if (trimmed === "/model") { this.app.overlay = buildModelPicker(this.app); this.app.redraw(); return; }
1308
1446
  if (trimmed === "/theme") { cycleTheme(); this.queueRebuild(); this.app.toast(`主题已切换: ${themeName()}`); return; }
1309
1447
  if (trimmed === "/permission") { this.app.showPermissionPicker(); return; }
1310
1448
  if (trimmed === "/goal") { this.app.showGoal(); return; }
@@ -1545,7 +1683,11 @@ export class ChatView extends Widget {
1545
1683
  return true;
1546
1684
  }
1547
1685
  }
1548
- if (node.kind === "assistant" || node.kind === "user") {
1686
+ // Node-level folds: user messages plus the notice/context cards that
1687
+ // explicitly render [点击展开]/[点击折叠]. They all use the same stable
1688
+ // node index key, so clicking any marked row (header, preview, or hint)
1689
+ // toggles the exact card the user saw.
1690
+ if (["assistant", "user", "context", "goal-round", "subagent-receipt"].includes(node.kind)) {
1549
1691
  if (this.expanded.has(info.nodeIdx)) this.expanded.delete(info.nodeIdx);
1550
1692
  else this.expanded.add(info.nodeIdx);
1551
1693
  this.#rebuild();
@@ -1556,6 +1698,8 @@ export class ChatView extends Widget {
1556
1698
  }
1557
1699
 
1558
1700
  #rebuild() {
1701
+ const oldBlock = this.blockItems?.[this.blockSel] ?? null;
1702
+ const oldIdentity = oldBlock ? `${oldBlock.nodeKey}:${oldBlock.blockIdx ?? "n"}:${oldBlock.kind}:${oldBlock.codeIndex ?? "-"}` : null;
1559
1703
  const w = Math.max(20, this.view.w - 2);
1560
1704
  const lines = [];
1561
1705
  const lineMap = [];
@@ -1645,7 +1789,7 @@ export class ChatView extends Widget {
1645
1789
  const text = node.text ?? "";
1646
1790
  const summary = node.source?.summary;
1647
1791
  const label = node.kind === "goal-round" ? `🎯 目标续轮 ${node.source?.round ?? ""}`
1648
- : node.kind === "subagent-receipt" ? (node.source?.kind === "subagent-settled" ? "🛰 子代理状态" : "🛰 子代理回执")
1792
+ : node.kind === "subagent-receipt" ? (node.source?.kind === "subagent-settled" ? "◇ 子代理状态" : "◇ 子代理回执")
1649
1793
  : `ℹ 上下文 · ${node.source?.kind ?? "注入"}`;
1650
1794
  beginCard(node.kind === "goal-round" ? "THINKBG" : "CARD");
1651
1795
  lines.push([{ t: ` ${label}${summary ? ` — ${truncate(summary, w - strWidth(label) - 8)}` : ""}`, fg: node.kind === "subagent-receipt" ? T.PURPLE : K.DIM, bold: true }]);
@@ -1755,8 +1899,12 @@ export class ChatView extends Widget {
1755
1899
  // failed exit code.
1756
1900
  const stopped = !running && (b.stopped || signal === "SIGTERM" || signal === "SIGINT");
1757
1901
  const failed = !orphan && !stopped && (b.isError || signal || (exitCode !== undefined && exitCode !== 0));
1758
- const status = running ? "TOOLBG" : failed ? "TOOLERR" : stopped ? "TOOLBG" : "TOOLOK";
1759
- const glyph = running ? "⏳" : failed ? "✗" : stopped ? "⏸" : orphan ? "◌" : "✓";
1902
+ // High-frequency tool calls need a quiet hierarchy: the same
1903
+ // neutral gray CARD used by ordinary output marks the clickable
1904
+ // range; green is reserved for the formal assistant output below.
1905
+ // Failed tools remain explicit text but never paint a red block.
1906
+ const status = "CARD";
1907
+ const glyph = running ? "⏳" : failed ? "!" : stopped ? "⏸" : orphan ? "◌" : "✓";
1760
1908
  const card = cardView ? renderToolCard(cardView, w, open) : [];
1761
1909
  beginCard(status);
1762
1910
  let timing = "";
@@ -1773,7 +1921,7 @@ export class ChatView extends Widget {
1773
1921
  lines.push([
1774
1922
  { t: open ? "▾ " : "▸ ", fg: K.ACCENT },
1775
1923
  { t: ` ${b.name ?? "tool"}`, fg: K.TXT, bold: true },
1776
- { t: ` ${glyph}`, fg: failed ? K.ERR : status === "TOOLOK" ? K.OK : K.WARN },
1924
+ { t: ` ${glyph}`, fg: failed ? K.WARN : running ? K.DIM : K.OK },
1777
1925
  { t: stepTag + timing, fg: K.DIM },
1778
1926
  { t: open ? " [b 折叠]" : " [b 展开]", fg: K.FAINT },
1779
1927
  ]);
@@ -1882,7 +2030,10 @@ export class ChatView extends Widget {
1882
2030
  mark(realIdx, bi);
1883
2031
  sep();
1884
2032
  } else {
1885
- beginCard("CARD");
2033
+ // Swap the previous visual priority: completed assistant output
2034
+ // gets the restrained green background, while high-frequency
2035
+ // tool cards use neutral gray only to reveal their click range.
2036
+ beginCard("TOOLOK");
1886
2037
  // FORMAL text output is NOT collapsible — the user's message
1887
2038
  // content must stay readable; only think/tool blocks fold.
1888
2039
  // A neutral assistant glyph identifies model output without
@@ -1967,12 +2118,200 @@ export class ChatView extends Widget {
1967
2118
  }
1968
2119
  this.lines = lines;
1969
2120
  this.lineMap = lineMap;
2121
+ this.#rebuildBlockItems(oldIdentity);
1970
2122
  if (process.env.DSH_TUI_DEBUG_CLICK && lineMap.length !== lines.length) {
1971
2123
  this.#clickLog(`INVARIANT BROKEN: lines=${lines.length} lineMap=${lineMap.length}`);
1972
2124
  }
1973
2125
  this.view.setLines(lines);
1974
2126
  }
1975
2127
 
2128
+ #rebuildBlockItems(oldIdentity = null) {
2129
+ const items = [];
2130
+ const nonBlank = (line) => (this.lines[line] ?? []).some((seg) => (seg.t ?? "").trim() !== "");
2131
+ const keyOf = (mark) => {
2132
+ if (!mark || mark.nodeIdx == null || mark.nodeIdx < 0) return null;
2133
+ if (mark.imgIdx !== undefined) return `${mark.nodeIdx}:img:${mark.imgIdx}`;
2134
+ if (mark.dispatchId != null) return `${mark.nodeIdx}:${mark.blockIdx}:dispatch:${mark.dispatchId}`;
2135
+ return `${mark.nodeIdx}:${mark.blockIdx ?? "node"}`;
2136
+ };
2137
+ for (let start = 0; start < this.lineMap.length;) {
2138
+ const key = keyOf(this.lineMap[start]);
2139
+ if (key === null) { start++; continue; }
2140
+ let end = start + 1;
2141
+ while (end < this.lineMap.length && keyOf(this.lineMap[end]) === key) end++;
2142
+ const mark = this.lineMap[start];
2143
+ const node = this.nodes[mark.nodeIdx];
2144
+ let first = -1, last = -1;
2145
+ for (let line = start; line < end; line++) if (nonBlank(line)) { if (first < 0) first = line; last = line; }
2146
+ if (first >= 0 && node) {
2147
+ const block = mark.blockIdx != null ? node.blocks?.[mark.blockIdx] : null;
2148
+ const baseKind = mark.imgIdx !== undefined ? "image" : mark.dispatchId != null ? "tool" : block?.kind ?? node.kind;
2149
+ const nodeKey = node.id ?? `seq:${node.firstSeq ?? "?"}:${node.kind}`;
2150
+ const base = { first, last, headerLine: first, nodeIdx: mark.nodeIdx, nodeKey, blockIdx: mark.blockIdx ?? null, kind: baseKind, foldable: false, code: null };
2151
+ if (block?.kind === "reasoning" || block?.kind === "tool" || mark.dispatchId != null) base.foldable = true;
2152
+ else if (!block && ["user", "context", "goal-round", "subagent-receipt"].includes(node.kind)) base.foldable = true;
2153
+ if (block?.kind === "text") {
2154
+ const ranges = [];
2155
+ let codeIndex = 0;
2156
+ for (let line = first; line <= last; line++) {
2157
+ const codeSeg = (this.lines[line] ?? []).find((seg) => seg.codeBlock || seg.copyCode);
2158
+ const meta = codeSeg?.codeBlock;
2159
+ if (!meta) continue;
2160
+ const prev = ranges.at(-1);
2161
+ if (prev?.meta === meta && line === prev.last + 1) prev.last = line;
2162
+ else ranges.push({ first: line, last: line, meta, codeIndex: codeIndex++ });
2163
+ }
2164
+ let cursor = first;
2165
+ for (const range of ranges) {
2166
+ let proseEnd = range.first - 1;
2167
+ while (proseEnd >= cursor && !nonBlank(proseEnd)) proseEnd--;
2168
+ if (proseEnd >= cursor) items.push({ ...base, first: cursor, last: proseEnd, headerLine: cursor, kind: "text" });
2169
+ items.push({ ...base, first: range.first, last: range.last, headerLine: range.first, kind: "code", codeIndex: range.codeIndex, code: { text: range.meta.text ?? "", lang: range.meta.lang ?? "text" } });
2170
+ cursor = range.last + 1;
2171
+ }
2172
+ while (cursor <= last && !nonBlank(cursor)) cursor++;
2173
+ if (cursor <= last) items.push({ ...base, first: cursor, last, headerLine: cursor, kind: "text" });
2174
+ if (ranges.length === 0) items.push(base);
2175
+ } else items.push(base);
2176
+ }
2177
+ start = end;
2178
+ }
2179
+ this.blockItems = items;
2180
+ let next = oldIdentity == null ? -1 : items.findIndex((item) => `${item.nodeKey}:${item.blockIdx ?? "n"}:${item.kind}:${item.codeIndex ?? "-"}` === oldIdentity);
2181
+ if (next < 0) {
2182
+ // New/opened sessions land on the latest textual conversation block,
2183
+ // not on an incidental retry/status/image row after it.
2184
+ for (let i = items.length - 1; i >= 0; i--) {
2185
+ if (["text", "code", "user"].includes(items[i].kind)) { next = i; break; }
2186
+ }
2187
+ if (next < 0) next = items.length - 1;
2188
+ }
2189
+ this.blockSel = next;
2190
+ const selected = items[next];
2191
+ if (selected && this.cursorMode === "block") this.cursor = { line: selected.headerLine, col: Math.max(0, strWidth(this.#lineText(selected.headerLine)) - 1) };
2192
+ }
2193
+
2194
+ #lineText(line) { return (this.lines[line] ?? []).map((seg) => seg.t ?? "").join(""); }
2195
+
2196
+ #scrollToTranscriptLine(line) {
2197
+ this.view.follow = false;
2198
+ this.view.anchorLock = null;
2199
+ if (line < this.view.scrollY) this.view.scrollY = line;
2200
+ else if (line >= this.view.scrollY + this.view.h) this.view.scrollY = Math.max(0, line - this.view.h + 1);
2201
+ }
2202
+
2203
+ #moveBlock(delta) {
2204
+ if (this.blockItems.length === 0) return false;
2205
+ // Bounded, Vim-style: the block cursor stops at the ends instead of
2206
+ // wrapping. Wrapping from the newest block to the oldest felt like a jump,
2207
+ // not navigation.
2208
+ const base = this.blockSel < 0 ? (delta > 0 ? -1 : 0) : this.blockSel;
2209
+ const next = base + delta;
2210
+ if (next < 0 || next >= this.blockItems.length) return true;
2211
+ this.blockSel = next;
2212
+ const item = this.blockItems[this.blockSel];
2213
+ this.cursor = { line: item.headerLine, col: 0 };
2214
+ if (this.cursorMode !== "block") this.#syncKeyboardSelection();
2215
+ this.#scrollToTranscriptLine(item.headerLine);
2216
+ this.app.redraw();
2217
+ return true;
2218
+ }
2219
+
2220
+ #syncKeyboardSelection() {
2221
+ if (this.cursorMode !== "visual" && this.cursorMode !== "visual-line") {
2222
+ this.selStart = this.selEnd = null;
2223
+ this.selAnchor = this.selFocus = null;
2224
+ return;
2225
+ }
2226
+ const anchor = this.visualAnchor ?? this.cursor;
2227
+ let a = { ...anchor }, b = { ...this.cursor };
2228
+ if (this.cursorMode === "visual-line") {
2229
+ a.col = 0;
2230
+ b.col = Math.max(0, strWidth(this.#lineText(b.line)) - 1);
2231
+ }
2232
+ this.selAnchor = a; this.selFocus = b;
2233
+ this.selStart = Math.min(a.line, b.line); this.selEnd = Math.max(a.line, b.line);
2234
+ }
2235
+
2236
+ #cursorStops(line = this.cursor.line) {
2237
+ const chars = graphemes(this.#lineText(line));
2238
+ const stops = [];
2239
+ let col = 0;
2240
+ for (const char of chars) { stops.push({ char, col, width: Math.max(1, graphemeWidth(char)) }); col += graphemeWidth(char); }
2241
+ if (stops.length === 0) stops.push({ char: "", col: 0, width: 1 });
2242
+ return stops;
2243
+ }
2244
+
2245
+ #cursorStopIndex(stops = this.#cursorStops()) {
2246
+ let index = 0;
2247
+ for (let i = 0; i < stops.length; i++) { if (stops[i].col <= this.cursor.col) index = i; else break; }
2248
+ return index;
2249
+ }
2250
+
2251
+ #moveCursorHorizontal(delta) {
2252
+ const stops = this.#cursorStops();
2253
+ const index = Math.max(0, Math.min(stops.length - 1, this.#cursorStopIndex(stops) + delta));
2254
+ this.cursor.col = stops[index].col;
2255
+ this.#syncKeyboardSelection(); this.app.redraw(); return true;
2256
+ }
2257
+
2258
+ #wordMotion(kind) {
2259
+ const stops = this.#cursorStops();
2260
+ let pos = this.#cursorStopIndex(stops);
2261
+ const word = (entry) => /[\p{L}\p{N}_]/u.test(entry?.char ?? "");
2262
+ if (kind === "w") { while (pos < stops.length && word(stops[pos])) pos++; while (pos < stops.length && !word(stops[pos])) pos++; }
2263
+ else if (kind === "b") { pos = Math.max(0, pos - 1); while (pos > 0 && !word(stops[pos])) pos--; while (pos > 0 && word(stops[pos - 1])) pos--; }
2264
+ else { while (pos + 1 < stops.length && !word(stops[pos])) pos++; while (pos + 1 < stops.length && word(stops[pos + 1])) pos++; }
2265
+ this.cursor.col = stops[Math.max(0, Math.min(stops.length - 1, pos))].col;
2266
+ this.#syncKeyboardSelection(); this.app.redraw(); return true;
2267
+ }
2268
+
2269
+ #selectedTranscriptText() {
2270
+ if (this.cursorMode === "block" || this.cursorMode === "normal") {
2271
+ const item = this.blockItems[this.blockSel];
2272
+ if (!item) return "";
2273
+ if (item.kind === "code") return item.code?.text ?? "";
2274
+ const node = this.nodes[item.nodeIdx];
2275
+ const block = item.blockIdx != null ? node?.blocks?.[item.blockIdx] : null;
2276
+ if (block) return [block.text ?? block.args ?? "", block.kind === "tool" && block.result != null ? block.result : ""].filter(Boolean).join("\n");
2277
+ return node?.text ?? "";
2278
+ }
2279
+ const a = this.selAnchor, b = this.selFocus;
2280
+ if (!a || !b) return "";
2281
+ const first = a.line < b.line || (a.line === b.line && a.col <= b.col) ? a : b;
2282
+ const last = first === a ? b : a;
2283
+ const cut = (text, from, to) => { let out = "", col = 0; for (const g of graphemes(text)) { const next = col + graphemeWidth(g); if (next > from && col <= to) out += g; col = next; } return out; };
2284
+ return this.lines.slice(first.line, last.line + 1).map((line, index, all) => cut(line.map((seg) => seg.t ?? "").join(""), index === 0 ? first.col : 0, index === all.length - 1 ? last.col : Infinity)).join("\n");
2285
+ }
2286
+
2287
+ #yankTranscript() {
2288
+ const text = this.#selectedTranscriptText();
2289
+ if (!text) { this.app.toast("未选中可复制内容"); return true; }
2290
+ this.app.copyText(text);
2291
+ this.app.toast((this.cursorMode === "block" || this.cursorMode === "normal") ? (this.blockItems[this.blockSel]?.kind === "code" ? "已复制代码块" : "已复制正文块") : "已复制选区");
2292
+ if (this.cursorMode === "visual" || this.cursorMode === "visual-line") { this.cursorMode = "normal"; this.visualAnchor = null; this.selStart = this.selEnd = null; this.selAnchor = this.selFocus = null; }
2293
+ return true;
2294
+ }
2295
+
2296
+ #toggleSelectedBlock() {
2297
+ const item = this.blockItems[this.blockSel];
2298
+ if (!item?.foldable) return false;
2299
+ return this.#toggleAt({ nodeIdx: item.nodeIdx, blockIdx: item.blockIdx });
2300
+ }
2301
+
2302
+ #openSelectedContextMenu() {
2303
+ const item = this.blockItems[this.blockSel];
2304
+ if (!item) return false;
2305
+ const info = { nodeIdx: item.nodeIdx, blockIdx: item.blockIdx };
2306
+ const node = this.nodes[item.nodeIdx];
2307
+ const entries = [{ label: "复制消息", action: () => this.app.copyText(this.#selectedTranscriptText()) }];
2308
+ if (item.foldable) entries.push({ label: "展开 / 折叠", action: () => this.#toggleAt(info) });
2309
+ if (node?.id) entries.push({ label: "转跳轨迹", action: () => this.app.jumpToTrajectoryNode(item.nodeIdx) });
2310
+ entries.push({ label: "加载更早记录", action: () => this.loadOlder() });
2311
+ this.app.openMenu(entries, { x: this.view.x + 2, y: this.view.y + Math.max(0, item.headerLine - this.view.scrollY) });
2312
+ return true;
2313
+ }
2314
+
1976
2315
  /** Blank session: whale logo + mode selection prompt (no conversation yet). */
1977
2316
  #renderWelcome(screen) {
1978
2317
  const x = this.view.x;
@@ -1980,8 +2319,20 @@ export class ChatView extends Widget {
1980
2319
  let y = this.view.y + 1;
1981
2320
  const put = (t, fg, bold) => { if (y < this.view.y + this.view.h) { screen.text(cx, y, t, { fg, attrs: bold ? 1 : 0 }); } y++; };
1982
2321
  put("", 0, false);
1983
- put(" DeepSeek Harness", T.HEADING, true);
1984
- put(" dsh-neotui", T.FAINT, false);
2322
+ this.welcomeVersionRows = [];
2323
+ const versionLine = (name, version, key, fg, bold) => {
2324
+ const check = this.app.versionChecks?.[key];
2325
+ const status = check?.state === "checking" ? "← 检查更新…"
2326
+ : check?.state === "current" ? "← 已是最新"
2327
+ : check?.state === "update" ? `← 可更新 ${check.latest}`
2328
+ : check?.state === "error" ? "← 检查失败(点击重试)"
2329
+ : "← 检查更新";
2330
+ const text = ` ${name} ${version === "unknown" ? "版本未知" : `v${version}`} ${status}`;
2331
+ put(text, fg, bold);
2332
+ this.welcomeVersionRows[y - 1] = { key, x1: cx, x2: cx + strWidth(text) - 1 };
2333
+ };
2334
+ versionLine("DeepSeek Harness", this.app.dshVersion ?? "unknown", "dsh", T.HEADING, true);
2335
+ versionLine("dsh-neotui", TUI_VERSION, "tui", T.FAINT, false);
1985
2336
  put("", 0, false);
1986
2337
  if (this.app.currentSession == null) {
1987
2338
  put(" 打开一个会话开始,或 Ctrl+N 新建", T.DIM, false);
@@ -1990,16 +2341,23 @@ export class ChatView extends Widget {
1990
2341
  put(" 请选择模式(F9 或点击下方,选择后立即生效):", T.WARN, true);
1991
2342
  put("", 0, false);
1992
2343
  this.welcomeModes = [];
2344
+ const currentPreset = this.app.sessions.find((s) => s.sessionId === this.app.currentSession)?.agentPreset;
1993
2345
  const presets = [
1994
2346
  ["standard", "标准模式", "完整编码 Agent(文件/Shell/检索/Skills/目标/子代理)"],
1995
2347
  ["code", "PTC 模式", "标准模式能力 + Code Mode SDK 单程序多步操作"],
1996
2348
  ["minimal", "极简模式", "仅持久 bash 与 str_replace_editor 双工具"],
1997
2349
  ["cordis", "创造模式", "标准模式 + 运行时检查/插件实验/预设创作"],
1998
2350
  ];
1999
- for (const [id, name, desc] of presets) {
2351
+ const currentIdx = presets.findIndex(([id]) => id === currentPreset);
2352
+ if (this.welcomeModeSel == null || this.welcomeModeSel >= presets.length) this.welcomeModeSel = currentIdx >= 0 ? currentIdx : 0;
2353
+ for (let i = 0; i < presets.length; i++) {
2354
+ const [id, name, desc] = presets[i];
2000
2355
  if (y < this.view.y + this.view.h) {
2001
- screen.text(cx, y, ` ○ ${name}`, { fg: T.ACCENT, attrs: 1 });
2002
- screen.text(cx + 2 + strWidth(`○ ${name}`) + 1, y, truncate(desc, this.view.w - 20), { fg: T.DIM });
2356
+ const active = id === currentPreset;
2357
+ const cursor = this.app.focused === this && i === this.welcomeModeSel;
2358
+ const label = `${cursor ? "=>" : " "} ${active ? "●" : "○"} ${name}${active ? " [当前]" : ""}`;
2359
+ screen.text(cx, y, ` ${label}`, { fg: active ? T.OK : cursor ? T.ACCENT : T.DIM, bg: cursor ? T.MENUSEL : -1, attrs: active || cursor ? 1 : 0 });
2360
+ screen.text(cx + 2 + strWidth(label) + 1, y, truncate(desc, Math.max(1, this.view.w - strWidth(label) - 8)), { fg: cursor ? T.TXT : T.DIM, bg: cursor ? T.MENUSEL : -1 });
2003
2361
  this.welcomeModes[y] = id;
2004
2362
  }
2005
2363
  y++;
@@ -2027,7 +2385,38 @@ export class ChatView extends Widget {
2027
2385
  }
2028
2386
  }
2029
2387
  }
2388
+ // A focused atomic code box advertises its keyboard action. The source
2389
+ // line is restored immediately so cache/render data stays immutable.
2390
+ const selectedBlock = this.app.focused === this ? this.blockItems[this.blockSel] : null;
2391
+ let savedCodeLine = null;
2392
+ let codeCaretCol = null;
2393
+ if (selectedBlock?.kind === "code" && this.cursorMode === "normal") {
2394
+ savedCodeLine = this.lines[selectedBlock.headerLine];
2395
+ // Width-neutral swap: [按y复制] replaces [复制] padded to the exact
2396
+ // reserved field, so the right corner/border never shifts.
2397
+ this.lines[selectedBlock.headerLine] = savedCodeLine.map((seg) => seg.copyCode ? { ...seg, t: pad("[按y复制]", strWidth(seg.t ?? "")), fg: T.SELFG, bg: T.ACCENT } : seg);
2398
+ let col = 0;
2399
+ for (const seg of this.lines[selectedBlock.headerLine]) { col += strWidth(seg.t ?? ""); if (seg.copyCode) { codeCaretCol = col; break; } }
2400
+ }
2030
2401
  this.view.render(screen);
2402
+ if (savedCodeLine) this.lines[selectedBlock.headerLine] = savedCodeLine;
2403
+ // Block mode has a two-cell gutter marker; cursor modes draw a one-cell
2404
+ // caret to the right of the current grapheme/code atom.
2405
+ if (this.app.focused === this && selectedBlock) {
2406
+ if (this.cursorMode === "block") {
2407
+ const row = selectedBlock.headerLine - this.view.scrollY;
2408
+ if (row >= 0 && row < this.view.h) screen.text(this.view.x, this.view.y + row, "=>", { fg: T.SELFG, bg: T.ACCENT, attrs: 1 });
2409
+ } else {
2410
+ const caretLine = selectedBlock.kind === "code" && this.cursorMode === "normal" ? selectedBlock.headerLine : this.cursor.line;
2411
+ const row = caretLine - this.view.scrollY;
2412
+ if (row >= 0 && row < this.view.h) {
2413
+ const stops = this.#cursorStops(caretLine);
2414
+ const stop = stops[this.#cursorStopIndex(stops)];
2415
+ const codeCol = selectedBlock.kind === "code" ? Math.min(this.view.w - 2, codeCaretCol ?? 0) : this.cursor.col + (stop?.width ?? 1);
2416
+ screen.put(this.view.x + Math.max(0, Math.min(this.view.w - 2, codeCol)), this.view.y + row, "|", { fg: T.SELFG, bg: T.ACCENT, attrs: 1 });
2417
+ }
2418
+ }
2419
+ }
2031
2420
  this.#renderDiving(screen);
2032
2421
  if (this.selStart !== null && this.selEnd !== null) {
2033
2422
  const y0 = Math.max(this.view.scrollY, this.selStart);
@@ -2035,7 +2424,7 @@ export class ChatView extends Widget {
2035
2424
  if (y1 >= y0) {
2036
2425
  for (let line = y0; line <= y1; line++) {
2037
2426
  let x0 = this.view.x, x1 = this.view.x + this.view.w - 2;
2038
- if (this.selectionMode === "character" && this.selAnchor && this.selFocus) {
2427
+ if (this.selAnchor && this.selFocus) {
2039
2428
  const a = this.selAnchor, b = this.selFocus;
2040
2429
  const first = a.line < b.line || (a.line === b.line && a.col <= b.col) ? a : b;
2041
2430
  const last = first === a ? b : a;
@@ -2099,7 +2488,7 @@ export class ChatView extends Widget {
2099
2488
  if (subagent) {
2100
2489
  const timing = this.app.projections.subagentTiming;
2101
2490
  const ms = (timing?.settledMs ?? 0) + (timing?.active ? Math.max(0, Date.now() - timing.active.since) : 0);
2102
- screen.text(this.x, row++, ` 🛰 子代理 · ${subagent.label ?? subagent.mode}${ms ? ` · ${fmtDuration(ms)}` : ""}`, { fg: T.PURPLE, bg: T.STATUSBG, bold: true });
2491
+ screen.text(this.x, row++, ` ◇ 子代理 · ${subagent.label ?? subagent.mode}${ms ? ` · ${fmtDuration(ms)}` : ""}`, { fg: T.PURPLE, bg: T.STATUSBG, bold: true });
2103
2492
  }
2104
2493
  if (!todos.length) return;
2105
2494
  const done = todos.filter((t) => t.status === "completed").length;
@@ -2148,6 +2537,8 @@ export class ChatView extends Widget {
2148
2537
  if (this.app.focused !== this.app.chat?.input) this.app.focus(this);
2149
2538
  // Welcome-screen mode click: select the preset under the cursor.
2150
2539
  if (this.nodes.length === 0 && ev.kind === "press" && ev.button === 0) {
2540
+ const versionHit = this.welcomeVersionRows?.[ev.y];
2541
+ if (versionHit && ev.x >= versionHit.x1 && ev.x <= versionHit.x2) { this.app.checkUpdates(versionHit.key, true); return true; }
2151
2542
  const id = this.welcomeModes[ev.y];
2152
2543
  if (id) { this.app.selectPreset(id); return true; }
2153
2544
  }
@@ -2187,7 +2578,7 @@ export class ChatView extends Widget {
2187
2578
  this.pressInfo = null; this.pressCtx = null;
2188
2579
  const rows = this.selEnd - this.selStart + 1;
2189
2580
  let text;
2190
- if (this.selectionMode === "character" && this.selAnchor && this.selFocus) {
2581
+ if (this.selAnchor && this.selFocus) {
2191
2582
  const a = this.selAnchor, b = this.selFocus;
2192
2583
  const first = a.line < b.line || (a.line === b.line && a.col <= b.col) ? a : b;
2193
2584
  const last = first === a ? b : a;
@@ -2238,70 +2629,116 @@ export class ChatView extends Widget {
2238
2629
  return false;
2239
2630
  }
2240
2631
 
2632
+ /** Match one event against the editable transcript bindings. Two-press
2633
+ * chords arm `bindingPending`; any other key disarms it. */
2634
+ #matchChatBinding(ev) {
2635
+ if (ev.type !== "key" || this.app.focused !== this) { this.bindingPending = null; return null; }
2636
+ const bindings = keyBindings();
2637
+ for (const id of CHAT_BINDING_ORDER) {
2638
+ const spec = bindings[id];
2639
+ if (!spec || spec.mode === "insert") continue;
2640
+ const pending = this.bindingPending?.id === id ? this.bindingPending : null;
2641
+ const hit = matchKeyBinding(ev, spec, pending);
2642
+ if (hit?.kind === "pending") {
2643
+ this.bindingPending = { id, slot: hit.slot, part: hit.part };
2644
+ this.app.toast("再按一次完成组合键");
2645
+ return null;
2646
+ }
2647
+ if (hit?.kind === "full") { this.bindingPending = null; return { id }; }
2648
+ }
2649
+ this.bindingPending = null;
2650
+ return null;
2651
+ }
2652
+
2241
2653
  onKey(ev) {
2654
+ const blankWelcome = this.nodes.length === 0 && (this.app.sessions.find((s) => s.sessionId === this.sessionId)?.blank ?? false);
2655
+ if (blankWelcome && ev.type === "key" && (ev.name === "up" || ev.name === "down")) {
2656
+ this.welcomeModeSel = wrapIndex(this.welcomeModeSel + (ev.name === "up" ? -1 : 1), this.welcomeModeIds.length);
2657
+ return true;
2658
+ }
2659
+ if (blankWelcome && ev.type === "key" && ev.name === "enter") {
2660
+ this.app.selectPreset(this.welcomeModeIds[this.welcomeModeSel]);
2661
+ return true;
2662
+ }
2242
2663
  if (ev.type === "text" || ev.type === "paste") {
2664
+ // In cursor/Visual modes text is never inserted into the transcript or
2665
+ // silently redirected to INSERT. Only explicit i enters the input editor.
2666
+ if (this.cursorMode !== "block") return true;
2243
2667
  this.app.focus(this.input);
2244
2668
  this.input.insert(ev.text);
2245
2669
  return true;
2246
2670
  }
2247
2671
  if (ev.type !== "key") return false;
2248
2672
  if (this.app.focused === this.input) return false;
2249
- if (ev.name !== "char" || ev.key !== "g") this.gKey = false;
2250
- switch (ev.name) {
2251
- case "up": return this.view.scroll(-3);
2252
- case "down": return this.view.scroll(3);
2253
- case "pgup": {
2254
- if (this.view.scrollY <= this.view.h) { void this.loadOlder(); return true; }
2255
- return this.view.scroll(-this.view.h);
2256
- }
2257
- case "pgdn": return this.view.scroll(this.view.h);
2673
+ if (ev.ctrl && (ev.name === "up" || ev.name === "down")) { this.view.scroll(ev.name === "up" ? -3 : 3); this.app.redraw(); return true; }
2674
+ if (ev.name === "up" || ev.name === "down") return this.#moveBlock(ev.name === "up" ? -1 : 1);
2675
+ if (ev.name === "pgup") { if (this.view.scrollY <= this.view.h) { void this.loadOlder(); return true; } return this.view.scroll(-this.view.h); }
2676
+ if (ev.name === "pgdn") return this.view.scroll(this.view.h);
2677
+ // Editable transcript bindings (two slots each): think/tools/insert/
2678
+ // top/bottom/prevQuestion/nextQuestion/sessionFilter.
2679
+ const chatHit = this.#matchChatBinding(ev);
2680
+ if (chatHit?.id === "top") {
2681
+ this.blockSel = 0; const item = this.blockItems[0]; if (item) { this.cursor = { line: item.headerLine, col: 0 }; this.#scrollToTranscriptLine(item.headerLine); } return true;
2258
2682
  }
2259
- if (ev.name === "char" && ev.key === "g" && !ev.ctrl && !ev.alt && !ev.shift) {
2260
- if (this.gKey) { this.gKey = false; this.view.anchorLock = null; this.view.follow = false; this.view.scrollY = 0; return true; }
2261
- this.gKey = true;
2262
- this.app.toast("再按 g 回顶");
2263
- return true;
2264
- }
2265
- if (ev.name === "char" && ev.key === "g" && ev.shift && !ev.alt) { this.view.anchorLock = null; this.view.follow = true; this.view.scrollY = this.view.maxScroll(); return true; }
2266
- // [ / ] — jump to the end of the previous / next user question. Guarded by
2267
- // focus so the sidebar's session-move [ ] keys keep working there.
2268
- if (ev.name === "char" && (ev.key === "[" || ev.key === "]") && !ev.ctrl && !ev.shift && this.app.focused === this) {
2269
- return this.#jumpQuestion(ev.key === "[" ? -1 : 1);
2683
+ if (chatHit?.id === "bottom") {
2684
+ if (this.blockItems.length === 0) return true;
2685
+ // Vim G: the newest block gets the cursor, and its header lands at the
2686
+ // bottom of the viewport so the arrow is always visible with the tail.
2687
+ this.blockSel = this.blockItems.length - 1;
2688
+ const item = this.blockItems[this.blockSel];
2689
+ this.cursor = { line: item.headerLine, col: 0 };
2690
+ this.view.follow = false; this.view.anchorLock = null;
2691
+ this.view.scrollY = Math.max(0, Math.min(this.view.maxScroll(), item.headerLine - Math.max(1, this.view.h - 2)));
2692
+ this.app.redraw(); return true;
2270
2693
  }
2271
- if (ev.name === "escape" && this.app.searchQuery) { this.app.searchQuery = null; this.queueRebuild(); return true; }
2272
- if (ev.name === "escape" && this.selStart !== null) { this.selStart = this.selEnd = null; this.selAnchor = this.selFocus = null; this.app.redraw(); return true; }
2273
- if (ev.name === "char" && ev.key === "v" && !ev.ctrl && !ev.alt) { this.selectionMode = this.selectionMode === "character" ? "line" : "character"; this.app.toast(`正文选择:${this.selectionMode === "character" ? "字符自由选择" : "整行选择"}(v 切换)`); return true; }
2274
- if (ev.name === "char" && ev.key === "i" && !ev.ctrl && !ev.alt) { this.app.focus(this.input); return true; }
2275
- if (ev.name === "char" && ev.key === "/" && !ev.ctrl && !ev.alt) { this.app.startSearch(); return true; }
2276
- if (ev.name === "char" && ev.key === "b" && !ev.ctrl && !ev.alt) {
2277
- this.bashMode = this.bashMode === "collapsed" ? "expanded" : "collapsed";
2694
+ if (chatHit?.id === "prevQuestion" || chatHit?.id === "nextQuestion") return this.#jumpQuestion(chatHit.id === "prevQuestion" ? -1 : 1);
2695
+ if (chatHit?.id === "insert") { this.app.focus(this.input); return true; }
2696
+ if (chatHit?.id === "sessionFilter") { this.app.startSearch(); return true; }
2697
+ if (chatHit?.id === "think" && this.cursorMode === "block") {
2698
+ this.thinkMode = this.thinkMode === "collapsed" ? "expanded" : "collapsed";
2278
2699
  this.expanded.clear();
2279
2700
  this.collapsedBlocks.clear();
2280
- this.app.toast(this.bashMode === "collapsed" ? "工具块:折叠(b 展开)" : "工具块:展开(b 折叠)");
2701
+ this.app.toast(this.thinkMode === "expanded" ? "思考块:全部展开" : "思考块:折叠(t 切换)");
2281
2702
  this.queueRebuild();
2282
2703
  return true;
2283
2704
  }
2284
- if (ev.name === "char" && ev.key === "t" && !ev.ctrl && !ev.alt) {
2285
- if (ev.shift) {
2286
- const wasPinned = this.view.follow || this.view.scrollY >= this.view.maxScroll();
2287
- const oldMax = this.view.maxScroll();
2288
- this.todosVisible = !this.todosVisible;
2289
- this.app.toast(this.todosVisible ? "任务块:已展开(Shift+T 最小化)" : "任务块:已最小化(Shift+T 展开)");
2290
- this.inputChanged();
2291
- // Re-anchor immediately: tail readers stay at the tail; readers higher
2292
- // in history keep the same visible transcript row without a manual nudge.
2293
- if (wasPinned) { this.view.scrollY = this.view.maxScroll(); this.view.follow = true; }
2294
- else this.view.scrollY = Math.max(0, Math.min(this.view.maxScroll(), this.view.scrollY + (this.view.maxScroll() - oldMax)));
2295
- this.app.redraw();
2296
- return true;
2297
- }
2298
- this.thinkMode = this.thinkMode === "collapsed" ? "expanded" : "collapsed";
2705
+ if (chatHit?.id === "tools" && this.cursorMode === "block") {
2706
+ this.bashMode = this.bashMode === "collapsed" ? "expanded" : "collapsed";
2299
2707
  this.expanded.clear();
2300
2708
  this.collapsedBlocks.clear();
2301
- this.app.toast(this.thinkMode === "expanded" ? "思考块:全部展开" : "思考块:折叠(t 切换)");
2709
+ this.app.toast(this.bashMode === "collapsed" ? "工具块:折叠(b 展开)" : "工具块:展开(b 折叠)");
2302
2710
  this.queueRebuild();
2303
2711
  return true;
2304
2712
  }
2713
+ if (ev.name === "char" && ev.key === "t" && ev.shift && !ev.ctrl && !ev.alt && this.cursorMode === "block") {
2714
+ const wasPinned = this.view.follow || this.view.scrollY >= this.view.maxScroll();
2715
+ const oldMax = this.view.maxScroll();
2716
+ this.todosVisible = !this.todosVisible;
2717
+ this.app.toast(this.todosVisible ? "任务块:已展开(Shift+T 最小化)" : "任务块:已最小化(Shift+T 展开)");
2718
+ this.inputChanged();
2719
+ // Re-anchor immediately: tail readers stay at the tail; readers higher
2720
+ // in history keep the same visible transcript row without a manual nudge.
2721
+ if (wasPinned) { this.view.scrollY = this.view.maxScroll(); this.view.follow = true; }
2722
+ else this.view.scrollY = Math.max(0, Math.min(this.view.maxScroll(), this.view.scrollY + (this.view.maxScroll() - oldMax)));
2723
+ this.app.redraw();
2724
+ return true;
2725
+ }
2726
+ if (ev.name === "escape" && this.app.searchQuery) { this.app.searchQuery = null; this.queueRebuild(); return true; }
2727
+ if (ev.name === "escape" && this.cursorMode !== "block") { this.cursorMode = "block"; this.visualAnchor = null; this.#syncKeyboardSelection(); this.app.redraw(); return true; }
2728
+ if (ev.name === "escape" && this.selStart !== null) { this.selStart = this.selEnd = null; this.selAnchor = this.selFocus = null; this.app.redraw(); return true; }
2729
+ if (ev.name === "enter" && this.blockItems[this.blockSel]) { this.cursorMode = "normal"; const item = this.blockItems[this.blockSel]; this.cursor = { line: item.headerLine, col: Math.max(0, strWidth(this.#lineText(item.headerLine)) - 1) }; this.app.redraw(); return true; }
2730
+ if (ev.name === "char" && ev.key === "j" && !ev.ctrl && !ev.alt) return this.#moveBlock(1);
2731
+ if (ev.name === "char" && ev.key === "k" && !ev.ctrl && !ev.alt) return this.#moveBlock(-1);
2732
+ if (ev.name === "char" && ev.key === "h" && !ev.ctrl && !ev.alt && this.cursorMode !== "block") { if (this.cursorMode === "normal" && this.blockItems[this.blockSel]?.kind === "code") return true; return this.#moveCursorHorizontal(-1); }
2733
+ if (ev.name === "char" && ev.key === "l" && !ev.ctrl && !ev.alt && this.cursorMode !== "block") { if (this.cursorMode === "normal" && this.blockItems[this.blockSel]?.kind === "code") return true; return this.#moveCursorHorizontal(1); }
2734
+ if (ev.name === "char" && ["w", "b", "e"].includes(ev.key) && !ev.ctrl && !ev.alt && this.cursorMode !== "block") { if (this.cursorMode === "normal" && this.blockItems[this.blockSel]?.kind === "code") return true; return this.#wordMotion(ev.key); }
2735
+ if (ev.name === "char" && ev.key === "0" && !ev.ctrl && !ev.alt && this.cursorMode !== "block") { this.cursor.col = 0; this.#syncKeyboardSelection(); this.app.redraw(); return true; }
2736
+ if (ev.name === "char" && ev.key === "$" && !ev.ctrl && !ev.alt && this.cursorMode !== "block") { const stops = this.#cursorStops(); this.cursor.col = stops[stops.length - 1].col; this.#syncKeyboardSelection(); this.app.redraw(); return true; }
2737
+ if (ev.name === "char" && ev.key === "v" && !ev.ctrl && !ev.alt) { this.cursorMode = ev.shift ? "visual-line" : "visual"; this.visualAnchor = { ...this.cursor }; this.#syncKeyboardSelection(); this.app.toast(ev.shift ? "VISUAL LINE(只读)" : "VISUAL(只读)"); return true; }
2738
+ if (ev.name === "char" && ev.key === "y" && !ev.ctrl && !ev.alt) return this.#yankTranscript();
2739
+ if (ev.name === "char" && ev.key === "c" && ev.ctrl && ev.shift) return this.#yankTranscript();
2740
+ if (ev.name === "char" && ev.key === " " && !ev.ctrl && !ev.alt) { this.#toggleSelectedBlock(); return true; }
2741
+ if (ev.name === "char" && ev.key === "r" && ev.ctrl) return this.#openSelectedContextMenu();
2305
2742
  return false;
2306
2743
  }
2307
2744
  }
@@ -2501,8 +2938,8 @@ export class QuestionPopup extends Popup {
2501
2938
  if(this.customEditing&&ev.name==="right"){this.customCursor=Math.min(Array.from(draft.custom).length,this.customCursor+1);return true;}
2502
2939
  if(this.customEditing&&ev.name==="home"){this.customCursor=0;return true;}
2503
2940
  if(this.customEditing&&ev.name==="end"){this.customCursor=Array.from(draft.custom).length;return true;}
2504
- if (ev.name === "up") { this.customEditing=false;this.selIdx = Math.max(0, this.selIdx - 1); return true; }
2505
- if (ev.name === "down") { this.customEditing=false;this.selIdx = Math.min(Math.max(0, choices - 1), this.selIdx + 1); return true; }
2941
+ if (ev.name === "up") { this.customEditing=false;this.selIdx = wrapIndex(this.selIdx - 1, choices); return true; }
2942
+ if (ev.name === "down") { this.customEditing=false;this.selIdx = wrapIndex(this.selIdx + 1, choices); return true; }
2506
2943
  if (ev.name === "char" && ev.key === " " && count && this.selIdx<count) { this.#choose(this.selIdx); return true; }
2507
2944
  if (ev.name === "backspace" && this.customEditing && this.customCursor>0) {const chars=Array.from(draft.custom);chars.splice(this.customCursor-1,1);draft.custom=chars.join("");this.customCursor--;return true;}
2508
2945
  if(ev.name==="delete"&&this.customEditing){const chars=Array.from(draft.custom);if(this.customCursor<chars.length){chars.splice(this.customCursor,1);draft.custom=chars.join("");}return true;}
@@ -2596,11 +3033,12 @@ export class ApprovalPopup extends Popup {
2596
3033
  // ---- App ----
2597
3034
 
2598
3035
  export class App {
2599
- constructor({ screen, term, api, base, log }) {
3036
+ constructor({ screen, term, api, base, log, versionFetcher = latestNpmVersion }) {
2600
3037
  this.screen = screen;
2601
3038
  this.term = term;
2602
3039
  this.api = api;
2603
3040
  this.log = log ?? (() => {});
3041
+ this.versionFetcher = versionFetcher;
2604
3042
  this.popup = null;
2605
3043
  this.activePrompt = null;
2606
3044
  this.promptQueue = [];
@@ -2621,13 +3059,18 @@ export class App {
2621
3059
  this.sessionEpoch = 0;
2622
3060
  this.refreshSessionsSeq = 0;
2623
3061
  this.searchSeq = 0;
2624
- this.searchSelected = 0;
2625
3062
  this.connState = "connecting";
2626
3063
  this.tokenUsage = null;
2627
3064
  this.sessions = [];
2628
3065
  this.currentSession = null;
3066
+ this.dshVersion = installedDshVersion();
3067
+ this.versionChecks = {
3068
+ dsh: { state: "idle", latest: null },
3069
+ tui: { state: "idle", latest: null },
3070
+ };
2629
3071
  this.searchActive = false;
2630
3072
  this.overlay = null; // Picker / Popup / ImagePopup modal
3073
+ this.fullBuffer = null; // full-screen panel buffer (workspace/settings/models/subagent/skills)
2631
3074
  this.mode = "chat"; // chat | workspace | trajectory
2632
3075
  this.sidebarWanted = true;
2633
3076
  this.sidebarVisible = true; // auto-collapses on narrow terminals
@@ -2650,7 +3093,8 @@ export class App {
2650
3093
  this.sidebar = new SidebarTree(this);
2651
3094
  this.sidebar.w = this.sidebarWidth;
2652
3095
  this.sidebar.h = screen.h - 1;
2653
- this.searchInput = new Input({ x: 0, y: 0, w: this.sidebarWidth, h: 1, prompt: "/ ", placeholder: "搜索会话…" });
3096
+ this.searchInput = new Input({ x: 0, y: 0, w: this.sidebarWidth, h: 1, prompt: "/ ", placeholder: "输入跨会话全文查询,Enter 执行…" });
3097
+ this.searchState = null;
2654
3098
  this.chat = new ChatView({ x: this.sidebarWidth, y: 0, w: screen.w - this.sidebarWidth, h: screen.h - 1, app: this });
2655
3099
  this.status = new StatusBar({ x: 0, y: screen.h - 1, w: screen.w, h: 1 });
2656
3100
  this.focus(this.chat);
@@ -2674,9 +3118,8 @@ export class App {
2674
3118
  this.sidebar.x = 0; this.sidebar.y = 0; this.sidebar.w = this.sidebarWidth; this.sidebar.h = this.screen.h - 1;
2675
3119
  this.searchInput.w = this.sidebarWidth;
2676
3120
  this.chat.resize(x, 1, w, mainH);
2677
- for (const p of [this.workspacePanel, this.trajectoryPanel, this.settingsPanel, this.modelPanel, this.subagentPanel, this.skillsPanel]) {
2678
- if (p?.relayout) p.relayout(x, 1, w, mainH);
2679
- }
3121
+ if (this.trajectoryPanel?.relayout) this.trajectoryPanel.relayout(x, 1, w, mainH);
3122
+ if (this.fullBuffer?.relayout) this.fullBuffer.relayout(0, 0, this.screen.w, this.screen.h);
2680
3123
  this.status.y = this.screen.h - footerH;
2681
3124
  this.status.h = footerH;
2682
3125
  this.status.w = this.screen.w;
@@ -2693,6 +3136,52 @@ export class App {
2693
3136
  this.setMode(this.mode === "trajectory" ? "chat" : "trajectory");
2694
3137
  }
2695
3138
 
3139
+ /** tmux-style pane focus. The sequence wraps and skips unavailable panes. */
3140
+ focusPane(delta) {
3141
+ const panes = [];
3142
+ if (this.sidebarVisible) panes.push("sidebar");
3143
+ panes.push("chat");
3144
+ if (this.currentSession) panes.push("trajectory");
3145
+ const current = this.focused === this.sidebar ? "sidebar" : this.mode === "trajectory" ? "trajectory" : "chat";
3146
+ const next = panes[wrapIndex(Math.max(0, panes.indexOf(current)) + delta, panes.length)];
3147
+ if (next === "sidebar") {
3148
+ this.focus(this.sidebar);
3149
+ } else if (next === "trajectory") {
3150
+ this.setMode("trajectory");
3151
+ this.focus(this.trajectoryPanel ?? this.chat);
3152
+ } else {
3153
+ this.setMode("chat");
3154
+ this.focus(this.chat);
3155
+ }
3156
+ this.redraw();
3157
+ return true;
3158
+ }
3159
+
3160
+ async checkUpdates(target = null, notify = false) {
3161
+ const specs = {
3162
+ dsh: { package: "@deepseek-ai/dsh", current: this.dshVersion, label: "DeepSeek Harness" },
3163
+ tui: { package: "dsh-neotui", current: TUI_VERSION, label: "dsh-neotui" },
3164
+ };
3165
+ const keys = target ? [target] : Object.keys(specs);
3166
+ await Promise.all(keys.map(async (key) => {
3167
+ const spec = specs[key];
3168
+ if (!spec) return;
3169
+ this.versionChecks[key] = { state: "checking", latest: null };
3170
+ this.redraw();
3171
+ try {
3172
+ const latest = await this.versionFetcher(spec.package);
3173
+ const comparison = compareSemver(spec.current, latest);
3174
+ const state = comparison === null ? (latest === spec.current ? "current" : "update") : comparison < 0 ? "update" : "current";
3175
+ this.versionChecks[key] = { state, latest };
3176
+ if (notify) this.toast(state === "current" ? `${spec.label} 已是最新版本 ${spec.current}` : `${spec.label} 可更新: ${spec.current} → ${latest}`);
3177
+ } catch (error) {
3178
+ this.versionChecks[key] = { state: "error", latest: null };
3179
+ if (notify) this.toast(`${spec.label} 更新检查失败: ${error.message}`);
3180
+ }
3181
+ this.redraw();
3182
+ }));
3183
+ }
3184
+
2696
3185
  /** Chat → trajectory: open the trajectory panel at the step containing a
2697
3186
  * chat node (right-click menu), loading older steps on demand. */
2698
3187
  async jumpToTrajectoryNode(nodeIdx) {
@@ -2825,6 +3314,7 @@ export class App {
2825
3314
  this.provider = host.provider ?? "";
2826
3315
  this.model = host.model ?? "";
2827
3316
  } catch (e) { this.log(`[app] host.describe: ${e.message}`); }
3317
+ void this.checkUpdates();
2828
3318
  await this.refreshSessions();
2829
3319
  // A /restart handoff carries the session to reopen: resume it instead of
2830
3320
  // minting a fresh blank session (which was leaking a stray "new session"
@@ -3237,6 +3727,7 @@ export class App {
3237
3727
  ? { workspaceId: group.workspaceId }
3238
3728
  : { cwd: group?.path ?? process.cwd() };
3239
3729
  const { sessionId } = await this.api.call("session.create", payload);
3730
+ if (typeof sessionId !== "string" || !sessionId) throw new Error("Host 未返回会话 ID");
3240
3731
  await this.refreshSessions();
3241
3732
  this.openSession(sessionId);
3242
3733
  } catch (e) { this.toast(`创建失败: ${e.message}`); }
@@ -3250,6 +3741,7 @@ export class App {
3250
3741
  async newSession() { return this.newSessionIn(null); }
3251
3742
 
3252
3743
  async openSession(sessionId) {
3744
+ if (typeof sessionId !== "string" || !sessionId) { this.toast("无法打开会话:缺少会话 ID"); return; }
3253
3745
  const epoch = ++this.sessionEpoch;
3254
3746
  this.currentSession = sessionId;
3255
3747
  this.projections = { ...(this.projectionsBySession.get(sessionId) ?? {}) };
@@ -3269,6 +3761,12 @@ export class App {
3269
3761
  }
3270
3762
  await this.chat.open(sessionId, epoch);
3271
3763
  if (epoch !== this.sessionEpoch || sessionId !== this.currentSession) return;
3764
+ // A sidebar Enter intentionally preserves sidebar focus, but every
3765
+ // session-scoped panel must immediately follow the newly opened session.
3766
+ if (this.mode === "trajectory" && this.trajectoryPanel) await this.trajectoryPanel.load(sessionId);
3767
+ else if (this.fullBuffer && this.fullBuffer === this.subagentPanel) this.subagentPanel.load(sessionId);
3768
+ else if (this.fullBuffer && this.fullBuffer === this.skillsPanel) this.skillsPanel.load?.(sessionId);
3769
+ if (epoch !== this.sessionEpoch || sessionId !== this.currentSession) return;
3272
3770
  this.loadFeedback(sessionId, epoch);
3273
3771
  this.updateModel(sessionId, epoch);
3274
3772
  this.refreshSubagentStats(sessionId);
@@ -3307,47 +3805,50 @@ export class App {
3307
3805
  }
3308
3806
 
3309
3807
  setMode(mode) {
3310
- this.mode = mode;
3311
- if (mode === "workspace") {
3312
- if (!this.workspacePanel) {
3313
- this.workspacePanel = new WorkspacePanel(this);
3314
- this.workspacePanel.load();
3315
- } else {
3316
- this.workspacePanel.load();
3317
- }
3318
- } else if (mode === "trajectory") {
3808
+ this.mode = mode === "trajectory" ? "trajectory" : "chat";
3809
+ if (this.mode === "trajectory") {
3319
3810
  if (!this.currentSession) { this.toast("先打开一个会话"); this.mode = "chat"; this.redraw(); return; }
3320
3811
  if (!this.trajectoryPanel) this.trajectoryPanel = new TrajectoryPanel(this);
3321
3812
  this.trajectoryPanel.load(this.currentSession);
3322
- } else if (mode === "settings") {
3323
- if (!this.settingsPanel) this.settingsPanel = new SettingsPanel(this);
3324
- this.settingsPanel.load();
3325
- } else if (mode === "models") {
3326
- if (!this.modelPanel) this.modelPanel = new ModelPanel(this);
3327
- this.modelPanel.load();
3328
- } else if (mode === "subagent") {
3329
- if (!this.currentSession) { this.toast("先打开一个会话"); this.mode = "chat"; this.redraw(); return; }
3330
- if (!this.subagentPanel) this.subagentPanel = new SubagentPanel(this);
3331
- this.subagentPanel.load(this.currentSession);
3332
- } else if (mode === "skills") {
3333
- if (!this.currentSession) { this.toast("先打开一个会话"); this.mode = "chat"; this.redraw(); return; }
3334
- if (!this.skillsPanel) this.skillsPanel = new SkillsPanel(this);
3335
- this.skillsPanel.load();
3336
3813
  }
3814
+ const panel = this.panelForMode();
3815
+ if (panel && this.focused !== this.sidebar) this.focus(panel);
3816
+ else if (this.focused !== this.sidebar) this.focus(this.chat);
3337
3817
  this.layout();
3338
3818
  this.redraw();
3339
3819
  }
3340
3820
 
3341
- panelForMode() {
3342
- switch (this.mode) {
3343
- case "workspace": return this.workspacePanel;
3344
- case "trajectory": return this.trajectoryPanel;
3345
- case "settings": return this.settingsPanel;
3346
- case "models": return this.modelPanel;
3347
- case "subagent": return this.subagentPanel;
3348
- case "skills": return this.skillsPanel;
3349
- default: return null;
3350
- }
3821
+ panelForMode() { return this.mode === "trajectory" ? this.trajectoryPanel : null; }
3822
+
3823
+ /** Full-screen modal buffers replace the old tab-page modes: they coexist
3824
+ * with the sidebar/chat/trajectory pane focus instead of fighting it. */
3825
+ openFullBuffer(panel) {
3826
+ if (!panel) return;
3827
+ this.fullBuffer = panel;
3828
+ panel.relayout(0, 0, this.screen.w, this.screen.h);
3829
+ this.focus(panel);
3830
+ this.redraw();
3831
+ }
3832
+ closeFullBuffer() {
3833
+ if (!this.fullBuffer) return true;
3834
+ this.fullBuffer = null;
3835
+ this.focus(this.chat);
3836
+ this.layout();
3837
+ this.redraw();
3838
+ return true;
3839
+ }
3840
+ showWorkspaceBuffer() { if (!this.workspacePanel) this.workspacePanel = new WorkspacePanel(this); this.openFullBuffer(this.workspacePanel); this.workspacePanel.load(); }
3841
+ showSettingsBuffer() { if (!this.settingsPanel) this.settingsPanel = new SettingsPanel(this); this.openFullBuffer(this.settingsPanel); this.settingsPanel.load(); }
3842
+ showModelsBuffer() { if (!this.modelPanel) this.modelPanel = new ModelPanel(this); this.openFullBuffer(this.modelPanel); this.modelPanel.load(); }
3843
+ showSubagentBuffer() {
3844
+ if (!this.currentSession) { this.toast("先打开一个会话"); return; }
3845
+ if (!this.subagentPanel) this.subagentPanel = new SubagentPanel(this);
3846
+ this.openFullBuffer(this.subagentPanel); this.subagentPanel.load(this.currentSession);
3847
+ }
3848
+ showSkillsBuffer() {
3849
+ if (!this.currentSession) { this.toast("先打开一个会话"); return; }
3850
+ if (!this.skillsPanel) this.skillsPanel = new SkillsPanel(this);
3851
+ this.openFullBuffer(this.skillsPanel); this.skillsPanel.load();
3351
3852
  }
3352
3853
 
3353
3854
  closeOverlay() { this.overlay = null; this.redraw(); }
@@ -3409,7 +3910,6 @@ export class App {
3409
3910
  this.chat.nodes = [];
3410
3911
  this.chat.collapsedBlocks.clear();
3411
3912
  this.chat.expanded.clear();
3412
- this.chat.expandedTools.clear();
3413
3913
  this.chat.queueRebuild();
3414
3914
  this.chat.view.anchorLock = null;
3415
3915
  this.mode = "chat";
@@ -3437,7 +3937,7 @@ export class App {
3437
3937
  const argv = process.argv.slice(1);
3438
3938
  // hand the CURRENT session to the new instance so it reopens it instead
3439
3939
  // of minting a fresh blank session (the "strange new session" in 未分组)
3440
- const env = { ...process.env, DSH_TUI_RESUME_SESSION: this.currentSession ?? "", DSH_TUI_RESUME_SCROLL: String(this.chat?.view?.scrollY ?? 0), DSH_TUI_RESUME_FOLLOW: this.chat?.view?.follow ? "1" : "0" };
3940
+ const env = { ...process.env, DSH_TUI_RESTART_HANDOFF: "1", DSH_TUI_RESUME_SESSION: this.currentSession ?? "", DSH_TUI_RESUME_SCROLL: String(this.chat?.view?.scrollY ?? 0), DSH_TUI_RESUME_FOLLOW: this.chat?.view?.follow ? "1" : "0" };
3441
3941
  const child = spawn("sh", ["-c", 'sleep 1; exec "$@"', "sh", ...argv], { detached: true, stdio: "inherit", env });
3442
3942
  child.unref();
3443
3943
  } catch (e) {
@@ -3498,7 +3998,9 @@ export class App {
3498
3998
  }
3499
3999
  try {
3500
4000
  await this.api.call("agentPreset.select", { sessionId: this.currentSession, agentPreset: id });
4001
+ if (sess) sess.agentPreset = id;
3501
4002
  this.toast(`模式已切换: ${modeName(id)}`);
4003
+ this.redraw();
3502
4004
  this.refreshSessions();
3503
4005
  } catch (e) {
3504
4006
  if (e.code === "agent-preset-locked") { this.toast("会话已开始,模式固定;已设为新会话默认"); this.setDefaultPreset(id); }
@@ -3547,7 +4049,7 @@ export class App {
3547
4049
  } catch (e) { this.toast(`权限切换失败: ${e.message}`); }
3548
4050
  }
3549
4051
 
3550
- /** F8: cycle read-only → workspace-write → danger-full-access (full access gates). */
4052
+ /** F8: cycle read-only → workspace-write → danger-full-access. */
3551
4053
  rotatePermission() {
3552
4054
  const order = ["read-only", "workspace-write", "danger-full-access"];
3553
4055
  const cur = this.projections.permissions?.currentValue;
@@ -3556,6 +4058,58 @@ export class App {
3556
4058
  this.switchPermission(next);
3557
4059
  }
3558
4060
 
4061
+ /** Execute an editable global binding (two slots per id). */
4062
+ #runBinding(id, slot) {
4063
+ switch (id) {
4064
+ case "sessionFilter": this.startSearch(); this.redraw(); return true;
4065
+ case "panel": this.overlay = new ControlPanel(this, { startPage: 0 }); this.redraw(); return true;
4066
+ case "homeSwitch": this.focusPane(slot === "key" ? -1 : 1); return true;
4067
+ case "permissionRotate": this.rotatePermission(); return true;
4068
+ case "editConfig": this.editConfigFile(); return true;
4069
+ case "quit": this.stop(); return true;
4070
+ case "model": this.overlay = buildModelPicker(this); this.redraw(); return true;
4071
+ case "trajectory": this.setMode("trajectory"); return true;
4072
+ case "workspace": this.showWorkspaceBuffer(); return true;
4073
+ case "settings": this.showSettingsBuffer(); return true;
4074
+ case "subagent": this.showSubagentBuffer(); return true;
4075
+ case "skills": this.showSkillsBuffer(); return true;
4076
+ case "goal": this.showGoal(); return true;
4077
+ case "jobs": this.showJobs(); return true;
4078
+ case "queue": this.showQueue(); return true;
4079
+ case "busyEnter": {
4080
+ const next = busyEnter() === "queue" ? "steer" : "queue";
4081
+ saveTuiConfig({ busyEnter: next });
4082
+ this.toast(`运行中 Enter:${next === "steer" ? "追加到当前回合" : "加入队列"}`);
4083
+ return true;
4084
+ }
4085
+ case "attachments": this.overlay = new AttachmentPanel(this); this.focus(this.overlay); this.redraw(); return true;
4086
+ case "stepJump": this.quickJumpStep(); return true;
4087
+ case "sidebar": this.toggleSidebar(); return true;
4088
+ default: return false;
4089
+ }
4090
+ }
4091
+
4092
+ /** Ctrl+K: open tui-config.json in $EDITOR (default editor). The terminal is
4093
+ * restored around the editor, then re-entered; the config cache is dropped
4094
+ * so the new bindings apply immediately. */
4095
+ async editConfigFile() {
4096
+ const file = tuiConfigFile();
4097
+ const editor = process.env.EDITOR || process.env.VISUAL || "vi";
4098
+ this.toast(`在 ${editor} 中打开 ${file}…`);
4099
+ this.redraw();
4100
+ await new Promise((r) => setTimeout(r, 150));
4101
+ try { this.term?.stop?.(); } catch {}
4102
+ try { this.spawnEditor(file, editor); } catch (e) { this.toast(`编辑器启动失败: ${e.message}`); }
4103
+ try { this.term?.start?.(); } catch {}
4104
+ reloadTuiConfig();
4105
+ this.layout(); this.redraw();
4106
+ this.toast("配置编辑完成;快捷键已重新加载");
4107
+ }
4108
+ spawnEditor(file, editor) {
4109
+ const [cmd, ...args] = String(editor).split(/\s+/).filter(Boolean);
4110
+ spawnSync(cmd, [...args, file], { stdio: "inherit" });
4111
+ }
4112
+
3559
4113
  showFilePicker() {
3560
4114
  const session = this.sessions.find((s) => s.sessionId === this.currentSession);
3561
4115
  this.overlay = new UploadPicker(this, { startPath: session?.cwd ?? process.cwd(), onUpload: (files) => {
@@ -3582,27 +4136,22 @@ export class App {
3582
4136
  }
3583
4137
 
3584
4138
  #modeTabs() {
3585
- // The two Shift+Tab flip targets; panels surface as an extra active tab.
4139
+ // Ctrl+Left/Right pane targets. The other panels are full-screen buffers.
3586
4140
  return [
3587
4141
  ["chat", "对话"],
3588
4142
  ["trajectory", "轨迹"],
3589
4143
  ];
3590
4144
  }
3591
4145
 
3592
- #panelLabel(mode) {
3593
- return { workspace: "工作区", settings: "设置", models: "模型供应商", skills: "技能", subagent: "子代理" }[mode] ?? null;
3594
- }
3595
-
3596
4146
  #renderTabBar(s) {
3597
4147
  const x = this.sidebarVisible ? this.sidebarWidth : 0;
3598
4148
  const w = this.screen.w - x;
3599
4149
  s.fillRect(x, 0, x + w - 1, 0, " ", { bg: T.PANEL });
3600
4150
  const tabs = [...this.#modeTabs()];
3601
- const panelLabel = this.#panelLabel(this.mode);
3602
- if (panelLabel) tabs.push([this.mode, panelLabel]);
3603
4151
  let tx = x;
4152
+ const sidebarFocused = this.focused === this.sidebar;
3604
4153
  for (const [id, label] of tabs) {
3605
- const sel = id === this.mode || (id === "chat" && this.mode !== "trajectory" && !panelLabel);
4154
+ const sel = !sidebarFocused && id === this.mode;
3606
4155
  const seg = ` ${label} `;
3607
4156
  s.text(tx, 0, seg, { fg: sel ? T.SELFG : T.DIM, bg: sel ? T.ACCENT : T.PANEL, attrs: sel ? 1 : 0 });
3608
4157
  tx += strWidth(seg);
@@ -3613,14 +4162,11 @@ export class App {
3613
4162
  #clickTab(px) {
3614
4163
  const x = this.sidebarVisible ? this.sidebarWidth : 0;
3615
4164
  const tabs = [...this.#modeTabs()];
3616
- const panelLabel = this.#panelLabel(this.mode);
3617
- if (panelLabel) tabs.push([this.mode, panelLabel]);
3618
4165
  let tx = x;
3619
4166
  for (const [id, label] of tabs) {
3620
4167
  const seg = ` ${label} `;
3621
4168
  if (px >= tx && px < tx + strWidth(seg)) {
3622
- if (id === this.mode && panelLabel && id !== "chat" && id !== "trajectory") { /* click active panel tab: stay */ return true; }
3623
- this.setMode(id === "chat" || id === "trajectory" || this.#panelLabel(id) ? id : "chat");
4169
+ this.setMode(id);
3624
4170
  return true;
3625
4171
  }
3626
4172
  tx += strWidth(seg);
@@ -3701,6 +4247,21 @@ export class App {
3701
4247
  this.redraw();
3702
4248
  return;
3703
4249
  }
4250
+ // Full-screen panel buffers (workspace/settings/models/subagent/skills)
4251
+ // are modal surfaces over the main area. Pane cycling (Ctrl+Left/Right)
4252
+ // works again the moment Esc closes the buffer.
4253
+ if (this.fullBuffer) {
4254
+ if (ev.type === "mouse") {
4255
+ if (this.fullBuffer.onMouse?.(ev)) this.redraw();
4256
+ } else {
4257
+ const handled = this.fullBuffer.onKey?.(ev);
4258
+ // Panels exit level by level; when the top level declines Escape the
4259
+ // App closes the buffer, restoring the main area's pane focus.
4260
+ if (ev.type === "key" && ev.name === "escape" && !handled) this.closeFullBuffer();
4261
+ else this.redraw();
4262
+ }
4263
+ return;
4264
+ }
3704
4265
 
3705
4266
  // tab bar clicks (row 0 of the main area)
3706
4267
  if (ev.type === "mouse" && ev.kind === "press" && ev.button === 0 && ev.y === 0 && ev.x >= (this.sidebarVisible ? this.sidebarWidth : 0)) {
@@ -3714,11 +4275,15 @@ export class App {
3714
4275
  return;
3715
4276
  }
3716
4277
  const panel = this.panelForMode();
3717
- if (panel) {
3718
- const handled = ev.type === "key" || ev.type === "text" ? panel.onKey(ev) : panel.onMouse(ev);
4278
+ const paneSwitch = ev.type === "key" && ev.ctrl && (ev.name === "left" || ev.name === "right");
4279
+ if (panel && this.focused !== this.sidebar && !paneSwitch) {
4280
+ const handled = ev.type === "key" || ev.type === "text" || ev.type === "paste" ? panel.onKey(ev) : panel.onMouse(ev);
3719
4281
  if (handled) { this.redraw(); return; }
4282
+ // A visible modal panel owns non-global text/paste even when it declines
4283
+ // the event; never leak it into the hidden chat/Input behind the panel.
4284
+ if (ev.type === "text" || ev.type === "paste") { this.redraw(); return; }
3720
4285
  }
3721
- // unhandled keys fall through to global shortcuts
4286
+ // unhandled key events fall through to global shortcuts
3722
4287
  }
3723
4288
  if (ev.type === "mouse") {
3724
4289
  // input drag-selection: the gesture continues across motion events
@@ -3774,7 +4339,7 @@ export class App {
3774
4339
  } else if (this.chat.inside(ev.x, ev.y)) {
3775
4340
  if (this.focused !== this.chat.input) this.focus(this.chat); // INSERT exits only via Esc
3776
4341
  if (this.chat.onMouse(ev)) this.redraw();
3777
- } else if (this.focused?.onMouse(ev)) {
4342
+ } else if (this.focused?.onMouse?.(ev)) {
3778
4343
  this.redraw();
3779
4344
  }
3780
4345
  return;
@@ -3811,29 +4376,21 @@ export class App {
3811
4376
  this.redraw();
3812
4377
  return;
3813
4378
  }
3814
- if (this.searchActive && (ev.ctrl && ev.key === " " || ev.name === "f7")) {
3815
- this.overlay = new ControlPanel(this, { startPage: 0 });
3816
- this.redraw();
3817
- return;
3818
- }
3819
4379
  if (this.searchActive) {
3820
4380
  this.#onSearchKey(ev);
3821
4381
  this.redraw();
3822
4382
  return;
3823
4383
  }
3824
- if ((ev.ctrl && ev.key === " ") || ev.name === "f7") {
3825
- this.overlay = new ControlPanel(this, { startPage: 0 });
3826
- this.redraw();
3827
- return;
3828
- }
3829
- if (ev.name === "backtab" && !ev.ctrl) {
3830
- if (this.mode === "trajectory") this.setMode("chat");
3831
- else if (this.mode === "chat") this.setMode("trajectory");
3832
- this.redraw();
3833
- return;
4384
+ // Editable global bindings: two slots per function, resolved by the
4385
+ // keybindings registry (tui-config.json keyBindings.<id>).
4386
+ const hit = bindingMatchFor(ev, keyBindings(), false, KEYBINDING_ORDER);
4387
+ if (hit && this.#runBinding(hit.id, hit.slot)) return;
4388
+ if (ev.ctrl && ev.shift && ev.key === "c") {
4389
+ if (this.focused === this.chat) this.chat.onKey(ev);
4390
+ else this.toast("请先在正文中选择要复制的内容");
4391
+ this.redraw(); return;
3834
4392
  }
3835
- if (ev.ctrl && ev.key === "q") { this.stop(); return; }
3836
- if (ev.ctrl && ev.key === "c") {
4393
+ if (ev.ctrl && ev.key === "c" && !ev.shift) {
3837
4394
  // NORMAL-mode Ctrl+C: two presses within the toast window exit the
3838
4395
  // process; the first press just warns (insert mode owns Ctrl+C for
3839
4396
  // clearing the input).
@@ -3844,34 +4401,9 @@ export class App {
3844
4401
  this.toast("再按一次 Ctrl+C 退出 TUI");
3845
4402
  return;
3846
4403
  }
3847
- if (ev.ctrl && ev.key === "n") { this.focus(this.chat); this.redraw(); return; }
3848
- if (ev.ctrl && ev.key === "o") { this.overlay = new AttachmentPanel(this); this.focus(this.overlay); this.redraw(); return; }
3849
- if (ev.ctrl && ev.key === "b") { this.toggleSidebar(); return; }
4404
+ if (ev.ctrl && ev.shift && ev.key === "w") { this.addWorkspace(); return; }
3850
4405
  if (ev.ctrl && ev.key === "p") { this.overlay = new ControlPanel(this, { startPage: 1 }); this.redraw(); return; }
3851
- if (ev.ctrl && ev.key === "m") { this.overlay = buildModelPicker(this); this.redraw(); return; }
3852
- if (ev.name === "f8") { this.rotatePermission(); return; }
3853
4406
  if (ev.name === "f9") { this.showModePicker(); return; }
3854
- if (ev.ctrl && ev.key === "w") {
3855
- if (ev.shift) { this.addWorkspace(); return; }
3856
- this.setMode("workspace"); return;
3857
- }
3858
- if (ev.ctrl && ev.key === "t") { this.setMode("trajectory"); return; }
3859
- if (ev.ctrl && ev.key === "e") { this.quickJumpStep(); return; }
3860
- if (ev.ctrl && ev.key === "j") { this.showJobs(); return; }
3861
- if (ev.ctrl && ev.key === "n") { this.showQueue(); return; }
3862
- if (ev.ctrl && ev.key === "y") {
3863
- const next = busyEnter() === "queue" ? "steer" : "queue";
3864
- saveTuiConfig({ busyEnter: next });
3865
- this.toast(`运行中 Enter:${next === "steer" ? "追加到当前回合" : "加入队列"}`);
3866
- return;
3867
- }
3868
- if (ev.ctrl && ev.key === "g") { this.showGoal(); return; }
3869
- if (ev.ctrl && ev.key === "f") { this.startSearch(); return; }
3870
- if (ev.ctrl && ev.key === "s") { this.setMode("settings"); return; }
3871
- if (ev.ctrl && ev.key === "a") { this.setMode("subagent"); return; }
3872
- if (ev.ctrl && ev.key === "k") { this.setMode("skills"); return; }
3873
- if (ev.name === "char" && ev.key === "/" && !ev.ctrl && this.focused !== this.chat.input) { this.startSearch(); this.redraw(); return; }
3874
- if (ev.name === "char" && ev.key === "n" && !ev.ctrl && this.focused === this.sidebar) { this.newSession(); return; }
3875
4407
  if (ev.name === "escape") {
3876
4408
  // Esc in NORMAL mode interrupts a running turn (one press, regardless
3877
4409
  // of focus); otherwise it steps back toward the chat view.
@@ -3880,28 +4412,28 @@ export class App {
3880
4412
  else if (this.mode !== "chat") this.setMode("chat");
3881
4413
  return;
3882
4414
  }
3883
- if (ev.name === "char" && ev.key === "i" && this.focused === this.sidebar) { this.focus(this.chat.input); this.redraw(); return; }
3884
4415
  }
3885
4416
  // nvim-style normal mode: single chars are shortcuts (chat first, then the
3886
4417
  // focused pane). Multi-char text (paste/IME) still types into the input.
3887
4418
  if ((ev.type === "text" || ev.type === "paste") && this.focused !== this.chat.input) {
3888
4419
  if (this.searchActive) {
3889
- this.searchInput.onKey(ev);
3890
- this.#refreshSearch();
4420
+ this.#onSearchKey(ev);
3891
4421
  this.redraw();
3892
4422
  return;
3893
4423
  }
3894
- if (ev.text.length === 1) {
3895
- // Legacy terminals deliver Shift+letter as the UPPERCASE char with no
3896
- // modifier info — recover the shift flag from the case so Shift+T
3897
- // (todo fold) and G (scroll bottom) work everywhere.
4424
+ if (graphemes(ev.text).length === 1) {
4425
+ // Legacy terminals deliver Shift+letter and Space as text. Route to
4426
+ // the focused pane first — a focused sidebar must never mutate the
4427
+ // hidden chat behind it — then fall back to transcript NORMAL keys.
4428
+ const text = graphemes(ev.text)[0];
3898
4429
  const asKey = {
3899
4430
  type: "key", name: "char",
3900
- key: ev.text.toLowerCase(), text: ev.text,
3901
- ctrl: false, alt: false, shift: ev.text !== ev.text.toLowerCase(),
4431
+ key: text.toLowerCase(), text,
4432
+ ctrl: false, alt: false, shift: text !== text.toLowerCase(),
3902
4433
  };
4434
+ if (this.focused && this.focused !== this.chat && this.focused !== this.chat.input && this.focused.onKey?.(asKey)) { this.redraw(); return; }
3903
4435
  if (this.chat.onKey(asKey)) { this.redraw(); return; }
3904
- if (this.focused && this.focused !== this.chat.input && this.focused.onKey(asKey)) { this.redraw(); return; }
4436
+ if (this.focused && this.focused !== this.chat.input && this.focused.onKey?.(asKey)) { this.redraw(); return; }
3905
4437
  this.toast("按 i 进入输入");
3906
4438
  return;
3907
4439
  }
@@ -3912,47 +4444,249 @@ export class App {
3912
4444
  }
3913
4445
  // focused widget
3914
4446
  if (this.focused) {
3915
- const handled = ev.type === "mouse" ? this.focused.onMouse(ev) : this.focused.onKey(ev);
4447
+ const handled = ev.type === "mouse" ? this.focused.onMouse?.(ev) : this.focused.onKey?.(ev);
3916
4448
  if (handled) this.redraw();
3917
4449
  }
3918
4450
  }
3919
4451
 
3920
4452
  startSearch() {
4453
+ this.searchSeq++;
3921
4454
  this.searchActive = true;
3922
4455
  this.searchInput.setValue("");
3923
- this.searchResults = this.sessions.map((s) => ({ sessionId:s.sessionId, title:s.projections?.values?.title ?? s.sessionId.slice(0,8), snippet:s.cwd ?? "" }));
4456
+ this.searchState = { phase: "input", query: "", rows: [], selected: 0, collapsed: new Set(), typeFold: new Set(), preview: [], previewScroll: 0, loading: false, hasMore: false, fallback: false, fallbackError: null };
3924
4457
  this.focus(this.searchInput);
4458
+ this.redraw();
3925
4459
  }
3926
4460
 
3927
- #refreshSearch() {
3928
- const query = this.searchInput.value.trim().toLowerCase();
3929
- this.searchSelected = 0;
3930
- const fuzzy = (text) => { let i=0; for(const ch of String(text).toLowerCase()) if(ch===query[i]) i++; return i===query.length; };
3931
- this.searchResults = this.sessions.filter((s) => !query || fuzzy(`${s.projections?.values?.title ?? ""} ${s.cwd ?? ""} ${s.sessionId}`)).map((s) => ({ sessionId:s.sessionId, title:s.projections?.values?.title ?? s.sessionId.slice(0,8), snippet:s.cwd ?? "" }));
4461
+ #searchWorkspaceFor(sessionId) {
4462
+ const ws = (this.workspaceItems ?? []).find((item) => (item.sessionIds ?? []).includes(sessionId));
4463
+ return ws ? { key: ws.workspaceId ?? ws.id ?? ws.path, title: ws.title ?? ws.name ?? ws.path ?? "工作区" } : { key: "ungrouped", title: "未分组" };
3932
4464
  }
3933
4465
 
3934
- #onSearchKey(ev) {
3935
- const input = this.searchInput;
3936
- if (ev.type === "key" && ((ev.name === "escape") || (ev.ctrl && (ev.key === "/" || ev.key === "_")))) { this.searchActive = false; this.searchResults = null; this.focus(this.sidebar); this.refreshSessions(); return; }
3937
- if (ev.type === "key" && ev.name === "up" && this.searchResults?.length) { this.searchSelected = Math.max(0, this.searchSelected - 1); return; }
3938
- if (ev.type === "key" && ev.name === "down" && this.searchResults?.length) { this.searchSelected = Math.min(this.searchResults.length - 1, this.searchSelected + 1); return; }
3939
- if (ev.type === "key" && ev.name === "enter") {
3940
- const selected = this.searchResults?.[this.searchSelected];
3941
- if (selected?.sessionId) this.openSession(selected.sessionId);
3942
- // Keep the filter active and its query fixed after opening.
3943
- this.focus(this.searchInput);
3944
- return;
4466
+ #searchBlockText(node, block = null) {
4467
+ if (!block) return String(node?.text ?? "");
4468
+ const fields = [block.name, block.text, block.args, block.result];
4469
+ return fields.filter((value) => value != null && value !== "").map((value) => {
4470
+ if (typeof value === "string") return value;
4471
+ try { return JSON.stringify(value); } catch { return String(value); }
4472
+ }).join("\n");
4473
+ }
4474
+
4475
+ #mergeHistoryEvents(older, newer) {
4476
+ const bySeq = new Map();
4477
+ for (const wrapped of [...(older ?? []), ...(newer ?? [])]) {
4478
+ const seq = wrapped?.event?.seq;
4479
+ if (seq == null) continue;
4480
+ bySeq.set(seq, wrapped);
3945
4481
  }
3946
- const handled = input.onKey(ev);
3947
- if (handled) this.#refreshSearch();
4482
+ return [...bySeq.values()].sort((a, b) => a.event.seq - b.event.seq);
4483
+ }
4484
+
4485
+ /** Resolve one Host search hit into session-level matches. `deep` pages back
4486
+ * toward the FTS hit; fallback scans stay on the single tail page. */
4487
+ async #resolveSearchSession(sessionId, snippet, lower, seq, state, { deep = true } = {}) {
4488
+ let history;
4489
+ try { history = await this.api.call("session.history", { sessionId, maxMessages: 80 }); }
4490
+ catch { history = { events: [], hasMore: false }; }
4491
+ if (seq !== this.searchSeq || !this.searchActive || this.searchState !== state) return null;
4492
+ let allEvents = this.#mergeHistoryEvents([], history.events);
4493
+ const contains = (list) => list.some((node) => node.kind === "assistant" ? (node.blocks ?? []).some((block) => this.#searchBlockText(node, block).toLowerCase().includes(lower)) : this.#searchBlockText(node).toLowerCase().includes(lower));
4494
+ let resolved = contains(nodeForEvents(allEvents, this.log));
4495
+ // Probe each bounded page independently, then derive the accumulated
4496
+ // window once. Rebuilding an ever-growing history on every page made a
4497
+ // deep search quadratic while adding no useful precision.
4498
+ for (let page = 0; deep && !resolved && history.hasMore && page < 40; page++) {
4499
+ const beforeSeq = allEvents[0]?.event?.seq;
4500
+ if (beforeSeq == null) break;
4501
+ const older = await this.api.call("session.history", { sessionId, beforeSeq, maxMessages: 80 });
4502
+ if (seq !== this.searchSeq || !this.searchActive || this.searchState !== state) return null;
4503
+ const merged = this.#mergeHistoryEvents(older.events, allEvents);
4504
+ const newBeforeSeq = merged[0]?.event?.seq;
4505
+ if (!older.events?.length || newBeforeSeq == null || newBeforeSeq >= beforeSeq) { history = { ...history, hasMore: false }; break; }
4506
+ resolved = contains(nodeForEvents(older.events, this.log));
4507
+ allEvents = merged; history = { ...older, events: allEvents };
4508
+ }
4509
+ const nodes = nodeForEvents(allEvents, this.log);
4510
+ const matches = [];
4511
+ for (let ni = 0; ni < nodes.length; ni++) {
4512
+ const node = nodes[ni];
4513
+ if (node.kind === "assistant") {
4514
+ for (let bi = 0; bi < (node.blocks ?? []).length; bi++) {
4515
+ const block = node.blocks[bi], text = this.#searchBlockText(node, block);
4516
+ if (text.toLowerCase().includes(lower)) matches.push({ nodeIdx: ni, blockIdx: bi, kind: block.kind, text, seq: node.firstSeq ?? node.lastSeq });
4517
+ }
4518
+ } else {
4519
+ const text = this.#searchBlockText(node);
4520
+ if (text.toLowerCase().includes(lower)) matches.push({ nodeIdx: ni, blockIdx: null, kind: node.kind, text, seq: node.firstSeq ?? node.lastSeq });
4521
+ }
4522
+ }
4523
+ if (!matches.length && deep) matches.push({ nodeIdx: -1, blockIdx: null, kind: "snippet", text: snippet ?? "", seq: null, approximate: true });
4524
+ const session = this.sessions.find((item) => item.sessionId === sessionId);
4525
+ return { sessionId, title: session?.projections?.values?.title ?? sessionId.slice(0, 8), snippet: snippet ?? "", nodes, matches, hasMore: history.hasMore, beforeSeq: allEvents[0]?.event?.seq ?? null };
4526
+ }
4527
+
4528
+ /** Bounded local scan over loaded sessions when the Host FTS index is absent. */
4529
+ async #localSearchFallback(query, lower, seq, state) {
4530
+ const groups = new Map();
4531
+ const candidates = (this.sessions ?? []).filter((session) => !session.blank).slice(0, 20);
4532
+ for (const session of candidates) {
4533
+ const entry = await this.#resolveSearchSession(session.sessionId, "", lower, seq, state, { deep: false });
4534
+ if (entry === null) return null;
4535
+ if (entry.matches.length === 0) continue;
4536
+ const ws = this.#searchWorkspaceFor(session.sessionId);
4537
+ if (!groups.has(ws.key)) groups.set(ws.key, { ...ws, sessions: [] });
4538
+ groups.get(ws.key).sessions.push(entry);
4539
+ }
4540
+ return [...groups.values()];
3948
4541
  }
3949
4542
 
3950
- #renderSearchResults(s) {
3951
- const ids=new Set((this.searchResults??[]).map(x=>x.sessionId));
3952
- const groups=this.sidebar.groups.map(g=>({...g,sessions:g.sessions.filter(x=>ids.has(x.sessionId))})).filter(g=>g.sessions.length);
3953
- let y=this.sidebar.y;
3954
- for(const g of groups){if(y>=this.sidebar.y+this.sidebar.h)break;s.text(this.sidebar.x,y++,truncate(`▾ ${g.title} (${g.sessions.length})`,this.sidebar.w-2),{fg:K.DIM});for(const sess of g.sessions){if(y>=this.sidebar.y+this.sidebar.h)break;const index=(this.searchResults??[]).findIndex(x=>x.sessionId===sess.sessionId),selected=index===this.searchSelected;s.fillRect(this.sidebar.x,y,this.sidebar.x+this.sidebar.w-2,y," ",{bg:selected?T.MENUSEL:T.BG});s.text(this.sidebar.x,y++,truncate(` ${selected?"▸":" "} ${sess.projections?.values?.title??sess.sessionId.slice(0,8)}`,this.sidebar.w-2),{fg:selected?T.SELFG:K.TXT,bg:selected?T.MENUSEL:T.BG});}}
3955
- if(!groups.length)s.text(this.sidebar.x,this.sidebar.y,"无结果",{fg:K.FAINT});
4543
+ async #executeSearch() {
4544
+ const state = this.searchState;
4545
+ const query = this.searchInput.value.trim();
4546
+ if (!state || !query || state.loading) { if (!query) this.toast("请输入搜索内容"); return; }
4547
+ state.loading = true; state.error = null; state.fallback = false; state.fallbackError = null; state.phase = "results"; state.query = query; state.rows = []; state.preview = []; state.selected = 0; this.focus(this); this.redraw();
4548
+ const seq = ++this.searchSeq;
4549
+ const lower = query.toLowerCase();
4550
+ try {
4551
+ const result = await this.api.call("session.search", { query });
4552
+ const groups = new Map();
4553
+ for (const hit of result.items ?? []) {
4554
+ const entry = await this.#resolveSearchSession(hit.sessionId, hit.snippet ?? "", lower, seq, state, { deep: true });
4555
+ if (entry === null) return;
4556
+ const ws = this.#searchWorkspaceFor(hit.sessionId);
4557
+ if (!groups.has(ws.key)) groups.set(ws.key, { ...ws, sessions: [] });
4558
+ groups.get(ws.key).sessions.push(entry);
4559
+ }
4560
+ if (seq !== this.searchSeq || !this.searchActive || this.searchState !== state) return;
4561
+ state.groups = [...groups.values()]; state.hasMore = !!result.hasMore; state.loading = false; this.#flattenSearchRows();
4562
+ } catch (error) {
4563
+ if (seq !== this.searchSeq || !this.searchActive || this.searchState !== state) return;
4564
+ // Deployments without @deepseek-ai/dsh-session-query reject session.search.
4565
+ // Degrade to a bounded local scan over the already-loaded sessions.
4566
+ const groups = await this.#localSearchFallback(query, lower, seq, state);
4567
+ if (groups === null) return;
4568
+ state.groups = groups; state.hasMore = false; state.loading = false; state.fallback = true; state.fallbackError = error.message;
4569
+ this.#flattenSearchRows();
4570
+ this.toast("Host 搜索索引不可用;已改用本地有界扫描");
4571
+ }
4572
+ if (seq === this.searchSeq && this.searchState === state) this.redraw();
4573
+ }
4574
+
4575
+ #flattenSearchRows() {
4576
+ const state = this.searchState; if (!state) return;
4577
+ const rows = [];
4578
+ for (const group of state.groups ?? []) {
4579
+ rows.push({ kind: "workspace", key: `w:${group.key}`, group });
4580
+ if (state.collapsed.has(`w:${group.key}`)) continue;
4581
+ for (const session of group.sessions) {
4582
+ rows.push({ kind: "session", key: `s:${session.sessionId}`, session, group });
4583
+ if (state.collapsed.has(`s:${session.sessionId}`)) continue;
4584
+ for (let mi = 0; mi < session.matches.length; mi++) {
4585
+ const match = session.matches[mi];
4586
+ if (state.typeFold.has(match.kind)) continue;
4587
+ rows.push({ kind: "match", key: `m:${session.sessionId}:${mi}`, session, group, match, matchIndex: mi });
4588
+ }
4589
+ }
4590
+ }
4591
+ state.rows = rows; state.selected = Math.min(state.selected, Math.max(0, rows.length - 1)); this.#updateSearchPreview();
4592
+ }
4593
+
4594
+ #updateSearchPreview() {
4595
+ const state = this.searchState; const row = state?.rows[state.selected]; if (!state) return;
4596
+ if (row?.kind === "match" && row.match.nodeIdx >= 0) {
4597
+ const from = Math.max(0, row.match.nodeIdx - 2), to = Math.min(row.session.nodes.length, row.match.nodeIdx + 3);
4598
+ state.preview = row.session.nodes.slice(from, to).flatMap((node, offset) => node.kind === "assistant" ? (node.blocks ?? []).map((block) => ({ kind: block.kind, text: this.#searchBlockText(node, block), active: from + offset === row.match.nodeIdx && block === node.blocks?.[row.match.blockIdx] })) : [{ kind: node.kind, text: this.#searchBlockText(node), active: from + offset === row.match.nodeIdx }]);
4599
+ } else if (row?.session) state.preview = [{ kind: "text", text: row.session.snippet }];
4600
+ else state.preview = [];
4601
+ state.previewScroll = 0;
4602
+ }
4603
+
4604
+ async #jumpSearchResult(row) {
4605
+ if (!row?.session) return;
4606
+ const sessionId = row.session.sessionId;
4607
+ const query = this.searchState?.query ?? "";
4608
+ this.searchSeq++;
4609
+ this.searchActive = false; this.searchState = null;
4610
+ await this.openSession(sessionId);
4611
+ this.setMode("chat"); this.focus(this.chat);
4612
+ if (row.kind === "match" && row.match.approximate) {
4613
+ this.toast("Host 找到该会话,但在解析预算内未定位到精确正文;已打开会话尾部");
4614
+ } else if (row.kind === "match" && row.match.nodeIdx >= 0) {
4615
+ const targetSeq = row.match.seq;
4616
+ let index = targetSeq == null ? row.match.nodeIdx : this.chat.nodes.findIndex((node) => node.firstSeq <= targetSeq && node.lastSeq >= targetSeq);
4617
+ // Search may have resolved up to forty 80-message pages; use the same
4618
+ // page size and budget while opening the target conversation.
4619
+ for (let i = 0; index < 0 && this.chat.hasMore && i < 40; i++) { await this.chat.loadOlder(null, 80); index = this.chat.nodes.findIndex((node) => node.firstSeq <= targetSeq && node.lastSeq >= targetSeq); }
4620
+ if (index >= 0) {
4621
+ this.chat.jumpToNode(index);
4622
+ const block = this.chat.blockItems.findIndex((item) => item.nodeIdx === index && (row.match.blockIdx == null || item.blockIdx === row.match.blockIdx) && (row.match.kind !== "code" || item.kind === "code"));
4623
+ if (block >= 0) {
4624
+ const item = this.chat.blockItems[block]; this.chat.blockSel = block; this.chat.cursorMode = "block"; this.chat.cursor = { line: item.headerLine, col: 0 }; this.chat.view.scrollY = Math.max(0, item.headerLine - 2);
4625
+ this.searchQuery = query || null;
4626
+ this.chat.queueRebuild();
4627
+ } else {
4628
+ this.toast("已定位到消息,但匹配块当前不可见");
4629
+ }
4630
+ } else {
4631
+ this.toast("在历史加载预算内未能定位该匹配;已打开会话尾部");
4632
+ }
4633
+ }
4634
+ this.redraw();
4635
+ }
4636
+
4637
+ #onSearchKey(ev) {
4638
+ const state = this.searchState; if (!state) return;
4639
+ if (ev.type === "key" && ev.name === "escape") { this.searchSeq++; this.searchActive = false; this.searchState = null; this.focus(this.chat); this.layout(); return; }
4640
+ if (state.phase === "input") {
4641
+ if (ev.type === "key" && ev.name === "enter") { void this.#executeSearch(); return; }
4642
+ this.searchInput.onKey(ev); return;
4643
+ }
4644
+ if (ev.type === "text" && graphemes(ev.text ?? "").length === 1) {
4645
+ const text = graphemes(ev.text)[0];
4646
+ ev = { type: "key", name: "char", key: text.toLowerCase(), text, ctrl: false, alt: false, shift: text !== text.toLowerCase() };
4647
+ }
4648
+ if (ev.type !== "key") return;
4649
+ if (ev.name === "char" && ev.key === "/" && !ev.ctrl) { state.phase = "input"; this.searchInput.setValue(state.query); this.focus(this.searchInput); return; }
4650
+ if (ev.ctrl && (ev.name === "up" || ev.name === "down")) { state.previewScroll = Math.max(0, state.previewScroll + (ev.name === "up" ? -1 : 1)); return; }
4651
+ if ((ev.name === "up" || ev.name === "down") && state.rows.length) { state.selected = wrapIndex(state.selected + (ev.name === "up" ? -1 : 1), state.rows.length); this.#updateSearchPreview(); return; }
4652
+ const row = state.rows[state.selected];
4653
+ if (ev.name === "char" && ev.key === " " && !ev.ctrl && row && row.kind !== "match") { const key = row.key; if (state.collapsed.has(key)) state.collapsed.delete(key); else state.collapsed.add(key); this.#flattenSearchRows(); return; }
4654
+ if (ev.name === "char" && (ev.key === "t" || ev.key === "b") && !ev.ctrl && !ev.shift) { const kind = ev.key === "t" ? "reasoning" : "tool"; if (state.typeFold.has(kind)) state.typeFold.delete(kind); else state.typeFold.add(kind); this.#flattenSearchRows(); return; }
4655
+ if (ev.name === "enter" && row) { if (row.kind === "match") void this.#jumpSearchResult(row); else { if (state.collapsed.has(row.key)) state.collapsed.delete(row.key); else state.collapsed.add(row.key); this.#flattenSearchRows(); } }
4656
+ }
4657
+
4658
+ #renderSearchBuffer(s) {
4659
+ const state = this.searchState; if (!state) return;
4660
+ s.fillRect(0, 0, s.w - 1, s.h - 1, " ", { bg: T.BG });
4661
+ const split = Math.max(24, Math.min(Math.floor(s.w * 0.36), 48));
4662
+ s.box(0, 0, s.w - 1, s.h - 1, { fg: T.BORDER2, bg: T.BG }, " 跨会话搜索 · Enter 执行 · / 编辑 · t/b 折叠类型 · Ctrl+↑↓ 预览 ");
4663
+ s.vline(split, 1, s.h - 2, "│", { fg: T.BORDER2 });
4664
+ this.searchInput.x = 2; this.searchInput.y = 1; this.searchInput.w = Math.max(8, s.w - 4); this.searchInput.render(s);
4665
+ let y = 3;
4666
+ if (state.phase === "input") {
4667
+ s.text(2, y++, "执行搜索前仅显示工作区 / 会话结构;不会实时扫描历史。", { fg: K.FAINT });
4668
+ for (const group of this.sidebar.groups) { if (y >= s.h - 2) break; s.text(2, y++, truncate(`▾ ${group.title} (${group.sessions.length})`, split - 3), { fg: K.DIM }); for (const session of group.sessions) { if (y >= s.h - 2) break; s.text(4, y++, truncate(session.projections?.values?.title ?? session.sessionId.slice(0, 8), split - 5), { fg: K.FAINT }); } }
4669
+ return;
4670
+ }
4671
+ if (state.loading) { s.text(2, y, "正在搜索 Host 索引并解析候选会话…", { fg: K.ACCENT }); return; }
4672
+ if (state.error) s.text(2, y++, `搜索失败: ${truncate(state.error, split - 8)}`, { fg: K.ERR });
4673
+ if (state.fallback) s.text(2, y++, `Host 搜索索引不可用:已本地扫描最近 20 个会话的近期历史(${truncate(String(state.fallbackError ?? ""), Math.max(8, split - 30))})`, { fg: K.WARN });
4674
+ if (state.hasMore && state.rows.length) s.text(2, y++, "Host 候选已截断,请缩小查询", { fg: K.WARN });
4675
+ const available = Math.max(1, s.h - y - 2), scroll = Math.max(0, Math.min(Math.max(0, state.rows.length - available), state.selected - Math.floor(available / 2)));
4676
+ for (let i = 0; i < available; i++) {
4677
+ const index = scroll + i, row = state.rows[index]; if (!row) break;
4678
+ const selected = index === state.selected, folded = state.collapsed.has(row.key);
4679
+ const label = row.kind === "workspace" ? `${folded ? "▸" : "▾"} ${row.group.title}` : row.kind === "session" ? ` ${folded ? "▸" : "▾"} ${row.session.title}` : ` ${selected ? "=>" : " "} [${row.match.kind}] ${row.match.text.replace(/\s+/g, " ")}`;
4680
+ s.text(1, y + i, truncate(label, split - 2), { fg: selected ? T.SELFG : row.kind === "match" ? K.TXT : K.DIM, bg: selected ? T.MENUSEL : -1, attrs: selected ? 1 : 0 });
4681
+ }
4682
+ let py = 3, logical = 0;
4683
+ for (const item of state.preview) {
4684
+ if (state.typeFold.has(item.kind)) continue;
4685
+ const wrapped = wrapDisplayText(item.text || "(空)", Math.max(10, s.w - split - 5));
4686
+ for (const line of wrapped) { if (logical++ < state.previewScroll) continue; if (py >= s.h - 2) break; s.text(split + 2, py++, truncate(`${item.active ? "=>" : " "} [${item.kind}] ${line}`, s.w - split - 4), { fg: item.active ? T.ACCENT : K.TXT, attrs: item.active ? 1 : 0 }); }
4687
+ if (py >= s.h - 2) break;
4688
+ }
4689
+ if (!state.rows.length) s.text(2, y, state.error ? `搜索失败: ${state.error}` : state.fallback ? "本地扫描没有匹配(仅最近 20 个会话的近期历史)" : state.hasMore ? "结果超过 Host 上限,请缩小查询" : "没有匹配", { fg: state.error ? K.BAD : K.FAINT });
3956
4690
  }
3957
4691
 
3958
4692
  redraw() {
@@ -4006,17 +4740,27 @@ export class App {
4006
4740
  this.term.output.write(s.render() + "\x1b[?25l");
4007
4741
  return;
4008
4742
  }
4743
+ if (this.searchActive && this.searchState) {
4744
+ this.#renderSearchBuffer(s);
4745
+ this.term.output.write(s.render() + "\x1b[?25l");
4746
+ return;
4747
+ }
4748
+ // Full-screen panel buffer: covers the whole surface; Esc returns to the
4749
+ // main area where pane focus (Ctrl+Left/Right) works again.
4750
+ if (this.fullBuffer) {
4751
+ this.fullBuffer.relayout(0, 0, s.w, s.h);
4752
+ this.fullBuffer.render(s);
4753
+ if (this.popup) this.popup.render(s);
4754
+ if (this.menu) this.menu.render(s);
4755
+ if (this.overlay) this.overlay.render(s);
4756
+ this.#renderToast(s);
4757
+ this.term.output.write(s.render() + "\x1b[?25l");
4758
+ return;
4759
+ }
4009
4760
  this.#renderTabBar(s);
4010
4761
  if (this.sidebarVisible) {
4011
- if (this.searchActive) {
4012
- this.searchInput.render(s);
4013
- this.sidebar.y = 1; this.sidebar.h = s.h - 2;
4014
- if (this.searchResults !== null) this.#renderSearchResults(s);
4015
- else this.sidebar.render(s);
4016
- } else {
4017
- this.sidebar.y = 0; this.sidebar.h = s.h - 1;
4018
- this.sidebar.render(s);
4019
- }
4762
+ this.sidebar.y = 0; this.sidebar.h = s.h - 1;
4763
+ this.sidebar.render(s);
4020
4764
  s.put(this.sidebar.w - 1, 0, "│", { fg: T.BORDER });
4021
4765
  for (let y = 1; y < s.h - 1; y++) s.put(this.sidebar.w - 1, y, "│", { fg: T.BORDER });
4022
4766
  }
@@ -4050,7 +4794,7 @@ export class App {
4050
4794
  row0.left.push({ t: ` ${badge} `, fg: T.SELFG, bg: T.ACCENT, bold: true });
4051
4795
  const rawGoal = this.goalData?.goal ?? this.goalData;
4052
4796
  if (rawGoal && !["complete", "completed", "cleared"].includes(rawGoal.phase)) {
4053
- row0.left.push({ t: ` 🎯 ${truncate(rawGoal.objective ?? "目标", 14)} · Ctrl+G `, fg: T.SELFG, bg: T.WARN, bold: true });
4797
+ row0.left.push({ t: ` 🎯 ${truncate(rawGoal.objective ?? "目标", 14)} · Ctrl+G `, fg: 0x000000, bg: T.WARN, bold: true });
4054
4798
  }
4055
4799
  if (this.sidebarVisible) row0.left.push({ t: " " + truncate(t || "(未选择会话)", 40) + " ", fg: T.TXT, bg: T.STATUSBG });
4056
4800
  else row0.left.push({ t: " " + truncate(t || "(未选择会话)", 40) + " ", fg: T.TXT, bg: T.STATUSBG });
@@ -4131,15 +4875,32 @@ export class App {
4131
4875
  const sub = this.projections.subagent;
4132
4876
  const subTiming = this.projections.subagentTiming;
4133
4877
  const subStats = this.subagentStatsBySession.get(this.currentSession) ?? { running: subTiming?.active ? 1 : 0, completed: 0 };
4878
+ // Tasks and subagents use exactly the same two-part status grammar:
4879
+ // WARN = currently running, OK = completed, FAINT = zero/idle.
4134
4880
  row2.left.push({
4135
4881
  t: ` ${running > 0 ? `${running} 个后台任务运行中` : "没有后台任务运行"} `,
4136
- fg: running > 0 ? T.WARN : T.FAINT, bg: T.STATUSBG,
4882
+ fg: running > 0 ? T.WARN : T.FAINT, bg: T.STATUSBG, bold: running > 0,
4883
+ });
4884
+ row2.left.push({
4885
+ t: ` ${done}已完成${failed > 0 ? ` · ${failed}失败` : ""} `,
4886
+ fg: done > 0 ? T.OK : failed > 0 ? T.WARN : T.FAINT, bg: T.STATUSBG,
4887
+ });
4888
+ row2.left.push({
4889
+ t: ` ${subStats.running > 0 ? `${subStats.running} 个子代理运行中` : "没有子代理运行"} `,
4890
+ fg: subStats.running > 0 ? T.WARN : T.FAINT, bg: T.STATUSBG, bold: subStats.running > 0,
4891
+ });
4892
+ row2.left.push({
4893
+ t: ` ${subStats.completed}已完成 `,
4894
+ fg: subStats.completed > 0 ? T.OK : T.FAINT, bg: T.STATUSBG,
4895
+ });
4896
+ // Ctrl+J belongs beside the two activity summaries it opens, not alone
4897
+ // at the far-right edge (especially once the queue badge also appears).
4898
+ row2.left.push({ t: " Ctrl+J 任务/子代理 ", fg: T.DIM, bg: T.STATUSBG });
4899
+ if (sub) row2.left.push({
4900
+ t: ` ◇ ${truncate(sub.label ?? sub.mode ?? "子代理", 20)} `,
4901
+ fg: subStats.running > 0 ? T.WARN : subStats.completed > 0 ? T.OK : T.FAINT, bg: T.STATUSBG,
4137
4902
  });
4138
- row2.left.push({ t: ` ${done}已完成${failed > 0 ? ` ${failed}失败` : ""} `, fg: done > 0 ? T.OK : T.FAINT, bg: T.STATUSBG });
4139
- row2.left.push({ t: ` ${subStats.running > 0 ? `${subStats.running} 个子代理运行中` : "没有子代理运行"} ${subStats.completed}已完成 `, fg: subStats.running > 0 ? T.PURPLE : T.FAINT, bg: T.STATUSBG, bold: subStats.running > 0 });
4140
- if (sub) row2.left.push({ t: ` 🛰 ${truncate(sub.label ?? sub.mode ?? "子代理", 20)} `, fg: T.PURPLE, bg: T.STATUSBG });
4141
- if(this.queueItems.length)row2.left.push({t:` 有${this.queueItems.length}条命令正在排队 Ctrl+N查看详情 `,fg:T.SELFG,bg:T.WARN,bold:true});
4142
- row2.right.push({ t: " Ctrl+J 任务/子代理 ", fg: T.DIM, bg: T.STATUSBG });
4903
+ if(this.queueItems.length)row2.left.push({t:` 有${this.queueItems.length}条命令正在排队 Ctrl+N查看详情 `,fg:0x000000,bg:T.WARN,bold:true});
4143
4904
  rows.push(row2);
4144
4905
  }
4145
4906
  this.status.rows = rows;
@@ -4148,15 +4909,7 @@ export class App {
4148
4909
  if (this.popup) this.popup.render(s);
4149
4910
  if (this.menu) this.menu.render(s);
4150
4911
  if (this.overlay) this.overlay.render(s);
4151
- if (this.toastMsg) {
4152
- // Toasts land in the LOWER half (just above the input/footer) where the
4153
- // user's attention is while pressing shortcuts — a solid color block.
4154
- const w = Math.min(s.w - 4, strWidth(this.toastMsg) + 6);
4155
- const x0 = Math.max(2, Math.floor((s.w - w) / 2));
4156
- const y = Math.max(1, this.chat.input.y - this.chat.todoHeight() - 2);
4157
- s.fillRect(x0, y, x0 + w - 1, y, " ", { bg: T.ACCENT });
4158
- s.text(x0 + 1, y, truncate(this.toastMsg, w - 2), { fg: T.SELFG, bg: T.ACCENT, attrs: 1 });
4159
- }
4912
+ this.#renderToast(s);
4160
4913
  if (this.renameInput && this.popup) this.renameInput.render(s);
4161
4914
 
4162
4915
  const out = s.render();
@@ -4174,6 +4927,17 @@ export class App {
4174
4927
  this.term.output.write(out + tail);
4175
4928
  }
4176
4929
 
4930
+ #renderToast(s) {
4931
+ if (!this.toastMsg) return;
4932
+ // Toasts land in the LOWER half (just above the input/footer) where the
4933
+ // user's attention is while pressing shortcuts — a solid color block.
4934
+ const w = Math.min(s.w - 4, strWidth(this.toastMsg) + 6);
4935
+ const x0 = Math.max(2, Math.floor((s.w - w) / 2));
4936
+ const y = Math.max(1, this.chat.input.y - this.chat.todoHeight() - 2);
4937
+ s.fillRect(x0, y, x0 + w - 1, y, " ", { bg: T.ACCENT });
4938
+ s.text(x0 + 1, y, truncate(this.toastMsg, w - 2), { fg: T.SELFG, bg: T.ACCENT, attrs: 1 });
4939
+ }
4940
+
4177
4941
  titleOf() {
4178
4942
  const s = this.sessions.find((x) => x.sessionId === this.currentSession);
4179
4943
  if (s) return s.projections?.values?.title ?? s.sessionId.slice(0, 8);