dsh-neotui 0.0.22 → 0.0.24

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 +48 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.0.22",
3
+ "version": "0.0.24",
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
@@ -565,6 +565,8 @@ export class ChatView extends Widget {
565
565
  this.expanded = new Set(); // node indexes (user-message full text)
566
566
  this.expandedTools = new Set();
567
567
  this.collapsedBlocks = new Set(); // per-block COLLAPSE (default expanded): `${realIdx}:${bi}`
568
+ this.blockHeights = new Map(); // collapsed block → its rendered height when collapsed
569
+ // (fixed-height fold: the space below stays put)
568
570
  this.thinkMode = "expanded"; // think blocks: expanded by default (t toggles)
569
571
  this.bashMode = "expanded"; // tool blocks: expanded | collapsed (b toggles)
570
572
  this.todosVisible = true; // todo block above the input (Shift+T toggles)
@@ -863,7 +865,7 @@ export class ChatView extends Widget {
863
865
  * shift the hit identity between press and release (the 4-line mystery:
864
866
  * the rendered frame and the hit test were consistent, but the identity
865
867
  * was re-resolved at release against a stream that had moved). */
866
- #anchorCtx(info) {
868
+ #anchorCtx(info, pressY = null) {
867
869
  const lineKey = (m) => (m ? `${m.nodeIdx}:${m.blockIdx ?? "n"}` : null);
868
870
  const topKey = lineKey(this.lineMap[this.view.scrollY]) ?? null;
869
871
  let topFirst = -1;
@@ -884,7 +886,8 @@ export class ChatView extends Widget {
884
886
  };
885
887
  const preHeaderIdx = info ? firstNonEmpty(match) : -1;
886
888
  const preHeaderRow = preHeaderIdx >= 0 ? preHeaderIdx - this.view.scrollY : null;
887
- return { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow };
889
+ const pressRow = pressY !== null && pressY !== undefined && pressY >= 0 ? pressY - this.view.scrollY : null;
890
+ return { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow, pressY, pressRow };
888
891
  }
889
892
 
890
893
  /** Toggle the block/node under a line-mark, then re-anchor the viewport.
@@ -899,16 +902,17 @@ export class ChatView extends Widget {
899
902
  }
900
903
  const node = this.nodes[info.nodeIdx];
901
904
  if (!node) return false;
902
- const { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow } = ctx ?? this.#anchorCtx(info);
905
+ const { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow, pressY, pressRow } = ctx ?? this.#anchorCtx(info);
906
+ let collapsing = false;
903
907
  if (process.env.DSH_TUI_DEBUG_CLICK) {
904
908
  this.#clickLog(`toggle mark=${JSON.stringify(info)} kind=${node.kind} blockIdx=${info.blockIdx} preHeaderRow=${preHeaderRow} topKey=${topKey} topOffset=${topOffset}`);
905
909
  }
906
910
  const reanchor = () => {
911
+ // Fixed-height folds keep the buffer size UNCHANGED (the collapsed
912
+ // block pads its recorded height with ghost rows), so nothing below
913
+ // the fold can move — the anchor is only a safety net now.
907
914
  // primary anchor: the clicked block's header stays at its pre-click
908
915
  // viewport row (zero shift) — only when the header was ON-SCREEN.
909
- // When the fold removed the tail content below, the anchored position
910
- // may exceed maxScroll: hold it via the view's anchorLock instead of
911
- // clamping (the clamp is exactly the slide the user sees).
912
916
  if (preHeaderRow !== null && preHeaderRow >= 0 && preHeaderIdx >= 0) {
913
917
  const h2 = firstNonEmpty(match);
914
918
  if (h2 >= 0) {
@@ -932,26 +936,38 @@ export class ChatView extends Widget {
932
936
  if (target > this.view.maxScroll()) this.view.anchorLock = target;
933
937
  };
934
938
  // never park the viewport on a blank separator row — it reads as a
935
- // spurious offset right after the click
939
+ // spurious offset right after the click. Ghost rows of a fixed-height
940
+ // fold are INTENTIONAL blanks (marked with a blockIdx) — leave them.
936
941
  const nudge = () => {
937
942
  while (
938
943
  this.view.scrollY < this.lineMap.length - 1 &&
939
- !(this.lines[this.view.scrollY] ?? []).some((g) => g.t.trim() !== "")
944
+ !(this.lines[this.view.scrollY] ?? []).some((g) => g.t.trim() !== "") &&
945
+ (this.lineMap[this.view.scrollY]?.blockIdx ?? null) === null
940
946
  ) this.view.scrollY++;
941
947
  };
942
948
  if (node.kind === "assistant" && info.blockIdx !== null) {
943
949
  const b = node.blocks[info.blockIdx];
944
950
  if (b && (b.kind === "tool" || b.kind === "reasoning" || b.kind === "other" || b.kind === "text")) {
945
951
  const key = `${info.nodeIdx}:${info.blockIdx}`;
952
+ // fixed-height fold: remember how tall the block is right now so the
953
+ // collapsed form can pad the SAME height (nothing below moves)
954
+ let firstM = -1, lastM = -1;
955
+ for (let i = 0; i < this.lineMap.length; i++) {
956
+ if (match(this.lineMap[i])) { if (firstM < 0) firstM = i; lastM = i; }
957
+ }
946
958
  if (b.kind === "reasoning") {
947
959
  // clean two-state override: expand ⇄ collapse (never a no-op click)
948
960
  const open = this.expanded.has(key) || (!this.collapsedBlocks.has(key) && this.thinkMode === "expanded");
961
+ collapsing = open;
949
962
  if (open) { this.expanded.delete(key); this.collapsedBlocks.add(key); }
950
963
  else { this.collapsedBlocks.delete(key); this.expanded.add(key); }
951
964
  } else {
965
+ collapsing = !this.collapsedBlocks.has(key);
952
966
  if (this.collapsedBlocks.has(key)) this.collapsedBlocks.delete(key);
953
967
  else this.collapsedBlocks.add(key);
954
968
  }
969
+ if (collapsing && firstM >= 0) this.blockHeights.set(key, lastM - firstM + 1);
970
+ else if (!collapsing) this.blockHeights.delete(key);
955
971
  this.#rebuild();
956
972
  reanchor(); nudge();
957
973
  if (process.env.DSH_TUI_DEBUG_CLICK) {
@@ -1095,6 +1111,7 @@ export class ChatView extends Widget {
1095
1111
  timing = " 已完成";
1096
1112
  }
1097
1113
  const thinkMeta = `${stepTag}(${b.text?.length ?? 0} 字)${timing}`;
1114
+ const blkLines0 = lines.length;
1098
1115
  lines.push([{ t: "💭 思考" + (b.streaming ? "…" : "") + thinkMeta + (open ? " [t 折叠]" : " [t 展开]"), fg: K.FAINT }]);
1099
1116
  mark(realIdx, bi);
1100
1117
  if (open) {
@@ -1107,6 +1124,13 @@ export class ChatView extends Widget {
1107
1124
  mark(realIdx, bi);
1108
1125
  }
1109
1126
  }
1127
+ // fixed-height fold: pad the recorded height with ghost rows so
1128
+ // nothing below the block moves
1129
+ if (!open) {
1130
+ const h = this.blockHeights.get(key) ?? 0;
1131
+ const filler = Math.max(0, h - (lines.length - blkLines0));
1132
+ for (let fi = 0; fi < filler; fi++) { lines.push([{ t: "" }]); mark(realIdx, bi); }
1133
+ }
1110
1134
  sep();
1111
1135
  } else if (b.kind === "tool") {
1112
1136
  const key = `${realIdx}:${bi}`;
@@ -1121,6 +1145,7 @@ export class ChatView extends Widget {
1121
1145
  const glyph = orphan ? "✗" : running ? "⏳" : exitCode !== undefined && exitCode !== 0 ? "✗" : "✓";
1122
1146
  const card = b.view ? renderToolCard(b.view, w, open) : [];
1123
1147
  beginCard(status);
1148
+ const blkLines0 = lines.length;
1124
1149
  let timing = "";
1125
1150
  if (running) {
1126
1151
  timing = ` 已经过 ${fmtDuration(Date.now() - (b.startedAt ?? Date.now()))}`;
@@ -1144,6 +1169,11 @@ export class ChatView extends Widget {
1144
1169
  lines.push([{ t: " " + truncate(summary, w - 6), fg: K.FAINT }]);
1145
1170
  mark(realIdx, bi);
1146
1171
  }
1172
+ // fixed-height fold: pad the recorded height with ghost rows so
1173
+ // nothing below the block moves
1174
+ const h = this.blockHeights.get(key) ?? 0;
1175
+ const filler = Math.max(0, h - (lines.length - blkLines0));
1176
+ for (let fi = 0; fi < filler; fi++) { lines.push([{ t: "" }]); mark(realIdx, bi); }
1147
1177
  }
1148
1178
  if (open) {
1149
1179
  for (const ln of card) { lines.push(ln); mark(realIdx, bi); }
@@ -1166,6 +1196,7 @@ export class ChatView extends Widget {
1166
1196
  sep();
1167
1197
  } else {
1168
1198
  beginCard("CARD");
1199
+ const blkLines0 = lines.length;
1169
1200
  // Text blocks collapse per-block like tool/reasoning blocks:
1170
1201
  // collapsed shows a 3-line preview + trailer, expanded the whole
1171
1202
  // text. Clicking (or the right-click 展开/折叠) toggles it.
@@ -1189,6 +1220,13 @@ export class ChatView extends Widget {
1189
1220
  lines.push([{ t: ` …共 ${text.length} 字(点击展开)`, fg: K.FAINT }]);
1190
1221
  mark(realIdx, bi);
1191
1222
  }
1223
+ // fixed-height fold: pad the recorded height with ghost rows so
1224
+ // nothing below the block moves
1225
+ if (!open) {
1226
+ const h = this.blockHeights.get(key) ?? 0;
1227
+ const filler = Math.max(0, h - (lines.length - blkLines0));
1228
+ for (let fi = 0; fi < filler; fi++) { lines.push([{ t: "" }]); mark(realIdx, bi); }
1229
+ }
1192
1230
  sep();
1193
1231
  }
1194
1232
  }
@@ -1347,7 +1385,7 @@ export class ChatView extends Widget {
1347
1385
  // between press and release, so resolving the block at release would
1348
1386
  // toggle a block a few lines away from the one the user saw.
1349
1387
  this.pressInfo = this.lineMap?.[this.pressY] ?? null;
1350
- this.pressCtx = this.pressInfo ? this.#anchorCtx(this.pressInfo) : null;
1388
+ this.pressCtx = this.pressInfo ? this.#anchorCtx(this.pressInfo, this.pressY) : null;
1351
1389
  if (process.env.DSH_TUI_DEBUG_CLICK) {
1352
1390
  const t = this.lines[this.pressY]?.map((g) => g.t).join("") ?? "";
1353
1391
  this.#clickLog(`press screenY=${ev.y} screenX=${ev.x} lineIdx=${this.pressY} mark=${JSON.stringify(this.pressInfo)} text="${t.slice(0, 40)}" scrollY=${this.view.scrollY} viewY=${this.view.y} viewH=${this.view.h}`);
@@ -2250,6 +2288,7 @@ export class App {
2250
2288
  this.chat.cache.clear();
2251
2289
  this.chat.nodes = [];
2252
2290
  this.chat.collapsedBlocks.clear();
2291
+ this.chat.blockHeights.clear();
2253
2292
  this.chat.expanded.clear();
2254
2293
  this.chat.expandedTools.clear();
2255
2294
  this.chat.queueRebuild();