dsh-neotui 0.1.6 → 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.6",
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
@@ -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,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
@@ -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": {