dsh-neotui 0.0.33 → 0.1.0

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.0",
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/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;
@@ -897,7 +897,20 @@ export class ChatView extends Widget {
897
897
  // the segment under the cursor at PRESS time (code-block [复制] hit test):
898
898
  // re-resolving it at release would let a streaming rebuild move the line
899
899
  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 };
900
+ // press-time BLOCK signature: the streaming tail re-derives between press
901
+ // and release (syncTail replaces the block objects), so the positional
902
+ // nodeIdx:blockIdx key can drift; the signature lets release re-locate
903
+ // the same block by content instead.
904
+ let pressSig = null;
905
+ if (info && info.blockIdx !== null) {
906
+ const b = this.nodes[info.nodeIdx]?.blocks?.[info.blockIdx];
907
+ if (b) pressSig = {
908
+ nodeId: this.nodes[info.nodeIdx]?.id ?? null,
909
+ kind: b.kind,
910
+ prefix: String(b.text ?? b.args ?? "").slice(0, 40),
911
+ };
912
+ }
913
+ return { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow, pressY, pressRow, pressX, pressSeg, pressSig };
901
914
  }
902
915
 
903
916
  /** The line-segment under a screen x on a rendered line (for the code
@@ -924,9 +937,9 @@ export class ChatView extends Widget {
924
937
  const ref = node?.images?.[info.imgIdx];
925
938
  if (ref) { this.app.openImage(ref, { all: node.images, index: info.imgIdx }); return true; }
926
939
  }
927
- const node = this.nodes[info.nodeIdx];
940
+ let node = this.nodes[info.nodeIdx];
928
941
  if (!node) return false;
929
- const { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow, pressY, pressRow, pressX, pressSeg } = ctx ?? this.#anchorCtx(info);
942
+ const { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow, pressY, pressRow, pressX, pressSeg, pressSig } = ctx ?? this.#anchorCtx(info);
930
943
  // code block [复制] button: copy the raw code, no toggle (hit identity
931
944
  // and segment locked at press time)
932
945
  if (pressSeg?.copyCode) {
@@ -934,6 +947,31 @@ export class ChatView extends Widget {
934
947
  this.app.toast("已复制代码块");
935
948
  return true;
936
949
  }
950
+ // The stream re-derives between press and release: if the positional
951
+ // block no longer matches the press-time signature, re-locate the SAME
952
+ // block by kind + content prefix (node id when the node carries one).
953
+ if (pressSig && info.blockIdx !== null) {
954
+ const cur = node.blocks?.[info.blockIdx];
955
+ const same = cur && cur.kind === pressSig.kind && String(cur.text ?? cur.args ?? "").slice(0, 40) === pressSig.prefix;
956
+ if (!same) {
957
+ let found = null;
958
+ for (let ni = 0; ni < this.nodes.length && !found; ni++) {
959
+ const n = this.nodes[ni];
960
+ if (pressSig.nodeId && n?.id && n.id !== pressSig.nodeId) continue;
961
+ for (let bi = 0; bi < (n?.blocks ?? []).length; bi++) {
962
+ const b = n.blocks[bi];
963
+ if (b.kind === pressSig.kind && String(b.text ?? b.args ?? "").slice(0, 40) === pressSig.prefix) {
964
+ found = { nodeIdx: ni, blockIdx: bi };
965
+ break;
966
+ }
967
+ }
968
+ }
969
+ if (found) {
970
+ info = { ...info, ...found };
971
+ node = this.nodes[found.nodeIdx];
972
+ }
973
+ }
974
+ }
937
975
  // formal text blocks are NOT collapsible: clicking them is a no-op
938
976
  if (node.kind === "assistant" && info.blockIdx !== null && node.blocks[info.blockIdx]?.kind === "text") {
939
977
  return true;
@@ -1222,18 +1260,33 @@ export class ChatView extends Widget {
1222
1260
  beginCard("CARD");
1223
1261
  // FORMAL text output is NOT collapsible — the user's message
1224
1262
  // content must stay readable; only think/tool blocks fold.
1225
- // Code blocks inside render as boxes with a [复制] button.
1263
+ // The 🐳 marker distinguishes formal output from 💭 think and
1264
+ // ▸ tool blocks at a glance. Code blocks inside render as boxes
1265
+ // with a [复制] button.
1226
1266
  const key = `${realIdx}:${bi}`;
1227
1267
  const text = b.text ?? "";
1228
1268
  const mdW = Math.max(10, w - 6 - strWidth(stepTag));
1229
1269
  const sink = { codeBlocks: [] };
1230
1270
  const md = renderMd(text, mdW, sink);
1271
+ const whale = { t: " 🐳", fg: K.ACCENT, bold: true };
1272
+ const step = { t: stepTag || " ", fg: K.FAINT };
1231
1273
  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); }
1274
+ // When the message STARTS with a code box, the whale+step
1275
+ // marker gets its own line so the box's top border keeps the
1276
+ // same indent as its content rows (corners over bars, not
1277
+ // shifted right by the marker).
1278
+ const firstIsBoxTop = md[0].some((g) => g.copyCode);
1279
+ if (firstIsBoxTop) {
1280
+ lines.push([whale, step]);
1281
+ mark(realIdx, bi);
1282
+ for (const ln of md) { lines.push([{ t: " " }, ...ln]); mark(realIdx, bi); }
1283
+ } else {
1284
+ lines.push([whale, step, ...md[0]]);
1285
+ mark(realIdx, bi);
1286
+ for (const ln of md.slice(1)) { lines.push([{ t: " " }, ...ln]); mark(realIdx, bi); }
1287
+ }
1235
1288
  } else {
1236
- lines.push([{ t: " " + stepTag, fg: K.FAINT }]);
1289
+ lines.push([whale, step]);
1237
1290
  mark(realIdx, bi);
1238
1291
  }
1239
1292
  sep();