dsh-neotui 0.0.32 → 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,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.0.32",
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
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 = () => {
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
  });
@@ -992,6 +992,14 @@ export class ChatView extends Widget {
992
992
  collapsing = open;
993
993
  if (open) { this.expanded.delete(key); this.collapsedBlocks.add(key); }
994
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); }
995
1003
  } else {
996
1004
  collapsing = !this.collapsedBlocks.has(key);
997
1005
  if (this.collapsedBlocks.has(key)) this.collapsedBlocks.delete(key);
@@ -1091,7 +1099,9 @@ export class ChatView extends Widget {
1091
1099
  // message text starts on that same line (no blank first row).
1092
1100
  const prefix = userPrefix();
1093
1101
  const pw = strWidth(prefix);
1094
- 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 });
1095
1105
  if (md.length === 0) {
1096
1106
  lines.push([{ t: " " + prefix, fg: K.OK, bold: true }]);
1097
1107
  mark(realIdx);
@@ -1154,7 +1164,7 @@ export class ChatView extends Widget {
1154
1164
  sep();
1155
1165
  } else if (b.kind === "tool") {
1156
1166
  const key = `${realIdx}:${bi}`;
1157
- const open = this.bashMode !== "collapsed" && !this.collapsedBlocks.has(key);
1167
+ const open = this.expanded.has(key) || (this.bashMode !== "collapsed" && !this.collapsedBlocks.has(key));
1158
1168
  const exitCode = b.view?.view?.exitCode;
1159
1169
  // A tool only TICKS while its turn is live. A finalized turn
1160
1170
  // whose result never matched (orphan) must freeze at 无结果 —
@@ -1483,6 +1493,7 @@ export class ChatView extends Widget {
1483
1493
  if (ev.name === "char" && ev.key === "/" && !ev.ctrl) { this.app.startSearch(); return true; }
1484
1494
  if (ev.name === "char" && ev.key === "b" && !ev.ctrl) {
1485
1495
  this.bashMode = this.bashMode === "collapsed" ? "expanded" : "collapsed";
1496
+ this.expanded.clear();
1486
1497
  this.collapsedBlocks.clear();
1487
1498
  this.app.toast(this.bashMode === "collapsed" ? "工具块:折叠(b 展开)" : "工具块:展开(b 折叠)");
1488
1499
  this.queueRebuild();
@@ -1857,6 +1868,17 @@ export class App {
1857
1868
  this.api.onHostFrame = (frame) => this.#onHostFrame(frame);
1858
1869
  this.api.onStateChange = (s) => { this.connState = s; this.redraw(); };
1859
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
+ }
1860
1882
  }
1861
1883
 
1862
1884
  async refreshSessions() {
@@ -2150,6 +2172,19 @@ export class App {
2150
2172
  this.refreshSessions();
2151
2173
  }
2152
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
+
2153
2188
  async newSessionIn(group = null) {
2154
2189
  // Reuse an existing empty draft instead of minting a fresh blank session on
2155
2190
  // every "new session" click (this is how the meaningless blank sessions pile
@@ -2623,8 +2658,11 @@ export class App {
2623
2658
  // typing and Ctrl+J / Shift+Enter newlines behave like a normal editor.
2624
2659
  if (this.focused === this.chat.input) {
2625
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();
2626
2664
  this.focus(this.chat);
2627
- this.toast("已退出输入(i 重新进入)");
2665
+ if (!interrupted) this.toast("已退出输入(i 重新进入)");
2628
2666
  } else {
2629
2667
  this.chat.input.onKey(ev);
2630
2668
  }
@@ -2674,13 +2712,11 @@ export class App {
2674
2712
  if (ev.name === "char" && ev.key === "/" && !ev.ctrl && this.focused !== this.chat.input) { this.startSearch(); this.redraw(); return; }
2675
2713
  if (ev.name === "char" && ev.key === "n" && !ev.ctrl && this.focused === this.sidebar) { this.newSession(); return; }
2676
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;
2677
2718
  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
- }
2719
+ else if (this.mode !== "chat") this.setMode("chat");
2684
2720
  return;
2685
2721
  }
2686
2722
  if (ev.name === "char" && ev.key === "i" && this.focused === this.sidebar) { this.focus(this.chat.input); this.redraw(); return; }