dsh-neotui 0.4.3 → 0.4.4
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/CHANGELOG.md +32 -0
- package/package.json +1 -1
- package/src/config.js +31 -0
- package/src/file-picker.js +5 -3
- package/src/keybindings.js +13 -10
- package/src/panels.js +419 -11
- package/src/side-question.mjs +75 -0
- package/src/term.js +21 -0
- package/src/views.js +1083 -87
- package/src/widgets.js +109 -1
package/src/views.js
CHANGED
|
@@ -2,20 +2,23 @@
|
|
|
2
2
|
import { Screen } from "./screen.js";
|
|
3
3
|
import { renderMd, C } from "./md.js";
|
|
4
4
|
import { truncate, strWidth, pad, bars, fmtDuration, fmtClock, fmtDateTime, graphemes, graphemeWidth, takeGraphemes, bytesLabel, prettyJson, looksLikeJson } from "./text.js";
|
|
5
|
-
import { readFileSync, appendFileSync, mkdirSync, existsSync, statSync } from "node:fs";
|
|
6
|
-
import { join, basename } from "node:path";
|
|
5
|
+
import { readFileSync, appendFileSync, mkdirSync, existsSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
7
6
|
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { join, basename } from "node:path";
|
|
9
|
+
import { tmpdir, homedir } from "node:os";
|
|
8
10
|
import { createRequire } from "node:module";
|
|
9
11
|
import { Widget, ScrollView, Input, Popup, Menu, StatusBar, wrapIndex } from "./widgets.js";
|
|
10
12
|
import { UploadPicker } from "./file-picker.js";
|
|
11
|
-
import { userPrefix, saveTuiConfig, loadTuiConfig, userName, busyEnter, foldDefaults, keyBindings, tuiConfigFile, reloadTuiConfig, searchHistory, rememberSearchQuery } from "./config.js";
|
|
13
|
+
import { userPrefix, saveTuiConfig, loadTuiConfig, userName, busyEnter, foldDefaults, keyBindings, tuiConfigFile, reloadTuiConfig, searchHistory, rememberSearchQuery, promptHistory, rememberPrompt, prefixKeys } from "./config.js";
|
|
12
14
|
import { bindingMatchFor, matchKeyBinding, CHAT_BINDING_ORDER, SIDEBAR_BINDING_ORDER, KEYBINDING_ORDER, INPUT_BINDING_ORDER, INPUT_EDIT_BINDING_ORDER } from "./keybindings.js";
|
|
13
|
-
export { userPrefix, saveTuiConfig, loadTuiConfig, userName, busyEnter, foldDefaults } from "./config.js";
|
|
15
|
+
export { userPrefix, saveTuiConfig, loadTuiConfig, userName, busyEnter, foldDefaults, promptHistory, rememberPrompt } from "./config.js";
|
|
14
16
|
import {
|
|
15
17
|
Picker, buildCommandPalette, buildModelPicker, buildModePicker, buildPermissionPicker,
|
|
16
18
|
modeName, permName, WorkspacePanel, TrajectoryPanel, DirPicker, AttachmentPanel,
|
|
17
|
-
ImagePopup, kittyCapable,
|
|
18
|
-
SkillsPanel, ControlPanel,
|
|
19
|
+
ImagePopup, kittyCapable, GoalPanel, SettingsPanel,
|
|
20
|
+
SkillsPanel, ControlPanel, QueuePanel, ModelPanel, fmtMs, ThemePickerBuffer,
|
|
21
|
+
SubagentPage, TasksPage,
|
|
19
22
|
} from "./panels.js";
|
|
20
23
|
|
|
21
24
|
import { T, themeName, cycleTheme } from "./theme.js";
|
|
@@ -1052,6 +1055,8 @@ class SidebarTree extends Widget {
|
|
|
1052
1055
|
this.sel = 0;
|
|
1053
1056
|
this.focused = false;
|
|
1054
1057
|
this.collapsed = new Set(); // group keys
|
|
1058
|
+
this.preview = { open: false, sessionId: null, title: "", lines: [], loading: false, error: null };
|
|
1059
|
+
this.previewToken = 0;
|
|
1055
1060
|
}
|
|
1056
1061
|
setData(workspaces, sessions, archivedIds, currentSessionId) {
|
|
1057
1062
|
const archived = new Set(archivedIds ?? []);
|
|
@@ -1230,11 +1235,68 @@ class SidebarTree extends Widget {
|
|
|
1230
1235
|
}
|
|
1231
1236
|
return false;
|
|
1232
1237
|
}
|
|
1238
|
+
/** Attached preview card (not a modal): p toggles it (Tab switched to the
|
|
1239
|
+
* window toggle); it never steals focus — ↑/↓ still moves the tree and the
|
|
1240
|
+
* card follows the selection. */
|
|
1241
|
+
#togglePreview() {
|
|
1242
|
+
const row = this.currentRow();
|
|
1243
|
+
if (!row || row.kind !== "session") return;
|
|
1244
|
+
if (this.preview.open && this.preview.sessionId === row.session.sessionId) {
|
|
1245
|
+
this.preview.open = false;
|
|
1246
|
+
this.app.redraw();
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
this.preview.open = true;
|
|
1250
|
+
void this.#refreshPreview(row.session, true);
|
|
1251
|
+
}
|
|
1252
|
+
/** Load the preview for a session (token-guarded, throttled at 250ms). */
|
|
1253
|
+
async #refreshPreview(session, force = false) {
|
|
1254
|
+
const app = this.app;
|
|
1255
|
+
const token = ++this.previewToken;
|
|
1256
|
+
this.preview.sessionId = session.sessionId;
|
|
1257
|
+
this.preview.title = session.title ?? session.sessionId.slice(0, 8);
|
|
1258
|
+
this.preview.loading = true;
|
|
1259
|
+
this.preview.error = null;
|
|
1260
|
+
this.preview.lines = [];
|
|
1261
|
+
app.redraw();
|
|
1262
|
+
try {
|
|
1263
|
+
const res = await app.api.call("session.history", { sessionId: session.sessionId, maxMessages: 12 });
|
|
1264
|
+
if (token !== this.previewToken) return; // stale
|
|
1265
|
+
const lines = [];
|
|
1266
|
+
for (const item of (res?.events ?? [])) {
|
|
1267
|
+
const ev = item?.event ?? item;
|
|
1268
|
+
const event = ev?.event ?? ev;
|
|
1269
|
+
const type = event?.type ?? ev?.type;
|
|
1270
|
+
const data = event?.data ?? ev?.data ?? {};
|
|
1271
|
+
if (type === "user/message") {
|
|
1272
|
+
const d = data.message ?? data;
|
|
1273
|
+
const text = partsToText(data.content ?? d.content ?? []).replace(/\s+/g, " ").trim();
|
|
1274
|
+
lines.push(["你", truncate(text || data.id || "(无文本)", 28)]);
|
|
1275
|
+
} else if (type === "assistant/message") {
|
|
1276
|
+
const d = data.message ?? data;
|
|
1277
|
+
const text = partsToText(d.content ?? []).replace(/\s+/g, " ").trim();
|
|
1278
|
+
lines.push(["AI", truncate(text || "(无文本)", 28)]);
|
|
1279
|
+
} else if (type === "tool/call") {
|
|
1280
|
+
const name = (data.call ?? data).name ?? data.name ?? "tool";
|
|
1281
|
+
lines.push(["⚙", truncate(String(name), 28)]);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
this.preview.lines = lines;
|
|
1285
|
+
} catch (e) {
|
|
1286
|
+
if (token === this.previewToken) this.preview.error = e.message;
|
|
1287
|
+
}
|
|
1288
|
+
if (token === this.previewToken) { this.preview.loading = false; app.redraw(); }
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1233
1291
|
onKey(ev) {
|
|
1234
1292
|
if (ev.type !== "key") return false;
|
|
1235
1293
|
switch (ev.name) {
|
|
1236
|
-
case "up":
|
|
1237
|
-
|
|
1294
|
+
case "up":
|
|
1295
|
+
if (this.move(-1)) { if (this.preview.open) { const r = this.currentRow(); if (r?.kind === "session") void this.#refreshPreview(r.session); } return true; }
|
|
1296
|
+
return false;
|
|
1297
|
+
case "down":
|
|
1298
|
+
if (this.move(1)) { if (this.preview.open) { const r = this.currentRow(); if (r?.kind === "session") void this.#refreshPreview(r.session); } return true; }
|
|
1299
|
+
return false;
|
|
1238
1300
|
case "pgup": this.scroll(-this.h); return true;
|
|
1239
1301
|
case "pgdn": this.scroll(this.h); return true;
|
|
1240
1302
|
case "home": this.sel = 0; this.#scrollToSel(); return true;
|
|
@@ -1243,7 +1305,7 @@ class SidebarTree extends Widget {
|
|
|
1243
1305
|
const row = this.currentRow();
|
|
1244
1306
|
if (!row) return false;
|
|
1245
1307
|
if (row.kind === "group") { this.toggle(row.group); this.app.redraw(); }
|
|
1246
|
-
else this.app.openSession(row.session.sessionId); // pane focus intentionally stays here
|
|
1308
|
+
else { this.preview.open = false; this.app.openSession(row.session.sessionId); } // pane focus intentionally stays here
|
|
1247
1309
|
return true;
|
|
1248
1310
|
}
|
|
1249
1311
|
case "left": {
|
|
@@ -1263,6 +1325,13 @@ class SidebarTree extends Widget {
|
|
|
1263
1325
|
case "char":
|
|
1264
1326
|
if (ev.ctrl && ev.key === "r") return this.openCurrentMenu();
|
|
1265
1327
|
if (ev.ctrl) return false;
|
|
1328
|
+
// p (no ctrl): toggle the attached preview card (Tab now switches the
|
|
1329
|
+
// focused window at the App layer, so the preview trigger moved here).
|
|
1330
|
+
if (ev.key === "p") {
|
|
1331
|
+
const row = this.currentRow();
|
|
1332
|
+
if (row?.kind === "session") { this.#togglePreview(); return true; }
|
|
1333
|
+
return false;
|
|
1334
|
+
}
|
|
1266
1335
|
{
|
|
1267
1336
|
const sbHit = bindingMatchFor(ev, keyBindings(), false, SIDEBAR_BINDING_ORDER);
|
|
1268
1337
|
if (sbHit?.id === "insert") { this.app.focus(this.app.chat.input); this.app.redraw(); return true; }
|
|
@@ -1293,6 +1362,8 @@ class SidebarTree extends Widget {
|
|
|
1293
1362
|
/** Slash commands offered by the input's candidate bar (Tab completes). */
|
|
1294
1363
|
const SLASH_COMMANDS = [
|
|
1295
1364
|
{ name: "/reload", desc: "重新载入界面(不重启进程)" },
|
|
1365
|
+
{ name: "/rewind", desc: "回退到某条提问之前(分支会话 + 原消息回填入输入框)" },
|
|
1366
|
+
{ name: "/btw", hint: "<问题>", desc: "侧问:不打断主回合的单轮问题(HoST 无通道时由 TUI 直连)" },
|
|
1296
1367
|
{ name: "/restart", desc: "重启 TUI 加载新版本" },
|
|
1297
1368
|
{ name: "/model", desc: "切换模型" },
|
|
1298
1369
|
{ name: "/theme", desc: "切换配色主题" },
|
|
@@ -1332,7 +1403,7 @@ export class ChatView extends Widget {
|
|
|
1332
1403
|
x: this.x, y: this.y + this.h - 2, w: this.w, h: 1,
|
|
1333
1404
|
multi: true, maxLines: 6, app: this.app, commands: SLASH_COMMANDS,
|
|
1334
1405
|
bg: T.PANEL,
|
|
1335
|
-
placeholder: "输入消息…(Shift+Enter/Ctrl+J 换行,Ctrl+L 展开,↑/↓ 历史,Tab 补全 /
|
|
1406
|
+
placeholder: "输入消息…(Shift+Enter/Ctrl+J 换行,Ctrl+L 展开,↑/↓ 历史,Tab 补全 / 命令 / 文件,Enter 发送)",
|
|
1336
1407
|
onEnter: (v) => this.send(v),
|
|
1337
1408
|
onChange: () => this.inputChanged(),
|
|
1338
1409
|
});
|
|
@@ -1553,6 +1624,8 @@ export class ChatView extends Widget {
|
|
|
1553
1624
|
async open(sessionId, epoch = this.app.sessionEpoch, maxMessages = 80) {
|
|
1554
1625
|
this.sessionId = sessionId;
|
|
1555
1626
|
this.nodes = [];
|
|
1627
|
+
const cwd = this.app.sessions?.find((s) => s.sessionId === sessionId)?.cwd;
|
|
1628
|
+
if (cwd) this.input.fileRoot = cwd; // file-path completion base
|
|
1556
1629
|
this.blockSel = -1;
|
|
1557
1630
|
this.cursorMode = "block";
|
|
1558
1631
|
this.visualAnchor = null;
|
|
@@ -1745,7 +1818,12 @@ export class ChatView extends Widget {
|
|
|
1745
1818
|
send(text) {
|
|
1746
1819
|
if (!this.sessionId) return;
|
|
1747
1820
|
const trimmed = text.trim();
|
|
1821
|
+
if (!trimmed.startsWith("/")) rememberPrompt(trimmed);
|
|
1822
|
+
const hist = this.app.chat?.input?.history;
|
|
1823
|
+
if (Array.isArray(hist)) this.app.chat.input.history = [trimmed, ...hist.filter((x) => x !== trimmed)].slice(0, 50);
|
|
1748
1824
|
if (trimmed === "/reload") { this.app.softReload(); return; }
|
|
1825
|
+
if (trimmed === "/rewind") { this.app.showRewindPicker(); return; }
|
|
1826
|
+
if (trimmed.startsWith("/btw")) { void this.app.sideQuestion(trimmed.slice(4).trim()); return; }
|
|
1749
1827
|
if (trimmed === "/restart") { this.app.restartApp(); return; }
|
|
1750
1828
|
if (trimmed === "/model") { this.app.overlay = buildModelPicker(this.app); this.app.redraw(); return; }
|
|
1751
1829
|
if (trimmed === "/theme") { this.app.showThemePicker(); return; }
|
|
@@ -1758,6 +1836,25 @@ export class ChatView extends Widget {
|
|
|
1758
1836
|
},
|
|
1759
1837
|
});
|
|
1760
1838
|
for (const e of errors) this.app.toast(`图片读取失败: ${e}`);
|
|
1839
|
+
// @-mentioned non-image files/directories attach as bounded text blocks
|
|
1840
|
+
// (Claude-Code parity; the token stays in the prompt as a reference).
|
|
1841
|
+
const atRoot = this.app.sessions?.find((s) => s.sessionId === this.sessionId)?.cwd ?? process.cwd();
|
|
1842
|
+
for (const m of trimmed.matchAll(/@([^\s@]+)/g)) {
|
|
1843
|
+
const ref = m[1];
|
|
1844
|
+
if (IMAGE_EXT.test(ref)) continue;
|
|
1845
|
+
const full = ref.startsWith("~") ? join(homedir(), ref.slice(1)) : join(atRoot, ref);
|
|
1846
|
+
if (!existsSync(full)) continue;
|
|
1847
|
+
const display = `@${ref}`;
|
|
1848
|
+
try {
|
|
1849
|
+
if (statSync(full).isDirectory()) {
|
|
1850
|
+
const names = readdirSync(full).slice(0, 200);
|
|
1851
|
+
parts.push({ type: "text", text: `\n<${display} 目录内容>\n${names.join("\n")}`.slice(0, 8192) });
|
|
1852
|
+
} else {
|
|
1853
|
+
const buf = readFileSync(full, "utf8");
|
|
1854
|
+
parts.push({ type: "text", text: `\n<${display} 文件内容>\n${buf}`.slice(0, 8192) });
|
|
1855
|
+
}
|
|
1856
|
+
} catch (e) { this.app.toast(`@${ref} 读取失败: ${e.message}`); }
|
|
1857
|
+
}
|
|
1761
1858
|
const clipParts = this.clipboardImages.map(({ mediaType, data, name }) => ({ type: "image", mediaType, data, name }));
|
|
1762
1859
|
const clipboardCount = clipParts.length;
|
|
1763
1860
|
parts.push(...clipParts);
|
|
@@ -3035,8 +3132,24 @@ export class ChatView extends Widget {
|
|
|
3035
3132
|
screen.text(Math.max(this.x + 1, this.x + this.w - 22), y, "Ctrl+O 附件管理器", { fg: K.FAINT, bg: T.BG2 });
|
|
3036
3133
|
}
|
|
3037
3134
|
|
|
3038
|
-
/** / command candidate bar above the input (↑/↓ cycle, Tab completes)
|
|
3135
|
+
/** / command candidate bar above the input (↑/↓ cycle, Tab completes);
|
|
3136
|
+
* also shows file-path completion candidates. */
|
|
3039
3137
|
#renderCmdBar(screen) {
|
|
3138
|
+
const inp = this.input;
|
|
3139
|
+
if (!inp.cmdOpen && inp.fileCands.length === 0 && inp.atCands.length === 0) return;
|
|
3140
|
+
if (inp.cmdOpen && inp.cmds.length > 0) { this.#renderCmdBarCommands(screen); return; }
|
|
3141
|
+
// file completion hint line
|
|
3142
|
+
const n = 1;
|
|
3143
|
+
const w = Math.min(this.view.w, 44);
|
|
3144
|
+
const y0 = Math.max(this.view.y, inp.y - n - 1);
|
|
3145
|
+
screen.fillRect(this.x, y0, this.x + w - 1, y0 + n - 1, " ", { bg: T.BG2 });
|
|
3146
|
+
const cands = inp.atCands.length ? inp.atCands : inp.fileCands;
|
|
3147
|
+
const shown = cands.slice(0, 6).map((n) => n.startsWith("@") ? n : n).join(" ");
|
|
3148
|
+
screen.text(this.x + 1, y0, `▸ ${truncate(shown, w - 3)}`, { fg: T.SELFG, bg: T.BG2 });
|
|
3149
|
+
return;
|
|
3150
|
+
}
|
|
3151
|
+
|
|
3152
|
+
#renderCmdBarCommands(screen) {
|
|
3040
3153
|
const inp = this.input;
|
|
3041
3154
|
if (!inp.cmdOpen || inp.cmds.length === 0) return;
|
|
3042
3155
|
const n = Math.min(inp.cmds.length, 6);
|
|
@@ -3613,6 +3726,8 @@ export class ApprovalPopup extends Popup {
|
|
|
3613
3726
|
// ---- App ----
|
|
3614
3727
|
|
|
3615
3728
|
export class App {
|
|
3729
|
+
#workspacePicker = null; // Ctrl+W 工作区选择小 buffer(用于重命名行取选中项)
|
|
3730
|
+
#splitDrag = null; // { node, axis, rect } — dragging a split divider
|
|
3616
3731
|
constructor({ screen, term, api, base, log, versionFetcher = latestNpmVersion, launcherAnime = false }) {
|
|
3617
3732
|
this.screen = screen;
|
|
3618
3733
|
this.term = term;
|
|
@@ -3629,6 +3744,15 @@ export class App {
|
|
|
3629
3744
|
this.logoData = null; // decoded custom logo: { palette, grid }
|
|
3630
3745
|
this.logoLoadError = null;
|
|
3631
3746
|
this.brandShimmer = -1; // welcome wordmark diagonal sweep phase (-1 = idle)
|
|
3747
|
+
// /btw default transport: spawn the standalone helper (one-shot key read)
|
|
3748
|
+
const helper = fileURLToPath(new URL("side-question.mjs", import.meta.url));
|
|
3749
|
+
this.sideQuestionRunner = (q, ctx, helperPath = helper) => new Promise((resolve) => {
|
|
3750
|
+
try {
|
|
3751
|
+
const res = spawnSync(process.execPath, [helperPath, "--question", q, "--context", ctx], { timeout: 60000, encoding: "utf8", maxBuffer: 8 * 1024 * 1024 });
|
|
3752
|
+
if (res.status === 0) { try { const data = JSON.parse(res.stdout); resolve({ answer: data.answer, err: null }); return; } catch { resolve({ answer: null, err: "答案解析失败" }); return; } }
|
|
3753
|
+
resolve({ answer: null, err: (res.stderr || "子进程失败").split("\n")[0] });
|
|
3754
|
+
} catch (e) { resolve({ answer: null, err: String(e?.message ?? e) }); }
|
|
3755
|
+
});
|
|
3632
3756
|
this.brandSweep0 = -1;
|
|
3633
3757
|
this.brandShimmerNext = null;
|
|
3634
3758
|
this.popup = null;
|
|
@@ -3663,7 +3787,16 @@ export class App {
|
|
|
3663
3787
|
this.searchActive = false;
|
|
3664
3788
|
this.overlay = null; // Picker / Popup / ImagePopup modal
|
|
3665
3789
|
this.fullBuffer = null; // full-screen panel buffer (workspace/settings/models/subagent/skills)
|
|
3666
|
-
this.
|
|
3790
|
+
this.mainTab = "chat"; // main-area tab: chat | trajectory | subagent | tasks (Shift+Tab cycles)
|
|
3791
|
+
this.mode = "chat"; // chat | trajectory (legacy mirror of mainTab)
|
|
3792
|
+
this.focusedWindow = "main"; // focused window: list (会话列表) | main (主窗口); Tab/Ctrl+Left+Right switch
|
|
3793
|
+
this.winTree = null; // split window tree (built lazily on the first split)
|
|
3794
|
+
this.focusedLeaf = null; // current leaf node of winTree
|
|
3795
|
+
this.lastMainLeaf = null; // Tab: list ↔ last focused main window
|
|
3796
|
+
this.pendingPrefix = null; // nvim-style pending layer (prefix tables)
|
|
3797
|
+
this.prefixTimeoutMs = 2000; // pending-layer timeout (tests may override)
|
|
3798
|
+
this.subagentPane = null; // lazy SubagentPage (tab 子代理)
|
|
3799
|
+
this.tasksPane = null; // lazy TasksPage (tab 后台任务)
|
|
3667
3800
|
this.sidebarWanted = true;
|
|
3668
3801
|
this.sidebarVisible = true; // auto-collapses on narrow terminals
|
|
3669
3802
|
this.tooSmall = false;
|
|
@@ -3712,7 +3845,10 @@ export class App {
|
|
|
3712
3845
|
this.sidebar.x = 0; this.sidebar.y = 0; this.sidebar.w = this.sidebarWidth; this.sidebar.h = this.screen.h - 1;
|
|
3713
3846
|
this.searchInput.w = this.sidebarWidth;
|
|
3714
3847
|
this.chat.resize(x, 1, w, mainH);
|
|
3715
|
-
|
|
3848
|
+
// The active main-area pane (trajectory/subagent/tasks) sits in exactly the
|
|
3849
|
+
// rect the ChatView occupies; non-active panes keep their last geometry.
|
|
3850
|
+
const pane = this.paneWidget();
|
|
3851
|
+
if (pane?.relayout) pane.relayout(x, 1, w, mainH);
|
|
3716
3852
|
if (this.fullBuffer?.relayout) this.fullBuffer.relayout(0, 0, this.screen.w, this.screen.h);
|
|
3717
3853
|
this.status.y = this.screen.h - footerH;
|
|
3718
3854
|
this.status.h = footerH;
|
|
@@ -3736,30 +3872,354 @@ export class App {
|
|
|
3736
3872
|
|
|
3737
3873
|
toggleChatTrajectory() {
|
|
3738
3874
|
if (!this.currentSession) { this.toast("先打开一个会话"); return; }
|
|
3739
|
-
this.
|
|
3740
|
-
}
|
|
3741
|
-
|
|
3742
|
-
/**
|
|
3743
|
-
|
|
3744
|
-
const
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3875
|
+
this.setTab(this.mainTab === "trajectory" ? "chat" : "trajectory");
|
|
3876
|
+
}
|
|
3877
|
+
|
|
3878
|
+
/** The main-area content rect — the exact rect the ChatView occupies. */
|
|
3879
|
+
mainAreaRect() {
|
|
3880
|
+
const x = this.sidebarVisible ? this.sidebarWidth : 0;
|
|
3881
|
+
const w = Math.max(1, this.screen.w - x);
|
|
3882
|
+
const footerH = Math.min(this.footerHeight(), Math.max(1, this.screen.h - 2));
|
|
3883
|
+
const mainH = Math.max(1, this.screen.h - 1 - footerH);
|
|
3884
|
+
return { x, y: 1, w, h: mainH };
|
|
3885
|
+
}
|
|
3886
|
+
|
|
3887
|
+
/** The currently-active non-chat pane widget (null when the chat tab is
|
|
3888
|
+
* active). Does NOT construct — panes are only grown by setTab(). */
|
|
3889
|
+
paneWidget() {
|
|
3890
|
+
if (this.mainTab === "trajectory") return this.trajectoryPanel;
|
|
3891
|
+
if (this.mainTab === "subagent") return this.subagentPane;
|
|
3892
|
+
if (this.mainTab === "tasks") return this.tasksPane;
|
|
3893
|
+
return null;
|
|
3894
|
+
}
|
|
3895
|
+
ensureTrajectoryPane() {
|
|
3896
|
+
if (!this.trajectoryPanel) this.trajectoryPanel = new TrajectoryPanel(this);
|
|
3897
|
+
return this.trajectoryPanel;
|
|
3898
|
+
}
|
|
3899
|
+
ensureSubagentPane() {
|
|
3900
|
+
if (!this.subagentPane) this.subagentPane = new SubagentPage(this);
|
|
3901
|
+
return this.subagentPane;
|
|
3902
|
+
}
|
|
3903
|
+
ensureTasksPane() {
|
|
3904
|
+
if (!this.tasksPane) this.tasksPane = new TasksPage(this);
|
|
3905
|
+
return this.tasksPane;
|
|
3906
|
+
}
|
|
3907
|
+
|
|
3908
|
+
/** Set the main-area tab (chat | trajectory | subagent | tasks) and focus
|
|
3909
|
+
* the main window. Non-chat panes are lazy-grown and seeded on first entry;
|
|
3910
|
+
* re-entry (including a cycle back) keeps their live state. */
|
|
3911
|
+
setTab(which) {
|
|
3912
|
+
this.mainTab = which;
|
|
3913
|
+
this.mode = which === "trajectory" ? "trajectory" : "chat";
|
|
3914
|
+
this.focusedWindow = "main";
|
|
3915
|
+
let pane = null;
|
|
3916
|
+
if (which === "trajectory") {
|
|
3917
|
+
pane = this.ensureTrajectoryPane();
|
|
3918
|
+
if (this.currentSession) pane.load(this.currentSession);
|
|
3919
|
+
} else if (which === "subagent") {
|
|
3920
|
+
pane = this.ensureSubagentPane();
|
|
3921
|
+
pane.onActivate?.();
|
|
3922
|
+
} else if (which === "tasks") {
|
|
3923
|
+
pane = this.ensureTasksPane();
|
|
3924
|
+
pane.onActivate?.();
|
|
3925
|
+
}
|
|
3926
|
+
this.layout();
|
|
3927
|
+
if (pane) this.focus(pane);
|
|
3928
|
+
else this.focus(this.chat);
|
|
3929
|
+
this.redraw();
|
|
3930
|
+
}
|
|
3931
|
+
|
|
3932
|
+
/** Window stops: list (会话列表) + main (主窗口). The list stop is omitted
|
|
3933
|
+
* when the sidebar is hidden. */
|
|
3934
|
+
windowStops() {
|
|
3935
|
+
return this.sidebarVisible ? ["list", "main"] : ["main"];
|
|
3936
|
+
}
|
|
3937
|
+
|
|
3938
|
+
/** tmux-style window focus over the 2-stop ring (list ↔ main, wrap). The
|
|
3939
|
+
* list stop is omitted when the sidebar is hidden. */
|
|
3940
|
+
focusWindow(delta) {
|
|
3941
|
+
if (!this.winTree) {
|
|
3942
|
+
const stops = this.windowStops();
|
|
3943
|
+
const idx = stops.indexOf(this.focusedWindow);
|
|
3944
|
+
const next = stops[wrapIndex(Math.max(0, idx) + delta, stops.length)];
|
|
3945
|
+
this.setFocusedWindow(next);
|
|
3946
|
+
return true;
|
|
3947
|
+
}
|
|
3948
|
+
const leaves = this.#leaves();
|
|
3949
|
+
const idx = leaves.indexOf(this.focusedLeaf ?? (this.focusedWindow === "list" ? this.#leafList() : this.#mainLeaves()[0]));
|
|
3950
|
+
const next = leaves[wrapIndex(Math.max(0, idx) + delta, leaves.length)];
|
|
3951
|
+
this.#focusLeaf(next);
|
|
3952
|
+
return true;
|
|
3953
|
+
}
|
|
3954
|
+
|
|
3955
|
+
/** Focus the given window. "list" focuses the session list; "main" focuses
|
|
3956
|
+
* whatever the active tab hosts (chat or the active pane). */
|
|
3957
|
+
setFocusedWindow(which) {
|
|
3958
|
+
if (this.winTree) {
|
|
3959
|
+
if (which === "list") this.#focusLeaf(this.#leafList());
|
|
3960
|
+
else this.#focusLeaf(this.lastMainLeaf ?? this.#mainLeaves()[0] ?? this.focusedLeaf);
|
|
3961
|
+
return;
|
|
3962
|
+
}
|
|
3963
|
+
this.focusedWindow = which;
|
|
3964
|
+
if (which === "list") {
|
|
3751
3965
|
this.focus(this.sidebar);
|
|
3752
|
-
} else if (next === "trajectory") {
|
|
3753
|
-
this.setMode("trajectory");
|
|
3754
|
-
this.focus(this.trajectoryPanel ?? this.chat);
|
|
3755
3966
|
} else {
|
|
3756
|
-
this.
|
|
3757
|
-
this.focus(
|
|
3967
|
+
const pane = this.paneWidget();
|
|
3968
|
+
if (pane) this.focus(pane);
|
|
3969
|
+
else this.focus(this.chat);
|
|
3758
3970
|
}
|
|
3759
3971
|
this.redraw();
|
|
3972
|
+
}
|
|
3973
|
+
|
|
3974
|
+
/** Cycle the main-window tab: chat → trajectory → subagent → tasks → chat.
|
|
3975
|
+
* Tabs are always reachable regardless of an open session. */
|
|
3976
|
+
cycleTab(delta) {
|
|
3977
|
+
const tabs = ["chat", "trajectory", "subagent", "tasks"];
|
|
3978
|
+
const idx = tabs.indexOf(this.mainTab);
|
|
3979
|
+
const next = tabs[wrapIndex(Math.max(0, idx) + delta, tabs.length)];
|
|
3980
|
+
this.setTab(next);
|
|
3760
3981
|
return true;
|
|
3761
3982
|
}
|
|
3762
3983
|
|
|
3984
|
+
// ---- Window split tree + prefix layers ----
|
|
3985
|
+
|
|
3986
|
+
/** Lazy window tree: built when the first split happens (list + one main). */
|
|
3987
|
+
#ensureWinTree() {
|
|
3988
|
+
if (this.winTree) return;
|
|
3989
|
+
const main = { type: "leaf", kind: "main", id: "m1", sessionId: this.currentSession, mainTab: this.mainTab };
|
|
3990
|
+
const list = { type: "leaf", kind: "list", id: "list" };
|
|
3991
|
+
this.winTree = { type: "split", dir: "right", a: list, b: main };
|
|
3992
|
+
this.focusedLeaf = this.focusedWindow === "list" ? list : main;
|
|
3993
|
+
this.lastMainLeaf = main;
|
|
3994
|
+
}
|
|
3995
|
+
/** Leaves in visual order (left→right, top→bottom). */
|
|
3996
|
+
#leaves(node = this.winTree) {
|
|
3997
|
+
if (!node) return [];
|
|
3998
|
+
if (node.type === "leaf") return [node];
|
|
3999
|
+
const l = this.#leaves(node.a), r = this.#leaves(node.b);
|
|
4000
|
+
return (node.dir === "left" || node.dir === "up") ? [...r, ...l] : [...l, ...r];
|
|
4001
|
+
}
|
|
4002
|
+
#mainLeaves() { return this.#leaves().filter((l) => l.kind === "main"); }
|
|
4003
|
+
#leafList(node = this.winTree) { return this.#leaves(node).find((l) => l.kind === "list"); }
|
|
4004
|
+
#contains(node, target) {
|
|
4005
|
+
if (!node || node.type === "leaf") return node === target;
|
|
4006
|
+
return this.#contains(node.a, target) || this.#contains(node.b, target);
|
|
4007
|
+
}
|
|
4008
|
+
#leafDepth(node, target, d = 0) {
|
|
4009
|
+
if (node.type === "leaf") return d;
|
|
4010
|
+
if (this.#contains(node.a, target) || this.#contains(node.b, target)) {
|
|
4011
|
+
const d1 = node.a.type === "split" || node.a === target || this.#contains(node.a, target) ? this.#leafDepth(node.a, target, d + 1) : d + 1;
|
|
4012
|
+
return this.#leafDepth(this.#contains(node.a, target) ? node.a : node.b, target, d + 1);
|
|
4013
|
+
}
|
|
4014
|
+
return d;
|
|
4015
|
+
}
|
|
4016
|
+
/** Replace leaf with a node in the tree (mutating). */
|
|
4017
|
+
#replaceLeaf(leaf, withNode, node = this.winTree) {
|
|
4018
|
+
if (!node || node.type === "leaf") return;
|
|
4019
|
+
if (node.a === leaf) { node.a = withNode; return; }
|
|
4020
|
+
if (node.b === leaf) { node.b = withNode; return; }
|
|
4021
|
+
if (this.#contains(node.a, leaf)) this.#replaceLeaf(leaf, withNode, node.a);
|
|
4022
|
+
else if (this.#contains(node.b, leaf)) this.#replaceLeaf(leaf, withNode, node.b);
|
|
4023
|
+
}
|
|
4024
|
+
/** Prune the parent split of target; returns the new root. */
|
|
4025
|
+
#prune(node, target) {
|
|
4026
|
+
if (node.type !== "split") return node;
|
|
4027
|
+
if (node.a === target) return node.b;
|
|
4028
|
+
if (node.b === target) return node.a;
|
|
4029
|
+
if (this.#contains(node.a, target)) return { ...node, a: this.#prune(node.a, target) };
|
|
4030
|
+
if (this.#contains(node.b, target)) return { ...node, b: this.#prune(node.b, target) };
|
|
4031
|
+
return node;
|
|
4032
|
+
}
|
|
4033
|
+
/** Rect of a leaf; the main-area rect for main leaves, the sidebar slot for list. */
|
|
4034
|
+
#leafRect(leaf) {
|
|
4035
|
+
const main = this.mainAreaRect();
|
|
4036
|
+
if (leaf.kind === "list") return { x: 0, y: 0, w: this.sidebarVisible ? this.sidebarWidth : 0, h: this.screen.h - 1 };
|
|
4037
|
+
return main;
|
|
4038
|
+
}
|
|
4039
|
+
/** Recompute the leaf rects and render each leaf (focused leaf renders its real
|
|
4040
|
+
* content; others draw a placeholder cell). Returns the rect map. */
|
|
4041
|
+
#layoutLeaves() {
|
|
4042
|
+
const map = new Map();
|
|
4043
|
+
const main = this.mainAreaRect();
|
|
4044
|
+
const listRect = { x: 0, y: 0, w: this.sidebarVisible ? this.sidebarWidth : 0, h: this.screen.h - 1 };
|
|
4045
|
+
// simple recursive rect assignment with 1-cell separators
|
|
4046
|
+
const place = (node, rect, depth = 0) => {
|
|
4047
|
+
if (node.type === "leaf") { map.set(node, rect); return; }
|
|
4048
|
+
const border = 1;
|
|
4049
|
+
if (node.dir === "right" || node.dir === "left") {
|
|
4050
|
+
const total = Math.max(2, rect.w - border);
|
|
4051
|
+
const r = Math.min(0.8, Math.max(0.2, node.ratio ?? 0.5));
|
|
4052
|
+
const aW = Math.max(1, Math.round(total * r));
|
|
4053
|
+
const bW = Math.max(1, total - aW);
|
|
4054
|
+
if (node.dir === "right") {
|
|
4055
|
+
place(node.a, { ...rect, w: aW }, depth + 1);
|
|
4056
|
+
place(node.b, { x: rect.x + aW + border, y: rect.y, w: bW, h: rect.h }, depth + 1);
|
|
4057
|
+
} else {
|
|
4058
|
+
place(node.b, { ...rect, w: bW }, depth + 1);
|
|
4059
|
+
place(node.a, { x: rect.x + bW + border, y: rect.y, w: aW, h: rect.h }, depth + 1);
|
|
4060
|
+
}
|
|
4061
|
+
} else {
|
|
4062
|
+
const total = Math.max(2, rect.h - border);
|
|
4063
|
+
const r = Math.min(0.8, Math.max(0.2, node.ratio ?? 0.5));
|
|
4064
|
+
const aH = Math.max(1, Math.round(total * r));
|
|
4065
|
+
const bH = Math.max(1, total - aH);
|
|
4066
|
+
if (node.dir === "down") {
|
|
4067
|
+
place(node.a, { ...rect, h: aH }, depth + 1);
|
|
4068
|
+
place(node.b, { x: rect.x, y: rect.y + aH + border, w: rect.w, h: bH }, depth + 1);
|
|
4069
|
+
} else {
|
|
4070
|
+
place(node.b, { ...rect, h: bH }, depth + 1);
|
|
4071
|
+
place(node.a, { x: rect.x, y: rect.y + bH + border, w: rect.w, h: aH }, depth + 1);
|
|
4072
|
+
}
|
|
4073
|
+
}
|
|
4074
|
+
};
|
|
4075
|
+
if (!this.winTree) { map.set(null, main); return map; }
|
|
4076
|
+
place(this.winTree, this.winTree.b?.kind === "main" && this.#leafList() === this.winTree.a ? main : main);
|
|
4077
|
+
// The tree root's split spans the WHOLE area, but the list leaf must occupy
|
|
4078
|
+
// the sidebar slot and mains the remaining main area. Handle that by
|
|
4079
|
+
// placing the list specially when it is a direct child:
|
|
4080
|
+
if (this.winTree.type === "split" && this.winTree.a?.kind === "list") {
|
|
4081
|
+
map.set(this.winTree.a, listRect);
|
|
4082
|
+
place(this.winTree.b, main);
|
|
4083
|
+
} else {
|
|
4084
|
+
place(this.winTree, { x: this.sidebarVisible ? this.sidebarWidth : 0, y: 1, w: main.w, h: main.h });
|
|
4085
|
+
if (this.#leafList()) map.set(this.#leafList(), listRect);
|
|
4086
|
+
}
|
|
4087
|
+
return map;
|
|
4088
|
+
}
|
|
4089
|
+
/** Split the focused leaf in a direction; the new window is a main window
|
|
4090
|
+
* adopting a blank session. Guards: 8 windows, depth 4, min 40x12. */
|
|
4091
|
+
splitWindow(dir) {
|
|
4092
|
+
this.#ensureWinTree();
|
|
4093
|
+
const leaf = this.focusedLeaf ?? (this.focusedWindow === "list" ? this.#leafList() : this.#mainLeaves()[0]);
|
|
4094
|
+
if (!leaf) { this.toast("请先打开一个会话"); return false; }
|
|
4095
|
+
if (this.#leaves().length >= 8) { this.toast("窗口已达上限 (8)"); return false; }
|
|
4096
|
+
if (this.#leafDepth(this.winTree, leaf) >= 4) { this.toast("分屏嵌套已达深度上限 (4)"); return false; }
|
|
4097
|
+
const r = this.#leafRect(leaf);
|
|
4098
|
+
if ((dir === "right" || dir === "left") && r.w < 40 * 2 + 1) { this.toast("空间不足:分屏后各窗口需 ≥40 列"); return false; }
|
|
4099
|
+
if ((dir === "up" || dir === "down") && r.h < 12 * 2 + 1) { this.toast("空间不足:分屏后各窗口需 ≥12 行"); return false; }
|
|
4100
|
+
const newLeaf = { type: "leaf", kind: "main", id: `m${Date.now()}`, sessionId: null, mainTab: "chat" };
|
|
4101
|
+
const node = { type: "split", dir, a: leaf, b: newLeaf, ratio: 0.5 };
|
|
4102
|
+
this.#replaceLeaf(leaf, node);
|
|
4103
|
+
this.focusedLeaf = newLeaf;
|
|
4104
|
+
this.lastMainLeaf = newLeaf;
|
|
4105
|
+
this.focusedWindow = "main";
|
|
4106
|
+
void this.#adoptBlankSession(newLeaf);
|
|
4107
|
+
this.layout();
|
|
4108
|
+
this.redraw();
|
|
4109
|
+
return true;
|
|
4110
|
+
}
|
|
4111
|
+
/** Close the focused main window (p c). Never deletes the session; if no
|
|
4112
|
+
* main window remains, a fresh blank-session main window appears. */
|
|
4113
|
+
closeWindow() {
|
|
4114
|
+
if (!this.winTree) { this.toast("还没有可分屏窗口"); return false; }
|
|
4115
|
+
const leaf = this.focusedLeaf ?? (this.focusedWindow === "list" ? this.#leafList() : this.#mainLeaves()[0]);
|
|
4116
|
+
if (!leaf || leaf.kind === "list") { this.toast("不能关闭会话列表"); return false; }
|
|
4117
|
+
const closedSession = leaf.sessionId;
|
|
4118
|
+
this.winTree = this.#prune(this.winTree, leaf);
|
|
4119
|
+
const mains = this.#mainLeaves();
|
|
4120
|
+
if (mains.length === 0) {
|
|
4121
|
+
const blank = { type: "leaf", kind: "main", id: `m${Date.now()}`, sessionId: null, mainTab: "chat" };
|
|
4122
|
+
const list = this.#leafList();
|
|
4123
|
+
this.winTree = list ? { type: "split", dir: "right", a: list, b: blank } : blank;
|
|
4124
|
+
this.focusedLeaf = blank;
|
|
4125
|
+
this.lastMainLeaf = blank;
|
|
4126
|
+
void this.#adoptBlankSession(blank);
|
|
4127
|
+
} else {
|
|
4128
|
+
this.focusedLeaf = mains[0];
|
|
4129
|
+
this.lastMainLeaf = mains[0];
|
|
4130
|
+
if (mains[0].sessionId && mains[0].sessionId !== this.chat.sessionId) this.openSession(mains[0].sessionId);
|
|
4131
|
+
}
|
|
4132
|
+
this.toast(closedSession ? `已关闭窗口(会话保留在侧栏)` : "已关闭窗口");
|
|
4133
|
+
this.layout();
|
|
4134
|
+
this.redraw();
|
|
4135
|
+
return true;
|
|
4136
|
+
}
|
|
4137
|
+
/** A blank session for a new window (reuse drafts like newSessionIn). */
|
|
4138
|
+
async #adoptBlankSession(leaf) {
|
|
4139
|
+
try {
|
|
4140
|
+
const blanks = this.sessions.filter((s) => s.blank && !s.running && s.sessionId !== this.currentSession);
|
|
4141
|
+
const blank = blanks.find((s) => s.cwd === process.cwd()) ?? blanks[0];
|
|
4142
|
+
if (blank) { await this.refreshSessions(); leaf.sessionId = blank.sessionId; this.openSession(blank.sessionId); return; }
|
|
4143
|
+
const { sessionId } = await this.api.call("session.create", { cwd: process.cwd() });
|
|
4144
|
+
if (typeof sessionId !== "string" || !sessionId) throw new Error("Host 未返回会话 ID");
|
|
4145
|
+
await this.refreshSessions();
|
|
4146
|
+
leaf.sessionId = sessionId;
|
|
4147
|
+
this.openSession(sessionId);
|
|
4148
|
+
} catch (e) { this.toast(`新建会话失败: ${e.message}`); }
|
|
4149
|
+
}
|
|
4150
|
+
/** Focus the given leaf (tree-aware). */
|
|
4151
|
+
#focusLeaf(leaf) {
|
|
4152
|
+
if (!leaf) return;
|
|
4153
|
+
this.focusedLeaf = leaf;
|
|
4154
|
+
if (leaf.kind === "list") {
|
|
4155
|
+
this.focusedWindow = "list";
|
|
4156
|
+
this.focus(this.sidebar);
|
|
4157
|
+
} else {
|
|
4158
|
+
this.focusedWindow = "main";
|
|
4159
|
+
this.lastMainLeaf = leaf;
|
|
4160
|
+
const tab = leaf.mainTab ?? "chat";
|
|
4161
|
+
if (tab !== this.mainTab) this.setTab(tab);
|
|
4162
|
+
else { const pane = this.paneWidget(); this.focus(pane ?? this.chat); }
|
|
4163
|
+
if (leaf.sessionId && leaf.sessionId !== this.chat.sessionId) this.openSession(leaf.sessionId);
|
|
4164
|
+
}
|
|
4165
|
+
this.redraw();
|
|
4166
|
+
}
|
|
4167
|
+
|
|
4168
|
+
/** Hit-test a split separator position (within ±1). */
|
|
4169
|
+
#hitSplitSeparator(px, py) {
|
|
4170
|
+
const walk = (node, rect) => {
|
|
4171
|
+
if (node.type !== "split") return null;
|
|
4172
|
+
const border = 1;
|
|
4173
|
+
if (node.dir === "right" || node.dir === "left") {
|
|
4174
|
+
const total = Math.max(2, rect.w - border);
|
|
4175
|
+
const r = Math.min(0.8, Math.max(0.2, node.ratio ?? 0.5));
|
|
4176
|
+
const aW = Math.max(1, Math.round(total * r));
|
|
4177
|
+
const x = rect.x + aW;
|
|
4178
|
+
if (Math.abs(px - x) <= 1 && py >= rect.y && py < rect.y + rect.h) return { node, rect };
|
|
4179
|
+
// recurse into the child containing the pointer
|
|
4180
|
+
const child = px < x ? node.a : node.b;
|
|
4181
|
+
const childRect = px < x ? { ...rect, w: aW } : { x: rect.x + aW + border, y: rect.y, w: Math.max(1, total - aW), h: rect.h };
|
|
4182
|
+
return walk(child, childRect);
|
|
4183
|
+
}
|
|
4184
|
+
const total = Math.max(2, rect.h - border);
|
|
4185
|
+
const r = Math.min(0.8, Math.max(0.2, node.ratio ?? 0.5));
|
|
4186
|
+
const aH = Math.max(1, Math.round(total * r));
|
|
4187
|
+
const y = rect.y + aH;
|
|
4188
|
+
if (Math.abs(py - y) <= 1 && px >= rect.x && px < rect.x + rect.w) return { node, rect };
|
|
4189
|
+
const child = py < y ? node.a : node.b;
|
|
4190
|
+
const childRect = py < y ? { ...rect, h: aH } : { x: rect.x, y: rect.y + aH + border, w: rect.w, h: Math.max(1, total - aH) };
|
|
4191
|
+
return walk(child, childRect);
|
|
4192
|
+
};
|
|
4193
|
+
const rootRect = { x: this.sidebarVisible ? this.sidebarWidth : 0, y: 1, w: this.mainAreaRect().w, h: this.mainAreaRect().h };
|
|
4194
|
+
return walk(this.winTree, rootRect);
|
|
4195
|
+
}
|
|
4196
|
+
|
|
4197
|
+
/** Prefix rows for the panel reference page — THE SAME table the engine
|
|
4198
|
+
* dispatches (single source of truth; chars from prefixKeys config). */
|
|
4199
|
+
prefixRowsData() {
|
|
4200
|
+
const k = prefixKeys();
|
|
4201
|
+
const t = this.#prefixTables().main;
|
|
4202
|
+
const meta = [
|
|
4203
|
+
["search", "跨会话全文搜索", "Ctrl+F 已释放"],
|
|
4204
|
+
["history", "输入历史搜索(可筛选)", "最近 50 条提问"],
|
|
4205
|
+
["rewind", "回退(分支会话 + 原消息回填)", "/rewind"],
|
|
4206
|
+
["model", "切换模型", "Ctrl+M 已释放"],
|
|
4207
|
+
["theme", "配色主题", "Ctrl+D 已释放"],
|
|
4208
|
+
["permission", "权限模式(沙箱 + 审批)", "F8 已释放"],
|
|
4209
|
+
["config", "打开配置文件(改 prefixKeys 前缀键)", "Ctrl+K"],
|
|
4210
|
+
["help", "帮助(按场景)", "Shift+/ 或前缀 ?"],
|
|
4211
|
+
["editor", "外部编辑器编辑输入", "$VISUAL · 非零退出保留原稿"],
|
|
4212
|
+
["panes", "窗格/分屏子表", "p r/l/u/n/c(NORMAL 亦可用)"],
|
|
4213
|
+
];
|
|
4214
|
+
return meta
|
|
4215
|
+
.filter(([action]) => t.entries[k[action]])
|
|
4216
|
+
.map(([action, desc, hint]) => [k[action], desc, hint, () => { this.closeOverlay(); t.entries[k[action]](); }]);
|
|
4217
|
+
}
|
|
4218
|
+
|
|
4219
|
+
/** Public window-tree inspection (tests/diagnostics). */
|
|
4220
|
+
splitLeaves() { return this.#leaves(); }
|
|
4221
|
+
mainLeafCount() { return this.#mainLeaves().length; }
|
|
4222
|
+
|
|
3763
4223
|
async checkUpdates(target = null, notify = false) {
|
|
3764
4224
|
const specs = {
|
|
3765
4225
|
dsh: { package: "@deepseek-ai/dsh", current: this.dshVersion, label: "DeepSeek Harness" },
|
|
@@ -3830,6 +4290,11 @@ export class App {
|
|
|
3830
4290
|
this.focused = w;
|
|
3831
4291
|
if (this.sidebar) this.sidebar.focused = w === this.sidebar;
|
|
3832
4292
|
if (this.chat?.input) this.chat.inputActive = w === this.chat.input;
|
|
4293
|
+
// The focused window follows what is actually focused: the session list when
|
|
4294
|
+
// the sidebar is focused, the main window for chat / its input / the active
|
|
4295
|
+
// tab's pane. Overlays (modal) never change the focused window.
|
|
4296
|
+
if (w === this.sidebar) this.focusedWindow = "list";
|
|
4297
|
+
else if (w === this.chat || w === this.chat?.input || w === this.paneWidget()) this.focusedWindow = "main";
|
|
3833
4298
|
}
|
|
3834
4299
|
|
|
3835
4300
|
openMenu(items, ev) {
|
|
@@ -3887,6 +4352,17 @@ export class App {
|
|
|
3887
4352
|
}
|
|
3888
4353
|
|
|
3889
4354
|
setStatus(msg) { this.statusMsg = msg; this.redraw(); }
|
|
4355
|
+
/** Live-frame fan-out into the active main-area pane. The subagent pane
|
|
4356
|
+
* reloads (throttled); the tasks pane repaints (it reads live data). */
|
|
4357
|
+
notifyPaneLive(kind) {
|
|
4358
|
+
if (kind === "subagent" && this.mainTab === "subagent") {
|
|
4359
|
+
const page = this.subagentPane;
|
|
4360
|
+
if (page && typeof page.reload === "function") page.reload();
|
|
4361
|
+
} else if (kind === "tasks" && this.mainTab === "tasks") {
|
|
4362
|
+
this.redraw();
|
|
4363
|
+
}
|
|
4364
|
+
}
|
|
4365
|
+
|
|
3890
4366
|
setJobs(jobs, sessionId = null) {
|
|
3891
4367
|
// snapshot buffered per session: the mux baseline arrives at connect
|
|
3892
4368
|
// time, possibly before the chat opens that session
|
|
@@ -3904,7 +4380,7 @@ export class App {
|
|
|
3904
4380
|
if (this.chat.sessionId) this.chat.pollTail();
|
|
3905
4381
|
// session.list is expensive (~100ms); refresh the sidebar every ~5s, not
|
|
3906
4382
|
// on every streaming poll.
|
|
3907
|
-
if (ticks++ % 10 === 0) { this.refreshSessions(); this.refreshSubagentStats(); }
|
|
4383
|
+
if (ticks++ % 10 === 0) { this.refreshSessions(); this.refreshSubagentStats(); this.notifyPaneLive("subagent"); }
|
|
3908
4384
|
const delay = this.chat.pollSlow ? 2000 : (this.chat.running ? 500 : 1500);
|
|
3909
4385
|
this.pollTimer = setTimeout(tick, delay);
|
|
3910
4386
|
};
|
|
@@ -3970,6 +4446,10 @@ export class App {
|
|
|
3970
4446
|
}
|
|
3971
4447
|
}
|
|
3972
4448
|
|
|
4449
|
+
/** Frame injection entry (tests & external callers): routes through the
|
|
4450
|
+
* private handler so the live fan-out is exercised exactly like the wire. */
|
|
4451
|
+
deliverFrame(frame) { this.#onFrame(frame); }
|
|
4452
|
+
|
|
3973
4453
|
#onFrame(frame) {
|
|
3974
4454
|
this.injectFrame(frame);
|
|
3975
4455
|
}
|
|
@@ -3991,6 +4471,7 @@ export class App {
|
|
|
3991
4471
|
if (frame.type === "session/queue") {
|
|
3992
4472
|
this.queueItems = frame.items ?? [];
|
|
3993
4473
|
if (this.overlay instanceof QueuePanel) this.overlay.syncItems(this.queueItems);
|
|
4474
|
+
this.notifyPaneLive("tasks");
|
|
3994
4475
|
}
|
|
3995
4476
|
this.chat.onFrame(frame);
|
|
3996
4477
|
}
|
|
@@ -4002,6 +4483,7 @@ export class App {
|
|
|
4002
4483
|
// connect-time baseline would otherwise be dropped by the filter
|
|
4003
4484
|
// below and the footer would stick at "0已完成")
|
|
4004
4485
|
this.setJobs(frame.jobs ?? [], frame.sessionId ?? null);
|
|
4486
|
+
this.notifyPaneLive("tasks");
|
|
4005
4487
|
if (this.chat.sessionId === frame.sessionId) this.chat.onFrame(frame);
|
|
4006
4488
|
break;
|
|
4007
4489
|
case "session/projection": {
|
|
@@ -4017,6 +4499,7 @@ export class App {
|
|
|
4017
4499
|
// Reflow immediately so the tail remains reachable above fixed docks.
|
|
4018
4500
|
if (["todos", "goal", "subagent"].includes(frame.key)) this.chat.inputChanged();
|
|
4019
4501
|
if (["todos", "goal"].includes(frame.key) && this.overlay instanceof GoalPanel) this.overlay.sync();
|
|
4502
|
+
if (frame.key === "subagent") this.notifyPaneLive("subagent");
|
|
4020
4503
|
break;
|
|
4021
4504
|
}
|
|
4022
4505
|
case "approval/resolved":
|
|
@@ -4215,6 +4698,212 @@ export class App {
|
|
|
4215
4698
|
|
|
4216
4699
|
/** Fork the whole session, or — with `atSeq` — everything up to that message
|
|
4217
4700
|
* (web forkAt parity: branch from a chosen point of a completed turn). */
|
|
4701
|
+
/** /btw side question: a single-turn, tool-less answer from the session
|
|
4702
|
+
* context without interrupting the running turn. Runs the standalone
|
|
4703
|
+
* side-question.mjs in a child process (the API key never enters this
|
|
4704
|
+
* process); the answer shows in a scrollable popup. */
|
|
4705
|
+
async sideQuestion(question) {
|
|
4706
|
+
const q = String(question ?? "").trim();
|
|
4707
|
+
if (!q) { this.toast("/btw <问题> — 请输入问题"); return; }
|
|
4708
|
+
this.toast("侧问中…(单轮、无工具,不打断主回合)");
|
|
4709
|
+
// context: the most recent visible transcript lines, capped
|
|
4710
|
+
const context = (this.chat.nodes ?? [])
|
|
4711
|
+
.flatMap((n) => (n.blocks ?? []).map((b) => b.text ?? ""))
|
|
4712
|
+
.filter((t) => t.trim())
|
|
4713
|
+
.slice(-6).join("\n").slice(0, 6000);
|
|
4714
|
+
let answer = null, err = null;
|
|
4715
|
+
const run = this.sideQuestionRunner;
|
|
4716
|
+
if (run) {
|
|
4717
|
+
try { ({ answer, err } = await run(q, context)); } catch (e) { err = String(e?.message ?? e); }
|
|
4718
|
+
} else {
|
|
4719
|
+
err = "sideQuestionRunner 未配置";
|
|
4720
|
+
}
|
|
4721
|
+
if (err || answer == null) { this.toast(`侧问失败: ${err ?? "无返回"}`); return; }
|
|
4722
|
+
const lines = answer.split("\n").map((l) => truncate(l, Math.min(78, this.screen.w - 8)));
|
|
4723
|
+
const h = Math.min(Math.max(6, lines.length + 2), Math.max(8, this.screen.h - 6));
|
|
4724
|
+
this.overlay = new Popup({
|
|
4725
|
+
x: Math.max(0, Math.floor((this.screen.w - Math.min(80, this.screen.w - 4)) / 2)),
|
|
4726
|
+
y: Math.max(0, Math.floor(this.screen.h / 2) - Math.floor(h / 2)),
|
|
4727
|
+
w: Math.min(80, this.screen.w - 4), h,
|
|
4728
|
+
title: `侧问 · ${truncate(q, 40)} · ↑↓ 滚动 · Esc 关闭`,
|
|
4729
|
+
lines, scrollable: true, buttons: [],
|
|
4730
|
+
onAction: () => { this.overlay = null; this.focus(this.chat); this.redraw(); },
|
|
4731
|
+
});
|
|
4732
|
+
this.focus(this.overlay);
|
|
4733
|
+
this.redraw();
|
|
4734
|
+
}
|
|
4735
|
+
|
|
4736
|
+
/** Scenario help: `?` (Shift+/) routes by the focused pane; the prefix
|
|
4737
|
+
* page's `?` reuses the focus recorded when the panel opened. Scrollable
|
|
4738
|
+
* buffer, Esc returns to the source pane. */
|
|
4739
|
+
showHelp(forceFocus = null) {
|
|
4740
|
+
const focusTarget = forceFocus ?? this.focused;
|
|
4741
|
+
const scenario = focusTarget === this.sidebar ? "sidebar" : (focusTarget === this.chat || focusTarget === this.chat.input) ? "chat" : "global";
|
|
4742
|
+
const lines = [];
|
|
4743
|
+
const pushTitle = (t) => lines.push([{ t: " " + t, fg: T.PURPLE, bg: T.PANEL, bold: true }]);
|
|
4744
|
+
const pushRow = (a, d) => lines.push([{ t: ` ${a}`, fg: T.ACCENT, bg: T.PANEL, bold: true }, { t: ` ${d}`, fg: T.TXT, bg: T.PANEL }]);
|
|
4745
|
+
const kb = keyBindings();
|
|
4746
|
+
if (scenario === "sidebar") {
|
|
4747
|
+
pushTitle("会话树(侧栏)帮助");
|
|
4748
|
+
pushRow("↑/↓", "选择会话 / 分组 Enter 打开(组=折叠/展开)");
|
|
4749
|
+
pushRow("Space", "组: 折叠/展开并回到组顶点");
|
|
4750
|
+
pushRow("Tab", "会话预览卡片(纯开关,焦点不离开;↑↓ 跟随)");
|
|
4751
|
+
pushRow("Ctrl+R", "会话菜单(重命名/移动/归档/删除/分叉/导出)");
|
|
4752
|
+
pushRow("[ / ]", "把会话移入上一/下一分组");
|
|
4753
|
+
pushRow("Ctrl+N", "新建会话(优先复用空白草稿)");
|
|
4754
|
+
pushRow("Ctrl+O", "附件管理器 / 文件选择器");
|
|
4755
|
+
} else if (scenario === "chat") {
|
|
4756
|
+
pushTitle("对话场景帮助");
|
|
4757
|
+
pushRow("g / G", "跳到首个 / 最新正文块 ↑/↓ 滚动历史");
|
|
4758
|
+
pushRow("[ / ]", "上一 / 下一提问的终点 Ctrl+E 步跳");
|
|
4759
|
+
pushRow("t / b", "全部思考块 / 工具块 展开折叠");
|
|
4760
|
+
pushRow("v / Shift+V", "正文块选择(复制走 OSC 52)");
|
|
4761
|
+
pushRow("Ctrl+R", "块菜单(复制/展开/分叉/文件预览)");
|
|
4762
|
+
pushRow("Ctrl+B", "侧栏显示/隐藏");
|
|
4763
|
+
pushRow(`?`, `本帮助(${scenario}场景) 双击 Ctrl+C 退出`);
|
|
4764
|
+
pushTitle("对话中的常用命令");
|
|
4765
|
+
pushRow("", SLASH_COMMANDS.map((c) => `${c.name}${c.hint ? " " + c.hint : ""}`).join(" · "));
|
|
4766
|
+
pushRow("", "Tab:输入中补全路径 / /命令(候选栏 ↑↓ 循环)");
|
|
4767
|
+
} else {
|
|
4768
|
+
pushTitle("全局约定");
|
|
4769
|
+
pushRow("Esc 层级", "输入 → 关命令栏 → 面板;NORMAL Esc 中断回合,INSERT 不打断");
|
|
4770
|
+
pushRow("Ctrl+Space", "控制面板 → 前缀页(首屏)");
|
|
4771
|
+
pushTitle("前缀键(Ctrl+Space 后按)");
|
|
4772
|
+
for (const [k, d, hint] of ControlPanel.prototype.prefixRows()) pushRow(k, `${d}${hint ? " · " + hint : ""}`);
|
|
4773
|
+
pushRow("Ctrl+P / F9", "命令面板 / 模式选择 Ctrl+G 目标 · Ctrl+T 轨迹页");
|
|
4774
|
+
pushRow("Ctrl+K / Ctrl+H", "编辑配置 / 技能");
|
|
4775
|
+
pushRow("双击 Ctrl+C / Ctrl+Q", "退出 TUI");
|
|
4776
|
+
pushRow("? / 前缀页 ?", "本帮助(按场景)");
|
|
4777
|
+
}
|
|
4778
|
+
const h = Math.min(Math.max(8, lines.length + 2), Math.max(10, this.screen.h - 4));
|
|
4779
|
+
const popup = new Popup({
|
|
4780
|
+
x: Math.max(0, Math.floor((this.screen.w - Math.min(78, this.screen.w - 4)) / 2)),
|
|
4781
|
+
y: Math.max(0, Math.floor(this.screen.h / 2) - Math.floor(h / 2)),
|
|
4782
|
+
w: Math.min(78, this.screen.w - 4), h,
|
|
4783
|
+
title: `${scenario === "sidebar" ? "侧栏" : scenario === "chat" ? "对话" : "全局"}帮助 · ↑↓ 滚动 · Esc 返回`,
|
|
4784
|
+
lines, scrollable: true, buttons: [],
|
|
4785
|
+
onAction: () => { this.overlay = null; this.focus(focusTarget ?? this.chat); this.redraw(); },
|
|
4786
|
+
});
|
|
4787
|
+
this.overlay = popup; this.focus(popup); this.redraw();
|
|
4788
|
+
}
|
|
4789
|
+
|
|
4790
|
+
/** External editor for the input draft (Claude-Code Ctrl+G parity, prefix e):
|
|
4791
|
+
* $VISUAL/$EDITOR edits the current draft in a temp file; a clean exit
|
|
4792
|
+
* refills the input, a non-zero exit (:cq) keeps the original draft,
|
|
4793
|
+
* and clearing the file abandons the edit. */
|
|
4794
|
+
editExternal() {
|
|
4795
|
+
const input = this.chat.input;
|
|
4796
|
+
const editor = process.env.VISUAL || process.env.EDITOR || "";
|
|
4797
|
+
if (!editor) { this.toast("未设置 $VISUAL/$EDITOR,无法打开外部编辑器"); return; }
|
|
4798
|
+
const tmp = join(tmpdir(), `dsh-tui-draft-${process.pid}-${Date.now()}.txt`);
|
|
4799
|
+
try { writeFileSync(tmp, input.value, "utf8"); } catch (e) { this.toast(`草稿写入失败: ${e.message}`); return; }
|
|
4800
|
+
const runner = this.externalEditRunner ?? ((file) => {
|
|
4801
|
+
const res = spawnSync(editor, [file], { stdio: "inherit" });
|
|
4802
|
+
return res.status;
|
|
4803
|
+
});
|
|
4804
|
+
let status = 0;
|
|
4805
|
+
try { status = Number(runner(tmp) ?? 0); } catch (e) { status = 1; this.toast(`编辑器失败: ${e.message}`); }
|
|
4806
|
+
let content = null;
|
|
4807
|
+
try { content = readFileSync(tmp, "utf8"); } catch {}
|
|
4808
|
+
try { unlinkSync(tmp); } catch {}
|
|
4809
|
+
if (status !== 0) { this.toast("编辑器非零退出(:cq);保留原稿"); return; }
|
|
4810
|
+
if (content == null || content.trim() === "") { this.toast("内容为空:放弃编辑"); return; }
|
|
4811
|
+
const old = input.value;
|
|
4812
|
+
input.setValue(content.replace(/\n$/, ""));
|
|
4813
|
+
if (input.value === old) this.toast("内容未变化");
|
|
4814
|
+
else { this.focus(input); this.toast("已从外部编辑器回填"); }
|
|
4815
|
+
this.redraw();
|
|
4816
|
+
}
|
|
4817
|
+
|
|
4818
|
+
/** Input-history search: filterable picker over the last ~50 typed prompts
|
|
4819
|
+
* (persisted), Enter refills the input. Reached from the prefix page (h). */
|
|
4820
|
+
showHistorySearch() {
|
|
4821
|
+
const merged = [];
|
|
4822
|
+
const seen = new Set();
|
|
4823
|
+
for (const t of [...this.chat.input.history, ...promptHistory()]) {
|
|
4824
|
+
const s = String(t ?? "").trim();
|
|
4825
|
+
if (!s || seen.has(s)) continue;
|
|
4826
|
+
seen.add(s); merged.push(s);
|
|
4827
|
+
}
|
|
4828
|
+
if (!merged.length) { this.toast("还没有输入历史"); return; }
|
|
4829
|
+
const items = merged.slice(0, 50).map((t) => ({ label: truncate(t.replace(/\s+/g, " "), 56), hint: "回车填入输入框", text: t }));
|
|
4830
|
+
const w = Math.max(1, Math.min(76, this.screen.w - 4)), ph = Math.max(1, Math.min(14, this.screen.h - 4));
|
|
4831
|
+
this.overlay = new Picker({
|
|
4832
|
+
x: Math.floor((this.screen.w - w) / 2), y: Math.floor((this.screen.h - ph) / 2),
|
|
4833
|
+
w, h: ph, title: "输入历史(输入过滤 / ↑↓ 选择 / Esc 取消)",
|
|
4834
|
+
items,
|
|
4835
|
+
onPick: (it) => {
|
|
4836
|
+
this.closeOverlay();
|
|
4837
|
+
const input = this.chat.input;
|
|
4838
|
+
input.setValue(it.text);
|
|
4839
|
+
this.focus(input);
|
|
4840
|
+
this.redraw();
|
|
4841
|
+
},
|
|
4842
|
+
onCancel: () => this.closeOverlay(),
|
|
4843
|
+
});
|
|
4844
|
+
this.redraw();
|
|
4845
|
+
}
|
|
4846
|
+
|
|
4847
|
+
/** Rewind: pick a previous user message, fork the session before that turn,
|
|
4848
|
+
* reopen the branch and put the original message back into the input.
|
|
4849
|
+
* Reached from /rewind (deliberately not a double-Esc global). */
|
|
4850
|
+
showRewindPicker() {
|
|
4851
|
+
const nodes = this.chat.nodes ?? [];
|
|
4852
|
+
const messages = [];
|
|
4853
|
+
for (const node of nodes) {
|
|
4854
|
+
if (node?.kind !== "user" || node.firstSeq == null) continue;
|
|
4855
|
+
const text = (node.blocks ?? []).filter((b) => b?.type === "text").map((b) => b.text ?? "").join("").trim();
|
|
4856
|
+
if (!text) continue;
|
|
4857
|
+
messages.push({ seq: node.firstSeq, text });
|
|
4858
|
+
}
|
|
4859
|
+
if (!messages.length) { this.toast("当前会话没有可回退的用户提问"); return; }
|
|
4860
|
+
const items = [...messages].reverse().map((m, i) => ({
|
|
4861
|
+
label: `${String(messages.length - i).padStart(2, " ")} ${truncate(m.text.replace(/\s+/g, " "), 40)}`,
|
|
4862
|
+
hint: `第 ${m.seq} 步`,
|
|
4863
|
+
seq: m.seq, text: m.text,
|
|
4864
|
+
}));
|
|
4865
|
+
const w = Math.max(1, Math.min(76, this.screen.w - 4)), ph = Math.max(1, Math.min(14, this.screen.h - 4));
|
|
4866
|
+
this.overlay = new Picker({
|
|
4867
|
+
x: Math.floor((this.screen.w - w) / 2), y: Math.floor((this.screen.h - ph) / 2),
|
|
4868
|
+
w, h: ph, title: "回退到…(Esc 取消)",
|
|
4869
|
+
items,
|
|
4870
|
+
onPick: (it) => this.#confirmRewind(it),
|
|
4871
|
+
onCancel: () => this.closeOverlay(),
|
|
4872
|
+
});
|
|
4873
|
+
this.redraw();
|
|
4874
|
+
}
|
|
4875
|
+
|
|
4876
|
+
#confirmRewind(it) {
|
|
4877
|
+
const title = "确认真回溯?";
|
|
4878
|
+
const lines = [`将回退到这条提问所属回合开始之前,并创建分支会话:`, "", ` ${truncate(it.text, Math.min(56, this.screen.w - 10))}`, "", "原消息会放回输入框供修改重发(原会话不变)。"];
|
|
4879
|
+
const popup = new Popup({
|
|
4880
|
+
x: Math.max(0, Math.floor((this.screen.w - Math.min(72, this.screen.w - 4)) / 2)),
|
|
4881
|
+
y: Math.max(0, Math.floor(this.screen.h / 2) - 4), w: Math.min(72, this.screen.w - 4), h: lines.length + 3,
|
|
4882
|
+
title, lines, buttons: [{ label: "是 (y)", action: "yes" }, { label: "否 (n)", action: "no" }],
|
|
4883
|
+
onAction: (b) => {
|
|
4884
|
+
this.closeOverlay();
|
|
4885
|
+
if (b?.action === "yes") void this.#executeRewind(it);
|
|
4886
|
+
else this.redraw();
|
|
4887
|
+
},
|
|
4888
|
+
});
|
|
4889
|
+
this.overlay = popup; this.focus(popup); this.redraw();
|
|
4890
|
+
}
|
|
4891
|
+
|
|
4892
|
+
async #executeRewind(it) {
|
|
4893
|
+
const s = this.sessions.find((x) => x.sessionId === this.currentSession);
|
|
4894
|
+
if (!s) return;
|
|
4895
|
+
if (it.seq <= 1) { this.toast("第一条消息不能作为回退边界"); return; }
|
|
4896
|
+
if (this.chat.running || s.running) { this.cancelSession({ sessionId: s.sessionId }); this.toast("已请求中断当前回合…"); await new Promise((r) => setTimeout(r, 600)); }
|
|
4897
|
+
try {
|
|
4898
|
+
const { sessionId } = await this.api.call("session.fork", { sessionId: s.sessionId, atSeq: it.seq - 1 });
|
|
4899
|
+
await this.refreshSessions();
|
|
4900
|
+
this.openSession(sessionId);
|
|
4901
|
+
const input = this.chat.input;
|
|
4902
|
+
if (!input.value) { input.setValue(it.text); this.focus(input); }
|
|
4903
|
+
this.toast(`已回退并分支: ${sessionId.slice(0, 8)};原消息已回到输入框`);
|
|
4904
|
+
} catch (e) { this.toast(`回退失败: ${e.message}`); }
|
|
4905
|
+
}
|
|
4906
|
+
|
|
4218
4907
|
async forkSession(s, atSeq = null) {
|
|
4219
4908
|
try {
|
|
4220
4909
|
const { sessionId } = await this.api.call("session.fork", { sessionId: s.sessionId, ...(atSeq != null ? { atSeq } : {}) });
|
|
@@ -4391,7 +5080,7 @@ export class App {
|
|
|
4391
5080
|
this.chat.input?.setValue(this.draftsBySession.get(sessionId) ?? "");
|
|
4392
5081
|
// A sidebar Enter intentionally preserves sidebar focus, but every
|
|
4393
5082
|
// session-scoped panel must immediately follow the newly opened session.
|
|
4394
|
-
if (this.
|
|
5083
|
+
if (this.mainTab === "trajectory" && this.trajectoryPanel) await this.trajectoryPanel.load(sessionId);
|
|
4395
5084
|
else if (this.fullBuffer && this.fullBuffer === this.subagentPanel) this.subagentPanel.load(sessionId);
|
|
4396
5085
|
else if (this.fullBuffer && this.fullBuffer === this.skillsPanel) this.skillsPanel.load?.(sessionId);
|
|
4397
5086
|
if (epoch !== this.sessionEpoch || sessionId !== this.currentSession) return;
|
|
@@ -4433,20 +5122,17 @@ export class App {
|
|
|
4433
5122
|
}
|
|
4434
5123
|
|
|
4435
5124
|
setMode(mode) {
|
|
4436
|
-
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
this.
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
|
|
4444
|
-
else if (this.focused !== this.sidebar) this.focus(this.chat);
|
|
4445
|
-
this.layout();
|
|
4446
|
-
this.redraw();
|
|
5125
|
+
// Trajectory requires an open session (tab-click / chat-toggle path); the
|
|
5126
|
+
// tab cycle (cycleTab) reaches trajectory regardless.
|
|
5127
|
+
if (mode === "trajectory" && !this.currentSession) {
|
|
5128
|
+
this.toast("先打开一个会话");
|
|
5129
|
+
this.setTab("chat");
|
|
5130
|
+
return;
|
|
5131
|
+
}
|
|
5132
|
+
this.setTab(mode);
|
|
4447
5133
|
}
|
|
4448
5134
|
|
|
4449
|
-
panelForMode() { return this.
|
|
5135
|
+
panelForMode() { return this.paneWidget(); }
|
|
4450
5136
|
|
|
4451
5137
|
/** Full-screen modal buffers replace the old tab-page modes: they coexist
|
|
4452
5138
|
* with the sidebar/chat/trajectory pane focus instead of fighting it. */
|
|
@@ -4465,6 +5151,56 @@ export class App {
|
|
|
4465
5151
|
this.redraw();
|
|
4466
5152
|
return true;
|
|
4467
5153
|
}
|
|
5154
|
+
/** Ctrl+W: small workspace picker — choose the current session's workspace
|
|
5155
|
+
* (move), rename it, or add a new one from a path. */
|
|
5156
|
+
showWorkspacePicker() {
|
|
5157
|
+
const items = (this.workspaceItems ?? []).map((w) => ({
|
|
5158
|
+
label: truncate(w.title ?? w.path ?? w.workspaceId, 40),
|
|
5159
|
+
hint: w.path ?? "",
|
|
5160
|
+
workspace: w,
|
|
5161
|
+
}));
|
|
5162
|
+
items.push({ label: "✎ 重命名当前工作区…", hint: "选中后重命名所选工作区", rename: true });
|
|
5163
|
+
items.push({ label: "+ 新建工作区…", hint: "输入路径", create: true });
|
|
5164
|
+
const w = Math.max(1, Math.min(72, this.screen.w - 4)), ph = Math.max(1, Math.min(14, this.screen.h - 4));
|
|
5165
|
+
this.overlay = new Picker({
|
|
5166
|
+
x: Math.floor((this.screen.w - w) / 2), y: Math.floor((this.screen.h - ph) / 2),
|
|
5167
|
+
w, h: ph, title: "工作区 · Enter 选择/执行 · Esc 关闭",
|
|
5168
|
+
items,
|
|
5169
|
+
onPick: (it) => {
|
|
5170
|
+
this.closeOverlay();
|
|
5171
|
+
if (it.create) { this.addWorkspace(); return; }
|
|
5172
|
+
if (it.rename) { const ws = this.#workspacePicker?.items?.[this.#workspacePicker.sel]?.workspace; this.#beginWorkspaceRename(ws); return; }
|
|
5173
|
+
if (it.workspace) { void this.#moveCurrentToWorkspace(it.workspace); }
|
|
5174
|
+
},
|
|
5175
|
+
onCancel: () => this.closeOverlay(),
|
|
5176
|
+
});
|
|
5177
|
+
// remember selection index for the rename row
|
|
5178
|
+
this.#workspacePicker = this.overlay;
|
|
5179
|
+
this.focus(this.overlay);
|
|
5180
|
+
this.redraw();
|
|
5181
|
+
}
|
|
5182
|
+
/** Move the current session into a workspace (append at its end). */
|
|
5183
|
+
async #moveCurrentToWorkspace(ws) {
|
|
5184
|
+
if (!this.currentSession) { this.toast("先打开一个会话"); return; }
|
|
5185
|
+
if ((ws.sessionIds ?? []).includes(this.currentSession)) { this.toast("当前会话已在该工作区"); return; }
|
|
5186
|
+
try {
|
|
5187
|
+
await this.api.call("workspace.insertSessionBefore", { workspaceId: ws.workspaceId, sessionId: this.currentSession });
|
|
5188
|
+
await this.refreshSessions();
|
|
5189
|
+
this.toast(`已移动到工作区: ${truncate(ws.title ?? ws.path ?? ws.workspaceId, 20)}`);
|
|
5190
|
+
} catch (e) { this.toast(`移动失败: ${e.message}`); }
|
|
5191
|
+
}
|
|
5192
|
+
/** Begin the inline rename of a workspace (reuses the existing rename UI). */
|
|
5193
|
+
#beginWorkspaceRename(ws) {
|
|
5194
|
+
const group = this.sidebar.groups.find((g) => g.workspaceId === ws?.workspaceId) ?? null;
|
|
5195
|
+
if (!group) { this.toast("未找到该工作区"); return; }
|
|
5196
|
+
// the existing rename flow is driven by the sidebar group; reuse its input
|
|
5197
|
+
const input = new Input({ x: this.sidebar.x + 2, y: 1, w: this.sidebar.w - 4, h: 1, prompt: "新标题: ", allowEmptyEnter: true, onEnter: () => this.#commitWorkspaceRename(group, input), onKey: (ev) => { if (ev?.name === "escape") { this.#closeRename(); return true; } return Input.prototype?.onKey ? false : false; } });
|
|
5198
|
+
input.value = group.title ?? "";
|
|
5199
|
+
input.cursor = graphemes(input.value).length;
|
|
5200
|
+
this.renameInput = input;
|
|
5201
|
+
this.redraw();
|
|
5202
|
+
}
|
|
5203
|
+
|
|
4468
5204
|
showWorkspaceBuffer() { if (!this.workspacePanel) this.workspacePanel = new WorkspacePanel(this); this.openFullBuffer(this.workspacePanel); this.workspacePanel.load(); }
|
|
4469
5205
|
/** Open a local file path in the workspace panel's preview pane. */
|
|
4470
5206
|
openWorkspaceFile(path) {
|
|
@@ -4476,11 +5212,7 @@ export class App {
|
|
|
4476
5212
|
}
|
|
4477
5213
|
showSettingsBuffer() { if (!this.settingsPanel) this.settingsPanel = new SettingsPanel(this); this.openFullBuffer(this.settingsPanel); this.settingsPanel.load(); }
|
|
4478
5214
|
showModelsBuffer() { if (!this.modelPanel) this.modelPanel = new ModelPanel(this); this.openFullBuffer(this.modelPanel); this.modelPanel.load(); }
|
|
4479
|
-
showSubagentBuffer() {
|
|
4480
|
-
if (!this.currentSession) { this.toast("先打开一个会话"); return; }
|
|
4481
|
-
if (!this.subagentPanel) this.subagentPanel = new SubagentPanel(this);
|
|
4482
|
-
this.openFullBuffer(this.subagentPanel); this.subagentPanel.load(this.currentSession);
|
|
4483
|
-
}
|
|
5215
|
+
showSubagentBuffer() { this.setTab("subagent"); }
|
|
4484
5216
|
showSkillsBuffer() {
|
|
4485
5217
|
if (!this.currentSession) { this.toast("先打开一个会话"); return; }
|
|
4486
5218
|
if (!this.skillsPanel) this.skillsPanel = new SkillsPanel(this);
|
|
@@ -4512,7 +5244,7 @@ export class App {
|
|
|
4512
5244
|
else this.toast("先打开一个会话");
|
|
4513
5245
|
}
|
|
4514
5246
|
|
|
4515
|
-
showJobs() { this.
|
|
5247
|
+
showJobs() { this.setTab("tasks"); }
|
|
4516
5248
|
async refreshSubagentStats(sessionId = this.currentSession) {
|
|
4517
5249
|
if (!sessionId) return;
|
|
4518
5250
|
try {
|
|
@@ -4523,8 +5255,8 @@ export class App {
|
|
|
4523
5255
|
if (sessionId === this.currentSession) this.redraw();
|
|
4524
5256
|
} catch {}
|
|
4525
5257
|
}
|
|
4526
|
-
showQueue() { this.
|
|
4527
|
-
showGoal() { this.overlay =
|
|
5258
|
+
showQueue() { this.setTab("tasks"); }
|
|
5259
|
+
showGoal() { this.overlay = new GoalPanel(this); this.focus(this.overlay); this.redraw(); }
|
|
4528
5260
|
showModePicker() { this.overlay = buildModePicker(this); this.redraw(); }
|
|
4529
5261
|
showThemePicker() { this.overlay = new ThemePickerBuffer(this); this.redraw(); }
|
|
4530
5262
|
|
|
@@ -4640,17 +5372,20 @@ export class App {
|
|
|
4640
5372
|
this.popup = null;
|
|
4641
5373
|
this.activePrompt = null;
|
|
4642
5374
|
this.promptQueue = [];
|
|
4643
|
-
for (const p of [this.workspacePanel, this.trajectoryPanel, this.settingsPanel, this.modelPanel, this.subagentPanel, this.skillsPanel]) {
|
|
5375
|
+
for (const p of [this.workspacePanel, this.trajectoryPanel, this.settingsPanel, this.modelPanel, this.subagentPanel, this.skillsPanel, this.subagentPane, this.tasksPane]) {
|
|
4644
5376
|
if (p?.dispose) { try { p.dispose(); } catch {} }
|
|
4645
5377
|
}
|
|
4646
5378
|
this.workspacePanel = this.trajectoryPanel = this.settingsPanel = this.modelPanel = this.subagentPanel = this.skillsPanel = null;
|
|
5379
|
+
this.subagentPane = this.tasksPane = null;
|
|
4647
5380
|
this.chat.cache.clear();
|
|
4648
5381
|
this.chat.nodes = [];
|
|
4649
5382
|
this.chat.collapsedBlocks.clear();
|
|
4650
5383
|
this.chat.expanded.clear();
|
|
4651
5384
|
this.chat.queueRebuild();
|
|
4652
5385
|
this.chat.view.anchorLock = null;
|
|
5386
|
+
this.mainTab = "chat";
|
|
4653
5387
|
this.mode = "chat";
|
|
5388
|
+
this.focusedWindow = "main";
|
|
4654
5389
|
this.focus(this.chat);
|
|
4655
5390
|
this.toast("正在重新加载…");
|
|
4656
5391
|
await this.refreshSessions();
|
|
@@ -4809,21 +5544,28 @@ export class App {
|
|
|
4809
5544
|
#runBinding(id, slot) {
|
|
4810
5545
|
switch (id) {
|
|
4811
5546
|
case "sessionFilter": this.startSearch(); this.redraw(); return true;
|
|
4812
|
-
case "panel":
|
|
4813
|
-
|
|
4814
|
-
|
|
5547
|
+
case "panel":
|
|
5548
|
+
this.focusBeforePanel = this.focused; // prefix ? 帮助回到此场景
|
|
5549
|
+
this.overlay = new ControlPanel(this, { startPage: 0 });
|
|
5550
|
+
this.redraw();
|
|
5551
|
+
return true;
|
|
5552
|
+
case "help": this.showHelp(); return true;
|
|
5553
|
+
case "panePrev": this.focusWindow(-1); return true;
|
|
5554
|
+
case "paneNext": this.focusWindow(1); return true;
|
|
5555
|
+
case "tabNext": this.#tabCycle(1); return true;
|
|
5556
|
+
case "tabPrev": this.#tabCycle(-1); return true;
|
|
4815
5557
|
case "permissionRotate": this.rotatePermission(); return true;
|
|
4816
5558
|
case "editConfig": this.editConfigFile(); return true;
|
|
4817
5559
|
case "quit": this.stop(); return true;
|
|
4818
5560
|
case "model": this.overlay = buildModelPicker(this); this.redraw(); return true;
|
|
4819
|
-
case "trajectory": this.
|
|
4820
|
-
case "workspace": this.
|
|
5561
|
+
case "trajectory": this.setTab("trajectory"); return true;
|
|
5562
|
+
case "workspace": this.showWorkspacePicker(); return true;
|
|
4821
5563
|
case "settings": this.showSettingsBuffer(); return true;
|
|
4822
|
-
case "subagent": this.
|
|
5564
|
+
case "subagent": this.setTab("subagent"); return true;
|
|
4823
5565
|
case "skills": this.showSkillsBuffer(); return true;
|
|
4824
5566
|
case "goal": this.showGoal(); return true;
|
|
4825
|
-
case "jobs": this.
|
|
4826
|
-
case "queue": this.
|
|
5567
|
+
case "jobs": this.setTab("tasks"); return true;
|
|
5568
|
+
case "queue": this.setTab("tasks"); return true;
|
|
4827
5569
|
case "busyEnter": {
|
|
4828
5570
|
const next = busyEnter() === "queue" ? "steer" : "queue";
|
|
4829
5571
|
saveTuiConfig({ busyEnter: next });
|
|
@@ -4834,7 +5576,7 @@ export class App {
|
|
|
4834
5576
|
case "stepJump": this.quickJumpStep(); return true;
|
|
4835
5577
|
case "sidebar": this.toggleSidebar(); return true;
|
|
4836
5578
|
case "addWorkspace": this.addWorkspace(); return true;
|
|
4837
|
-
case "commandPalette": this.overlay = new ControlPanel(this, { startPage:
|
|
5579
|
+
case "commandPalette": this.overlay = new ControlPanel(this, { startPage: 2 }); this.redraw(); return true;
|
|
4838
5580
|
case "modePicker": this.showModePicker(); return true;
|
|
4839
5581
|
case "themePicker": this.showThemePicker(); return true;
|
|
4840
5582
|
case "logoPicker": this.showLogoPicker(); return true;
|
|
@@ -4901,27 +5643,84 @@ export class App {
|
|
|
4901
5643
|
}
|
|
4902
5644
|
|
|
4903
5645
|
#modeTabs() {
|
|
4904
|
-
//
|
|
5646
|
+
// Main-window tab strip: 对话 | 轨迹 | 子代理 | 后台任务 (Shift+Tab cycles).
|
|
4905
5647
|
return [
|
|
4906
5648
|
["chat", "对话"],
|
|
4907
5649
|
["trajectory", "轨迹"],
|
|
5650
|
+
["subagent", "子代理"],
|
|
5651
|
+
["tasks", "后台任务"],
|
|
4908
5652
|
];
|
|
4909
5653
|
}
|
|
4910
5654
|
|
|
5655
|
+
/** Draw the split-tree chrome: the focused main leaf keeps its real content
|
|
5656
|
+
* (rendered above); every OTHER leaf gets a faint placeholder cell with its
|
|
5657
|
+
* window id + short session, and the separators get border chars. */
|
|
5658
|
+
#renderSplitOverlay(s) {
|
|
5659
|
+
const map = this.#layoutLeaves();
|
|
5660
|
+
const leaves = this.#leaves();
|
|
5661
|
+
const focused = this.focusedLeaf ?? (this.focusedWindow === "list" ? this.#leafList() : this.#mainLeaves()[0]);
|
|
5662
|
+
// separators: for each split node, draw the border line between a and b
|
|
5663
|
+
const drawSep = (node, rect) => {
|
|
5664
|
+
if (node.type === "leaf") return;
|
|
5665
|
+
const border = 1;
|
|
5666
|
+
if (node.dir === "right" || node.dir === "left") {
|
|
5667
|
+
const total = Math.max(2, rect.w - border);
|
|
5668
|
+
const aW = Math.max(1, Math.floor(total / 2));
|
|
5669
|
+
const x = rect.x + aW;
|
|
5670
|
+
for (let y = Math.max(0, rect.y); y < rect.y + rect.h; y++) s.put(x, y, "│", { fg: T.BORDER });
|
|
5671
|
+
} else {
|
|
5672
|
+
const total = Math.max(2, rect.h - border);
|
|
5673
|
+
const aH = Math.max(1, Math.floor(total / 2));
|
|
5674
|
+
const y = rect.y + aH;
|
|
5675
|
+
for (let x = Math.max(0, rect.x); x < rect.x + rect.w; x++) s.put(x, y, "─", { fg: T.BORDER });
|
|
5676
|
+
}
|
|
5677
|
+
drawSep(node.a, { ...rect, ...(node.dir === "right" || node.dir === "left" ? { w: Math.max(1, Math.floor(total / 2)) } : { h: Math.max(1, Math.floor(total / 2)) }) });
|
|
5678
|
+
drawSep(node.b, { ...rect, ...(node.dir === "right" || node.dir === "left" ? { x: rect.x + (node.dir === "right" ? Math.max(1, Math.floor(total / 2)) + border : 0), w: Math.max(1, total - Math.floor(total / 2)) } : { y: rect.y + (node.dir === "down" ? Math.max(1, Math.floor(total / 2)) + border : 0), h: Math.max(1, total - Math.floor(total / 2)) }) });
|
|
5679
|
+
};
|
|
5680
|
+
const rootRect = { x: this.sidebarVisible ? this.sidebarWidth : 0, y: 1, w: this.mainAreaRect().w, h: this.mainAreaRect().h };
|
|
5681
|
+
if (leaves.length > 2) drawSep(this.winTree, rootRect);
|
|
5682
|
+
// placeholders for non-focused leaves
|
|
5683
|
+
let n = 1;
|
|
5684
|
+
for (const leaf of leaves) {
|
|
5685
|
+
if (leaf.kind === "main") {
|
|
5686
|
+
const rect = map.get(leaf);
|
|
5687
|
+
if (leaf === focused || !rect || rect.w < 8 || rect.h < 3) continue;
|
|
5688
|
+
const label = `W${n} · ${leaf.sessionId ? leaf.sessionId.slice(0, 8) : "空白会话"} · Shift+Tab/Ctrl+←→ 切换`;
|
|
5689
|
+
s.fillRect(rect.x + 1, rect.y, rect.x + rect.w - 1, rect.y + rect.h - 1, " ", { bg: T.BG });
|
|
5690
|
+
s.text(rect.x + 2, rect.y + 1, truncate(label, Math.max(0, rect.w - 4)), { fg: T.FAINT, bg: T.BG });
|
|
5691
|
+
}
|
|
5692
|
+
n++;
|
|
5693
|
+
}
|
|
5694
|
+
// the focused leaf also gets a small W# marker at its top-left
|
|
5695
|
+
const frect = map.get(focused);
|
|
5696
|
+
if (frect && focused?.kind === "main") {
|
|
5697
|
+
const idx = leaves.indexOf(focused) + 1;
|
|
5698
|
+
s.text(frect.x + 1, frect.y, `W${idx}`, { fg: T.DIM, bg: T.PANEL });
|
|
5699
|
+
}
|
|
5700
|
+
}
|
|
5701
|
+
|
|
4911
5702
|
#renderTabBar(s) {
|
|
4912
5703
|
const x = this.sidebarVisible ? this.sidebarWidth : 0;
|
|
4913
5704
|
const w = this.screen.w - x;
|
|
4914
5705
|
s.fillRect(x, 0, x + w - 1, 0, " ", { bg: T.PANEL });
|
|
4915
5706
|
const tabs = [...this.#modeTabs()];
|
|
4916
5707
|
let tx = x;
|
|
4917
|
-
const sidebarFocused = this.focused === this.sidebar;
|
|
4918
5708
|
for (const [id, label] of tabs) {
|
|
4919
|
-
const sel =
|
|
5709
|
+
const sel = id === this.mainTab;
|
|
4920
5710
|
const seg = ` ${label} `;
|
|
4921
|
-
|
|
5711
|
+
// The active tab stays highlighted even while the list window is focused;
|
|
5712
|
+
// the strip simply indicates which tab would be active in the main window.
|
|
5713
|
+
const fg = sel ? T.SELFG : T.DIM;
|
|
5714
|
+
const bg = sel ? T.ACCENT : T.PANEL;
|
|
5715
|
+
s.text(tx, 0, seg, { fg, bg, attrs: sel ? 1 : 0 });
|
|
4922
5716
|
tx += strWidth(seg);
|
|
4923
5717
|
}
|
|
4924
|
-
|
|
5718
|
+
// Flow the key hint right after the tabs: it never overlaps the sidebar,
|
|
5719
|
+
// and clips at the right edge on narrow mains (Screen.text truncates).
|
|
5720
|
+
const hint = " Shift+Tab 切标签 · Tab/Ctrl+←→ 切窗口 ";
|
|
5721
|
+
s.text(tx, 0, truncate(hint, Math.max(0, x + w - tx)), { fg: T.FAINT, bg: T.PANEL });
|
|
5722
|
+
tx += strWidth(hint);
|
|
5723
|
+
if (this.currentSession == null) s.text(tx + 1, 0, "未选会话", { fg: T.FAINT, bg: T.PANEL });
|
|
4925
5724
|
}
|
|
4926
5725
|
|
|
4927
5726
|
#clickTab(px) {
|
|
@@ -4939,6 +5738,115 @@ export class App {
|
|
|
4939
5738
|
return false;
|
|
4940
5739
|
}
|
|
4941
5740
|
|
|
5741
|
+
/** Whether an editor (INSERT) surface owns the keyboard: the chat input or a
|
|
5742
|
+
* pane's own message input (subagent/tasks inputMode). */
|
|
5743
|
+
#inInsertMode() {
|
|
5744
|
+
if (this.focused === this.chat?.input) return true;
|
|
5745
|
+
return this.focused?.inputMode === true;
|
|
5746
|
+
}
|
|
5747
|
+
|
|
5748
|
+
/** Structural window/tab keys (NORMAL mode only; see dispatch). Returns true
|
|
5749
|
+
* when handled so the key never reaches a binding or the focused widget.
|
|
5750
|
+
* Ctrl+Left/Right (panePrev/paneNext) are editable bindings and route through
|
|
5751
|
+
* the binding matcher to focusWindow; here we only own Tab/Backtab. */
|
|
5752
|
+
#handleStructuralKey(ev) {
|
|
5753
|
+
if (ev.type !== "key") return false;
|
|
5754
|
+
if (this.#inInsertMode()) return false; // INSERT keeps editor semantics
|
|
5755
|
+
if (ev.name === "tab" && !ev.ctrl && !ev.alt) {
|
|
5756
|
+
// Toggle focused window: list ↔ main (fast toggle, same as the cycle).
|
|
5757
|
+
if (this.sidebarVisible) this.focusWindow(1);
|
|
5758
|
+
else this.setFocusedWindow("main");
|
|
5759
|
+
return true;
|
|
5760
|
+
}
|
|
5761
|
+
if (ev.name === "backtab" && !ev.ctrl && !ev.alt) {
|
|
5762
|
+
// Cycle the main-window tabs. From the list window, focus main first.
|
|
5763
|
+
if (this.focusedWindow !== "main") this.setFocusedWindow("main");
|
|
5764
|
+
this.cycleTab(1);
|
|
5765
|
+
return true;
|
|
5766
|
+
}
|
|
5767
|
+
return false;
|
|
5768
|
+
}
|
|
5769
|
+
|
|
5770
|
+
/** Tab cycling shared by Shift+Tab (structural) and Ctrl+H/L bindings:
|
|
5771
|
+
* from the list window, focus the main window first. */
|
|
5772
|
+
#tabCycle(delta) {
|
|
5773
|
+
if (this.focusedWindow !== "main") this.setFocusedWindow("main");
|
|
5774
|
+
this.cycleTab(delta);
|
|
5775
|
+
return true;
|
|
5776
|
+
}
|
|
5777
|
+
|
|
5778
|
+
// ---- chained prefix layers (nvim-style pending keys) ----
|
|
5779
|
+
|
|
5780
|
+
/** Generic pending-layer tables. The p table is the first user; future
|
|
5781
|
+
* g/z tables register here too. Entries: (ev) => run. */
|
|
5782
|
+
#prefixTables() {
|
|
5783
|
+
const k = prefixKeys();
|
|
5784
|
+
return {
|
|
5785
|
+
// 主表:Ctrl+Space 面板首屏与 NORMAL 前缀层共用(同一张表,同一语义)
|
|
5786
|
+
main: {
|
|
5787
|
+
hint: `${k.search}=搜索 ${k.model}=模型 ${k.theme}=主题 ${k.history}=历史 ${k.rewind}=回退 ${k.config}=配置 ${k.help}=帮助 ${k.editor}=编辑 ${k.permission}=权限 ${k.panes}=窗格 · Esc 取消`,
|
|
5788
|
+
entries: {
|
|
5789
|
+
[k.search]: () => { this.closeOverlay(); this.startSearch(); this.redraw(); },
|
|
5790
|
+
[k.model]: () => { this.closeOverlay(); this.overlay = buildModelPicker(this); this.redraw(); },
|
|
5791
|
+
[k.theme]: () => { this.closeOverlay(); this.showThemePicker(); },
|
|
5792
|
+
[k.history]: () => { this.closeOverlay(); this.showHistorySearch(); },
|
|
5793
|
+
[k.rewind]: () => { this.closeOverlay(); this.showRewindPicker(); },
|
|
5794
|
+
[k.config]: () => { this.closeOverlay(); this.editConfigFile(); },
|
|
5795
|
+
[k.help]: () => { this.closeOverlay(); this.showHelp(this.focusBeforePanel ?? null); },
|
|
5796
|
+
[k.editor]: () => { this.closeOverlay(); this.editExternal(); },
|
|
5797
|
+
[k.permission]: () => { this.closeOverlay(); this.showPermissionPicker(); },
|
|
5798
|
+
[k.panes]: () => { this.closeOverlay(); this.armPrefix("p"); },
|
|
5799
|
+
},
|
|
5800
|
+
},
|
|
5801
|
+
p: {
|
|
5802
|
+
hint: "p · r=右 l=左 u=上 n=下 c=关窗 · Esc/2s 取消",
|
|
5803
|
+
entries: {
|
|
5804
|
+
r: () => this.splitWindow("right"),
|
|
5805
|
+
l: () => this.splitWindow("left"),
|
|
5806
|
+
u: () => this.splitWindow("up"),
|
|
5807
|
+
n: () => this.splitWindow("down"),
|
|
5808
|
+
c: () => this.closeWindow(),
|
|
5809
|
+
},
|
|
5810
|
+
},
|
|
5811
|
+
};
|
|
5812
|
+
}
|
|
5813
|
+
/** Arm a pending layer programmatically (e.g. the panel chaining into p). */
|
|
5814
|
+
armPrefix(key) {
|
|
5815
|
+
const table = this.#prefixTables()[key];
|
|
5816
|
+
if (!table) return false;
|
|
5817
|
+
this.pendingPrefix = { table, key, timer: null };
|
|
5818
|
+
this.pendingPrefix.timer = setTimeout(() => {
|
|
5819
|
+
if (this.pendingPrefix?.key === key) this.#cancelPrefix();
|
|
5820
|
+
}, this.prefixTimeoutMs);
|
|
5821
|
+
this.redraw();
|
|
5822
|
+
return true;
|
|
5823
|
+
}
|
|
5824
|
+
#handlePrefixKey(ev) {
|
|
5825
|
+
if (ev.type !== "key") return false;
|
|
5826
|
+
const pending = this.pendingPrefix;
|
|
5827
|
+
if (pending) {
|
|
5828
|
+
if (ev.name === "escape") { this.#cancelPrefix(); return true; }
|
|
5829
|
+
const entry = pending.table.entries[ev.key];
|
|
5830
|
+
if (entry) { pending.table; this.#cancelPrefix(); entry(ev); return true; }
|
|
5831
|
+
this.#cancelPrefix("已取消 p 前缀");
|
|
5832
|
+
return true;
|
|
5833
|
+
}
|
|
5834
|
+
// Arm only from a main window, not in INSERT (list window: p = preview).
|
|
5835
|
+
if (ev.name === "char" && ev.key === "p" && !ev.ctrl && !ev.alt && !ev.shift
|
|
5836
|
+
&& this.focusedWindow === "main" && !this.#inInsertMode()) {
|
|
5837
|
+
this.armPrefix("p");
|
|
5838
|
+
return true;
|
|
5839
|
+
}
|
|
5840
|
+
return false;
|
|
5841
|
+
}
|
|
5842
|
+
#cancelPrefix(message = "") {
|
|
5843
|
+
if (!this.pendingPrefix) return;
|
|
5844
|
+
if (this.pendingPrefix.timer) clearTimeout(this.pendingPrefix.timer);
|
|
5845
|
+
this.pendingPrefix = null;
|
|
5846
|
+
if (message) this.toast(message);
|
|
5847
|
+
this.redraw();
|
|
5848
|
+
}
|
|
5849
|
+
|
|
4942
5850
|
// ---- dispatch ----
|
|
4943
5851
|
|
|
4944
5852
|
onEvent(ev) {
|
|
@@ -5045,27 +5953,54 @@ export class App {
|
|
|
5045
5953
|
return;
|
|
5046
5954
|
}
|
|
5047
5955
|
|
|
5956
|
+
if (this.#handlePrefixKey(ev)) return;
|
|
5048
5957
|
// tab bar clicks (row 0 of the main area)
|
|
5049
5958
|
if (ev.type === "mouse" && ev.kind === "press" && ev.button === 0 && ev.y === 0 && ev.x >= (this.sidebarVisible ? this.sidebarWidth : 0)) {
|
|
5050
5959
|
if (this.#clickTab(ev.x)) { this.redraw(); return; }
|
|
5051
5960
|
}
|
|
5052
5961
|
// mouse routes by position (click = focus + dispatch)
|
|
5053
|
-
if (this.
|
|
5054
|
-
|
|
5055
|
-
|
|
5056
|
-
|
|
5057
|
-
|
|
5058
|
-
|
|
5059
|
-
|
|
5060
|
-
|
|
5061
|
-
|
|
5962
|
+
if (this.mainTab !== "chat") {
|
|
5963
|
+
const panel = this.paneWidget();
|
|
5964
|
+
if (panel && this.focused === panel) {
|
|
5965
|
+
// The pane owns keyboard while it is focused. Intercept the pane-level
|
|
5966
|
+
// close (q / Esc) and insert (i) keys BEFORE delegating — the panes'
|
|
5967
|
+
// own close handlers would close an overlay; here they switch back to
|
|
5968
|
+
// the chat tab. Tab/Shift+Tab (window/tab layer) and Ctrl+Left/Right
|
|
5969
|
+
// (panePrev/paneNext) never leak into a pane: they fall through to the
|
|
5970
|
+
// global shortcuts below and are switched at the window/tab layer.
|
|
5971
|
+
if (ev.type === "key") {
|
|
5972
|
+
if (ev.name === "escape" || (ev.name === "char" && ev.key === "q" && !ev.ctrl)) {
|
|
5973
|
+
this.setTab("chat");
|
|
5974
|
+
this.redraw();
|
|
5975
|
+
return;
|
|
5976
|
+
}
|
|
5977
|
+
if (bindingMatchFor(ev, keyBindings(), false, ["insert"])?.id === "insert") {
|
|
5978
|
+
// In the 子代理 pane, i belongs to the pane (focus its message
|
|
5979
|
+
// input); elsewhere i returns to chat and focuses the input.
|
|
5980
|
+
if (this.mainTab !== "subagent") {
|
|
5981
|
+
this.setTab("chat");
|
|
5982
|
+
this.focus(this.chat.input);
|
|
5983
|
+
this.redraw();
|
|
5984
|
+
return;
|
|
5985
|
+
}
|
|
5986
|
+
}
|
|
5987
|
+
} else if (ev.type === "text" && graphemes(ev.text).length === 1) {
|
|
5988
|
+
// Legacy terminals deliver q/i as text; mirror the key intercepts.
|
|
5989
|
+
const t = graphemes(ev.text)[0].toLowerCase();
|
|
5990
|
+
if (t === "q") { this.setTab("chat"); this.redraw(); return; }
|
|
5991
|
+
if (t === "i") { this.setTab("chat"); this.focus(this.chat.input); this.redraw(); return; }
|
|
5992
|
+
}
|
|
5062
5993
|
const handled = ev.type === "key" || ev.type === "text" || ev.type === "paste" ? panel.onKey(ev) : panel.onMouse(ev);
|
|
5063
5994
|
if (handled) { this.redraw(); return; }
|
|
5064
|
-
// A visible
|
|
5065
|
-
// the event; never leak it into the hidden chat/Input behind
|
|
5995
|
+
// A visible panel owns non-global text/paste even when it declines
|
|
5996
|
+
// the event; never leak it into the hidden chat/Input behind it.
|
|
5066
5997
|
if (ev.type === "text" || ev.type === "paste") { this.redraw(); return; }
|
|
5998
|
+
// unhandled key events fall through to global shortcuts
|
|
5999
|
+
} else if (ev.type === "mouse" && this.sidebarVisible && this.sidebar.inside(ev.x, ev.y)) {
|
|
6000
|
+
if (this.focused !== this.chat.input) this.focus(this.sidebar); // INSERT exits only via Esc
|
|
6001
|
+
if (this.sidebar.onMouse(ev)) this.redraw();
|
|
6002
|
+
return;
|
|
5067
6003
|
}
|
|
5068
|
-
// unhandled key events fall through to global shortcuts
|
|
5069
6004
|
}
|
|
5070
6005
|
if (ev.type === "mouse") {
|
|
5071
6006
|
// input drag-selection: the gesture continues across motion events
|
|
@@ -5075,6 +6010,24 @@ export class App {
|
|
|
5075
6010
|
if (this.chat.input.onMouse(ev)) this.redraw();
|
|
5076
6011
|
return;
|
|
5077
6012
|
}
|
|
6013
|
+
// Split-window divider drag: press within ±1 of a split separator.
|
|
6014
|
+
if (this.winTree && ev.kind === "press" && ev.button === 0 && !this.draggingDivider) {
|
|
6015
|
+
const sep = this.#hitSplitSeparator(ev.x, ev.y);
|
|
6016
|
+
if (sep) { this.#splitDrag = sep; this.redraw(); return; }
|
|
6017
|
+
}
|
|
6018
|
+
if (this.#splitDrag) {
|
|
6019
|
+
const sd = this.#splitDrag;
|
|
6020
|
+
if (ev.kind === "drag" && ev.button === 0) {
|
|
6021
|
+
const { node, rect } = sd;
|
|
6022
|
+
const horizontal = node.dir === "right" || node.dir === "left";
|
|
6023
|
+
const span = horizontal ? Math.max(2, rect.w - 1) : Math.max(2, rect.h - 1);
|
|
6024
|
+
const pos = horizontal ? (ev.x - rect.x) : (ev.y - rect.y);
|
|
6025
|
+
node.ratio = Math.min(0.8, Math.max(0.2, pos / span));
|
|
6026
|
+
this.layout(); this.redraw();
|
|
6027
|
+
return;
|
|
6028
|
+
}
|
|
6029
|
+
if (ev.kind === "release" && ev.button === 0) { this.#splitDrag = null; this.redraw(); return; }
|
|
6030
|
+
}
|
|
5078
6031
|
// Draggable sidebar divider: press on the boundary column (±1) starts a
|
|
5079
6032
|
// resize; drag events (motion flag) update the width live.
|
|
5080
6033
|
const divX = this.sidebarVisible ? this.sidebarWidth : -1;
|
|
@@ -5164,6 +6117,13 @@ export class App {
|
|
|
5164
6117
|
this.redraw();
|
|
5165
6118
|
return;
|
|
5166
6119
|
}
|
|
6120
|
+
// Structural window/tab keys (NORMAL mode only — INSERT keeps editor
|
|
6121
|
+
// semantics: Tab still completes in the input, Ctrl+Left/Right are editor
|
|
6122
|
+
// motions). Tab toggles the focused window (list ↔ main); Backtab cycles
|
|
6123
|
+
// the main-window tabs (chat → trajectory → subagent → tasks). Ctrl+Left/
|
|
6124
|
+
// Right (panePrev/paneNext) are editable bindings matched below and also
|
|
6125
|
+
// switch the focused window.
|
|
6126
|
+
if (this.#handleStructuralKey(ev)) return;
|
|
5167
6127
|
// Editable global bindings: two slots per function, resolved by the
|
|
5168
6128
|
// keybindings registry (tui-config.json keyBindings.<id>).
|
|
5169
6129
|
const hit = bindingMatchFor(ev, keyBindings(), false, KEYBINDING_ORDER);
|
|
@@ -5188,8 +6148,8 @@ export class App {
|
|
|
5188
6148
|
// Esc in NORMAL mode interrupts a running turn (one press, regardless
|
|
5189
6149
|
// of focus); otherwise it steps back toward the chat view.
|
|
5190
6150
|
if (this.#interruptIfRunning()) return;
|
|
5191
|
-
if (this.focused === this.sidebar) { this.focus(this.chat); this.redraw(); }
|
|
5192
|
-
else if (this.
|
|
6151
|
+
if (this.focused === this.sidebar) { this.setFocusedWindow("main"); this.focus(this.chat); this.redraw(); }
|
|
6152
|
+
else if (this.mainTab !== "chat") this.setTab("chat");
|
|
5193
6153
|
return;
|
|
5194
6154
|
}
|
|
5195
6155
|
}
|
|
@@ -5970,6 +6930,7 @@ export class App {
|
|
|
5970
6930
|
if (this.popup) this.popup.render(s);
|
|
5971
6931
|
if (this.menu) this.menu.render(s);
|
|
5972
6932
|
if (this.overlay) this.overlay.render(s);
|
|
6933
|
+
if (this.sidebar.preview.open) this.#renderPreviewCard(s);
|
|
5973
6934
|
this.#renderToast(s);
|
|
5974
6935
|
this.term.output.write(s.render() + "\x1b[?25l");
|
|
5975
6936
|
return;
|
|
@@ -5982,8 +6943,14 @@ export class App {
|
|
|
5982
6943
|
for (let y = 1; y < s.h - 1; y++) s.put(this.sidebar.w - 1, y, "│", { fg: T.BORDER });
|
|
5983
6944
|
}
|
|
5984
6945
|
const modePanel = this.panelForMode();
|
|
5985
|
-
if (modePanel)
|
|
5986
|
-
|
|
6946
|
+
if (modePanel) {
|
|
6947
|
+
// The active pane occupies exactly the chat-area rect; ensure it is
|
|
6948
|
+
// relaid there (it may have diverged after the last resize).
|
|
6949
|
+
const r = this.mainAreaRect();
|
|
6950
|
+
if (modePanel.relayout) modePanel.relayout(r.x, r.y, r.w, r.h);
|
|
6951
|
+
modePanel.render(s);
|
|
6952
|
+
} else this.chat.render(s);
|
|
6953
|
+
if (this.winTree) this.#renderSplitOverlay(s);
|
|
5987
6954
|
|
|
5988
6955
|
// footer: multi-row powerline-style status
|
|
5989
6956
|
const t = this.titleOf();
|
|
@@ -6002,6 +6969,7 @@ export class App {
|
|
|
6002
6969
|
// Ctrl+Space (command panel) earns the prime spot right after the mode
|
|
6003
6970
|
// badge — the shortcut every session needs to discover first.
|
|
6004
6971
|
row0.left.push({ t: " Ctrl+Space 面板 ", fg: T.DIM, bg: T.STATUSBG });
|
|
6972
|
+
if (this.pendingPrefix) row0.left.push({ t: ` ${this.pendingPrefix.table.hint} `, fg: 0x000000, bg: T.WARN, bold: true });
|
|
6005
6973
|
// Left badge: the session's permission/mode (e.g. "工作区写入/创造模式"),
|
|
6006
6974
|
// which is far more meaningful than a static "工作区" label.
|
|
6007
6975
|
const perm = this.projections.permissions?.currentValue;
|
|
@@ -6013,8 +6981,10 @@ export class App {
|
|
|
6013
6981
|
if (rawGoal && !["complete", "completed", "cleared"].includes(rawGoal.phase)) {
|
|
6014
6982
|
row0.left.push({ t: ` 🎯 ${truncate(rawGoal.objective ?? "目标", 14)} · Ctrl+G `, fg: 0x000000, bg: T.WARN, bold: true });
|
|
6015
6983
|
}
|
|
6016
|
-
|
|
6017
|
-
|
|
6984
|
+
const winTag = this.winTree ? `W${(this.splitLeaves().indexOf(this.focusedLeaf) + 1) || 1} · ` : "";
|
|
6985
|
+
const titleSeg = winTag + truncate((winTag ? t : t || "(未选择会话)"), 40);
|
|
6986
|
+
if (this.sidebarVisible) row0.left.push({ t: " " + titleSeg + " ", fg: T.TXT, bg: T.STATUSBG });
|
|
6987
|
+
else row0.left.push({ t: " " + titleSeg + " ", fg: T.TXT, bg: T.STATUSBG });
|
|
6018
6988
|
if (cur?.running) row0.left.push({ t: " ●运行 ", fg: T.OK, bg: T.STATUSBG });
|
|
6019
6989
|
// session elapsed/start: effective time (model+tool work, not wall clock)
|
|
6020
6990
|
// right after the session name; start = the earliest event time loaded
|
|
@@ -6112,12 +7082,12 @@ export class App {
|
|
|
6112
7082
|
});
|
|
6113
7083
|
// Ctrl+J belongs beside the two activity summaries it opens, not alone
|
|
6114
7084
|
// at the far-right edge (especially once the queue badge also appears).
|
|
6115
|
-
row2.left.push({ t: " Ctrl+
|
|
7085
|
+
row2.left.push({ t: " 任务/子代理 · Ctrl+Space ", fg: T.DIM, bg: T.STATUSBG });
|
|
6116
7086
|
if (sub) row2.left.push({
|
|
6117
7087
|
t: ` ◇ ${truncate(sub.label ?? sub.mode ?? "子代理", 20)} `,
|
|
6118
7088
|
fg: subStats.running > 0 ? T.WARN : subStats.completed > 0 ? T.OK : T.FAINT, bg: T.STATUSBG,
|
|
6119
7089
|
});
|
|
6120
|
-
if(this.queueItems.length)row2.left.push({t:`
|
|
7090
|
+
if(this.queueItems.length)row2.left.push({t:` 排队${this.queueItems.length}条 · Ctrl+Space→任务 查看 `,fg:0x000000,bg:T.WARN,bold:true});
|
|
6121
7091
|
rows.push(row2);
|
|
6122
7092
|
}
|
|
6123
7093
|
this.status.rows = rows;
|
|
@@ -6144,6 +7114,32 @@ export class App {
|
|
|
6144
7114
|
this.term.output.write(out + tail);
|
|
6145
7115
|
}
|
|
6146
7116
|
|
|
7117
|
+
/** Attached preview card beside the sidebar: purely rendered state, never
|
|
7118
|
+
* a modal — the tree keeps focus and the card follows the selection. */
|
|
7119
|
+
#renderPreviewCard(s) {
|
|
7120
|
+
const p = this.sidebar.preview;
|
|
7121
|
+
const x = this.sidebarWidth + 1;
|
|
7122
|
+
const w = Math.min(34, Math.max(20, this.screen.w - x - 2));
|
|
7123
|
+
const y = 1, h = Math.max(5, this.screen.h - 2);
|
|
7124
|
+
s.fillRect(x, y, x + w - 1, y + h - 1, " ", { bg: T.PANEL });
|
|
7125
|
+
s.box(x, y, x + w - 1, y + h - 1, { fg: T.ACCENT, bg: T.PANEL }, ` 预览 · ${truncate(p.title ?? "", 14)} · Tab 关闭`);
|
|
7126
|
+
const rows = h - 4;
|
|
7127
|
+
if (p.loading) { s.text(x + 2, y + 2, "加载中…", { fg: T.FAINT, bg: T.PANEL }); }
|
|
7128
|
+
else if (p.error) { s.text(x + 2, y + 2, truncate(`读取失败: ${p.error}`, w - 6), { fg: T.ERR, bg: T.PANEL }); }
|
|
7129
|
+
else if (!p.lines.length) { s.text(x + 2, y + 2, "(还没有往来记录)", { fg: T.FAINT, bg: T.PANEL }); }
|
|
7130
|
+
else {
|
|
7131
|
+
for (let i = 0; i < rows; i++) {
|
|
7132
|
+
const [kind, text] = p.lines[i];
|
|
7133
|
+
if (!kind) break;
|
|
7134
|
+
const fg = kind === "你" ? T.OK : kind === "AI" ? T.TXT : T.PURPLE;
|
|
7135
|
+
s.text(x + 2, y + 2 + i, kind, { fg, bg: T.PANEL, bold: kind === "你", attrs: kind === "你" ? 1 : 0 });
|
|
7136
|
+
s.text(x + 5, y + 2 + i, truncate(text, w - 7), { fg: T.TXT, bg: T.PANEL });
|
|
7137
|
+
}
|
|
7138
|
+
if (p.lines.length > rows) s.text(x + w - 4, y + h - 1, `…${p.lines.length - rows} 更多`, { fg: T.FAINT, bg: T.PANEL });
|
|
7139
|
+
}
|
|
7140
|
+
s.text(x + 2, y + h - 1, "↑↓ 随选区更新 · Tab 关闭", { fg: T.FAINT, bg: T.PANEL });
|
|
7141
|
+
}
|
|
7142
|
+
|
|
6147
7143
|
#renderToast(s) {
|
|
6148
7144
|
if (!this.toastMsg) return;
|
|
6149
7145
|
// Toasts land in the LOWER half (just above the input/footer) where the
|