dsh-neotui 0.0.31 → 0.0.33

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,9 +1,11 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.0.31",
3
+ "version": "0.0.33",
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,8 @@ 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, opts = {}) {
164
+ const hardBreaks = !!opts.hardBreaks;
164
165
  const lines = [];
165
166
  const pushLine = (segs = []) => {
166
167
  if (segs.length === 0) segs = [SEG(" ")];
@@ -178,8 +179,15 @@ export function renderMd(text, width) {
178
179
 
179
180
  const flushPara = () => {
180
181
  if (para.length === 0) return;
181
- const segs = parseInline(para.join(" "));
182
- lines.push(...wrapSegs(segs, width));
182
+ if (hardBreaks) {
183
+ // verbatim line breaks: each source line renders on its own row
184
+ for (const line of para) {
185
+ lines.push(...wrapSegs(parseInline(line), width));
186
+ }
187
+ } else {
188
+ const segs = parseInline(para.join(" "));
189
+ lines.push(...wrapSegs(segs, width));
190
+ }
183
191
  para = [];
184
192
  };
185
193
  const flushQuote = () => {
@@ -246,10 +254,19 @@ export function renderMd(text, width) {
246
254
  const codeLines = codeBuf.length === 0 ? [""] : codeBuf;
247
255
  let maxLen = Math.max(...codeLines.map((l) => strWidth(l)), 1);
248
256
  const barW = Math.min(hw, maxLen + 2);
249
- // a fence WITHOUT a language gets no label — the bare "text" tag next
250
- // to the box corner just looks like a rendering artifact
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)
260
+ const btn = "[复制]";
261
+ const btnW = strWidth(btn);
262
+ const topLen = Math.max(1, barW - btnW - 4);
251
263
  const tag = lang && lang !== "text" ? " " + lang : "";
252
- lines.push([SEG("┌" + "─".repeat(Math.max(1, barW - 2)) + "" + tag, { fg: C.hr })]);
264
+ if (sink?.codeBlocks) sink.codeBlocks.push({ text: codeBuf.join("\n"), lineIdx: lines.length, lang });
265
+ lines.push([
266
+ SEG("┌" + "─".repeat(topLen) + " ", { fg: C.hr }),
267
+ SEG(btn, { fg: C.link, bold: true, copyCode: codeBuf.join("\n") }),
268
+ SEG(" ┐" + tag, { fg: C.hr }),
269
+ ]);
253
270
  for (const cl of codeLines) {
254
271
  const hls = highlightLine(cl, lang);
255
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 })]);
package/src/term.js CHANGED
@@ -29,6 +29,7 @@ export class Term {
29
29
  this.onEvent = onEvent ?? (() => {});
30
30
  this.onResize = onResize ?? (() => {});
31
31
  this.kitty = kitty;
32
+ this.kittyActive = false; // set when the terminal answers the CSI ? u query
32
33
  this.decoder = new StringDecoder("utf8");
33
34
  this.buf = "";
34
35
  this.started = false;
@@ -52,7 +53,7 @@ export class Term {
52
53
  o.write("\x1b[?1006h"); // SGR extended coordinates
53
54
  o.write("\x1b[?2004h"); // bracketed paste
54
55
  o.write("\x1b[?7l"); // no autowrap (we clip ourselves)
55
- if (this.kitty) o.write("\x1b[>1u"); // kitty: disambiguate escape codes
56
+ if (this.kitty) { o.write("\x1b[>1u"); o.write("\x1b[?u"); }
56
57
  this.resizeHandler = () => this.#resize();
57
58
  process.on("SIGWINCH", this.resizeHandler);
58
59
  this.#resize();
@@ -206,7 +207,13 @@ export class Term {
206
207
  this.#mouse(params, final);
207
208
  return;
208
209
  }
209
- if (prefix === "?") return; // private responses (cursor pos, kitty flags) — ignored
210
+ if (prefix === "?") {
211
+ // CSI ? flags u — the terminal answered our kitty-protocol query, so
212
+ // it DOES honor the protocol (WezTerm etc. reply with the flags it
213
+ // supports; terminals with the feature off reply nothing at all).
214
+ if (final === "u") this.kittyActive = true;
215
+ return; // other private responses (cursor pos) stay ignored
216
+ }
210
217
  if (prefix === ">") return;
211
218
  if (final === "Z") { // Shift+Tab (backtab)
212
219
  this.#emit({ type: "key", name: "backtab", ctrl: false, alt: false, shift: true });
@@ -244,8 +251,13 @@ export class Term {
244
251
  }
245
252
 
246
253
  #kittyKey(params) {
247
- const [cp = 0, mod = 1] = params.split(";").filter((s) => s !== "").map(Number);
248
- const ctrl = !!(mod & 4), alt = !!(mod & 2), shift = !!(mod & 1);
254
+ // kitty: CSI code[:alternates] ; modifiers[:event-type] u the modifier
255
+ // value is 1 + the bitmask (shift=1, alt=2, ctrl=4), and the event-type
256
+ // sub-field (press/repeat/release) rides after a colon on the modifier.
257
+ const [cpRaw = "0", modRaw = "1"] = params.split(";");
258
+ const cp = Number(String(cpRaw).split(":")[0]) || 0;
259
+ const m = (Number(String(modRaw).split(":")[0]) || 1) - 1;
260
+ const ctrl = !!(m & 4), alt = !!(m & 2), shift = !!(m & 1);
249
261
  let name = KITTY_KEY_NAMES[cp];
250
262
  if (name === "tab" && shift) name = "backtab";
251
263
  if (name) {
package/src/views.js CHANGED
@@ -582,7 +582,7 @@ export class ChatView extends Widget {
582
582
  x: this.x, y: this.y + this.h - 2, w: this.w, h: 1,
583
583
  multi: true, maxLines: 6,
584
584
  bg: T.PANEL,
585
- placeholder: "输入消息…(Ctrl+J 换行,Enter 发送)",
585
+ placeholder: "输入消息…(Shift+Enter / Ctrl+J 换行,Enter 发送)",
586
586
  onEnter: (v) => this.send(v),
587
587
  onChange: () => this.inputChanged(),
588
588
  });
@@ -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}`);
@@ -963,6 +992,14 @@ export class ChatView extends Widget {
963
992
  collapsing = open;
964
993
  if (open) { this.expanded.delete(key); this.collapsedBlocks.add(key); }
965
994
  else { this.collapsedBlocks.delete(key); this.expanded.add(key); }
995
+ } else if (b.kind === "tool") {
996
+ // same two-state override, driven by bashMode: in all-collapsed
997
+ // mode (b) a click expands this block alone; a second click folds
998
+ // it again — never a no-op.
999
+ const open = this.expanded.has(key) || (this.bashMode !== "collapsed" && !this.collapsedBlocks.has(key));
1000
+ collapsing = open;
1001
+ if (open) { this.expanded.delete(key); this.collapsedBlocks.add(key); }
1002
+ else { this.collapsedBlocks.delete(key); this.expanded.add(key); }
966
1003
  } else {
967
1004
  collapsing = !this.collapsedBlocks.has(key);
968
1005
  if (this.collapsedBlocks.has(key)) this.collapsedBlocks.delete(key);
@@ -1062,7 +1099,9 @@ export class ChatView extends Widget {
1062
1099
  // message text starts on that same line (no blank first row).
1063
1100
  const prefix = userPrefix();
1064
1101
  const pw = strWidth(prefix);
1065
- const md = renderMd(shown, Math.max(10, w - 4 - pw));
1102
+ // The user's own submitted text keeps its line breaks verbatim
1103
+ // (what they typed is what they see); only the width wraps.
1104
+ const md = renderMd(shown, Math.max(10, w - 4 - pw), null, { hardBreaks: true });
1066
1105
  if (md.length === 0) {
1067
1106
  lines.push([{ t: " " + prefix, fg: K.OK, bold: true }]);
1068
1107
  mark(realIdx);
@@ -1125,7 +1164,7 @@ export class ChatView extends Widget {
1125
1164
  sep();
1126
1165
  } else if (b.kind === "tool") {
1127
1166
  const key = `${realIdx}:${bi}`;
1128
- const open = this.bashMode !== "collapsed" && !this.collapsedBlocks.has(key);
1167
+ const open = this.expanded.has(key) || (this.bashMode !== "collapsed" && !this.collapsedBlocks.has(key));
1129
1168
  const exitCode = b.view?.view?.exitCode;
1130
1169
  // A tool only TICKS while its turn is live. A finalized turn
1131
1170
  // whose result never matched (orphan) must freeze at 无结果 —
@@ -1181,27 +1220,20 @@ export class ChatView extends Widget {
1181
1220
  sep();
1182
1221
  } else {
1183
1222
  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.
1223
+ // FORMAL text output is NOT collapsible — the user's message
1224
+ // content must stay readable; only think/tool blocks fold.
1225
+ // Code blocks inside render as boxes with a [复制] button.
1187
1226
  const key = `${realIdx}:${bi}`;
1188
- const open = !this.collapsedBlocks.has(key);
1189
1227
  const text = b.text ?? "";
1190
- const glyph = open ? "▾" : "▸";
1191
1228
  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);
1229
+ const sink = { codeBlocks: [] };
1230
+ const md = renderMd(text, mdW, sink);
1195
1231
  if (md.length > 0) {
1196
- lines.push([{ t: " " }, { t: glyph + " ", fg: K.FAINT }, { t: stepTag, fg: K.FAINT }, ...md[0]]);
1232
+ lines.push([{ t: " " }, { t: stepTag, fg: K.FAINT }, ...md[0]]);
1197
1233
  mark(realIdx, bi);
1198
1234
  for (const ln of md.slice(1)) { lines.push([{ t: " " }, ...ln]); mark(realIdx, bi); }
1199
1235
  } 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 }]);
1236
+ lines.push([{ t: " " + stepTag, fg: K.FAINT }]);
1205
1237
  mark(realIdx, bi);
1206
1238
  }
1207
1239
  sep();
@@ -1365,7 +1397,8 @@ export class ChatView extends Widget {
1365
1397
  // between press and release, so resolving the block at release would
1366
1398
  // toggle a block a few lines away from the one the user saw.
1367
1399
  this.pressInfo = this.lineMap?.[this.pressY] ?? null;
1368
- this.pressCtx = this.pressInfo ? this.#anchorCtx(this.pressInfo, this.pressY) : null;
1400
+ this.pressX = ev.x;
1401
+ this.pressCtx = this.pressInfo ? this.#anchorCtx(this.pressInfo, this.pressY, ev.x) : null;
1369
1402
  if (process.env.DSH_TUI_DEBUG_CLICK) {
1370
1403
  const t = this.lines[this.pressY]?.map((g) => g.t).join("") ?? "";
1371
1404
  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 +1440,11 @@ export class ChatView extends Widget {
1407
1440
  const node = this.nodes[info.nodeIdx];
1408
1441
  const items = [
1409
1442
  { label: "复制消息", action: () => this.app.copyNode(info.nodeIdx) },
1410
- { label: "展开 / 折叠", action: () => this.#toggleAt(info) },
1411
1443
  ];
1444
+ const clickedBlock = node?.kind === "assistant" && info.blockIdx !== null ? node.blocks[info.blockIdx] : null;
1445
+ if (clickedBlock?.kind !== "text") {
1446
+ items.push({ label: "展开 / 折叠", action: () => this.#toggleAt(info) });
1447
+ }
1412
1448
  if (node?.id) {
1413
1449
  items.push({ label: "转跳轨迹", action: () => this.app.jumpToTrajectoryNode(info.nodeIdx) });
1414
1450
  }
@@ -1457,6 +1493,7 @@ export class ChatView extends Widget {
1457
1493
  if (ev.name === "char" && ev.key === "/" && !ev.ctrl) { this.app.startSearch(); return true; }
1458
1494
  if (ev.name === "char" && ev.key === "b" && !ev.ctrl) {
1459
1495
  this.bashMode = this.bashMode === "collapsed" ? "expanded" : "collapsed";
1496
+ this.expanded.clear();
1460
1497
  this.collapsedBlocks.clear();
1461
1498
  this.app.toast(this.bashMode === "collapsed" ? "工具块:折叠(b 展开)" : "工具块:展开(b 折叠)");
1462
1499
  this.queueRebuild();
@@ -1831,6 +1868,17 @@ export class App {
1831
1868
  this.api.onHostFrame = (frame) => this.#onHostFrame(frame);
1832
1869
  this.api.onStateChange = (s) => { this.connState = s; this.redraw(); };
1833
1870
  this.#startPolling();
1871
+ // WezTerm ships with enable_kitty_keyboard = false: the terminal ignores
1872
+ // our CSI > 1u request AND the CSI ? u query, so Shift+Enter arrives as a
1873
+ // plain CR (indistinguishable from Enter). Detect the dead query and say
1874
+ // so once, instead of letting Shift+Enter silently submit.
1875
+ if (this.term?.kitty && !this.term?.kittyActive) {
1876
+ setTimeout(() => {
1877
+ if (!this.term?.kittyActive) {
1878
+ this.toast("终端未开启 kitty 键盘协议:Shift+Enter 换行不可用(可用 Ctrl+J)。WezTerm 请在配置中设置 enable_kitty_keyboard = true 后重启终端");
1879
+ }
1880
+ }, 1500);
1881
+ }
1834
1882
  }
1835
1883
 
1836
1884
  async refreshSessions() {
@@ -2124,6 +2172,19 @@ export class App {
2124
2172
  this.refreshSessions();
2125
2173
  }
2126
2174
 
2175
+ /** ESC 打断: cancel the current turn if it is running. The chat's live
2176
+ * `running` flag (jobs mux frames + streaming nodes) is the fast source;
2177
+ * the (≤5s-fresh) session list is the fallback. Returns true if a cancel
2178
+ * request was actually sent. */
2179
+ #interruptIfRunning() {
2180
+ if (!this.currentSession) return false;
2181
+ const running = this.chat.running || !!this.sessions.find((s) => s.sessionId === this.currentSession)?.running;
2182
+ if (!running) return false;
2183
+ this.cancelSession({ sessionId: this.currentSession });
2184
+ this.toast("已请求中断当前回合");
2185
+ return true;
2186
+ }
2187
+
2127
2188
  async newSessionIn(group = null) {
2128
2189
  // Reuse an existing empty draft instead of minting a fresh blank session on
2129
2190
  // every "new session" click (this is how the meaningless blank sessions pile
@@ -2597,8 +2658,11 @@ export class App {
2597
2658
  // typing and Ctrl+J / Shift+Enter newlines behave like a normal editor.
2598
2659
  if (this.focused === this.chat.input) {
2599
2660
  if (ev.name === "escape") {
2661
+ // one ESC press: interrupt a running turn AND leave insert —
2662
+ // otherwise the key just exits insert (vim muscle memory).
2663
+ const interrupted = this.#interruptIfRunning();
2600
2664
  this.focus(this.chat);
2601
- this.toast("已退出输入(i 重新进入)");
2665
+ if (!interrupted) this.toast("已退出输入(i 重新进入)");
2602
2666
  } else {
2603
2667
  this.chat.input.onKey(ev);
2604
2668
  }
@@ -2648,13 +2712,11 @@ export class App {
2648
2712
  if (ev.name === "char" && ev.key === "/" && !ev.ctrl && this.focused !== this.chat.input) { this.startSearch(); this.redraw(); return; }
2649
2713
  if (ev.name === "char" && ev.key === "n" && !ev.ctrl && this.focused === this.sidebar) { this.newSession(); return; }
2650
2714
  if (ev.name === "escape") {
2715
+ // Esc in NORMAL mode interrupts a running turn (one press, regardless
2716
+ // of focus); otherwise it steps back toward the chat view.
2717
+ if (this.#interruptIfRunning()) return;
2651
2718
  if (this.focused === this.sidebar) { this.focus(this.chat); this.redraw(); }
2652
- else {
2653
- // Esc in NORMAL mode interrupts a running turn (insert→normal→interrupt).
2654
- const cur = this.sessions.find((s) => s.sessionId === this.currentSession);
2655
- if (cur?.running) { this.cancelSession(cur); this.toast("已请求中断当前回合"); }
2656
- else if (this.mode !== "chat") this.setMode("chat");
2657
- }
2719
+ else if (this.mode !== "chat") this.setMode("chat");
2658
2720
  return;
2659
2721
  }
2660
2722
  if (ev.name === "char" && ev.key === "i" && this.focused === this.sidebar) { this.focus(this.chat.input); this.redraw(); return; }