dsh-neotui 0.0.32 → 0.1.0
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 +26 -13
- package/src/term.js +16 -4
- package/src/theme.js +1 -1
- package/src/views.js +108 -19
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 = () => {
|
|
@@ -244,26 +252,31 @@ export function renderMd(text, width, sink = null) {
|
|
|
244
252
|
const lang = inCode || "text";
|
|
245
253
|
const hw = Math.max(2, width - 4);
|
|
246
254
|
const codeLines = codeBuf.length === 0 ? [""] : codeBuf;
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
// to the corner looked like a rendering artifact)
|
|
255
|
+
if (sink?.codeBlocks) sink.codeBlocks.push({ text: codeBuf.join("\n"), lineIdx: lines.length, lang });
|
|
256
|
+
// Fixed-width box: EVERY row is exactly (hw + 4) columns wide —
|
|
257
|
+
// top `┌…[复制]…┐`, content `│ … │`, bottom `└…┘` — so the corners
|
|
258
|
+
// always sit above the vertical bars, never above the code text.
|
|
252
259
|
const btn = "[复制]";
|
|
253
260
|
const btnW = strWidth(btn);
|
|
254
|
-
const
|
|
261
|
+
const inner = hw + 2; // columns between the corners
|
|
262
|
+
const leftLen = Math.max(1, inner - btnW - 2);
|
|
255
263
|
const tag = lang && lang !== "text" ? " " + lang : "";
|
|
256
|
-
if (sink?.codeBlocks) sink.codeBlocks.push({ text: codeBuf.join("\n"), lineIdx: lines.length, lang });
|
|
257
264
|
lines.push([
|
|
258
|
-
SEG("┌" + "─".repeat(
|
|
265
|
+
SEG("┌" + "─".repeat(leftLen) + " ", { fg: C.hr }),
|
|
259
266
|
SEG(btn, { fg: C.link, bold: true, copyCode: codeBuf.join("\n") }),
|
|
260
267
|
SEG(" ┐" + tag, { fg: C.hr }),
|
|
261
268
|
]);
|
|
262
269
|
for (const cl of codeLines) {
|
|
263
270
|
const hls = highlightLine(cl, lang);
|
|
264
|
-
|
|
271
|
+
for (const row of wrapSegs(hls, hw)) {
|
|
272
|
+
const rowW = strWidth(row.map((g) => g.t ?? "").join(""));
|
|
273
|
+
const segs = [SEG("│ ", { fg: C.hr }), ...row];
|
|
274
|
+
if (rowW < hw) segs.push(SEG(" ".repeat(hw - rowW)));
|
|
275
|
+
segs.push(SEG(" │", { fg: C.hr }));
|
|
276
|
+
lines.push(segs);
|
|
277
|
+
}
|
|
265
278
|
}
|
|
266
|
-
lines.push([SEG("└" + "─".repeat(
|
|
279
|
+
lines.push([SEG("└" + "─".repeat(inner) + "┘", { fg: C.hr })]);
|
|
267
280
|
inCode = null;
|
|
268
281
|
}
|
|
269
282
|
i++;
|
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/theme.js
CHANGED
package/src/views.js
CHANGED
|
@@ -566,7 +566,7 @@ export class ChatView extends Widget {
|
|
|
566
566
|
this.expandedTools = new Set();
|
|
567
567
|
this.collapsedBlocks = new Set(); // per-block COLLAPSE (default expanded): `${realIdx}:${bi}`
|
|
568
568
|
this.thinkMode = "expanded"; // think blocks: expanded by default (t toggles)
|
|
569
|
-
this.bashMode = "
|
|
569
|
+
this.bashMode = "collapsed"; // tool blocks: collapsed by default (b toggles)
|
|
570
570
|
this.todosVisible = true; // todo block above the input (Shift+T toggles)
|
|
571
571
|
this.todoSeen = false; // once seen, the todo box keeps its height
|
|
572
572
|
this.running = false;
|
|
@@ -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
|
});
|
|
@@ -897,7 +897,20 @@ export class ChatView extends Widget {
|
|
|
897
897
|
// the segment under the cursor at PRESS time (code-block [复制] hit test):
|
|
898
898
|
// re-resolving it at release would let a streaming rebuild move the line
|
|
899
899
|
const pressSeg = pressY !== null && pressY !== undefined && pressY >= 0 ? this.#segAtLine(pressY, pressX) : null;
|
|
900
|
-
|
|
900
|
+
// press-time BLOCK signature: the streaming tail re-derives between press
|
|
901
|
+
// and release (syncTail replaces the block objects), so the positional
|
|
902
|
+
// nodeIdx:blockIdx key can drift; the signature lets release re-locate
|
|
903
|
+
// the same block by content instead.
|
|
904
|
+
let pressSig = null;
|
|
905
|
+
if (info && info.blockIdx !== null) {
|
|
906
|
+
const b = this.nodes[info.nodeIdx]?.blocks?.[info.blockIdx];
|
|
907
|
+
if (b) pressSig = {
|
|
908
|
+
nodeId: this.nodes[info.nodeIdx]?.id ?? null,
|
|
909
|
+
kind: b.kind,
|
|
910
|
+
prefix: String(b.text ?? b.args ?? "").slice(0, 40),
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
return { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow, pressY, pressRow, pressX, pressSeg, pressSig };
|
|
901
914
|
}
|
|
902
915
|
|
|
903
916
|
/** The line-segment under a screen x on a rendered line (for the code
|
|
@@ -924,9 +937,9 @@ export class ChatView extends Widget {
|
|
|
924
937
|
const ref = node?.images?.[info.imgIdx];
|
|
925
938
|
if (ref) { this.app.openImage(ref, { all: node.images, index: info.imgIdx }); return true; }
|
|
926
939
|
}
|
|
927
|
-
|
|
940
|
+
let node = this.nodes[info.nodeIdx];
|
|
928
941
|
if (!node) return false;
|
|
929
|
-
const { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow, pressY, pressRow, pressX, pressSeg } = ctx ?? this.#anchorCtx(info);
|
|
942
|
+
const { lineKey, topKey, topFirst, topOffset, match, firstNonEmpty, preHeaderIdx, preHeaderRow, pressY, pressRow, pressX, pressSeg, pressSig } = ctx ?? this.#anchorCtx(info);
|
|
930
943
|
// code block [复制] button: copy the raw code, no toggle (hit identity
|
|
931
944
|
// and segment locked at press time)
|
|
932
945
|
if (pressSeg?.copyCode) {
|
|
@@ -934,6 +947,31 @@ export class ChatView extends Widget {
|
|
|
934
947
|
this.app.toast("已复制代码块");
|
|
935
948
|
return true;
|
|
936
949
|
}
|
|
950
|
+
// The stream re-derives between press and release: if the positional
|
|
951
|
+
// block no longer matches the press-time signature, re-locate the SAME
|
|
952
|
+
// block by kind + content prefix (node id when the node carries one).
|
|
953
|
+
if (pressSig && info.blockIdx !== null) {
|
|
954
|
+
const cur = node.blocks?.[info.blockIdx];
|
|
955
|
+
const same = cur && cur.kind === pressSig.kind && String(cur.text ?? cur.args ?? "").slice(0, 40) === pressSig.prefix;
|
|
956
|
+
if (!same) {
|
|
957
|
+
let found = null;
|
|
958
|
+
for (let ni = 0; ni < this.nodes.length && !found; ni++) {
|
|
959
|
+
const n = this.nodes[ni];
|
|
960
|
+
if (pressSig.nodeId && n?.id && n.id !== pressSig.nodeId) continue;
|
|
961
|
+
for (let bi = 0; bi < (n?.blocks ?? []).length; bi++) {
|
|
962
|
+
const b = n.blocks[bi];
|
|
963
|
+
if (b.kind === pressSig.kind && String(b.text ?? b.args ?? "").slice(0, 40) === pressSig.prefix) {
|
|
964
|
+
found = { nodeIdx: ni, blockIdx: bi };
|
|
965
|
+
break;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
if (found) {
|
|
970
|
+
info = { ...info, ...found };
|
|
971
|
+
node = this.nodes[found.nodeIdx];
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}
|
|
937
975
|
// formal text blocks are NOT collapsible: clicking them is a no-op
|
|
938
976
|
if (node.kind === "assistant" && info.blockIdx !== null && node.blocks[info.blockIdx]?.kind === "text") {
|
|
939
977
|
return true;
|
|
@@ -992,6 +1030,14 @@ export class ChatView extends Widget {
|
|
|
992
1030
|
collapsing = open;
|
|
993
1031
|
if (open) { this.expanded.delete(key); this.collapsedBlocks.add(key); }
|
|
994
1032
|
else { this.collapsedBlocks.delete(key); this.expanded.add(key); }
|
|
1033
|
+
} else if (b.kind === "tool") {
|
|
1034
|
+
// same two-state override, driven by bashMode: in all-collapsed
|
|
1035
|
+
// mode (b) a click expands this block alone; a second click folds
|
|
1036
|
+
// it again — never a no-op.
|
|
1037
|
+
const open = this.expanded.has(key) || (this.bashMode !== "collapsed" && !this.collapsedBlocks.has(key));
|
|
1038
|
+
collapsing = open;
|
|
1039
|
+
if (open) { this.expanded.delete(key); this.collapsedBlocks.add(key); }
|
|
1040
|
+
else { this.collapsedBlocks.delete(key); this.expanded.add(key); }
|
|
995
1041
|
} else {
|
|
996
1042
|
collapsing = !this.collapsedBlocks.has(key);
|
|
997
1043
|
if (this.collapsedBlocks.has(key)) this.collapsedBlocks.delete(key);
|
|
@@ -1091,7 +1137,9 @@ export class ChatView extends Widget {
|
|
|
1091
1137
|
// message text starts on that same line (no blank first row).
|
|
1092
1138
|
const prefix = userPrefix();
|
|
1093
1139
|
const pw = strWidth(prefix);
|
|
1094
|
-
|
|
1140
|
+
// The user's own submitted text keeps its line breaks verbatim
|
|
1141
|
+
// (what they typed is what they see); only the width wraps.
|
|
1142
|
+
const md = renderMd(shown, Math.max(10, w - 4 - pw), null, { hardBreaks: true });
|
|
1095
1143
|
if (md.length === 0) {
|
|
1096
1144
|
lines.push([{ t: " " + prefix, fg: K.OK, bold: true }]);
|
|
1097
1145
|
mark(realIdx);
|
|
@@ -1154,7 +1202,7 @@ export class ChatView extends Widget {
|
|
|
1154
1202
|
sep();
|
|
1155
1203
|
} else if (b.kind === "tool") {
|
|
1156
1204
|
const key = `${realIdx}:${bi}`;
|
|
1157
|
-
const open = this.bashMode !== "collapsed" && !this.collapsedBlocks.has(key);
|
|
1205
|
+
const open = this.expanded.has(key) || (this.bashMode !== "collapsed" && !this.collapsedBlocks.has(key));
|
|
1158
1206
|
const exitCode = b.view?.view?.exitCode;
|
|
1159
1207
|
// A tool only TICKS while its turn is live. A finalized turn
|
|
1160
1208
|
// whose result never matched (orphan) must freeze at 无结果 —
|
|
@@ -1212,18 +1260,33 @@ export class ChatView extends Widget {
|
|
|
1212
1260
|
beginCard("CARD");
|
|
1213
1261
|
// FORMAL text output is NOT collapsible — the user's message
|
|
1214
1262
|
// content must stay readable; only think/tool blocks fold.
|
|
1215
|
-
//
|
|
1263
|
+
// The 🐳 marker distinguishes formal output from 💭 think and
|
|
1264
|
+
// ▸ tool blocks at a glance. Code blocks inside render as boxes
|
|
1265
|
+
// with a [复制] button.
|
|
1216
1266
|
const key = `${realIdx}:${bi}`;
|
|
1217
1267
|
const text = b.text ?? "";
|
|
1218
1268
|
const mdW = Math.max(10, w - 6 - strWidth(stepTag));
|
|
1219
1269
|
const sink = { codeBlocks: [] };
|
|
1220
1270
|
const md = renderMd(text, mdW, sink);
|
|
1271
|
+
const whale = { t: " 🐳", fg: K.ACCENT, bold: true };
|
|
1272
|
+
const step = { t: stepTag || " ", fg: K.FAINT };
|
|
1221
1273
|
if (md.length > 0) {
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1274
|
+
// When the message STARTS with a code box, the whale+step
|
|
1275
|
+
// marker gets its own line so the box's top border keeps the
|
|
1276
|
+
// same indent as its content rows (corners over bars, not
|
|
1277
|
+
// shifted right by the marker).
|
|
1278
|
+
const firstIsBoxTop = md[0].some((g) => g.copyCode);
|
|
1279
|
+
if (firstIsBoxTop) {
|
|
1280
|
+
lines.push([whale, step]);
|
|
1281
|
+
mark(realIdx, bi);
|
|
1282
|
+
for (const ln of md) { lines.push([{ t: " " }, ...ln]); mark(realIdx, bi); }
|
|
1283
|
+
} else {
|
|
1284
|
+
lines.push([whale, step, ...md[0]]);
|
|
1285
|
+
mark(realIdx, bi);
|
|
1286
|
+
for (const ln of md.slice(1)) { lines.push([{ t: " " }, ...ln]); mark(realIdx, bi); }
|
|
1287
|
+
}
|
|
1225
1288
|
} else {
|
|
1226
|
-
lines.push([
|
|
1289
|
+
lines.push([whale, step]);
|
|
1227
1290
|
mark(realIdx, bi);
|
|
1228
1291
|
}
|
|
1229
1292
|
sep();
|
|
@@ -1483,6 +1546,7 @@ export class ChatView extends Widget {
|
|
|
1483
1546
|
if (ev.name === "char" && ev.key === "/" && !ev.ctrl) { this.app.startSearch(); return true; }
|
|
1484
1547
|
if (ev.name === "char" && ev.key === "b" && !ev.ctrl) {
|
|
1485
1548
|
this.bashMode = this.bashMode === "collapsed" ? "expanded" : "collapsed";
|
|
1549
|
+
this.expanded.clear();
|
|
1486
1550
|
this.collapsedBlocks.clear();
|
|
1487
1551
|
this.app.toast(this.bashMode === "collapsed" ? "工具块:折叠(b 展开)" : "工具块:展开(b 折叠)");
|
|
1488
1552
|
this.queueRebuild();
|
|
@@ -1857,6 +1921,17 @@ export class App {
|
|
|
1857
1921
|
this.api.onHostFrame = (frame) => this.#onHostFrame(frame);
|
|
1858
1922
|
this.api.onStateChange = (s) => { this.connState = s; this.redraw(); };
|
|
1859
1923
|
this.#startPolling();
|
|
1924
|
+
// WezTerm ships with enable_kitty_keyboard = false: the terminal ignores
|
|
1925
|
+
// our CSI > 1u request AND the CSI ? u query, so Shift+Enter arrives as a
|
|
1926
|
+
// plain CR (indistinguishable from Enter). Detect the dead query and say
|
|
1927
|
+
// so once, instead of letting Shift+Enter silently submit.
|
|
1928
|
+
if (this.term?.kitty && !this.term?.kittyActive) {
|
|
1929
|
+
setTimeout(() => {
|
|
1930
|
+
if (!this.term?.kittyActive) {
|
|
1931
|
+
this.toast("终端未开启 kitty 键盘协议:Shift+Enter 换行不可用(可用 Ctrl+J)。WezTerm 请在配置中设置 enable_kitty_keyboard = true 后重启终端");
|
|
1932
|
+
}
|
|
1933
|
+
}, 1500);
|
|
1934
|
+
}
|
|
1860
1935
|
}
|
|
1861
1936
|
|
|
1862
1937
|
async refreshSessions() {
|
|
@@ -2150,6 +2225,19 @@ export class App {
|
|
|
2150
2225
|
this.refreshSessions();
|
|
2151
2226
|
}
|
|
2152
2227
|
|
|
2228
|
+
/** ESC 打断: cancel the current turn if it is running. The chat's live
|
|
2229
|
+
* `running` flag (jobs mux frames + streaming nodes) is the fast source;
|
|
2230
|
+
* the (≤5s-fresh) session list is the fallback. Returns true if a cancel
|
|
2231
|
+
* request was actually sent. */
|
|
2232
|
+
#interruptIfRunning() {
|
|
2233
|
+
if (!this.currentSession) return false;
|
|
2234
|
+
const running = this.chat.running || !!this.sessions.find((s) => s.sessionId === this.currentSession)?.running;
|
|
2235
|
+
if (!running) return false;
|
|
2236
|
+
this.cancelSession({ sessionId: this.currentSession });
|
|
2237
|
+
this.toast("已请求中断当前回合");
|
|
2238
|
+
return true;
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2153
2241
|
async newSessionIn(group = null) {
|
|
2154
2242
|
// Reuse an existing empty draft instead of minting a fresh blank session on
|
|
2155
2243
|
// every "new session" click (this is how the meaningless blank sessions pile
|
|
@@ -2623,8 +2711,11 @@ export class App {
|
|
|
2623
2711
|
// typing and Ctrl+J / Shift+Enter newlines behave like a normal editor.
|
|
2624
2712
|
if (this.focused === this.chat.input) {
|
|
2625
2713
|
if (ev.name === "escape") {
|
|
2714
|
+
// one ESC press: interrupt a running turn AND leave insert —
|
|
2715
|
+
// otherwise the key just exits insert (vim muscle memory).
|
|
2716
|
+
const interrupted = this.#interruptIfRunning();
|
|
2626
2717
|
this.focus(this.chat);
|
|
2627
|
-
this.toast("已退出输入(i 重新进入)");
|
|
2718
|
+
if (!interrupted) this.toast("已退出输入(i 重新进入)");
|
|
2628
2719
|
} else {
|
|
2629
2720
|
this.chat.input.onKey(ev);
|
|
2630
2721
|
}
|
|
@@ -2674,13 +2765,11 @@ export class App {
|
|
|
2674
2765
|
if (ev.name === "char" && ev.key === "/" && !ev.ctrl && this.focused !== this.chat.input) { this.startSearch(); this.redraw(); return; }
|
|
2675
2766
|
if (ev.name === "char" && ev.key === "n" && !ev.ctrl && this.focused === this.sidebar) { this.newSession(); return; }
|
|
2676
2767
|
if (ev.name === "escape") {
|
|
2768
|
+
// Esc in NORMAL mode interrupts a running turn (one press, regardless
|
|
2769
|
+
// of focus); otherwise it steps back toward the chat view.
|
|
2770
|
+
if (this.#interruptIfRunning()) return;
|
|
2677
2771
|
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
|
-
}
|
|
2772
|
+
else if (this.mode !== "chat") this.setMode("chat");
|
|
2684
2773
|
return;
|
|
2685
2774
|
}
|
|
2686
2775
|
if (ev.name === "char" && ev.key === "i" && this.focused === this.sidebar) { this.focus(this.chat.input); this.redraw(); return; }
|