dsh-neotui 0.0.16 → 0.0.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
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
@@ -696,7 +696,7 @@ export class ChatView extends Widget {
696
696
  if (idx < 0 || idx >= this.nodes.length) return false;
697
697
  for (let li = 0; li < this.lineMap.length; li++) {
698
698
  if (this.lineMap[li]?.nodeIdx === idx) {
699
- this.view.scrollY = Math.max(0, li - 2);
699
+ this.view.anchorLock = null; this.view.scrollY = Math.max(0, li - 2);
700
700
  this.app.redraw();
701
701
  return true;
702
702
  }
@@ -770,7 +770,7 @@ export class ChatView extends Widget {
770
770
  this.#rebuild();
771
771
  // Jump to the LIVE tail (the newest content) on open — the view would
772
772
  // otherwise sit at the top showing the oldest turns.
773
- this.view.scrollY = this.view.maxScroll();
773
+ this.view.anchorLock = null; this.view.scrollY = this.view.maxScroll();
774
774
  }
775
775
 
776
776
  async loadOlder() {
@@ -817,6 +817,7 @@ export class ChatView extends Widget {
817
817
  send(text) {
818
818
  if (!this.sessionId) return;
819
819
  const trimmed = text.trim();
820
+ if (trimmed === "/reload") { this.app.reloadApp(); return; }
820
821
  if (!trimmed) return;
821
822
  const { parts, images, errors } = buildPromptParts(trimmed, {
822
823
  readFile: (p) => {
@@ -836,12 +837,29 @@ export class ChatView extends Widget {
836
837
  }
837
838
 
838
839
  #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.
840
+ // NO flush here: the click maps against the exact frame the user saw.
841
+ // Flushing would re-derive the streaming tail (the chunk-stream and the
842
+ // poll snapshot produce DIFFERENT block structures) and make y point at a
843
+ // different block the source of the 5–6 line offsets.
844
+ return this.#toggleAt(this.lineMap?.[y]);
845
+ }
846
+
847
+ /** Toggle the block/node under a line-mark, then re-anchor the viewport.
848
+ * `info` must be the mark of the frame the user clicked on. */
849
+ #toggleAt(info) {
850
+ if (!info) return false;
851
+ if (info.imgIdx !== undefined) {
852
+ const node = this.nodes[info.nodeIdx];
853
+ const ref = node?.images?.[info.imgIdx];
854
+ if (ref) { this.app.openImage(ref, { all: node.images, index: info.imgIdx }); return true; }
855
+ }
856
+ const node = this.nodes[info.nodeIdx];
857
+ if (!node) return false;
858
+ // anchor context captured from the frame the user saw:
859
+ // - the clicked block's header line + its viewport row (primary anchor)
860
+ // - the viewport-top line's identity + offset (fallback for deep clicks)
843
861
  const lineKey = (m) => (m ? `${m.nodeIdx}:${m.blockIdx ?? "n"}` : null);
844
- const topKey = lineKey(this.lineMap?.[this.view.scrollY]) ?? null;
862
+ const topKey = lineKey(this.lineMap[this.view.scrollY]) ?? null;
845
863
  let topFirst = -1;
846
864
  if (topKey !== null) {
847
865
  for (let i = 0; i < this.lineMap.length; i++) {
@@ -849,91 +867,78 @@ export class ChatView extends Widget {
849
867
  }
850
868
  }
851
869
  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) => {
870
+ const match = info.blockIdx !== null
871
+ ? (m) => m?.nodeIdx === info.nodeIdx && m?.blockIdx === info.blockIdx
872
+ : (m) => m?.nodeIdx === info.nodeIdx;
873
+ const firstNonEmpty = (m) => {
859
874
  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;
875
+ if (m(this.lineMap[i]) && (this.lines[i] ?? []).some((g) => g.t.trim() !== "")) return i;
861
876
  }
862
877
  return -1;
863
878
  };
864
- const preHeaderIdx = preInfo ? firstNonEmpty(clickedMatch) : -1;
879
+ const preHeaderIdx = firstNonEmpty(match);
865
880
  const preHeaderRow = preHeaderIdx >= 0 ? preHeaderIdx - this.view.scrollY : null;
866
- // A queued (deferred) rebuild would leave lineMap one frame behind the
867
- // nodes array; flush now so the click maps to the CURRENT node/block.
868
- this.flushRebuild();
869
- // map rendered line back to node via cached line→node map
870
- const info = this.lineMap?.[y];
871
- if (info) {
872
- if (info.imgIdx !== undefined) {
873
- const node = this.nodes[info.nodeIdx];
874
- const ref = node?.images?.[info.imgIdx];
875
- if (ref) { this.app.openImage(ref, { all: node.images, index: info.imgIdx }); return true; }
876
- }
877
- const node = this.nodes[info.nodeIdx];
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.
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
- }
892
- if (topKey === null || topFirst < 0) return;
893
- let first = -1, last = -1;
894
- for (let i = 0; i < this.lineMap.length; i++) {
895
- if (lineKey(this.lineMap[i]) !== topKey) continue;
896
- if (first < 0) first = i;
897
- last = i;
898
- }
899
- if (first < 0) return;
900
- const target = Math.min(first + topOffset, last);
901
- this.view.scrollY = Math.max(0, Math.min(target, this.view.maxScroll()));
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
- };
911
- if (node?.kind === "assistant" && info.blockIdx !== null) {
912
- const b = node.blocks[info.blockIdx];
913
- if (b && (b.kind === "tool" || b.kind === "reasoning" || b.kind === "other" || b.kind === "text")) {
914
- const key = `${info.nodeIdx}:${info.blockIdx}`;
915
- if (b.kind === "reasoning") {
916
- // clean two-state override: expand ⇄ collapse (never a no-op click)
917
- const open = this.expanded.has(key) || (!this.collapsedBlocks.has(key) && this.thinkMode === "expanded");
918
- if (open) { this.expanded.delete(key); this.collapsedBlocks.add(key); }
919
- else { this.collapsedBlocks.delete(key); this.expanded.add(key); }
920
- } else {
921
- if (this.collapsedBlocks.has(key)) this.collapsedBlocks.delete(key);
922
- else this.collapsedBlocks.add(key);
923
- }
924
- this.#rebuild();
925
- reanchor(); nudge();
926
- return true;
881
+ const reanchor = () => {
882
+ // primary anchor: the clicked block's header stays at its pre-click
883
+ // viewport row (zero shift) — only when the header was ON-SCREEN.
884
+ // When the fold removed the tail content below, the anchored position
885
+ // may exceed maxScroll: hold it via the view's anchorLock instead of
886
+ // clamping (the clamp is exactly the 5–6 line slide the user sees).
887
+ if (preHeaderRow !== null && preHeaderRow >= 0 && preHeaderIdx >= 0) {
888
+ const h2 = firstNonEmpty(match);
889
+ if (h2 >= 0) {
890
+ const sy = h2 - preHeaderRow;
891
+ this.view.scrollY = Math.max(0, sy);
892
+ if (sy > this.view.maxScroll()) this.view.anchorLock = sy;
893
+ return;
927
894
  }
928
895
  }
929
- if (node && (node.kind === "assistant" || node.kind === "user")) {
930
- if (this.expanded.has(info.nodeIdx)) this.expanded.delete(info.nodeIdx);
931
- else this.expanded.add(info.nodeIdx);
896
+ // fallback (deep content click): restore the viewport-top position
897
+ if (topKey === null || topFirst < 0) return;
898
+ let first = -1, last = -1;
899
+ for (let i = 0; i < this.lineMap.length; i++) {
900
+ if (lineKey(this.lineMap[i]) !== topKey) continue;
901
+ if (first < 0) first = i;
902
+ last = i;
903
+ }
904
+ if (first < 0) return;
905
+ const target = Math.min(first + topOffset, last);
906
+ this.view.scrollY = Math.max(0, target);
907
+ if (target > this.view.maxScroll()) this.view.anchorLock = target;
908
+ };
909
+ // never park the viewport on a blank separator row — it reads as a
910
+ // spurious offset right after the click
911
+ const nudge = () => {
912
+ while (
913
+ this.view.scrollY < this.lineMap.length - 1 &&
914
+ !(this.lines[this.view.scrollY] ?? []).some((g) => g.t.trim() !== "")
915
+ ) this.view.scrollY++;
916
+ };
917
+ if (node.kind === "assistant" && info.blockIdx !== null) {
918
+ const b = node.blocks[info.blockIdx];
919
+ if (b && (b.kind === "tool" || b.kind === "reasoning" || b.kind === "other" || b.kind === "text")) {
920
+ const key = `${info.nodeIdx}:${info.blockIdx}`;
921
+ if (b.kind === "reasoning") {
922
+ // clean two-state override: expand ⇄ collapse (never a no-op click)
923
+ const open = this.expanded.has(key) || (!this.collapsedBlocks.has(key) && this.thinkMode === "expanded");
924
+ if (open) { this.expanded.delete(key); this.collapsedBlocks.add(key); }
925
+ else { this.collapsedBlocks.delete(key); this.expanded.add(key); }
926
+ } else {
927
+ if (this.collapsedBlocks.has(key)) this.collapsedBlocks.delete(key);
928
+ else this.collapsedBlocks.add(key);
929
+ }
932
930
  this.#rebuild();
933
931
  reanchor(); nudge();
934
932
  return true;
935
933
  }
936
934
  }
935
+ if (node.kind === "assistant" || node.kind === "user") {
936
+ if (this.expanded.has(info.nodeIdx)) this.expanded.delete(info.nodeIdx);
937
+ else this.expanded.add(info.nodeIdx);
938
+ this.#rebuild();
939
+ reanchor(); nudge();
940
+ return true;
941
+ }
937
942
  return false;
938
943
  }
939
944
 
@@ -1336,14 +1341,14 @@ export class ChatView extends Widget {
1336
1341
  return true;
1337
1342
  }
1338
1343
  if (ev.kind === "press" && ev.button === 2) {
1339
- this.flushRebuild(); // keep lineMap current with the nodes array
1344
+ // map against the frame the user saw — no flush (see #clickLine)
1340
1345
  const y = ev.y - this.view.y + this.view.scrollY;
1341
1346
  const info = this.lineMap?.[y];
1342
1347
  if (info) {
1343
1348
  const node = this.nodes[info.nodeIdx];
1344
1349
  const items = [
1345
1350
  { label: "复制消息", action: () => this.app.copyNode(info.nodeIdx) },
1346
- { label: "展开 / 折叠", action: () => this.#clickLine(y, ev) },
1351
+ { label: "展开 / 折叠", action: () => this.#toggleAt(info) },
1347
1352
  ];
1348
1353
  if (node?.id) {
1349
1354
  items.push({ label: "转跳轨迹", action: () => this.app.jumpToTrajectoryNode(info.nodeIdx) });
@@ -1381,12 +1386,12 @@ export class ChatView extends Widget {
1381
1386
  case "pgdn": return this.view.scroll(this.view.h);
1382
1387
  }
1383
1388
  if (ev.name === "char" && ev.key === "g" && !ev.ctrl && !ev.shift) {
1384
- if (this.gKey) { this.gKey = false; this.view.scrollY = 0; return true; }
1389
+ if (this.gKey) { this.gKey = false; this.view.anchorLock = null; this.view.scrollY = 0; return true; }
1385
1390
  this.gKey = true;
1386
1391
  this.app.toast("再按 g 回顶");
1387
1392
  return true;
1388
1393
  }
1389
- if (ev.name === "char" && ev.key === "g" && ev.shift) { this.view.scrollY = this.view.maxScroll(); return true; }
1394
+ if (ev.name === "char" && ev.key === "g" && ev.shift) { this.view.anchorLock = null; this.view.scrollY = this.view.maxScroll(); return true; }
1390
1395
  if (ev.name === "escape" && this.app.searchQuery) { this.app.searchQuery = null; this.queueRebuild(); return true; }
1391
1396
  if (ev.name === "escape" && this.selStart !== null) { this.selStart = this.selEnd = null; this.app.redraw(); return true; }
1392
1397
  if (ev.name === "char" && ev.key === "i" && !ev.ctrl) { this.app.focus(this.input); return true; }
@@ -2189,6 +2194,25 @@ export class App {
2189
2194
  showModePicker() { this.overlay = buildModePicker(this); this.redraw(); }
2190
2195
  showPermissionPicker() { this.overlay = buildPermissionPicker(this); this.redraw(); }
2191
2196
 
2197
+ /** /reload: restart the TUI process in the same terminal so a freshly
2198
+ * published build (the profile symlinks the repo) takes effect without
2199
+ * quitting and re-running `dsh` by hand. */
2200
+ async reloadApp() {
2201
+ this.toast("正在重启 TUI…");
2202
+ this.redraw();
2203
+ await new Promise((r) => setTimeout(r, 250));
2204
+ try { this.term?.stop?.(); } catch {}
2205
+ try {
2206
+ const { spawn } = await import("node:child_process");
2207
+ const child = spawn(process.argv[0], process.argv.slice(1), { detached: true, stdio: "inherit" });
2208
+ child.unref();
2209
+ } catch (e) {
2210
+ this.toast(`重启失败: ${e.message}(请手动重启)`);
2211
+ return;
2212
+ }
2213
+ process.exit(0);
2214
+ }
2215
+
2192
2216
  /** Ctrl+E: fzf-style quick jump to a step — type to fuzzy-filter the step
2193
2217
  * list, Enter opens the trajectory window around the picked step. */
2194
2218
  async quickJumpStep() {
package/src/widgets.js CHANGED
@@ -27,6 +27,7 @@ export class ScrollView extends Widget {
27
27
  super(opts);
28
28
  this.lines = []; // array of arrays of segs: {t, fg, bg, bold, italic, underline, strike, code, link}
29
29
  this.scrollY = 0;
30
+ this.anchorLock = null; // click anchor held beyond maxScroll (fold at the tail)
30
31
  this.autoScroll = opts.autoScroll ?? false;
31
32
  this.onClick = opts.onClick ?? null; // (y, ev) => bool
32
33
  this.onWheel = null; // optional custom wheel handler
@@ -36,7 +37,12 @@ export class ScrollView extends Widget {
36
37
  setLines(lines, { keep = false } = {}) {
37
38
  const atBottom = this.autoScroll && this.scrollY + this.h >= this.lines.length - 1 || (keep && this.scrollY + this.h >= this.lines.length);
38
39
  this.lines = lines;
39
- if (atBottom || this.scrollY > Math.max(0, this.lines.length - this.h)) {
40
+ if (this.anchorLock != null) {
41
+ // A click fold removed the tail content: hold the exact anchored
42
+ // position even though the buffer now ends above it (no delayed snap).
43
+ this.scrollY = this.anchorLock;
44
+ if (this.scrollY <= Math.max(0, this.lines.length - this.h)) this.anchorLock = null;
45
+ } else if (atBottom || this.scrollY > Math.max(0, this.lines.length - this.h)) {
40
46
  this.scrollY = Math.max(0, this.lines.length - this.h);
41
47
  }
42
48
  }
@@ -44,6 +50,7 @@ export class ScrollView extends Widget {
44
50
  maxScroll() { return Math.max(0, this.lines.length - this.h); }
45
51
  scroll(dy) {
46
52
  const before = this.scrollY;
53
+ this.anchorLock = null; // explicit scroll releases the click anchor
47
54
  this.scrollY = Math.max(0, Math.min(this.maxScroll(), this.scrollY + dy));
48
55
  return this.scrollY !== before;
49
56
  }
@@ -78,7 +85,8 @@ export class ScrollView extends Widget {
78
85
  const trackH = Math.max(1, this.h - 2);
79
86
  const total = Math.max(1, this.lines.length);
80
87
  const thumbH = Math.max(1, Math.floor(this.h * this.h / total));
81
- const thumbY = Math.floor((this.h - 2) * this.scrollY / Math.max(1, this.maxScroll()));
88
+ const frac = Math.min(1, this.scrollY / Math.max(1, this.maxScroll()));
89
+ const thumbY = Math.floor((this.h - 2) * frac);
82
90
  for (let i = 0; i < this.h; i++) {
83
91
  const inThumb = i >= 1 + thumbY && i < 1 + thumbY + thumbH;
84
92
  const inTrack = i >= 1 && i < this.h - 1;
@@ -114,6 +122,7 @@ export class ScrollView extends Widget {
114
122
  return false;
115
123
  }
116
124
  #scrubTo(ey) {
125
+ this.anchorLock = null; // scrubbing releases the click anchor
117
126
  const trackH = Math.max(1, this.h - 2);
118
127
  const total = Math.max(1, this.lines.length);
119
128
  const thumbH = Math.max(1, Math.floor(this.h * this.h / total));