dsh-neotui 0.0.14 → 0.0.16

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 +66 -22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.0.14",
3
+ "version": "0.0.16",
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
@@ -836,8 +836,35 @@ export class ChatView extends Widget {
836
836
  }
837
837
 
838
838
  #clickLine(y, ev) {
839
+ // Capture the viewport geometry BEFORE any flush: the flush can grow the
840
+ // streaming tail (and atBottom snaps the view 1–2 rows), so anchoring on
841
+ // the post-flush state would reproduce exactly the shift the user saw.
842
+ // The pre-flush lineMap is what the user clicked on.
843
+ const lineKey = (m) => (m ? `${m.nodeIdx}:${m.blockIdx ?? "n"}` : null);
844
+ const topKey = lineKey(this.lineMap?.[this.view.scrollY]) ?? null;
845
+ let topFirst = -1;
846
+ if (topKey !== null) {
847
+ for (let i = 0; i < this.lineMap.length; i++) {
848
+ if (lineKey(this.lineMap[i]) === topKey) { topFirst = i; break; }
849
+ }
850
+ }
851
+ const topOffset = topFirst >= 0 ? this.view.scrollY - topFirst : 0;
852
+ // the clicked block's header (first non-empty line with its mark) and its
853
+ // pre-click viewport row — the PRIMARY anchor: keep the clicked thing put
854
+ const preInfo = this.lineMap?.[y];
855
+ const clickedMatch = preInfo?.blockIdx !== null
856
+ ? (m) => m?.nodeIdx === preInfo?.nodeIdx && m?.blockIdx === preInfo?.blockIdx
857
+ : (m) => m?.nodeIdx === preInfo?.nodeIdx;
858
+ const firstNonEmpty = (match) => {
859
+ for (let i = 0; i < this.lineMap.length; i++) {
860
+ if (match(this.lineMap[i]) && (this.lines[i] ?? []).some((g) => g.t.trim() !== "")) return i;
861
+ }
862
+ return -1;
863
+ };
864
+ const preHeaderIdx = preInfo ? firstNonEmpty(clickedMatch) : -1;
865
+ const preHeaderRow = preHeaderIdx >= 0 ? preHeaderIdx - this.view.scrollY : null;
839
866
  // A queued (deferred) rebuild would leave lineMap one frame behind the
840
- // nodes array; flush first so the click maps to the CURRENT node/block.
867
+ // nodes array; flush now so the click maps to the CURRENT node/block.
841
868
  this.flushRebuild();
842
869
  // map rendered line back to node via cached line→node map
843
870
  const info = this.lineMap?.[y];
@@ -848,22 +875,20 @@ export class ChatView extends Widget {
848
875
  if (ref) { this.app.openImage(ref, { all: node.images, index: info.imgIdx }); return true; }
849
876
  }
850
877
  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. 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) {
861
- for (let i = 0; i < this.lineMap.length; i++) {
862
- if (lineKey(this.lineMap[i]) === topKey) { topFirst = i; break; }
863
- }
864
- }
865
- const topOffset = topFirst >= 0 ? this.view.scrollY - topFirst : 0;
878
+ // Expand/collapse changes the line count around the click. Primary
879
+ // anchor: the clicked block's header stays at its pre-click viewport
880
+ // row (zero shift). Fallback (header was above the viewport — a deep
881
+ // content click): restore the pre-click viewport-top position.
866
882
  const reanchor = () => {
883
+ // primary anchor applies only when the header was ON-SCREEN (a deep
884
+ // content click has the header above the viewport — use the fallback)
885
+ if (preHeaderRow !== null && preHeaderRow >= 0 && preHeaderIdx >= 0) {
886
+ const h2 = firstNonEmpty((m) => m?.nodeIdx === info.nodeIdx && m?.blockIdx === info.blockIdx);
887
+ if (h2 >= 0) {
888
+ this.view.scrollY = Math.max(0, Math.min(h2 - preHeaderRow, this.view.maxScroll()));
889
+ return;
890
+ }
891
+ }
867
892
  if (topKey === null || topFirst < 0) return;
868
893
  let first = -1, last = -1;
869
894
  for (let i = 0; i < this.lineMap.length; i++) {
@@ -875,6 +900,14 @@ export class ChatView extends Widget {
875
900
  const target = Math.min(first + topOffset, last);
876
901
  this.view.scrollY = Math.max(0, Math.min(target, this.view.maxScroll()));
877
902
  };
903
+ // never park the viewport on a blank separator row — it reads as a
904
+ // spurious offset right after the click
905
+ const nudge = () => {
906
+ while (
907
+ this.view.scrollY < this.lineMap.length - 1 &&
908
+ !(this.lines[this.view.scrollY] ?? []).some((g) => g.t.trim() !== "")
909
+ ) this.view.scrollY++;
910
+ };
878
911
  if (node?.kind === "assistant" && info.blockIdx !== null) {
879
912
  const b = node.blocks[info.blockIdx];
880
913
  if (b && (b.kind === "tool" || b.kind === "reasoning" || b.kind === "other" || b.kind === "text")) {
@@ -889,7 +922,7 @@ export class ChatView extends Widget {
889
922
  else this.collapsedBlocks.add(key);
890
923
  }
891
924
  this.#rebuild();
892
- reanchor();
925
+ reanchor(); nudge();
893
926
  return true;
894
927
  }
895
928
  }
@@ -897,7 +930,7 @@ export class ChatView extends Widget {
897
930
  if (this.expanded.has(info.nodeIdx)) this.expanded.delete(info.nodeIdx);
898
931
  else this.expanded.add(info.nodeIdx);
899
932
  this.#rebuild();
900
- reanchor();
933
+ reanchor(); nudge();
901
934
  return true;
902
935
  }
903
936
  }
@@ -1016,12 +1049,16 @@ export class ChatView extends Widget {
1016
1049
  const open = manuallyExpanded || (!manuallyCollapsed && this.thinkMode === "expanded");
1017
1050
  beginCard("THINKBG");
1018
1051
  // Live blocks tick (已经过…); finished blocks freeze at their
1019
- // total (已完成,耗时…). No timestamps no bogus timer shown.
1052
+ // total (已完成,耗时…). Snapshot-derived blocks may lack a
1053
+ // per-block start (the history projection has no block-start):
1054
+ // show a plain 已完成 then — never a bogus ticking timer.
1020
1055
  let timing = "";
1021
1056
  if (b.streaming) {
1022
1057
  timing = ` 已经过 ${fmtDuration(Date.now() - (b.startedAt ?? Date.now()))}`;
1023
1058
  } else if (b.startedAt !== undefined && b.endedAt !== undefined) {
1024
1059
  timing = ` 已完成,耗时 ${fmtDuration(b.endedAt - b.startedAt)}`;
1060
+ } else if (b.endedAt !== undefined) {
1061
+ timing = " 已完成";
1025
1062
  }
1026
1063
  const thinkMeta = `${stepTag}(${b.text?.length ?? 0} 字)${timing}`;
1027
1064
  lines.push([{ t: "💭 思考" + (b.streaming ? "…" : "") + thinkMeta + (open ? " [t 折叠]" : " [t 展开]"), fg: K.FAINT }]);
@@ -1041,16 +1078,23 @@ export class ChatView extends Widget {
1041
1078
  const key = `${realIdx}:${bi}`;
1042
1079
  const open = this.bashMode !== "collapsed" && !this.collapsedBlocks.has(key);
1043
1080
  const exitCode = b.view?.view?.exitCode;
1044
- const status = b.result == null && !b.done ? "TOOLBG" : exitCode !== undefined && exitCode !== 0 ? "TOOLERR" : "TOOLOK";
1045
- const glyph = b.result == null && !b.done ? "⏳" : exitCode !== undefined && exitCode !== 0 ? "✗" : "✓";
1081
+ // A tool only TICKS while its turn is live. A finalized turn
1082
+ // whose result never matched (orphan) must freeze at 无结果
1083
+ // otherwise the timer runs forever ("timing chaos").
1084
+ const running = b.result == null && !b.done && node.streaming;
1085
+ const orphan = b.result == null && !b.done && !node.streaming;
1086
+ const status = orphan ? "TOOLERR" : running ? "TOOLBG" : exitCode !== undefined && exitCode !== 0 ? "TOOLERR" : "TOOLOK";
1087
+ const glyph = orphan ? "✗" : running ? "⏳" : exitCode !== undefined && exitCode !== 0 ? "✗" : "✓";
1046
1088
  const card = b.view ? renderToolCard(b.view, w, open) : [];
1047
1089
  beginCard(status);
1048
1090
  let timing = "";
1049
- if (b.result == null && !b.done) {
1091
+ if (running) {
1050
1092
  timing = ` 已经过 ${fmtDuration(Date.now() - (b.startedAt ?? Date.now()))}`;
1051
1093
  } else if (b.startedAt !== undefined && b.endedAt !== undefined) {
1052
1094
  const failed = exitCode !== undefined && exitCode !== 0;
1053
1095
  timing = ` ${failed ? "失败" : "已完成"},耗时 ${fmtDuration(b.endedAt - b.startedAt)}`;
1096
+ } else if (orphan) {
1097
+ timing = " 无结果";
1054
1098
  }
1055
1099
  lines.push([
1056
1100
  { t: open ? "▾ " : "▸ ", fg: K.ACCENT },