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.
- package/package.json +1 -1
- package/src/views.js +59 -31
package/package.json
CHANGED
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
|
|
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
|
|
722
|
-
|
|
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
|
-
//
|
|
910
|
-
//
|
|
911
|
-
//
|
|
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
|
-
|
|
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,
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
const
|
|
2848
|
-
const
|
|
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} 个任务正在后台运行` : "没有任务正在后台运行"} `,
|