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.
- package/package.json +1 -1
- package/src/views.js +48 -9
package/package.json
CHANGED
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
|
-
|
|
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();
|