dsh-neotui 0.0.15 → 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 +47 -20
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.0.15",
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++) {
@@ -874,8 +899,10 @@ export class ChatView extends Widget {
874
899
  if (first < 0) return;
875
900
  const target = Math.min(first + topOffset, last);
876
901
  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
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 = () => {
879
906
  while (
880
907
  this.view.scrollY < this.lineMap.length - 1 &&
881
908
  !(this.lines[this.view.scrollY] ?? []).some((g) => g.t.trim() !== "")
@@ -895,7 +922,7 @@ export class ChatView extends Widget {
895
922
  else this.collapsedBlocks.add(key);
896
923
  }
897
924
  this.#rebuild();
898
- reanchor();
925
+ reanchor(); nudge();
899
926
  return true;
900
927
  }
901
928
  }
@@ -903,7 +930,7 @@ export class ChatView extends Widget {
903
930
  if (this.expanded.has(info.nodeIdx)) this.expanded.delete(info.nodeIdx);
904
931
  else this.expanded.add(info.nodeIdx);
905
932
  this.#rebuild();
906
- reanchor();
933
+ reanchor(); nudge();
907
934
  return true;
908
935
  }
909
936
  }