dsh-neotui 0.0.11 → 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
@@ -101,7 +101,7 @@ tui/app/ dsh-neotui-app bundle:cordis.patch.yml + tui-startup/tui-r
101
101
  - 会话内搜索:**Ctrl+F** 模糊搜本会话消息/工具名,回车跳转
102
102
  - 轨迹详情:点击回合行弹窗(工具调用 + 耗时),`/` 过滤回合;**增量加载**(每页 20 回合逐页渲染,最近回合秒开,历史步骤按需翻页,不再阻塞等待全量历史)
103
103
  - 轨迹 详细/简略:每个 step 左键单击(▸/▾)或右键菜单第一项 →「展开(详细)/ 折叠(简略)」内联展开事件列表(仿对话页);跳转定位的 step 自动展开 + 高亮闪烁
104
- - 轨迹窗口模型:转跳只显示 `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 始终唯一
105
105
  - 工作区文件搜索:工作区面板内直接打字(`/`)模糊搜文件名,回车预览
106
106
  - 消息反馈:助手消息右键 👍/👎(`messageFeedback/put` Typert RPC,`{request:…}` 载荷),已评状态回显、可删除
107
107
  - 图片画廊:多图时 ←/→ 切换,标题显示 (N/M) + 尺寸
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.0.11",
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,6 +848,25 @@ 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
872
  if (b && (b.kind === "tool" || b.kind === "reasoning" || b.kind === "other" || b.kind === "text")) {
@@ -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
  }