dsh-neotui 0.0.13 → 0.0.15

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/views.js +46 -20
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
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/views.js CHANGED
@@ -849,22 +849,37 @@ export class ChatView extends Widget {
849
849
  }
850
850
  const node = this.nodes[info.nodeIdx];
851
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. This applies ALWAYS even at the
855
- // bottom: the tail-follow snap would otherwise fling the clicked header
856
- // out of view when a folded block re-expands.
857
- const reanchor = (match) => {
858
- let fallback = -1;
852
+ // leave scrollY pointing at unrelated content. Anchor: remember the
853
+ // viewport-top line's identity AND its offset within that block, then
854
+ // restore the same position after the rebuild the view does not move
855
+ // at all when the top line survives (e.g. clicking a block header), and
856
+ // lands on the fold's last line when the top line itself was collapsed.
857
+ const lineKey = (m) => (m ? `${m.nodeIdx}:${m.blockIdx ?? "n"}` : null);
858
+ const topKey = lineKey(this.lineMap[this.view.scrollY]);
859
+ let topFirst = -1;
860
+ if (topKey !== null) {
859
861
  for (let i = 0; i < this.lineMap.length; i++) {
860
- if (!match(this.lineMap[i])) continue;
861
- if ((this.lines[i] ?? []).some((g) => g.t.trim() !== "")) {
862
- this.view.scrollY = Math.max(0, Math.min(i, this.view.maxScroll()));
863
- return;
864
- }
865
- if (fallback < 0) fallback = i;
862
+ if (lineKey(this.lineMap[i]) === topKey) { topFirst = i; break; }
863
+ }
864
+ }
865
+ const topOffset = topFirst >= 0 ? this.view.scrollY - topFirst : 0;
866
+ const reanchor = () => {
867
+ if (topKey === null || topFirst < 0) return;
868
+ let first = -1, last = -1;
869
+ for (let i = 0; i < this.lineMap.length; i++) {
870
+ if (lineKey(this.lineMap[i]) !== topKey) continue;
871
+ if (first < 0) first = i;
872
+ last = i;
866
873
  }
867
- if (fallback >= 0) this.view.scrollY = Math.max(0, Math.min(fallback, this.view.maxScroll()));
874
+ if (first < 0) return;
875
+ const target = Math.min(first + topOffset, last);
876
+ this.view.scrollY = Math.max(0, Math.min(target, this.view.maxScroll()));
877
+ // never park the viewport on a blank separator row — it reads as a
878
+ // spurious offset right after the click
879
+ while (
880
+ this.view.scrollY < this.lineMap.length - 1 &&
881
+ !(this.lines[this.view.scrollY] ?? []).some((g) => g.t.trim() !== "")
882
+ ) this.view.scrollY++;
868
883
  };
869
884
  if (node?.kind === "assistant" && info.blockIdx !== null) {
870
885
  const b = node.blocks[info.blockIdx];
@@ -880,7 +895,7 @@ export class ChatView extends Widget {
880
895
  else this.collapsedBlocks.add(key);
881
896
  }
882
897
  this.#rebuild();
883
- reanchor((m) => m?.nodeIdx === info.nodeIdx && m?.blockIdx === info.blockIdx);
898
+ reanchor();
884
899
  return true;
885
900
  }
886
901
  }
@@ -888,7 +903,7 @@ export class ChatView extends Widget {
888
903
  if (this.expanded.has(info.nodeIdx)) this.expanded.delete(info.nodeIdx);
889
904
  else this.expanded.add(info.nodeIdx);
890
905
  this.#rebuild();
891
- reanchor((m) => m?.nodeIdx === info.nodeIdx);
906
+ reanchor();
892
907
  return true;
893
908
  }
894
909
  }
@@ -1007,12 +1022,16 @@ export class ChatView extends Widget {
1007
1022
  const open = manuallyExpanded || (!manuallyCollapsed && this.thinkMode === "expanded");
1008
1023
  beginCard("THINKBG");
1009
1024
  // Live blocks tick (已经过…); finished blocks freeze at their
1010
- // total (已完成,耗时…). No timestamps no bogus timer shown.
1025
+ // total (已完成,耗时…). Snapshot-derived blocks may lack a
1026
+ // per-block start (the history projection has no block-start):
1027
+ // show a plain 已完成 then — never a bogus ticking timer.
1011
1028
  let timing = "";
1012
1029
  if (b.streaming) {
1013
1030
  timing = ` 已经过 ${fmtDuration(Date.now() - (b.startedAt ?? Date.now()))}`;
1014
1031
  } else if (b.startedAt !== undefined && b.endedAt !== undefined) {
1015
1032
  timing = ` 已完成,耗时 ${fmtDuration(b.endedAt - b.startedAt)}`;
1033
+ } else if (b.endedAt !== undefined) {
1034
+ timing = " 已完成";
1016
1035
  }
1017
1036
  const thinkMeta = `${stepTag}(${b.text?.length ?? 0} 字)${timing}`;
1018
1037
  lines.push([{ t: "💭 思考" + (b.streaming ? "…" : "") + thinkMeta + (open ? " [t 折叠]" : " [t 展开]"), fg: K.FAINT }]);
@@ -1032,16 +1051,23 @@ export class ChatView extends Widget {
1032
1051
  const key = `${realIdx}:${bi}`;
1033
1052
  const open = this.bashMode !== "collapsed" && !this.collapsedBlocks.has(key);
1034
1053
  const exitCode = b.view?.view?.exitCode;
1035
- const status = b.result == null && !b.done ? "TOOLBG" : exitCode !== undefined && exitCode !== 0 ? "TOOLERR" : "TOOLOK";
1036
- const glyph = b.result == null && !b.done ? "⏳" : exitCode !== undefined && exitCode !== 0 ? "✗" : "✓";
1054
+ // A tool only TICKS while its turn is live. A finalized turn
1055
+ // whose result never matched (orphan) must freeze at 无结果
1056
+ // otherwise the timer runs forever ("timing chaos").
1057
+ const running = b.result == null && !b.done && node.streaming;
1058
+ const orphan = b.result == null && !b.done && !node.streaming;
1059
+ const status = orphan ? "TOOLERR" : running ? "TOOLBG" : exitCode !== undefined && exitCode !== 0 ? "TOOLERR" : "TOOLOK";
1060
+ const glyph = orphan ? "✗" : running ? "⏳" : exitCode !== undefined && exitCode !== 0 ? "✗" : "✓";
1037
1061
  const card = b.view ? renderToolCard(b.view, w, open) : [];
1038
1062
  beginCard(status);
1039
1063
  let timing = "";
1040
- if (b.result == null && !b.done) {
1064
+ if (running) {
1041
1065
  timing = ` 已经过 ${fmtDuration(Date.now() - (b.startedAt ?? Date.now()))}`;
1042
1066
  } else if (b.startedAt !== undefined && b.endedAt !== undefined) {
1043
1067
  const failed = exitCode !== undefined && exitCode !== 0;
1044
1068
  timing = ` ${failed ? "失败" : "已完成"},耗时 ${fmtDuration(b.endedAt - b.startedAt)}`;
1069
+ } else if (orphan) {
1070
+ timing = " 无结果";
1045
1071
  }
1046
1072
  lines.push([
1047
1073
  { t: open ? "▾ " : "▸ ", fg: K.ACCENT },