dsh-neotui 0.0.10 → 0.0.12

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/README.md CHANGED
@@ -59,7 +59,8 @@ tui/app/ dsh-neotui-app bundle:cordis.patch.yml + tui-startup/tui-r
59
59
  | 设置次级页 | 在「设置」页内 **Shift+Tab** 翻次级页(常规 / 插件) |
60
60
  | 主页切换 | **Shift+Tab** 在对话 ↔ 轨迹之间切换;顶部标签页(对话/轨迹/工作区/设置/技能/子代理)可点击直达 |
61
61
  | 输入模式 | `i` 或点击进入,**Esc 退出**(nvim normal/insert);未聚焦时字母=快捷键,多字粘贴直接输入 |
62
- | 思考/工具折叠 | `t` 思考块展开折叠,`b` 工具块(bash 等)展开折叠;右键消息 →「展开 / 折叠」折叠单个输出块 |
62
+ | 思考/工具折叠 | `t` 思考块展开折叠,`b` 工具块(bash 等)展开折叠;右键消息 →「展开 / 折叠」折叠单个输出块;正文文本块同样支持点击/右键折叠(▸/▾ + 「…共 N 字」尾注);展开的思考块渲染**全文**,不再截断 |
63
+ | 右键菜单 | 菜单打开后**再次右键**任意位置:立即关闭当前菜单并在新位置打开新菜单(OS 式);左键点击菜单外仍是关闭 |
63
64
  | 对话 ↔ 轨迹转跳 | 对话里右键消息 →「转跳轨迹」定位到对应 step(自动展开+高亮);轨迹里右键 step →「转跳对话」回到该消息 |
64
65
  | 步骤快速转跳 | **Ctrl+E**:fzf 式过滤 step 列表(编号/耗时/工具/消息预览),回车定位到该 step 的轨迹窗口 |
65
66
  | 轨迹窗口导航 | 转跳后只显示 `step±20` 窗口;**PgUp/PgDn** 上下各加载 10 步,**Home/End** 跳到最早/最新 20 步,`r` 回最新 |
@@ -100,9 +101,9 @@ tui/app/ dsh-neotui-app bundle:cordis.patch.yml + tui-startup/tui-r
100
101
  - 会话内搜索:**Ctrl+F** 模糊搜本会话消息/工具名,回车跳转
101
102
  - 轨迹详情:点击回合行弹窗(工具调用 + 耗时),`/` 过滤回合;**增量加载**(每页 20 回合逐页渲染,最近回合秒开,历史步骤按需翻页,不再阻塞等待全量历史)
102
103
  - 轨迹 详细/简略:每个 step 左键单击(▸/▾)或右键菜单第一项 →「展开(详细)/ 折叠(简略)」内联展开事件列表(仿对话页);跳转定位的 step 自动展开 + 高亮闪烁
103
- - 轨迹窗口模型:转跳只显示 `step±20`;PgUp/PgDn 按 10 步扩展窗口(按需向后翻页加载),Home 加载到 step 1–20,End 回到最新 20 步,窗口指示行显示「窗口 step AB · N 步」
104
+ - 轨迹窗口模型:转跳只显示目标 step **±20 个相邻步骤**;PgUp/PgDn 按 10 步扩展窗口(按需向后翻页加载),Home 加载到最早 20 步,End 回到最新 20 步,窗口指示行显示「窗口 #N–#M(已加载 X) · step ab」;窗口边界基于事件序号(seq)而非 step 编号——会话压缩后 step 编号会重新计数,seq 始终唯一
104
105
  - 工作区文件搜索:工作区面板内直接打字(`/`)模糊搜文件名,回车预览
105
- - 消息反馈:助手消息右键 👍/👎(`messageFeedback/put` Typert RPC),已评状态回显、可删除
106
+ - 消息反馈:助手消息右键 👍/👎(`messageFeedback/put` Typert RPC,`{request:…}` 载荷),已评状态回显、可删除
106
107
  - 图片画廊:多图时 ←/→ 切换,标题显示 (N/M) + 尺寸
107
108
  - 会话内搜索高亮:Ctrl+F 跳转后匹配词高亮(黄底),Esc 清除
108
109
  - 轨迹逐事件详情:点击回合 → 事件列表 → 选事件看全文
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "description": "Neo-TUI: mouse-driven terminal UI client for DeepSeek Harness (B-tier per dsh-tui-design.md)",
5
5
  "type": "module",
6
6
  "bin": { "dsh-neotui": "bin/dsh-tui.js" },
package/src/panels.js CHANGED
@@ -556,12 +556,12 @@ export class TrajectoryPanel extends Widget {
556
556
  this.allEvents = [];
557
557
  this.sessionId = null;
558
558
  this.expandedSteps = new Set(); // step identity keys rendered 详细 (expanded)
559
- this.flashStep = null; // step NUMBER just jumped to (brief highlight)
559
+ this.flashKey = null; // step key just jumped to (brief highlight)
560
560
  this.flashUntil = 0;
561
561
  this.loadPromise = null; // dedupes concurrent load(currentSession)
562
562
  this.loadTarget = null;
563
- this.winLo = null; // visible step-number window [winLo, winHi]; null = follow the tail
564
- this.winHi = null;
563
+ this.winSeqLo = null; // visible window = first-event SEQ range; null = follow the tail
564
+ this.winSeqHi = null;
565
565
  // LEFT click toggles a step's 详细/简略 expansion (the ▸/▾ triangle).
566
566
  this.view = new ScrollView({ x: this.x, y: this.y, w: this.w, h: this.h, showScrollbar: true, onClick: (y) => this.#clickLine(y) });
567
567
  this.stepLines = [];
@@ -665,98 +665,127 @@ export class TrajectoryPanel extends Widget {
665
665
  return -1;
666
666
  }
667
667
 
668
- /** Load older pages until step `loStep` is covered (or the session's first
669
- * step is reached). Used by jumps and Home — many pages when going far. */
670
- async ensureLoaded(loStep, maxPages = 80) {
668
+ /** Load older pages until at least `minCount` steps are loaded (or the
669
+ * session's first step is reached). Used by jumps and Home. */
670
+ async ensureCount(minCount, maxPages = 80) {
671
671
  for (let i = 0; i < maxPages; i++) {
672
- if (!this.hasMore) break;
673
- if (this.steps.length && this.steps[0].step <= loStep) break;
674
- this.app.setStatus(`加载更早轨迹…(当前最早 step ${this.steps[0]?.step ?? "?"})`);
672
+ if (!this.hasMore || this.steps.length >= minCount) break;
673
+ this.app.setStatus(`加载更早轨迹…(已加载 ${this.steps.length} 步)`);
675
674
  await this.loadOlder();
676
675
  }
677
676
  this.app.setStatus("");
678
677
  }
679
678
 
680
- /** Set the visible step-number window and re-render. */
681
- setWindow(lo, hi) {
682
- this.winLo = lo;
683
- this.winHi = hi;
679
+ /** The visible window is a SEQ RANGE (first-event seqs are globally unique
680
+ * and monotonic; the server's step numbers restart after compactions and
681
+ * cannot be used as boundaries). null = follow the tail (newest 20). */
682
+ setWindow(loSeq, hiSeq) {
683
+ this.winSeqLo = loSeq;
684
+ this.winSeqHi = hiSeq;
684
685
  this.buildLines();
685
686
  }
686
687
 
687
- /** Step number at the top of the viewport (for anchoring after growth). */
688
- #topVisibleStep() {
688
+ /** Tail-follow window: the newest 20 loaded steps. */
689
+ #tailWindow() {
690
+ const n = this.steps.length;
691
+ if (n === 0) return;
692
+ const lo = Math.max(0, n - 20);
693
+ this.winSeqLo = this.stepKey(this.steps[lo]);
694
+ this.winSeqHi = this.stepKey(this.steps[n - 1]);
695
+ }
696
+
697
+ /** Seq of the step at the top of the viewport (for anchoring after growth). */
698
+ #topVisibleSeq() {
689
699
  const si = this.stepLines[this.view.scrollY];
690
- return si !== undefined ? this.steps[si]?.step : null;
700
+ return si !== undefined ? this.stepKey(this.steps[si]) : null;
691
701
  }
692
702
 
693
- /** Scroll so the given step number sits at the top of the viewport. */
694
- #anchorScroll(stepNum) {
695
- const li = this.stepLines.findIndex((si) => this.steps[si]?.step === stepNum);
696
- if (li >= 0) this.view.scrollY = Math.max(0, li);
703
+ /** Scroll so the given step seq sits at the top of the viewport. */
704
+ #anchorScroll(seq) {
705
+ const li = this.stepLines.findIndex((si) => this.stepKey(this.steps[si]) === seq);
706
+ if (li >= 0) this.view.scrollY = Math.max(0, Math.min(li, this.view.maxScroll()));
697
707
  }
698
708
 
699
709
  /** Scroll to a step: open a ±20 window around it (loading older pages on
700
710
  * demand), auto-expand and highlight the step. */
701
711
  async jumpToStep(si) {
702
712
  if (si < 0 || si >= this.steps.length) return;
703
- const S = this.steps[si].step;
704
- this.expandedSteps.add(this.stepKey(this.steps[si]));
705
- this.flashStep = S;
713
+ const key = this.stepKey(this.steps[si]);
714
+ this.expandedSteps.add(key);
715
+ this.flashKey = key;
706
716
  this.flashUntil = Date.now() + 3000;
707
- await this.ensureLoaded(Math.max(1, S - 20));
708
- // ensureLoaded re-segments re-find the step index by its number
709
- const si2 = this.steps.findIndex((s) => s.step === S);
710
- this.setWindow(Math.max(1, S - 20), Math.min(this.totalSteps(), S + 20));
711
- const li = si2 >= 0 ? this.stepLines.indexOf(si2) : -1;
712
- this.view.scrollY = li >= 0 ? Math.max(0, li - 2) : 0;
717
+ // load older pages until at least 20 steps sit above the target
718
+ for (let i = 0; i < 80 && this.hasMore; i++) {
719
+ if (this.steps.findIndex((s) => this.stepKey(s) === key) >= 20) break;
720
+ await this.loadOlder();
721
+ }
722
+ const idx = this.steps.findIndex((s) => this.stepKey(s) === key);
723
+ if (idx < 0) return;
724
+ const lo = Math.max(0, idx - 20), hi = Math.min(this.steps.length - 1, idx + 20);
725
+ this.setWindow(this.stepKey(this.steps[lo]), this.stepKey(this.steps[hi]));
726
+ const li = this.stepLines.indexOf(idx);
727
+ this.view.scrollY = li >= 0 ? Math.max(0, Math.min(li - 2, this.view.maxScroll())) : 0;
713
728
  this.app.redraw();
714
729
  }
715
730
 
716
731
  /** PgUp: extend the window 10 steps upward (loading older if needed),
717
732
  * keeping the view anchored on the step that was at the top. */
718
733
  async extendUp() {
719
- const N = this.totalSteps();
720
- if (this.winLo == null) { this.winLo = Math.max(1, N - 19); this.winHi = N; }
721
- if (this.winLo <= 1 && this.steps.length && this.steps[0].step <= 1) {
722
- this.app.toast("已到最早步骤");
723
- return;
734
+ if (this.winSeqLo == null) this.#tailWindow();
735
+ if (this.steps.length === 0) return;
736
+ let topIdx = this.steps.findIndex((s) => this.stepKey(s) === this.winSeqLo);
737
+ if (topIdx < 0) topIdx = 0;
738
+ if (topIdx === 0 && !this.hasMore) { this.app.toast("已到最早步骤"); return; }
739
+ // ensure at least 10 steps above the window top are loaded
740
+ for (let i = 0; i < 80 && this.hasMore && topIdx < 10; i++) {
741
+ await this.loadOlder();
742
+ topIdx = this.steps.findIndex((s) => this.stepKey(s) === this.winSeqLo);
724
743
  }
725
- const anchor = this.#topVisibleStep() ?? this.winLo;
726
- this.winLo = Math.max(1, this.winLo - 10);
727
- if (!this.steps.length || this.steps[0].step > this.winLo) await this.ensureLoaded(this.winLo);
744
+ const anchorSeq = this.#topVisibleSeq();
745
+ this.winSeqLo = this.stepKey(this.steps[Math.max(0, topIdx - 10)]);
728
746
  this.buildLines();
729
- this.#anchorScroll(anchor);
747
+ if (anchorSeq != null) this.#anchorScroll(anchorSeq);
730
748
  this.app.redraw();
731
749
  }
732
750
 
733
751
  /** PgDn: extend the window 10 steps downward (the newer steps are already
734
752
  * loaded — the tail is always kept). */
735
753
  extendDown() {
736
- const N = this.totalSteps();
737
- if (this.winLo == null) { this.winLo = Math.max(1, N - 19); this.winHi = N; }
738
- if (this.winHi >= N) { this.app.toast("已到最新步骤"); return; }
739
- this.winHi = Math.min(N, this.winHi + 10);
754
+ if (this.winSeqLo == null) this.#tailWindow();
755
+ if (this.steps.length === 0) return;
756
+ let bottomIdx = this.steps.length - 1;
757
+ for (let i = this.steps.length - 1; i >= 0; i--) {
758
+ if (this.stepKey(this.steps[i]) <= this.winSeqHi) { bottomIdx = i; break; }
759
+ }
760
+ const target = Math.min(this.steps.length - 1, bottomIdx + 10);
761
+ if (target === bottomIdx) { this.app.toast("已到最新步骤"); return; }
762
+ this.winSeqHi = this.stepKey(this.steps[target]);
740
763
  this.buildLines();
741
764
  this.app.redraw();
742
765
  }
743
766
 
744
767
  /** Home: jump to the very first steps (loading all the way back). */
745
768
  async gotoHome() {
746
- await this.ensureLoaded(1);
747
- this.setWindow(1, 20);
748
- const li = this.stepLines.findIndex((si) => this.steps[si]?.step === 1);
749
- this.view.scrollY = li >= 0 ? li : 0;
750
- this.app.toast("已跳到最早步骤(step 1–20)");
769
+ for (let i = 0; i < 80 && this.hasMore; i++) {
770
+ this.app.setStatus(`加载全部步骤…(已加载 ${this.steps.length} 步)`);
771
+ await this.loadOlder();
772
+ }
773
+ this.app.setStatus("");
774
+ if (this.steps.length === 0) return;
775
+ const hi = Math.min(19, this.steps.length - 1);
776
+ this.setWindow(this.stepKey(this.steps[0]), this.stepKey(this.steps[hi]));
777
+ this.view.scrollY = 0;
778
+ this.app.toast("已跳到最早步骤");
751
779
  this.app.redraw();
752
780
  }
753
781
 
754
782
  /** End: jump to the newest steps. */
755
783
  gotoEnd() {
756
- const N = this.totalSteps();
757
- this.setWindow(Math.max(1, N - 19), N);
784
+ if (this.steps.length === 0) return;
785
+ const lo = Math.max(0, this.steps.length - 20);
786
+ this.setWindow(this.stepKey(this.steps[lo]), this.stepKey(this.steps[this.steps.length - 1]));
758
787
  this.view.scrollY = this.view.maxScroll();
759
- this.app.toast(`已跳到最新步骤(step ${Math.max(1, N - 19)}–${N})`);
788
+ this.app.toast("已跳到最新步骤");
760
789
  this.app.redraw();
761
790
  }
762
791
 
@@ -773,7 +802,7 @@ export class TrajectoryPanel extends Widget {
773
802
  }
774
803
  if (si < 0 && messageId) {
775
804
  // still not found — the message is far back; scan everything (bounded)
776
- await this.ensureLoaded(1, 60);
805
+ await this.ensureCount(Infinity, 60);
777
806
  si = this.indexOfMessage(messageId);
778
807
  }
779
808
  if (si >= 0) {
@@ -871,8 +900,17 @@ export class TrajectoryPanel extends Widget {
871
900
  buildLines() {
872
901
  const w = Math.max(40, this.w - 2);
873
902
  const N = this.totalSteps();
874
- const lo = this.winLo ?? Math.max(1, N - 19);
875
- const hi = this.winHi ?? N;
903
+ // Window boundaries are SEQ-based (step numbers restart after compaction).
904
+ let loSeq = this.winSeqLo, hiSeq = this.winSeqHi;
905
+ if (loSeq == null && this.steps.length) {
906
+ const lo = Math.max(0, this.steps.length - 20);
907
+ loSeq = this.stepKey(this.steps[lo]);
908
+ hiSeq = this.stepKey(this.steps[this.steps.length - 1]);
909
+ }
910
+ const winIdxLo = this.steps.findIndex((s) => this.stepKey(s) === loSeq);
911
+ const winIdxHi = this.steps.findIndex((s) => this.stepKey(s) === hiSeq);
912
+ const loStepNum = this.steps[winIdxLo]?.step ?? "?";
913
+ const hiStepNum = this.steps[winIdxHi]?.step ?? "?";
876
914
  const lines = [];
877
915
  lines.push([{ t: "轨迹 — 步骤时间轴(左键展开/折叠 · PgUp/PgDn 上下加载 · Home/End 首尾 · Ctrl+E 转跳 · r 刷新)", fg: K.ACCENT, bold: true }]);
878
916
  if (this.hasMore) lines.push([{ t: "▲ 更早步骤(点击 / PgUp 向上加载 10 步)", fg: K.FAINT }]);
@@ -881,7 +919,7 @@ export class TrajectoryPanel extends Widget {
881
919
  if (st) {
882
920
  lines.push([{ t: `回合 ${st.turns} · 步骤 ${st.steps} · LLM ${fmtMs(st.llmMs)} · 工具 ${fmtMs(st.toolMs)}`, fg: K.DIM }]);
883
921
  }
884
- lines.push([{ t: `窗口 step ${lo}–${hi} · ${N} 步${this.winLo == null ? "(跟随最新)" : ""}:`, fg: K.DIM, underline: true }]);
922
+ lines.push([{ t: `窗口 #${winIdxLo + 1}–#${winIdxHi + 1}(已加载 ${this.steps.length}${this.hasMore ? "+" : ""})· step ${loStepNum}–${hiStepNum}${this.winSeqLo == null ? "(跟随最新)" : ""}:`, fg: K.DIM, underline: true }]);
885
923
  this.stepLines = [];
886
924
  const list = this.query
887
925
  ? this.steps.filter((t) => t.events.some((e) => {
@@ -889,7 +927,10 @@ export class TrajectoryPanel extends Widget {
889
927
  const hay = `${e.type} ${d.name ?? ""} ${typeof d.content === "string" ? d.content : ""}`.toLowerCase();
890
928
  return hay.includes(this.query.toLowerCase());
891
929
  }))
892
- : this.steps.filter((s) => s.step >= lo && s.step <= hi);
930
+ : this.steps.filter((s) => {
931
+ const k = this.stepKey(s);
932
+ return k >= loSeq && k <= hiSeq;
933
+ });
893
934
  for (const step of list.reverse()) {
894
935
  const si = this.steps.indexOf(step);
895
936
  const tools = [...new Set(step.events.filter((e) => e.type === "tool/call").map((e) => e.data?.name))];
@@ -900,7 +941,7 @@ export class TrajectoryPanel extends Widget {
900
941
  const bg = tools.length ? (hasResult ? T.TOOLOK : T.TOOLBG) : hasReasoning ? T.THINKBG : T.CARD;
901
942
  const summary = tools.slice(0, 3).join(",") || (hasReasoning ? "模型推理" : "纯文本");
902
943
  const open = this.expandedSteps.has(this.stepKey(step)); // 详细
903
- const flash = this.flashStep === step.step && Date.now() < this.flashUntil;
944
+ const flash = this.flashKey === this.stepKey(step) && Date.now() < this.flashUntil;
904
945
  const rowBg = flash ? T.ACCENT : bg;
905
946
  const label = `${open ? "▾" : "▸"} step ${String(step.step).padStart(3)} ${pad(dur, 8)} ${summary} ${open ? "[折叠]" : "[展开]"}`;
906
947
  const segs = [{ t: label, fg: flash ? T.SELFG : K.TXT, bg: rowBg, bold: true }];
@@ -971,7 +1012,7 @@ export class TrajectoryPanel extends Widget {
971
1012
  }
972
1013
  if (ev.name === "backspace") { this.query = this.query.slice(0, -1); this.buildLines(); this.app.redraw(); return true; }
973
1014
  if (ev.name === "char" && ev.key === "r" && !ev.ctrl) {
974
- this.winLo = this.winHi = null;
1015
+ this.winSeqLo = this.winSeqHi = null;
975
1016
  this.steps = [];
976
1017
  this.load(this.sessionId);
977
1018
  return true;
package/src/views.js CHANGED
@@ -848,9 +848,28 @@ export class ChatView extends Widget {
848
848
  if (ref) { this.app.openImage(ref, { all: node.images, index: info.imgIdx }); return true; }
849
849
  }
850
850
  const node = this.nodes[info.nodeIdx];
851
+ // Expand/collapse changes the line count ABOVE the viewport, which would
852
+ // leave scrollY pointing at unrelated content (the "chaotic" jump).
853
+ // Anchor: after the rebuild, scroll so the clicked block's first line
854
+ // sits at the top of the viewport. At the bottom the tail-follow snap
855
+ // in setLines is the right behavior instead.
856
+ const wasAtBottom = this.view.scrollY + this.view.h >= this.view.lines.length - 1;
857
+ const reanchor = (match) => {
858
+ if (wasAtBottom) return;
859
+ let fallback = -1;
860
+ for (let i = 0; i < this.lineMap.length; i++) {
861
+ if (!match(this.lineMap[i])) continue;
862
+ if ((this.lines[i] ?? []).some((g) => g.t.trim() !== "")) {
863
+ this.view.scrollY = Math.max(0, Math.min(i, this.view.maxScroll()));
864
+ return;
865
+ }
866
+ if (fallback < 0) fallback = i;
867
+ }
868
+ if (fallback >= 0) this.view.scrollY = Math.max(0, Math.min(fallback, this.view.maxScroll()));
869
+ };
851
870
  if (node?.kind === "assistant" && info.blockIdx !== null) {
852
871
  const b = node.blocks[info.blockIdx];
853
- if (b && (b.kind === "tool" || b.kind === "reasoning" || b.kind === "other")) {
872
+ if (b && (b.kind === "tool" || b.kind === "reasoning" || b.kind === "other" || b.kind === "text")) {
854
873
  const key = `${info.nodeIdx}:${info.blockIdx}`;
855
874
  if (b.kind === "reasoning") {
856
875
  // clean two-state override: expand ⇄ collapse (never a no-op click)
@@ -862,6 +881,7 @@ export class ChatView extends Widget {
862
881
  else this.collapsedBlocks.add(key);
863
882
  }
864
883
  this.#rebuild();
884
+ reanchor((m) => m?.nodeIdx === info.nodeIdx && m?.blockIdx === info.blockIdx);
865
885
  return true;
866
886
  }
867
887
  }
@@ -869,6 +889,7 @@ export class ChatView extends Widget {
869
889
  if (this.expanded.has(info.nodeIdx)) this.expanded.delete(info.nodeIdx);
870
890
  else this.expanded.add(info.nodeIdx);
871
891
  this.#rebuild();
892
+ reanchor((m) => m?.nodeIdx === info.nodeIdx);
872
893
  return true;
873
894
  }
874
895
  }
@@ -891,7 +912,6 @@ export class ChatView extends Widget {
891
912
  if (this.hasMore) { lines.push([{ t: "▲ 更早的记录", fg: K.FAINT }]); mark(-1); }
892
913
  if (skipCount > 0) { lines.push([{ t: `… 更早 ${skipCount} 条记录(PgUp 加载)`, fg: K.FAINT }]); mark(-1); }
893
914
 
894
- const MAX_TEXT = 4000;
895
915
  for (let ni = 0; ni < nodes.length; ni++) {
896
916
  const node = nodes[ni];
897
917
  const realIdx = ni + skipCount;
@@ -999,7 +1019,8 @@ export class ChatView extends Widget {
999
1019
  lines.push([{ t: "💭 思考" + (b.streaming ? "…" : "") + thinkMeta + (open ? " [t 折叠]" : " [t 展开]"), fg: K.FAINT }]);
1000
1020
  mark(realIdx, bi);
1001
1021
  if (open) {
1002
- for (const ln of renderMd(truncateText(b.text, MAX_TEXT), w - 4)) { lines.push([{ t: " " }, ...ln]); mark(realIdx, bi); }
1022
+ // Expanded = the WHOLE reasoning (no hidden remainder).
1023
+ for (const ln of renderMd(b.text ?? "", w - 4)) { lines.push([{ t: " " }, ...ln]); mark(realIdx, bi); }
1003
1024
  } else {
1004
1025
  // collapsed: three-line preview
1005
1026
  for (const ln of renderMd(truncateText(b.text, 400), w - 4).slice(0, 3)) {
@@ -1059,18 +1080,27 @@ export class ChatView extends Widget {
1059
1080
  sep();
1060
1081
  } else {
1061
1082
  beginCard("CARD");
1083
+ // Text blocks collapse per-block like tool/reasoning blocks:
1084
+ // collapsed shows a 3-line preview + trailer, expanded the whole
1085
+ // text. Clicking (or the right-click 展开/折叠) toggles it.
1086
+ const key = `${realIdx}:${bi}`;
1087
+ const open = !this.collapsedBlocks.has(key);
1062
1088
  const text = b.text ?? "";
1063
- const md = renderMd(text, w - 4 - strWidth(stepTag));
1089
+ const glyph = open ? "▾" : "▸";
1090
+ const mdW = Math.max(10, w - 6 - strWidth(stepTag));
1091
+ const md = open
1092
+ ? renderMd(text, mdW)
1093
+ : renderMd(truncateText(text, 600), mdW).slice(0, 3);
1064
1094
  if (md.length > 0) {
1065
- lines.push([{ t: " " }, { t: stepTag, fg: K.FAINT }, ...md[0]]);
1066
- mark(realIdx);
1067
- for (const ln of md.slice(1)) { lines.push([{ t: " " }, ...ln]); mark(realIdx); }
1068
- // Label the block's LAST line with its blockIdx IN PLACE. A
1069
- // pushed mark without a line would desync lineMap from lines
1070
- // and shift every later click mapping (the off-by-one bug).
1071
- lineMap[lineMap.length - 1] = { nodeIdx: realIdx, blockIdx: bi };
1095
+ lines.push([{ t: " " }, { t: glyph + " ", fg: K.FAINT }, { t: stepTag, fg: K.FAINT }, ...md[0]]);
1096
+ mark(realIdx, bi);
1097
+ for (const ln of md.slice(1)) { lines.push([{ t: " " }, ...ln]); mark(realIdx, bi); }
1072
1098
  } else {
1073
- lines.push([{ t: " " + stepTag, fg: K.FAINT }]);
1099
+ lines.push([{ t: " " + glyph + " " + stepTag, fg: K.FAINT }]);
1100
+ mark(realIdx, bi);
1101
+ }
1102
+ if (!open && text.length > 0) {
1103
+ lines.push([{ t: ` …共 ${text.length} 字(点击展开)`, fg: K.FAINT }]);
1074
1104
  mark(realIdx, bi);
1075
1105
  }
1076
1106
  sep();
@@ -1298,13 +1328,13 @@ export class ChatView extends Widget {
1298
1328
  case "pgup": if (this.view.scrollY === 0) { this.loadOlder(); return true; } return this.view.scroll(-this.view.h);
1299
1329
  case "pgdn": return this.view.scroll(this.view.h);
1300
1330
  }
1301
- if (ev.name === "char" && ev.key === "g" && !ev.ctrl) {
1331
+ if (ev.name === "char" && ev.key === "g" && !ev.ctrl && !ev.shift) {
1302
1332
  if (this.gKey) { this.gKey = false; this.view.scrollY = 0; return true; }
1303
1333
  this.gKey = true;
1304
1334
  this.app.toast("再按 g 回顶");
1305
1335
  return true;
1306
1336
  }
1307
- if (ev.name === "char" && ev.key === "G") { this.view.scrollY = this.view.maxScroll(); return true; }
1337
+ if (ev.name === "char" && ev.key === "g" && ev.shift) { this.view.scrollY = this.view.maxScroll(); return true; }
1308
1338
  if (ev.name === "escape" && this.app.searchQuery) { this.app.searchQuery = null; this.queueRebuild(); return true; }
1309
1339
  if (ev.name === "escape" && this.selStart !== null) { this.selStart = this.selEnd = null; this.app.redraw(); return true; }
1310
1340
  if (ev.name === "char" && ev.key === "i" && !ev.ctrl) { this.app.focus(this.input); return true; }
@@ -1891,18 +1921,26 @@ export class App {
1891
1921
  async loadFeedback() {
1892
1922
  if (!this.currentSession) return;
1893
1923
  try {
1894
- const res = await this.api.rpcCall("messageFeedback/list", { sessionId: this.currentSession });
1924
+ const res = await this.api.rpcCall("messageFeedback/list", { request: { sessionId: this.currentSession } });
1895
1925
  this.feedbackMap = new Map();
1896
- for (const item of res.items ?? []) this.feedbackMap.set(item.messageId, item);
1926
+ for (const item of res?.value?.items ?? res?.items ?? []) this.feedbackMap.set(item.messageId, item);
1897
1927
  } catch { this.feedbackMap = new Map(); }
1898
1928
  }
1899
1929
 
1900
1930
  async feedback(messageId, rating) {
1901
1931
  const existing = this.feedbackMap?.get(messageId);
1902
1932
  try {
1903
- const item = await this.api.rpcCall("messageFeedback/put", {
1904
- sessionId: this.currentSession, messageId, rating, ifVersion: existing?.version ?? null,
1933
+ const res = await this.api.rpcCall("messageFeedback/put", {
1934
+ request: {
1935
+ sessionId: this.currentSession, messageId, rating,
1936
+ ifVersion: existing?.version ?? null,
1937
+ },
1905
1938
  });
1939
+ if (res?.ok === false) {
1940
+ this.toast(`反馈失败: ${res.error?.code ?? res.error?.message ?? "unknown"}`);
1941
+ return;
1942
+ }
1943
+ const item = res?.value ?? res;
1906
1944
  this.feedbackMap = this.feedbackMap ?? new Map();
1907
1945
  this.feedbackMap.set(messageId, item);
1908
1946
  this.toast(rating === "positive" ? "已记录 👍" : "已记录 👎");
@@ -1913,7 +1951,13 @@ export class App {
1913
1951
  const existing = this.feedbackMap?.get(messageId);
1914
1952
  if (!existing) return;
1915
1953
  try {
1916
- await this.api.rpcCall("messageFeedback/delete", { sessionId: this.currentSession, messageId, ifVersion: existing.version });
1954
+ const res = await this.api.rpcCall("messageFeedback/delete", {
1955
+ request: { sessionId: this.currentSession, messageId, ifVersion: existing.version },
1956
+ });
1957
+ if (res?.ok === false) {
1958
+ this.toast(`删除反馈失败: ${res.error?.code ?? res.error?.message ?? "unknown"}`);
1959
+ return;
1960
+ }
1917
1961
  this.feedbackMap.delete(messageId);
1918
1962
  this.toast("已删除反馈");
1919
1963
  } catch (e) { this.toast(`删除反馈失败: ${e.message}`); }
@@ -2290,16 +2334,29 @@ export class App {
2290
2334
  }
2291
2335
  if (this.menu) {
2292
2336
  const before = this.menu;
2293
- if (ev.type === "key") this.menu.onKey(ev);
2294
- else if (ev.type === "mouse") {
2295
- if (ev.kind === "press" && ev.button === 0 && !this.menu.inside(ev.x, ev.y)) { this.menu = null; this.swallowRelease = true; }
2296
- else {
2337
+ if (ev.type === "key") { this.menu.onKey(ev); this.redraw(); return; }
2338
+ if (ev.type === "mouse") {
2339
+ if (ev.kind === "press" && ev.button === 0 && !this.menu.inside(ev.x, ev.y)) {
2340
+ this.menu = null; this.swallowRelease = true; this.redraw(); return;
2341
+ }
2342
+ if (ev.kind === "press" && ev.button === 2) {
2343
+ if (this.menu.inside(ev.x, ev.y)) { this.redraw(); return; } // right-click on the menu itself: keep it open
2344
+ // OS-style: a right-click anywhere else closes the current menu and
2345
+ // the SAME press falls through to open a fresh menu at the new spot.
2346
+ this.menu = null;
2347
+ this.swallowRelease = true;
2348
+ this.redraw();
2349
+ // fall through to the normal mouse routing below
2350
+ } else {
2297
2351
  this.menu.onMouse(ev);
2298
2352
  if (ev.kind === "press" && this.menu !== before) this.swallowRelease = true;
2353
+ this.redraw();
2354
+ return;
2299
2355
  }
2356
+ } else {
2357
+ this.redraw();
2358
+ return;
2300
2359
  }
2301
- this.redraw();
2302
- return;
2303
2360
  }
2304
2361
  if (this.overlay) {
2305
2362
  const before = this.overlay;
@@ -2443,7 +2500,14 @@ export class App {
2443
2500
  return;
2444
2501
  }
2445
2502
  if (ev.text.length === 1) {
2446
- const asKey = { type: "key", name: "char", key: ev.text, text: ev.text, ctrl: false, alt: false, shift: false };
2503
+ // Legacy terminals deliver Shift+letter as the UPPERCASE char with no
2504
+ // modifier info — recover the shift flag from the case so Shift+T
2505
+ // (todo fold) and G (scroll bottom) work everywhere.
2506
+ const asKey = {
2507
+ type: "key", name: "char",
2508
+ key: ev.text.toLowerCase(), text: ev.text,
2509
+ ctrl: false, alt: false, shift: ev.text !== ev.text.toLowerCase(),
2510
+ };
2447
2511
  if (this.chat.onKey(asKey)) { this.redraw(); return; }
2448
2512
  if (this.focused && this.focused !== this.chat.input && this.focused.onKey(asKey)) { this.redraw(); return; }
2449
2513
  this.toast("按 i 进入输入");