dsh-neotui 0.0.33 → 0.1.1

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.33",
3
+ "version": "0.1.1",
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": {
package/src/md.js CHANGED
@@ -252,26 +252,31 @@ export function renderMd(text, width, sink = null, opts = {}) {
252
252
  const lang = inCode || "text";
253
253
  const hw = Math.max(2, width - 4);
254
254
  const codeLines = codeBuf.length === 0 ? [""] : codeBuf;
255
- let maxLen = Math.max(...codeLines.map((l) => strWidth(l)), 1);
256
- const barW = Math.min(hw, maxLen + 2);
257
- // web-style box with a click-to-copy button in the top-right corner;
258
- // a fence WITHOUT a language gets no label (the bare "text" tag next
259
- // to the corner looked like a rendering artifact)
255
+ if (sink?.codeBlocks) sink.codeBlocks.push({ text: codeBuf.join("\n"), lineIdx: lines.length, lang });
256
+ // Fixed-width box: EVERY row is exactly (hw + 4) columns wide —
257
+ // top `┌…[复制]…┐`, content `│ │`, bottom `└…┘` — so the corners
258
+ // always sit above the vertical bars, never above the code text.
260
259
  const btn = "[复制]";
261
260
  const btnW = strWidth(btn);
262
- const topLen = Math.max(1, barW - btnW - 4);
261
+ const inner = hw + 2; // columns between the corners
262
+ const leftLen = Math.max(1, inner - btnW - 2);
263
263
  const tag = lang && lang !== "text" ? " " + lang : "";
264
- if (sink?.codeBlocks) sink.codeBlocks.push({ text: codeBuf.join("\n"), lineIdx: lines.length, lang });
265
264
  lines.push([
266
- SEG("┌" + "─".repeat(topLen) + " ", { fg: C.hr }),
265
+ SEG("┌" + "─".repeat(leftLen) + " ", { fg: C.hr }),
267
266
  SEG(btn, { fg: C.link, bold: true, copyCode: codeBuf.join("\n") }),
268
267
  SEG(" ┐" + tag, { fg: C.hr }),
269
268
  ]);
270
269
  for (const cl of codeLines) {
271
270
  const hls = highlightLine(cl, lang);
272
- lines.push([SEG("│ ", { fg: C.hr }), ...wrapSegs(hls, hw, { pad: true }).map((l) => l).flatMap((l, k) => (k === 0 ? l : [SEG(" ", { fg: C.hr }), ...l])), SEG(" │", { fg: C.hr })]);
271
+ for (const row of wrapSegs(hls, hw)) {
272
+ const rowW = strWidth(row.map((g) => g.t ?? "").join(""));
273
+ const segs = [SEG("│ ", { fg: C.hr }), ...row];
274
+ if (rowW < hw) segs.push(SEG(" ".repeat(hw - rowW)));
275
+ segs.push(SEG(" │", { fg: C.hr }));
276
+ lines.push(segs);
277
+ }
273
278
  }
274
- lines.push([SEG("└" + "─".repeat(Math.max(1, barW - 2)) + "┘", { fg: C.hr })]);
279
+ lines.push([SEG("└" + "─".repeat(inner) + "┘", { fg: C.hr })]);
275
280
  inCode = null;
276
281
  }
277
282
  i++;
package/src/panels.js CHANGED
@@ -1216,6 +1216,8 @@ export class ControlPanel extends Widget {
1216
1216
  ["n", "新建会话", () => this.app.newSession()],
1217
1217
  ["g g", "滚动到顶", () => { this.app.closeOverlay(); this.app.chat.view.scrollY = 0; }],
1218
1218
  ["G", "滚动到底", () => { this.app.closeOverlay(); this.app.chat.view.scrollY = this.app.chat.view.maxScroll(); }],
1219
+ ["[", "上一提问的终点", () => { this.app.closeOverlay(); this.app.focus(this.app.chat); this.app.chat.onKey({ type: "key", name: "char", key: "[", text: "[", ctrl: false, alt: false, shift: false }); }],
1220
+ ["]", "下一提问的终点", () => { this.app.closeOverlay(); this.app.focus(this.app.chat); this.app.chat.onKey({ type: "key", name: "char", key: "]", text: "]", ctrl: false, alt: false, shift: false }); }],
1219
1221
  ["Ctrl+P", "控制面板", () => { this.page = 1; this.sel = 0; this.app.redraw(); }],
1220
1222
  ["Ctrl+M", "切换模型", () => { this.app.overlay = buildModelPicker(this.app); }],
1221
1223
  ["Ctrl+T", "轨迹视图", () => { this.app.closeOverlay(); this.app.setMode("trajectory"); }],
package/src/theme.js CHANGED
@@ -56,7 +56,7 @@ function themeFile() {
56
56
  return join(base, "tui-theme.txt");
57
57
  }
58
58
 
59
- let current = "dark";
59
+ let current = "gruvbox";
60
60
  const ORDER = ["dark", "light", "gruvbox"];
61
61
 
62
62
  try {
package/src/views.js CHANGED
@@ -566,7 +566,7 @@ export class ChatView extends Widget {
566
566
  this.expandedTools = new Set();
567
567
  this.collapsedBlocks = new Set(); // per-block COLLAPSE (default expanded): `${realIdx}:${bi}`
568
568
  this.thinkMode = "expanded"; // think blocks: expanded by default (t toggles)
569
- this.bashMode = "expanded"; // tool blocks: expanded | collapsed (b toggles)
569
+ this.bashMode = "collapsed"; // tool blocks: collapsed by default (b toggles)
570
570
  this.todosVisible = true; // todo block above the input (Shift+T toggles)
571
571
  this.todoSeen = false; // once seen, the todo box keeps its height
572
572
  this.running = false;
@@ -784,8 +784,8 @@ export class ChatView extends Widget {
784
784
  this.view.follow = true;
785
785
  }
786
786
 
787
- async loadOlder() {
788
- if (!this.hasMore || this.loadingOlder || this.minSeq == null) return;
787
+ async loadOlder(onDone = null) {
788
+ if (!this.hasMore || this.loadingOlder || this.minSeq == null) { onDone?.(); return; }
789
789
  this.loadingOlder = true;
790
790
  this.app.setStatus("加载更早记录…");
791
791
  try {
@@ -803,6 +803,51 @@ export class ChatView extends Widget {
803
803
  }
804
804
  this.loadingOlder = false;
805
805
  this.#rebuild();
806
+ onDone?.();
807
+ }
808
+
809
+ /** [ / ] — jump the viewport to the END of the previous (dir -1) or next
810
+ * (dir +1) user question. `previous` walks to the question entirely above
811
+ * the viewport top (loading older history first when needed); `next` walks
812
+ * to the first question that starts below the viewport top. */
813
+ #jumpQuestion(dir) {
814
+ this.flushRebuild(); // lineMap must reflect the current nodes array
815
+ const top = this.view.scrollY;
816
+ const qs = [];
817
+ for (let i = 0; i < this.nodes.length; i++) {
818
+ if (this.nodes[i]?.kind !== "user") continue;
819
+ let first = -1, last = -1;
820
+ for (let li = 0; li < this.lineMap.length; li++) {
821
+ if (this.lineMap[li]?.nodeIdx === i) { if (first < 0) first = li; last = li; }
822
+ }
823
+ if (first < 0) continue; // node outside the rendered window
824
+ qs.push({ first, last });
825
+ }
826
+ let target = null;
827
+ if (dir < 0) {
828
+ for (let k = qs.length - 1; k >= 0; k--) {
829
+ if (qs[k].last < top) { target = qs[k]; break; }
830
+ }
831
+ if (!target && this.hasMore && this.minSeq != null) {
832
+ this.loadOlder(() => this.#jumpQuestion(-1)); // after the prepend, retry against the fuller window
833
+ return true;
834
+ }
835
+ } else {
836
+ target = qs.find((q) => q.first > top) ?? null;
837
+ }
838
+ if (!target) {
839
+ this.app.toast(dir < 0 ? "已到最早的问题" : "已到最后的问题");
840
+ return false;
841
+ }
842
+ // the question's last CONTENT line (skip trailing blank separator rows);
843
+ // put it ~3 rows into the viewport, never above the question's own start
844
+ let end = target.last;
845
+ while (end > target.first && !(this.lines[end] ?? []).some((g) => g.t.trim() !== "")) end--;
846
+ this.view.anchorLock = null;
847
+ this.view.follow = false;
848
+ this.view.scrollY = Math.max(0, Math.max(target.first, end - 3));
849
+ this.app.redraw();
850
+ return true;
806
851
  }
807
852
 
808
853
  onFrame(frame) {
@@ -897,7 +942,20 @@ export class ChatView extends Widget {
897
942
  // the segment under the cursor at PRESS time (code-block [复制] hit test):
898
943
  // re-resolving it at release would let a streaming rebuild move the line
899
944
  const pressSeg = pressY !== null && pressY !== undefined && pressY >= 0 ? this.#segAtLine(pressY, pressX) : null;
900
- return { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow, pressY, pressRow, pressX, pressSeg };
945
+ // press-time BLOCK signature: the streaming tail re-derives between press
946
+ // and release (syncTail replaces the block objects), so the positional
947
+ // nodeIdx:blockIdx key can drift; the signature lets release re-locate
948
+ // the same block by content instead.
949
+ let pressSig = null;
950
+ if (info && info.blockIdx !== null) {
951
+ const b = this.nodes[info.nodeIdx]?.blocks?.[info.blockIdx];
952
+ if (b) pressSig = {
953
+ nodeId: this.nodes[info.nodeIdx]?.id ?? null,
954
+ kind: b.kind,
955
+ prefix: String(b.text ?? b.args ?? "").slice(0, 40),
956
+ };
957
+ }
958
+ return { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow, pressY, pressRow, pressX, pressSeg, pressSig };
901
959
  }
902
960
 
903
961
  /** The line-segment under a screen x on a rendered line (for the code
@@ -924,9 +982,9 @@ export class ChatView extends Widget {
924
982
  const ref = node?.images?.[info.imgIdx];
925
983
  if (ref) { this.app.openImage(ref, { all: node.images, index: info.imgIdx }); return true; }
926
984
  }
927
- const node = this.nodes[info.nodeIdx];
985
+ let node = this.nodes[info.nodeIdx];
928
986
  if (!node) return false;
929
- const { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow, pressY, pressRow, pressX, pressSeg } = ctx ?? this.#anchorCtx(info);
987
+ const { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow, pressY, pressRow, pressX, pressSeg, pressSig } = ctx ?? this.#anchorCtx(info);
930
988
  // code block [复制] button: copy the raw code, no toggle (hit identity
931
989
  // and segment locked at press time)
932
990
  if (pressSeg?.copyCode) {
@@ -934,6 +992,31 @@ export class ChatView extends Widget {
934
992
  this.app.toast("已复制代码块");
935
993
  return true;
936
994
  }
995
+ // The stream re-derives between press and release: if the positional
996
+ // block no longer matches the press-time signature, re-locate the SAME
997
+ // block by kind + content prefix (node id when the node carries one).
998
+ if (pressSig && info.blockIdx !== null) {
999
+ const cur = node.blocks?.[info.blockIdx];
1000
+ const same = cur && cur.kind === pressSig.kind && String(cur.text ?? cur.args ?? "").slice(0, 40) === pressSig.prefix;
1001
+ if (!same) {
1002
+ let found = null;
1003
+ for (let ni = 0; ni < this.nodes.length && !found; ni++) {
1004
+ const n = this.nodes[ni];
1005
+ if (pressSig.nodeId && n?.id && n.id !== pressSig.nodeId) continue;
1006
+ for (let bi = 0; bi < (n?.blocks ?? []).length; bi++) {
1007
+ const b = n.blocks[bi];
1008
+ if (b.kind === pressSig.kind && String(b.text ?? b.args ?? "").slice(0, 40) === pressSig.prefix) {
1009
+ found = { nodeIdx: ni, blockIdx: bi };
1010
+ break;
1011
+ }
1012
+ }
1013
+ }
1014
+ if (found) {
1015
+ info = { ...info, ...found };
1016
+ node = this.nodes[found.nodeIdx];
1017
+ }
1018
+ }
1019
+ }
937
1020
  // formal text blocks are NOT collapsible: clicking them is a no-op
938
1021
  if (node.kind === "assistant" && info.blockIdx !== null && node.blocks[info.blockIdx]?.kind === "text") {
939
1022
  return true;
@@ -1222,18 +1305,33 @@ export class ChatView extends Widget {
1222
1305
  beginCard("CARD");
1223
1306
  // FORMAL text output is NOT collapsible — the user's message
1224
1307
  // content must stay readable; only think/tool blocks fold.
1225
- // Code blocks inside render as boxes with a [复制] button.
1308
+ // The 🐳 marker distinguishes formal output from 💭 think and
1309
+ // ▸ tool blocks at a glance. Code blocks inside render as boxes
1310
+ // with a [复制] button.
1226
1311
  const key = `${realIdx}:${bi}`;
1227
1312
  const text = b.text ?? "";
1228
1313
  const mdW = Math.max(10, w - 6 - strWidth(stepTag));
1229
1314
  const sink = { codeBlocks: [] };
1230
1315
  const md = renderMd(text, mdW, sink);
1316
+ const whale = { t: " 🐳", fg: K.ACCENT, bold: true };
1317
+ const step = { t: stepTag || " ", fg: K.FAINT };
1231
1318
  if (md.length > 0) {
1232
- lines.push([{ t: " " }, { t: stepTag, fg: K.FAINT }, ...md[0]]);
1233
- mark(realIdx, bi);
1234
- for (const ln of md.slice(1)) { lines.push([{ t: " " }, ...ln]); mark(realIdx, bi); }
1319
+ // When the message STARTS with a code box, the whale+step
1320
+ // marker gets its own line so the box's top border keeps the
1321
+ // same indent as its content rows (corners over bars, not
1322
+ // shifted right by the marker).
1323
+ const firstIsBoxTop = md[0].some((g) => g.copyCode);
1324
+ if (firstIsBoxTop) {
1325
+ lines.push([whale, step]);
1326
+ mark(realIdx, bi);
1327
+ for (const ln of md) { lines.push([{ t: " " }, ...ln]); mark(realIdx, bi); }
1328
+ } else {
1329
+ lines.push([whale, step, ...md[0]]);
1330
+ mark(realIdx, bi);
1331
+ for (const ln of md.slice(1)) { lines.push([{ t: " " }, ...ln]); mark(realIdx, bi); }
1332
+ }
1235
1333
  } else {
1236
- lines.push([{ t: " " + stepTag, fg: K.FAINT }]);
1334
+ lines.push([whale, step]);
1237
1335
  mark(realIdx, bi);
1238
1336
  }
1239
1337
  sep();
@@ -1487,6 +1585,11 @@ export class ChatView extends Widget {
1487
1585
  return true;
1488
1586
  }
1489
1587
  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; }
1588
+ // [ / ] — jump to the end of the previous / next user question. Guarded by
1589
+ // focus so the sidebar's session-move [ ] keys keep working there.
1590
+ if (ev.name === "char" && (ev.key === "[" || ev.key === "]") && !ev.ctrl && !ev.shift && this.app.focused === this) {
1591
+ return this.#jumpQuestion(ev.key === "[" ? -1 : 1);
1592
+ }
1490
1593
  if (ev.name === "escape" && this.app.searchQuery) { this.app.searchQuery = null; this.queueRebuild(); return true; }
1491
1594
  if (ev.name === "escape" && this.selStart !== null) { this.selStart = this.selEnd = null; this.app.redraw(); return true; }
1492
1595
  if (ev.name === "char" && ev.key === "i" && !ev.ctrl) { this.app.focus(this.input); return true; }
@@ -1715,6 +1818,8 @@ export class App {
1715
1818
  this.overlay = null; // Picker / Popup / ImagePopup modal
1716
1819
  this.mode = "chat"; // chat | workspace | trajectory
1717
1820
  this.sidebarVisible = true; // Ctrl+B hides the whole session pane (nvim-style)
1821
+ this.sidebarWidth = 30; // draggable divider: the session pane's column width
1822
+ this.draggingDivider = false;
1718
1823
  this.feedbackMap = new Map(); // messageId → {rating, version}
1719
1824
  this.searchQuery = null; // active find-in-conversation term (highlight)
1720
1825
  this.findQuery = null; // term being typed in the find picker
@@ -1726,10 +1831,10 @@ export class App {
1726
1831
  this.skillsPanel = null;
1727
1832
 
1728
1833
  this.sidebar = new SidebarTree(this);
1729
- this.sidebar.w = 30;
1834
+ this.sidebar.w = this.sidebarWidth;
1730
1835
  this.sidebar.h = screen.h - 1;
1731
- this.searchInput = new Input({ x: 0, y: 0, w: 30, h: 1, prompt: "/ ", placeholder: "搜索会话…" });
1732
- this.chat = new ChatView({ x: 30, y: 0, w: screen.w - 30, h: screen.h - 1, app: this });
1836
+ this.searchInput = new Input({ x: 0, y: 0, w: this.sidebarWidth, h: 1, prompt: "/ ", placeholder: "搜索会话…" });
1837
+ this.chat = new ChatView({ x: this.sidebarWidth, y: 0, w: screen.w - this.sidebarWidth, h: screen.h - 1, app: this });
1733
1838
  this.status = new StatusBar({ x: 0, y: screen.h - 1, w: screen.w, h: 1 });
1734
1839
  this.focus(this.chat);
1735
1840
  this.layout();
@@ -1742,11 +1847,12 @@ export class App {
1742
1847
  }
1743
1848
 
1744
1849
  layout() {
1745
- const x = this.sidebarVisible ? 30 : 0;
1850
+ const x = this.sidebarVisible ? this.sidebarWidth : 0;
1746
1851
  const w = this.screen.w - x;
1747
1852
  const footerH = this.footerHeight();
1748
1853
  const mainH = this.screen.h - 1 - footerH;
1749
- this.sidebar.x = 0; this.sidebar.y = 0; this.sidebar.w = 30; this.sidebar.h = this.screen.h - 1;
1854
+ this.sidebar.x = 0; this.sidebar.y = 0; this.sidebar.w = this.sidebarWidth; this.sidebar.h = this.screen.h - 1;
1855
+ this.searchInput.w = this.sidebarWidth;
1750
1856
  this.chat.resize(x, 1, w, mainH);
1751
1857
  for (const p of [this.workspacePanel, this.trajectoryPanel, this.settingsPanel, this.subagentPanel, this.skillsPanel]) {
1752
1858
  if (p?.relayout) p.relayout(x, 1, w, mainH);
@@ -2608,7 +2714,7 @@ export class App {
2608
2714
  }
2609
2715
 
2610
2716
  // tab bar clicks (row 0 of the main area)
2611
- if (ev.type === "mouse" && ev.kind === "press" && ev.button === 0 && ev.y === 0 && ev.x >= (this.sidebarVisible ? 30 : 0)) {
2717
+ if (ev.type === "mouse" && ev.kind === "press" && ev.button === 0 && ev.y === 0 && ev.x >= (this.sidebarVisible ? this.sidebarWidth : 0)) {
2612
2718
  if (this.#clickTab(ev.x)) { this.redraw(); return; }
2613
2719
  }
2614
2720
  // mouse routes by position (click = focus + dispatch)
@@ -2626,6 +2732,23 @@ export class App {
2626
2732
  // unhandled keys fall through to global shortcuts
2627
2733
  }
2628
2734
  if (ev.type === "mouse") {
2735
+ // Draggable sidebar divider: press on the boundary column (±1) starts a
2736
+ // resize; drag events (motion flag) update the width live.
2737
+ const divX = this.sidebarVisible ? this.sidebarWidth : -1;
2738
+ const onDivider = divX >= 0 && ev.y >= 1 && ev.x >= divX - 1 && ev.x <= divX + 1;
2739
+ if (ev.kind === "press" && ev.button === 0 && onDivider) {
2740
+ this.draggingDivider = true;
2741
+ this.redraw();
2742
+ return;
2743
+ }
2744
+ if (this.draggingDivider) {
2745
+ if (ev.kind === "drag" && ev.button === 0) {
2746
+ const nw = Math.max(14, Math.min(ev.x, Math.floor(this.screen.w * 0.6)));
2747
+ if (nw !== this.sidebarWidth) { this.sidebarWidth = nw; this.layout(); this.redraw(); }
2748
+ return;
2749
+ }
2750
+ if (ev.kind === "release" && ev.button === 0) { this.draggingDivider = false; this.redraw(); return; }
2751
+ }
2629
2752
  // Pure mouse motion must never change focus/mode (vim-style: INSERT is
2630
2753
  // keyboard-only). It reaches just the already-focused widget (drag select).
2631
2754
  if (ev.motion) {