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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.0.24",
3
+ "version": "0.0.26",
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
@@ -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 (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). */
721
725
  todoHeight() {
722
726
  const todos = this.app.todos;
723
- if (!this.todosVisible || !todos || todos.length === 0) return 0;
724
- 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
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
- return 2 + (this.jobs?.length ? 1 : 0);
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
- // ── row 2: background jobs one summary line, never per-job noise ──
2863
- if (this.jobs?.length) {
2864
- const running = this.jobs.filter((j) => j.status === "running").length;
2865
- const done = this.jobs.filter((j) => j.status === "completed").length;
2866
- const failed = this.jobs.filter((j) => j.status === "failed").length;
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));