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 +1 -1
- package/src/panels.js +14 -3
- package/src/term.js +38 -12
- package/src/views.js +34 -11
- package/src/widgets.js +52 -5
package/package.json
CHANGED
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"
|
|
1415
|
-
|
|
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 ? `${
|
|
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)
|
|
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
|
@@ -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
|
|
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
|
-
|
|
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
|
-
//
|
|
1974
|
-
//
|
|
1975
|
-
//
|
|
1976
|
-
|
|
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
|
-
|
|
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
|
|
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 "
|
|
467
|
-
case "
|
|
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":
|