dsh-neotui 0.0.32 → 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.32",
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
@@ -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, sink = null) {
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, sink = null) {
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 = () => {
@@ -244,26 +252,31 @@ export function renderMd(text, width, sink = null) {
244
252
  const lang = inCode || "text";
245
253
  const hw = Math.max(2, width - 4);
246
254
  const codeLines = codeBuf.length === 0 ? [""] : codeBuf;
247
- let maxLen = Math.max(...codeLines.map((l) => strWidth(l)), 1);
248
- const barW = Math.min(hw, maxLen + 2);
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)
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.
252
259
  const btn = "[复制]";
253
260
  const btnW = strWidth(btn);
254
- 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);
255
263
  const tag = lang && lang !== "text" ? " " + lang : "";
256
- if (sink?.codeBlocks) sink.codeBlocks.push({ text: codeBuf.join("\n"), lineIdx: lines.length, lang });
257
264
  lines.push([
258
- SEG("┌" + "─".repeat(topLen) + " ", { fg: C.hr }),
265
+ SEG("┌" + "─".repeat(leftLen) + " ", { fg: C.hr }),
259
266
  SEG(btn, { fg: C.link, bold: true, copyCode: codeBuf.join("\n") }),
260
267
  SEG(" ┐" + tag, { fg: C.hr }),
261
268
  ]);
262
269
  for (const cl of codeLines) {
263
270
  const hls = highlightLine(cl, lang);
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 })]);
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
+ }
265
278
  }
266
- lines.push([SEG("└" + "─".repeat(Math.max(1, barW - 2)) + "┘", { fg: C.hr })]);
279
+ lines.push([SEG("└" + "─".repeat(inner) + "┘", { fg: C.hr })]);
267
280
  inCode = null;
268
281
  }
269
282
  i++;
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/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;
@@ -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
  });
@@ -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;
@@ -992,6 +1030,14 @@ export class ChatView extends Widget {
992
1030
  collapsing = open;
993
1031
  if (open) { this.expanded.delete(key); this.collapsedBlocks.add(key); }
994
1032
  else { this.collapsedBlocks.delete(key); this.expanded.add(key); }
1033
+ } else if (b.kind === "tool") {
1034
+ // same two-state override, driven by bashMode: in all-collapsed
1035
+ // mode (b) a click expands this block alone; a second click folds
1036
+ // it again — never a no-op.
1037
+ const open = this.expanded.has(key) || (this.bashMode !== "collapsed" && !this.collapsedBlocks.has(key));
1038
+ collapsing = open;
1039
+ if (open) { this.expanded.delete(key); this.collapsedBlocks.add(key); }
1040
+ else { this.collapsedBlocks.delete(key); this.expanded.add(key); }
995
1041
  } else {
996
1042
  collapsing = !this.collapsedBlocks.has(key);
997
1043
  if (this.collapsedBlocks.has(key)) this.collapsedBlocks.delete(key);
@@ -1091,7 +1137,9 @@ export class ChatView extends Widget {
1091
1137
  // message text starts on that same line (no blank first row).
1092
1138
  const prefix = userPrefix();
1093
1139
  const pw = strWidth(prefix);
1094
- const md = renderMd(shown, Math.max(10, w - 4 - pw));
1140
+ // The user's own submitted text keeps its line breaks verbatim
1141
+ // (what they typed is what they see); only the width wraps.
1142
+ const md = renderMd(shown, Math.max(10, w - 4 - pw), null, { hardBreaks: true });
1095
1143
  if (md.length === 0) {
1096
1144
  lines.push([{ t: " " + prefix, fg: K.OK, bold: true }]);
1097
1145
  mark(realIdx);
@@ -1154,7 +1202,7 @@ export class ChatView extends Widget {
1154
1202
  sep();
1155
1203
  } else if (b.kind === "tool") {
1156
1204
  const key = `${realIdx}:${bi}`;
1157
- const open = this.bashMode !== "collapsed" && !this.collapsedBlocks.has(key);
1205
+ const open = this.expanded.has(key) || (this.bashMode !== "collapsed" && !this.collapsedBlocks.has(key));
1158
1206
  const exitCode = b.view?.view?.exitCode;
1159
1207
  // A tool only TICKS while its turn is live. A finalized turn
1160
1208
  // whose result never matched (orphan) must freeze at 无结果 —
@@ -1212,18 +1260,33 @@ export class ChatView extends Widget {
1212
1260
  beginCard("CARD");
1213
1261
  // FORMAL text output is NOT collapsible — the user's message
1214
1262
  // content must stay readable; only think/tool blocks fold.
1215
- // 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.
1216
1266
  const key = `${realIdx}:${bi}`;
1217
1267
  const text = b.text ?? "";
1218
1268
  const mdW = Math.max(10, w - 6 - strWidth(stepTag));
1219
1269
  const sink = { codeBlocks: [] };
1220
1270
  const md = renderMd(text, mdW, sink);
1271
+ const whale = { t: " 🐳", fg: K.ACCENT, bold: true };
1272
+ const step = { t: stepTag || " ", fg: K.FAINT };
1221
1273
  if (md.length > 0) {
1222
- lines.push([{ t: " " }, { t: stepTag, fg: K.FAINT }, ...md[0]]);
1223
- mark(realIdx, bi);
1224
- 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
+ }
1225
1288
  } else {
1226
- lines.push([{ t: " " + stepTag, fg: K.FAINT }]);
1289
+ lines.push([whale, step]);
1227
1290
  mark(realIdx, bi);
1228
1291
  }
1229
1292
  sep();
@@ -1483,6 +1546,7 @@ export class ChatView extends Widget {
1483
1546
  if (ev.name === "char" && ev.key === "/" && !ev.ctrl) { this.app.startSearch(); return true; }
1484
1547
  if (ev.name === "char" && ev.key === "b" && !ev.ctrl) {
1485
1548
  this.bashMode = this.bashMode === "collapsed" ? "expanded" : "collapsed";
1549
+ this.expanded.clear();
1486
1550
  this.collapsedBlocks.clear();
1487
1551
  this.app.toast(this.bashMode === "collapsed" ? "工具块:折叠(b 展开)" : "工具块:展开(b 折叠)");
1488
1552
  this.queueRebuild();
@@ -1857,6 +1921,17 @@ export class App {
1857
1921
  this.api.onHostFrame = (frame) => this.#onHostFrame(frame);
1858
1922
  this.api.onStateChange = (s) => { this.connState = s; this.redraw(); };
1859
1923
  this.#startPolling();
1924
+ // WezTerm ships with enable_kitty_keyboard = false: the terminal ignores
1925
+ // our CSI > 1u request AND the CSI ? u query, so Shift+Enter arrives as a
1926
+ // plain CR (indistinguishable from Enter). Detect the dead query and say
1927
+ // so once, instead of letting Shift+Enter silently submit.
1928
+ if (this.term?.kitty && !this.term?.kittyActive) {
1929
+ setTimeout(() => {
1930
+ if (!this.term?.kittyActive) {
1931
+ this.toast("终端未开启 kitty 键盘协议:Shift+Enter 换行不可用(可用 Ctrl+J)。WezTerm 请在配置中设置 enable_kitty_keyboard = true 后重启终端");
1932
+ }
1933
+ }, 1500);
1934
+ }
1860
1935
  }
1861
1936
 
1862
1937
  async refreshSessions() {
@@ -2150,6 +2225,19 @@ export class App {
2150
2225
  this.refreshSessions();
2151
2226
  }
2152
2227
 
2228
+ /** ESC 打断: cancel the current turn if it is running. The chat's live
2229
+ * `running` flag (jobs mux frames + streaming nodes) is the fast source;
2230
+ * the (≤5s-fresh) session list is the fallback. Returns true if a cancel
2231
+ * request was actually sent. */
2232
+ #interruptIfRunning() {
2233
+ if (!this.currentSession) return false;
2234
+ const running = this.chat.running || !!this.sessions.find((s) => s.sessionId === this.currentSession)?.running;
2235
+ if (!running) return false;
2236
+ this.cancelSession({ sessionId: this.currentSession });
2237
+ this.toast("已请求中断当前回合");
2238
+ return true;
2239
+ }
2240
+
2153
2241
  async newSessionIn(group = null) {
2154
2242
  // Reuse an existing empty draft instead of minting a fresh blank session on
2155
2243
  // every "new session" click (this is how the meaningless blank sessions pile
@@ -2623,8 +2711,11 @@ export class App {
2623
2711
  // typing and Ctrl+J / Shift+Enter newlines behave like a normal editor.
2624
2712
  if (this.focused === this.chat.input) {
2625
2713
  if (ev.name === "escape") {
2714
+ // one ESC press: interrupt a running turn AND leave insert —
2715
+ // otherwise the key just exits insert (vim muscle memory).
2716
+ const interrupted = this.#interruptIfRunning();
2626
2717
  this.focus(this.chat);
2627
- this.toast("已退出输入(i 重新进入)");
2718
+ if (!interrupted) this.toast("已退出输入(i 重新进入)");
2628
2719
  } else {
2629
2720
  this.chat.input.onKey(ev);
2630
2721
  }
@@ -2674,13 +2765,11 @@ export class App {
2674
2765
  if (ev.name === "char" && ev.key === "/" && !ev.ctrl && this.focused !== this.chat.input) { this.startSearch(); this.redraw(); return; }
2675
2766
  if (ev.name === "char" && ev.key === "n" && !ev.ctrl && this.focused === this.sidebar) { this.newSession(); return; }
2676
2767
  if (ev.name === "escape") {
2768
+ // Esc in NORMAL mode interrupts a running turn (one press, regardless
2769
+ // of focus); otherwise it steps back toward the chat view.
2770
+ if (this.#interruptIfRunning()) return;
2677
2771
  if (this.focused === this.sidebar) { this.focus(this.chat); this.redraw(); }
2678
- else {
2679
- // Esc in NORMAL mode interrupts a running turn (insert→normal→interrupt).
2680
- const cur = this.sessions.find((s) => s.sessionId === this.currentSession);
2681
- if (cur?.running) { this.cancelSession(cur); this.toast("已请求中断当前回合"); }
2682
- else if (this.mode !== "chat") this.setMode("chat");
2683
- }
2772
+ else if (this.mode !== "chat") this.setMode("chat");
2684
2773
  return;
2685
2774
  }
2686
2775
  if (ev.name === "char" && ev.key === "i" && this.focused === this.sidebar) { this.focus(this.chat.input); this.redraw(); return; }