dsh-neotui 0.0.24 → 0.0.26
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 +35 -13
- package/src/widgets.js +4 -1
package/package.json
CHANGED
package/src/views.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { Screen } from "./screen.js";
|
|
3
3
|
import { renderMd, C } from "./md.js";
|
|
4
4
|
import { truncate, strWidth, bars, fmtDuration } from "./text.js";
|
|
5
|
-
import { readFileSync, appendFileSync } from "node:fs";
|
|
5
|
+
import { readFileSync, appendFileSync, mkdirSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { Widget, List, ScrollView, Input, Popup, Menu, StatusBar } from "./widgets.js";
|
|
8
8
|
import { userPrefix, saveTuiConfig, loadTuiConfig, userName } from "./config.js";
|
|
@@ -570,6 +570,7 @@ export class ChatView extends Widget {
|
|
|
570
570
|
this.thinkMode = "expanded"; // think blocks: expanded by default (t toggles)
|
|
571
571
|
this.bashMode = "expanded"; // tool blocks: expanded | collapsed (b toggles)
|
|
572
572
|
this.todosVisible = true; // todo block above the input (Shift+T toggles)
|
|
573
|
+
this.todoSeen = false; // once seen, the todo box keeps its height
|
|
573
574
|
this.running = false;
|
|
574
575
|
this.hasMore = false;
|
|
575
576
|
this.loadingOlder = false;
|
|
@@ -701,7 +702,7 @@ export class ChatView extends Widget {
|
|
|
701
702
|
if (idx < 0 || idx >= this.nodes.length) return false;
|
|
702
703
|
for (let li = 0; li < this.lineMap.length; li++) {
|
|
703
704
|
if (this.lineMap[li]?.nodeIdx === idx) {
|
|
704
|
-
this.view.anchorLock = null; this.view.scrollY = Math.max(0, li - 2);
|
|
705
|
+
this.view.anchorLock = null; this.view.follow = false; this.view.scrollY = Math.max(0, li - 2);
|
|
705
706
|
this.app.redraw();
|
|
706
707
|
return true;
|
|
707
708
|
}
|
|
@@ -717,11 +718,16 @@ export class ChatView extends Widget {
|
|
|
717
718
|
}
|
|
718
719
|
}
|
|
719
720
|
|
|
720
|
-
/** 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). */
|
|
721
725
|
todoHeight() {
|
|
722
726
|
const todos = this.app.todos;
|
|
723
|
-
if (!this.todosVisible
|
|
724
|
-
|
|
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
|
|
725
731
|
}
|
|
726
732
|
|
|
727
733
|
inputChanged() {
|
|
@@ -776,6 +782,7 @@ export class ChatView extends Widget {
|
|
|
776
782
|
// Jump to the LIVE tail (the newest content) on open — the view would
|
|
777
783
|
// otherwise sit at the top showing the oldest turns.
|
|
778
784
|
this.view.anchorLock = null; this.view.scrollY = this.view.maxScroll();
|
|
785
|
+
this.view.follow = true;
|
|
779
786
|
}
|
|
780
787
|
|
|
781
788
|
async loadOlder() {
|
|
@@ -847,6 +854,7 @@ export class ChatView extends Widget {
|
|
|
847
854
|
#clickLog(msg) {
|
|
848
855
|
try {
|
|
849
856
|
const dir = process.env.DSH_HOME ?? join(process.env.HOME ?? ".", ".dsh");
|
|
857
|
+
mkdirSync(dir, { recursive: true });
|
|
850
858
|
appendFileSync(join(dir, "tui-click-debug.log"), `${new Date().toISOString()} ${msg}\n`);
|
|
851
859
|
} catch {}
|
|
852
860
|
}
|
|
@@ -945,6 +953,8 @@ export class ChatView extends Widget {
|
|
|
945
953
|
(this.lineMap[this.view.scrollY]?.blockIdx ?? null) === null
|
|
946
954
|
) this.view.scrollY++;
|
|
947
955
|
};
|
|
956
|
+
// interacting with a block = reading mode: stop following the stream
|
|
957
|
+
this.view.follow = false;
|
|
948
958
|
if (node.kind === "assistant" && info.blockIdx !== null) {
|
|
949
959
|
const b = node.blocks[info.blockIdx];
|
|
950
960
|
if (b && (b.kind === "tool" || b.kind === "reasoning" || b.kind === "other" || b.kind === "text")) {
|
|
@@ -1465,12 +1475,12 @@ export class ChatView extends Widget {
|
|
|
1465
1475
|
case "pgdn": return this.view.scroll(this.view.h);
|
|
1466
1476
|
}
|
|
1467
1477
|
if (ev.name === "char" && ev.key === "g" && !ev.ctrl && !ev.shift) {
|
|
1468
|
-
if (this.gKey) { this.gKey = false; this.view.anchorLock = null; this.view.scrollY = 0; return true; }
|
|
1478
|
+
if (this.gKey) { this.gKey = false; this.view.anchorLock = null; this.view.follow = false; this.view.scrollY = 0; return true; }
|
|
1469
1479
|
this.gKey = true;
|
|
1470
1480
|
this.app.toast("再按 g 回顶");
|
|
1471
1481
|
return true;
|
|
1472
1482
|
}
|
|
1473
|
-
if (ev.name === "char" && ev.key === "g" && ev.shift) { this.view.anchorLock = null; this.view.scrollY = this.view.maxScroll(); return true; }
|
|
1483
|
+
if (ev.name === "char" && ev.key === "g" && ev.shift) { this.view.anchorLock = null; this.view.follow = true; this.view.scrollY = this.view.maxScroll(); return true; }
|
|
1474
1484
|
if (ev.name === "escape" && this.app.searchQuery) { this.app.searchQuery = null; this.queueRebuild(); return true; }
|
|
1475
1485
|
if (ev.name === "escape" && this.selStart !== null) { this.selStart = this.selEnd = null; this.app.redraw(); return true; }
|
|
1476
1486
|
if (ev.name === "char" && ev.key === "i" && !ev.ctrl) { this.app.focus(this.input); return true; }
|
|
@@ -1719,7 +1729,9 @@ export class App {
|
|
|
1719
1729
|
}
|
|
1720
1730
|
|
|
1721
1731
|
footerHeight() {
|
|
1722
|
-
|
|
1732
|
+
// Constant 3 rows: the jobs summary row is always present, so jobs
|
|
1733
|
+
// arriving/completing in the background never reflow the layout.
|
|
1734
|
+
return 3;
|
|
1723
1735
|
}
|
|
1724
1736
|
|
|
1725
1737
|
layout() {
|
|
@@ -2859,11 +2871,21 @@ export class App {
|
|
|
2859
2871
|
if (stats.ttftMs) row1.right.push({ t: ` 首响${Math.round(stats.ttftMs / stats.ttftSteps)}ms `, fg: T.FAINT, bg: T.STATUSBG });
|
|
2860
2872
|
}
|
|
2861
2873
|
rows.push(row1);
|
|
2862
|
-
//
|
|
2863
|
-
|
|
2864
|
-
const
|
|
2865
|
-
const
|
|
2866
|
-
|
|
2874
|
+
// frozen-view indicator: N lines of new content arrived below
|
|
2875
|
+
{
|
|
2876
|
+
const c = this.chat;
|
|
2877
|
+
const below = c.lines.length - 1 - (c.view.scrollY + c.view.h);
|
|
2878
|
+
if (!c.view.follow && below > 0) {
|
|
2879
|
+
row1.left.push({ t: ` ↓${below} 条新内容 · G 跟随 `, fg: T.SELFG, bg: T.ACCENT, bold: true });
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
// ── row 2: background jobs — one summary line, always present so its
|
|
2883
|
+
// appearance/disappearance never reflows the layout ──
|
|
2884
|
+
{
|
|
2885
|
+
const jobs = this.jobs ?? [];
|
|
2886
|
+
const running = jobs.filter((j) => j.status === "running").length;
|
|
2887
|
+
const done = jobs.filter((j) => j.status === "completed").length;
|
|
2888
|
+
const failed = jobs.filter((j) => j.status === "failed").length;
|
|
2867
2889
|
const row2 = { left: [], right: [] };
|
|
2868
2890
|
row2.left.push({
|
|
2869
2891
|
t: ` ${running > 0 ? `${running} 个任务正在后台运行` : "没有任务正在后台运行"} `,
|
package/src/widgets.js
CHANGED
|
@@ -28,6 +28,7 @@ export class ScrollView extends Widget {
|
|
|
28
28
|
this.lines = []; // array of arrays of segs: {t, fg, bg, bold, italic, underline, strike, code, link}
|
|
29
29
|
this.scrollY = 0;
|
|
30
30
|
this.anchorLock = null; // click anchor held beyond maxScroll (fold at the tail)
|
|
31
|
+
this.follow = opts.follow ?? true; // auto-follow the tail while pinned
|
|
31
32
|
this.autoScroll = opts.autoScroll ?? false;
|
|
32
33
|
this.onClick = opts.onClick ?? null; // (y, ev) => bool
|
|
33
34
|
this.onWheel = null; // optional custom wheel handler
|
|
@@ -35,7 +36,7 @@ export class ScrollView extends Widget {
|
|
|
35
36
|
this.title = opts.title ?? "";
|
|
36
37
|
}
|
|
37
38
|
setLines(lines, { keep = false } = {}) {
|
|
38
|
-
const atBottom = this.autoScroll && this.scrollY + this.h >= this.lines.length - 1 || (keep && this.scrollY + this.h >= this.lines.length);
|
|
39
|
+
const atBottom = this.autoScroll && this.follow && this.scrollY + this.h >= this.lines.length - 1 || (keep && this.scrollY + this.h >= this.lines.length);
|
|
39
40
|
this.lines = lines;
|
|
40
41
|
if (this.anchorLock != null) {
|
|
41
42
|
// A click fold removed the tail content: hold the exact anchored
|
|
@@ -52,6 +53,7 @@ export class ScrollView extends Widget {
|
|
|
52
53
|
const before = this.scrollY;
|
|
53
54
|
this.anchorLock = null; // explicit scroll releases the click anchor
|
|
54
55
|
this.scrollY = Math.max(0, Math.min(this.maxScroll(), this.scrollY + dy));
|
|
56
|
+
this.follow = this.scrollY >= this.maxScroll(); // reaching the bottom re-follows
|
|
55
57
|
return this.scrollY !== before;
|
|
56
58
|
}
|
|
57
59
|
render(screen) {
|
|
@@ -123,6 +125,7 @@ export class ScrollView extends Widget {
|
|
|
123
125
|
}
|
|
124
126
|
#scrubTo(ey) {
|
|
125
127
|
this.anchorLock = null; // scrubbing releases the click anchor
|
|
128
|
+
this.follow = this.scrollY >= this.maxScroll();
|
|
126
129
|
const trackH = Math.max(1, this.h - 2);
|
|
127
130
|
const total = Math.max(1, this.lines.length);
|
|
128
131
|
const thumbH = Math.max(1, Math.floor(this.h * this.h / total));
|