dsh-neotui 0.0.30 → 0.0.32

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.
Files changed (3) hide show
  1. package/package.json +4 -2
  2. package/src/md.js +14 -2
  3. package/src/views.js +45 -19
package/package.json CHANGED
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.0.30",
3
+ "version": "0.0.32",
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
- "bin": { "dsh-neotui": "bin/dsh-tui.js" },
6
+ "bin": {
7
+ "dsh-neotui": "bin/dsh-tui.js"
8
+ },
7
9
  "exports": {
8
10
  ".": "./src/index.js",
9
11
  "./package.json": "./package.json"
package/src/md.js CHANGED
@@ -160,7 +160,7 @@ function highlightLine(line, lang) {
160
160
  // ---- block renderer ----
161
161
  // Returns array of lines; each line = array of segments.
162
162
 
163
- export function renderMd(text, width) {
163
+ export function renderMd(text, width, sink = null) {
164
164
  const lines = [];
165
165
  const pushLine = (segs = []) => {
166
166
  if (segs.length === 0) segs = [SEG(" ")];
@@ -246,7 +246,19 @@ export function renderMd(text, width) {
246
246
  const codeLines = codeBuf.length === 0 ? [""] : codeBuf;
247
247
  let maxLen = Math.max(...codeLines.map((l) => strWidth(l)), 1);
248
248
  const barW = Math.min(hw, maxLen + 2);
249
- lines.push([SEG("┌" + "─".repeat(Math.max(1, barW - 2)) + "┐", { fg: C.hr }), SEG(" " + lang, { fg: C.dim })]);
249
+ // web-style box with a click-to-copy button in the top-right corner;
250
+ // a fence WITHOUT a language gets no label (the bare "text" tag next
251
+ // to the corner looked like a rendering artifact)
252
+ const btn = "[复制]";
253
+ const btnW = strWidth(btn);
254
+ const topLen = Math.max(1, barW - btnW - 4);
255
+ const tag = lang && lang !== "text" ? " " + lang : "";
256
+ if (sink?.codeBlocks) sink.codeBlocks.push({ text: codeBuf.join("\n"), lineIdx: lines.length, lang });
257
+ lines.push([
258
+ SEG("┌" + "─".repeat(topLen) + " ", { fg: C.hr }),
259
+ SEG(btn, { fg: C.link, bold: true, copyCode: codeBuf.join("\n") }),
260
+ SEG(" ┐" + tag, { fg: C.hr }),
261
+ ]);
250
262
  for (const cl of codeLines) {
251
263
  const hls = highlightLine(cl, lang);
252
264
  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 })]);
package/src/views.js CHANGED
@@ -594,6 +594,7 @@ export class ChatView extends Widget {
594
594
  this.pressY = null;
595
595
  this.pressInfo = null; // hit identity locked at press time
596
596
  this.pressCtx = null;
597
+ this.pressX = null;
597
598
  this.selStart = null;
598
599
  this.selEnd = null;
599
600
  this.stepState = { step: null }; // step/start tracking for the mux merge path
@@ -871,7 +872,7 @@ export class ChatView extends Widget {
871
872
  * shift the hit identity between press and release (the 4-line mystery:
872
873
  * the rendered frame and the hit test were consistent, but the identity
873
874
  * was re-resolved at release against a stream that had moved). */
874
- #anchorCtx(info, pressY = null) {
875
+ #anchorCtx(info, pressY = null, pressX = null) {
875
876
  const lineKey = (m) => (m ? `${m.nodeIdx}:${m.blockIdx ?? "n"}` : null);
876
877
  const topKey = lineKey(this.lineMap[this.view.scrollY]) ?? null;
877
878
  let topFirst = -1;
@@ -893,7 +894,24 @@ export class ChatView extends Widget {
893
894
  const preHeaderIdx = info ? firstNonEmpty(match) : -1;
894
895
  const preHeaderRow = preHeaderIdx >= 0 ? preHeaderIdx - this.view.scrollY : null;
895
896
  const pressRow = pressY !== null && pressY !== undefined && pressY >= 0 ? pressY - this.view.scrollY : null;
896
- return { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow, pressY, pressRow };
897
+ // the segment under the cursor at PRESS time (code-block [复制] hit test):
898
+ // re-resolving it at release would let a streaming rebuild move the line
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 };
901
+ }
902
+
903
+ /** The line-segment under a screen x on a rendered line (for the code
904
+ * block's [复制] button hit test). */
905
+ #segAtLine(y, x) {
906
+ const line = this.lines[y];
907
+ if (!line || x == null || x < 0) return null;
908
+ let px = this.view.x;
909
+ for (const g of line) {
910
+ const w = strWidth(g.t ?? "");
911
+ if (x >= px && x < px + w) return g;
912
+ px += w;
913
+ }
914
+ return null;
897
915
  }
898
916
 
899
917
  /** Toggle the block/node under a line-mark, then re-anchor the viewport.
@@ -908,7 +926,18 @@ export class ChatView extends Widget {
908
926
  }
909
927
  const node = this.nodes[info.nodeIdx];
910
928
  if (!node) return false;
911
- const { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow, pressY, pressRow } = ctx ?? this.#anchorCtx(info);
929
+ const { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow, pressY, pressRow, pressX, pressSeg } = ctx ?? this.#anchorCtx(info);
930
+ // code block [复制] button: copy the raw code, no toggle (hit identity
931
+ // and segment locked at press time)
932
+ if (pressSeg?.copyCode) {
933
+ this.app.copyText(pressSeg.copyCode);
934
+ this.app.toast("已复制代码块");
935
+ return true;
936
+ }
937
+ // formal text blocks are NOT collapsible: clicking them is a no-op
938
+ if (node.kind === "assistant" && info.blockIdx !== null && node.blocks[info.blockIdx]?.kind === "text") {
939
+ return true;
940
+ }
912
941
  let collapsing = false;
913
942
  if (process.env.DSH_TUI_DEBUG_CLICK) {
914
943
  this.#clickLog(`toggle mark=${JSON.stringify(info)} kind=${node.kind} blockIdx=${info.blockIdx} preHeaderRow=${preHeaderRow} topKey=${topKey} topOffset=${topOffset}`);
@@ -1181,27 +1210,20 @@ export class ChatView extends Widget {
1181
1210
  sep();
1182
1211
  } else {
1183
1212
  beginCard("CARD");
1184
- // Text blocks collapse per-block like tool/reasoning blocks:
1185
- // collapsed shows a 3-line preview + trailer, expanded the whole
1186
- // text. Clicking (or the right-click 展开/折叠) toggles it.
1213
+ // FORMAL text output is NOT collapsible — the user's message
1214
+ // content must stay readable; only think/tool blocks fold.
1215
+ // Code blocks inside render as boxes with a [复制] button.
1187
1216
  const key = `${realIdx}:${bi}`;
1188
- const open = !this.collapsedBlocks.has(key);
1189
1217
  const text = b.text ?? "";
1190
- const glyph = open ? "▾" : "▸";
1191
1218
  const mdW = Math.max(10, w - 6 - strWidth(stepTag));
1192
- const md = open
1193
- ? renderMd(text, mdW)
1194
- : renderMd(truncateText(text, 600), mdW).slice(0, 3);
1219
+ const sink = { codeBlocks: [] };
1220
+ const md = renderMd(text, mdW, sink);
1195
1221
  if (md.length > 0) {
1196
- lines.push([{ t: " " }, { t: glyph + " ", fg: K.FAINT }, { t: stepTag, fg: K.FAINT }, ...md[0]]);
1222
+ lines.push([{ t: " " }, { t: stepTag, fg: K.FAINT }, ...md[0]]);
1197
1223
  mark(realIdx, bi);
1198
1224
  for (const ln of md.slice(1)) { lines.push([{ t: " " }, ...ln]); mark(realIdx, bi); }
1199
1225
  } else {
1200
- lines.push([{ t: " " + glyph + " " + stepTag, fg: K.FAINT }]);
1201
- mark(realIdx, bi);
1202
- }
1203
- if (!open && text.length > 0) {
1204
- lines.push([{ t: ` …共 ${text.length} 字(点击展开)`, fg: K.FAINT }]);
1226
+ lines.push([{ t: " " + stepTag, fg: K.FAINT }]);
1205
1227
  mark(realIdx, bi);
1206
1228
  }
1207
1229
  sep();
@@ -1365,7 +1387,8 @@ export class ChatView extends Widget {
1365
1387
  // between press and release, so resolving the block at release would
1366
1388
  // toggle a block a few lines away from the one the user saw.
1367
1389
  this.pressInfo = this.lineMap?.[this.pressY] ?? null;
1368
- this.pressCtx = this.pressInfo ? this.#anchorCtx(this.pressInfo, this.pressY) : null;
1390
+ this.pressX = ev.x;
1391
+ this.pressCtx = this.pressInfo ? this.#anchorCtx(this.pressInfo, this.pressY, ev.x) : null;
1369
1392
  if (process.env.DSH_TUI_DEBUG_CLICK) {
1370
1393
  const t = this.lines[this.pressY]?.map((g) => g.t).join("") ?? "";
1371
1394
  this.#clickLog(`press screenY=${ev.y} screenX=${ev.x} lineIdx=${this.pressY} mark=${JSON.stringify(this.pressInfo)} text="${t.slice(0, 40)}" scrollY=${this.view.scrollY} viewY=${this.view.y} viewH=${this.view.h} assumedH=${this.app.screen?.h} assumedW=${this.app.screen?.w} ttyRows=${process.stdout.rows} ttyCols=${process.stdout.columns} inputY=${this.input.y} inputH=${this.input.h} todoH=${this.todoHeight()} footerH=${this.app.footerHeight?.() ?? "?"}`);
@@ -1407,8 +1430,11 @@ export class ChatView extends Widget {
1407
1430
  const node = this.nodes[info.nodeIdx];
1408
1431
  const items = [
1409
1432
  { label: "复制消息", action: () => this.app.copyNode(info.nodeIdx) },
1410
- { label: "展开 / 折叠", action: () => this.#toggleAt(info) },
1411
1433
  ];
1434
+ const clickedBlock = node?.kind === "assistant" && info.blockIdx !== null ? node.blocks[info.blockIdx] : null;
1435
+ if (clickedBlock?.kind !== "text") {
1436
+ items.push({ label: "展开 / 折叠", action: () => this.#toggleAt(info) });
1437
+ }
1412
1438
  if (node?.id) {
1413
1439
  items.push({ label: "转跳轨迹", action: () => this.app.jumpToTrajectoryNode(info.nodeIdx) });
1414
1440
  }