dsh-neotui 0.1.6 → 0.1.8

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.6",
3
+ "version": "0.1.8",
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
@@ -1410,13 +1410,19 @@ export class JobsPanel extends Popup {
1410
1410
  // Expanded = EVERYTHING: every field, full values — long commands wrap
1411
1411
  // across lines instead of being truncated (the web clips them; we don't).
1412
1412
  // `label` carries the full command, so it is shown here too (the header
1413
- // row keeps only a 36-column preview of it).
1413
+ // row keeps only a 36-column preview of it). Epoch timestamps render as
1414
+ // Beijing time, not raw millisecond integers.
1414
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
+ };
1415
1420
  const lines = [];
1416
1421
  const budget = Math.max(20, this.w - 10);
1417
1422
  for (const [k, v] of Object.entries(j)) {
1418
1423
  if (["status", "kind"].includes(k)) continue;
1419
- const s = v !== null && typeof v === "object" ? JSON.stringify(v) : String(v ?? "");
1424
+ let s = v !== null && typeof v === "object" ? JSON.stringify(v) : String(v ?? "");
1425
+ if (k === "startedAt" || k === "finishedAt") s = fmtBeijing(v);
1420
1426
  if (s === "") continue;
1421
1427
  const key = names[k] ?? k;
1422
1428
  let rest = s;
package/src/term.js CHANGED
@@ -30,10 +30,12 @@ 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;
36
37
  this.pasteBuf = null;
38
+ this.pasting = false; // inside a bracketed paste (200~ … 201~)
37
39
  this.w = (output.columns || 80); this.h = (output.rows || 24);
38
40
  }
39
41
 
@@ -81,6 +83,24 @@ export class Term {
81
83
 
82
84
  #emit(ev) { this.onEvent(ev); }
83
85
 
86
+ /** A lone ESC waits briefly for the rest of its sequence; if nothing
87
+ * arrives it becomes the standalone Escape key. */
88
+ #armEscFallback() {
89
+ if (this.escTimer) return;
90
+ this.escTimer = setTimeout(() => {
91
+ this.escTimer = null;
92
+ if (this.buf === "\x1b") {
93
+ this.buf = "";
94
+ this.#emit({ type: "key", name: "escape", ctrl: false, alt: false, shift: false });
95
+ } else {
96
+ this.#parse(); // more bytes arrived: re-parse the pending ESC
97
+ }
98
+ }, 30);
99
+ }
100
+ #clearEscFallback() {
101
+ if (this.escTimer) { clearTimeout(this.escTimer); this.escTimer = null; }
102
+ }
103
+
84
104
  #feed(s) {
85
105
  if (!s) return;
86
106
  this.buf += s;
@@ -89,40 +109,67 @@ export class Term {
89
109
 
90
110
  #parse() {
91
111
  const buf = this.buf;
112
+ const PASTE_END = "\x1b[201~";
92
113
  let i = 0;
93
114
  while (i < buf.length) {
115
+ // Inside a bracketed paste: collect raw content up to the END marker
116
+ // and emit it as ONE text event — otherwise a large paste arrives as
117
+ // many small text chunks and the input's two-stage paste never fires.
118
+ if (this.pasting) {
119
+ const end = buf.indexOf(PASTE_END, i);
120
+ if (end === -1) {
121
+ // keep any partial END-marker suffix in the buffer for reassembly
122
+ let keep = 0;
123
+ for (let k = 1; k < PASTE_END.length; k++) {
124
+ if (buf.length - k >= 0 && buf.endsWith(PASTE_END.slice(0, k))) keep = k;
125
+ }
126
+ this.pasteBuf += buf.slice(i, buf.length - keep);
127
+ i = buf.length - keep;
128
+ break;
129
+ }
130
+ this.pasteBuf += buf.slice(i, end);
131
+ this.pasting = false;
132
+ this.#emit({ type: "text", text: this.pasteBuf });
133
+ this.pasteBuf = "";
134
+ i = end + PASTE_END.length;
135
+ continue;
136
+ }
94
137
  const ch = buf[i];
95
138
  if (ch === "\x1b") {
96
139
  // escape sequence
97
140
  const next = buf[i + 1];
141
+ if (next !== undefined) this.#clearEscFallback();
98
142
  if (next === "[") {
99
143
  const r = this.#parseCsi(buf, i + 2);
100
- if (r === null) { i = buf.length; break; } // incomplete
144
+ if (r === null) break; // incomplete: retain the sequence head
101
145
  i = r.next;
102
146
  } else if (next === "O") {
103
147
  const fin = buf[i + 2];
104
- if (fin === undefined) { i = buf.length; break; }
148
+ if (fin === undefined) break; // incomplete: retain
105
149
  const name = KEY_NAMES[fin];
106
150
  if (name) this.#emit({ type: "key", name, ctrl: false, alt: false, shift: false });
107
151
  i += 3;
108
152
  } else if (next === "]") {
109
153
  // OSC: skip to BEL or ST
110
154
  let j = i + 2;
155
+ let terminated = false;
111
156
  while (j < buf.length) {
112
- if (buf[j] === "\x07") { j++; break; }
113
- if (buf[j] === "\x1b" && buf[j + 1] === "\\") { j += 2; break; }
157
+ if (buf[j] === "\x07") { terminated = true; j++; break; }
158
+ if (buf[j] === "\x1b" && buf[j + 1] === "\\") { terminated = true; j += 2; break; }
114
159
  j++;
115
160
  }
116
- if (j > buf.length && buf[buf.length - 1] !== "\x07") { i = buf.length; break; }
161
+ if (!terminated) break; // incomplete OSC: retain the head
117
162
  i = j;
118
163
  } else if (next === "P" || next === "X" || next === "^" || next === "_") {
119
164
  // DCS / SOS / PM / APC: skip to ST (or BEL)
120
165
  let j = i + 2;
166
+ let terminated = false;
121
167
  while (j < buf.length) {
122
- if (buf[j] === "\x07") { j++; break; }
123
- if (buf[j] === "\x1b" && buf[j + 1] === "\\") { j += 2; break; }
168
+ if (buf[j] === "\x07") { terminated = true; j++; break; }
169
+ if (buf[j] === "\x1b" && buf[j + 1] === "\\") { terminated = true; j += 2; break; }
124
170
  j++;
125
171
  }
172
+ if (!terminated) break; // incomplete: retain the head
126
173
  i = j;
127
174
  } else if (next !== undefined) {
128
175
  // ESC + char = alt key
@@ -131,11 +178,14 @@ export class Term {
131
178
  else this.#emit({ type: "key", name: "char", key: next.toLowerCase(), text: next, ctrl: false, alt: true, shift: false });
132
179
  i += 2;
133
180
  } 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++;
181
+ // Lone ESC at the end of the buffer. A pty does NOT deliver escape
182
+ // sequences atomically: during a restart-handoff burst a mouse
183
+ // report can split across chunks, and emitting "escape key" here
184
+ // turned the remainder into input text (raw SGR bytes sent to the
185
+ // session). Hold the ESC and re-parse on the next chunk; only treat
186
+ // it as the Escape key if nothing follows within a short window.
187
+ this.#armEscFallback();
188
+ break; // retain the lone ESC
139
189
  }
140
190
  } else if (ch === "\r") {
141
191
  this.#emit({ type: "key", name: "enter", ctrl: false, alt: false, shift: false });
@@ -236,6 +286,13 @@ export class Term {
236
286
  return;
237
287
  }
238
288
  const [n = 0, mod = 1] = nums;
289
+ if (n === 200) { this.pasting = true; this.pasteBuf = ""; return; } // bracketed paste START
290
+ if (n === 201) { // bracketed paste END (normally consumed by the collector)
291
+ this.pasting = false;
292
+ if (this.pasteBuf !== "") this.#emit({ type: "text", text: this.pasteBuf });
293
+ this.pasteBuf = "";
294
+ return;
295
+ }
239
296
  const name = TILDE_NAMES[n];
240
297
  if (!name) return;
241
298
  const ctrl = !!(mod - 1 & 4), alt = !!(mod - 1 & 2), shift = !!(mod - 1 & 1);
package/src/views.js CHANGED
@@ -1809,6 +1809,7 @@ export class App {
1809
1809
  this.toastUntil = 0;
1810
1810
  this.jobs = [];
1811
1811
  this.jobsBySession = new Map(); // sessionId → latest session/jobs snapshot
1812
+ this.ctrlCUntil = null; // NORMAL-mode double-Ctrl+C exit window
1812
1813
  this.focused = null;
1813
1814
  this.provider = "";
1814
1815
  this.model = "";
@@ -2849,6 +2850,17 @@ export class App {
2849
2850
  return;
2850
2851
  }
2851
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
+ }
2852
2864
  if (ev.ctrl && ev.key === "n") { this.focus(this.chat); this.redraw(); return; }
2853
2865
  if (ev.ctrl && ev.key === "b") { this.toggleSidebar(); return; }
2854
2866
  if (ev.ctrl && ev.key === "p") { this.overlay = new ControlPanel(this, { startPage: 1 }); this.redraw(); return; }
package/src/widgets.js CHANGED
@@ -499,6 +499,7 @@ export class Input extends Widget {
499
499
  if (ev.ctrl) {
500
500
  switch (ev.key) {
501
501
  case "j": if (this.multi) { this.insert("\n"); return true; } return false;
502
+ case "c": this.#touch(); this.value = ""; this.cursor = 0; this.selectAll = false; this.onChange?.(); this.app?.toast?.("已清空输入栏"); return true;
502
503
  case "u": this.#touch(); this.value = this.#cps().slice(this.cursor).join(""); this.cursor = 0; this.onChange?.(); return true;
503
504
  case "k": this.#touch(); this.value = this.#cps().slice(0, this.cursor).join(""); this.onChange?.(); return true;
504
505
  case "l": {