dsh-neotui 0.0.16 → 0.0.17

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 +98 -80
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.0.16",
3
+ "version": "0.0.17",
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
@@ -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,72 @@ 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
+ if (preHeaderRow !== null && preHeaderRow >= 0 && preHeaderIdx >= 0) {
885
+ const h2 = firstNonEmpty(match);
886
+ if (h2 >= 0) {
887
+ this.view.scrollY = Math.max(0, Math.min(h2 - preHeaderRow, this.view.maxScroll()));
888
+ return;
927
889
  }
928
890
  }
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);
891
+ // fallback (deep content click): restore the viewport-top position
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
+ }
932
924
  this.#rebuild();
933
925
  reanchor(); nudge();
934
926
  return true;
935
927
  }
936
928
  }
929
+ if (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);
932
+ this.#rebuild();
933
+ reanchor(); nudge();
934
+ return true;
935
+ }
937
936
  return false;
938
937
  }
939
938
 
@@ -1336,14 +1335,14 @@ export class ChatView extends Widget {
1336
1335
  return true;
1337
1336
  }
1338
1337
  if (ev.kind === "press" && ev.button === 2) {
1339
- this.flushRebuild(); // keep lineMap current with the nodes array
1338
+ // map against the frame the user saw — no flush (see #clickLine)
1340
1339
  const y = ev.y - this.view.y + this.view.scrollY;
1341
1340
  const info = this.lineMap?.[y];
1342
1341
  if (info) {
1343
1342
  const node = this.nodes[info.nodeIdx];
1344
1343
  const items = [
1345
1344
  { label: "复制消息", action: () => this.app.copyNode(info.nodeIdx) },
1346
- { label: "展开 / 折叠", action: () => this.#clickLine(y, ev) },
1345
+ { label: "展开 / 折叠", action: () => this.#toggleAt(info) },
1347
1346
  ];
1348
1347
  if (node?.id) {
1349
1348
  items.push({ label: "转跳轨迹", action: () => this.app.jumpToTrajectoryNode(info.nodeIdx) });
@@ -2189,6 +2188,25 @@ export class App {
2189
2188
  showModePicker() { this.overlay = buildModePicker(this); this.redraw(); }
2190
2189
  showPermissionPicker() { this.overlay = buildPermissionPicker(this); this.redraw(); }
2191
2190
 
2191
+ /** /reload: restart the TUI process in the same terminal so a freshly
2192
+ * published build (the profile symlinks the repo) takes effect without
2193
+ * quitting and re-running `dsh` by hand. */
2194
+ async reloadApp() {
2195
+ this.toast("正在重启 TUI…");
2196
+ this.redraw();
2197
+ await new Promise((r) => setTimeout(r, 250));
2198
+ try { this.term?.stop?.(); } catch {}
2199
+ try {
2200
+ const { spawn } = await import("node:child_process");
2201
+ const child = spawn(process.argv[0], process.argv.slice(1), { detached: true, stdio: "inherit" });
2202
+ child.unref();
2203
+ } catch (e) {
2204
+ this.toast(`重启失败: ${e.message}(请手动重启)`);
2205
+ return;
2206
+ }
2207
+ process.exit(0);
2208
+ }
2209
+
2192
2210
  /** Ctrl+E: fzf-style quick jump to a step — type to fuzzy-filter the step
2193
2211
  * list, Enter opens the trajectory window around the picked step. */
2194
2212
  async quickJumpStep() {