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 +1 -1
- package/src/panels.js +8 -2
- package/src/term.js +69 -12
- package/src/views.js +12 -0
- package/src/widgets.js +1 -0
package/package.json
CHANGED
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
|
-
|
|
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)
|
|
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)
|
|
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 (
|
|
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
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
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": {
|