dsh-neotui 0.0.18 → 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 +79 -27
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.0.18",
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
@@ -817,7 +819,8 @@ export class ChatView extends Widget {
817
819
  send(text) {
818
820
  if (!this.sessionId) return;
819
821
  const trimmed = text.trim();
820
- if (trimmed === "/reload") { this.app.reloadApp(); return; }
822
+ if (trimmed === "/reload") { this.app.softReload(); return; }
823
+ if (trimmed === "/restart") { this.app.restartApp(); return; }
821
824
  if (!trimmed) return;
822
825
  const { parts, images, errors } = buildPromptParts(trimmed, {
823
826
  readFile: (p) => {
@@ -844,20 +847,13 @@ export class ChatView extends Widget {
844
847
  return this.#toggleAt(this.lineMap?.[y]);
845
848
  }
846
849
 
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)
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) {
861
857
  const lineKey = (m) => (m ? `${m.nodeIdx}:${m.blockIdx ?? "n"}` : null);
862
858
  const topKey = lineKey(this.lineMap[this.view.scrollY]) ?? null;
863
859
  let topFirst = -1;
@@ -867,23 +863,39 @@ export class ChatView extends Widget {
867
863
  }
868
864
  }
869
865
  const topOffset = topFirst >= 0 ? this.view.scrollY - topFirst : 0;
870
- const match = info.blockIdx !== null
871
- ? (m) => m?.nodeIdx === info.nodeIdx && m?.blockIdx === info.blockIdx
872
- : (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;
873
869
  const firstNonEmpty = (m) => {
874
870
  for (let i = 0; i < this.lineMap.length; i++) {
875
871
  if (m(this.lineMap[i]) && (this.lines[i] ?? []).some((g) => g.t.trim() !== "")) return i;
876
872
  }
877
873
  return -1;
878
874
  };
879
- const preHeaderIdx = firstNonEmpty(match);
875
+ const preHeaderIdx = info ? firstNonEmpty(match) : -1;
880
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);
881
893
  const reanchor = () => {
882
894
  // primary anchor: the clicked block's header stays at its pre-click
883
895
  // viewport row (zero shift) — only when the header was ON-SCREEN.
884
896
  // When the fold removed the tail content below, the anchored position
885
897
  // 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).
898
+ // clamping (the clamp is exactly the slide the user sees).
887
899
  if (preHeaderRow !== null && preHeaderRow >= 0 && preHeaderIdx >= 0) {
888
900
  const h2 = firstNonEmpty(match);
889
901
  if (h2 >= 0) {
@@ -1314,6 +1326,11 @@ export class ChatView extends Widget {
1314
1326
  if (ev.kind === "wheel-up" && this.view.scrollY === 0 && this.hasMore) { this.loadOlder(); return true; }
1315
1327
  if (ev.kind === "press" && ev.button === 0) {
1316
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;
1317
1334
  return true;
1318
1335
  }
1319
1336
  if (ev.kind === "drag" && ev.button === 0 && this.pressY !== null) {
@@ -1329,6 +1346,7 @@ export class ChatView extends Widget {
1329
1346
  const wasPress = this.pressY;
1330
1347
  this.pressY = null;
1331
1348
  if (this.selStart !== null && this.selEnd !== null) {
1349
+ this.pressInfo = null; this.pressCtx = null;
1332
1350
  const text = this.lines.slice(this.selStart, this.selEnd + 1).map((l) => l.map((g) => g.t).join("")).join("\n");
1333
1351
  const rows = this.selEnd - this.selStart + 1;
1334
1352
  this.selStart = this.selEnd = null;
@@ -1337,7 +1355,9 @@ export class ChatView extends Widget {
1337
1355
  return true;
1338
1356
  }
1339
1357
  this.selStart = this.selEnd = null;
1340
- this.#clickLine(wasPress, ev);
1358
+ this.#toggleAt(this.pressInfo, this.pressCtx);
1359
+ this.pressInfo = null;
1360
+ this.pressCtx = null;
1341
1361
  return true;
1342
1362
  }
1343
1363
  if (ev.kind === "press" && ev.button === 2) {
@@ -2194,17 +2214,49 @@ export class App {
2194
2214
  showModePicker() { this.overlay = buildModePicker(this); this.redraw(); }
2195
2215
  showPermissionPicker() { this.overlay = buildPermissionPicker(this); this.redraw(); }
2196
2216
 
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…");
2217
+ /** /reload: in-place soft reload fresh session list, fresh chat history,
2218
+ * panels rebuilt, screen re-rendered. No process churn, no terminal
2219
+ * handoff (the old process-restart approach leaked mouse bytes into the
2220
+ * boot of the new instance). */
2221
+ async softReload() {
2222
+ this.closeOverlay();
2223
+ this.menu = null;
2224
+ this.popup = null;
2225
+ for (const p of [this.workspacePanel, this.trajectoryPanel, this.settingsPanel, this.subagentPanel, this.skillsPanel]) {
2226
+ if (p?.dispose) { try { p.dispose(); } catch {} }
2227
+ }
2228
+ this.workspacePanel = this.trajectoryPanel = this.settingsPanel = this.subagentPanel = this.skillsPanel = null;
2229
+ this.chat.cache.clear();
2230
+ this.chat.nodes = [];
2231
+ this.chat.collapsedBlocks.clear();
2232
+ this.chat.expanded.clear();
2233
+ this.chat.expandedTools.clear();
2234
+ this.chat.queueRebuild();
2235
+ this.chat.view.anchorLock = null;
2236
+ this.mode = "chat";
2237
+ this.focus(this.chat);
2238
+ this.toast("正在重新加载…");
2239
+ await this.refreshSessions();
2240
+ if (this.currentSession) await this.openSession(this.currentSession);
2241
+ else await this.newSessionIn(null);
2242
+ this.layout();
2243
+ this.redraw();
2244
+ this.toast("已重新加载会话与界面");
2245
+ }
2246
+
2247
+ /** /restart: restart the TUI process in the same terminal so a freshly
2248
+ * published build (the profile symlinks the repo) takes effect. The new
2249
+ * instance starts one second after this process restores the terminal —
2250
+ * no stray mouse/keyboard bytes leak into its boot. */
2251
+ async restartApp() {
2252
+ this.toast("正在重启 TUI(加载新版本代码)…");
2202
2253
  this.redraw();
2203
2254
  await new Promise((r) => setTimeout(r, 250));
2204
2255
  try { this.term?.stop?.(); } catch {}
2205
2256
  try {
2206
2257
  const { spawn } = await import("node:child_process");
2207
- const child = spawn(process.argv[0], process.argv.slice(1), { detached: true, stdio: "inherit" });
2258
+ const argv = process.argv.slice(1);
2259
+ const child = spawn("sh", ["-c", 'sleep 1; exec "$@"', "sh", ...argv], { detached: true, stdio: "inherit" });
2208
2260
  child.unref();
2209
2261
  } catch (e) {
2210
2262
  this.toast(`重启失败: ${e.message}(请手动重启)`);