dsh-neotui 0.0.32 → 0.0.33
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/md.js +11 -3
- package/src/term.js +16 -4
- package/src/views.js +46 -10
package/package.json
CHANGED
package/src/md.js
CHANGED
|
@@ -160,7 +160,8 @@ function highlightLine(line, lang) {
|
|
|
160
160
|
// ---- block renderer ----
|
|
161
161
|
// Returns array of lines; each line = array of segments.
|
|
162
162
|
|
|
163
|
-
export function renderMd(text, width, sink = null) {
|
|
163
|
+
export function renderMd(text, width, sink = null, opts = {}) {
|
|
164
|
+
const hardBreaks = !!opts.hardBreaks;
|
|
164
165
|
const lines = [];
|
|
165
166
|
const pushLine = (segs = []) => {
|
|
166
167
|
if (segs.length === 0) segs = [SEG(" ")];
|
|
@@ -178,8 +179,15 @@ export function renderMd(text, width, sink = null) {
|
|
|
178
179
|
|
|
179
180
|
const flushPara = () => {
|
|
180
181
|
if (para.length === 0) return;
|
|
181
|
-
|
|
182
|
-
|
|
182
|
+
if (hardBreaks) {
|
|
183
|
+
// verbatim line breaks: each source line renders on its own row
|
|
184
|
+
for (const line of para) {
|
|
185
|
+
lines.push(...wrapSegs(parseInline(line), width));
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
const segs = parseInline(para.join(" "));
|
|
189
|
+
lines.push(...wrapSegs(segs, width));
|
|
190
|
+
}
|
|
183
191
|
para = [];
|
|
184
192
|
};
|
|
185
193
|
const flushQuote = () => {
|
package/src/term.js
CHANGED
|
@@ -29,6 +29,7 @@ export class Term {
|
|
|
29
29
|
this.onEvent = onEvent ?? (() => {});
|
|
30
30
|
this.onResize = onResize ?? (() => {});
|
|
31
31
|
this.kitty = kitty;
|
|
32
|
+
this.kittyActive = false; // set when the terminal answers the CSI ? u query
|
|
32
33
|
this.decoder = new StringDecoder("utf8");
|
|
33
34
|
this.buf = "";
|
|
34
35
|
this.started = false;
|
|
@@ -52,7 +53,7 @@ export class Term {
|
|
|
52
53
|
o.write("\x1b[?1006h"); // SGR extended coordinates
|
|
53
54
|
o.write("\x1b[?2004h"); // bracketed paste
|
|
54
55
|
o.write("\x1b[?7l"); // no autowrap (we clip ourselves)
|
|
55
|
-
if (this.kitty) o.write("\x1b[>1u");
|
|
56
|
+
if (this.kitty) { o.write("\x1b[>1u"); o.write("\x1b[?u"); }
|
|
56
57
|
this.resizeHandler = () => this.#resize();
|
|
57
58
|
process.on("SIGWINCH", this.resizeHandler);
|
|
58
59
|
this.#resize();
|
|
@@ -206,7 +207,13 @@ export class Term {
|
|
|
206
207
|
this.#mouse(params, final);
|
|
207
208
|
return;
|
|
208
209
|
}
|
|
209
|
-
if (prefix === "?")
|
|
210
|
+
if (prefix === "?") {
|
|
211
|
+
// CSI ? flags u — the terminal answered our kitty-protocol query, so
|
|
212
|
+
// it DOES honor the protocol (WezTerm etc. reply with the flags it
|
|
213
|
+
// supports; terminals with the feature off reply nothing at all).
|
|
214
|
+
if (final === "u") this.kittyActive = true;
|
|
215
|
+
return; // other private responses (cursor pos) stay ignored
|
|
216
|
+
}
|
|
210
217
|
if (prefix === ">") return;
|
|
211
218
|
if (final === "Z") { // Shift+Tab (backtab)
|
|
212
219
|
this.#emit({ type: "key", name: "backtab", ctrl: false, alt: false, shift: true });
|
|
@@ -244,8 +251,13 @@ export class Term {
|
|
|
244
251
|
}
|
|
245
252
|
|
|
246
253
|
#kittyKey(params) {
|
|
247
|
-
|
|
248
|
-
|
|
254
|
+
// kitty: CSI code[:alternates] ; modifiers[:event-type] u — the modifier
|
|
255
|
+
// value is 1 + the bitmask (shift=1, alt=2, ctrl=4), and the event-type
|
|
256
|
+
// sub-field (press/repeat/release) rides after a colon on the modifier.
|
|
257
|
+
const [cpRaw = "0", modRaw = "1"] = params.split(";");
|
|
258
|
+
const cp = Number(String(cpRaw).split(":")[0]) || 0;
|
|
259
|
+
const m = (Number(String(modRaw).split(":")[0]) || 1) - 1;
|
|
260
|
+
const ctrl = !!(m & 4), alt = !!(m & 2), shift = !!(m & 1);
|
|
249
261
|
let name = KITTY_KEY_NAMES[cp];
|
|
250
262
|
if (name === "tab" && shift) name = "backtab";
|
|
251
263
|
if (name) {
|
package/src/views.js
CHANGED
|
@@ -582,7 +582,7 @@ export class ChatView extends Widget {
|
|
|
582
582
|
x: this.x, y: this.y + this.h - 2, w: this.w, h: 1,
|
|
583
583
|
multi: true, maxLines: 6,
|
|
584
584
|
bg: T.PANEL,
|
|
585
|
-
placeholder: "输入消息…(Ctrl+J 换行,Enter 发送)",
|
|
585
|
+
placeholder: "输入消息…(Shift+Enter / Ctrl+J 换行,Enter 发送)",
|
|
586
586
|
onEnter: (v) => this.send(v),
|
|
587
587
|
onChange: () => this.inputChanged(),
|
|
588
588
|
});
|
|
@@ -992,6 +992,14 @@ export class ChatView extends Widget {
|
|
|
992
992
|
collapsing = open;
|
|
993
993
|
if (open) { this.expanded.delete(key); this.collapsedBlocks.add(key); }
|
|
994
994
|
else { this.collapsedBlocks.delete(key); this.expanded.add(key); }
|
|
995
|
+
} else if (b.kind === "tool") {
|
|
996
|
+
// same two-state override, driven by bashMode: in all-collapsed
|
|
997
|
+
// mode (b) a click expands this block alone; a second click folds
|
|
998
|
+
// it again — never a no-op.
|
|
999
|
+
const open = this.expanded.has(key) || (this.bashMode !== "collapsed" && !this.collapsedBlocks.has(key));
|
|
1000
|
+
collapsing = open;
|
|
1001
|
+
if (open) { this.expanded.delete(key); this.collapsedBlocks.add(key); }
|
|
1002
|
+
else { this.collapsedBlocks.delete(key); this.expanded.add(key); }
|
|
995
1003
|
} else {
|
|
996
1004
|
collapsing = !this.collapsedBlocks.has(key);
|
|
997
1005
|
if (this.collapsedBlocks.has(key)) this.collapsedBlocks.delete(key);
|
|
@@ -1091,7 +1099,9 @@ export class ChatView extends Widget {
|
|
|
1091
1099
|
// message text starts on that same line (no blank first row).
|
|
1092
1100
|
const prefix = userPrefix();
|
|
1093
1101
|
const pw = strWidth(prefix);
|
|
1094
|
-
|
|
1102
|
+
// The user's own submitted text keeps its line breaks verbatim
|
|
1103
|
+
// (what they typed is what they see); only the width wraps.
|
|
1104
|
+
const md = renderMd(shown, Math.max(10, w - 4 - pw), null, { hardBreaks: true });
|
|
1095
1105
|
if (md.length === 0) {
|
|
1096
1106
|
lines.push([{ t: " " + prefix, fg: K.OK, bold: true }]);
|
|
1097
1107
|
mark(realIdx);
|
|
@@ -1154,7 +1164,7 @@ export class ChatView extends Widget {
|
|
|
1154
1164
|
sep();
|
|
1155
1165
|
} else if (b.kind === "tool") {
|
|
1156
1166
|
const key = `${realIdx}:${bi}`;
|
|
1157
|
-
const open = this.bashMode !== "collapsed" && !this.collapsedBlocks.has(key);
|
|
1167
|
+
const open = this.expanded.has(key) || (this.bashMode !== "collapsed" && !this.collapsedBlocks.has(key));
|
|
1158
1168
|
const exitCode = b.view?.view?.exitCode;
|
|
1159
1169
|
// A tool only TICKS while its turn is live. A finalized turn
|
|
1160
1170
|
// whose result never matched (orphan) must freeze at 无结果 —
|
|
@@ -1483,6 +1493,7 @@ export class ChatView extends Widget {
|
|
|
1483
1493
|
if (ev.name === "char" && ev.key === "/" && !ev.ctrl) { this.app.startSearch(); return true; }
|
|
1484
1494
|
if (ev.name === "char" && ev.key === "b" && !ev.ctrl) {
|
|
1485
1495
|
this.bashMode = this.bashMode === "collapsed" ? "expanded" : "collapsed";
|
|
1496
|
+
this.expanded.clear();
|
|
1486
1497
|
this.collapsedBlocks.clear();
|
|
1487
1498
|
this.app.toast(this.bashMode === "collapsed" ? "工具块:折叠(b 展开)" : "工具块:展开(b 折叠)");
|
|
1488
1499
|
this.queueRebuild();
|
|
@@ -1857,6 +1868,17 @@ export class App {
|
|
|
1857
1868
|
this.api.onHostFrame = (frame) => this.#onHostFrame(frame);
|
|
1858
1869
|
this.api.onStateChange = (s) => { this.connState = s; this.redraw(); };
|
|
1859
1870
|
this.#startPolling();
|
|
1871
|
+
// WezTerm ships with enable_kitty_keyboard = false: the terminal ignores
|
|
1872
|
+
// our CSI > 1u request AND the CSI ? u query, so Shift+Enter arrives as a
|
|
1873
|
+
// plain CR (indistinguishable from Enter). Detect the dead query and say
|
|
1874
|
+
// so once, instead of letting Shift+Enter silently submit.
|
|
1875
|
+
if (this.term?.kitty && !this.term?.kittyActive) {
|
|
1876
|
+
setTimeout(() => {
|
|
1877
|
+
if (!this.term?.kittyActive) {
|
|
1878
|
+
this.toast("终端未开启 kitty 键盘协议:Shift+Enter 换行不可用(可用 Ctrl+J)。WezTerm 请在配置中设置 enable_kitty_keyboard = true 后重启终端");
|
|
1879
|
+
}
|
|
1880
|
+
}, 1500);
|
|
1881
|
+
}
|
|
1860
1882
|
}
|
|
1861
1883
|
|
|
1862
1884
|
async refreshSessions() {
|
|
@@ -2150,6 +2172,19 @@ export class App {
|
|
|
2150
2172
|
this.refreshSessions();
|
|
2151
2173
|
}
|
|
2152
2174
|
|
|
2175
|
+
/** ESC 打断: cancel the current turn if it is running. The chat's live
|
|
2176
|
+
* `running` flag (jobs mux frames + streaming nodes) is the fast source;
|
|
2177
|
+
* the (≤5s-fresh) session list is the fallback. Returns true if a cancel
|
|
2178
|
+
* request was actually sent. */
|
|
2179
|
+
#interruptIfRunning() {
|
|
2180
|
+
if (!this.currentSession) return false;
|
|
2181
|
+
const running = this.chat.running || !!this.sessions.find((s) => s.sessionId === this.currentSession)?.running;
|
|
2182
|
+
if (!running) return false;
|
|
2183
|
+
this.cancelSession({ sessionId: this.currentSession });
|
|
2184
|
+
this.toast("已请求中断当前回合");
|
|
2185
|
+
return true;
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2153
2188
|
async newSessionIn(group = null) {
|
|
2154
2189
|
// Reuse an existing empty draft instead of minting a fresh blank session on
|
|
2155
2190
|
// every "new session" click (this is how the meaningless blank sessions pile
|
|
@@ -2623,8 +2658,11 @@ export class App {
|
|
|
2623
2658
|
// typing and Ctrl+J / Shift+Enter newlines behave like a normal editor.
|
|
2624
2659
|
if (this.focused === this.chat.input) {
|
|
2625
2660
|
if (ev.name === "escape") {
|
|
2661
|
+
// one ESC press: interrupt a running turn AND leave insert —
|
|
2662
|
+
// otherwise the key just exits insert (vim muscle memory).
|
|
2663
|
+
const interrupted = this.#interruptIfRunning();
|
|
2626
2664
|
this.focus(this.chat);
|
|
2627
|
-
this.toast("已退出输入(i 重新进入)");
|
|
2665
|
+
if (!interrupted) this.toast("已退出输入(i 重新进入)");
|
|
2628
2666
|
} else {
|
|
2629
2667
|
this.chat.input.onKey(ev);
|
|
2630
2668
|
}
|
|
@@ -2674,13 +2712,11 @@ export class App {
|
|
|
2674
2712
|
if (ev.name === "char" && ev.key === "/" && !ev.ctrl && this.focused !== this.chat.input) { this.startSearch(); this.redraw(); return; }
|
|
2675
2713
|
if (ev.name === "char" && ev.key === "n" && !ev.ctrl && this.focused === this.sidebar) { this.newSession(); return; }
|
|
2676
2714
|
if (ev.name === "escape") {
|
|
2715
|
+
// Esc in NORMAL mode interrupts a running turn (one press, regardless
|
|
2716
|
+
// of focus); otherwise it steps back toward the chat view.
|
|
2717
|
+
if (this.#interruptIfRunning()) return;
|
|
2677
2718
|
if (this.focused === this.sidebar) { this.focus(this.chat); this.redraw(); }
|
|
2678
|
-
else
|
|
2679
|
-
// Esc in NORMAL mode interrupts a running turn (insert→normal→interrupt).
|
|
2680
|
-
const cur = this.sessions.find((s) => s.sessionId === this.currentSession);
|
|
2681
|
-
if (cur?.running) { this.cancelSession(cur); this.toast("已请求中断当前回合"); }
|
|
2682
|
-
else if (this.mode !== "chat") this.setMode("chat");
|
|
2683
|
-
}
|
|
2719
|
+
else if (this.mode !== "chat") this.setMode("chat");
|
|
2684
2720
|
return;
|
|
2685
2721
|
}
|
|
2686
2722
|
if (ev.name === "char" && ev.key === "i" && this.focused === this.sidebar) { this.focus(this.chat.input); this.redraw(); return; }
|