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 +1 -1
- package/src/panels.js +8 -2
- package/src/term.js +38 -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,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)
|
|
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)
|
|
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 (
|
|
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
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
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": {
|