dsh-neotui 0.2.3 → 0.3.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/CHANGELOG.md +25 -0
- package/README.md +30 -13
- package/bin/dsh-tui.js +1 -1
- package/package.json +5 -2
- package/src/config.js +46 -12
- package/src/file-picker.js +4 -4
- package/src/keybindings.js +172 -0
- package/src/md.js +23 -21
- package/src/panels.js +1771 -427
- package/src/term.js +18 -10
- package/src/text.js +11 -3
- package/src/views.js +925 -263
- package/src/widgets.js +37 -27
package/src/views.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
// views.js — App composition: session list + chat timeline + approvals + status.
|
|
2
2
|
import { Screen } from "./screen.js";
|
|
3
3
|
import { renderMd, C } from "./md.js";
|
|
4
|
-
import { truncate, strWidth, bars, fmtDuration, fmtClock, fmtDateTime, graphemes, graphemeWidth } from "./text.js";
|
|
4
|
+
import { truncate, strWidth, pad, bars, fmtDuration, fmtClock, fmtDateTime, graphemes, graphemeWidth } from "./text.js";
|
|
5
5
|
import { readFileSync, appendFileSync, mkdirSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { spawnSync } from "node:child_process";
|
|
8
8
|
import { createRequire } from "node:module";
|
|
9
|
-
import { Widget,
|
|
9
|
+
import { Widget, ScrollView, Input, Popup, Menu, StatusBar, wrapIndex } from "./widgets.js";
|
|
10
10
|
import { UploadPicker } from "./file-picker.js";
|
|
11
|
-
import { userPrefix, saveTuiConfig, loadTuiConfig, userName, busyEnter, foldDefaults, keyBindings,
|
|
11
|
+
import { userPrefix, saveTuiConfig, loadTuiConfig, userName, busyEnter, foldDefaults, keyBindings, tuiConfigFile, reloadTuiConfig } from "./config.js";
|
|
12
|
+
import { bindingMatchFor, matchKeyBinding, CHAT_BINDING_ORDER, SIDEBAR_BINDING_ORDER, KEYBINDING_ORDER } from "./keybindings.js";
|
|
12
13
|
export { userPrefix, saveTuiConfig, loadTuiConfig, userName, busyEnter, foldDefaults } from "./config.js";
|
|
13
14
|
import {
|
|
14
15
|
Picker, buildCommandPalette, buildModelPicker, buildModePicker, buildPermissionPicker,
|
|
@@ -108,9 +109,14 @@ function renderToolCard(view, width, expanded) {
|
|
|
108
109
|
const rows = output.split("\n");
|
|
109
110
|
const cap = expanded ? 200 : 8;
|
|
110
111
|
for (const row of rows.slice(0, cap)) lines.push([{ t: " " + truncate(row, width - 4), fg: K.TXT, code: true }]);
|
|
111
|
-
if (rows.length > cap) lines.push([{ t:
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
if (rows.length > cap) lines.push([{ t: expanded
|
|
113
|
+
? ` …其余 ${rows.length - cap} 行超过详情上限`
|
|
114
|
+
: ` …隐藏 ${rows.length - cap} 行(点击展开)`, fg: K.FAINT }]);
|
|
115
|
+
// Keep terminal cards visually neutral at high tool-call frequency: a
|
|
116
|
+
// non-zero exit is still explicit text, but no longer introduces a red
|
|
117
|
+
// block/label that dominates the transcript.
|
|
118
|
+
if (card.signal) lines.push([{ t: ` signal ${card.signal}`, fg: K.WARN, bold: true }]);
|
|
119
|
+
else if (card.exitCode != null) lines.push([{ t: ` exit ${card.exitCode}`, fg: card.exitCode === 0 ? K.OK : K.WARN, bold: card.exitCode !== 0 }]);
|
|
114
120
|
else if (card.running) lines.push([{ t: " ● 运行中", fg: K.WARN }]);
|
|
115
121
|
break;
|
|
116
122
|
}
|
|
@@ -483,7 +489,25 @@ function applyEvent(nodes, event, view, log, state = null) {
|
|
|
483
489
|
export function nodeForEvents(events, log) {
|
|
484
490
|
const nodes = [];
|
|
485
491
|
const state = { step: null };
|
|
486
|
-
for (const { event, view } of events)
|
|
492
|
+
for (const { event, view } of events) {
|
|
493
|
+
const before = nodes.length;
|
|
494
|
+
applyEvent(nodes, event, view, log, state);
|
|
495
|
+
// Durable search/jump anchor: the event that created each derived node.
|
|
496
|
+
const seq = event?.seq;
|
|
497
|
+
if (Number.isFinite(seq)) {
|
|
498
|
+
if (nodes.length > before) {
|
|
499
|
+
// The event appended one or more sibling nodes: never leak its seq into
|
|
500
|
+
// the previous sibling (that would make search jumps land one early).
|
|
501
|
+
for (let i = before; i < nodes.length; i++) {
|
|
502
|
+
if (nodes[i].firstSeq == null) nodes[i].firstSeq = seq;
|
|
503
|
+
nodes[i].lastSeq = seq;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
// Mutation-only events keep the node's original anchor. A later tool
|
|
507
|
+
// result or turn/end may mutate a non-tail node, so assigning that seq to
|
|
508
|
+
// the array tail would create another false jump range.
|
|
509
|
+
}
|
|
510
|
+
}
|
|
487
511
|
return nodes;
|
|
488
512
|
}
|
|
489
513
|
|
|
@@ -767,6 +791,8 @@ class SidebarTree extends Widget {
|
|
|
767
791
|
this.groups = groups;
|
|
768
792
|
this.#flatten();
|
|
769
793
|
this.sel = Math.min(this.sel, Math.max(0, this.rows.length - 1));
|
|
794
|
+
this.scrollY = Math.max(0, Math.min(this.scrollY, this.maxScroll()));
|
|
795
|
+
this.#scrollToSel();
|
|
770
796
|
this.app.redraw();
|
|
771
797
|
}
|
|
772
798
|
#flatten() {
|
|
@@ -783,17 +809,21 @@ class SidebarTree extends Widget {
|
|
|
783
809
|
if (this.collapsed.has(group.key)) this.collapsed.delete(group.key);
|
|
784
810
|
else this.collapsed.add(group.key);
|
|
785
811
|
this.#flatten();
|
|
812
|
+
this.sel = Math.min(this.sel, Math.max(0, this.rows.length - 1));
|
|
813
|
+
this.scrollY = Math.max(0, Math.min(this.scrollY, this.maxScroll()));
|
|
814
|
+
this.#scrollToSel();
|
|
786
815
|
}
|
|
787
|
-
collapseAll() { for (const g of this.groups) this.collapsed.add(g.key); this.#flatten(); }
|
|
788
|
-
expandAll() { this.collapsed.clear(); this.#flatten(); }
|
|
816
|
+
collapseAll() { for (const g of this.groups) this.collapsed.add(g.key); this.#flatten(); this.sel = Math.min(this.sel, Math.max(0, this.rows.length - 1)); this.scrollY = Math.max(0, Math.min(this.scrollY, this.maxScroll())); this.#scrollToSel(); }
|
|
817
|
+
expandAll() { this.collapsed.clear(); this.#flatten(); this.sel = Math.min(this.sel, Math.max(0, this.rows.length - 1)); this.scrollY = Math.max(0, Math.min(this.scrollY, this.maxScroll())); this.#scrollToSel(); }
|
|
789
818
|
#rowTitle(sess) {
|
|
790
819
|
return sess.projections?.values?.title ?? (sess.blank ? "(空白会话)" : sess.sessionId.slice(0, 8));
|
|
791
820
|
}
|
|
792
821
|
render(screen) {
|
|
793
822
|
screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", {});
|
|
794
823
|
const w = this.w - 1;
|
|
795
|
-
//
|
|
796
|
-
|
|
824
|
+
// Header itself becomes the pane-focus badge. When focused, chat/trajectory
|
|
825
|
+
// tabs deliberately relinquish their highlight to this label.
|
|
826
|
+
screen.text(this.x, this.y, truncate("▣ 工作区", w - 2), { fg: this.focused ? T.SELFG : T.ACCENT, bg: this.focused ? T.ACCENT : -1, attrs: 1 });
|
|
797
827
|
screen.hline(this.x, this.x + w, this.y + 1, "─", { fg: T.BORDER });
|
|
798
828
|
const listTop = this.y + 2;
|
|
799
829
|
for (let i = 0; i < this.h - 2; i++) {
|
|
@@ -849,13 +879,37 @@ class SidebarTree extends Widget {
|
|
|
849
879
|
}
|
|
850
880
|
move(delta) {
|
|
851
881
|
if (this.rows.length === 0) return false;
|
|
852
|
-
const next =
|
|
853
|
-
if (next === this.sel) return false;
|
|
882
|
+
const next = wrapIndex(this.sel + delta, this.rows.length);
|
|
854
883
|
this.sel = next;
|
|
855
884
|
this.#scrollToSel();
|
|
856
885
|
return true;
|
|
857
886
|
}
|
|
858
887
|
currentRow() { return this.rows[this.sel] ?? null; }
|
|
888
|
+
#menuFor(row) {
|
|
889
|
+
if (!row) return [
|
|
890
|
+
{ label: "新建工作区…", action: () => this.app.addWorkspace() },
|
|
891
|
+
{ label: "新建会话", action: () => this.app.newSessionIn(null) },
|
|
892
|
+
];
|
|
893
|
+
if (row.kind === "session") return null;
|
|
894
|
+
const items = [
|
|
895
|
+
{ label: "新建会话", action: () => this.app.newSessionIn(row.group) },
|
|
896
|
+
{ label: "新建工作区…", action: () => this.app.addWorkspace() },
|
|
897
|
+
{ label: "折叠全部", action: () => { this.collapseAll(); this.app.redraw(); } },
|
|
898
|
+
{ label: "展开全部", action: () => { this.expandAll(); this.app.redraw(); } },
|
|
899
|
+
];
|
|
900
|
+
if (row.group.workspaceId) {
|
|
901
|
+
items.push({ label: "重命名工作区", action: () => this.app.renameWorkspace(row.group) });
|
|
902
|
+
items.push({ label: "删除工作区…", action: () => this.app.deleteWorkspace(row.group) });
|
|
903
|
+
}
|
|
904
|
+
return items;
|
|
905
|
+
}
|
|
906
|
+
openCurrentMenu() {
|
|
907
|
+
const row = this.currentRow();
|
|
908
|
+
const ev = { x: this.x + 2, y: this.y + 2 + Math.max(0, this.sel - this.scrollY) };
|
|
909
|
+
if (row?.kind === "session") this.app.sessionMenu({ data: row.session }, ev);
|
|
910
|
+
else this.app.openMenu(this.#menuFor(row), ev);
|
|
911
|
+
return true;
|
|
912
|
+
}
|
|
859
913
|
onMouse(ev) {
|
|
860
914
|
if (ev.kind === "wheel-up") { this.scroll(-3); return true; }
|
|
861
915
|
if (ev.kind === "wheel-down") { this.scroll(3); return true; }
|
|
@@ -875,30 +929,10 @@ class SidebarTree extends Widget {
|
|
|
875
929
|
if (ev.kind === "press" && ev.button === 2) {
|
|
876
930
|
const idx = this.scrollY + (ev.y - this.y - 2);
|
|
877
931
|
const row = this.rows[idx];
|
|
878
|
-
if (!row) {
|
|
879
|
-
// right-click on empty sidebar space → workspace-level actions
|
|
880
|
-
this.app.openMenu([
|
|
881
|
-
{ label: "新建工作区…", action: () => this.app.addWorkspace() },
|
|
882
|
-
{ label: "新建会话", action: () => this.app.newSessionIn(null) },
|
|
883
|
-
], ev);
|
|
884
|
-
return true;
|
|
885
|
-
}
|
|
932
|
+
if (!row) { this.app.openMenu(this.#menuFor(null), ev); return true; }
|
|
886
933
|
this.sel = idx;
|
|
887
|
-
if (row.kind === "
|
|
888
|
-
|
|
889
|
-
{ label: "新建会话", action: () => this.app.newSessionIn(row.group) },
|
|
890
|
-
{ label: "新建工作区…", action: () => this.app.addWorkspace() },
|
|
891
|
-
{ label: "折叠全部", action: () => { this.collapseAll(); this.app.redraw(); } },
|
|
892
|
-
{ label: "展开全部", action: () => { this.expandAll(); this.app.redraw(); } },
|
|
893
|
-
];
|
|
894
|
-
if (row.group.workspaceId) {
|
|
895
|
-
items.push({ label: "重命名工作区", action: () => this.app.renameWorkspace(row.group) });
|
|
896
|
-
items.push({ label: "删除工作区…", action: () => this.app.deleteWorkspace(row.group) });
|
|
897
|
-
}
|
|
898
|
-
this.app.openMenu(items, ev);
|
|
899
|
-
} else {
|
|
900
|
-
this.app.sessionMenu({ data: row.session }, ev);
|
|
901
|
-
}
|
|
934
|
+
if (row.kind === "session") this.app.sessionMenu({ data: row.session }, ev);
|
|
935
|
+
else this.app.openMenu(this.#menuFor(row), ev);
|
|
902
936
|
return true;
|
|
903
937
|
}
|
|
904
938
|
return false;
|
|
@@ -911,28 +945,45 @@ class SidebarTree extends Widget {
|
|
|
911
945
|
case "pgup": this.scroll(-this.h); return true;
|
|
912
946
|
case "pgdn": this.scroll(this.h); return true;
|
|
913
947
|
case "home": this.sel = 0; this.#scrollToSel(); return true;
|
|
914
|
-
case "end": this.sel = this.rows.length - 1; this.#scrollToSel(); return true;
|
|
948
|
+
case "end": this.sel = Math.max(0, this.rows.length - 1); this.#scrollToSel(); return true;
|
|
915
949
|
case "enter": {
|
|
916
950
|
const row = this.currentRow();
|
|
917
951
|
if (!row) return false;
|
|
918
952
|
if (row.kind === "group") { this.toggle(row.group); this.app.redraw(); }
|
|
919
|
-
else this.app.openSession(row.session.sessionId);
|
|
953
|
+
else this.app.openSession(row.session.sessionId); // pane focus intentionally stays here
|
|
920
954
|
return true;
|
|
921
955
|
}
|
|
922
956
|
case "left": {
|
|
957
|
+
if (ev.ctrl) return false;
|
|
923
958
|
const row = this.currentRow();
|
|
924
959
|
if (row?.kind === "group" && !this.collapsed.has(row.group.key)) { this.toggle(row.group); this.app.redraw(); return true; }
|
|
925
960
|
if (row?.kind === "session") { this.sel = this.rows.findLastIndex((r, i) => i <= this.sel && r.kind === "group"); this.#scrollToSel(); return true; }
|
|
926
961
|
return false;
|
|
927
962
|
}
|
|
928
963
|
case "right": {
|
|
964
|
+
if (ev.ctrl) return false;
|
|
929
965
|
const row = this.currentRow();
|
|
930
966
|
if (row?.kind === "group" && this.collapsed.has(row.group.key)) { this.toggle(row.group); this.app.redraw(); return true; }
|
|
931
967
|
if (row?.kind === "group") { this.sel = Math.min(this.rows.length - 1, this.sel + 1); this.#scrollToSel(); return true; }
|
|
932
968
|
return false;
|
|
933
969
|
}
|
|
934
970
|
case "char":
|
|
935
|
-
if (
|
|
971
|
+
if (ev.ctrl && ev.key === "r") return this.openCurrentMenu();
|
|
972
|
+
if (ev.ctrl) return false;
|
|
973
|
+
{
|
|
974
|
+
const sbHit = bindingMatchFor(ev, keyBindings(), false, SIDEBAR_BINDING_ORDER);
|
|
975
|
+
if (sbHit?.id === "insert") { this.app.focus(this.app.chat.input); this.app.redraw(); return true; }
|
|
976
|
+
if (sbHit?.id === "newSession") { const r = this.currentRow(); if (r?.kind === "group") this.app.newSessionIn(r.group); else this.app.newSession(); return true; }
|
|
977
|
+
}
|
|
978
|
+
if (ev.key === " ") {
|
|
979
|
+
const row = this.currentRow();
|
|
980
|
+
if (row?.kind === "group") { this.toggle(row.group); this.app.redraw(); return true; }
|
|
981
|
+
if (row?.kind === "session") {
|
|
982
|
+
const groupIndex = this.rows.findLastIndex((candidate, index) => index <= this.sel && candidate.kind === "group");
|
|
983
|
+
this.toggle(row.group); this.sel = Math.max(0, groupIndex); this.#scrollToSel(); this.app.redraw(); return true;
|
|
984
|
+
}
|
|
985
|
+
return false;
|
|
986
|
+
}
|
|
936
987
|
if (!ev.ctrl && (ev.key === "[" || ev.key === "]")) {
|
|
937
988
|
const r = this.currentRow();
|
|
938
989
|
if (r?.kind === "session") { this.app.moveSession(r.session, ev.key === "[" ? -1 : 1); return true; }
|
|
@@ -969,7 +1020,6 @@ export class ChatView extends Widget {
|
|
|
969
1020
|
this.nodes = [];
|
|
970
1021
|
this.lines = [];
|
|
971
1022
|
this.expanded = new Set(); // node indexes (user-message full text)
|
|
972
|
-
this.expandedTools = new Set();
|
|
973
1023
|
this.collapsedBlocks = new Set(); // per-block COLLAPSE (default expanded): `${realIdx}:${bi}`
|
|
974
1024
|
const fd = foldDefaults();
|
|
975
1025
|
this.thinkMode = fd.think ? "expanded" : "collapsed"; // t toggles
|
|
@@ -998,6 +1048,8 @@ export class ChatView extends Widget {
|
|
|
998
1048
|
this.cache = new Map(); // node render cache: key → { lines, marks }
|
|
999
1049
|
this.cardRanges = []; // absolute line ranges of card-backed message blocks
|
|
1000
1050
|
this.welcomeModes = []; // absolute row y → agent preset id (welcome screen)
|
|
1051
|
+
this.welcomeModeIds = ["standard", "code", "minimal", "cordis"];
|
|
1052
|
+
this.welcomeModeSel = 0;
|
|
1001
1053
|
this.pressY = null;
|
|
1002
1054
|
this.pressInfo = null; // hit identity locked at press time
|
|
1003
1055
|
this.pressCtx = null;
|
|
@@ -1006,7 +1058,14 @@ export class ChatView extends Widget {
|
|
|
1006
1058
|
this.selEnd = null;
|
|
1007
1059
|
this.selAnchor = null;
|
|
1008
1060
|
this.selFocus = null;
|
|
1009
|
-
|
|
1061
|
+
// Mouse selection is permanently free/character-based. Keyboard selection
|
|
1062
|
+
// has its own Vim-like read-only cursor modes.
|
|
1063
|
+
this.blockItems = [];
|
|
1064
|
+
this.blockSel = -1;
|
|
1065
|
+
this.cursorMode = "block"; // block | normal | visual | visual-line
|
|
1066
|
+
this.cursor = { line: 0, col: 0 };
|
|
1067
|
+
this.visualAnchor = null;
|
|
1068
|
+
this.bindingPending = null; // in-progress two-press chord ({id, slot, part})
|
|
1010
1069
|
this.clipboardImages = [];
|
|
1011
1070
|
this.attachments = [];
|
|
1012
1071
|
this.stepState = { step: null }; // step/start tracking for the mux merge path
|
|
@@ -1146,6 +1205,8 @@ export class ChatView extends Widget {
|
|
|
1146
1205
|
for (let li = 0; li < this.lineMap.length; li++) {
|
|
1147
1206
|
if (this.lineMap[li]?.nodeIdx === idx) {
|
|
1148
1207
|
this.view.anchorLock = null; this.view.follow = false; this.view.scrollY = Math.max(0, li - 2);
|
|
1208
|
+
const block = this.blockItems.findIndex((item) => item.nodeIdx === idx);
|
|
1209
|
+
if (block >= 0) { this.blockSel = block; this.cursor = { line: this.blockItems[block].headerLine, col: 0 }; }
|
|
1149
1210
|
this.app.redraw();
|
|
1150
1211
|
return true;
|
|
1151
1212
|
}
|
|
@@ -1199,18 +1260,21 @@ export class ChatView extends Widget {
|
|
|
1199
1260
|
this.#rebuild();
|
|
1200
1261
|
}
|
|
1201
1262
|
|
|
1202
|
-
async open(sessionId, epoch = this.app.sessionEpoch) {
|
|
1263
|
+
async open(sessionId, epoch = this.app.sessionEpoch, maxMessages = 80) {
|
|
1203
1264
|
this.sessionId = sessionId;
|
|
1204
1265
|
this.nodes = [];
|
|
1266
|
+
this.welcomeModeSel = 0;
|
|
1267
|
+
this.blockSel = -1;
|
|
1268
|
+
this.cursorMode = "block";
|
|
1269
|
+
this.visualAnchor = null;
|
|
1205
1270
|
this.expanded.clear();
|
|
1206
|
-
this.expandedTools.clear();
|
|
1207
1271
|
this.collapsedBlocks.clear();
|
|
1208
1272
|
this.hasMore = false;
|
|
1209
1273
|
this.minSeq = null;
|
|
1210
1274
|
this.cache.clear();
|
|
1211
1275
|
this.app.setStatus(`加载会话 ${sessionId.slice(0, 8)}…`);
|
|
1212
1276
|
try {
|
|
1213
|
-
const hist = await this.app.api.call("session.history", { sessionId });
|
|
1277
|
+
const hist = await this.app.api.call("session.history", { sessionId, maxMessages });
|
|
1214
1278
|
if (this.sessionId !== sessionId || this.app.sessionEpoch !== epoch) return;
|
|
1215
1279
|
this.minSeq = hist.events[0]?.event?.seq ?? null;
|
|
1216
1280
|
this.lastSeq = hist.events[hist.events.length - 1]?.event?.seq ?? null;
|
|
@@ -1234,7 +1298,7 @@ export class ChatView extends Widget {
|
|
|
1234
1298
|
this.view.follow = true;
|
|
1235
1299
|
}
|
|
1236
1300
|
|
|
1237
|
-
async loadOlder(onDone = null) {
|
|
1301
|
+
async loadOlder(onDone = null, maxMessages = 20) {
|
|
1238
1302
|
if (!this.hasMore || this.loadingOlder || this.minSeq == null) { if (!this.hasMore) this.app.toast("已加载到会话开头"); if (onDone) queueMicrotask(onDone); return; }
|
|
1239
1303
|
const sessionId = this.sessionId;
|
|
1240
1304
|
const epoch = this.app.sessionEpoch;
|
|
@@ -1243,7 +1307,7 @@ export class ChatView extends Widget {
|
|
|
1243
1307
|
const oldLength = this.lines.length;
|
|
1244
1308
|
this.app.setStatus("加载更早记录…");
|
|
1245
1309
|
try {
|
|
1246
|
-
const hist = await this.app.api.call("session.history", { sessionId, beforeSeq: this.minSeq, maxMessages
|
|
1310
|
+
const hist = await this.app.api.call("session.history", { sessionId, beforeSeq: this.minSeq, maxMessages });
|
|
1247
1311
|
if (this.sessionId !== sessionId || this.app.sessionEpoch !== epoch) { this.loadingOlder = false; return; }
|
|
1248
1312
|
if (hist.events.length === 0) { this.hasMore = false; }
|
|
1249
1313
|
else {
|
|
@@ -1254,6 +1318,24 @@ export class ChatView extends Widget {
|
|
|
1254
1318
|
this.hasMore = hist.hasMore && this.minSeq < previousMinSeq;
|
|
1255
1319
|
this.#noteEarliest(hist.events);
|
|
1256
1320
|
const more = nodeForEvents(hist.events, this.app.log);
|
|
1321
|
+
const shift = more.length;
|
|
1322
|
+
if (shift > 0) {
|
|
1323
|
+
const shiftedExpanded = new Set();
|
|
1324
|
+
for (const key of this.expanded) {
|
|
1325
|
+
if (typeof key === "number") shiftedExpanded.add(key + shift);
|
|
1326
|
+
else if (typeof key === "string" && /^(\d+):(\d+)$/.test(key)) {
|
|
1327
|
+
const [, ni, bi] = /^(\d+):(\d+)$/.exec(key); shiftedExpanded.add(`${Number(ni) + shift}:${bi}`);
|
|
1328
|
+
} else shiftedExpanded.add(key); // dispatch ids are stable callIds
|
|
1329
|
+
}
|
|
1330
|
+
const shiftedCollapsed = new Set();
|
|
1331
|
+
for (const key of this.collapsedBlocks) {
|
|
1332
|
+
const match = /^(\d+):(\d+)$/.exec(String(key));
|
|
1333
|
+
shiftedCollapsed.add(match ? `${Number(match[1]) + shift}:${match[2]}` : key);
|
|
1334
|
+
}
|
|
1335
|
+
this.expanded = shiftedExpanded;
|
|
1336
|
+
this.collapsedBlocks = shiftedCollapsed;
|
|
1337
|
+
this.cache.clear();
|
|
1338
|
+
}
|
|
1257
1339
|
this.nodes = [...more, ...this.nodes];
|
|
1258
1340
|
}
|
|
1259
1341
|
} catch (e) {
|
|
@@ -1360,7 +1442,7 @@ export class ChatView extends Widget {
|
|
|
1360
1442
|
const trimmed = text.trim();
|
|
1361
1443
|
if (trimmed === "/reload") { this.app.softReload(); return; }
|
|
1362
1444
|
if (trimmed === "/restart") { this.app.restartApp(); return; }
|
|
1363
|
-
if (trimmed === "/model") { this.app.
|
|
1445
|
+
if (trimmed === "/model") { this.app.overlay = buildModelPicker(this.app); this.app.redraw(); return; }
|
|
1364
1446
|
if (trimmed === "/theme") { cycleTheme(); this.queueRebuild(); this.app.toast(`主题已切换: ${themeName()}`); return; }
|
|
1365
1447
|
if (trimmed === "/permission") { this.app.showPermissionPicker(); return; }
|
|
1366
1448
|
if (trimmed === "/goal") { this.app.showGoal(); return; }
|
|
@@ -1601,7 +1683,11 @@ export class ChatView extends Widget {
|
|
|
1601
1683
|
return true;
|
|
1602
1684
|
}
|
|
1603
1685
|
}
|
|
1604
|
-
|
|
1686
|
+
// Node-level folds: user messages plus the notice/context cards that
|
|
1687
|
+
// explicitly render [点击展开]/[点击折叠]. They all use the same stable
|
|
1688
|
+
// node index key, so clicking any marked row (header, preview, or hint)
|
|
1689
|
+
// toggles the exact card the user saw.
|
|
1690
|
+
if (["assistant", "user", "context", "goal-round", "subagent-receipt"].includes(node.kind)) {
|
|
1605
1691
|
if (this.expanded.has(info.nodeIdx)) this.expanded.delete(info.nodeIdx);
|
|
1606
1692
|
else this.expanded.add(info.nodeIdx);
|
|
1607
1693
|
this.#rebuild();
|
|
@@ -1612,6 +1698,8 @@ export class ChatView extends Widget {
|
|
|
1612
1698
|
}
|
|
1613
1699
|
|
|
1614
1700
|
#rebuild() {
|
|
1701
|
+
const oldBlock = this.blockItems?.[this.blockSel] ?? null;
|
|
1702
|
+
const oldIdentity = oldBlock ? `${oldBlock.nodeKey}:${oldBlock.blockIdx ?? "n"}:${oldBlock.kind}:${oldBlock.codeIndex ?? "-"}` : null;
|
|
1615
1703
|
const w = Math.max(20, this.view.w - 2);
|
|
1616
1704
|
const lines = [];
|
|
1617
1705
|
const lineMap = [];
|
|
@@ -1701,7 +1789,7 @@ export class ChatView extends Widget {
|
|
|
1701
1789
|
const text = node.text ?? "";
|
|
1702
1790
|
const summary = node.source?.summary;
|
|
1703
1791
|
const label = node.kind === "goal-round" ? `🎯 目标续轮 ${node.source?.round ?? ""}`
|
|
1704
|
-
: node.kind === "subagent-receipt" ? (node.source?.kind === "subagent-settled" ? "
|
|
1792
|
+
: node.kind === "subagent-receipt" ? (node.source?.kind === "subagent-settled" ? "◇ 子代理状态" : "◇ 子代理回执")
|
|
1705
1793
|
: `ℹ 上下文 · ${node.source?.kind ?? "注入"}`;
|
|
1706
1794
|
beginCard(node.kind === "goal-round" ? "THINKBG" : "CARD");
|
|
1707
1795
|
lines.push([{ t: ` ${label}${summary ? ` — ${truncate(summary, w - strWidth(label) - 8)}` : ""}`, fg: node.kind === "subagent-receipt" ? T.PURPLE : K.DIM, bold: true }]);
|
|
@@ -1811,8 +1899,12 @@ export class ChatView extends Widget {
|
|
|
1811
1899
|
// failed exit code.
|
|
1812
1900
|
const stopped = !running && (b.stopped || signal === "SIGTERM" || signal === "SIGINT");
|
|
1813
1901
|
const failed = !orphan && !stopped && (b.isError || signal || (exitCode !== undefined && exitCode !== 0));
|
|
1814
|
-
|
|
1815
|
-
|
|
1902
|
+
// High-frequency tool calls need a quiet hierarchy: the same
|
|
1903
|
+
// neutral gray CARD used by ordinary output marks the clickable
|
|
1904
|
+
// range; green is reserved for the formal assistant output below.
|
|
1905
|
+
// Failed tools remain explicit text but never paint a red block.
|
|
1906
|
+
const status = "CARD";
|
|
1907
|
+
const glyph = running ? "⏳" : failed ? "!" : stopped ? "⏸" : orphan ? "◌" : "✓";
|
|
1816
1908
|
const card = cardView ? renderToolCard(cardView, w, open) : [];
|
|
1817
1909
|
beginCard(status);
|
|
1818
1910
|
let timing = "";
|
|
@@ -1829,7 +1921,7 @@ export class ChatView extends Widget {
|
|
|
1829
1921
|
lines.push([
|
|
1830
1922
|
{ t: open ? "▾ " : "▸ ", fg: K.ACCENT },
|
|
1831
1923
|
{ t: ` ${b.name ?? "tool"}`, fg: K.TXT, bold: true },
|
|
1832
|
-
{ t: ` ${glyph}`, fg: failed ? K.
|
|
1924
|
+
{ t: ` ${glyph}`, fg: failed ? K.WARN : running ? K.DIM : K.OK },
|
|
1833
1925
|
{ t: stepTag + timing, fg: K.DIM },
|
|
1834
1926
|
{ t: open ? " [b 折叠]" : " [b 展开]", fg: K.FAINT },
|
|
1835
1927
|
]);
|
|
@@ -1938,7 +2030,10 @@ export class ChatView extends Widget {
|
|
|
1938
2030
|
mark(realIdx, bi);
|
|
1939
2031
|
sep();
|
|
1940
2032
|
} else {
|
|
1941
|
-
|
|
2033
|
+
// Swap the previous visual priority: completed assistant output
|
|
2034
|
+
// gets the restrained green background, while high-frequency
|
|
2035
|
+
// tool cards use neutral gray only to reveal their click range.
|
|
2036
|
+
beginCard("TOOLOK");
|
|
1942
2037
|
// FORMAL text output is NOT collapsible — the user's message
|
|
1943
2038
|
// content must stay readable; only think/tool blocks fold.
|
|
1944
2039
|
// A neutral assistant glyph identifies model output without
|
|
@@ -2023,12 +2118,200 @@ export class ChatView extends Widget {
|
|
|
2023
2118
|
}
|
|
2024
2119
|
this.lines = lines;
|
|
2025
2120
|
this.lineMap = lineMap;
|
|
2121
|
+
this.#rebuildBlockItems(oldIdentity);
|
|
2026
2122
|
if (process.env.DSH_TUI_DEBUG_CLICK && lineMap.length !== lines.length) {
|
|
2027
2123
|
this.#clickLog(`INVARIANT BROKEN: lines=${lines.length} lineMap=${lineMap.length}`);
|
|
2028
2124
|
}
|
|
2029
2125
|
this.view.setLines(lines);
|
|
2030
2126
|
}
|
|
2031
2127
|
|
|
2128
|
+
#rebuildBlockItems(oldIdentity = null) {
|
|
2129
|
+
const items = [];
|
|
2130
|
+
const nonBlank = (line) => (this.lines[line] ?? []).some((seg) => (seg.t ?? "").trim() !== "");
|
|
2131
|
+
const keyOf = (mark) => {
|
|
2132
|
+
if (!mark || mark.nodeIdx == null || mark.nodeIdx < 0) return null;
|
|
2133
|
+
if (mark.imgIdx !== undefined) return `${mark.nodeIdx}:img:${mark.imgIdx}`;
|
|
2134
|
+
if (mark.dispatchId != null) return `${mark.nodeIdx}:${mark.blockIdx}:dispatch:${mark.dispatchId}`;
|
|
2135
|
+
return `${mark.nodeIdx}:${mark.blockIdx ?? "node"}`;
|
|
2136
|
+
};
|
|
2137
|
+
for (let start = 0; start < this.lineMap.length;) {
|
|
2138
|
+
const key = keyOf(this.lineMap[start]);
|
|
2139
|
+
if (key === null) { start++; continue; }
|
|
2140
|
+
let end = start + 1;
|
|
2141
|
+
while (end < this.lineMap.length && keyOf(this.lineMap[end]) === key) end++;
|
|
2142
|
+
const mark = this.lineMap[start];
|
|
2143
|
+
const node = this.nodes[mark.nodeIdx];
|
|
2144
|
+
let first = -1, last = -1;
|
|
2145
|
+
for (let line = start; line < end; line++) if (nonBlank(line)) { if (first < 0) first = line; last = line; }
|
|
2146
|
+
if (first >= 0 && node) {
|
|
2147
|
+
const block = mark.blockIdx != null ? node.blocks?.[mark.blockIdx] : null;
|
|
2148
|
+
const baseKind = mark.imgIdx !== undefined ? "image" : mark.dispatchId != null ? "tool" : block?.kind ?? node.kind;
|
|
2149
|
+
const nodeKey = node.id ?? `seq:${node.firstSeq ?? "?"}:${node.kind}`;
|
|
2150
|
+
const base = { first, last, headerLine: first, nodeIdx: mark.nodeIdx, nodeKey, blockIdx: mark.blockIdx ?? null, kind: baseKind, foldable: false, code: null };
|
|
2151
|
+
if (block?.kind === "reasoning" || block?.kind === "tool" || mark.dispatchId != null) base.foldable = true;
|
|
2152
|
+
else if (!block && ["user", "context", "goal-round", "subagent-receipt"].includes(node.kind)) base.foldable = true;
|
|
2153
|
+
if (block?.kind === "text") {
|
|
2154
|
+
const ranges = [];
|
|
2155
|
+
let codeIndex = 0;
|
|
2156
|
+
for (let line = first; line <= last; line++) {
|
|
2157
|
+
const codeSeg = (this.lines[line] ?? []).find((seg) => seg.codeBlock || seg.copyCode);
|
|
2158
|
+
const meta = codeSeg?.codeBlock;
|
|
2159
|
+
if (!meta) continue;
|
|
2160
|
+
const prev = ranges.at(-1);
|
|
2161
|
+
if (prev?.meta === meta && line === prev.last + 1) prev.last = line;
|
|
2162
|
+
else ranges.push({ first: line, last: line, meta, codeIndex: codeIndex++ });
|
|
2163
|
+
}
|
|
2164
|
+
let cursor = first;
|
|
2165
|
+
for (const range of ranges) {
|
|
2166
|
+
let proseEnd = range.first - 1;
|
|
2167
|
+
while (proseEnd >= cursor && !nonBlank(proseEnd)) proseEnd--;
|
|
2168
|
+
if (proseEnd >= cursor) items.push({ ...base, first: cursor, last: proseEnd, headerLine: cursor, kind: "text" });
|
|
2169
|
+
items.push({ ...base, first: range.first, last: range.last, headerLine: range.first, kind: "code", codeIndex: range.codeIndex, code: { text: range.meta.text ?? "", lang: range.meta.lang ?? "text" } });
|
|
2170
|
+
cursor = range.last + 1;
|
|
2171
|
+
}
|
|
2172
|
+
while (cursor <= last && !nonBlank(cursor)) cursor++;
|
|
2173
|
+
if (cursor <= last) items.push({ ...base, first: cursor, last, headerLine: cursor, kind: "text" });
|
|
2174
|
+
if (ranges.length === 0) items.push(base);
|
|
2175
|
+
} else items.push(base);
|
|
2176
|
+
}
|
|
2177
|
+
start = end;
|
|
2178
|
+
}
|
|
2179
|
+
this.blockItems = items;
|
|
2180
|
+
let next = oldIdentity == null ? -1 : items.findIndex((item) => `${item.nodeKey}:${item.blockIdx ?? "n"}:${item.kind}:${item.codeIndex ?? "-"}` === oldIdentity);
|
|
2181
|
+
if (next < 0) {
|
|
2182
|
+
// New/opened sessions land on the latest textual conversation block,
|
|
2183
|
+
// not on an incidental retry/status/image row after it.
|
|
2184
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
2185
|
+
if (["text", "code", "user"].includes(items[i].kind)) { next = i; break; }
|
|
2186
|
+
}
|
|
2187
|
+
if (next < 0) next = items.length - 1;
|
|
2188
|
+
}
|
|
2189
|
+
this.blockSel = next;
|
|
2190
|
+
const selected = items[next];
|
|
2191
|
+
if (selected && this.cursorMode === "block") this.cursor = { line: selected.headerLine, col: Math.max(0, strWidth(this.#lineText(selected.headerLine)) - 1) };
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
#lineText(line) { return (this.lines[line] ?? []).map((seg) => seg.t ?? "").join(""); }
|
|
2195
|
+
|
|
2196
|
+
#scrollToTranscriptLine(line) {
|
|
2197
|
+
this.view.follow = false;
|
|
2198
|
+
this.view.anchorLock = null;
|
|
2199
|
+
if (line < this.view.scrollY) this.view.scrollY = line;
|
|
2200
|
+
else if (line >= this.view.scrollY + this.view.h) this.view.scrollY = Math.max(0, line - this.view.h + 1);
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
#moveBlock(delta) {
|
|
2204
|
+
if (this.blockItems.length === 0) return false;
|
|
2205
|
+
// Bounded, Vim-style: the block cursor stops at the ends instead of
|
|
2206
|
+
// wrapping. Wrapping from the newest block to the oldest felt like a jump,
|
|
2207
|
+
// not navigation.
|
|
2208
|
+
const base = this.blockSel < 0 ? (delta > 0 ? -1 : 0) : this.blockSel;
|
|
2209
|
+
const next = base + delta;
|
|
2210
|
+
if (next < 0 || next >= this.blockItems.length) return true;
|
|
2211
|
+
this.blockSel = next;
|
|
2212
|
+
const item = this.blockItems[this.blockSel];
|
|
2213
|
+
this.cursor = { line: item.headerLine, col: 0 };
|
|
2214
|
+
if (this.cursorMode !== "block") this.#syncKeyboardSelection();
|
|
2215
|
+
this.#scrollToTranscriptLine(item.headerLine);
|
|
2216
|
+
this.app.redraw();
|
|
2217
|
+
return true;
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
#syncKeyboardSelection() {
|
|
2221
|
+
if (this.cursorMode !== "visual" && this.cursorMode !== "visual-line") {
|
|
2222
|
+
this.selStart = this.selEnd = null;
|
|
2223
|
+
this.selAnchor = this.selFocus = null;
|
|
2224
|
+
return;
|
|
2225
|
+
}
|
|
2226
|
+
const anchor = this.visualAnchor ?? this.cursor;
|
|
2227
|
+
let a = { ...anchor }, b = { ...this.cursor };
|
|
2228
|
+
if (this.cursorMode === "visual-line") {
|
|
2229
|
+
a.col = 0;
|
|
2230
|
+
b.col = Math.max(0, strWidth(this.#lineText(b.line)) - 1);
|
|
2231
|
+
}
|
|
2232
|
+
this.selAnchor = a; this.selFocus = b;
|
|
2233
|
+
this.selStart = Math.min(a.line, b.line); this.selEnd = Math.max(a.line, b.line);
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
#cursorStops(line = this.cursor.line) {
|
|
2237
|
+
const chars = graphemes(this.#lineText(line));
|
|
2238
|
+
const stops = [];
|
|
2239
|
+
let col = 0;
|
|
2240
|
+
for (const char of chars) { stops.push({ char, col, width: Math.max(1, graphemeWidth(char)) }); col += graphemeWidth(char); }
|
|
2241
|
+
if (stops.length === 0) stops.push({ char: "", col: 0, width: 1 });
|
|
2242
|
+
return stops;
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
#cursorStopIndex(stops = this.#cursorStops()) {
|
|
2246
|
+
let index = 0;
|
|
2247
|
+
for (let i = 0; i < stops.length; i++) { if (stops[i].col <= this.cursor.col) index = i; else break; }
|
|
2248
|
+
return index;
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
#moveCursorHorizontal(delta) {
|
|
2252
|
+
const stops = this.#cursorStops();
|
|
2253
|
+
const index = Math.max(0, Math.min(stops.length - 1, this.#cursorStopIndex(stops) + delta));
|
|
2254
|
+
this.cursor.col = stops[index].col;
|
|
2255
|
+
this.#syncKeyboardSelection(); this.app.redraw(); return true;
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
#wordMotion(kind) {
|
|
2259
|
+
const stops = this.#cursorStops();
|
|
2260
|
+
let pos = this.#cursorStopIndex(stops);
|
|
2261
|
+
const word = (entry) => /[\p{L}\p{N}_]/u.test(entry?.char ?? "");
|
|
2262
|
+
if (kind === "w") { while (pos < stops.length && word(stops[pos])) pos++; while (pos < stops.length && !word(stops[pos])) pos++; }
|
|
2263
|
+
else if (kind === "b") { pos = Math.max(0, pos - 1); while (pos > 0 && !word(stops[pos])) pos--; while (pos > 0 && word(stops[pos - 1])) pos--; }
|
|
2264
|
+
else { while (pos + 1 < stops.length && !word(stops[pos])) pos++; while (pos + 1 < stops.length && word(stops[pos + 1])) pos++; }
|
|
2265
|
+
this.cursor.col = stops[Math.max(0, Math.min(stops.length - 1, pos))].col;
|
|
2266
|
+
this.#syncKeyboardSelection(); this.app.redraw(); return true;
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
#selectedTranscriptText() {
|
|
2270
|
+
if (this.cursorMode === "block" || this.cursorMode === "normal") {
|
|
2271
|
+
const item = this.blockItems[this.blockSel];
|
|
2272
|
+
if (!item) return "";
|
|
2273
|
+
if (item.kind === "code") return item.code?.text ?? "";
|
|
2274
|
+
const node = this.nodes[item.nodeIdx];
|
|
2275
|
+
const block = item.blockIdx != null ? node?.blocks?.[item.blockIdx] : null;
|
|
2276
|
+
if (block) return [block.text ?? block.args ?? "", block.kind === "tool" && block.result != null ? block.result : ""].filter(Boolean).join("\n");
|
|
2277
|
+
return node?.text ?? "";
|
|
2278
|
+
}
|
|
2279
|
+
const a = this.selAnchor, b = this.selFocus;
|
|
2280
|
+
if (!a || !b) return "";
|
|
2281
|
+
const first = a.line < b.line || (a.line === b.line && a.col <= b.col) ? a : b;
|
|
2282
|
+
const last = first === a ? b : a;
|
|
2283
|
+
const cut = (text, from, to) => { let out = "", col = 0; for (const g of graphemes(text)) { const next = col + graphemeWidth(g); if (next > from && col <= to) out += g; col = next; } return out; };
|
|
2284
|
+
return this.lines.slice(first.line, last.line + 1).map((line, index, all) => cut(line.map((seg) => seg.t ?? "").join(""), index === 0 ? first.col : 0, index === all.length - 1 ? last.col : Infinity)).join("\n");
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
#yankTranscript() {
|
|
2288
|
+
const text = this.#selectedTranscriptText();
|
|
2289
|
+
if (!text) { this.app.toast("未选中可复制内容"); return true; }
|
|
2290
|
+
this.app.copyText(text);
|
|
2291
|
+
this.app.toast((this.cursorMode === "block" || this.cursorMode === "normal") ? (this.blockItems[this.blockSel]?.kind === "code" ? "已复制代码块" : "已复制正文块") : "已复制选区");
|
|
2292
|
+
if (this.cursorMode === "visual" || this.cursorMode === "visual-line") { this.cursorMode = "normal"; this.visualAnchor = null; this.selStart = this.selEnd = null; this.selAnchor = this.selFocus = null; }
|
|
2293
|
+
return true;
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
#toggleSelectedBlock() {
|
|
2297
|
+
const item = this.blockItems[this.blockSel];
|
|
2298
|
+
if (!item?.foldable) return false;
|
|
2299
|
+
return this.#toggleAt({ nodeIdx: item.nodeIdx, blockIdx: item.blockIdx });
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
#openSelectedContextMenu() {
|
|
2303
|
+
const item = this.blockItems[this.blockSel];
|
|
2304
|
+
if (!item) return false;
|
|
2305
|
+
const info = { nodeIdx: item.nodeIdx, blockIdx: item.blockIdx };
|
|
2306
|
+
const node = this.nodes[item.nodeIdx];
|
|
2307
|
+
const entries = [{ label: "复制消息", action: () => this.app.copyText(this.#selectedTranscriptText()) }];
|
|
2308
|
+
if (item.foldable) entries.push({ label: "展开 / 折叠", action: () => this.#toggleAt(info) });
|
|
2309
|
+
if (node?.id) entries.push({ label: "转跳轨迹", action: () => this.app.jumpToTrajectoryNode(item.nodeIdx) });
|
|
2310
|
+
entries.push({ label: "加载更早记录", action: () => this.loadOlder() });
|
|
2311
|
+
this.app.openMenu(entries, { x: this.view.x + 2, y: this.view.y + Math.max(0, item.headerLine - this.view.scrollY) });
|
|
2312
|
+
return true;
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2032
2315
|
/** Blank session: whale logo + mode selection prompt (no conversation yet). */
|
|
2033
2316
|
#renderWelcome(screen) {
|
|
2034
2317
|
const x = this.view.x;
|
|
@@ -2065,12 +2348,16 @@ export class ChatView extends Widget {
|
|
|
2065
2348
|
["minimal", "极简模式", "仅持久 bash 与 str_replace_editor 双工具"],
|
|
2066
2349
|
["cordis", "创造模式", "标准模式 + 运行时检查/插件实验/预设创作"],
|
|
2067
2350
|
];
|
|
2068
|
-
|
|
2351
|
+
const currentIdx = presets.findIndex(([id]) => id === currentPreset);
|
|
2352
|
+
if (this.welcomeModeSel == null || this.welcomeModeSel >= presets.length) this.welcomeModeSel = currentIdx >= 0 ? currentIdx : 0;
|
|
2353
|
+
for (let i = 0; i < presets.length; i++) {
|
|
2354
|
+
const [id, name, desc] = presets[i];
|
|
2069
2355
|
if (y < this.view.y + this.view.h) {
|
|
2070
|
-
const
|
|
2071
|
-
const
|
|
2072
|
-
|
|
2073
|
-
screen.text(cx
|
|
2356
|
+
const active = id === currentPreset;
|
|
2357
|
+
const cursor = this.app.focused === this && i === this.welcomeModeSel;
|
|
2358
|
+
const label = `${cursor ? "=>" : " "} ${active ? "●" : "○"} ${name}${active ? " [当前]" : ""}`;
|
|
2359
|
+
screen.text(cx, y, ` ${label}`, { fg: active ? T.OK : cursor ? T.ACCENT : T.DIM, bg: cursor ? T.MENUSEL : -1, attrs: active || cursor ? 1 : 0 });
|
|
2360
|
+
screen.text(cx + 2 + strWidth(label) + 1, y, truncate(desc, Math.max(1, this.view.w - strWidth(label) - 8)), { fg: cursor ? T.TXT : T.DIM, bg: cursor ? T.MENUSEL : -1 });
|
|
2074
2361
|
this.welcomeModes[y] = id;
|
|
2075
2362
|
}
|
|
2076
2363
|
y++;
|
|
@@ -2098,7 +2385,38 @@ export class ChatView extends Widget {
|
|
|
2098
2385
|
}
|
|
2099
2386
|
}
|
|
2100
2387
|
}
|
|
2388
|
+
// A focused atomic code box advertises its keyboard action. The source
|
|
2389
|
+
// line is restored immediately so cache/render data stays immutable.
|
|
2390
|
+
const selectedBlock = this.app.focused === this ? this.blockItems[this.blockSel] : null;
|
|
2391
|
+
let savedCodeLine = null;
|
|
2392
|
+
let codeCaretCol = null;
|
|
2393
|
+
if (selectedBlock?.kind === "code" && this.cursorMode === "normal") {
|
|
2394
|
+
savedCodeLine = this.lines[selectedBlock.headerLine];
|
|
2395
|
+
// Width-neutral swap: [按y复制] replaces [复制] padded to the exact
|
|
2396
|
+
// reserved field, so the right corner/border never shifts.
|
|
2397
|
+
this.lines[selectedBlock.headerLine] = savedCodeLine.map((seg) => seg.copyCode ? { ...seg, t: pad("[按y复制]", strWidth(seg.t ?? "")), fg: T.SELFG, bg: T.ACCENT } : seg);
|
|
2398
|
+
let col = 0;
|
|
2399
|
+
for (const seg of this.lines[selectedBlock.headerLine]) { col += strWidth(seg.t ?? ""); if (seg.copyCode) { codeCaretCol = col; break; } }
|
|
2400
|
+
}
|
|
2101
2401
|
this.view.render(screen);
|
|
2402
|
+
if (savedCodeLine) this.lines[selectedBlock.headerLine] = savedCodeLine;
|
|
2403
|
+
// Block mode has a two-cell gutter marker; cursor modes draw a one-cell
|
|
2404
|
+
// caret to the right of the current grapheme/code atom.
|
|
2405
|
+
if (this.app.focused === this && selectedBlock) {
|
|
2406
|
+
if (this.cursorMode === "block") {
|
|
2407
|
+
const row = selectedBlock.headerLine - this.view.scrollY;
|
|
2408
|
+
if (row >= 0 && row < this.view.h) screen.text(this.view.x, this.view.y + row, "=>", { fg: T.SELFG, bg: T.ACCENT, attrs: 1 });
|
|
2409
|
+
} else {
|
|
2410
|
+
const caretLine = selectedBlock.kind === "code" && this.cursorMode === "normal" ? selectedBlock.headerLine : this.cursor.line;
|
|
2411
|
+
const row = caretLine - this.view.scrollY;
|
|
2412
|
+
if (row >= 0 && row < this.view.h) {
|
|
2413
|
+
const stops = this.#cursorStops(caretLine);
|
|
2414
|
+
const stop = stops[this.#cursorStopIndex(stops)];
|
|
2415
|
+
const codeCol = selectedBlock.kind === "code" ? Math.min(this.view.w - 2, codeCaretCol ?? 0) : this.cursor.col + (stop?.width ?? 1);
|
|
2416
|
+
screen.put(this.view.x + Math.max(0, Math.min(this.view.w - 2, codeCol)), this.view.y + row, "|", { fg: T.SELFG, bg: T.ACCENT, attrs: 1 });
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2102
2420
|
this.#renderDiving(screen);
|
|
2103
2421
|
if (this.selStart !== null && this.selEnd !== null) {
|
|
2104
2422
|
const y0 = Math.max(this.view.scrollY, this.selStart);
|
|
@@ -2106,7 +2424,7 @@ export class ChatView extends Widget {
|
|
|
2106
2424
|
if (y1 >= y0) {
|
|
2107
2425
|
for (let line = y0; line <= y1; line++) {
|
|
2108
2426
|
let x0 = this.view.x, x1 = this.view.x + this.view.w - 2;
|
|
2109
|
-
if (this.
|
|
2427
|
+
if (this.selAnchor && this.selFocus) {
|
|
2110
2428
|
const a = this.selAnchor, b = this.selFocus;
|
|
2111
2429
|
const first = a.line < b.line || (a.line === b.line && a.col <= b.col) ? a : b;
|
|
2112
2430
|
const last = first === a ? b : a;
|
|
@@ -2170,7 +2488,7 @@ export class ChatView extends Widget {
|
|
|
2170
2488
|
if (subagent) {
|
|
2171
2489
|
const timing = this.app.projections.subagentTiming;
|
|
2172
2490
|
const ms = (timing?.settledMs ?? 0) + (timing?.active ? Math.max(0, Date.now() - timing.active.since) : 0);
|
|
2173
|
-
screen.text(this.x, row++, `
|
|
2491
|
+
screen.text(this.x, row++, ` ◇ 子代理 · ${subagent.label ?? subagent.mode}${ms ? ` · ${fmtDuration(ms)}` : ""}`, { fg: T.PURPLE, bg: T.STATUSBG, bold: true });
|
|
2174
2492
|
}
|
|
2175
2493
|
if (!todos.length) return;
|
|
2176
2494
|
const done = todos.filter((t) => t.status === "completed").length;
|
|
@@ -2260,7 +2578,7 @@ export class ChatView extends Widget {
|
|
|
2260
2578
|
this.pressInfo = null; this.pressCtx = null;
|
|
2261
2579
|
const rows = this.selEnd - this.selStart + 1;
|
|
2262
2580
|
let text;
|
|
2263
|
-
if (this.
|
|
2581
|
+
if (this.selAnchor && this.selFocus) {
|
|
2264
2582
|
const a = this.selAnchor, b = this.selFocus;
|
|
2265
2583
|
const first = a.line < b.line || (a.line === b.line && a.col <= b.col) ? a : b;
|
|
2266
2584
|
const last = first === a ? b : a;
|
|
@@ -2311,70 +2629,116 @@ export class ChatView extends Widget {
|
|
|
2311
2629
|
return false;
|
|
2312
2630
|
}
|
|
2313
2631
|
|
|
2632
|
+
/** Match one event against the editable transcript bindings. Two-press
|
|
2633
|
+
* chords arm `bindingPending`; any other key disarms it. */
|
|
2634
|
+
#matchChatBinding(ev) {
|
|
2635
|
+
if (ev.type !== "key" || this.app.focused !== this) { this.bindingPending = null; return null; }
|
|
2636
|
+
const bindings = keyBindings();
|
|
2637
|
+
for (const id of CHAT_BINDING_ORDER) {
|
|
2638
|
+
const spec = bindings[id];
|
|
2639
|
+
if (!spec || spec.mode === "insert") continue;
|
|
2640
|
+
const pending = this.bindingPending?.id === id ? this.bindingPending : null;
|
|
2641
|
+
const hit = matchKeyBinding(ev, spec, pending);
|
|
2642
|
+
if (hit?.kind === "pending") {
|
|
2643
|
+
this.bindingPending = { id, slot: hit.slot, part: hit.part };
|
|
2644
|
+
this.app.toast("再按一次完成组合键");
|
|
2645
|
+
return null;
|
|
2646
|
+
}
|
|
2647
|
+
if (hit?.kind === "full") { this.bindingPending = null; return { id }; }
|
|
2648
|
+
}
|
|
2649
|
+
this.bindingPending = null;
|
|
2650
|
+
return null;
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2314
2653
|
onKey(ev) {
|
|
2654
|
+
const blankWelcome = this.nodes.length === 0 && (this.app.sessions.find((s) => s.sessionId === this.sessionId)?.blank ?? false);
|
|
2655
|
+
if (blankWelcome && ev.type === "key" && (ev.name === "up" || ev.name === "down")) {
|
|
2656
|
+
this.welcomeModeSel = wrapIndex(this.welcomeModeSel + (ev.name === "up" ? -1 : 1), this.welcomeModeIds.length);
|
|
2657
|
+
return true;
|
|
2658
|
+
}
|
|
2659
|
+
if (blankWelcome && ev.type === "key" && ev.name === "enter") {
|
|
2660
|
+
this.app.selectPreset(this.welcomeModeIds[this.welcomeModeSel]);
|
|
2661
|
+
return true;
|
|
2662
|
+
}
|
|
2315
2663
|
if (ev.type === "text" || ev.type === "paste") {
|
|
2664
|
+
// In cursor/Visual modes text is never inserted into the transcript or
|
|
2665
|
+
// silently redirected to INSERT. Only explicit i enters the input editor.
|
|
2666
|
+
if (this.cursorMode !== "block") return true;
|
|
2316
2667
|
this.app.focus(this.input);
|
|
2317
2668
|
this.input.insert(ev.text);
|
|
2318
2669
|
return true;
|
|
2319
2670
|
}
|
|
2320
2671
|
if (ev.type !== "key") return false;
|
|
2321
2672
|
if (this.app.focused === this.input) return false;
|
|
2322
|
-
if (ev.name
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
}
|
|
2332
|
-
if (ev.name === "char" && ev.key === "g" && !ev.ctrl && !ev.alt && !ev.shift) {
|
|
2333
|
-
if (this.gKey) { this.gKey = false; this.view.anchorLock = null; this.view.follow = false; this.view.scrollY = 0; return true; }
|
|
2334
|
-
this.gKey = true;
|
|
2335
|
-
this.app.toast("再按 g 回顶");
|
|
2336
|
-
return true;
|
|
2673
|
+
if (ev.ctrl && (ev.name === "up" || ev.name === "down")) { this.view.scroll(ev.name === "up" ? -3 : 3); this.app.redraw(); return true; }
|
|
2674
|
+
if (ev.name === "up" || ev.name === "down") return this.#moveBlock(ev.name === "up" ? -1 : 1);
|
|
2675
|
+
if (ev.name === "pgup") { if (this.view.scrollY <= this.view.h) { void this.loadOlder(); return true; } return this.view.scroll(-this.view.h); }
|
|
2676
|
+
if (ev.name === "pgdn") return this.view.scroll(this.view.h);
|
|
2677
|
+
// Editable transcript bindings (two slots each): think/tools/insert/
|
|
2678
|
+
// top/bottom/prevQuestion/nextQuestion/sessionFilter.
|
|
2679
|
+
const chatHit = this.#matchChatBinding(ev);
|
|
2680
|
+
if (chatHit?.id === "top") {
|
|
2681
|
+
this.blockSel = 0; const item = this.blockItems[0]; if (item) { this.cursor = { line: item.headerLine, col: 0 }; this.#scrollToTranscriptLine(item.headerLine); } return true;
|
|
2337
2682
|
}
|
|
2338
|
-
if (
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2683
|
+
if (chatHit?.id === "bottom") {
|
|
2684
|
+
if (this.blockItems.length === 0) return true;
|
|
2685
|
+
// Vim G: the newest block gets the cursor, and its header lands at the
|
|
2686
|
+
// bottom of the viewport so the arrow is always visible with the tail.
|
|
2687
|
+
this.blockSel = this.blockItems.length - 1;
|
|
2688
|
+
const item = this.blockItems[this.blockSel];
|
|
2689
|
+
this.cursor = { line: item.headerLine, col: 0 };
|
|
2690
|
+
this.view.follow = false; this.view.anchorLock = null;
|
|
2691
|
+
this.view.scrollY = Math.max(0, Math.min(this.view.maxScroll(), item.headerLine - Math.max(1, this.view.h - 2)));
|
|
2692
|
+
this.app.redraw(); return true;
|
|
2343
2693
|
}
|
|
2344
|
-
if (
|
|
2345
|
-
if (
|
|
2346
|
-
if (
|
|
2347
|
-
if (
|
|
2348
|
-
|
|
2349
|
-
if (ev.name === "char" && ev.key === "b" && !ev.ctrl && !ev.alt) {
|
|
2350
|
-
this.bashMode = this.bashMode === "collapsed" ? "expanded" : "collapsed";
|
|
2694
|
+
if (chatHit?.id === "prevQuestion" || chatHit?.id === "nextQuestion") return this.#jumpQuestion(chatHit.id === "prevQuestion" ? -1 : 1);
|
|
2695
|
+
if (chatHit?.id === "insert") { this.app.focus(this.input); return true; }
|
|
2696
|
+
if (chatHit?.id === "sessionFilter") { this.app.startSearch(); return true; }
|
|
2697
|
+
if (chatHit?.id === "think" && this.cursorMode === "block") {
|
|
2698
|
+
this.thinkMode = this.thinkMode === "collapsed" ? "expanded" : "collapsed";
|
|
2351
2699
|
this.expanded.clear();
|
|
2352
2700
|
this.collapsedBlocks.clear();
|
|
2353
|
-
this.app.toast(this.
|
|
2701
|
+
this.app.toast(this.thinkMode === "expanded" ? "思考块:全部展开" : "思考块:折叠(t 切换)");
|
|
2354
2702
|
this.queueRebuild();
|
|
2355
2703
|
return true;
|
|
2356
2704
|
}
|
|
2357
|
-
if (
|
|
2358
|
-
|
|
2359
|
-
const wasPinned = this.view.follow || this.view.scrollY >= this.view.maxScroll();
|
|
2360
|
-
const oldMax = this.view.maxScroll();
|
|
2361
|
-
this.todosVisible = !this.todosVisible;
|
|
2362
|
-
this.app.toast(this.todosVisible ? "任务块:已展开(Shift+T 最小化)" : "任务块:已最小化(Shift+T 展开)");
|
|
2363
|
-
this.inputChanged();
|
|
2364
|
-
// Re-anchor immediately: tail readers stay at the tail; readers higher
|
|
2365
|
-
// in history keep the same visible transcript row without a manual nudge.
|
|
2366
|
-
if (wasPinned) { this.view.scrollY = this.view.maxScroll(); this.view.follow = true; }
|
|
2367
|
-
else this.view.scrollY = Math.max(0, Math.min(this.view.maxScroll(), this.view.scrollY + (this.view.maxScroll() - oldMax)));
|
|
2368
|
-
this.app.redraw();
|
|
2369
|
-
return true;
|
|
2370
|
-
}
|
|
2371
|
-
this.thinkMode = this.thinkMode === "collapsed" ? "expanded" : "collapsed";
|
|
2705
|
+
if (chatHit?.id === "tools" && this.cursorMode === "block") {
|
|
2706
|
+
this.bashMode = this.bashMode === "collapsed" ? "expanded" : "collapsed";
|
|
2372
2707
|
this.expanded.clear();
|
|
2373
2708
|
this.collapsedBlocks.clear();
|
|
2374
|
-
this.app.toast(this.
|
|
2709
|
+
this.app.toast(this.bashMode === "collapsed" ? "工具块:折叠(b 展开)" : "工具块:展开(b 折叠)");
|
|
2375
2710
|
this.queueRebuild();
|
|
2376
2711
|
return true;
|
|
2377
2712
|
}
|
|
2713
|
+
if (ev.name === "char" && ev.key === "t" && ev.shift && !ev.ctrl && !ev.alt && this.cursorMode === "block") {
|
|
2714
|
+
const wasPinned = this.view.follow || this.view.scrollY >= this.view.maxScroll();
|
|
2715
|
+
const oldMax = this.view.maxScroll();
|
|
2716
|
+
this.todosVisible = !this.todosVisible;
|
|
2717
|
+
this.app.toast(this.todosVisible ? "任务块:已展开(Shift+T 最小化)" : "任务块:已最小化(Shift+T 展开)");
|
|
2718
|
+
this.inputChanged();
|
|
2719
|
+
// Re-anchor immediately: tail readers stay at the tail; readers higher
|
|
2720
|
+
// in history keep the same visible transcript row without a manual nudge.
|
|
2721
|
+
if (wasPinned) { this.view.scrollY = this.view.maxScroll(); this.view.follow = true; }
|
|
2722
|
+
else this.view.scrollY = Math.max(0, Math.min(this.view.maxScroll(), this.view.scrollY + (this.view.maxScroll() - oldMax)));
|
|
2723
|
+
this.app.redraw();
|
|
2724
|
+
return true;
|
|
2725
|
+
}
|
|
2726
|
+
if (ev.name === "escape" && this.app.searchQuery) { this.app.searchQuery = null; this.queueRebuild(); return true; }
|
|
2727
|
+
if (ev.name === "escape" && this.cursorMode !== "block") { this.cursorMode = "block"; this.visualAnchor = null; this.#syncKeyboardSelection(); this.app.redraw(); return true; }
|
|
2728
|
+
if (ev.name === "escape" && this.selStart !== null) { this.selStart = this.selEnd = null; this.selAnchor = this.selFocus = null; this.app.redraw(); return true; }
|
|
2729
|
+
if (ev.name === "enter" && this.blockItems[this.blockSel]) { this.cursorMode = "normal"; const item = this.blockItems[this.blockSel]; this.cursor = { line: item.headerLine, col: Math.max(0, strWidth(this.#lineText(item.headerLine)) - 1) }; this.app.redraw(); return true; }
|
|
2730
|
+
if (ev.name === "char" && ev.key === "j" && !ev.ctrl && !ev.alt) return this.#moveBlock(1);
|
|
2731
|
+
if (ev.name === "char" && ev.key === "k" && !ev.ctrl && !ev.alt) return this.#moveBlock(-1);
|
|
2732
|
+
if (ev.name === "char" && ev.key === "h" && !ev.ctrl && !ev.alt && this.cursorMode !== "block") { if (this.cursorMode === "normal" && this.blockItems[this.blockSel]?.kind === "code") return true; return this.#moveCursorHorizontal(-1); }
|
|
2733
|
+
if (ev.name === "char" && ev.key === "l" && !ev.ctrl && !ev.alt && this.cursorMode !== "block") { if (this.cursorMode === "normal" && this.blockItems[this.blockSel]?.kind === "code") return true; return this.#moveCursorHorizontal(1); }
|
|
2734
|
+
if (ev.name === "char" && ["w", "b", "e"].includes(ev.key) && !ev.ctrl && !ev.alt && this.cursorMode !== "block") { if (this.cursorMode === "normal" && this.blockItems[this.blockSel]?.kind === "code") return true; return this.#wordMotion(ev.key); }
|
|
2735
|
+
if (ev.name === "char" && ev.key === "0" && !ev.ctrl && !ev.alt && this.cursorMode !== "block") { this.cursor.col = 0; this.#syncKeyboardSelection(); this.app.redraw(); return true; }
|
|
2736
|
+
if (ev.name === "char" && ev.key === "$" && !ev.ctrl && !ev.alt && this.cursorMode !== "block") { const stops = this.#cursorStops(); this.cursor.col = stops[stops.length - 1].col; this.#syncKeyboardSelection(); this.app.redraw(); return true; }
|
|
2737
|
+
if (ev.name === "char" && ev.key === "v" && !ev.ctrl && !ev.alt) { this.cursorMode = ev.shift ? "visual-line" : "visual"; this.visualAnchor = { ...this.cursor }; this.#syncKeyboardSelection(); this.app.toast(ev.shift ? "VISUAL LINE(只读)" : "VISUAL(只读)"); return true; }
|
|
2738
|
+
if (ev.name === "char" && ev.key === "y" && !ev.ctrl && !ev.alt) return this.#yankTranscript();
|
|
2739
|
+
if (ev.name === "char" && ev.key === "c" && ev.ctrl && ev.shift) return this.#yankTranscript();
|
|
2740
|
+
if (ev.name === "char" && ev.key === " " && !ev.ctrl && !ev.alt) { this.#toggleSelectedBlock(); return true; }
|
|
2741
|
+
if (ev.name === "char" && ev.key === "r" && ev.ctrl) return this.#openSelectedContextMenu();
|
|
2378
2742
|
return false;
|
|
2379
2743
|
}
|
|
2380
2744
|
}
|
|
@@ -2574,8 +2938,8 @@ export class QuestionPopup extends Popup {
|
|
|
2574
2938
|
if(this.customEditing&&ev.name==="right"){this.customCursor=Math.min(Array.from(draft.custom).length,this.customCursor+1);return true;}
|
|
2575
2939
|
if(this.customEditing&&ev.name==="home"){this.customCursor=0;return true;}
|
|
2576
2940
|
if(this.customEditing&&ev.name==="end"){this.customCursor=Array.from(draft.custom).length;return true;}
|
|
2577
|
-
if (ev.name === "up") { this.customEditing=false;this.selIdx =
|
|
2578
|
-
if (ev.name === "down") { this.customEditing=false;this.selIdx =
|
|
2941
|
+
if (ev.name === "up") { this.customEditing=false;this.selIdx = wrapIndex(this.selIdx - 1, choices); return true; }
|
|
2942
|
+
if (ev.name === "down") { this.customEditing=false;this.selIdx = wrapIndex(this.selIdx + 1, choices); return true; }
|
|
2579
2943
|
if (ev.name === "char" && ev.key === " " && count && this.selIdx<count) { this.#choose(this.selIdx); return true; }
|
|
2580
2944
|
if (ev.name === "backspace" && this.customEditing && this.customCursor>0) {const chars=Array.from(draft.custom);chars.splice(this.customCursor-1,1);draft.custom=chars.join("");this.customCursor--;return true;}
|
|
2581
2945
|
if(ev.name==="delete"&&this.customEditing){const chars=Array.from(draft.custom);if(this.customCursor<chars.length){chars.splice(this.customCursor,1);draft.custom=chars.join("");}return true;}
|
|
@@ -2695,7 +3059,6 @@ export class App {
|
|
|
2695
3059
|
this.sessionEpoch = 0;
|
|
2696
3060
|
this.refreshSessionsSeq = 0;
|
|
2697
3061
|
this.searchSeq = 0;
|
|
2698
|
-
this.searchSelected = 0;
|
|
2699
3062
|
this.connState = "connecting";
|
|
2700
3063
|
this.tokenUsage = null;
|
|
2701
3064
|
this.sessions = [];
|
|
@@ -2707,6 +3070,7 @@ export class App {
|
|
|
2707
3070
|
};
|
|
2708
3071
|
this.searchActive = false;
|
|
2709
3072
|
this.overlay = null; // Picker / Popup / ImagePopup modal
|
|
3073
|
+
this.fullBuffer = null; // full-screen panel buffer (workspace/settings/models/subagent/skills)
|
|
2710
3074
|
this.mode = "chat"; // chat | workspace | trajectory
|
|
2711
3075
|
this.sidebarWanted = true;
|
|
2712
3076
|
this.sidebarVisible = true; // auto-collapses on narrow terminals
|
|
@@ -2729,7 +3093,8 @@ export class App {
|
|
|
2729
3093
|
this.sidebar = new SidebarTree(this);
|
|
2730
3094
|
this.sidebar.w = this.sidebarWidth;
|
|
2731
3095
|
this.sidebar.h = screen.h - 1;
|
|
2732
|
-
this.searchInput = new Input({ x: 0, y: 0, w: this.sidebarWidth, h: 1, prompt: "/ ", placeholder: "
|
|
3096
|
+
this.searchInput = new Input({ x: 0, y: 0, w: this.sidebarWidth, h: 1, prompt: "/ ", placeholder: "输入跨会话全文查询,Enter 执行…" });
|
|
3097
|
+
this.searchState = null;
|
|
2733
3098
|
this.chat = new ChatView({ x: this.sidebarWidth, y: 0, w: screen.w - this.sidebarWidth, h: screen.h - 1, app: this });
|
|
2734
3099
|
this.status = new StatusBar({ x: 0, y: screen.h - 1, w: screen.w, h: 1 });
|
|
2735
3100
|
this.focus(this.chat);
|
|
@@ -2753,9 +3118,8 @@ export class App {
|
|
|
2753
3118
|
this.sidebar.x = 0; this.sidebar.y = 0; this.sidebar.w = this.sidebarWidth; this.sidebar.h = this.screen.h - 1;
|
|
2754
3119
|
this.searchInput.w = this.sidebarWidth;
|
|
2755
3120
|
this.chat.resize(x, 1, w, mainH);
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
}
|
|
3121
|
+
if (this.trajectoryPanel?.relayout) this.trajectoryPanel.relayout(x, 1, w, mainH);
|
|
3122
|
+
if (this.fullBuffer?.relayout) this.fullBuffer.relayout(0, 0, this.screen.w, this.screen.h);
|
|
2759
3123
|
this.status.y = this.screen.h - footerH;
|
|
2760
3124
|
this.status.h = footerH;
|
|
2761
3125
|
this.status.w = this.screen.w;
|
|
@@ -2772,6 +3136,27 @@ export class App {
|
|
|
2772
3136
|
this.setMode(this.mode === "trajectory" ? "chat" : "trajectory");
|
|
2773
3137
|
}
|
|
2774
3138
|
|
|
3139
|
+
/** tmux-style pane focus. The sequence wraps and skips unavailable panes. */
|
|
3140
|
+
focusPane(delta) {
|
|
3141
|
+
const panes = [];
|
|
3142
|
+
if (this.sidebarVisible) panes.push("sidebar");
|
|
3143
|
+
panes.push("chat");
|
|
3144
|
+
if (this.currentSession) panes.push("trajectory");
|
|
3145
|
+
const current = this.focused === this.sidebar ? "sidebar" : this.mode === "trajectory" ? "trajectory" : "chat";
|
|
3146
|
+
const next = panes[wrapIndex(Math.max(0, panes.indexOf(current)) + delta, panes.length)];
|
|
3147
|
+
if (next === "sidebar") {
|
|
3148
|
+
this.focus(this.sidebar);
|
|
3149
|
+
} else if (next === "trajectory") {
|
|
3150
|
+
this.setMode("trajectory");
|
|
3151
|
+
this.focus(this.trajectoryPanel ?? this.chat);
|
|
3152
|
+
} else {
|
|
3153
|
+
this.setMode("chat");
|
|
3154
|
+
this.focus(this.chat);
|
|
3155
|
+
}
|
|
3156
|
+
this.redraw();
|
|
3157
|
+
return true;
|
|
3158
|
+
}
|
|
3159
|
+
|
|
2775
3160
|
async checkUpdates(target = null, notify = false) {
|
|
2776
3161
|
const specs = {
|
|
2777
3162
|
dsh: { package: "@deepseek-ai/dsh", current: this.dshVersion, label: "DeepSeek Harness" },
|
|
@@ -3342,6 +3727,7 @@ export class App {
|
|
|
3342
3727
|
? { workspaceId: group.workspaceId }
|
|
3343
3728
|
: { cwd: group?.path ?? process.cwd() };
|
|
3344
3729
|
const { sessionId } = await this.api.call("session.create", payload);
|
|
3730
|
+
if (typeof sessionId !== "string" || !sessionId) throw new Error("Host 未返回会话 ID");
|
|
3345
3731
|
await this.refreshSessions();
|
|
3346
3732
|
this.openSession(sessionId);
|
|
3347
3733
|
} catch (e) { this.toast(`创建失败: ${e.message}`); }
|
|
@@ -3355,6 +3741,7 @@ export class App {
|
|
|
3355
3741
|
async newSession() { return this.newSessionIn(null); }
|
|
3356
3742
|
|
|
3357
3743
|
async openSession(sessionId) {
|
|
3744
|
+
if (typeof sessionId !== "string" || !sessionId) { this.toast("无法打开会话:缺少会话 ID"); return; }
|
|
3358
3745
|
const epoch = ++this.sessionEpoch;
|
|
3359
3746
|
this.currentSession = sessionId;
|
|
3360
3747
|
this.projections = { ...(this.projectionsBySession.get(sessionId) ?? {}) };
|
|
@@ -3374,6 +3761,12 @@ export class App {
|
|
|
3374
3761
|
}
|
|
3375
3762
|
await this.chat.open(sessionId, epoch);
|
|
3376
3763
|
if (epoch !== this.sessionEpoch || sessionId !== this.currentSession) return;
|
|
3764
|
+
// A sidebar Enter intentionally preserves sidebar focus, but every
|
|
3765
|
+
// session-scoped panel must immediately follow the newly opened session.
|
|
3766
|
+
if (this.mode === "trajectory" && this.trajectoryPanel) await this.trajectoryPanel.load(sessionId);
|
|
3767
|
+
else if (this.fullBuffer && this.fullBuffer === this.subagentPanel) this.subagentPanel.load(sessionId);
|
|
3768
|
+
else if (this.fullBuffer && this.fullBuffer === this.skillsPanel) this.skillsPanel.load?.(sessionId);
|
|
3769
|
+
if (epoch !== this.sessionEpoch || sessionId !== this.currentSession) return;
|
|
3377
3770
|
this.loadFeedback(sessionId, epoch);
|
|
3378
3771
|
this.updateModel(sessionId, epoch);
|
|
3379
3772
|
this.refreshSubagentStats(sessionId);
|
|
@@ -3412,47 +3805,50 @@ export class App {
|
|
|
3412
3805
|
}
|
|
3413
3806
|
|
|
3414
3807
|
setMode(mode) {
|
|
3415
|
-
this.mode = mode;
|
|
3416
|
-
if (mode === "
|
|
3417
|
-
if (!this.workspacePanel) {
|
|
3418
|
-
this.workspacePanel = new WorkspacePanel(this);
|
|
3419
|
-
this.workspacePanel.load();
|
|
3420
|
-
} else {
|
|
3421
|
-
this.workspacePanel.load();
|
|
3422
|
-
}
|
|
3423
|
-
} else if (mode === "trajectory") {
|
|
3808
|
+
this.mode = mode === "trajectory" ? "trajectory" : "chat";
|
|
3809
|
+
if (this.mode === "trajectory") {
|
|
3424
3810
|
if (!this.currentSession) { this.toast("先打开一个会话"); this.mode = "chat"; this.redraw(); return; }
|
|
3425
3811
|
if (!this.trajectoryPanel) this.trajectoryPanel = new TrajectoryPanel(this);
|
|
3426
3812
|
this.trajectoryPanel.load(this.currentSession);
|
|
3427
|
-
} else if (mode === "settings") {
|
|
3428
|
-
if (!this.settingsPanel) this.settingsPanel = new SettingsPanel(this);
|
|
3429
|
-
this.settingsPanel.load();
|
|
3430
|
-
} else if (mode === "models") {
|
|
3431
|
-
if (!this.modelPanel) this.modelPanel = new ModelPanel(this);
|
|
3432
|
-
this.modelPanel.load();
|
|
3433
|
-
} else if (mode === "subagent") {
|
|
3434
|
-
if (!this.currentSession) { this.toast("先打开一个会话"); this.mode = "chat"; this.redraw(); return; }
|
|
3435
|
-
if (!this.subagentPanel) this.subagentPanel = new SubagentPanel(this);
|
|
3436
|
-
this.subagentPanel.load(this.currentSession);
|
|
3437
|
-
} else if (mode === "skills") {
|
|
3438
|
-
if (!this.currentSession) { this.toast("先打开一个会话"); this.mode = "chat"; this.redraw(); return; }
|
|
3439
|
-
if (!this.skillsPanel) this.skillsPanel = new SkillsPanel(this);
|
|
3440
|
-
this.skillsPanel.load();
|
|
3441
3813
|
}
|
|
3814
|
+
const panel = this.panelForMode();
|
|
3815
|
+
if (panel && this.focused !== this.sidebar) this.focus(panel);
|
|
3816
|
+
else if (this.focused !== this.sidebar) this.focus(this.chat);
|
|
3442
3817
|
this.layout();
|
|
3443
3818
|
this.redraw();
|
|
3444
3819
|
}
|
|
3445
3820
|
|
|
3446
|
-
panelForMode() {
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3821
|
+
panelForMode() { return this.mode === "trajectory" ? this.trajectoryPanel : null; }
|
|
3822
|
+
|
|
3823
|
+
/** Full-screen modal buffers replace the old tab-page modes: they coexist
|
|
3824
|
+
* with the sidebar/chat/trajectory pane focus instead of fighting it. */
|
|
3825
|
+
openFullBuffer(panel) {
|
|
3826
|
+
if (!panel) return;
|
|
3827
|
+
this.fullBuffer = panel;
|
|
3828
|
+
panel.relayout(0, 0, this.screen.w, this.screen.h);
|
|
3829
|
+
this.focus(panel);
|
|
3830
|
+
this.redraw();
|
|
3831
|
+
}
|
|
3832
|
+
closeFullBuffer() {
|
|
3833
|
+
if (!this.fullBuffer) return true;
|
|
3834
|
+
this.fullBuffer = null;
|
|
3835
|
+
this.focus(this.chat);
|
|
3836
|
+
this.layout();
|
|
3837
|
+
this.redraw();
|
|
3838
|
+
return true;
|
|
3839
|
+
}
|
|
3840
|
+
showWorkspaceBuffer() { if (!this.workspacePanel) this.workspacePanel = new WorkspacePanel(this); this.openFullBuffer(this.workspacePanel); this.workspacePanel.load(); }
|
|
3841
|
+
showSettingsBuffer() { if (!this.settingsPanel) this.settingsPanel = new SettingsPanel(this); this.openFullBuffer(this.settingsPanel); this.settingsPanel.load(); }
|
|
3842
|
+
showModelsBuffer() { if (!this.modelPanel) this.modelPanel = new ModelPanel(this); this.openFullBuffer(this.modelPanel); this.modelPanel.load(); }
|
|
3843
|
+
showSubagentBuffer() {
|
|
3844
|
+
if (!this.currentSession) { this.toast("先打开一个会话"); return; }
|
|
3845
|
+
if (!this.subagentPanel) this.subagentPanel = new SubagentPanel(this);
|
|
3846
|
+
this.openFullBuffer(this.subagentPanel); this.subagentPanel.load(this.currentSession);
|
|
3847
|
+
}
|
|
3848
|
+
showSkillsBuffer() {
|
|
3849
|
+
if (!this.currentSession) { this.toast("先打开一个会话"); return; }
|
|
3850
|
+
if (!this.skillsPanel) this.skillsPanel = new SkillsPanel(this);
|
|
3851
|
+
this.openFullBuffer(this.skillsPanel); this.skillsPanel.load();
|
|
3456
3852
|
}
|
|
3457
3853
|
|
|
3458
3854
|
closeOverlay() { this.overlay = null; this.redraw(); }
|
|
@@ -3514,7 +3910,6 @@ export class App {
|
|
|
3514
3910
|
this.chat.nodes = [];
|
|
3515
3911
|
this.chat.collapsedBlocks.clear();
|
|
3516
3912
|
this.chat.expanded.clear();
|
|
3517
|
-
this.chat.expandedTools.clear();
|
|
3518
3913
|
this.chat.queueRebuild();
|
|
3519
3914
|
this.chat.view.anchorLock = null;
|
|
3520
3915
|
this.mode = "chat";
|
|
@@ -3542,7 +3937,7 @@ export class App {
|
|
|
3542
3937
|
const argv = process.argv.slice(1);
|
|
3543
3938
|
// hand the CURRENT session to the new instance so it reopens it instead
|
|
3544
3939
|
// of minting a fresh blank session (the "strange new session" in 未分组)
|
|
3545
|
-
const env = { ...process.env, DSH_TUI_RESUME_SESSION: this.currentSession ?? "", DSH_TUI_RESUME_SCROLL: String(this.chat?.view?.scrollY ?? 0), DSH_TUI_RESUME_FOLLOW: this.chat?.view?.follow ? "1" : "0" };
|
|
3940
|
+
const env = { ...process.env, DSH_TUI_RESTART_HANDOFF: "1", DSH_TUI_RESUME_SESSION: this.currentSession ?? "", DSH_TUI_RESUME_SCROLL: String(this.chat?.view?.scrollY ?? 0), DSH_TUI_RESUME_FOLLOW: this.chat?.view?.follow ? "1" : "0" };
|
|
3546
3941
|
const child = spawn("sh", ["-c", 'sleep 1; exec "$@"', "sh", ...argv], { detached: true, stdio: "inherit", env });
|
|
3547
3942
|
child.unref();
|
|
3548
3943
|
} catch (e) {
|
|
@@ -3654,7 +4049,7 @@ export class App {
|
|
|
3654
4049
|
} catch (e) { this.toast(`权限切换失败: ${e.message}`); }
|
|
3655
4050
|
}
|
|
3656
4051
|
|
|
3657
|
-
/**
|
|
4052
|
+
/** F8: cycle read-only → workspace-write → danger-full-access. */
|
|
3658
4053
|
rotatePermission() {
|
|
3659
4054
|
const order = ["read-only", "workspace-write", "danger-full-access"];
|
|
3660
4055
|
const cur = this.projections.permissions?.currentValue;
|
|
@@ -3663,6 +4058,58 @@ export class App {
|
|
|
3663
4058
|
this.switchPermission(next);
|
|
3664
4059
|
}
|
|
3665
4060
|
|
|
4061
|
+
/** Execute an editable global binding (two slots per id). */
|
|
4062
|
+
#runBinding(id, slot) {
|
|
4063
|
+
switch (id) {
|
|
4064
|
+
case "sessionFilter": this.startSearch(); this.redraw(); return true;
|
|
4065
|
+
case "panel": this.overlay = new ControlPanel(this, { startPage: 0 }); this.redraw(); return true;
|
|
4066
|
+
case "homeSwitch": this.focusPane(slot === "key" ? -1 : 1); return true;
|
|
4067
|
+
case "permissionRotate": this.rotatePermission(); return true;
|
|
4068
|
+
case "editConfig": this.editConfigFile(); return true;
|
|
4069
|
+
case "quit": this.stop(); return true;
|
|
4070
|
+
case "model": this.overlay = buildModelPicker(this); this.redraw(); return true;
|
|
4071
|
+
case "trajectory": this.setMode("trajectory"); return true;
|
|
4072
|
+
case "workspace": this.showWorkspaceBuffer(); return true;
|
|
4073
|
+
case "settings": this.showSettingsBuffer(); return true;
|
|
4074
|
+
case "subagent": this.showSubagentBuffer(); return true;
|
|
4075
|
+
case "skills": this.showSkillsBuffer(); return true;
|
|
4076
|
+
case "goal": this.showGoal(); return true;
|
|
4077
|
+
case "jobs": this.showJobs(); return true;
|
|
4078
|
+
case "queue": this.showQueue(); return true;
|
|
4079
|
+
case "busyEnter": {
|
|
4080
|
+
const next = busyEnter() === "queue" ? "steer" : "queue";
|
|
4081
|
+
saveTuiConfig({ busyEnter: next });
|
|
4082
|
+
this.toast(`运行中 Enter:${next === "steer" ? "追加到当前回合" : "加入队列"}`);
|
|
4083
|
+
return true;
|
|
4084
|
+
}
|
|
4085
|
+
case "attachments": this.overlay = new AttachmentPanel(this); this.focus(this.overlay); this.redraw(); return true;
|
|
4086
|
+
case "stepJump": this.quickJumpStep(); return true;
|
|
4087
|
+
case "sidebar": this.toggleSidebar(); return true;
|
|
4088
|
+
default: return false;
|
|
4089
|
+
}
|
|
4090
|
+
}
|
|
4091
|
+
|
|
4092
|
+
/** Ctrl+K: open tui-config.json in $EDITOR (default editor). The terminal is
|
|
4093
|
+
* restored around the editor, then re-entered; the config cache is dropped
|
|
4094
|
+
* so the new bindings apply immediately. */
|
|
4095
|
+
async editConfigFile() {
|
|
4096
|
+
const file = tuiConfigFile();
|
|
4097
|
+
const editor = process.env.EDITOR || process.env.VISUAL || "vi";
|
|
4098
|
+
this.toast(`在 ${editor} 中打开 ${file}…`);
|
|
4099
|
+
this.redraw();
|
|
4100
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
4101
|
+
try { this.term?.stop?.(); } catch {}
|
|
4102
|
+
try { this.spawnEditor(file, editor); } catch (e) { this.toast(`编辑器启动失败: ${e.message}`); }
|
|
4103
|
+
try { this.term?.start?.(); } catch {}
|
|
4104
|
+
reloadTuiConfig();
|
|
4105
|
+
this.layout(); this.redraw();
|
|
4106
|
+
this.toast("配置编辑完成;快捷键已重新加载");
|
|
4107
|
+
}
|
|
4108
|
+
spawnEditor(file, editor) {
|
|
4109
|
+
const [cmd, ...args] = String(editor).split(/\s+/).filter(Boolean);
|
|
4110
|
+
spawnSync(cmd, [...args, file], { stdio: "inherit" });
|
|
4111
|
+
}
|
|
4112
|
+
|
|
3666
4113
|
showFilePicker() {
|
|
3667
4114
|
const session = this.sessions.find((s) => s.sessionId === this.currentSession);
|
|
3668
4115
|
this.overlay = new UploadPicker(this, { startPath: session?.cwd ?? process.cwd(), onUpload: (files) => {
|
|
@@ -3689,27 +4136,22 @@ export class App {
|
|
|
3689
4136
|
}
|
|
3690
4137
|
|
|
3691
4138
|
#modeTabs() {
|
|
3692
|
-
//
|
|
4139
|
+
// Ctrl+Left/Right pane targets. The other panels are full-screen buffers.
|
|
3693
4140
|
return [
|
|
3694
4141
|
["chat", "对话"],
|
|
3695
4142
|
["trajectory", "轨迹"],
|
|
3696
4143
|
];
|
|
3697
4144
|
}
|
|
3698
4145
|
|
|
3699
|
-
#panelLabel(mode) {
|
|
3700
|
-
return { workspace: "工作区", settings: "设置", models: "模型供应商", skills: "技能", subagent: "子代理" }[mode] ?? null;
|
|
3701
|
-
}
|
|
3702
|
-
|
|
3703
4146
|
#renderTabBar(s) {
|
|
3704
4147
|
const x = this.sidebarVisible ? this.sidebarWidth : 0;
|
|
3705
4148
|
const w = this.screen.w - x;
|
|
3706
4149
|
s.fillRect(x, 0, x + w - 1, 0, " ", { bg: T.PANEL });
|
|
3707
4150
|
const tabs = [...this.#modeTabs()];
|
|
3708
|
-
const panelLabel = this.#panelLabel(this.mode);
|
|
3709
|
-
if (panelLabel) tabs.push([this.mode, panelLabel]);
|
|
3710
4151
|
let tx = x;
|
|
4152
|
+
const sidebarFocused = this.focused === this.sidebar;
|
|
3711
4153
|
for (const [id, label] of tabs) {
|
|
3712
|
-
const sel =
|
|
4154
|
+
const sel = !sidebarFocused && id === this.mode;
|
|
3713
4155
|
const seg = ` ${label} `;
|
|
3714
4156
|
s.text(tx, 0, seg, { fg: sel ? T.SELFG : T.DIM, bg: sel ? T.ACCENT : T.PANEL, attrs: sel ? 1 : 0 });
|
|
3715
4157
|
tx += strWidth(seg);
|
|
@@ -3720,14 +4162,11 @@ export class App {
|
|
|
3720
4162
|
#clickTab(px) {
|
|
3721
4163
|
const x = this.sidebarVisible ? this.sidebarWidth : 0;
|
|
3722
4164
|
const tabs = [...this.#modeTabs()];
|
|
3723
|
-
const panelLabel = this.#panelLabel(this.mode);
|
|
3724
|
-
if (panelLabel) tabs.push([this.mode, panelLabel]);
|
|
3725
4165
|
let tx = x;
|
|
3726
4166
|
for (const [id, label] of tabs) {
|
|
3727
4167
|
const seg = ` ${label} `;
|
|
3728
4168
|
if (px >= tx && px < tx + strWidth(seg)) {
|
|
3729
|
-
|
|
3730
|
-
this.setMode(id === "chat" || id === "trajectory" || this.#panelLabel(id) ? id : "chat");
|
|
4169
|
+
this.setMode(id);
|
|
3731
4170
|
return true;
|
|
3732
4171
|
}
|
|
3733
4172
|
tx += strWidth(seg);
|
|
@@ -3808,6 +4247,21 @@ export class App {
|
|
|
3808
4247
|
this.redraw();
|
|
3809
4248
|
return;
|
|
3810
4249
|
}
|
|
4250
|
+
// Full-screen panel buffers (workspace/settings/models/subagent/skills)
|
|
4251
|
+
// are modal surfaces over the main area. Pane cycling (Ctrl+Left/Right)
|
|
4252
|
+
// works again the moment Esc closes the buffer.
|
|
4253
|
+
if (this.fullBuffer) {
|
|
4254
|
+
if (ev.type === "mouse") {
|
|
4255
|
+
if (this.fullBuffer.onMouse?.(ev)) this.redraw();
|
|
4256
|
+
} else {
|
|
4257
|
+
const handled = this.fullBuffer.onKey?.(ev);
|
|
4258
|
+
// Panels exit level by level; when the top level declines Escape the
|
|
4259
|
+
// App closes the buffer, restoring the main area's pane focus.
|
|
4260
|
+
if (ev.type === "key" && ev.name === "escape" && !handled) this.closeFullBuffer();
|
|
4261
|
+
else this.redraw();
|
|
4262
|
+
}
|
|
4263
|
+
return;
|
|
4264
|
+
}
|
|
3811
4265
|
|
|
3812
4266
|
// tab bar clicks (row 0 of the main area)
|
|
3813
4267
|
if (ev.type === "mouse" && ev.kind === "press" && ev.button === 0 && ev.y === 0 && ev.x >= (this.sidebarVisible ? this.sidebarWidth : 0)) {
|
|
@@ -3821,11 +4275,15 @@ export class App {
|
|
|
3821
4275
|
return;
|
|
3822
4276
|
}
|
|
3823
4277
|
const panel = this.panelForMode();
|
|
3824
|
-
|
|
3825
|
-
|
|
4278
|
+
const paneSwitch = ev.type === "key" && ev.ctrl && (ev.name === "left" || ev.name === "right");
|
|
4279
|
+
if (panel && this.focused !== this.sidebar && !paneSwitch) {
|
|
4280
|
+
const handled = ev.type === "key" || ev.type === "text" || ev.type === "paste" ? panel.onKey(ev) : panel.onMouse(ev);
|
|
3826
4281
|
if (handled) { this.redraw(); return; }
|
|
4282
|
+
// A visible modal panel owns non-global text/paste even when it declines
|
|
4283
|
+
// the event; never leak it into the hidden chat/Input behind the panel.
|
|
4284
|
+
if (ev.type === "text" || ev.type === "paste") { this.redraw(); return; }
|
|
3827
4285
|
}
|
|
3828
|
-
// unhandled
|
|
4286
|
+
// unhandled key events fall through to global shortcuts
|
|
3829
4287
|
}
|
|
3830
4288
|
if (ev.type === "mouse") {
|
|
3831
4289
|
// input drag-selection: the gesture continues across motion events
|
|
@@ -3881,7 +4339,7 @@ export class App {
|
|
|
3881
4339
|
} else if (this.chat.inside(ev.x, ev.y)) {
|
|
3882
4340
|
if (this.focused !== this.chat.input) this.focus(this.chat); // INSERT exits only via Esc
|
|
3883
4341
|
if (this.chat.onMouse(ev)) this.redraw();
|
|
3884
|
-
} else if (this.focused?.onMouse(ev)) {
|
|
4342
|
+
} else if (this.focused?.onMouse?.(ev)) {
|
|
3885
4343
|
this.redraw();
|
|
3886
4344
|
}
|
|
3887
4345
|
return;
|
|
@@ -3918,25 +4376,21 @@ export class App {
|
|
|
3918
4376
|
this.redraw();
|
|
3919
4377
|
return;
|
|
3920
4378
|
}
|
|
3921
|
-
if (this.searchActive && (ev.ctrl && ev.key === " " || ev.name === "f7")) {
|
|
3922
|
-
this.overlay = new ControlPanel(this, { startPage: 0 });
|
|
3923
|
-
this.redraw();
|
|
3924
|
-
return;
|
|
3925
|
-
}
|
|
3926
4379
|
if (this.searchActive) {
|
|
3927
4380
|
this.#onSearchKey(ev);
|
|
3928
4381
|
this.redraw();
|
|
3929
4382
|
return;
|
|
3930
4383
|
}
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
4384
|
+
// Editable global bindings: two slots per function, resolved by the
|
|
4385
|
+
// keybindings registry (tui-config.json keyBindings.<id>).
|
|
4386
|
+
const hit = bindingMatchFor(ev, keyBindings(), false, KEYBINDING_ORDER);
|
|
4387
|
+
if (hit && this.#runBinding(hit.id, hit.slot)) return;
|
|
4388
|
+
if (ev.ctrl && ev.shift && ev.key === "c") {
|
|
4389
|
+
if (this.focused === this.chat) this.chat.onKey(ev);
|
|
4390
|
+
else this.toast("请先在正文中选择要复制的内容");
|
|
4391
|
+
this.redraw(); return;
|
|
3935
4392
|
}
|
|
3936
|
-
if (ev.
|
|
3937
|
-
if (ev.name === "backtab" && !ev.ctrl) { this.rotatePermission(); return; }
|
|
3938
|
-
if (ev.ctrl && ev.key === "q") { this.stop(); return; }
|
|
3939
|
-
if (ev.ctrl && ev.key === "c") {
|
|
4393
|
+
if (ev.ctrl && ev.key === "c" && !ev.shift) {
|
|
3940
4394
|
// NORMAL-mode Ctrl+C: two presses within the toast window exit the
|
|
3941
4395
|
// process; the first press just warns (insert mode owns Ctrl+C for
|
|
3942
4396
|
// clearing the input).
|
|
@@ -3947,33 +4401,9 @@ export class App {
|
|
|
3947
4401
|
this.toast("再按一次 Ctrl+C 退出 TUI");
|
|
3948
4402
|
return;
|
|
3949
4403
|
}
|
|
3950
|
-
if (ev.ctrl && ev.key === "
|
|
3951
|
-
if (ev.ctrl && ev.key === "b") { this.toggleSidebar(); return; }
|
|
4404
|
+
if (ev.ctrl && ev.shift && ev.key === "w") { this.addWorkspace(); return; }
|
|
3952
4405
|
if (ev.ctrl && ev.key === "p") { this.overlay = new ControlPanel(this, { startPage: 1 }); this.redraw(); return; }
|
|
3953
|
-
if (ev.ctrl && ev.key === "m") { this.overlay = buildModelPicker(this); this.redraw(); return; }
|
|
3954
|
-
if (ev.name === "f8") { this.rotatePermission(); return; }
|
|
3955
4406
|
if (ev.name === "f9") { this.showModePicker(); return; }
|
|
3956
|
-
if (ev.ctrl && ev.key === "w") {
|
|
3957
|
-
if (ev.shift) { this.addWorkspace(); return; }
|
|
3958
|
-
this.setMode("workspace"); return;
|
|
3959
|
-
}
|
|
3960
|
-
if (ev.ctrl && ev.key === "t") { this.setMode("trajectory"); return; }
|
|
3961
|
-
if (ev.ctrl && ev.key === "e") { this.quickJumpStep(); return; }
|
|
3962
|
-
if (ev.ctrl && ev.key === "j") { this.showJobs(); return; }
|
|
3963
|
-
if (ev.ctrl && ev.key === "n") { this.showQueue(); return; }
|
|
3964
|
-
if (ev.ctrl && ev.key === "y") {
|
|
3965
|
-
const next = busyEnter() === "queue" ? "steer" : "queue";
|
|
3966
|
-
saveTuiConfig({ busyEnter: next });
|
|
3967
|
-
this.toast(`运行中 Enter:${next === "steer" ? "追加到当前回合" : "加入队列"}`);
|
|
3968
|
-
return;
|
|
3969
|
-
}
|
|
3970
|
-
if (ev.ctrl && ev.key === "g") { this.showGoal(); return; }
|
|
3971
|
-
if (ev.ctrl && ev.key === "f") { this.startSearch(); return; }
|
|
3972
|
-
if (ev.ctrl && ev.key === "s") { this.setMode("settings"); return; }
|
|
3973
|
-
if (ev.ctrl && ev.key === "a") { this.setMode("subagent"); return; }
|
|
3974
|
-
if (ev.ctrl && ev.key === "k") { this.setMode("skills"); return; }
|
|
3975
|
-
if (ev.name === "char" && ev.key === "/" && !ev.ctrl && this.focused !== this.chat.input) { this.startSearch(); this.redraw(); return; }
|
|
3976
|
-
if (ev.name === "char" && ev.key === "n" && !ev.ctrl && this.focused === this.sidebar) { this.newSession(); return; }
|
|
3977
4407
|
if (ev.name === "escape") {
|
|
3978
4408
|
// Esc in NORMAL mode interrupts a running turn (one press, regardless
|
|
3979
4409
|
// of focus); otherwise it steps back toward the chat view.
|
|
@@ -3982,28 +4412,28 @@ export class App {
|
|
|
3982
4412
|
else if (this.mode !== "chat") this.setMode("chat");
|
|
3983
4413
|
return;
|
|
3984
4414
|
}
|
|
3985
|
-
if (ev.name === "char" && ev.key === "i" && this.focused === this.sidebar) { this.focus(this.chat.input); this.redraw(); return; }
|
|
3986
4415
|
}
|
|
3987
4416
|
// nvim-style normal mode: single chars are shortcuts (chat first, then the
|
|
3988
4417
|
// focused pane). Multi-char text (paste/IME) still types into the input.
|
|
3989
4418
|
if ((ev.type === "text" || ev.type === "paste") && this.focused !== this.chat.input) {
|
|
3990
4419
|
if (this.searchActive) {
|
|
3991
|
-
this
|
|
3992
|
-
this.#refreshSearch();
|
|
4420
|
+
this.#onSearchKey(ev);
|
|
3993
4421
|
this.redraw();
|
|
3994
4422
|
return;
|
|
3995
4423
|
}
|
|
3996
|
-
if (ev.text.length === 1) {
|
|
3997
|
-
// Legacy terminals deliver Shift+letter
|
|
3998
|
-
//
|
|
3999
|
-
//
|
|
4424
|
+
if (graphemes(ev.text).length === 1) {
|
|
4425
|
+
// Legacy terminals deliver Shift+letter and Space as text. Route to
|
|
4426
|
+
// the focused pane first — a focused sidebar must never mutate the
|
|
4427
|
+
// hidden chat behind it — then fall back to transcript NORMAL keys.
|
|
4428
|
+
const text = graphemes(ev.text)[0];
|
|
4000
4429
|
const asKey = {
|
|
4001
4430
|
type: "key", name: "char",
|
|
4002
|
-
key:
|
|
4003
|
-
ctrl: false, alt: false, shift:
|
|
4431
|
+
key: text.toLowerCase(), text,
|
|
4432
|
+
ctrl: false, alt: false, shift: text !== text.toLowerCase(),
|
|
4004
4433
|
};
|
|
4434
|
+
if (this.focused && this.focused !== this.chat && this.focused !== this.chat.input && this.focused.onKey?.(asKey)) { this.redraw(); return; }
|
|
4005
4435
|
if (this.chat.onKey(asKey)) { this.redraw(); return; }
|
|
4006
|
-
if (this.focused && this.focused !== this.chat.input && this.focused.onKey(asKey)) { this.redraw(); return; }
|
|
4436
|
+
if (this.focused && this.focused !== this.chat.input && this.focused.onKey?.(asKey)) { this.redraw(); return; }
|
|
4007
4437
|
this.toast("按 i 进入输入");
|
|
4008
4438
|
return;
|
|
4009
4439
|
}
|
|
@@ -4014,47 +4444,249 @@ export class App {
|
|
|
4014
4444
|
}
|
|
4015
4445
|
// focused widget
|
|
4016
4446
|
if (this.focused) {
|
|
4017
|
-
const handled = ev.type === "mouse" ? this.focused.onMouse(ev) : this.focused.onKey(ev);
|
|
4447
|
+
const handled = ev.type === "mouse" ? this.focused.onMouse?.(ev) : this.focused.onKey?.(ev);
|
|
4018
4448
|
if (handled) this.redraw();
|
|
4019
4449
|
}
|
|
4020
4450
|
}
|
|
4021
4451
|
|
|
4022
4452
|
startSearch() {
|
|
4453
|
+
this.searchSeq++;
|
|
4023
4454
|
this.searchActive = true;
|
|
4024
4455
|
this.searchInput.setValue("");
|
|
4025
|
-
this.
|
|
4456
|
+
this.searchState = { phase: "input", query: "", rows: [], selected: 0, collapsed: new Set(), typeFold: new Set(), preview: [], previewScroll: 0, loading: false, hasMore: false, fallback: false, fallbackError: null };
|
|
4026
4457
|
this.focus(this.searchInput);
|
|
4458
|
+
this.redraw();
|
|
4027
4459
|
}
|
|
4028
4460
|
|
|
4029
|
-
#
|
|
4030
|
-
const
|
|
4031
|
-
|
|
4032
|
-
const fuzzy = (text) => { let i=0; for(const ch of String(text).toLowerCase()) if(ch===query[i]) i++; return i===query.length; };
|
|
4033
|
-
this.searchResults = this.sessions.filter((s) => !query || fuzzy(`${s.projections?.values?.title ?? ""} ${s.cwd ?? ""} ${s.sessionId}`)).map((s) => ({ sessionId:s.sessionId, title:s.projections?.values?.title ?? s.sessionId.slice(0,8), snippet:s.cwd ?? "" }));
|
|
4461
|
+
#searchWorkspaceFor(sessionId) {
|
|
4462
|
+
const ws = (this.workspaceItems ?? []).find((item) => (item.sessionIds ?? []).includes(sessionId));
|
|
4463
|
+
return ws ? { key: ws.workspaceId ?? ws.id ?? ws.path, title: ws.title ?? ws.name ?? ws.path ?? "工作区" } : { key: "ungrouped", title: "未分组" };
|
|
4034
4464
|
}
|
|
4035
4465
|
|
|
4036
|
-
#
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4466
|
+
#searchBlockText(node, block = null) {
|
|
4467
|
+
if (!block) return String(node?.text ?? "");
|
|
4468
|
+
const fields = [block.name, block.text, block.args, block.result];
|
|
4469
|
+
return fields.filter((value) => value != null && value !== "").map((value) => {
|
|
4470
|
+
if (typeof value === "string") return value;
|
|
4471
|
+
try { return JSON.stringify(value); } catch { return String(value); }
|
|
4472
|
+
}).join("\n");
|
|
4473
|
+
}
|
|
4474
|
+
|
|
4475
|
+
#mergeHistoryEvents(older, newer) {
|
|
4476
|
+
const bySeq = new Map();
|
|
4477
|
+
for (const wrapped of [...(older ?? []), ...(newer ?? [])]) {
|
|
4478
|
+
const seq = wrapped?.event?.seq;
|
|
4479
|
+
if (seq == null) continue;
|
|
4480
|
+
bySeq.set(seq, wrapped);
|
|
4481
|
+
}
|
|
4482
|
+
return [...bySeq.values()].sort((a, b) => a.event.seq - b.event.seq);
|
|
4483
|
+
}
|
|
4484
|
+
|
|
4485
|
+
/** Resolve one Host search hit into session-level matches. `deep` pages back
|
|
4486
|
+
* toward the FTS hit; fallback scans stay on the single tail page. */
|
|
4487
|
+
async #resolveSearchSession(sessionId, snippet, lower, seq, state, { deep = true } = {}) {
|
|
4488
|
+
let history;
|
|
4489
|
+
try { history = await this.api.call("session.history", { sessionId, maxMessages: 80 }); }
|
|
4490
|
+
catch { history = { events: [], hasMore: false }; }
|
|
4491
|
+
if (seq !== this.searchSeq || !this.searchActive || this.searchState !== state) return null;
|
|
4492
|
+
let allEvents = this.#mergeHistoryEvents([], history.events);
|
|
4493
|
+
const contains = (list) => list.some((node) => node.kind === "assistant" ? (node.blocks ?? []).some((block) => this.#searchBlockText(node, block).toLowerCase().includes(lower)) : this.#searchBlockText(node).toLowerCase().includes(lower));
|
|
4494
|
+
let resolved = contains(nodeForEvents(allEvents, this.log));
|
|
4495
|
+
// Probe each bounded page independently, then derive the accumulated
|
|
4496
|
+
// window once. Rebuilding an ever-growing history on every page made a
|
|
4497
|
+
// deep search quadratic while adding no useful precision.
|
|
4498
|
+
for (let page = 0; deep && !resolved && history.hasMore && page < 40; page++) {
|
|
4499
|
+
const beforeSeq = allEvents[0]?.event?.seq;
|
|
4500
|
+
if (beforeSeq == null) break;
|
|
4501
|
+
const older = await this.api.call("session.history", { sessionId, beforeSeq, maxMessages: 80 });
|
|
4502
|
+
if (seq !== this.searchSeq || !this.searchActive || this.searchState !== state) return null;
|
|
4503
|
+
const merged = this.#mergeHistoryEvents(older.events, allEvents);
|
|
4504
|
+
const newBeforeSeq = merged[0]?.event?.seq;
|
|
4505
|
+
if (!older.events?.length || newBeforeSeq == null || newBeforeSeq >= beforeSeq) { history = { ...history, hasMore: false }; break; }
|
|
4506
|
+
resolved = contains(nodeForEvents(older.events, this.log));
|
|
4507
|
+
allEvents = merged; history = { ...older, events: allEvents };
|
|
4508
|
+
}
|
|
4509
|
+
const nodes = nodeForEvents(allEvents, this.log);
|
|
4510
|
+
const matches = [];
|
|
4511
|
+
for (let ni = 0; ni < nodes.length; ni++) {
|
|
4512
|
+
const node = nodes[ni];
|
|
4513
|
+
if (node.kind === "assistant") {
|
|
4514
|
+
for (let bi = 0; bi < (node.blocks ?? []).length; bi++) {
|
|
4515
|
+
const block = node.blocks[bi], text = this.#searchBlockText(node, block);
|
|
4516
|
+
if (text.toLowerCase().includes(lower)) matches.push({ nodeIdx: ni, blockIdx: bi, kind: block.kind, text, seq: node.firstSeq ?? node.lastSeq });
|
|
4517
|
+
}
|
|
4518
|
+
} else {
|
|
4519
|
+
const text = this.#searchBlockText(node);
|
|
4520
|
+
if (text.toLowerCase().includes(lower)) matches.push({ nodeIdx: ni, blockIdx: null, kind: node.kind, text, seq: node.firstSeq ?? node.lastSeq });
|
|
4521
|
+
}
|
|
4522
|
+
}
|
|
4523
|
+
if (!matches.length && deep) matches.push({ nodeIdx: -1, blockIdx: null, kind: "snippet", text: snippet ?? "", seq: null, approximate: true });
|
|
4524
|
+
const session = this.sessions.find((item) => item.sessionId === sessionId);
|
|
4525
|
+
return { sessionId, title: session?.projections?.values?.title ?? sessionId.slice(0, 8), snippet: snippet ?? "", nodes, matches, hasMore: history.hasMore, beforeSeq: allEvents[0]?.event?.seq ?? null };
|
|
4526
|
+
}
|
|
4527
|
+
|
|
4528
|
+
/** Bounded local scan over loaded sessions when the Host FTS index is absent. */
|
|
4529
|
+
async #localSearchFallback(query, lower, seq, state) {
|
|
4530
|
+
const groups = new Map();
|
|
4531
|
+
const candidates = (this.sessions ?? []).filter((session) => !session.blank).slice(0, 20);
|
|
4532
|
+
for (const session of candidates) {
|
|
4533
|
+
const entry = await this.#resolveSearchSession(session.sessionId, "", lower, seq, state, { deep: false });
|
|
4534
|
+
if (entry === null) return null;
|
|
4535
|
+
if (entry.matches.length === 0) continue;
|
|
4536
|
+
const ws = this.#searchWorkspaceFor(session.sessionId);
|
|
4537
|
+
if (!groups.has(ws.key)) groups.set(ws.key, { ...ws, sessions: [] });
|
|
4538
|
+
groups.get(ws.key).sessions.push(entry);
|
|
4539
|
+
}
|
|
4540
|
+
return [...groups.values()];
|
|
4541
|
+
}
|
|
4542
|
+
|
|
4543
|
+
async #executeSearch() {
|
|
4544
|
+
const state = this.searchState;
|
|
4545
|
+
const query = this.searchInput.value.trim();
|
|
4546
|
+
if (!state || !query || state.loading) { if (!query) this.toast("请输入搜索内容"); return; }
|
|
4547
|
+
state.loading = true; state.error = null; state.fallback = false; state.fallbackError = null; state.phase = "results"; state.query = query; state.rows = []; state.preview = []; state.selected = 0; this.focus(this); this.redraw();
|
|
4548
|
+
const seq = ++this.searchSeq;
|
|
4549
|
+
const lower = query.toLowerCase();
|
|
4550
|
+
try {
|
|
4551
|
+
const result = await this.api.call("session.search", { query });
|
|
4552
|
+
const groups = new Map();
|
|
4553
|
+
for (const hit of result.items ?? []) {
|
|
4554
|
+
const entry = await this.#resolveSearchSession(hit.sessionId, hit.snippet ?? "", lower, seq, state, { deep: true });
|
|
4555
|
+
if (entry === null) return;
|
|
4556
|
+
const ws = this.#searchWorkspaceFor(hit.sessionId);
|
|
4557
|
+
if (!groups.has(ws.key)) groups.set(ws.key, { ...ws, sessions: [] });
|
|
4558
|
+
groups.get(ws.key).sessions.push(entry);
|
|
4559
|
+
}
|
|
4560
|
+
if (seq !== this.searchSeq || !this.searchActive || this.searchState !== state) return;
|
|
4561
|
+
state.groups = [...groups.values()]; state.hasMore = !!result.hasMore; state.loading = false; this.#flattenSearchRows();
|
|
4562
|
+
} catch (error) {
|
|
4563
|
+
if (seq !== this.searchSeq || !this.searchActive || this.searchState !== state) return;
|
|
4564
|
+
// Deployments without @deepseek-ai/dsh-session-query reject session.search.
|
|
4565
|
+
// Degrade to a bounded local scan over the already-loaded sessions.
|
|
4566
|
+
const groups = await this.#localSearchFallback(query, lower, seq, state);
|
|
4567
|
+
if (groups === null) return;
|
|
4568
|
+
state.groups = groups; state.hasMore = false; state.loading = false; state.fallback = true; state.fallbackError = error.message;
|
|
4569
|
+
this.#flattenSearchRows();
|
|
4570
|
+
this.toast("Host 搜索索引不可用;已改用本地有界扫描");
|
|
4571
|
+
}
|
|
4572
|
+
if (seq === this.searchSeq && this.searchState === state) this.redraw();
|
|
4573
|
+
}
|
|
4574
|
+
|
|
4575
|
+
#flattenSearchRows() {
|
|
4576
|
+
const state = this.searchState; if (!state) return;
|
|
4577
|
+
const rows = [];
|
|
4578
|
+
for (const group of state.groups ?? []) {
|
|
4579
|
+
rows.push({ kind: "workspace", key: `w:${group.key}`, group });
|
|
4580
|
+
if (state.collapsed.has(`w:${group.key}`)) continue;
|
|
4581
|
+
for (const session of group.sessions) {
|
|
4582
|
+
rows.push({ kind: "session", key: `s:${session.sessionId}`, session, group });
|
|
4583
|
+
if (state.collapsed.has(`s:${session.sessionId}`)) continue;
|
|
4584
|
+
for (let mi = 0; mi < session.matches.length; mi++) {
|
|
4585
|
+
const match = session.matches[mi];
|
|
4586
|
+
if (state.typeFold.has(match.kind)) continue;
|
|
4587
|
+
rows.push({ kind: "match", key: `m:${session.sessionId}:${mi}`, session, group, match, matchIndex: mi });
|
|
4588
|
+
}
|
|
4589
|
+
}
|
|
4047
4590
|
}
|
|
4048
|
-
|
|
4049
|
-
|
|
4591
|
+
state.rows = rows; state.selected = Math.min(state.selected, Math.max(0, rows.length - 1)); this.#updateSearchPreview();
|
|
4592
|
+
}
|
|
4593
|
+
|
|
4594
|
+
#updateSearchPreview() {
|
|
4595
|
+
const state = this.searchState; const row = state?.rows[state.selected]; if (!state) return;
|
|
4596
|
+
if (row?.kind === "match" && row.match.nodeIdx >= 0) {
|
|
4597
|
+
const from = Math.max(0, row.match.nodeIdx - 2), to = Math.min(row.session.nodes.length, row.match.nodeIdx + 3);
|
|
4598
|
+
state.preview = row.session.nodes.slice(from, to).flatMap((node, offset) => node.kind === "assistant" ? (node.blocks ?? []).map((block) => ({ kind: block.kind, text: this.#searchBlockText(node, block), active: from + offset === row.match.nodeIdx && block === node.blocks?.[row.match.blockIdx] })) : [{ kind: node.kind, text: this.#searchBlockText(node), active: from + offset === row.match.nodeIdx }]);
|
|
4599
|
+
} else if (row?.session) state.preview = [{ kind: "text", text: row.session.snippet }];
|
|
4600
|
+
else state.preview = [];
|
|
4601
|
+
state.previewScroll = 0;
|
|
4602
|
+
}
|
|
4603
|
+
|
|
4604
|
+
async #jumpSearchResult(row) {
|
|
4605
|
+
if (!row?.session) return;
|
|
4606
|
+
const sessionId = row.session.sessionId;
|
|
4607
|
+
const query = this.searchState?.query ?? "";
|
|
4608
|
+
this.searchSeq++;
|
|
4609
|
+
this.searchActive = false; this.searchState = null;
|
|
4610
|
+
await this.openSession(sessionId);
|
|
4611
|
+
this.setMode("chat"); this.focus(this.chat);
|
|
4612
|
+
if (row.kind === "match" && row.match.approximate) {
|
|
4613
|
+
this.toast("Host 找到该会话,但在解析预算内未定位到精确正文;已打开会话尾部");
|
|
4614
|
+
} else if (row.kind === "match" && row.match.nodeIdx >= 0) {
|
|
4615
|
+
const targetSeq = row.match.seq;
|
|
4616
|
+
let index = targetSeq == null ? row.match.nodeIdx : this.chat.nodes.findIndex((node) => node.firstSeq <= targetSeq && node.lastSeq >= targetSeq);
|
|
4617
|
+
// Search may have resolved up to forty 80-message pages; use the same
|
|
4618
|
+
// page size and budget while opening the target conversation.
|
|
4619
|
+
for (let i = 0; index < 0 && this.chat.hasMore && i < 40; i++) { await this.chat.loadOlder(null, 80); index = this.chat.nodes.findIndex((node) => node.firstSeq <= targetSeq && node.lastSeq >= targetSeq); }
|
|
4620
|
+
if (index >= 0) {
|
|
4621
|
+
this.chat.jumpToNode(index);
|
|
4622
|
+
const block = this.chat.blockItems.findIndex((item) => item.nodeIdx === index && (row.match.blockIdx == null || item.blockIdx === row.match.blockIdx) && (row.match.kind !== "code" || item.kind === "code"));
|
|
4623
|
+
if (block >= 0) {
|
|
4624
|
+
const item = this.chat.blockItems[block]; this.chat.blockSel = block; this.chat.cursorMode = "block"; this.chat.cursor = { line: item.headerLine, col: 0 }; this.chat.view.scrollY = Math.max(0, item.headerLine - 2);
|
|
4625
|
+
this.searchQuery = query || null;
|
|
4626
|
+
this.chat.queueRebuild();
|
|
4627
|
+
} else {
|
|
4628
|
+
this.toast("已定位到消息,但匹配块当前不可见");
|
|
4629
|
+
}
|
|
4630
|
+
} else {
|
|
4631
|
+
this.toast("在历史加载预算内未能定位该匹配;已打开会话尾部");
|
|
4632
|
+
}
|
|
4633
|
+
}
|
|
4634
|
+
this.redraw();
|
|
4050
4635
|
}
|
|
4051
4636
|
|
|
4052
|
-
#
|
|
4053
|
-
const
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4637
|
+
#onSearchKey(ev) {
|
|
4638
|
+
const state = this.searchState; if (!state) return;
|
|
4639
|
+
if (ev.type === "key" && ev.name === "escape") { this.searchSeq++; this.searchActive = false; this.searchState = null; this.focus(this.chat); this.layout(); return; }
|
|
4640
|
+
if (state.phase === "input") {
|
|
4641
|
+
if (ev.type === "key" && ev.name === "enter") { void this.#executeSearch(); return; }
|
|
4642
|
+
this.searchInput.onKey(ev); return;
|
|
4643
|
+
}
|
|
4644
|
+
if (ev.type === "text" && graphemes(ev.text ?? "").length === 1) {
|
|
4645
|
+
const text = graphemes(ev.text)[0];
|
|
4646
|
+
ev = { type: "key", name: "char", key: text.toLowerCase(), text, ctrl: false, alt: false, shift: text !== text.toLowerCase() };
|
|
4647
|
+
}
|
|
4648
|
+
if (ev.type !== "key") return;
|
|
4649
|
+
if (ev.name === "char" && ev.key === "/" && !ev.ctrl) { state.phase = "input"; this.searchInput.setValue(state.query); this.focus(this.searchInput); return; }
|
|
4650
|
+
if (ev.ctrl && (ev.name === "up" || ev.name === "down")) { state.previewScroll = Math.max(0, state.previewScroll + (ev.name === "up" ? -1 : 1)); return; }
|
|
4651
|
+
if ((ev.name === "up" || ev.name === "down") && state.rows.length) { state.selected = wrapIndex(state.selected + (ev.name === "up" ? -1 : 1), state.rows.length); this.#updateSearchPreview(); return; }
|
|
4652
|
+
const row = state.rows[state.selected];
|
|
4653
|
+
if (ev.name === "char" && ev.key === " " && !ev.ctrl && row && row.kind !== "match") { const key = row.key; if (state.collapsed.has(key)) state.collapsed.delete(key); else state.collapsed.add(key); this.#flattenSearchRows(); return; }
|
|
4654
|
+
if (ev.name === "char" && (ev.key === "t" || ev.key === "b") && !ev.ctrl && !ev.shift) { const kind = ev.key === "t" ? "reasoning" : "tool"; if (state.typeFold.has(kind)) state.typeFold.delete(kind); else state.typeFold.add(kind); this.#flattenSearchRows(); return; }
|
|
4655
|
+
if (ev.name === "enter" && row) { if (row.kind === "match") void this.#jumpSearchResult(row); else { if (state.collapsed.has(row.key)) state.collapsed.delete(row.key); else state.collapsed.add(row.key); this.#flattenSearchRows(); } }
|
|
4656
|
+
}
|
|
4657
|
+
|
|
4658
|
+
#renderSearchBuffer(s) {
|
|
4659
|
+
const state = this.searchState; if (!state) return;
|
|
4660
|
+
s.fillRect(0, 0, s.w - 1, s.h - 1, " ", { bg: T.BG });
|
|
4661
|
+
const split = Math.max(24, Math.min(Math.floor(s.w * 0.36), 48));
|
|
4662
|
+
s.box(0, 0, s.w - 1, s.h - 1, { fg: T.BORDER2, bg: T.BG }, " 跨会话搜索 · Enter 执行 · / 编辑 · t/b 折叠类型 · Ctrl+↑↓ 预览 ");
|
|
4663
|
+
s.vline(split, 1, s.h - 2, "│", { fg: T.BORDER2 });
|
|
4664
|
+
this.searchInput.x = 2; this.searchInput.y = 1; this.searchInput.w = Math.max(8, s.w - 4); this.searchInput.render(s);
|
|
4665
|
+
let y = 3;
|
|
4666
|
+
if (state.phase === "input") {
|
|
4667
|
+
s.text(2, y++, "执行搜索前仅显示工作区 / 会话结构;不会实时扫描历史。", { fg: K.FAINT });
|
|
4668
|
+
for (const group of this.sidebar.groups) { if (y >= s.h - 2) break; s.text(2, y++, truncate(`▾ ${group.title} (${group.sessions.length})`, split - 3), { fg: K.DIM }); for (const session of group.sessions) { if (y >= s.h - 2) break; s.text(4, y++, truncate(session.projections?.values?.title ?? session.sessionId.slice(0, 8), split - 5), { fg: K.FAINT }); } }
|
|
4669
|
+
return;
|
|
4670
|
+
}
|
|
4671
|
+
if (state.loading) { s.text(2, y, "正在搜索 Host 索引并解析候选会话…", { fg: K.ACCENT }); return; }
|
|
4672
|
+
if (state.error) s.text(2, y++, `搜索失败: ${truncate(state.error, split - 8)}`, { fg: K.ERR });
|
|
4673
|
+
if (state.fallback) s.text(2, y++, `Host 搜索索引不可用:已本地扫描最近 20 个会话的近期历史(${truncate(String(state.fallbackError ?? ""), Math.max(8, split - 30))})`, { fg: K.WARN });
|
|
4674
|
+
if (state.hasMore && state.rows.length) s.text(2, y++, "Host 候选已截断,请缩小查询", { fg: K.WARN });
|
|
4675
|
+
const available = Math.max(1, s.h - y - 2), scroll = Math.max(0, Math.min(Math.max(0, state.rows.length - available), state.selected - Math.floor(available / 2)));
|
|
4676
|
+
for (let i = 0; i < available; i++) {
|
|
4677
|
+
const index = scroll + i, row = state.rows[index]; if (!row) break;
|
|
4678
|
+
const selected = index === state.selected, folded = state.collapsed.has(row.key);
|
|
4679
|
+
const label = row.kind === "workspace" ? `${folded ? "▸" : "▾"} ${row.group.title}` : row.kind === "session" ? ` ${folded ? "▸" : "▾"} ${row.session.title}` : ` ${selected ? "=>" : " "} [${row.match.kind}] ${row.match.text.replace(/\s+/g, " ")}`;
|
|
4680
|
+
s.text(1, y + i, truncate(label, split - 2), { fg: selected ? T.SELFG : row.kind === "match" ? K.TXT : K.DIM, bg: selected ? T.MENUSEL : -1, attrs: selected ? 1 : 0 });
|
|
4681
|
+
}
|
|
4682
|
+
let py = 3, logical = 0;
|
|
4683
|
+
for (const item of state.preview) {
|
|
4684
|
+
if (state.typeFold.has(item.kind)) continue;
|
|
4685
|
+
const wrapped = wrapDisplayText(item.text || "(空)", Math.max(10, s.w - split - 5));
|
|
4686
|
+
for (const line of wrapped) { if (logical++ < state.previewScroll) continue; if (py >= s.h - 2) break; s.text(split + 2, py++, truncate(`${item.active ? "=>" : " "} [${item.kind}] ${line}`, s.w - split - 4), { fg: item.active ? T.ACCENT : K.TXT, attrs: item.active ? 1 : 0 }); }
|
|
4687
|
+
if (py >= s.h - 2) break;
|
|
4688
|
+
}
|
|
4689
|
+
if (!state.rows.length) s.text(2, y, state.error ? `搜索失败: ${state.error}` : state.fallback ? "本地扫描没有匹配(仅最近 20 个会话的近期历史)" : state.hasMore ? "结果超过 Host 上限,请缩小查询" : "没有匹配", { fg: state.error ? K.BAD : K.FAINT });
|
|
4058
4690
|
}
|
|
4059
4691
|
|
|
4060
4692
|
redraw() {
|
|
@@ -4108,17 +4740,27 @@ export class App {
|
|
|
4108
4740
|
this.term.output.write(s.render() + "\x1b[?25l");
|
|
4109
4741
|
return;
|
|
4110
4742
|
}
|
|
4743
|
+
if (this.searchActive && this.searchState) {
|
|
4744
|
+
this.#renderSearchBuffer(s);
|
|
4745
|
+
this.term.output.write(s.render() + "\x1b[?25l");
|
|
4746
|
+
return;
|
|
4747
|
+
}
|
|
4748
|
+
// Full-screen panel buffer: covers the whole surface; Esc returns to the
|
|
4749
|
+
// main area where pane focus (Ctrl+Left/Right) works again.
|
|
4750
|
+
if (this.fullBuffer) {
|
|
4751
|
+
this.fullBuffer.relayout(0, 0, s.w, s.h);
|
|
4752
|
+
this.fullBuffer.render(s);
|
|
4753
|
+
if (this.popup) this.popup.render(s);
|
|
4754
|
+
if (this.menu) this.menu.render(s);
|
|
4755
|
+
if (this.overlay) this.overlay.render(s);
|
|
4756
|
+
this.#renderToast(s);
|
|
4757
|
+
this.term.output.write(s.render() + "\x1b[?25l");
|
|
4758
|
+
return;
|
|
4759
|
+
}
|
|
4111
4760
|
this.#renderTabBar(s);
|
|
4112
4761
|
if (this.sidebarVisible) {
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
this.sidebar.y = 1; this.sidebar.h = s.h - 2;
|
|
4116
|
-
if (this.searchResults !== null) this.#renderSearchResults(s);
|
|
4117
|
-
else this.sidebar.render(s);
|
|
4118
|
-
} else {
|
|
4119
|
-
this.sidebar.y = 0; this.sidebar.h = s.h - 1;
|
|
4120
|
-
this.sidebar.render(s);
|
|
4121
|
-
}
|
|
4762
|
+
this.sidebar.y = 0; this.sidebar.h = s.h - 1;
|
|
4763
|
+
this.sidebar.render(s);
|
|
4122
4764
|
s.put(this.sidebar.w - 1, 0, "│", { fg: T.BORDER });
|
|
4123
4765
|
for (let y = 1; y < s.h - 1; y++) s.put(this.sidebar.w - 1, y, "│", { fg: T.BORDER });
|
|
4124
4766
|
}
|
|
@@ -4152,7 +4794,7 @@ export class App {
|
|
|
4152
4794
|
row0.left.push({ t: ` ${badge} `, fg: T.SELFG, bg: T.ACCENT, bold: true });
|
|
4153
4795
|
const rawGoal = this.goalData?.goal ?? this.goalData;
|
|
4154
4796
|
if (rawGoal && !["complete", "completed", "cleared"].includes(rawGoal.phase)) {
|
|
4155
|
-
row0.left.push({ t: ` 🎯 ${truncate(rawGoal.objective ?? "目标", 14)} · Ctrl+G `, fg:
|
|
4797
|
+
row0.left.push({ t: ` 🎯 ${truncate(rawGoal.objective ?? "目标", 14)} · Ctrl+G `, fg: 0x000000, bg: T.WARN, bold: true });
|
|
4156
4798
|
}
|
|
4157
4799
|
if (this.sidebarVisible) row0.left.push({ t: " " + truncate(t || "(未选择会话)", 40) + " ", fg: T.TXT, bg: T.STATUSBG });
|
|
4158
4800
|
else row0.left.push({ t: " " + truncate(t || "(未选择会话)", 40) + " ", fg: T.TXT, bg: T.STATUSBG });
|
|
@@ -4233,15 +4875,32 @@ export class App {
|
|
|
4233
4875
|
const sub = this.projections.subagent;
|
|
4234
4876
|
const subTiming = this.projections.subagentTiming;
|
|
4235
4877
|
const subStats = this.subagentStatsBySession.get(this.currentSession) ?? { running: subTiming?.active ? 1 : 0, completed: 0 };
|
|
4878
|
+
// Tasks and subagents use exactly the same two-part status grammar:
|
|
4879
|
+
// WARN = currently running, OK = completed, FAINT = zero/idle.
|
|
4236
4880
|
row2.left.push({
|
|
4237
4881
|
t: ` ${running > 0 ? `${running} 个后台任务运行中` : "没有后台任务运行"} `,
|
|
4238
|
-
fg: running > 0 ? T.WARN : T.FAINT, bg: T.STATUSBG,
|
|
4882
|
+
fg: running > 0 ? T.WARN : T.FAINT, bg: T.STATUSBG, bold: running > 0,
|
|
4883
|
+
});
|
|
4884
|
+
row2.left.push({
|
|
4885
|
+
t: ` ${done}已完成${failed > 0 ? ` · ${failed}失败` : ""} `,
|
|
4886
|
+
fg: done > 0 ? T.OK : failed > 0 ? T.WARN : T.FAINT, bg: T.STATUSBG,
|
|
4887
|
+
});
|
|
4888
|
+
row2.left.push({
|
|
4889
|
+
t: ` ${subStats.running > 0 ? `${subStats.running} 个子代理运行中` : "没有子代理运行"} `,
|
|
4890
|
+
fg: subStats.running > 0 ? T.WARN : T.FAINT, bg: T.STATUSBG, bold: subStats.running > 0,
|
|
4239
4891
|
});
|
|
4240
|
-
row2.left.push({
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4892
|
+
row2.left.push({
|
|
4893
|
+
t: ` ${subStats.completed}已完成 `,
|
|
4894
|
+
fg: subStats.completed > 0 ? T.OK : T.FAINT, bg: T.STATUSBG,
|
|
4895
|
+
});
|
|
4896
|
+
// Ctrl+J belongs beside the two activity summaries it opens, not alone
|
|
4897
|
+
// at the far-right edge (especially once the queue badge also appears).
|
|
4898
|
+
row2.left.push({ t: " Ctrl+J 任务/子代理 ", fg: T.DIM, bg: T.STATUSBG });
|
|
4899
|
+
if (sub) row2.left.push({
|
|
4900
|
+
t: ` ◇ ${truncate(sub.label ?? sub.mode ?? "子代理", 20)} `,
|
|
4901
|
+
fg: subStats.running > 0 ? T.WARN : subStats.completed > 0 ? T.OK : T.FAINT, bg: T.STATUSBG,
|
|
4902
|
+
});
|
|
4903
|
+
if(this.queueItems.length)row2.left.push({t:` 有${this.queueItems.length}条命令正在排队 Ctrl+N查看详情 `,fg:0x000000,bg:T.WARN,bold:true});
|
|
4245
4904
|
rows.push(row2);
|
|
4246
4905
|
}
|
|
4247
4906
|
this.status.rows = rows;
|
|
@@ -4250,15 +4909,7 @@ export class App {
|
|
|
4250
4909
|
if (this.popup) this.popup.render(s);
|
|
4251
4910
|
if (this.menu) this.menu.render(s);
|
|
4252
4911
|
if (this.overlay) this.overlay.render(s);
|
|
4253
|
-
|
|
4254
|
-
// Toasts land in the LOWER half (just above the input/footer) where the
|
|
4255
|
-
// user's attention is while pressing shortcuts — a solid color block.
|
|
4256
|
-
const w = Math.min(s.w - 4, strWidth(this.toastMsg) + 6);
|
|
4257
|
-
const x0 = Math.max(2, Math.floor((s.w - w) / 2));
|
|
4258
|
-
const y = Math.max(1, this.chat.input.y - this.chat.todoHeight() - 2);
|
|
4259
|
-
s.fillRect(x0, y, x0 + w - 1, y, " ", { bg: T.ACCENT });
|
|
4260
|
-
s.text(x0 + 1, y, truncate(this.toastMsg, w - 2), { fg: T.SELFG, bg: T.ACCENT, attrs: 1 });
|
|
4261
|
-
}
|
|
4912
|
+
this.#renderToast(s);
|
|
4262
4913
|
if (this.renameInput && this.popup) this.renameInput.render(s);
|
|
4263
4914
|
|
|
4264
4915
|
const out = s.render();
|
|
@@ -4276,6 +4927,17 @@ export class App {
|
|
|
4276
4927
|
this.term.output.write(out + tail);
|
|
4277
4928
|
}
|
|
4278
4929
|
|
|
4930
|
+
#renderToast(s) {
|
|
4931
|
+
if (!this.toastMsg) return;
|
|
4932
|
+
// Toasts land in the LOWER half (just above the input/footer) where the
|
|
4933
|
+
// user's attention is while pressing shortcuts — a solid color block.
|
|
4934
|
+
const w = Math.min(s.w - 4, strWidth(this.toastMsg) + 6);
|
|
4935
|
+
const x0 = Math.max(2, Math.floor((s.w - w) / 2));
|
|
4936
|
+
const y = Math.max(1, this.chat.input.y - this.chat.todoHeight() - 2);
|
|
4937
|
+
s.fillRect(x0, y, x0 + w - 1, y, " ", { bg: T.ACCENT });
|
|
4938
|
+
s.text(x0 + 1, y, truncate(this.toastMsg, w - 2), { fg: T.SELFG, bg: T.ACCENT, attrs: 1 });
|
|
4939
|
+
}
|
|
4940
|
+
|
|
4279
4941
|
titleOf() {
|
|
4280
4942
|
const s = this.sessions.find((x) => x.sessionId === this.currentSession);
|
|
4281
4943
|
if (s) return s.projections?.values?.title ?? s.sessionId.slice(0, 8);
|