dsh-neotui 0.0.31 → 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 +4 -2
- package/src/md.js +23 -6
- package/src/term.js +16 -4
- package/src/views.js +91 -29
package/package.json
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-neotui",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.33",
|
|
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
|
-
"bin": {
|
|
6
|
+
"bin": {
|
|
7
|
+
"dsh-neotui": "bin/dsh-tui.js"
|
|
8
|
+
},
|
|
7
9
|
"exports": {
|
|
8
10
|
".": "./src/index.js",
|
|
9
11
|
"./package.json": "./package.json"
|
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) {
|
|
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) {
|
|
|
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 = () => {
|
|
@@ -246,10 +254,19 @@ export function renderMd(text, width) {
|
|
|
246
254
|
const codeLines = codeBuf.length === 0 ? [""] : codeBuf;
|
|
247
255
|
let maxLen = Math.max(...codeLines.map((l) => strWidth(l)), 1);
|
|
248
256
|
const barW = Math.min(hw, maxLen + 2);
|
|
249
|
-
//
|
|
250
|
-
//
|
|
257
|
+
// web-style box with a click-to-copy button in the top-right corner;
|
|
258
|
+
// a fence WITHOUT a language gets no label (the bare "text" tag next
|
|
259
|
+
// to the corner looked like a rendering artifact)
|
|
260
|
+
const btn = "[复制]";
|
|
261
|
+
const btnW = strWidth(btn);
|
|
262
|
+
const topLen = Math.max(1, barW - btnW - 4);
|
|
251
263
|
const tag = lang && lang !== "text" ? " " + lang : "";
|
|
252
|
-
|
|
264
|
+
if (sink?.codeBlocks) sink.codeBlocks.push({ text: codeBuf.join("\n"), lineIdx: lines.length, lang });
|
|
265
|
+
lines.push([
|
|
266
|
+
SEG("┌" + "─".repeat(topLen) + " ", { fg: C.hr }),
|
|
267
|
+
SEG(btn, { fg: C.link, bold: true, copyCode: codeBuf.join("\n") }),
|
|
268
|
+
SEG(" ┐" + tag, { fg: C.hr }),
|
|
269
|
+
]);
|
|
253
270
|
for (const cl of codeLines) {
|
|
254
271
|
const hls = highlightLine(cl, lang);
|
|
255
272
|
lines.push([SEG("│ ", { fg: C.hr }), ...wrapSegs(hls, hw, { pad: true }).map((l) => l).flatMap((l, k) => (k === 0 ? l : [SEG(" ", { fg: C.hr }), ...l])), SEG(" │", { fg: C.hr })]);
|
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
|
});
|
|
@@ -594,6 +594,7 @@ export class ChatView extends Widget {
|
|
|
594
594
|
this.pressY = null;
|
|
595
595
|
this.pressInfo = null; // hit identity locked at press time
|
|
596
596
|
this.pressCtx = null;
|
|
597
|
+
this.pressX = null;
|
|
597
598
|
this.selStart = null;
|
|
598
599
|
this.selEnd = null;
|
|
599
600
|
this.stepState = { step: null }; // step/start tracking for the mux merge path
|
|
@@ -871,7 +872,7 @@ export class ChatView extends Widget {
|
|
|
871
872
|
* shift the hit identity between press and release (the 4-line mystery:
|
|
872
873
|
* the rendered frame and the hit test were consistent, but the identity
|
|
873
874
|
* was re-resolved at release against a stream that had moved). */
|
|
874
|
-
#anchorCtx(info, pressY = null) {
|
|
875
|
+
#anchorCtx(info, pressY = null, pressX = null) {
|
|
875
876
|
const lineKey = (m) => (m ? `${m.nodeIdx}:${m.blockIdx ?? "n"}` : null);
|
|
876
877
|
const topKey = lineKey(this.lineMap[this.view.scrollY]) ?? null;
|
|
877
878
|
let topFirst = -1;
|
|
@@ -893,7 +894,24 @@ export class ChatView extends Widget {
|
|
|
893
894
|
const preHeaderIdx = info ? firstNonEmpty(match) : -1;
|
|
894
895
|
const preHeaderRow = preHeaderIdx >= 0 ? preHeaderIdx - this.view.scrollY : null;
|
|
895
896
|
const pressRow = pressY !== null && pressY !== undefined && pressY >= 0 ? pressY - this.view.scrollY : null;
|
|
896
|
-
|
|
897
|
+
// the segment under the cursor at PRESS time (code-block [复制] hit test):
|
|
898
|
+
// re-resolving it at release would let a streaming rebuild move the line
|
|
899
|
+
const pressSeg = pressY !== null && pressY !== undefined && pressY >= 0 ? this.#segAtLine(pressY, pressX) : null;
|
|
900
|
+
return { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow, pressY, pressRow, pressX, pressSeg };
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** The line-segment under a screen x on a rendered line (for the code
|
|
904
|
+
* block's [复制] button hit test). */
|
|
905
|
+
#segAtLine(y, x) {
|
|
906
|
+
const line = this.lines[y];
|
|
907
|
+
if (!line || x == null || x < 0) return null;
|
|
908
|
+
let px = this.view.x;
|
|
909
|
+
for (const g of line) {
|
|
910
|
+
const w = strWidth(g.t ?? "");
|
|
911
|
+
if (x >= px && x < px + w) return g;
|
|
912
|
+
px += w;
|
|
913
|
+
}
|
|
914
|
+
return null;
|
|
897
915
|
}
|
|
898
916
|
|
|
899
917
|
/** Toggle the block/node under a line-mark, then re-anchor the viewport.
|
|
@@ -908,7 +926,18 @@ export class ChatView extends Widget {
|
|
|
908
926
|
}
|
|
909
927
|
const node = this.nodes[info.nodeIdx];
|
|
910
928
|
if (!node) return false;
|
|
911
|
-
const { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow, pressY, pressRow } = ctx ?? this.#anchorCtx(info);
|
|
929
|
+
const { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow, pressY, pressRow, pressX, pressSeg } = ctx ?? this.#anchorCtx(info);
|
|
930
|
+
// code block [复制] button: copy the raw code, no toggle (hit identity
|
|
931
|
+
// and segment locked at press time)
|
|
932
|
+
if (pressSeg?.copyCode) {
|
|
933
|
+
this.app.copyText(pressSeg.copyCode);
|
|
934
|
+
this.app.toast("已复制代码块");
|
|
935
|
+
return true;
|
|
936
|
+
}
|
|
937
|
+
// formal text blocks are NOT collapsible: clicking them is a no-op
|
|
938
|
+
if (node.kind === "assistant" && info.blockIdx !== null && node.blocks[info.blockIdx]?.kind === "text") {
|
|
939
|
+
return true;
|
|
940
|
+
}
|
|
912
941
|
let collapsing = false;
|
|
913
942
|
if (process.env.DSH_TUI_DEBUG_CLICK) {
|
|
914
943
|
this.#clickLog(`toggle mark=${JSON.stringify(info)} kind=${node.kind} blockIdx=${info.blockIdx} preHeaderRow=${preHeaderRow} topKey=${topKey} topOffset=${topOffset}`);
|
|
@@ -963,6 +992,14 @@ export class ChatView extends Widget {
|
|
|
963
992
|
collapsing = open;
|
|
964
993
|
if (open) { this.expanded.delete(key); this.collapsedBlocks.add(key); }
|
|
965
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); }
|
|
966
1003
|
} else {
|
|
967
1004
|
collapsing = !this.collapsedBlocks.has(key);
|
|
968
1005
|
if (this.collapsedBlocks.has(key)) this.collapsedBlocks.delete(key);
|
|
@@ -1062,7 +1099,9 @@ export class ChatView extends Widget {
|
|
|
1062
1099
|
// message text starts on that same line (no blank first row).
|
|
1063
1100
|
const prefix = userPrefix();
|
|
1064
1101
|
const pw = strWidth(prefix);
|
|
1065
|
-
|
|
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 });
|
|
1066
1105
|
if (md.length === 0) {
|
|
1067
1106
|
lines.push([{ t: " " + prefix, fg: K.OK, bold: true }]);
|
|
1068
1107
|
mark(realIdx);
|
|
@@ -1125,7 +1164,7 @@ export class ChatView extends Widget {
|
|
|
1125
1164
|
sep();
|
|
1126
1165
|
} else if (b.kind === "tool") {
|
|
1127
1166
|
const key = `${realIdx}:${bi}`;
|
|
1128
|
-
const open = this.bashMode !== "collapsed" && !this.collapsedBlocks.has(key);
|
|
1167
|
+
const open = this.expanded.has(key) || (this.bashMode !== "collapsed" && !this.collapsedBlocks.has(key));
|
|
1129
1168
|
const exitCode = b.view?.view?.exitCode;
|
|
1130
1169
|
// A tool only TICKS while its turn is live. A finalized turn
|
|
1131
1170
|
// whose result never matched (orphan) must freeze at 无结果 —
|
|
@@ -1181,27 +1220,20 @@ export class ChatView extends Widget {
|
|
|
1181
1220
|
sep();
|
|
1182
1221
|
} else {
|
|
1183
1222
|
beginCard("CARD");
|
|
1184
|
-
//
|
|
1185
|
-
//
|
|
1186
|
-
//
|
|
1223
|
+
// FORMAL text output is NOT collapsible — the user's message
|
|
1224
|
+
// content must stay readable; only think/tool blocks fold.
|
|
1225
|
+
// Code blocks inside render as boxes with a [复制] button.
|
|
1187
1226
|
const key = `${realIdx}:${bi}`;
|
|
1188
|
-
const open = !this.collapsedBlocks.has(key);
|
|
1189
1227
|
const text = b.text ?? "";
|
|
1190
|
-
const glyph = open ? "▾" : "▸";
|
|
1191
1228
|
const mdW = Math.max(10, w - 6 - strWidth(stepTag));
|
|
1192
|
-
const
|
|
1193
|
-
|
|
1194
|
-
: renderMd(truncateText(text, 600), mdW).slice(0, 3);
|
|
1229
|
+
const sink = { codeBlocks: [] };
|
|
1230
|
+
const md = renderMd(text, mdW, sink);
|
|
1195
1231
|
if (md.length > 0) {
|
|
1196
|
-
lines.push([{ t: " " }, { t:
|
|
1232
|
+
lines.push([{ t: " " }, { t: stepTag, fg: K.FAINT }, ...md[0]]);
|
|
1197
1233
|
mark(realIdx, bi);
|
|
1198
1234
|
for (const ln of md.slice(1)) { lines.push([{ t: " " }, ...ln]); mark(realIdx, bi); }
|
|
1199
1235
|
} else {
|
|
1200
|
-
lines.push([{ t: " " +
|
|
1201
|
-
mark(realIdx, bi);
|
|
1202
|
-
}
|
|
1203
|
-
if (!open && text.length > 0) {
|
|
1204
|
-
lines.push([{ t: ` …共 ${text.length} 字(点击展开)`, fg: K.FAINT }]);
|
|
1236
|
+
lines.push([{ t: " " + stepTag, fg: K.FAINT }]);
|
|
1205
1237
|
mark(realIdx, bi);
|
|
1206
1238
|
}
|
|
1207
1239
|
sep();
|
|
@@ -1365,7 +1397,8 @@ export class ChatView extends Widget {
|
|
|
1365
1397
|
// between press and release, so resolving the block at release would
|
|
1366
1398
|
// toggle a block a few lines away from the one the user saw.
|
|
1367
1399
|
this.pressInfo = this.lineMap?.[this.pressY] ?? null;
|
|
1368
|
-
this.
|
|
1400
|
+
this.pressX = ev.x;
|
|
1401
|
+
this.pressCtx = this.pressInfo ? this.#anchorCtx(this.pressInfo, this.pressY, ev.x) : null;
|
|
1369
1402
|
if (process.env.DSH_TUI_DEBUG_CLICK) {
|
|
1370
1403
|
const t = this.lines[this.pressY]?.map((g) => g.t).join("") ?? "";
|
|
1371
1404
|
this.#clickLog(`press screenY=${ev.y} screenX=${ev.x} lineIdx=${this.pressY} mark=${JSON.stringify(this.pressInfo)} text="${t.slice(0, 40)}" scrollY=${this.view.scrollY} viewY=${this.view.y} viewH=${this.view.h} assumedH=${this.app.screen?.h} assumedW=${this.app.screen?.w} ttyRows=${process.stdout.rows} ttyCols=${process.stdout.columns} inputY=${this.input.y} inputH=${this.input.h} todoH=${this.todoHeight()} footerH=${this.app.footerHeight?.() ?? "?"}`);
|
|
@@ -1407,8 +1440,11 @@ export class ChatView extends Widget {
|
|
|
1407
1440
|
const node = this.nodes[info.nodeIdx];
|
|
1408
1441
|
const items = [
|
|
1409
1442
|
{ label: "复制消息", action: () => this.app.copyNode(info.nodeIdx) },
|
|
1410
|
-
{ label: "展开 / 折叠", action: () => this.#toggleAt(info) },
|
|
1411
1443
|
];
|
|
1444
|
+
const clickedBlock = node?.kind === "assistant" && info.blockIdx !== null ? node.blocks[info.blockIdx] : null;
|
|
1445
|
+
if (clickedBlock?.kind !== "text") {
|
|
1446
|
+
items.push({ label: "展开 / 折叠", action: () => this.#toggleAt(info) });
|
|
1447
|
+
}
|
|
1412
1448
|
if (node?.id) {
|
|
1413
1449
|
items.push({ label: "转跳轨迹", action: () => this.app.jumpToTrajectoryNode(info.nodeIdx) });
|
|
1414
1450
|
}
|
|
@@ -1457,6 +1493,7 @@ export class ChatView extends Widget {
|
|
|
1457
1493
|
if (ev.name === "char" && ev.key === "/" && !ev.ctrl) { this.app.startSearch(); return true; }
|
|
1458
1494
|
if (ev.name === "char" && ev.key === "b" && !ev.ctrl) {
|
|
1459
1495
|
this.bashMode = this.bashMode === "collapsed" ? "expanded" : "collapsed";
|
|
1496
|
+
this.expanded.clear();
|
|
1460
1497
|
this.collapsedBlocks.clear();
|
|
1461
1498
|
this.app.toast(this.bashMode === "collapsed" ? "工具块:折叠(b 展开)" : "工具块:展开(b 折叠)");
|
|
1462
1499
|
this.queueRebuild();
|
|
@@ -1831,6 +1868,17 @@ export class App {
|
|
|
1831
1868
|
this.api.onHostFrame = (frame) => this.#onHostFrame(frame);
|
|
1832
1869
|
this.api.onStateChange = (s) => { this.connState = s; this.redraw(); };
|
|
1833
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
|
+
}
|
|
1834
1882
|
}
|
|
1835
1883
|
|
|
1836
1884
|
async refreshSessions() {
|
|
@@ -2124,6 +2172,19 @@ export class App {
|
|
|
2124
2172
|
this.refreshSessions();
|
|
2125
2173
|
}
|
|
2126
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
|
+
|
|
2127
2188
|
async newSessionIn(group = null) {
|
|
2128
2189
|
// Reuse an existing empty draft instead of minting a fresh blank session on
|
|
2129
2190
|
// every "new session" click (this is how the meaningless blank sessions pile
|
|
@@ -2597,8 +2658,11 @@ export class App {
|
|
|
2597
2658
|
// typing and Ctrl+J / Shift+Enter newlines behave like a normal editor.
|
|
2598
2659
|
if (this.focused === this.chat.input) {
|
|
2599
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();
|
|
2600
2664
|
this.focus(this.chat);
|
|
2601
|
-
this.toast("已退出输入(i 重新进入)");
|
|
2665
|
+
if (!interrupted) this.toast("已退出输入(i 重新进入)");
|
|
2602
2666
|
} else {
|
|
2603
2667
|
this.chat.input.onKey(ev);
|
|
2604
2668
|
}
|
|
@@ -2648,13 +2712,11 @@ export class App {
|
|
|
2648
2712
|
if (ev.name === "char" && ev.key === "/" && !ev.ctrl && this.focused !== this.chat.input) { this.startSearch(); this.redraw(); return; }
|
|
2649
2713
|
if (ev.name === "char" && ev.key === "n" && !ev.ctrl && this.focused === this.sidebar) { this.newSession(); return; }
|
|
2650
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;
|
|
2651
2718
|
if (this.focused === this.sidebar) { this.focus(this.chat); this.redraw(); }
|
|
2652
|
-
else
|
|
2653
|
-
// Esc in NORMAL mode interrupts a running turn (insert→normal→interrupt).
|
|
2654
|
-
const cur = this.sessions.find((s) => s.sessionId === this.currentSession);
|
|
2655
|
-
if (cur?.running) { this.cancelSession(cur); this.toast("已请求中断当前回合"); }
|
|
2656
|
-
else if (this.mode !== "chat") this.setMode("chat");
|
|
2657
|
-
}
|
|
2719
|
+
else if (this.mode !== "chat") this.setMode("chat");
|
|
2658
2720
|
return;
|
|
2659
2721
|
}
|
|
2660
2722
|
if (ev.name === "char" && ev.key === "i" && this.focused === this.sidebar) { this.focus(this.chat.input); this.redraw(); return; }
|