dsh-neotui 0.0.23 → 0.0.25

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 +59 -31
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.0.23",
3
+ "version": "0.0.25",
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,9 +565,12 @@ 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)
573
+ this.todoSeen = false; // once seen, the todo box keeps its height
571
574
  this.running = false;
572
575
  this.hasMore = false;
573
576
  this.loadingOlder = false;
@@ -715,11 +718,16 @@ export class ChatView extends Widget {
715
718
  }
716
719
  }
717
720
 
718
- /** Height of the collapsible todo block (0 when empty or collapsed). */
721
+ /** Height of the collapsible todo block. FROZEN at the max (1 header + 6
722
+ * items) once the session has ever shown todos: the harness updates the
723
+ * list in the background while the user reads, and a height that tracks
724
+ * the count reflows the whole layout every time (the idle 2-line shifts). */
719
725
  todoHeight() {
720
726
  const todos = this.app.todos;
721
- if (!this.todosVisible || !todos || todos.length === 0) return 0;
722
- return Math.min(todos.length, 6) + 1; // 1 header row + up to 6 todos
727
+ if (!this.todosVisible) return 0;
728
+ if (!todos || todos.length === 0) return this.todoSeen ? 7 : 0;
729
+ this.todoSeen = true;
730
+ return 7; // fixed max height — short lists just show fewer rows
723
731
  }
724
732
 
725
733
  inputChanged() {
@@ -906,28 +914,11 @@ export class ChatView extends Widget {
906
914
  this.#clickLog(`toggle mark=${JSON.stringify(info)} kind=${node.kind} blockIdx=${info.blockIdx} preHeaderRow=${preHeaderRow} topKey=${topKey} topOffset=${topOffset}`);
907
915
  }
908
916
  const reanchor = () => {
909
- // COLLAPSING a block by clicking one of its CONTENT lines: the clicked
910
- // text itself vanishes, and the content below slides up by the fold
911
- // size the "4-line offset" the user sees. Land the fold's LAST line
912
- // (the trailer/summary) exactly at the clicked row so the eye stays
913
- // anchored and nothing below visibly shifts.
914
- if (collapsing && pressY !== null && pressY !== preHeaderIdx && pressRow !== null && pressRow >= 0) {
915
- let lastIdx = -1;
916
- for (let i = 0; i < this.lineMap.length; i++) {
917
- if (match(this.lineMap[i])) lastIdx = i;
918
- }
919
- if (lastIdx >= 0) {
920
- const sy = lastIdx - pressRow;
921
- this.view.scrollY = Math.max(0, sy);
922
- if (sy > this.view.maxScroll()) this.view.anchorLock = sy;
923
- return;
924
- }
925
- }
917
+ // Fixed-height folds keep the buffer size UNCHANGED (the collapsed
918
+ // block pads its recorded height with ghost rows), so nothing below
919
+ // the fold can move the anchor is only a safety net now.
926
920
  // primary anchor: the clicked block's header stays at its pre-click
927
921
  // viewport row (zero shift) — only when the header was ON-SCREEN.
928
- // When the fold removed the tail content below, the anchored position
929
- // may exceed maxScroll: hold it via the view's anchorLock instead of
930
- // clamping (the clamp is exactly the slide the user sees).
931
922
  if (preHeaderRow !== null && preHeaderRow >= 0 && preHeaderIdx >= 0) {
932
923
  const h2 = firstNonEmpty(match);
933
924
  if (h2 >= 0) {
@@ -951,17 +942,25 @@ export class ChatView extends Widget {
951
942
  if (target > this.view.maxScroll()) this.view.anchorLock = target;
952
943
  };
953
944
  // never park the viewport on a blank separator row — it reads as a
954
- // spurious offset right after the click
945
+ // spurious offset right after the click. Ghost rows of a fixed-height
946
+ // fold are INTENTIONAL blanks (marked with a blockIdx) — leave them.
955
947
  const nudge = () => {
956
948
  while (
957
949
  this.view.scrollY < this.lineMap.length - 1 &&
958
- !(this.lines[this.view.scrollY] ?? []).some((g) => g.t.trim() !== "")
950
+ !(this.lines[this.view.scrollY] ?? []).some((g) => g.t.trim() !== "") &&
951
+ (this.lineMap[this.view.scrollY]?.blockIdx ?? null) === null
959
952
  ) this.view.scrollY++;
960
953
  };
961
954
  if (node.kind === "assistant" && info.blockIdx !== null) {
962
955
  const b = node.blocks[info.blockIdx];
963
956
  if (b && (b.kind === "tool" || b.kind === "reasoning" || b.kind === "other" || b.kind === "text")) {
964
957
  const key = `${info.nodeIdx}:${info.blockIdx}`;
958
+ // fixed-height fold: remember how tall the block is right now so the
959
+ // collapsed form can pad the SAME height (nothing below moves)
960
+ let firstM = -1, lastM = -1;
961
+ for (let i = 0; i < this.lineMap.length; i++) {
962
+ if (match(this.lineMap[i])) { if (firstM < 0) firstM = i; lastM = i; }
963
+ }
965
964
  if (b.kind === "reasoning") {
966
965
  // clean two-state override: expand ⇄ collapse (never a no-op click)
967
966
  const open = this.expanded.has(key) || (!this.collapsedBlocks.has(key) && this.thinkMode === "expanded");
@@ -973,6 +972,8 @@ export class ChatView extends Widget {
973
972
  if (this.collapsedBlocks.has(key)) this.collapsedBlocks.delete(key);
974
973
  else this.collapsedBlocks.add(key);
975
974
  }
975
+ if (collapsing && firstM >= 0) this.blockHeights.set(key, lastM - firstM + 1);
976
+ else if (!collapsing) this.blockHeights.delete(key);
976
977
  this.#rebuild();
977
978
  reanchor(); nudge();
978
979
  if (process.env.DSH_TUI_DEBUG_CLICK) {
@@ -1116,6 +1117,7 @@ export class ChatView extends Widget {
1116
1117
  timing = " 已完成";
1117
1118
  }
1118
1119
  const thinkMeta = `${stepTag}(${b.text?.length ?? 0} 字)${timing}`;
1120
+ const blkLines0 = lines.length;
1119
1121
  lines.push([{ t: "💭 思考" + (b.streaming ? "…" : "") + thinkMeta + (open ? " [t 折叠]" : " [t 展开]"), fg: K.FAINT }]);
1120
1122
  mark(realIdx, bi);
1121
1123
  if (open) {
@@ -1128,6 +1130,13 @@ export class ChatView extends Widget {
1128
1130
  mark(realIdx, bi);
1129
1131
  }
1130
1132
  }
1133
+ // fixed-height fold: pad the recorded height with ghost rows so
1134
+ // nothing below the block moves
1135
+ if (!open) {
1136
+ const h = this.blockHeights.get(key) ?? 0;
1137
+ const filler = Math.max(0, h - (lines.length - blkLines0));
1138
+ for (let fi = 0; fi < filler; fi++) { lines.push([{ t: "" }]); mark(realIdx, bi); }
1139
+ }
1131
1140
  sep();
1132
1141
  } else if (b.kind === "tool") {
1133
1142
  const key = `${realIdx}:${bi}`;
@@ -1142,6 +1151,7 @@ export class ChatView extends Widget {
1142
1151
  const glyph = orphan ? "✗" : running ? "⏳" : exitCode !== undefined && exitCode !== 0 ? "✗" : "✓";
1143
1152
  const card = b.view ? renderToolCard(b.view, w, open) : [];
1144
1153
  beginCard(status);
1154
+ const blkLines0 = lines.length;
1145
1155
  let timing = "";
1146
1156
  if (running) {
1147
1157
  timing = ` 已经过 ${fmtDuration(Date.now() - (b.startedAt ?? Date.now()))}`;
@@ -1165,6 +1175,11 @@ export class ChatView extends Widget {
1165
1175
  lines.push([{ t: " " + truncate(summary, w - 6), fg: K.FAINT }]);
1166
1176
  mark(realIdx, bi);
1167
1177
  }
1178
+ // fixed-height fold: pad the recorded height with ghost rows so
1179
+ // nothing below the block moves
1180
+ const h = this.blockHeights.get(key) ?? 0;
1181
+ const filler = Math.max(0, h - (lines.length - blkLines0));
1182
+ for (let fi = 0; fi < filler; fi++) { lines.push([{ t: "" }]); mark(realIdx, bi); }
1168
1183
  }
1169
1184
  if (open) {
1170
1185
  for (const ln of card) { lines.push(ln); mark(realIdx, bi); }
@@ -1187,6 +1202,7 @@ export class ChatView extends Widget {
1187
1202
  sep();
1188
1203
  } else {
1189
1204
  beginCard("CARD");
1205
+ const blkLines0 = lines.length;
1190
1206
  // Text blocks collapse per-block like tool/reasoning blocks:
1191
1207
  // collapsed shows a 3-line preview + trailer, expanded the whole
1192
1208
  // text. Clicking (or the right-click 展开/折叠) toggles it.
@@ -1210,6 +1226,13 @@ export class ChatView extends Widget {
1210
1226
  lines.push([{ t: ` …共 ${text.length} 字(点击展开)`, fg: K.FAINT }]);
1211
1227
  mark(realIdx, bi);
1212
1228
  }
1229
+ // fixed-height fold: pad the recorded height with ghost rows so
1230
+ // nothing below the block moves
1231
+ if (!open) {
1232
+ const h = this.blockHeights.get(key) ?? 0;
1233
+ const filler = Math.max(0, h - (lines.length - blkLines0));
1234
+ for (let fi = 0; fi < filler; fi++) { lines.push([{ t: "" }]); mark(realIdx, bi); }
1235
+ }
1213
1236
  sep();
1214
1237
  }
1215
1238
  }
@@ -1702,7 +1725,9 @@ export class App {
1702
1725
  }
1703
1726
 
1704
1727
  footerHeight() {
1705
- return 2 + (this.jobs?.length ? 1 : 0);
1728
+ // Constant 3 rows: the jobs summary row is always present, so jobs
1729
+ // arriving/completing in the background never reflow the layout.
1730
+ return 3;
1706
1731
  }
1707
1732
 
1708
1733
  layout() {
@@ -2271,6 +2296,7 @@ export class App {
2271
2296
  this.chat.cache.clear();
2272
2297
  this.chat.nodes = [];
2273
2298
  this.chat.collapsedBlocks.clear();
2299
+ this.chat.blockHeights.clear();
2274
2300
  this.chat.expanded.clear();
2275
2301
  this.chat.expandedTools.clear();
2276
2302
  this.chat.queueRebuild();
@@ -2841,11 +2867,13 @@ export class App {
2841
2867
  if (stats.ttftMs) row1.right.push({ t: ` 首响${Math.round(stats.ttftMs / stats.ttftSteps)}ms `, fg: T.FAINT, bg: T.STATUSBG });
2842
2868
  }
2843
2869
  rows.push(row1);
2844
- // ── row 2: background jobs — one summary line, never per-job noise ──
2845
- if (this.jobs?.length) {
2846
- const running = this.jobs.filter((j) => j.status === "running").length;
2847
- const done = this.jobs.filter((j) => j.status === "completed").length;
2848
- const failed = this.jobs.filter((j) => j.status === "failed").length;
2870
+ // ── row 2: background jobs — one summary line, always present so its
2871
+ // appearance/disappearance never reflows the layout ──
2872
+ {
2873
+ const jobs = this.jobs ?? [];
2874
+ const running = jobs.filter((j) => j.status === "running").length;
2875
+ const done = jobs.filter((j) => j.status === "completed").length;
2876
+ const failed = jobs.filter((j) => j.status === "failed").length;
2849
2877
  const row2 = { left: [], right: [] };
2850
2878
  row2.left.push({
2851
2879
  t: ` ${running > 0 ? `${running} 个任务正在后台运行` : "没有任务正在后台运行"} `,