dsh-neotui 0.1.5 → 0.1.7

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.1.5",
3
+ "version": "0.1.7",
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/panels.js CHANGED
@@ -1218,6 +1218,7 @@ export class ControlPanel extends Widget {
1218
1218
  ["G", "滚动到底", () => { this.app.closeOverlay(); this.app.chat.view.scrollY = this.app.chat.view.maxScroll(); }],
1219
1219
  ["[", "上一提问的终点", () => { this.app.closeOverlay(); this.app.focus(this.app.chat); this.app.chat.onKey({ type: "key", name: "char", key: "[", text: "[", ctrl: false, alt: false, shift: false }); }],
1220
1220
  ["]", "下一提问的终点", () => { this.app.closeOverlay(); this.app.focus(this.app.chat); this.app.chat.onKey({ type: "key", name: "char", key: "]", text: "]", ctrl: false, alt: false, shift: false }); }],
1221
+ ["Ctrl+L", "输入栏 展开/折叠", () => { this.app.closeOverlay(); this.app.focus(this.app.chat.input); this.app.chat.input.onKey({ type: "key", name: "char", key: "l", text: "l", ctrl: true, alt: false, shift: false }); }],
1221
1222
  ["Ctrl+P", "控制面板", () => { this.page = 1; this.sel = 0; this.app.redraw(); }],
1222
1223
  ["Ctrl+M", "切换模型", () => { this.app.overlay = buildModelPicker(this.app); }],
1223
1224
  ["Ctrl+T", "轨迹视图", () => { this.app.closeOverlay(); this.app.setMode("trajectory"); }],
@@ -1408,16 +1409,26 @@ export class JobsPanel extends Popup {
1408
1409
  #detailLines(j) {
1409
1410
  // Expanded = EVERYTHING: every field, full values — long commands wrap
1410
1411
  // across lines instead of being truncated (the web clips them; we don't).
1412
+ // `label` carries the full command, so it is shown here too (the header
1413
+ // row keeps only a 36-column preview of it). Epoch timestamps render as
1414
+ // Beijing time, not raw millisecond integers.
1415
+ const names = { label: "命令", detail: "结果", startedAt: "开始于", finishedAt: "结束于" };
1416
+ const fmtBeijing = (ms) => {
1417
+ if (typeof ms !== "number" || !isFinite(ms)) return String(ms ?? "");
1418
+ return new Date(ms).toLocaleString("sv-SE", { timeZone: "Asia/Shanghai", hour12: false }).replace("T", " ") + "(北京时间)";
1419
+ };
1411
1420
  const lines = [];
1412
1421
  const budget = Math.max(20, this.w - 10);
1413
1422
  for (const [k, v] of Object.entries(j)) {
1414
- if (["status", "kind", "label"].includes(k)) continue;
1415
- const s = v !== null && typeof v === "object" ? JSON.stringify(v) : String(v ?? "");
1423
+ if (["status", "kind"].includes(k)) continue;
1424
+ let s = v !== null && typeof v === "object" ? JSON.stringify(v) : String(v ?? "");
1425
+ if (k === "startedAt" || k === "finishedAt") s = fmtBeijing(v);
1416
1426
  if (s === "") continue;
1427
+ const key = names[k] ?? k;
1417
1428
  let rest = s;
1418
1429
  let first = true;
1419
1430
  while (rest.length > 0 || first) {
1420
- const head = first ? `${k}: ` : " ";
1431
+ const head = first ? `${key}: ` : " ";
1421
1432
  const take = JobsPanel.#cutWidth(rest, budget - strWidth(head));
1422
1433
  lines.push([{ t: ` ${head}${take}`, fg: K.DIM }]);
1423
1434
  rest = rest.slice(take.length);
package/src/term.js CHANGED
@@ -30,6 +30,7 @@ export class Term {
30
30
  this.onResize = onResize ?? (() => {});
31
31
  this.kitty = kitty;
32
32
  this.kittyActive = false; // set when the terminal answers the CSI ? u query
33
+ this.escTimer = null; // pending lone-ESC fallback (split-sequence safety)
33
34
  this.decoder = new StringDecoder("utf8");
34
35
  this.buf = "";
35
36
  this.started = false;
@@ -81,6 +82,24 @@ export class Term {
81
82
 
82
83
  #emit(ev) { this.onEvent(ev); }
83
84
 
85
+ /** A lone ESC waits briefly for the rest of its sequence; if nothing
86
+ * arrives it becomes the standalone Escape key. */
87
+ #armEscFallback() {
88
+ if (this.escTimer) return;
89
+ this.escTimer = setTimeout(() => {
90
+ this.escTimer = null;
91
+ if (this.buf === "\x1b") {
92
+ this.buf = "";
93
+ this.#emit({ type: "key", name: "escape", ctrl: false, alt: false, shift: false });
94
+ } else {
95
+ this.#parse(); // more bytes arrived: re-parse the pending ESC
96
+ }
97
+ }, 30);
98
+ }
99
+ #clearEscFallback() {
100
+ if (this.escTimer) { clearTimeout(this.escTimer); this.escTimer = null; }
101
+ }
102
+
84
103
  #feed(s) {
85
104
  if (!s) return;
86
105
  this.buf += s;
@@ -95,34 +114,38 @@ export class Term {
95
114
  if (ch === "\x1b") {
96
115
  // escape sequence
97
116
  const next = buf[i + 1];
117
+ if (next !== undefined) this.#clearEscFallback();
98
118
  if (next === "[") {
99
119
  const r = this.#parseCsi(buf, i + 2);
100
- if (r === null) { i = buf.length; break; } // incomplete
120
+ if (r === null) break; // incomplete: retain the sequence head
101
121
  i = r.next;
102
122
  } else if (next === "O") {
103
123
  const fin = buf[i + 2];
104
- if (fin === undefined) { i = buf.length; break; }
124
+ if (fin === undefined) break; // incomplete: retain
105
125
  const name = KEY_NAMES[fin];
106
126
  if (name) this.#emit({ type: "key", name, ctrl: false, alt: false, shift: false });
107
127
  i += 3;
108
128
  } else if (next === "]") {
109
129
  // OSC: skip to BEL or ST
110
130
  let j = i + 2;
131
+ let terminated = false;
111
132
  while (j < buf.length) {
112
- if (buf[j] === "\x07") { j++; break; }
113
- if (buf[j] === "\x1b" && buf[j + 1] === "\\") { j += 2; break; }
133
+ if (buf[j] === "\x07") { terminated = true; j++; break; }
134
+ if (buf[j] === "\x1b" && buf[j + 1] === "\\") { terminated = true; j += 2; break; }
114
135
  j++;
115
136
  }
116
- if (j > buf.length && buf[buf.length - 1] !== "\x07") { i = buf.length; break; }
137
+ if (!terminated) break; // incomplete OSC: retain the head
117
138
  i = j;
118
139
  } else if (next === "P" || next === "X" || next === "^" || next === "_") {
119
140
  // DCS / SOS / PM / APC: skip to ST (or BEL)
120
141
  let j = i + 2;
142
+ let terminated = false;
121
143
  while (j < buf.length) {
122
- if (buf[j] === "\x07") { j++; break; }
123
- if (buf[j] === "\x1b" && buf[j + 1] === "\\") { j += 2; break; }
144
+ if (buf[j] === "\x07") { terminated = true; j++; break; }
145
+ if (buf[j] === "\x1b" && buf[j + 1] === "\\") { terminated = true; j += 2; break; }
124
146
  j++;
125
147
  }
148
+ if (!terminated) break; // incomplete: retain the head
126
149
  i = j;
127
150
  } else if (next !== undefined) {
128
151
  // ESC + char = alt key
@@ -131,11 +154,14 @@ export class Term {
131
154
  else this.#emit({ type: "key", name: "char", key: next.toLowerCase(), text: next, ctrl: false, alt: true, shift: false });
132
155
  i += 2;
133
156
  } else {
134
- // Lone ESC at end of buffer = the standalone Escape key. Terminals
135
- // deliver escape sequences atomically, so a bare \x1b is never the
136
- // head of a sequence that will arrive in a later chunk.
137
- this.#emit({ type: "key", name: "escape", ctrl: false, alt: false, shift: false });
138
- i++;
157
+ // Lone ESC at the end of the buffer. A pty does NOT deliver escape
158
+ // sequences atomically: during a restart-handoff burst a mouse
159
+ // report can split across chunks, and emitting "escape key" here
160
+ // turned the remainder into input text (raw SGR bytes sent to the
161
+ // session). Hold the ESC and re-parse on the next chunk; only treat
162
+ // it as the Escape key if nothing follows within a short window.
163
+ this.#armEscFallback();
164
+ break; // retain the lone ESC
139
165
  }
140
166
  } else if (ch === "\r") {
141
167
  this.#emit({ type: "key", name: "enter", ctrl: false, alt: false, shift: false });
package/src/views.js CHANGED
@@ -580,9 +580,9 @@ export class ChatView extends Widget {
580
580
  });
581
581
  this.input = new Input({
582
582
  x: this.x, y: this.y + this.h - 2, w: this.w, h: 1,
583
- multi: true, maxLines: 6,
583
+ multi: true, maxLines: 6, app: this.app,
584
584
  bg: T.PANEL,
585
- placeholder: "输入消息…(Shift+Enter / Ctrl+J 换行,Enter 发送)",
585
+ placeholder: "输入消息…(Shift+Enter/Ctrl+J 换行,Ctrl+L 展开,Enter 发送)",
586
586
  onEnter: (v) => this.send(v),
587
587
  onChange: () => this.inputChanged(),
588
588
  });
@@ -731,10 +731,12 @@ export class ChatView extends Widget {
731
731
 
732
732
  inputChanged() {
733
733
  // Multi-line input grew/shrunk → reflow view vs input, keep the tail visible.
734
- const ih = this.input.height();
734
+ // The expanded (Ctrl+L) input may claim the whole window: clamp so the
735
+ // scroll view keeps at least one row.
736
+ const th = this.todoHeight();
737
+ const ih = Math.min(this.input.height(), Math.max(1, this.h - th - 2));
735
738
  const prevIh = this.input.h;
736
739
  this.input.h = ih;
737
- const th = this.todoHeight();
738
740
  this.view.h = this.h - ih - th - 1;
739
741
  this.input.y = this.y + this.h - ih;
740
742
  if (ih !== prevIh) this.app.layout();
@@ -743,9 +745,9 @@ export class ChatView extends Widget {
743
745
 
744
746
  resize(x, y, w, h) {
745
747
  this.x = x; this.y = y; this.w = w; this.h = h;
746
- const ih = this.input.height();
747
- this.input.h = ih;
748
748
  const th = this.todoHeight();
749
+ const ih = Math.min(this.input.height(), Math.max(1, h - th - 2));
750
+ this.input.h = ih;
749
751
  this.view.x = x; this.view.y = y; this.view.w = w; this.view.h = h - ih - th - 1;
750
752
  this.input.x = x; this.input.y = y + h - ih; this.input.w = w;
751
753
  this.cache.clear();
@@ -1807,6 +1809,7 @@ export class App {
1807
1809
  this.toastUntil = 0;
1808
1810
  this.jobs = [];
1809
1811
  this.jobsBySession = new Map(); // sessionId → latest session/jobs snapshot
1812
+ this.ctrlCUntil = null; // NORMAL-mode double-Ctrl+C exit window
1810
1813
  this.focused = null;
1811
1814
  this.provider = "";
1812
1815
  this.model = "";
@@ -1970,10 +1973,16 @@ export class App {
1970
1973
  this.model = host.model ?? "";
1971
1974
  } catch (e) { this.log(`[app] host.describe: ${e.message}`); }
1972
1975
  await this.refreshSessions();
1973
- // Open on a blank session at the launch directory (reusing an existing
1974
- // draft when available) so the blank-session homepage shows immediately
1975
- // instead of a fully empty chat area.
1976
- if (!this.currentSession) {
1976
+ // A /restart handoff carries the session to reopen: resume it instead of
1977
+ // minting a fresh blank session (which was leaking a stray "new session"
1978
+ // into 未分组 on every restart).
1979
+ const resumeId = process.env.DSH_TUI_RESUME_SESSION;
1980
+ if (resumeId && this.sessions.some((s) => s.sessionId === resumeId)) {
1981
+ await this.openSession(resumeId);
1982
+ } else if (!this.currentSession) {
1983
+ // Open on a blank session at the launch directory (reusing an existing
1984
+ // draft when available) so the blank-session homepage shows immediately
1985
+ // instead of a fully empty chat area.
1977
1986
  await this.newSessionIn(null);
1978
1987
  }
1979
1988
  this.api.connectMux();
@@ -2488,7 +2497,10 @@ export class App {
2488
2497
  try {
2489
2498
  const { spawn } = await import("node:child_process");
2490
2499
  const argv = process.argv.slice(1);
2491
- const child = spawn("sh", ["-c", 'sleep 1; exec "$@"', "sh", ...argv], { detached: true, stdio: "inherit" });
2500
+ // hand the CURRENT session to the new instance so it reopens it instead
2501
+ // of minting a fresh blank session (the "strange new session" in 未分组)
2502
+ const env = { ...process.env, DSH_TUI_RESUME_SESSION: this.currentSession ?? "" };
2503
+ const child = spawn("sh", ["-c", 'sleep 1; exec "$@"', "sh", ...argv], { detached: true, stdio: "inherit", env });
2492
2504
  child.unref();
2493
2505
  } catch (e) {
2494
2506
  this.toast(`重启失败: ${e.message}(请手动重启)`);
@@ -2838,6 +2850,17 @@ export class App {
2838
2850
  return;
2839
2851
  }
2840
2852
  if (ev.ctrl && ev.key === "q") { this.stop(); return; }
2853
+ if (ev.ctrl && ev.key === "c") {
2854
+ // NORMAL-mode Ctrl+C: two presses within the toast window exit the
2855
+ // process; the first press just warns (insert mode owns Ctrl+C for
2856
+ // clearing the input).
2857
+ if (this.focused === this.chat?.input) return false;
2858
+ const now = Date.now();
2859
+ if (this.ctrlCUntil != null && now < this.ctrlCUntil) { this.stop(); return; }
2860
+ this.ctrlCUntil = now + 3000;
2861
+ this.toast("再按一次 Ctrl+C 退出 TUI");
2862
+ return;
2863
+ }
2841
2864
  if (ev.ctrl && ev.key === "n") { this.focus(this.chat); this.redraw(); return; }
2842
2865
  if (ev.ctrl && ev.key === "b") { this.toggleSidebar(); return; }
2843
2866
  if (ev.ctrl && ev.key === "p") { this.overlay = new ControlPanel(this, { startPage: 1 }); this.redraw(); return; }
package/src/widgets.js CHANGED
@@ -253,6 +253,11 @@ export class Input extends Widget {
253
253
  this.border = opts.border ?? T.BORDER2;
254
254
  this.multi = opts.multi ?? false;
255
255
  this.maxLines = opts.maxLines ?? 6;
256
+ this.baseMaxLines = this.maxLines; // restored when the input collapses
257
+ this.expanded = false; // Ctrl+L: drop the line cap and fill the window
258
+ this.app = opts.app ?? null;
259
+ this.pendingPaste = null; // large-paste stage 1: held-back clipboard text
260
+ this.pendingPlaceholder = null; // the "[已复制 N 行内容]" marker currently shown
256
261
  this.onChange = opts.onChange ?? null;
257
262
  this.allowEmptyEnter = opts.allowEmptyEnter ?? false;
258
263
  this.history = [];
@@ -313,6 +318,7 @@ export class Input extends Widget {
313
318
  /** Rendered height: 1, or wrapped rows capped at maxLines when multi. */
314
319
  height() { return this.multi ? Math.max(1, Math.min(this.maxLines, this.#visualRows().length)) : 1; }
315
320
  setValue(v, opts = {}) {
321
+ this.#touch();
316
322
  this.value = String(v);
317
323
  this.cursor = this.#cps().length;
318
324
  this.selectAll = Boolean(opts.select); // first insert/text replaces the whole value
@@ -416,15 +422,45 @@ export class Input extends Widget {
416
422
  }
417
423
  return false;
418
424
  }
425
+ /** Any manual edit cancels the held-back paste (the placeholder stays as
426
+ * ordinary text). */
427
+ #touch() { this.pendingPaste = null; this.pendingPlaceholder = null; }
428
+ /** Claude-Code-style two-stage paste: the first Ctrl+Shift+V of a large
429
+ * clipboard shows a "[已复制 N 行内容]" placeholder; pasting the same
430
+ * content again inserts it in full, like a normal paste. */
431
+ #paste(text) {
432
+ text = String(text ?? "");
433
+ const large = text.includes("\n") || text.length > 300;
434
+ if (large) {
435
+ if (this.pendingPaste && this.pendingPaste.text === text) {
436
+ const full = this.pendingPaste.text;
437
+ const placeholder = this.pendingPlaceholder;
438
+ this.#touch();
439
+ if (this.value === placeholder) this.setValue(full);
440
+ else this.insert(full);
441
+ this.app?.toast?.("已粘贴完整内容");
442
+ return true;
443
+ }
444
+ const lines = text.split("\n").length;
445
+ this.pendingPaste = { text };
446
+ this.pendingPlaceholder = `[已复制 ${lines} 行内容]`;
447
+ this.insert(this.pendingPlaceholder);
448
+ this.app?.toast?.("再次 Ctrl+Shift+V 粘贴完整内容(Ctrl+L 展开输入栏)");
449
+ return true;
450
+ }
451
+ this.#touch();
452
+ this.insert(text);
453
+ return true;
454
+ }
419
455
  onKey(ev) {
420
- if (ev.type === "text") { this.insert(ev.text); return true; }
456
+ if (ev.type === "text") { return this.#paste(ev.text ?? ""); }
421
457
  if (ev.type !== "key") return false;
422
458
  switch (ev.name) {
423
459
  case "backspace":
424
- if (this.cursor > 0) { this.#deleteAt(this.cursor - 1); this.cursor--; this.onChange?.(); }
460
+ if (this.cursor > 0) { this.#touch(); this.#deleteAt(this.cursor - 1); this.cursor--; this.onChange?.(); }
425
461
  return true;
426
462
  case "delete":
427
- if (this.cursor < this.#cps().length) { this.#deleteAt(this.cursor); this.onChange?.(); }
463
+ if (this.cursor < this.#cps().length) { this.#touch(); this.#deleteAt(this.cursor); this.onChange?.(); }
428
464
  return true;
429
465
  case "left": this.selectAll = false; this.cursor = Math.max(0, this.cursor - 1); return true;
430
466
  case "right": this.selectAll = false; this.cursor = Math.min(this.#cps().length, this.cursor + 1); return true;
@@ -463,8 +499,18 @@ export class Input extends Widget {
463
499
  if (ev.ctrl) {
464
500
  switch (ev.key) {
465
501
  case "j": if (this.multi) { this.insert("\n"); return true; } return false;
466
- case "u": this.value = this.#cps().slice(this.cursor).join(""); this.cursor = 0; this.onChange?.(); return true;
467
- case "k": this.value = this.#cps().slice(0, this.cursor).join(""); this.onChange?.(); return true;
502
+ case "c": this.#touch(); this.value = ""; this.cursor = 0; this.selectAll = false; this.onChange?.(); this.app?.toast?.("已清空输入栏"); return true;
503
+ case "u": this.#touch(); this.value = this.#cps().slice(this.cursor).join(""); this.cursor = 0; this.onChange?.(); return true;
504
+ case "k": this.#touch(); this.value = this.#cps().slice(0, this.cursor).join(""); this.onChange?.(); return true;
505
+ case "l": {
506
+ // expand/collapse the input: expanded drops the 6-line cap and
507
+ // lets the editor fill the window above the input
508
+ this.expanded = !this.expanded;
509
+ this.maxLines = this.expanded ? 1000 : this.baseMaxLines;
510
+ this.onChange?.();
511
+ this.app?.toast?.(this.expanded ? "输入栏已展开(Ctrl+L 折叠)" : "输入栏已折叠(Ctrl+L 展开)");
512
+ return true;
513
+ }
468
514
  case "a": this.cursor = 0; return true;
469
515
  case "e": this.cursor = this.#cps().length; return true;
470
516
  case "w": {
@@ -481,6 +527,7 @@ export class Input extends Widget {
481
527
  }
482
528
  return false;
483
529
  }
530
+ this.#touch();
484
531
  this.insert(ev.text);
485
532
  return true;
486
533
  case "enter":