dsh-neotui 0.2.3 → 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,14 +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
8
  import { createRequire } from "node:module";
9
- import { Widget, List, ScrollView, Input, Popup, Menu, StatusBar } from "./widgets.js";
9
+ import { Widget, ScrollView, Input, Popup, Menu, StatusBar, wrapIndex } from "./widgets.js";
10
10
  import { UploadPicker } from "./file-picker.js";
11
- 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";
12
13
  export { userPrefix, saveTuiConfig, loadTuiConfig, userName, busyEnter, foldDefaults } from "./config.js";
13
14
  import {
14
15
  Picker, buildCommandPalette, buildModelPicker, buildModePicker, buildPermissionPicker,
@@ -108,9 +109,14 @@ function renderToolCard(view, width, expanded) {
108
109
  const rows = output.split("\n");
109
110
  const cap = expanded ? 200 : 8;
110
111
  for (const row of rows.slice(0, cap)) lines.push([{ t: " " + truncate(row, width - 4), fg: K.TXT, code: true }]);
111
- if (rows.length > cap) lines.push([{ t: ` …隐藏 ${rows.length - cap} 行(点击展开)`, fg: K.FAINT }]);
112
- if (card.signal) lines.push([{ t: ` signal ${card.signal}`, fg: K.ERR, bold: true }]);
113
- 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 }]);
114
120
  else if (card.running) lines.push([{ t: " ● 运行中", fg: K.WARN }]);
115
121
  break;
116
122
  }
@@ -483,7 +489,25 @@ function applyEvent(nodes, event, view, log, state = null) {
483
489
  export function nodeForEvents(events, log) {
484
490
  const nodes = [];
485
491
  const state = { step: null };
486
- 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
+ }
487
511
  return nodes;
488
512
  }
489
513
 
@@ -767,6 +791,8 @@ class SidebarTree extends Widget {
767
791
  this.groups = groups;
768
792
  this.#flatten();
769
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();
770
796
  this.app.redraw();
771
797
  }
772
798
  #flatten() {
@@ -783,17 +809,21 @@ class SidebarTree extends Widget {
783
809
  if (this.collapsed.has(group.key)) this.collapsed.delete(group.key);
784
810
  else this.collapsed.add(group.key);
785
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();
786
815
  }
787
- collapseAll() { for (const g of this.groups) this.collapsed.add(g.key); this.#flatten(); }
788
- 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(); }
789
818
  #rowTitle(sess) {
790
819
  return sess.projections?.values?.title ?? (sess.blank ? "(空白会话)" : sess.sessionId.slice(0, 8));
791
820
  }
792
821
  render(screen) {
793
822
  screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", {});
794
823
  const w = this.w - 1;
795
- // header: workspace title
796
- 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 });
797
827
  screen.hline(this.x, this.x + w, this.y + 1, "─", { fg: T.BORDER });
798
828
  const listTop = this.y + 2;
799
829
  for (let i = 0; i < this.h - 2; i++) {
@@ -849,13 +879,37 @@ class SidebarTree extends Widget {
849
879
  }
850
880
  move(delta) {
851
881
  if (this.rows.length === 0) return false;
852
- const next = Math.max(0, Math.min(this.rows.length - 1, this.sel + delta));
853
- if (next === this.sel) return false;
882
+ const next = wrapIndex(this.sel + delta, this.rows.length);
854
883
  this.sel = next;
855
884
  this.#scrollToSel();
856
885
  return true;
857
886
  }
858
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
+ }
859
913
  onMouse(ev) {
860
914
  if (ev.kind === "wheel-up") { this.scroll(-3); return true; }
861
915
  if (ev.kind === "wheel-down") { this.scroll(3); return true; }
@@ -875,30 +929,10 @@ class SidebarTree extends Widget {
875
929
  if (ev.kind === "press" && ev.button === 2) {
876
930
  const idx = this.scrollY + (ev.y - this.y - 2);
877
931
  const row = this.rows[idx];
878
- if (!row) {
879
- // right-click on empty sidebar space → workspace-level actions
880
- this.app.openMenu([
881
- { label: "新建工作区…", action: () => this.app.addWorkspace() },
882
- { label: "新建会话", action: () => this.app.newSessionIn(null) },
883
- ], ev);
884
- return true;
885
- }
932
+ if (!row) { this.app.openMenu(this.#menuFor(null), ev); return true; }
886
933
  this.sel = idx;
887
- if (row.kind === "group") {
888
- const items = [
889
- { label: "新建会话", action: () => this.app.newSessionIn(row.group) },
890
- { label: "新建工作区…", action: () => this.app.addWorkspace() },
891
- { label: "折叠全部", action: () => { this.collapseAll(); this.app.redraw(); } },
892
- { label: "展开全部", action: () => { this.expandAll(); this.app.redraw(); } },
893
- ];
894
- if (row.group.workspaceId) {
895
- items.push({ label: "重命名工作区", action: () => this.app.renameWorkspace(row.group) });
896
- items.push({ label: "删除工作区…", action: () => this.app.deleteWorkspace(row.group) });
897
- }
898
- this.app.openMenu(items, ev);
899
- } else {
900
- this.app.sessionMenu({ data: row.session }, ev);
901
- }
934
+ if (row.kind === "session") this.app.sessionMenu({ data: row.session }, ev);
935
+ else this.app.openMenu(this.#menuFor(row), ev);
902
936
  return true;
903
937
  }
904
938
  return false;
@@ -911,28 +945,45 @@ class SidebarTree extends Widget {
911
945
  case "pgup": this.scroll(-this.h); return true;
912
946
  case "pgdn": this.scroll(this.h); return true;
913
947
  case "home": this.sel = 0; this.#scrollToSel(); return true;
914
- 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;
915
949
  case "enter": {
916
950
  const row = this.currentRow();
917
951
  if (!row) return false;
918
952
  if (row.kind === "group") { this.toggle(row.group); this.app.redraw(); }
919
- else this.app.openSession(row.session.sessionId);
953
+ else this.app.openSession(row.session.sessionId); // pane focus intentionally stays here
920
954
  return true;
921
955
  }
922
956
  case "left": {
957
+ if (ev.ctrl) return false;
923
958
  const row = this.currentRow();
924
959
  if (row?.kind === "group" && !this.collapsed.has(row.group.key)) { this.toggle(row.group); this.app.redraw(); return true; }
925
960
  if (row?.kind === "session") { this.sel = this.rows.findLastIndex((r, i) => i <= this.sel && r.kind === "group"); this.#scrollToSel(); return true; }
926
961
  return false;
927
962
  }
928
963
  case "right": {
964
+ if (ev.ctrl) return false;
929
965
  const row = this.currentRow();
930
966
  if (row?.kind === "group" && this.collapsed.has(row.group.key)) { this.toggle(row.group); this.app.redraw(); return true; }
931
967
  if (row?.kind === "group") { this.sel = Math.min(this.rows.length - 1, this.sel + 1); this.#scrollToSel(); return true; }
932
968
  return false;
933
969
  }
934
970
  case "char":
935
- 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
+ }
936
987
  if (!ev.ctrl && (ev.key === "[" || ev.key === "]")) {
937
988
  const r = this.currentRow();
938
989
  if (r?.kind === "session") { this.app.moveSession(r.session, ev.key === "[" ? -1 : 1); return true; }
@@ -969,7 +1020,6 @@ export class ChatView extends Widget {
969
1020
  this.nodes = [];
970
1021
  this.lines = [];
971
1022
  this.expanded = new Set(); // node indexes (user-message full text)
972
- this.expandedTools = new Set();
973
1023
  this.collapsedBlocks = new Set(); // per-block COLLAPSE (default expanded): `${realIdx}:${bi}`
974
1024
  const fd = foldDefaults();
975
1025
  this.thinkMode = fd.think ? "expanded" : "collapsed"; // t toggles
@@ -998,6 +1048,8 @@ export class ChatView extends Widget {
998
1048
  this.cache = new Map(); // node render cache: key → { lines, marks }
999
1049
  this.cardRanges = []; // absolute line ranges of card-backed message blocks
1000
1050
  this.welcomeModes = []; // absolute row y → agent preset id (welcome screen)
1051
+ this.welcomeModeIds = ["standard", "code", "minimal", "cordis"];
1052
+ this.welcomeModeSel = 0;
1001
1053
  this.pressY = null;
1002
1054
  this.pressInfo = null; // hit identity locked at press time
1003
1055
  this.pressCtx = null;
@@ -1006,7 +1058,14 @@ export class ChatView extends Widget {
1006
1058
  this.selEnd = null;
1007
1059
  this.selAnchor = null;
1008
1060
  this.selFocus = null;
1009
- 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})
1010
1069
  this.clipboardImages = [];
1011
1070
  this.attachments = [];
1012
1071
  this.stepState = { step: null }; // step/start tracking for the mux merge path
@@ -1146,6 +1205,8 @@ export class ChatView extends Widget {
1146
1205
  for (let li = 0; li < this.lineMap.length; li++) {
1147
1206
  if (this.lineMap[li]?.nodeIdx === idx) {
1148
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 }; }
1149
1210
  this.app.redraw();
1150
1211
  return true;
1151
1212
  }
@@ -1199,18 +1260,21 @@ export class ChatView extends Widget {
1199
1260
  this.#rebuild();
1200
1261
  }
1201
1262
 
1202
- async open(sessionId, epoch = this.app.sessionEpoch) {
1263
+ async open(sessionId, epoch = this.app.sessionEpoch, maxMessages = 80) {
1203
1264
  this.sessionId = sessionId;
1204
1265
  this.nodes = [];
1266
+ this.welcomeModeSel = 0;
1267
+ this.blockSel = -1;
1268
+ this.cursorMode = "block";
1269
+ this.visualAnchor = null;
1205
1270
  this.expanded.clear();
1206
- this.expandedTools.clear();
1207
1271
  this.collapsedBlocks.clear();
1208
1272
  this.hasMore = false;
1209
1273
  this.minSeq = null;
1210
1274
  this.cache.clear();
1211
1275
  this.app.setStatus(`加载会话 ${sessionId.slice(0, 8)}…`);
1212
1276
  try {
1213
- const hist = await this.app.api.call("session.history", { sessionId });
1277
+ const hist = await this.app.api.call("session.history", { sessionId, maxMessages });
1214
1278
  if (this.sessionId !== sessionId || this.app.sessionEpoch !== epoch) return;
1215
1279
  this.minSeq = hist.events[0]?.event?.seq ?? null;
1216
1280
  this.lastSeq = hist.events[hist.events.length - 1]?.event?.seq ?? null;
@@ -1234,7 +1298,7 @@ export class ChatView extends Widget {
1234
1298
  this.view.follow = true;
1235
1299
  }
1236
1300
 
1237
- async loadOlder(onDone = null) {
1301
+ async loadOlder(onDone = null, maxMessages = 20) {
1238
1302
  if (!this.hasMore || this.loadingOlder || this.minSeq == null) { if (!this.hasMore) this.app.toast("已加载到会话开头"); if (onDone) queueMicrotask(onDone); return; }
1239
1303
  const sessionId = this.sessionId;
1240
1304
  const epoch = this.app.sessionEpoch;
@@ -1243,7 +1307,7 @@ export class ChatView extends Widget {
1243
1307
  const oldLength = this.lines.length;
1244
1308
  this.app.setStatus("加载更早记录…");
1245
1309
  try {
1246
- 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 });
1247
1311
  if (this.sessionId !== sessionId || this.app.sessionEpoch !== epoch) { this.loadingOlder = false; return; }
1248
1312
  if (hist.events.length === 0) { this.hasMore = false; }
1249
1313
  else {
@@ -1254,6 +1318,24 @@ export class ChatView extends Widget {
1254
1318
  this.hasMore = hist.hasMore && this.minSeq < previousMinSeq;
1255
1319
  this.#noteEarliest(hist.events);
1256
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
+ }
1257
1339
  this.nodes = [...more, ...this.nodes];
1258
1340
  }
1259
1341
  } catch (e) {
@@ -1360,7 +1442,7 @@ export class ChatView extends Widget {
1360
1442
  const trimmed = text.trim();
1361
1443
  if (trimmed === "/reload") { this.app.softReload(); return; }
1362
1444
  if (trimmed === "/restart") { this.app.restartApp(); return; }
1363
- if (trimmed === "/model") { this.app.showModePicker(); return; }
1445
+ if (trimmed === "/model") { this.app.overlay = buildModelPicker(this.app); this.app.redraw(); return; }
1364
1446
  if (trimmed === "/theme") { cycleTheme(); this.queueRebuild(); this.app.toast(`主题已切换: ${themeName()}`); return; }
1365
1447
  if (trimmed === "/permission") { this.app.showPermissionPicker(); return; }
1366
1448
  if (trimmed === "/goal") { this.app.showGoal(); return; }
@@ -1601,7 +1683,11 @@ export class ChatView extends Widget {
1601
1683
  return true;
1602
1684
  }
1603
1685
  }
1604
- 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)) {
1605
1691
  if (this.expanded.has(info.nodeIdx)) this.expanded.delete(info.nodeIdx);
1606
1692
  else this.expanded.add(info.nodeIdx);
1607
1693
  this.#rebuild();
@@ -1612,6 +1698,8 @@ export class ChatView extends Widget {
1612
1698
  }
1613
1699
 
1614
1700
  #rebuild() {
1701
+ const oldBlock = this.blockItems?.[this.blockSel] ?? null;
1702
+ const oldIdentity = oldBlock ? `${oldBlock.nodeKey}:${oldBlock.blockIdx ?? "n"}:${oldBlock.kind}:${oldBlock.codeIndex ?? "-"}` : null;
1615
1703
  const w = Math.max(20, this.view.w - 2);
1616
1704
  const lines = [];
1617
1705
  const lineMap = [];
@@ -1701,7 +1789,7 @@ export class ChatView extends Widget {
1701
1789
  const text = node.text ?? "";
1702
1790
  const summary = node.source?.summary;
1703
1791
  const label = node.kind === "goal-round" ? `🎯 目标续轮 ${node.source?.round ?? ""}`
1704
- : node.kind === "subagent-receipt" ? (node.source?.kind === "subagent-settled" ? "🛰 子代理状态" : "🛰 子代理回执")
1792
+ : node.kind === "subagent-receipt" ? (node.source?.kind === "subagent-settled" ? "◇ 子代理状态" : "◇ 子代理回执")
1705
1793
  : `ℹ 上下文 · ${node.source?.kind ?? "注入"}`;
1706
1794
  beginCard(node.kind === "goal-round" ? "THINKBG" : "CARD");
1707
1795
  lines.push([{ t: ` ${label}${summary ? ` — ${truncate(summary, w - strWidth(label) - 8)}` : ""}`, fg: node.kind === "subagent-receipt" ? T.PURPLE : K.DIM, bold: true }]);
@@ -1811,8 +1899,12 @@ export class ChatView extends Widget {
1811
1899
  // failed exit code.
1812
1900
  const stopped = !running && (b.stopped || signal === "SIGTERM" || signal === "SIGINT");
1813
1901
  const failed = !orphan && !stopped && (b.isError || signal || (exitCode !== undefined && exitCode !== 0));
1814
- const status = running ? "TOOLBG" : failed ? "TOOLERR" : stopped ? "TOOLBG" : "TOOLOK";
1815
- 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 ? "◌" : "✓";
1816
1908
  const card = cardView ? renderToolCard(cardView, w, open) : [];
1817
1909
  beginCard(status);
1818
1910
  let timing = "";
@@ -1829,7 +1921,7 @@ export class ChatView extends Widget {
1829
1921
  lines.push([
1830
1922
  { t: open ? "▾ " : "▸ ", fg: K.ACCENT },
1831
1923
  { t: ` ${b.name ?? "tool"}`, fg: K.TXT, bold: true },
1832
- { t: ` ${glyph}`, fg: failed ? K.ERR : status === "TOOLOK" ? K.OK : K.WARN },
1924
+ { t: ` ${glyph}`, fg: failed ? K.WARN : running ? K.DIM : K.OK },
1833
1925
  { t: stepTag + timing, fg: K.DIM },
1834
1926
  { t: open ? " [b 折叠]" : " [b 展开]", fg: K.FAINT },
1835
1927
  ]);
@@ -1938,7 +2030,10 @@ export class ChatView extends Widget {
1938
2030
  mark(realIdx, bi);
1939
2031
  sep();
1940
2032
  } else {
1941
- 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");
1942
2037
  // FORMAL text output is NOT collapsible — the user's message
1943
2038
  // content must stay readable; only think/tool blocks fold.
1944
2039
  // A neutral assistant glyph identifies model output without
@@ -2023,12 +2118,200 @@ export class ChatView extends Widget {
2023
2118
  }
2024
2119
  this.lines = lines;
2025
2120
  this.lineMap = lineMap;
2121
+ this.#rebuildBlockItems(oldIdentity);
2026
2122
  if (process.env.DSH_TUI_DEBUG_CLICK && lineMap.length !== lines.length) {
2027
2123
  this.#clickLog(`INVARIANT BROKEN: lines=${lines.length} lineMap=${lineMap.length}`);
2028
2124
  }
2029
2125
  this.view.setLines(lines);
2030
2126
  }
2031
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
+
2032
2315
  /** Blank session: whale logo + mode selection prompt (no conversation yet). */
2033
2316
  #renderWelcome(screen) {
2034
2317
  const x = this.view.x;
@@ -2065,12 +2348,16 @@ export class ChatView extends Widget {
2065
2348
  ["minimal", "极简模式", "仅持久 bash 与 str_replace_editor 双工具"],
2066
2349
  ["cordis", "创造模式", "标准模式 + 运行时检查/插件实验/预设创作"],
2067
2350
  ];
2068
- 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];
2069
2355
  if (y < this.view.y + this.view.h) {
2070
- const selected = id === currentPreset;
2071
- const label = `${selected ? "●" : "○"} ${name}${selected ? " [当前]" : ""}`;
2072
- screen.text(cx, y, ` ${label}`, { fg: selected ? T.OK : T.ACCENT, bg: selected ? T.MENUSEL : -1, attrs: 1 });
2073
- screen.text(cx + 2 + strWidth(label) + 1, y, truncate(desc, Math.max(1, this.view.w - strWidth(label) - 8)), { fg: selected ? T.TXT : T.DIM, bg: selected ? T.MENUSEL : -1 });
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 });
2074
2361
  this.welcomeModes[y] = id;
2075
2362
  }
2076
2363
  y++;
@@ -2098,7 +2385,38 @@ export class ChatView extends Widget {
2098
2385
  }
2099
2386
  }
2100
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
+ }
2101
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
+ }
2102
2420
  this.#renderDiving(screen);
2103
2421
  if (this.selStart !== null && this.selEnd !== null) {
2104
2422
  const y0 = Math.max(this.view.scrollY, this.selStart);
@@ -2106,7 +2424,7 @@ export class ChatView extends Widget {
2106
2424
  if (y1 >= y0) {
2107
2425
  for (let line = y0; line <= y1; line++) {
2108
2426
  let x0 = this.view.x, x1 = this.view.x + this.view.w - 2;
2109
- if (this.selectionMode === "character" && this.selAnchor && this.selFocus) {
2427
+ if (this.selAnchor && this.selFocus) {
2110
2428
  const a = this.selAnchor, b = this.selFocus;
2111
2429
  const first = a.line < b.line || (a.line === b.line && a.col <= b.col) ? a : b;
2112
2430
  const last = first === a ? b : a;
@@ -2170,7 +2488,7 @@ export class ChatView extends Widget {
2170
2488
  if (subagent) {
2171
2489
  const timing = this.app.projections.subagentTiming;
2172
2490
  const ms = (timing?.settledMs ?? 0) + (timing?.active ? Math.max(0, Date.now() - timing.active.since) : 0);
2173
- 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 });
2174
2492
  }
2175
2493
  if (!todos.length) return;
2176
2494
  const done = todos.filter((t) => t.status === "completed").length;
@@ -2260,7 +2578,7 @@ export class ChatView extends Widget {
2260
2578
  this.pressInfo = null; this.pressCtx = null;
2261
2579
  const rows = this.selEnd - this.selStart + 1;
2262
2580
  let text;
2263
- if (this.selectionMode === "character" && this.selAnchor && this.selFocus) {
2581
+ if (this.selAnchor && this.selFocus) {
2264
2582
  const a = this.selAnchor, b = this.selFocus;
2265
2583
  const first = a.line < b.line || (a.line === b.line && a.col <= b.col) ? a : b;
2266
2584
  const last = first === a ? b : a;
@@ -2311,70 +2629,116 @@ export class ChatView extends Widget {
2311
2629
  return false;
2312
2630
  }
2313
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
+
2314
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
+ }
2315
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;
2316
2667
  this.app.focus(this.input);
2317
2668
  this.input.insert(ev.text);
2318
2669
  return true;
2319
2670
  }
2320
2671
  if (ev.type !== "key") return false;
2321
2672
  if (this.app.focused === this.input) return false;
2322
- if (ev.name !== "char" || ev.key !== "g") this.gKey = false;
2323
- switch (ev.name) {
2324
- case "up": return this.view.scroll(-3);
2325
- case "down": return this.view.scroll(3);
2326
- case "pgup": {
2327
- if (this.view.scrollY <= this.view.h) { void this.loadOlder(); return true; }
2328
- return this.view.scroll(-this.view.h);
2329
- }
2330
- case "pgdn": return this.view.scroll(this.view.h);
2331
- }
2332
- if (ev.name === "char" && ev.key === "g" && !ev.ctrl && !ev.alt && !ev.shift) {
2333
- if (this.gKey) { this.gKey = false; this.view.anchorLock = null; this.view.follow = false; this.view.scrollY = 0; return true; }
2334
- this.gKey = true;
2335
- this.app.toast("再按 g 回顶");
2336
- return true;
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;
2337
2682
  }
2338
- 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; }
2339
- // [ / ] — jump to the end of the previous / next user question. Guarded by
2340
- // focus so the sidebar's session-move [ ] keys keep working there.
2341
- if (ev.name === "char" && (ev.key === "[" || ev.key === "]") && !ev.ctrl && !ev.shift && this.app.focused === this) {
2342
- 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;
2343
2693
  }
2344
- if (ev.name === "escape" && this.app.searchQuery) { this.app.searchQuery = null; this.queueRebuild(); return true; }
2345
- if (ev.name === "escape" && this.selStart !== null) { this.selStart = this.selEnd = null; this.selAnchor = this.selFocus = null; this.app.redraw(); return true; }
2346
- 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; }
2347
- if (ev.name === "char" && ev.key === "i" && !ev.ctrl && !ev.alt) { this.app.focus(this.input); return true; }
2348
- if (ev.name === "char" && ev.key === "/" && !ev.ctrl && !ev.alt) { this.app.startSearch(); return true; }
2349
- if (ev.name === "char" && ev.key === "b" && !ev.ctrl && !ev.alt) {
2350
- 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";
2351
2699
  this.expanded.clear();
2352
2700
  this.collapsedBlocks.clear();
2353
- this.app.toast(this.bashMode === "collapsed" ? "工具块:折叠(b 展开)" : "工具块:展开(b 折叠)");
2701
+ this.app.toast(this.thinkMode === "expanded" ? "思考块:全部展开" : "思考块:折叠(t 切换)");
2354
2702
  this.queueRebuild();
2355
2703
  return true;
2356
2704
  }
2357
- if (ev.name === "char" && ev.key === "t" && !ev.ctrl && !ev.alt) {
2358
- if (ev.shift) {
2359
- const wasPinned = this.view.follow || this.view.scrollY >= this.view.maxScroll();
2360
- const oldMax = this.view.maxScroll();
2361
- this.todosVisible = !this.todosVisible;
2362
- this.app.toast(this.todosVisible ? "任务块:已展开(Shift+T 最小化)" : "任务块:已最小化(Shift+T 展开)");
2363
- this.inputChanged();
2364
- // Re-anchor immediately: tail readers stay at the tail; readers higher
2365
- // in history keep the same visible transcript row without a manual nudge.
2366
- if (wasPinned) { this.view.scrollY = this.view.maxScroll(); this.view.follow = true; }
2367
- else this.view.scrollY = Math.max(0, Math.min(this.view.maxScroll(), this.view.scrollY + (this.view.maxScroll() - oldMax)));
2368
- this.app.redraw();
2369
- return true;
2370
- }
2371
- this.thinkMode = this.thinkMode === "collapsed" ? "expanded" : "collapsed";
2705
+ if (chatHit?.id === "tools" && this.cursorMode === "block") {
2706
+ this.bashMode = this.bashMode === "collapsed" ? "expanded" : "collapsed";
2372
2707
  this.expanded.clear();
2373
2708
  this.collapsedBlocks.clear();
2374
- this.app.toast(this.thinkMode === "expanded" ? "思考块:全部展开" : "思考块:折叠(t 切换)");
2709
+ this.app.toast(this.bashMode === "collapsed" ? "工具块:折叠(b 展开)" : "工具块:展开(b 折叠)");
2375
2710
  this.queueRebuild();
2376
2711
  return true;
2377
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();
2378
2742
  return false;
2379
2743
  }
2380
2744
  }
@@ -2574,8 +2938,8 @@ export class QuestionPopup extends Popup {
2574
2938
  if(this.customEditing&&ev.name==="right"){this.customCursor=Math.min(Array.from(draft.custom).length,this.customCursor+1);return true;}
2575
2939
  if(this.customEditing&&ev.name==="home"){this.customCursor=0;return true;}
2576
2940
  if(this.customEditing&&ev.name==="end"){this.customCursor=Array.from(draft.custom).length;return true;}
2577
- if (ev.name === "up") { this.customEditing=false;this.selIdx = Math.max(0, this.selIdx - 1); return true; }
2578
- 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; }
2579
2943
  if (ev.name === "char" && ev.key === " " && count && this.selIdx<count) { this.#choose(this.selIdx); return true; }
2580
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;}
2581
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;}
@@ -2695,7 +3059,6 @@ export class App {
2695
3059
  this.sessionEpoch = 0;
2696
3060
  this.refreshSessionsSeq = 0;
2697
3061
  this.searchSeq = 0;
2698
- this.searchSelected = 0;
2699
3062
  this.connState = "connecting";
2700
3063
  this.tokenUsage = null;
2701
3064
  this.sessions = [];
@@ -2707,6 +3070,7 @@ export class App {
2707
3070
  };
2708
3071
  this.searchActive = false;
2709
3072
  this.overlay = null; // Picker / Popup / ImagePopup modal
3073
+ this.fullBuffer = null; // full-screen panel buffer (workspace/settings/models/subagent/skills)
2710
3074
  this.mode = "chat"; // chat | workspace | trajectory
2711
3075
  this.sidebarWanted = true;
2712
3076
  this.sidebarVisible = true; // auto-collapses on narrow terminals
@@ -2729,7 +3093,8 @@ export class App {
2729
3093
  this.sidebar = new SidebarTree(this);
2730
3094
  this.sidebar.w = this.sidebarWidth;
2731
3095
  this.sidebar.h = screen.h - 1;
2732
- 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;
2733
3098
  this.chat = new ChatView({ x: this.sidebarWidth, y: 0, w: screen.w - this.sidebarWidth, h: screen.h - 1, app: this });
2734
3099
  this.status = new StatusBar({ x: 0, y: screen.h - 1, w: screen.w, h: 1 });
2735
3100
  this.focus(this.chat);
@@ -2753,9 +3118,8 @@ export class App {
2753
3118
  this.sidebar.x = 0; this.sidebar.y = 0; this.sidebar.w = this.sidebarWidth; this.sidebar.h = this.screen.h - 1;
2754
3119
  this.searchInput.w = this.sidebarWidth;
2755
3120
  this.chat.resize(x, 1, w, mainH);
2756
- for (const p of [this.workspacePanel, this.trajectoryPanel, this.settingsPanel, this.modelPanel, this.subagentPanel, this.skillsPanel]) {
2757
- if (p?.relayout) p.relayout(x, 1, w, mainH);
2758
- }
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);
2759
3123
  this.status.y = this.screen.h - footerH;
2760
3124
  this.status.h = footerH;
2761
3125
  this.status.w = this.screen.w;
@@ -2772,6 +3136,27 @@ export class App {
2772
3136
  this.setMode(this.mode === "trajectory" ? "chat" : "trajectory");
2773
3137
  }
2774
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
+
2775
3160
  async checkUpdates(target = null, notify = false) {
2776
3161
  const specs = {
2777
3162
  dsh: { package: "@deepseek-ai/dsh", current: this.dshVersion, label: "DeepSeek Harness" },
@@ -3342,6 +3727,7 @@ export class App {
3342
3727
  ? { workspaceId: group.workspaceId }
3343
3728
  : { cwd: group?.path ?? process.cwd() };
3344
3729
  const { sessionId } = await this.api.call("session.create", payload);
3730
+ if (typeof sessionId !== "string" || !sessionId) throw new Error("Host 未返回会话 ID");
3345
3731
  await this.refreshSessions();
3346
3732
  this.openSession(sessionId);
3347
3733
  } catch (e) { this.toast(`创建失败: ${e.message}`); }
@@ -3355,6 +3741,7 @@ export class App {
3355
3741
  async newSession() { return this.newSessionIn(null); }
3356
3742
 
3357
3743
  async openSession(sessionId) {
3744
+ if (typeof sessionId !== "string" || !sessionId) { this.toast("无法打开会话:缺少会话 ID"); return; }
3358
3745
  const epoch = ++this.sessionEpoch;
3359
3746
  this.currentSession = sessionId;
3360
3747
  this.projections = { ...(this.projectionsBySession.get(sessionId) ?? {}) };
@@ -3374,6 +3761,12 @@ export class App {
3374
3761
  }
3375
3762
  await this.chat.open(sessionId, epoch);
3376
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;
3377
3770
  this.loadFeedback(sessionId, epoch);
3378
3771
  this.updateModel(sessionId, epoch);
3379
3772
  this.refreshSubagentStats(sessionId);
@@ -3412,47 +3805,50 @@ export class App {
3412
3805
  }
3413
3806
 
3414
3807
  setMode(mode) {
3415
- this.mode = mode;
3416
- if (mode === "workspace") {
3417
- if (!this.workspacePanel) {
3418
- this.workspacePanel = new WorkspacePanel(this);
3419
- this.workspacePanel.load();
3420
- } else {
3421
- this.workspacePanel.load();
3422
- }
3423
- } else if (mode === "trajectory") {
3808
+ this.mode = mode === "trajectory" ? "trajectory" : "chat";
3809
+ if (this.mode === "trajectory") {
3424
3810
  if (!this.currentSession) { this.toast("先打开一个会话"); this.mode = "chat"; this.redraw(); return; }
3425
3811
  if (!this.trajectoryPanel) this.trajectoryPanel = new TrajectoryPanel(this);
3426
3812
  this.trajectoryPanel.load(this.currentSession);
3427
- } else if (mode === "settings") {
3428
- if (!this.settingsPanel) this.settingsPanel = new SettingsPanel(this);
3429
- this.settingsPanel.load();
3430
- } else if (mode === "models") {
3431
- if (!this.modelPanel) this.modelPanel = new ModelPanel(this);
3432
- this.modelPanel.load();
3433
- } else if (mode === "subagent") {
3434
- if (!this.currentSession) { this.toast("先打开一个会话"); this.mode = "chat"; this.redraw(); return; }
3435
- if (!this.subagentPanel) this.subagentPanel = new SubagentPanel(this);
3436
- this.subagentPanel.load(this.currentSession);
3437
- } else if (mode === "skills") {
3438
- if (!this.currentSession) { this.toast("先打开一个会话"); this.mode = "chat"; this.redraw(); return; }
3439
- if (!this.skillsPanel) this.skillsPanel = new SkillsPanel(this);
3440
- this.skillsPanel.load();
3441
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);
3442
3817
  this.layout();
3443
3818
  this.redraw();
3444
3819
  }
3445
3820
 
3446
- panelForMode() {
3447
- switch (this.mode) {
3448
- case "workspace": return this.workspacePanel;
3449
- case "trajectory": return this.trajectoryPanel;
3450
- case "settings": return this.settingsPanel;
3451
- case "models": return this.modelPanel;
3452
- case "subagent": return this.subagentPanel;
3453
- case "skills": return this.skillsPanel;
3454
- default: return null;
3455
- }
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();
3456
3852
  }
3457
3853
 
3458
3854
  closeOverlay() { this.overlay = null; this.redraw(); }
@@ -3514,7 +3910,6 @@ export class App {
3514
3910
  this.chat.nodes = [];
3515
3911
  this.chat.collapsedBlocks.clear();
3516
3912
  this.chat.expanded.clear();
3517
- this.chat.expandedTools.clear();
3518
3913
  this.chat.queueRebuild();
3519
3914
  this.chat.view.anchorLock = null;
3520
3915
  this.mode = "chat";
@@ -3542,7 +3937,7 @@ export class App {
3542
3937
  const argv = process.argv.slice(1);
3543
3938
  // hand the CURRENT session to the new instance so it reopens it instead
3544
3939
  // of minting a fresh blank session (the "strange new session" in 未分组)
3545
- 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" };
3546
3941
  const child = spawn("sh", ["-c", 'sleep 1; exec "$@"', "sh", ...argv], { detached: true, stdio: "inherit", env });
3547
3942
  child.unref();
3548
3943
  } catch (e) {
@@ -3654,7 +4049,7 @@ export class App {
3654
4049
  } catch (e) { this.toast(`权限切换失败: ${e.message}`); }
3655
4050
  }
3656
4051
 
3657
- /** Shift+Tab/F8: cycle read-only → workspace-write → danger-full-access. */
4052
+ /** F8: cycle read-only → workspace-write → danger-full-access. */
3658
4053
  rotatePermission() {
3659
4054
  const order = ["read-only", "workspace-write", "danger-full-access"];
3660
4055
  const cur = this.projections.permissions?.currentValue;
@@ -3663,6 +4058,58 @@ export class App {
3663
4058
  this.switchPermission(next);
3664
4059
  }
3665
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
+
3666
4113
  showFilePicker() {
3667
4114
  const session = this.sessions.find((s) => s.sessionId === this.currentSession);
3668
4115
  this.overlay = new UploadPicker(this, { startPath: session?.cwd ?? process.cwd(), onUpload: (files) => {
@@ -3689,27 +4136,22 @@ export class App {
3689
4136
  }
3690
4137
 
3691
4138
  #modeTabs() {
3692
- // 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.
3693
4140
  return [
3694
4141
  ["chat", "对话"],
3695
4142
  ["trajectory", "轨迹"],
3696
4143
  ];
3697
4144
  }
3698
4145
 
3699
- #panelLabel(mode) {
3700
- return { workspace: "工作区", settings: "设置", models: "模型供应商", skills: "技能", subagent: "子代理" }[mode] ?? null;
3701
- }
3702
-
3703
4146
  #renderTabBar(s) {
3704
4147
  const x = this.sidebarVisible ? this.sidebarWidth : 0;
3705
4148
  const w = this.screen.w - x;
3706
4149
  s.fillRect(x, 0, x + w - 1, 0, " ", { bg: T.PANEL });
3707
4150
  const tabs = [...this.#modeTabs()];
3708
- const panelLabel = this.#panelLabel(this.mode);
3709
- if (panelLabel) tabs.push([this.mode, panelLabel]);
3710
4151
  let tx = x;
4152
+ const sidebarFocused = this.focused === this.sidebar;
3711
4153
  for (const [id, label] of tabs) {
3712
- const sel = id === this.mode || (id === "chat" && this.mode !== "trajectory" && !panelLabel);
4154
+ const sel = !sidebarFocused && id === this.mode;
3713
4155
  const seg = ` ${label} `;
3714
4156
  s.text(tx, 0, seg, { fg: sel ? T.SELFG : T.DIM, bg: sel ? T.ACCENT : T.PANEL, attrs: sel ? 1 : 0 });
3715
4157
  tx += strWidth(seg);
@@ -3720,14 +4162,11 @@ export class App {
3720
4162
  #clickTab(px) {
3721
4163
  const x = this.sidebarVisible ? this.sidebarWidth : 0;
3722
4164
  const tabs = [...this.#modeTabs()];
3723
- const panelLabel = this.#panelLabel(this.mode);
3724
- if (panelLabel) tabs.push([this.mode, panelLabel]);
3725
4165
  let tx = x;
3726
4166
  for (const [id, label] of tabs) {
3727
4167
  const seg = ` ${label} `;
3728
4168
  if (px >= tx && px < tx + strWidth(seg)) {
3729
- if (id === this.mode && panelLabel && id !== "chat" && id !== "trajectory") { /* click active panel tab: stay */ return true; }
3730
- this.setMode(id === "chat" || id === "trajectory" || this.#panelLabel(id) ? id : "chat");
4169
+ this.setMode(id);
3731
4170
  return true;
3732
4171
  }
3733
4172
  tx += strWidth(seg);
@@ -3808,6 +4247,21 @@ export class App {
3808
4247
  this.redraw();
3809
4248
  return;
3810
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
+ }
3811
4265
 
3812
4266
  // tab bar clicks (row 0 of the main area)
3813
4267
  if (ev.type === "mouse" && ev.kind === "press" && ev.button === 0 && ev.y === 0 && ev.x >= (this.sidebarVisible ? this.sidebarWidth : 0)) {
@@ -3821,11 +4275,15 @@ export class App {
3821
4275
  return;
3822
4276
  }
3823
4277
  const panel = this.panelForMode();
3824
- if (panel) {
3825
- 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);
3826
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; }
3827
4285
  }
3828
- // unhandled keys fall through to global shortcuts
4286
+ // unhandled key events fall through to global shortcuts
3829
4287
  }
3830
4288
  if (ev.type === "mouse") {
3831
4289
  // input drag-selection: the gesture continues across motion events
@@ -3881,7 +4339,7 @@ export class App {
3881
4339
  } else if (this.chat.inside(ev.x, ev.y)) {
3882
4340
  if (this.focused !== this.chat.input) this.focus(this.chat); // INSERT exits only via Esc
3883
4341
  if (this.chat.onMouse(ev)) this.redraw();
3884
- } else if (this.focused?.onMouse(ev)) {
4342
+ } else if (this.focused?.onMouse?.(ev)) {
3885
4343
  this.redraw();
3886
4344
  }
3887
4345
  return;
@@ -3918,25 +4376,21 @@ export class App {
3918
4376
  this.redraw();
3919
4377
  return;
3920
4378
  }
3921
- if (this.searchActive && (ev.ctrl && ev.key === " " || ev.name === "f7")) {
3922
- this.overlay = new ControlPanel(this, { startPage: 0 });
3923
- this.redraw();
3924
- return;
3925
- }
3926
4379
  if (this.searchActive) {
3927
4380
  this.#onSearchKey(ev);
3928
4381
  this.redraw();
3929
4382
  return;
3930
4383
  }
3931
- if ((ev.ctrl && ev.key === " ") || ev.name === "f7") {
3932
- this.overlay = new ControlPanel(this, { startPage: 0 });
3933
- this.redraw();
3934
- 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;
3935
4392
  }
3936
- if (ev.name === "tab" && !ev.ctrl) { this.toggleChatTrajectory(); return; }
3937
- if (ev.name === "backtab" && !ev.ctrl) { this.rotatePermission(); return; }
3938
- if (ev.ctrl && ev.key === "q") { this.stop(); return; }
3939
- if (ev.ctrl && ev.key === "c") {
4393
+ if (ev.ctrl && ev.key === "c" && !ev.shift) {
3940
4394
  // NORMAL-mode Ctrl+C: two presses within the toast window exit the
3941
4395
  // process; the first press just warns (insert mode owns Ctrl+C for
3942
4396
  // clearing the input).
@@ -3947,33 +4401,9 @@ export class App {
3947
4401
  this.toast("再按一次 Ctrl+C 退出 TUI");
3948
4402
  return;
3949
4403
  }
3950
- if (ev.ctrl && ev.key === "o") { this.overlay = new AttachmentPanel(this); this.focus(this.overlay); this.redraw(); return; }
3951
- if (ev.ctrl && ev.key === "b") { this.toggleSidebar(); return; }
4404
+ if (ev.ctrl && ev.shift && ev.key === "w") { this.addWorkspace(); return; }
3952
4405
  if (ev.ctrl && ev.key === "p") { this.overlay = new ControlPanel(this, { startPage: 1 }); this.redraw(); return; }
3953
- if (ev.ctrl && ev.key === "m") { this.overlay = buildModelPicker(this); this.redraw(); return; }
3954
- if (ev.name === "f8") { this.rotatePermission(); return; }
3955
4406
  if (ev.name === "f9") { this.showModePicker(); return; }
3956
- if (ev.ctrl && ev.key === "w") {
3957
- if (ev.shift) { this.addWorkspace(); return; }
3958
- this.setMode("workspace"); return;
3959
- }
3960
- if (ev.ctrl && ev.key === "t") { this.setMode("trajectory"); return; }
3961
- if (ev.ctrl && ev.key === "e") { this.quickJumpStep(); return; }
3962
- if (ev.ctrl && ev.key === "j") { this.showJobs(); return; }
3963
- if (ev.ctrl && ev.key === "n") { this.showQueue(); return; }
3964
- if (ev.ctrl && ev.key === "y") {
3965
- const next = busyEnter() === "queue" ? "steer" : "queue";
3966
- saveTuiConfig({ busyEnter: next });
3967
- this.toast(`运行中 Enter:${next === "steer" ? "追加到当前回合" : "加入队列"}`);
3968
- return;
3969
- }
3970
- if (ev.ctrl && ev.key === "g") { this.showGoal(); return; }
3971
- if (ev.ctrl && ev.key === "f") { this.startSearch(); return; }
3972
- if (ev.ctrl && ev.key === "s") { this.setMode("settings"); return; }
3973
- if (ev.ctrl && ev.key === "a") { this.setMode("subagent"); return; }
3974
- if (ev.ctrl && ev.key === "k") { this.setMode("skills"); return; }
3975
- if (ev.name === "char" && ev.key === "/" && !ev.ctrl && this.focused !== this.chat.input) { this.startSearch(); this.redraw(); return; }
3976
- if (ev.name === "char" && ev.key === "n" && !ev.ctrl && this.focused === this.sidebar) { this.newSession(); return; }
3977
4407
  if (ev.name === "escape") {
3978
4408
  // Esc in NORMAL mode interrupts a running turn (one press, regardless
3979
4409
  // of focus); otherwise it steps back toward the chat view.
@@ -3982,28 +4412,28 @@ export class App {
3982
4412
  else if (this.mode !== "chat") this.setMode("chat");
3983
4413
  return;
3984
4414
  }
3985
- if (ev.name === "char" && ev.key === "i" && this.focused === this.sidebar) { this.focus(this.chat.input); this.redraw(); return; }
3986
4415
  }
3987
4416
  // nvim-style normal mode: single chars are shortcuts (chat first, then the
3988
4417
  // focused pane). Multi-char text (paste/IME) still types into the input.
3989
4418
  if ((ev.type === "text" || ev.type === "paste") && this.focused !== this.chat.input) {
3990
4419
  if (this.searchActive) {
3991
- this.searchInput.onKey(ev);
3992
- this.#refreshSearch();
4420
+ this.#onSearchKey(ev);
3993
4421
  this.redraw();
3994
4422
  return;
3995
4423
  }
3996
- if (ev.text.length === 1) {
3997
- // Legacy terminals deliver Shift+letter as the UPPERCASE char with no
3998
- // modifier info — recover the shift flag from the case so Shift+T
3999
- // (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];
4000
4429
  const asKey = {
4001
4430
  type: "key", name: "char",
4002
- key: ev.text.toLowerCase(), text: ev.text,
4003
- ctrl: false, alt: false, shift: ev.text !== ev.text.toLowerCase(),
4431
+ key: text.toLowerCase(), text,
4432
+ ctrl: false, alt: false, shift: text !== text.toLowerCase(),
4004
4433
  };
4434
+ if (this.focused && this.focused !== this.chat && this.focused !== this.chat.input && this.focused.onKey?.(asKey)) { this.redraw(); return; }
4005
4435
  if (this.chat.onKey(asKey)) { this.redraw(); return; }
4006
- 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; }
4007
4437
  this.toast("按 i 进入输入");
4008
4438
  return;
4009
4439
  }
@@ -4014,47 +4444,249 @@ export class App {
4014
4444
  }
4015
4445
  // focused widget
4016
4446
  if (this.focused) {
4017
- 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);
4018
4448
  if (handled) this.redraw();
4019
4449
  }
4020
4450
  }
4021
4451
 
4022
4452
  startSearch() {
4453
+ this.searchSeq++;
4023
4454
  this.searchActive = true;
4024
4455
  this.searchInput.setValue("");
4025
- 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 };
4026
4457
  this.focus(this.searchInput);
4458
+ this.redraw();
4027
4459
  }
4028
4460
 
4029
- #refreshSearch() {
4030
- const query = this.searchInput.value.trim().toLowerCase();
4031
- this.searchSelected = 0;
4032
- const fuzzy = (text) => { let i=0; for(const ch of String(text).toLowerCase()) if(ch===query[i]) i++; return i===query.length; };
4033
- 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: "未分组" };
4034
4464
  }
4035
4465
 
4036
- #onSearchKey(ev) {
4037
- const input = this.searchInput;
4038
- 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; }
4039
- if (ev.type === "key" && ev.name === "up" && this.searchResults?.length) { this.searchSelected = Math.max(0, this.searchSelected - 1); return; }
4040
- if (ev.type === "key" && ev.name === "down" && this.searchResults?.length) { this.searchSelected = Math.min(this.searchResults.length - 1, this.searchSelected + 1); return; }
4041
- if (ev.type === "key" && ev.name === "enter") {
4042
- const selected = this.searchResults?.[this.searchSelected];
4043
- if (selected?.sessionId) this.openSession(selected.sessionId);
4044
- // Keep the filter active and its query fixed after opening.
4045
- this.focus(this.searchInput);
4046
- 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);
4481
+ }
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()];
4541
+ }
4542
+
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
+ }
4047
4590
  }
4048
- const handled = input.onKey(ev);
4049
- if (handled) this.#refreshSearch();
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();
4050
4635
  }
4051
4636
 
4052
- #renderSearchResults(s) {
4053
- const ids=new Set((this.searchResults??[]).map(x=>x.sessionId));
4054
- const groups=this.sidebar.groups.map(g=>({...g,sessions:g.sessions.filter(x=>ids.has(x.sessionId))})).filter(g=>g.sessions.length);
4055
- let y=this.sidebar.y;
4056
- 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});}}
4057
- if(!groups.length)s.text(this.sidebar.x,this.sidebar.y,"无结果",{fg:K.FAINT});
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 });
4058
4690
  }
4059
4691
 
4060
4692
  redraw() {
@@ -4108,17 +4740,27 @@ export class App {
4108
4740
  this.term.output.write(s.render() + "\x1b[?25l");
4109
4741
  return;
4110
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
+ }
4111
4760
  this.#renderTabBar(s);
4112
4761
  if (this.sidebarVisible) {
4113
- if (this.searchActive) {
4114
- this.searchInput.render(s);
4115
- this.sidebar.y = 1; this.sidebar.h = s.h - 2;
4116
- if (this.searchResults !== null) this.#renderSearchResults(s);
4117
- else this.sidebar.render(s);
4118
- } else {
4119
- this.sidebar.y = 0; this.sidebar.h = s.h - 1;
4120
- this.sidebar.render(s);
4121
- }
4762
+ this.sidebar.y = 0; this.sidebar.h = s.h - 1;
4763
+ this.sidebar.render(s);
4122
4764
  s.put(this.sidebar.w - 1, 0, "│", { fg: T.BORDER });
4123
4765
  for (let y = 1; y < s.h - 1; y++) s.put(this.sidebar.w - 1, y, "│", { fg: T.BORDER });
4124
4766
  }
@@ -4152,7 +4794,7 @@ export class App {
4152
4794
  row0.left.push({ t: ` ${badge} `, fg: T.SELFG, bg: T.ACCENT, bold: true });
4153
4795
  const rawGoal = this.goalData?.goal ?? this.goalData;
4154
4796
  if (rawGoal && !["complete", "completed", "cleared"].includes(rawGoal.phase)) {
4155
- 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 });
4156
4798
  }
4157
4799
  if (this.sidebarVisible) row0.left.push({ t: " " + truncate(t || "(未选择会话)", 40) + " ", fg: T.TXT, bg: T.STATUSBG });
4158
4800
  else row0.left.push({ t: " " + truncate(t || "(未选择会话)", 40) + " ", fg: T.TXT, bg: T.STATUSBG });
@@ -4233,15 +4875,32 @@ export class App {
4233
4875
  const sub = this.projections.subagent;
4234
4876
  const subTiming = this.projections.subagentTiming;
4235
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.
4236
4880
  row2.left.push({
4237
4881
  t: ` ${running > 0 ? `${running} 个后台任务运行中` : "没有后台任务运行"} `,
4238
- 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,
4239
4891
  });
4240
- row2.left.push({ t: ` ${done}已完成${failed > 0 ? ` ${failed}失败` : ""} `, fg: done > 0 ? T.OK : T.FAINT, bg: T.STATUSBG });
4241
- 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 });
4242
- if (sub) row2.left.push({ t: ` 🛰 ${truncate(sub.label ?? sub.mode ?? "子代理", 20)} `, fg: T.PURPLE, bg: T.STATUSBG });
4243
- if(this.queueItems.length)row2.left.push({t:` 有${this.queueItems.length}条命令正在排队 Ctrl+N查看详情 `,fg:T.SELFG,bg:T.WARN,bold:true});
4244
- row2.right.push({ t: " Ctrl+J 任务/子代理 ", fg: T.DIM, bg: T.STATUSBG });
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,
4902
+ });
4903
+ if(this.queueItems.length)row2.left.push({t:` 有${this.queueItems.length}条命令正在排队 Ctrl+N查看详情 `,fg:0x000000,bg:T.WARN,bold:true});
4245
4904
  rows.push(row2);
4246
4905
  }
4247
4906
  this.status.rows = rows;
@@ -4250,15 +4909,7 @@ export class App {
4250
4909
  if (this.popup) this.popup.render(s);
4251
4910
  if (this.menu) this.menu.render(s);
4252
4911
  if (this.overlay) this.overlay.render(s);
4253
- if (this.toastMsg) {
4254
- // Toasts land in the LOWER half (just above the input/footer) where the
4255
- // user's attention is while pressing shortcuts — a solid color block.
4256
- const w = Math.min(s.w - 4, strWidth(this.toastMsg) + 6);
4257
- const x0 = Math.max(2, Math.floor((s.w - w) / 2));
4258
- const y = Math.max(1, this.chat.input.y - this.chat.todoHeight() - 2);
4259
- s.fillRect(x0, y, x0 + w - 1, y, " ", { bg: T.ACCENT });
4260
- s.text(x0 + 1, y, truncate(this.toastMsg, w - 2), { fg: T.SELFG, bg: T.ACCENT, attrs: 1 });
4261
- }
4912
+ this.#renderToast(s);
4262
4913
  if (this.renameInput && this.popup) this.renameInput.render(s);
4263
4914
 
4264
4915
  const out = s.render();
@@ -4276,6 +4927,17 @@ export class App {
4276
4927
  this.term.output.write(out + tail);
4277
4928
  }
4278
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
+
4279
4941
  titleOf() {
4280
4942
  const s = this.sessions.find((x) => x.sessionId === this.currentSession);
4281
4943
  if (s) return s.projections?.values?.title ?? s.sessionId.slice(0, 8);