dsh-neotui 0.0.16 → 0.0.18
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 +108 -84
- package/src/widgets.js +11 -2
package/package.json
CHANGED
package/src/views.js
CHANGED
|
@@ -696,7 +696,7 @@ export class ChatView extends Widget {
|
|
|
696
696
|
if (idx < 0 || idx >= this.nodes.length) return false;
|
|
697
697
|
for (let li = 0; li < this.lineMap.length; li++) {
|
|
698
698
|
if (this.lineMap[li]?.nodeIdx === idx) {
|
|
699
|
-
this.view.scrollY = Math.max(0, li - 2);
|
|
699
|
+
this.view.anchorLock = null; this.view.scrollY = Math.max(0, li - 2);
|
|
700
700
|
this.app.redraw();
|
|
701
701
|
return true;
|
|
702
702
|
}
|
|
@@ -770,7 +770,7 @@ export class ChatView extends Widget {
|
|
|
770
770
|
this.#rebuild();
|
|
771
771
|
// Jump to the LIVE tail (the newest content) on open — the view would
|
|
772
772
|
// otherwise sit at the top showing the oldest turns.
|
|
773
|
-
this.view.scrollY = this.view.maxScroll();
|
|
773
|
+
this.view.anchorLock = null; this.view.scrollY = this.view.maxScroll();
|
|
774
774
|
}
|
|
775
775
|
|
|
776
776
|
async loadOlder() {
|
|
@@ -817,6 +817,7 @@ export class ChatView extends Widget {
|
|
|
817
817
|
send(text) {
|
|
818
818
|
if (!this.sessionId) return;
|
|
819
819
|
const trimmed = text.trim();
|
|
820
|
+
if (trimmed === "/reload") { this.app.reloadApp(); return; }
|
|
820
821
|
if (!trimmed) return;
|
|
821
822
|
const { parts, images, errors } = buildPromptParts(trimmed, {
|
|
822
823
|
readFile: (p) => {
|
|
@@ -836,12 +837,29 @@ export class ChatView extends Widget {
|
|
|
836
837
|
}
|
|
837
838
|
|
|
838
839
|
#clickLine(y, ev) {
|
|
839
|
-
//
|
|
840
|
-
//
|
|
841
|
-
//
|
|
842
|
-
//
|
|
840
|
+
// NO flush here: the click maps against the exact frame the user saw.
|
|
841
|
+
// Flushing would re-derive the streaming tail (the chunk-stream and the
|
|
842
|
+
// poll snapshot produce DIFFERENT block structures) and make y point at a
|
|
843
|
+
// different block — the source of the 5–6 line offsets.
|
|
844
|
+
return this.#toggleAt(this.lineMap?.[y]);
|
|
845
|
+
}
|
|
846
|
+
|
|
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)
|
|
843
861
|
const lineKey = (m) => (m ? `${m.nodeIdx}:${m.blockIdx ?? "n"}` : null);
|
|
844
|
-
const topKey = lineKey(this.lineMap
|
|
862
|
+
const topKey = lineKey(this.lineMap[this.view.scrollY]) ?? null;
|
|
845
863
|
let topFirst = -1;
|
|
846
864
|
if (topKey !== null) {
|
|
847
865
|
for (let i = 0; i < this.lineMap.length; i++) {
|
|
@@ -849,91 +867,78 @@ export class ChatView extends Widget {
|
|
|
849
867
|
}
|
|
850
868
|
}
|
|
851
869
|
const topOffset = topFirst >= 0 ? this.view.scrollY - topFirst : 0;
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
const
|
|
856
|
-
? (m) => m?.nodeIdx === preInfo?.nodeIdx && m?.blockIdx === preInfo?.blockIdx
|
|
857
|
-
: (m) => m?.nodeIdx === preInfo?.nodeIdx;
|
|
858
|
-
const firstNonEmpty = (match) => {
|
|
870
|
+
const match = info.blockIdx !== null
|
|
871
|
+
? (m) => m?.nodeIdx === info.nodeIdx && m?.blockIdx === info.blockIdx
|
|
872
|
+
: (m) => m?.nodeIdx === info.nodeIdx;
|
|
873
|
+
const firstNonEmpty = (m) => {
|
|
859
874
|
for (let i = 0; i < this.lineMap.length; i++) {
|
|
860
|
-
if (
|
|
875
|
+
if (m(this.lineMap[i]) && (this.lines[i] ?? []).some((g) => g.t.trim() !== "")) return i;
|
|
861
876
|
}
|
|
862
877
|
return -1;
|
|
863
878
|
};
|
|
864
|
-
const preHeaderIdx =
|
|
879
|
+
const preHeaderIdx = firstNonEmpty(match);
|
|
865
880
|
const preHeaderRow = preHeaderIdx >= 0 ? preHeaderIdx - this.view.scrollY : null;
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
if (
|
|
873
|
-
const
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
// anchor: the clicked block's header stays at its pre-click viewport
|
|
880
|
-
// row (zero shift). Fallback (header was above the viewport — a deep
|
|
881
|
-
// content click): restore the pre-click viewport-top position.
|
|
882
|
-
const reanchor = () => {
|
|
883
|
-
// primary anchor applies only when the header was ON-SCREEN (a deep
|
|
884
|
-
// content click has the header above the viewport — use the fallback)
|
|
885
|
-
if (preHeaderRow !== null && preHeaderRow >= 0 && preHeaderIdx >= 0) {
|
|
886
|
-
const h2 = firstNonEmpty((m) => m?.nodeIdx === info.nodeIdx && m?.blockIdx === info.blockIdx);
|
|
887
|
-
if (h2 >= 0) {
|
|
888
|
-
this.view.scrollY = Math.max(0, Math.min(h2 - preHeaderRow, this.view.maxScroll()));
|
|
889
|
-
return;
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
if (topKey === null || topFirst < 0) return;
|
|
893
|
-
let first = -1, last = -1;
|
|
894
|
-
for (let i = 0; i < this.lineMap.length; i++) {
|
|
895
|
-
if (lineKey(this.lineMap[i]) !== topKey) continue;
|
|
896
|
-
if (first < 0) first = i;
|
|
897
|
-
last = i;
|
|
898
|
-
}
|
|
899
|
-
if (first < 0) return;
|
|
900
|
-
const target = Math.min(first + topOffset, last);
|
|
901
|
-
this.view.scrollY = Math.max(0, Math.min(target, this.view.maxScroll()));
|
|
902
|
-
};
|
|
903
|
-
// never park the viewport on a blank separator row — it reads as a
|
|
904
|
-
// spurious offset right after the click
|
|
905
|
-
const nudge = () => {
|
|
906
|
-
while (
|
|
907
|
-
this.view.scrollY < this.lineMap.length - 1 &&
|
|
908
|
-
!(this.lines[this.view.scrollY] ?? []).some((g) => g.t.trim() !== "")
|
|
909
|
-
) this.view.scrollY++;
|
|
910
|
-
};
|
|
911
|
-
if (node?.kind === "assistant" && info.blockIdx !== null) {
|
|
912
|
-
const b = node.blocks[info.blockIdx];
|
|
913
|
-
if (b && (b.kind === "tool" || b.kind === "reasoning" || b.kind === "other" || b.kind === "text")) {
|
|
914
|
-
const key = `${info.nodeIdx}:${info.blockIdx}`;
|
|
915
|
-
if (b.kind === "reasoning") {
|
|
916
|
-
// clean two-state override: expand ⇄ collapse (never a no-op click)
|
|
917
|
-
const open = this.expanded.has(key) || (!this.collapsedBlocks.has(key) && this.thinkMode === "expanded");
|
|
918
|
-
if (open) { this.expanded.delete(key); this.collapsedBlocks.add(key); }
|
|
919
|
-
else { this.collapsedBlocks.delete(key); this.expanded.add(key); }
|
|
920
|
-
} else {
|
|
921
|
-
if (this.collapsedBlocks.has(key)) this.collapsedBlocks.delete(key);
|
|
922
|
-
else this.collapsedBlocks.add(key);
|
|
923
|
-
}
|
|
924
|
-
this.#rebuild();
|
|
925
|
-
reanchor(); nudge();
|
|
926
|
-
return true;
|
|
881
|
+
const reanchor = () => {
|
|
882
|
+
// primary anchor: the clicked block's header stays at its pre-click
|
|
883
|
+
// viewport row (zero shift) — only when the header was ON-SCREEN.
|
|
884
|
+
// When the fold removed the tail content below, the anchored position
|
|
885
|
+
// 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).
|
|
887
|
+
if (preHeaderRow !== null && preHeaderRow >= 0 && preHeaderIdx >= 0) {
|
|
888
|
+
const h2 = firstNonEmpty(match);
|
|
889
|
+
if (h2 >= 0) {
|
|
890
|
+
const sy = h2 - preHeaderRow;
|
|
891
|
+
this.view.scrollY = Math.max(0, sy);
|
|
892
|
+
if (sy > this.view.maxScroll()) this.view.anchorLock = sy;
|
|
893
|
+
return;
|
|
927
894
|
}
|
|
928
895
|
}
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
896
|
+
// fallback (deep content click): restore the viewport-top position
|
|
897
|
+
if (topKey === null || topFirst < 0) return;
|
|
898
|
+
let first = -1, last = -1;
|
|
899
|
+
for (let i = 0; i < this.lineMap.length; i++) {
|
|
900
|
+
if (lineKey(this.lineMap[i]) !== topKey) continue;
|
|
901
|
+
if (first < 0) first = i;
|
|
902
|
+
last = i;
|
|
903
|
+
}
|
|
904
|
+
if (first < 0) return;
|
|
905
|
+
const target = Math.min(first + topOffset, last);
|
|
906
|
+
this.view.scrollY = Math.max(0, target);
|
|
907
|
+
if (target > this.view.maxScroll()) this.view.anchorLock = target;
|
|
908
|
+
};
|
|
909
|
+
// never park the viewport on a blank separator row — it reads as a
|
|
910
|
+
// spurious offset right after the click
|
|
911
|
+
const nudge = () => {
|
|
912
|
+
while (
|
|
913
|
+
this.view.scrollY < this.lineMap.length - 1 &&
|
|
914
|
+
!(this.lines[this.view.scrollY] ?? []).some((g) => g.t.trim() !== "")
|
|
915
|
+
) this.view.scrollY++;
|
|
916
|
+
};
|
|
917
|
+
if (node.kind === "assistant" && info.blockIdx !== null) {
|
|
918
|
+
const b = node.blocks[info.blockIdx];
|
|
919
|
+
if (b && (b.kind === "tool" || b.kind === "reasoning" || b.kind === "other" || b.kind === "text")) {
|
|
920
|
+
const key = `${info.nodeIdx}:${info.blockIdx}`;
|
|
921
|
+
if (b.kind === "reasoning") {
|
|
922
|
+
// clean two-state override: expand ⇄ collapse (never a no-op click)
|
|
923
|
+
const open = this.expanded.has(key) || (!this.collapsedBlocks.has(key) && this.thinkMode === "expanded");
|
|
924
|
+
if (open) { this.expanded.delete(key); this.collapsedBlocks.add(key); }
|
|
925
|
+
else { this.collapsedBlocks.delete(key); this.expanded.add(key); }
|
|
926
|
+
} else {
|
|
927
|
+
if (this.collapsedBlocks.has(key)) this.collapsedBlocks.delete(key);
|
|
928
|
+
else this.collapsedBlocks.add(key);
|
|
929
|
+
}
|
|
932
930
|
this.#rebuild();
|
|
933
931
|
reanchor(); nudge();
|
|
934
932
|
return true;
|
|
935
933
|
}
|
|
936
934
|
}
|
|
935
|
+
if (node.kind === "assistant" || node.kind === "user") {
|
|
936
|
+
if (this.expanded.has(info.nodeIdx)) this.expanded.delete(info.nodeIdx);
|
|
937
|
+
else this.expanded.add(info.nodeIdx);
|
|
938
|
+
this.#rebuild();
|
|
939
|
+
reanchor(); nudge();
|
|
940
|
+
return true;
|
|
941
|
+
}
|
|
937
942
|
return false;
|
|
938
943
|
}
|
|
939
944
|
|
|
@@ -1336,14 +1341,14 @@ export class ChatView extends Widget {
|
|
|
1336
1341
|
return true;
|
|
1337
1342
|
}
|
|
1338
1343
|
if (ev.kind === "press" && ev.button === 2) {
|
|
1339
|
-
|
|
1344
|
+
// map against the frame the user saw — no flush (see #clickLine)
|
|
1340
1345
|
const y = ev.y - this.view.y + this.view.scrollY;
|
|
1341
1346
|
const info = this.lineMap?.[y];
|
|
1342
1347
|
if (info) {
|
|
1343
1348
|
const node = this.nodes[info.nodeIdx];
|
|
1344
1349
|
const items = [
|
|
1345
1350
|
{ label: "复制消息", action: () => this.app.copyNode(info.nodeIdx) },
|
|
1346
|
-
{ label: "展开 / 折叠", action: () => this.#
|
|
1351
|
+
{ label: "展开 / 折叠", action: () => this.#toggleAt(info) },
|
|
1347
1352
|
];
|
|
1348
1353
|
if (node?.id) {
|
|
1349
1354
|
items.push({ label: "转跳轨迹", action: () => this.app.jumpToTrajectoryNode(info.nodeIdx) });
|
|
@@ -1381,12 +1386,12 @@ export class ChatView extends Widget {
|
|
|
1381
1386
|
case "pgdn": return this.view.scroll(this.view.h);
|
|
1382
1387
|
}
|
|
1383
1388
|
if (ev.name === "char" && ev.key === "g" && !ev.ctrl && !ev.shift) {
|
|
1384
|
-
if (this.gKey) { this.gKey = false; this.view.scrollY = 0; return true; }
|
|
1389
|
+
if (this.gKey) { this.gKey = false; this.view.anchorLock = null; this.view.scrollY = 0; return true; }
|
|
1385
1390
|
this.gKey = true;
|
|
1386
1391
|
this.app.toast("再按 g 回顶");
|
|
1387
1392
|
return true;
|
|
1388
1393
|
}
|
|
1389
|
-
if (ev.name === "char" && ev.key === "g" && ev.shift) { this.view.scrollY = this.view.maxScroll(); return true; }
|
|
1394
|
+
if (ev.name === "char" && ev.key === "g" && ev.shift) { this.view.anchorLock = null; this.view.scrollY = this.view.maxScroll(); return true; }
|
|
1390
1395
|
if (ev.name === "escape" && this.app.searchQuery) { this.app.searchQuery = null; this.queueRebuild(); return true; }
|
|
1391
1396
|
if (ev.name === "escape" && this.selStart !== null) { this.selStart = this.selEnd = null; this.app.redraw(); return true; }
|
|
1392
1397
|
if (ev.name === "char" && ev.key === "i" && !ev.ctrl) { this.app.focus(this.input); return true; }
|
|
@@ -2189,6 +2194,25 @@ export class App {
|
|
|
2189
2194
|
showModePicker() { this.overlay = buildModePicker(this); this.redraw(); }
|
|
2190
2195
|
showPermissionPicker() { this.overlay = buildPermissionPicker(this); this.redraw(); }
|
|
2191
2196
|
|
|
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…");
|
|
2202
|
+
this.redraw();
|
|
2203
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
2204
|
+
try { this.term?.stop?.(); } catch {}
|
|
2205
|
+
try {
|
|
2206
|
+
const { spawn } = await import("node:child_process");
|
|
2207
|
+
const child = spawn(process.argv[0], process.argv.slice(1), { detached: true, stdio: "inherit" });
|
|
2208
|
+
child.unref();
|
|
2209
|
+
} catch (e) {
|
|
2210
|
+
this.toast(`重启失败: ${e.message}(请手动重启)`);
|
|
2211
|
+
return;
|
|
2212
|
+
}
|
|
2213
|
+
process.exit(0);
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2192
2216
|
/** Ctrl+E: fzf-style quick jump to a step — type to fuzzy-filter the step
|
|
2193
2217
|
* list, Enter opens the trajectory window around the picked step. */
|
|
2194
2218
|
async quickJumpStep() {
|
package/src/widgets.js
CHANGED
|
@@ -27,6 +27,7 @@ export class ScrollView extends Widget {
|
|
|
27
27
|
super(opts);
|
|
28
28
|
this.lines = []; // array of arrays of segs: {t, fg, bg, bold, italic, underline, strike, code, link}
|
|
29
29
|
this.scrollY = 0;
|
|
30
|
+
this.anchorLock = null; // click anchor held beyond maxScroll (fold at the tail)
|
|
30
31
|
this.autoScroll = opts.autoScroll ?? false;
|
|
31
32
|
this.onClick = opts.onClick ?? null; // (y, ev) => bool
|
|
32
33
|
this.onWheel = null; // optional custom wheel handler
|
|
@@ -36,7 +37,12 @@ export class ScrollView extends Widget {
|
|
|
36
37
|
setLines(lines, { keep = false } = {}) {
|
|
37
38
|
const atBottom = this.autoScroll && this.scrollY + this.h >= this.lines.length - 1 || (keep && this.scrollY + this.h >= this.lines.length);
|
|
38
39
|
this.lines = lines;
|
|
39
|
-
if (
|
|
40
|
+
if (this.anchorLock != null) {
|
|
41
|
+
// A click fold removed the tail content: hold the exact anchored
|
|
42
|
+
// position even though the buffer now ends above it (no delayed snap).
|
|
43
|
+
this.scrollY = this.anchorLock;
|
|
44
|
+
if (this.scrollY <= Math.max(0, this.lines.length - this.h)) this.anchorLock = null;
|
|
45
|
+
} else if (atBottom || this.scrollY > Math.max(0, this.lines.length - this.h)) {
|
|
40
46
|
this.scrollY = Math.max(0, this.lines.length - this.h);
|
|
41
47
|
}
|
|
42
48
|
}
|
|
@@ -44,6 +50,7 @@ export class ScrollView extends Widget {
|
|
|
44
50
|
maxScroll() { return Math.max(0, this.lines.length - this.h); }
|
|
45
51
|
scroll(dy) {
|
|
46
52
|
const before = this.scrollY;
|
|
53
|
+
this.anchorLock = null; // explicit scroll releases the click anchor
|
|
47
54
|
this.scrollY = Math.max(0, Math.min(this.maxScroll(), this.scrollY + dy));
|
|
48
55
|
return this.scrollY !== before;
|
|
49
56
|
}
|
|
@@ -78,7 +85,8 @@ export class ScrollView extends Widget {
|
|
|
78
85
|
const trackH = Math.max(1, this.h - 2);
|
|
79
86
|
const total = Math.max(1, this.lines.length);
|
|
80
87
|
const thumbH = Math.max(1, Math.floor(this.h * this.h / total));
|
|
81
|
-
const
|
|
88
|
+
const frac = Math.min(1, this.scrollY / Math.max(1, this.maxScroll()));
|
|
89
|
+
const thumbY = Math.floor((this.h - 2) * frac);
|
|
82
90
|
for (let i = 0; i < this.h; i++) {
|
|
83
91
|
const inThumb = i >= 1 + thumbY && i < 1 + thumbY + thumbH;
|
|
84
92
|
const inTrack = i >= 1 && i < this.h - 1;
|
|
@@ -114,6 +122,7 @@ export class ScrollView extends Widget {
|
|
|
114
122
|
return false;
|
|
115
123
|
}
|
|
116
124
|
#scrubTo(ey) {
|
|
125
|
+
this.anchorLock = null; // scrubbing releases the click anchor
|
|
117
126
|
const trackH = Math.max(1, this.h - 2);
|
|
118
127
|
const total = Math.max(1, this.lines.length);
|
|
119
128
|
const thumbH = Math.max(1, Math.floor(this.h * this.h / total));
|