dsh-neotui 0.0.19 → 0.0.20

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 +39 -20
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.0.19",
3
+ "version": "0.0.20",
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
@@ -590,6 +590,8 @@ export class ChatView extends Widget {
590
590
  this.cardRanges = []; // absolute line ranges of card-backed message blocks
591
591
  this.welcomeModes = []; // absolute row y → agent preset id (welcome screen)
592
592
  this.pressY = null;
593
+ this.pressInfo = null; // hit identity locked at press time
594
+ this.pressCtx = null;
593
595
  this.selStart = null;
594
596
  this.selEnd = null;
595
597
  this.stepState = { step: null }; // step/start tracking for the mux merge path
@@ -845,20 +847,13 @@ export class ChatView extends Widget {
845
847
  return this.#toggleAt(this.lineMap?.[y]);
846
848
  }
847
849
 
848
- /** Toggle the block/node under a line-mark, then re-anchor the viewport.
849
- * `info` must be the mark of the frame the user clicked on. */
850
- #toggleAt(info) {
851
- if (!info) return false;
852
- if (info.imgIdx !== undefined) {
853
- const node = this.nodes[info.nodeIdx];
854
- const ref = node?.images?.[info.imgIdx];
855
- if (ref) { this.app.openImage(ref, { all: node.images, index: info.imgIdx }); return true; }
856
- }
857
- const node = this.nodes[info.nodeIdx];
858
- if (!node) return false;
859
- // anchor context captured from the frame the user saw:
860
- // - the clicked block's header line + its viewport row (primary anchor)
861
- // - the viewport-top line's identity + offset (fallback for deep clicks)
850
+ /** Anchor context for a click: the clicked block's header line + its
851
+ * viewport row (primary anchor) and the viewport-top identity + offset
852
+ * (fallback for deep clicks). Captured at PRESS time so the stream cannot
853
+ * shift the hit identity between press and release (the 4-line mystery:
854
+ * the rendered frame and the hit test were consistent, but the identity
855
+ * was re-resolved at release against a stream that had moved). */
856
+ #anchorCtx(info) {
862
857
  const lineKey = (m) => (m ? `${m.nodeIdx}:${m.blockIdx ?? "n"}` : null);
863
858
  const topKey = lineKey(this.lineMap[this.view.scrollY]) ?? null;
864
859
  let topFirst = -1;
@@ -868,23 +863,39 @@ export class ChatView extends Widget {
868
863
  }
869
864
  }
870
865
  const topOffset = topFirst >= 0 ? this.view.scrollY - topFirst : 0;
871
- const match = info.blockIdx !== null
872
- ? (m) => m?.nodeIdx === info.nodeIdx && m?.blockIdx === info.blockIdx
873
- : (m) => m?.nodeIdx === info.nodeIdx;
866
+ const match = info?.blockIdx !== null
867
+ ? (m) => m?.nodeIdx === info?.nodeIdx && m?.blockIdx === info?.blockIdx
868
+ : (m) => m?.nodeIdx === info?.nodeIdx;
874
869
  const firstNonEmpty = (m) => {
875
870
  for (let i = 0; i < this.lineMap.length; i++) {
876
871
  if (m(this.lineMap[i]) && (this.lines[i] ?? []).some((g) => g.t.trim() !== "")) return i;
877
872
  }
878
873
  return -1;
879
874
  };
880
- const preHeaderIdx = firstNonEmpty(match);
875
+ const preHeaderIdx = info ? firstNonEmpty(match) : -1;
881
876
  const preHeaderRow = preHeaderIdx >= 0 ? preHeaderIdx - this.view.scrollY : null;
877
+ return { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow };
878
+ }
879
+
880
+ /** Toggle the block/node under a line-mark, then re-anchor the viewport.
881
+ * `info` must be the mark of the frame the user clicked on; `ctx` its
882
+ * press-time anchor context. */
883
+ #toggleAt(info, ctx = null) {
884
+ if (!info) return false;
885
+ if (info.imgIdx !== undefined) {
886
+ const node = this.nodes[info.nodeIdx];
887
+ const ref = node?.images?.[info.imgIdx];
888
+ if (ref) { this.app.openImage(ref, { all: node.images, index: info.imgIdx }); return true; }
889
+ }
890
+ const node = this.nodes[info.nodeIdx];
891
+ if (!node) return false;
892
+ const { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow } = ctx ?? this.#anchorCtx(info);
882
893
  const reanchor = () => {
883
894
  // primary anchor: the clicked block's header stays at its pre-click
884
895
  // viewport row (zero shift) — only when the header was ON-SCREEN.
885
896
  // When the fold removed the tail content below, the anchored position
886
897
  // may exceed maxScroll: hold it via the view's anchorLock instead of
887
- // clamping (the clamp is exactly the 5–6 line slide the user sees).
898
+ // clamping (the clamp is exactly the slide the user sees).
888
899
  if (preHeaderRow !== null && preHeaderRow >= 0 && preHeaderIdx >= 0) {
889
900
  const h2 = firstNonEmpty(match);
890
901
  if (h2 >= 0) {
@@ -1315,6 +1326,11 @@ export class ChatView extends Widget {
1315
1326
  if (ev.kind === "wheel-up" && this.view.scrollY === 0 && this.hasMore) { this.loadOlder(); return true; }
1316
1327
  if (ev.kind === "press" && ev.button === 0) {
1317
1328
  this.pressY = ev.y - this.view.y + this.view.scrollY;
1329
+ // LOCK the hit identity at press time: the stream keeps growing
1330
+ // between press and release, so resolving the block at release would
1331
+ // toggle a block a few lines away from the one the user saw.
1332
+ this.pressInfo = this.lineMap?.[this.pressY] ?? null;
1333
+ this.pressCtx = this.pressInfo ? this.#anchorCtx(this.pressInfo) : null;
1318
1334
  return true;
1319
1335
  }
1320
1336
  if (ev.kind === "drag" && ev.button === 0 && this.pressY !== null) {
@@ -1330,6 +1346,7 @@ export class ChatView extends Widget {
1330
1346
  const wasPress = this.pressY;
1331
1347
  this.pressY = null;
1332
1348
  if (this.selStart !== null && this.selEnd !== null) {
1349
+ this.pressInfo = null; this.pressCtx = null;
1333
1350
  const text = this.lines.slice(this.selStart, this.selEnd + 1).map((l) => l.map((g) => g.t).join("")).join("\n");
1334
1351
  const rows = this.selEnd - this.selStart + 1;
1335
1352
  this.selStart = this.selEnd = null;
@@ -1338,7 +1355,9 @@ export class ChatView extends Widget {
1338
1355
  return true;
1339
1356
  }
1340
1357
  this.selStart = this.selEnd = null;
1341
- this.#clickLine(wasPress, ev);
1358
+ this.#toggleAt(this.pressInfo, this.pressCtx);
1359
+ this.pressInfo = null;
1360
+ this.pressCtx = null;
1342
1361
  return true;
1343
1362
  }
1344
1363
  if (ev.kind === "press" && ev.button === 2) {