dsh-neotui 0.2.2 → 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 +32 -14
- package/bin/dsh-tui.js +1 -1
- package/package.json +5 -2
- package/src/config.js +46 -11
- package/src/file-picker.js +4 -4
- package/src/keybindings.js +172 -0
- package/src/md.js +23 -21
- package/src/panels.js +1772 -427
- package/src/term.js +18 -10
- package/src/text.js +11 -3
- package/src/views.js +1045 -281
- package/src/widgets.js +37 -27
package/src/views.js
CHANGED
|
@@ -1,13 +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
|
-
import {
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
import { Widget, ScrollView, Input, Popup, Menu, StatusBar, wrapIndex } from "./widgets.js";
|
|
9
10
|
import { UploadPicker } from "./file-picker.js";
|
|
10
|
-
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";
|
|
11
13
|
export { userPrefix, saveTuiConfig, loadTuiConfig, userName, busyEnter, foldDefaults } from "./config.js";
|
|
12
14
|
import {
|
|
13
15
|
Picker, buildCommandPalette, buildModelPicker, buildModePicker, buildPermissionPicker,
|
|
@@ -20,6 +22,67 @@ import { T, themeName, cycleTheme } from "./theme.js";
|
|
|
20
22
|
// Live theme accessor: K.K.DIM etc. resolve against the active palette at render time.
|
|
21
23
|
const K = new Proxy({}, { get(_k, key) { return T[key]; } });
|
|
22
24
|
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
export const TUI_VERSION = (() => {
|
|
27
|
+
try { return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version; }
|
|
28
|
+
catch { return "unknown"; }
|
|
29
|
+
})();
|
|
30
|
+
let dshVersionCache = null;
|
|
31
|
+
export function installedDshVersion(run = spawnSync) {
|
|
32
|
+
if (dshVersionCache) return dshVersionCache;
|
|
33
|
+
if (process.env.DSH_VERSION) return (dshVersionCache = process.env.DSH_VERSION.replace(/^v/, ""));
|
|
34
|
+
try {
|
|
35
|
+
const file = require.resolve("@deepseek-ai/dsh/package.json");
|
|
36
|
+
const version = JSON.parse(readFileSync(file, "utf8")).version;
|
|
37
|
+
if (version) return (dshVersionCache = version);
|
|
38
|
+
} catch {}
|
|
39
|
+
try {
|
|
40
|
+
const result = run("dsh", ["--version"], { encoding: "utf8", timeout: 2000 });
|
|
41
|
+
const version = String(result.stdout ?? "").trim().replace(/^v/, "");
|
|
42
|
+
if (result.status === 0 && version) return (dshVersionCache = version);
|
|
43
|
+
} catch {}
|
|
44
|
+
return (dshVersionCache = "unknown");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function latestNpmVersion(name) {
|
|
48
|
+
const controller = new AbortController();
|
|
49
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
50
|
+
try {
|
|
51
|
+
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`, {
|
|
52
|
+
headers: { accept: "application/json" }, signal: controller.signal,
|
|
53
|
+
});
|
|
54
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
55
|
+
const body = await response.json();
|
|
56
|
+
if (typeof body?.version !== "string" || !body.version) throw new Error("缺少版本号");
|
|
57
|
+
return body.version;
|
|
58
|
+
} finally { clearTimeout(timer); }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Semver comparison for update checks. Returns null for an unparseable value,
|
|
62
|
+
* otherwise -1/0/1 for left older/equal/newer than right. In particular, a
|
|
63
|
+
* locally newer build must never be advertised as "可更新" to an older npm tag. */
|
|
64
|
+
function compareSemver(left, right) {
|
|
65
|
+
const parse = (value) => {
|
|
66
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(value ?? ""));
|
|
67
|
+
if (!match) return null;
|
|
68
|
+
return { core: match.slice(1, 4).map(Number), pre: match[4]?.split(".") ?? [] };
|
|
69
|
+
};
|
|
70
|
+
const a = parse(left), b = parse(right);
|
|
71
|
+
if (!a || !b) return null;
|
|
72
|
+
for (let i = 0; i < 3; i++) if (a.core[i] !== b.core[i]) return a.core[i] > b.core[i] ? 1 : -1;
|
|
73
|
+
if (a.pre.length === 0 || b.pre.length === 0) return a.pre.length === b.pre.length ? 0 : a.pre.length === 0 ? 1 : -1;
|
|
74
|
+
const n = Math.max(a.pre.length, b.pre.length);
|
|
75
|
+
for (let i = 0; i < n; i++) {
|
|
76
|
+
if (a.pre[i] === undefined || b.pre[i] === undefined) return a.pre[i] === b.pre[i] ? 0 : a.pre[i] === undefined ? -1 : 1;
|
|
77
|
+
if (a.pre[i] === b.pre[i]) continue;
|
|
78
|
+
const ai = /^\d+$/.test(a.pre[i]), bi = /^\d+$/.test(b.pre[i]);
|
|
79
|
+
if (ai && bi) return Number(a.pre[i]) > Number(b.pre[i]) ? 1 : -1;
|
|
80
|
+
if (ai !== bi) return ai ? -1 : 1;
|
|
81
|
+
return a.pre[i] > b.pre[i] ? 1 : -1;
|
|
82
|
+
}
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
23
86
|
// ---- Tool card renderers (host-computed view models) ----
|
|
24
87
|
|
|
25
88
|
function renderToolCard(view, width, expanded) {
|
|
@@ -46,9 +109,14 @@ function renderToolCard(view, width, expanded) {
|
|
|
46
109
|
const rows = output.split("\n");
|
|
47
110
|
const cap = expanded ? 200 : 8;
|
|
48
111
|
for (const row of rows.slice(0, cap)) lines.push([{ t: " " + truncate(row, width - 4), fg: K.TXT, code: true }]);
|
|
49
|
-
if (rows.length > cap) lines.push([{ t:
|
|
50
|
-
|
|
51
|
-
|
|
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 }]);
|
|
52
120
|
else if (card.running) lines.push([{ t: " ● 运行中", fg: K.WARN }]);
|
|
53
121
|
break;
|
|
54
122
|
}
|
|
@@ -421,7 +489,25 @@ function applyEvent(nodes, event, view, log, state = null) {
|
|
|
421
489
|
export function nodeForEvents(events, log) {
|
|
422
490
|
const nodes = [];
|
|
423
491
|
const state = { step: null };
|
|
424
|
-
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
|
+
}
|
|
425
511
|
return nodes;
|
|
426
512
|
}
|
|
427
513
|
|
|
@@ -705,6 +791,8 @@ class SidebarTree extends Widget {
|
|
|
705
791
|
this.groups = groups;
|
|
706
792
|
this.#flatten();
|
|
707
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();
|
|
708
796
|
this.app.redraw();
|
|
709
797
|
}
|
|
710
798
|
#flatten() {
|
|
@@ -721,17 +809,21 @@ class SidebarTree extends Widget {
|
|
|
721
809
|
if (this.collapsed.has(group.key)) this.collapsed.delete(group.key);
|
|
722
810
|
else this.collapsed.add(group.key);
|
|
723
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();
|
|
724
815
|
}
|
|
725
|
-
collapseAll() { for (const g of this.groups) this.collapsed.add(g.key); this.#flatten(); }
|
|
726
|
-
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(); }
|
|
727
818
|
#rowTitle(sess) {
|
|
728
819
|
return sess.projections?.values?.title ?? (sess.blank ? "(空白会话)" : sess.sessionId.slice(0, 8));
|
|
729
820
|
}
|
|
730
821
|
render(screen) {
|
|
731
822
|
screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", {});
|
|
732
823
|
const w = this.w - 1;
|
|
733
|
-
//
|
|
734
|
-
|
|
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 });
|
|
735
827
|
screen.hline(this.x, this.x + w, this.y + 1, "─", { fg: T.BORDER });
|
|
736
828
|
const listTop = this.y + 2;
|
|
737
829
|
for (let i = 0; i < this.h - 2; i++) {
|
|
@@ -787,13 +879,37 @@ class SidebarTree extends Widget {
|
|
|
787
879
|
}
|
|
788
880
|
move(delta) {
|
|
789
881
|
if (this.rows.length === 0) return false;
|
|
790
|
-
const next =
|
|
791
|
-
if (next === this.sel) return false;
|
|
882
|
+
const next = wrapIndex(this.sel + delta, this.rows.length);
|
|
792
883
|
this.sel = next;
|
|
793
884
|
this.#scrollToSel();
|
|
794
885
|
return true;
|
|
795
886
|
}
|
|
796
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
|
+
}
|
|
797
913
|
onMouse(ev) {
|
|
798
914
|
if (ev.kind === "wheel-up") { this.scroll(-3); return true; }
|
|
799
915
|
if (ev.kind === "wheel-down") { this.scroll(3); return true; }
|
|
@@ -813,30 +929,10 @@ class SidebarTree extends Widget {
|
|
|
813
929
|
if (ev.kind === "press" && ev.button === 2) {
|
|
814
930
|
const idx = this.scrollY + (ev.y - this.y - 2);
|
|
815
931
|
const row = this.rows[idx];
|
|
816
|
-
if (!row) {
|
|
817
|
-
// right-click on empty sidebar space → workspace-level actions
|
|
818
|
-
this.app.openMenu([
|
|
819
|
-
{ label: "新建工作区…", action: () => this.app.addWorkspace() },
|
|
820
|
-
{ label: "新建会话", action: () => this.app.newSessionIn(null) },
|
|
821
|
-
], ev);
|
|
822
|
-
return true;
|
|
823
|
-
}
|
|
932
|
+
if (!row) { this.app.openMenu(this.#menuFor(null), ev); return true; }
|
|
824
933
|
this.sel = idx;
|
|
825
|
-
if (row.kind === "
|
|
826
|
-
|
|
827
|
-
{ label: "新建会话", action: () => this.app.newSessionIn(row.group) },
|
|
828
|
-
{ label: "新建工作区…", action: () => this.app.addWorkspace() },
|
|
829
|
-
{ label: "折叠全部", action: () => { this.collapseAll(); this.app.redraw(); } },
|
|
830
|
-
{ label: "展开全部", action: () => { this.expandAll(); this.app.redraw(); } },
|
|
831
|
-
];
|
|
832
|
-
if (row.group.workspaceId) {
|
|
833
|
-
items.push({ label: "重命名工作区", action: () => this.app.renameWorkspace(row.group) });
|
|
834
|
-
items.push({ label: "删除工作区…", action: () => this.app.deleteWorkspace(row.group) });
|
|
835
|
-
}
|
|
836
|
-
this.app.openMenu(items, ev);
|
|
837
|
-
} else {
|
|
838
|
-
this.app.sessionMenu({ data: row.session }, ev);
|
|
839
|
-
}
|
|
934
|
+
if (row.kind === "session") this.app.sessionMenu({ data: row.session }, ev);
|
|
935
|
+
else this.app.openMenu(this.#menuFor(row), ev);
|
|
840
936
|
return true;
|
|
841
937
|
}
|
|
842
938
|
return false;
|
|
@@ -849,28 +945,45 @@ class SidebarTree extends Widget {
|
|
|
849
945
|
case "pgup": this.scroll(-this.h); return true;
|
|
850
946
|
case "pgdn": this.scroll(this.h); return true;
|
|
851
947
|
case "home": this.sel = 0; this.#scrollToSel(); return true;
|
|
852
|
-
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;
|
|
853
949
|
case "enter": {
|
|
854
950
|
const row = this.currentRow();
|
|
855
951
|
if (!row) return false;
|
|
856
952
|
if (row.kind === "group") { this.toggle(row.group); this.app.redraw(); }
|
|
857
|
-
else this.app.openSession(row.session.sessionId);
|
|
953
|
+
else this.app.openSession(row.session.sessionId); // pane focus intentionally stays here
|
|
858
954
|
return true;
|
|
859
955
|
}
|
|
860
956
|
case "left": {
|
|
957
|
+
if (ev.ctrl) return false;
|
|
861
958
|
const row = this.currentRow();
|
|
862
959
|
if (row?.kind === "group" && !this.collapsed.has(row.group.key)) { this.toggle(row.group); this.app.redraw(); return true; }
|
|
863
960
|
if (row?.kind === "session") { this.sel = this.rows.findLastIndex((r, i) => i <= this.sel && r.kind === "group"); this.#scrollToSel(); return true; }
|
|
864
961
|
return false;
|
|
865
962
|
}
|
|
866
963
|
case "right": {
|
|
964
|
+
if (ev.ctrl) return false;
|
|
867
965
|
const row = this.currentRow();
|
|
868
966
|
if (row?.kind === "group" && this.collapsed.has(row.group.key)) { this.toggle(row.group); this.app.redraw(); return true; }
|
|
869
967
|
if (row?.kind === "group") { this.sel = Math.min(this.rows.length - 1, this.sel + 1); this.#scrollToSel(); return true; }
|
|
870
968
|
return false;
|
|
871
969
|
}
|
|
872
970
|
case "char":
|
|
873
|
-
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
|
+
}
|
|
874
987
|
if (!ev.ctrl && (ev.key === "[" || ev.key === "]")) {
|
|
875
988
|
const r = this.currentRow();
|
|
876
989
|
if (r?.kind === "session") { this.app.moveSession(r.session, ev.key === "[" ? -1 : 1); return true; }
|
|
@@ -907,13 +1020,11 @@ export class ChatView extends Widget {
|
|
|
907
1020
|
this.nodes = [];
|
|
908
1021
|
this.lines = [];
|
|
909
1022
|
this.expanded = new Set(); // node indexes (user-message full text)
|
|
910
|
-
this.expandedTools = new Set();
|
|
911
1023
|
this.collapsedBlocks = new Set(); // per-block COLLAPSE (default expanded): `${realIdx}:${bi}`
|
|
912
1024
|
const fd = foldDefaults();
|
|
913
1025
|
this.thinkMode = fd.think ? "expanded" : "collapsed"; // t toggles
|
|
914
1026
|
this.bashMode = fd.bash ? "expanded" : "collapsed"; // b toggles
|
|
915
1027
|
this.todosVisible = fd.todos; // Shift+T toggles
|
|
916
|
-
this.todoSeen = false; // once seen, the todo box keeps its height
|
|
917
1028
|
this.running = false;
|
|
918
1029
|
this.hasMore = false;
|
|
919
1030
|
this.loadingOlder = false;
|
|
@@ -937,6 +1048,8 @@ export class ChatView extends Widget {
|
|
|
937
1048
|
this.cache = new Map(); // node render cache: key → { lines, marks }
|
|
938
1049
|
this.cardRanges = []; // absolute line ranges of card-backed message blocks
|
|
939
1050
|
this.welcomeModes = []; // absolute row y → agent preset id (welcome screen)
|
|
1051
|
+
this.welcomeModeIds = ["standard", "code", "minimal", "cordis"];
|
|
1052
|
+
this.welcomeModeSel = 0;
|
|
940
1053
|
this.pressY = null;
|
|
941
1054
|
this.pressInfo = null; // hit identity locked at press time
|
|
942
1055
|
this.pressCtx = null;
|
|
@@ -945,7 +1058,14 @@ export class ChatView extends Widget {
|
|
|
945
1058
|
this.selEnd = null;
|
|
946
1059
|
this.selAnchor = null;
|
|
947
1060
|
this.selFocus = null;
|
|
948
|
-
|
|
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})
|
|
949
1069
|
this.clipboardImages = [];
|
|
950
1070
|
this.attachments = [];
|
|
951
1071
|
this.stepState = { step: null }; // step/start tracking for the mux merge path
|
|
@@ -1085,6 +1205,8 @@ export class ChatView extends Widget {
|
|
|
1085
1205
|
for (let li = 0; li < this.lineMap.length; li++) {
|
|
1086
1206
|
if (this.lineMap[li]?.nodeIdx === idx) {
|
|
1087
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 }; }
|
|
1088
1210
|
this.app.redraw();
|
|
1089
1211
|
return true;
|
|
1090
1212
|
}
|
|
@@ -1100,20 +1222,15 @@ export class ChatView extends Widget {
|
|
|
1100
1222
|
}
|
|
1101
1223
|
}
|
|
1102
1224
|
|
|
1103
|
-
/** Height of the collapsible todo block
|
|
1104
|
-
*
|
|
1105
|
-
* list in the background while the user reads, and a height that tracks
|
|
1106
|
-
* the count reflows the whole layout every time (the idle 2-line shifts). */
|
|
1225
|
+
/** Height of the collapsible todo block: one framed row per visible task,
|
|
1226
|
+
* capped at six items. Short lists must not reserve blank body rows. */
|
|
1107
1227
|
todoHeight() {
|
|
1108
1228
|
const todos = this.app.todos;
|
|
1109
1229
|
const subagent = this.app.projections.subagent;
|
|
1110
|
-
|
|
1111
|
-
if (
|
|
1112
|
-
|
|
1113
|
-
this.
|
|
1114
|
-
// The task dock owns a framed header and footer. Folded keeps the framed
|
|
1115
|
-
// two-row strip visible rather than blending one text row into transcript.
|
|
1116
|
-
return h + (this.todosVisible ? 8 : 2);
|
|
1230
|
+
const subagentRows = subagent ? 1 : 0;
|
|
1231
|
+
if (!todos || todos.length === 0) return subagentRows;
|
|
1232
|
+
// Header + actual body rows + footer. Folded retains only the frame.
|
|
1233
|
+
return subagentRows + (this.todosVisible ? Math.min(todos.length, 6) + 2 : 2);
|
|
1117
1234
|
}
|
|
1118
1235
|
|
|
1119
1236
|
divingNode() { return [...this.nodes].reverse().find((node) => node.kind === "turn-progress") ?? null; }
|
|
@@ -1143,18 +1260,21 @@ export class ChatView extends Widget {
|
|
|
1143
1260
|
this.#rebuild();
|
|
1144
1261
|
}
|
|
1145
1262
|
|
|
1146
|
-
async open(sessionId, epoch = this.app.sessionEpoch) {
|
|
1263
|
+
async open(sessionId, epoch = this.app.sessionEpoch, maxMessages = 80) {
|
|
1147
1264
|
this.sessionId = sessionId;
|
|
1148
1265
|
this.nodes = [];
|
|
1266
|
+
this.welcomeModeSel = 0;
|
|
1267
|
+
this.blockSel = -1;
|
|
1268
|
+
this.cursorMode = "block";
|
|
1269
|
+
this.visualAnchor = null;
|
|
1149
1270
|
this.expanded.clear();
|
|
1150
|
-
this.expandedTools.clear();
|
|
1151
1271
|
this.collapsedBlocks.clear();
|
|
1152
1272
|
this.hasMore = false;
|
|
1153
1273
|
this.minSeq = null;
|
|
1154
1274
|
this.cache.clear();
|
|
1155
1275
|
this.app.setStatus(`加载会话 ${sessionId.slice(0, 8)}…`);
|
|
1156
1276
|
try {
|
|
1157
|
-
const hist = await this.app.api.call("session.history", { sessionId });
|
|
1277
|
+
const hist = await this.app.api.call("session.history", { sessionId, maxMessages });
|
|
1158
1278
|
if (this.sessionId !== sessionId || this.app.sessionEpoch !== epoch) return;
|
|
1159
1279
|
this.minSeq = hist.events[0]?.event?.seq ?? null;
|
|
1160
1280
|
this.lastSeq = hist.events[hist.events.length - 1]?.event?.seq ?? null;
|
|
@@ -1178,7 +1298,7 @@ export class ChatView extends Widget {
|
|
|
1178
1298
|
this.view.follow = true;
|
|
1179
1299
|
}
|
|
1180
1300
|
|
|
1181
|
-
async loadOlder(onDone = null) {
|
|
1301
|
+
async loadOlder(onDone = null, maxMessages = 20) {
|
|
1182
1302
|
if (!this.hasMore || this.loadingOlder || this.minSeq == null) { if (!this.hasMore) this.app.toast("已加载到会话开头"); if (onDone) queueMicrotask(onDone); return; }
|
|
1183
1303
|
const sessionId = this.sessionId;
|
|
1184
1304
|
const epoch = this.app.sessionEpoch;
|
|
@@ -1187,7 +1307,7 @@ export class ChatView extends Widget {
|
|
|
1187
1307
|
const oldLength = this.lines.length;
|
|
1188
1308
|
this.app.setStatus("加载更早记录…");
|
|
1189
1309
|
try {
|
|
1190
|
-
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 });
|
|
1191
1311
|
if (this.sessionId !== sessionId || this.app.sessionEpoch !== epoch) { this.loadingOlder = false; return; }
|
|
1192
1312
|
if (hist.events.length === 0) { this.hasMore = false; }
|
|
1193
1313
|
else {
|
|
@@ -1198,6 +1318,24 @@ export class ChatView extends Widget {
|
|
|
1198
1318
|
this.hasMore = hist.hasMore && this.minSeq < previousMinSeq;
|
|
1199
1319
|
this.#noteEarliest(hist.events);
|
|
1200
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
|
+
}
|
|
1201
1339
|
this.nodes = [...more, ...this.nodes];
|
|
1202
1340
|
}
|
|
1203
1341
|
} catch (e) {
|
|
@@ -1304,7 +1442,7 @@ export class ChatView extends Widget {
|
|
|
1304
1442
|
const trimmed = text.trim();
|
|
1305
1443
|
if (trimmed === "/reload") { this.app.softReload(); return; }
|
|
1306
1444
|
if (trimmed === "/restart") { this.app.restartApp(); return; }
|
|
1307
|
-
if (trimmed === "/model") { this.app.
|
|
1445
|
+
if (trimmed === "/model") { this.app.overlay = buildModelPicker(this.app); this.app.redraw(); return; }
|
|
1308
1446
|
if (trimmed === "/theme") { cycleTheme(); this.queueRebuild(); this.app.toast(`主题已切换: ${themeName()}`); return; }
|
|
1309
1447
|
if (trimmed === "/permission") { this.app.showPermissionPicker(); return; }
|
|
1310
1448
|
if (trimmed === "/goal") { this.app.showGoal(); return; }
|
|
@@ -1545,7 +1683,11 @@ export class ChatView extends Widget {
|
|
|
1545
1683
|
return true;
|
|
1546
1684
|
}
|
|
1547
1685
|
}
|
|
1548
|
-
|
|
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)) {
|
|
1549
1691
|
if (this.expanded.has(info.nodeIdx)) this.expanded.delete(info.nodeIdx);
|
|
1550
1692
|
else this.expanded.add(info.nodeIdx);
|
|
1551
1693
|
this.#rebuild();
|
|
@@ -1556,6 +1698,8 @@ export class ChatView extends Widget {
|
|
|
1556
1698
|
}
|
|
1557
1699
|
|
|
1558
1700
|
#rebuild() {
|
|
1701
|
+
const oldBlock = this.blockItems?.[this.blockSel] ?? null;
|
|
1702
|
+
const oldIdentity = oldBlock ? `${oldBlock.nodeKey}:${oldBlock.blockIdx ?? "n"}:${oldBlock.kind}:${oldBlock.codeIndex ?? "-"}` : null;
|
|
1559
1703
|
const w = Math.max(20, this.view.w - 2);
|
|
1560
1704
|
const lines = [];
|
|
1561
1705
|
const lineMap = [];
|
|
@@ -1645,7 +1789,7 @@ export class ChatView extends Widget {
|
|
|
1645
1789
|
const text = node.text ?? "";
|
|
1646
1790
|
const summary = node.source?.summary;
|
|
1647
1791
|
const label = node.kind === "goal-round" ? `🎯 目标续轮 ${node.source?.round ?? ""}`
|
|
1648
|
-
: node.kind === "subagent-receipt" ? (node.source?.kind === "subagent-settled" ? "
|
|
1792
|
+
: node.kind === "subagent-receipt" ? (node.source?.kind === "subagent-settled" ? "◇ 子代理状态" : "◇ 子代理回执")
|
|
1649
1793
|
: `ℹ 上下文 · ${node.source?.kind ?? "注入"}`;
|
|
1650
1794
|
beginCard(node.kind === "goal-round" ? "THINKBG" : "CARD");
|
|
1651
1795
|
lines.push([{ t: ` ${label}${summary ? ` — ${truncate(summary, w - strWidth(label) - 8)}` : ""}`, fg: node.kind === "subagent-receipt" ? T.PURPLE : K.DIM, bold: true }]);
|
|
@@ -1755,8 +1899,12 @@ export class ChatView extends Widget {
|
|
|
1755
1899
|
// failed exit code.
|
|
1756
1900
|
const stopped = !running && (b.stopped || signal === "SIGTERM" || signal === "SIGINT");
|
|
1757
1901
|
const failed = !orphan && !stopped && (b.isError || signal || (exitCode !== undefined && exitCode !== 0));
|
|
1758
|
-
|
|
1759
|
-
|
|
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 ? "◌" : "✓";
|
|
1760
1908
|
const card = cardView ? renderToolCard(cardView, w, open) : [];
|
|
1761
1909
|
beginCard(status);
|
|
1762
1910
|
let timing = "";
|
|
@@ -1773,7 +1921,7 @@ export class ChatView extends Widget {
|
|
|
1773
1921
|
lines.push([
|
|
1774
1922
|
{ t: open ? "▾ " : "▸ ", fg: K.ACCENT },
|
|
1775
1923
|
{ t: ` ${b.name ?? "tool"}`, fg: K.TXT, bold: true },
|
|
1776
|
-
{ t: ` ${glyph}`, fg: failed ? K.
|
|
1924
|
+
{ t: ` ${glyph}`, fg: failed ? K.WARN : running ? K.DIM : K.OK },
|
|
1777
1925
|
{ t: stepTag + timing, fg: K.DIM },
|
|
1778
1926
|
{ t: open ? " [b 折叠]" : " [b 展开]", fg: K.FAINT },
|
|
1779
1927
|
]);
|
|
@@ -1882,7 +2030,10 @@ export class ChatView extends Widget {
|
|
|
1882
2030
|
mark(realIdx, bi);
|
|
1883
2031
|
sep();
|
|
1884
2032
|
} else {
|
|
1885
|
-
|
|
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");
|
|
1886
2037
|
// FORMAL text output is NOT collapsible — the user's message
|
|
1887
2038
|
// content must stay readable; only think/tool blocks fold.
|
|
1888
2039
|
// A neutral assistant glyph identifies model output without
|
|
@@ -1967,12 +2118,200 @@ export class ChatView extends Widget {
|
|
|
1967
2118
|
}
|
|
1968
2119
|
this.lines = lines;
|
|
1969
2120
|
this.lineMap = lineMap;
|
|
2121
|
+
this.#rebuildBlockItems(oldIdentity);
|
|
1970
2122
|
if (process.env.DSH_TUI_DEBUG_CLICK && lineMap.length !== lines.length) {
|
|
1971
2123
|
this.#clickLog(`INVARIANT BROKEN: lines=${lines.length} lineMap=${lineMap.length}`);
|
|
1972
2124
|
}
|
|
1973
2125
|
this.view.setLines(lines);
|
|
1974
2126
|
}
|
|
1975
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
|
+
|
|
1976
2315
|
/** Blank session: whale logo + mode selection prompt (no conversation yet). */
|
|
1977
2316
|
#renderWelcome(screen) {
|
|
1978
2317
|
const x = this.view.x;
|
|
@@ -1980,8 +2319,20 @@ export class ChatView extends Widget {
|
|
|
1980
2319
|
let y = this.view.y + 1;
|
|
1981
2320
|
const put = (t, fg, bold) => { if (y < this.view.y + this.view.h) { screen.text(cx, y, t, { fg, attrs: bold ? 1 : 0 }); } y++; };
|
|
1982
2321
|
put("", 0, false);
|
|
1983
|
-
|
|
1984
|
-
|
|
2322
|
+
this.welcomeVersionRows = [];
|
|
2323
|
+
const versionLine = (name, version, key, fg, bold) => {
|
|
2324
|
+
const check = this.app.versionChecks?.[key];
|
|
2325
|
+
const status = check?.state === "checking" ? "← 检查更新…"
|
|
2326
|
+
: check?.state === "current" ? "← 已是最新"
|
|
2327
|
+
: check?.state === "update" ? `← 可更新 ${check.latest}`
|
|
2328
|
+
: check?.state === "error" ? "← 检查失败(点击重试)"
|
|
2329
|
+
: "← 检查更新";
|
|
2330
|
+
const text = ` ${name} ${version === "unknown" ? "版本未知" : `v${version}`} ${status}`;
|
|
2331
|
+
put(text, fg, bold);
|
|
2332
|
+
this.welcomeVersionRows[y - 1] = { key, x1: cx, x2: cx + strWidth(text) - 1 };
|
|
2333
|
+
};
|
|
2334
|
+
versionLine("DeepSeek Harness", this.app.dshVersion ?? "unknown", "dsh", T.HEADING, true);
|
|
2335
|
+
versionLine("dsh-neotui", TUI_VERSION, "tui", T.FAINT, false);
|
|
1985
2336
|
put("", 0, false);
|
|
1986
2337
|
if (this.app.currentSession == null) {
|
|
1987
2338
|
put(" 打开一个会话开始,或 Ctrl+N 新建", T.DIM, false);
|
|
@@ -1990,16 +2341,23 @@ export class ChatView extends Widget {
|
|
|
1990
2341
|
put(" 请选择模式(F9 或点击下方,选择后立即生效):", T.WARN, true);
|
|
1991
2342
|
put("", 0, false);
|
|
1992
2343
|
this.welcomeModes = [];
|
|
2344
|
+
const currentPreset = this.app.sessions.find((s) => s.sessionId === this.app.currentSession)?.agentPreset;
|
|
1993
2345
|
const presets = [
|
|
1994
2346
|
["standard", "标准模式", "完整编码 Agent(文件/Shell/检索/Skills/目标/子代理)"],
|
|
1995
2347
|
["code", "PTC 模式", "标准模式能力 + Code Mode SDK 单程序多步操作"],
|
|
1996
2348
|
["minimal", "极简模式", "仅持久 bash 与 str_replace_editor 双工具"],
|
|
1997
2349
|
["cordis", "创造模式", "标准模式 + 运行时检查/插件实验/预设创作"],
|
|
1998
2350
|
];
|
|
1999
|
-
|
|
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];
|
|
2000
2355
|
if (y < this.view.y + this.view.h) {
|
|
2001
|
-
|
|
2002
|
-
|
|
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 });
|
|
2003
2361
|
this.welcomeModes[y] = id;
|
|
2004
2362
|
}
|
|
2005
2363
|
y++;
|
|
@@ -2027,7 +2385,38 @@ export class ChatView extends Widget {
|
|
|
2027
2385
|
}
|
|
2028
2386
|
}
|
|
2029
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
|
+
}
|
|
2030
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
|
+
}
|
|
2031
2420
|
this.#renderDiving(screen);
|
|
2032
2421
|
if (this.selStart !== null && this.selEnd !== null) {
|
|
2033
2422
|
const y0 = Math.max(this.view.scrollY, this.selStart);
|
|
@@ -2035,7 +2424,7 @@ export class ChatView extends Widget {
|
|
|
2035
2424
|
if (y1 >= y0) {
|
|
2036
2425
|
for (let line = y0; line <= y1; line++) {
|
|
2037
2426
|
let x0 = this.view.x, x1 = this.view.x + this.view.w - 2;
|
|
2038
|
-
if (this.
|
|
2427
|
+
if (this.selAnchor && this.selFocus) {
|
|
2039
2428
|
const a = this.selAnchor, b = this.selFocus;
|
|
2040
2429
|
const first = a.line < b.line || (a.line === b.line && a.col <= b.col) ? a : b;
|
|
2041
2430
|
const last = first === a ? b : a;
|
|
@@ -2099,7 +2488,7 @@ export class ChatView extends Widget {
|
|
|
2099
2488
|
if (subagent) {
|
|
2100
2489
|
const timing = this.app.projections.subagentTiming;
|
|
2101
2490
|
const ms = (timing?.settledMs ?? 0) + (timing?.active ? Math.max(0, Date.now() - timing.active.since) : 0);
|
|
2102
|
-
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 });
|
|
2103
2492
|
}
|
|
2104
2493
|
if (!todos.length) return;
|
|
2105
2494
|
const done = todos.filter((t) => t.status === "completed").length;
|
|
@@ -2148,6 +2537,8 @@ export class ChatView extends Widget {
|
|
|
2148
2537
|
if (this.app.focused !== this.app.chat?.input) this.app.focus(this);
|
|
2149
2538
|
// Welcome-screen mode click: select the preset under the cursor.
|
|
2150
2539
|
if (this.nodes.length === 0 && ev.kind === "press" && ev.button === 0) {
|
|
2540
|
+
const versionHit = this.welcomeVersionRows?.[ev.y];
|
|
2541
|
+
if (versionHit && ev.x >= versionHit.x1 && ev.x <= versionHit.x2) { this.app.checkUpdates(versionHit.key, true); return true; }
|
|
2151
2542
|
const id = this.welcomeModes[ev.y];
|
|
2152
2543
|
if (id) { this.app.selectPreset(id); return true; }
|
|
2153
2544
|
}
|
|
@@ -2187,7 +2578,7 @@ export class ChatView extends Widget {
|
|
|
2187
2578
|
this.pressInfo = null; this.pressCtx = null;
|
|
2188
2579
|
const rows = this.selEnd - this.selStart + 1;
|
|
2189
2580
|
let text;
|
|
2190
|
-
if (this.
|
|
2581
|
+
if (this.selAnchor && this.selFocus) {
|
|
2191
2582
|
const a = this.selAnchor, b = this.selFocus;
|
|
2192
2583
|
const first = a.line < b.line || (a.line === b.line && a.col <= b.col) ? a : b;
|
|
2193
2584
|
const last = first === a ? b : a;
|
|
@@ -2238,70 +2629,116 @@ export class ChatView extends Widget {
|
|
|
2238
2629
|
return false;
|
|
2239
2630
|
}
|
|
2240
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
|
+
|
|
2241
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
|
+
}
|
|
2242
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;
|
|
2243
2667
|
this.app.focus(this.input);
|
|
2244
2668
|
this.input.insert(ev.text);
|
|
2245
2669
|
return true;
|
|
2246
2670
|
}
|
|
2247
2671
|
if (ev.type !== "key") return false;
|
|
2248
2672
|
if (this.app.focused === this.input) return false;
|
|
2249
|
-
if (ev.name
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
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;
|
|
2258
2682
|
}
|
|
2259
|
-
if (
|
|
2260
|
-
if (this.
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
return this.#jumpQuestion(ev.key === "[" ? -1 : 1);
|
|
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;
|
|
2270
2693
|
}
|
|
2271
|
-
if (
|
|
2272
|
-
if (
|
|
2273
|
-
if (
|
|
2274
|
-
if (
|
|
2275
|
-
|
|
2276
|
-
if (ev.name === "char" && ev.key === "b" && !ev.ctrl && !ev.alt) {
|
|
2277
|
-
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";
|
|
2278
2699
|
this.expanded.clear();
|
|
2279
2700
|
this.collapsedBlocks.clear();
|
|
2280
|
-
this.app.toast(this.
|
|
2701
|
+
this.app.toast(this.thinkMode === "expanded" ? "思考块:全部展开" : "思考块:折叠(t 切换)");
|
|
2281
2702
|
this.queueRebuild();
|
|
2282
2703
|
return true;
|
|
2283
2704
|
}
|
|
2284
|
-
if (
|
|
2285
|
-
|
|
2286
|
-
const wasPinned = this.view.follow || this.view.scrollY >= this.view.maxScroll();
|
|
2287
|
-
const oldMax = this.view.maxScroll();
|
|
2288
|
-
this.todosVisible = !this.todosVisible;
|
|
2289
|
-
this.app.toast(this.todosVisible ? "任务块:已展开(Shift+T 最小化)" : "任务块:已最小化(Shift+T 展开)");
|
|
2290
|
-
this.inputChanged();
|
|
2291
|
-
// Re-anchor immediately: tail readers stay at the tail; readers higher
|
|
2292
|
-
// in history keep the same visible transcript row without a manual nudge.
|
|
2293
|
-
if (wasPinned) { this.view.scrollY = this.view.maxScroll(); this.view.follow = true; }
|
|
2294
|
-
else this.view.scrollY = Math.max(0, Math.min(this.view.maxScroll(), this.view.scrollY + (this.view.maxScroll() - oldMax)));
|
|
2295
|
-
this.app.redraw();
|
|
2296
|
-
return true;
|
|
2297
|
-
}
|
|
2298
|
-
this.thinkMode = this.thinkMode === "collapsed" ? "expanded" : "collapsed";
|
|
2705
|
+
if (chatHit?.id === "tools" && this.cursorMode === "block") {
|
|
2706
|
+
this.bashMode = this.bashMode === "collapsed" ? "expanded" : "collapsed";
|
|
2299
2707
|
this.expanded.clear();
|
|
2300
2708
|
this.collapsedBlocks.clear();
|
|
2301
|
-
this.app.toast(this.
|
|
2709
|
+
this.app.toast(this.bashMode === "collapsed" ? "工具块:折叠(b 展开)" : "工具块:展开(b 折叠)");
|
|
2302
2710
|
this.queueRebuild();
|
|
2303
2711
|
return true;
|
|
2304
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();
|
|
2305
2742
|
return false;
|
|
2306
2743
|
}
|
|
2307
2744
|
}
|
|
@@ -2501,8 +2938,8 @@ export class QuestionPopup extends Popup {
|
|
|
2501
2938
|
if(this.customEditing&&ev.name==="right"){this.customCursor=Math.min(Array.from(draft.custom).length,this.customCursor+1);return true;}
|
|
2502
2939
|
if(this.customEditing&&ev.name==="home"){this.customCursor=0;return true;}
|
|
2503
2940
|
if(this.customEditing&&ev.name==="end"){this.customCursor=Array.from(draft.custom).length;return true;}
|
|
2504
|
-
if (ev.name === "up") { this.customEditing=false;this.selIdx =
|
|
2505
|
-
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; }
|
|
2506
2943
|
if (ev.name === "char" && ev.key === " " && count && this.selIdx<count) { this.#choose(this.selIdx); return true; }
|
|
2507
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;}
|
|
2508
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;}
|
|
@@ -2596,11 +3033,12 @@ export class ApprovalPopup extends Popup {
|
|
|
2596
3033
|
// ---- App ----
|
|
2597
3034
|
|
|
2598
3035
|
export class App {
|
|
2599
|
-
constructor({ screen, term, api, base, log }) {
|
|
3036
|
+
constructor({ screen, term, api, base, log, versionFetcher = latestNpmVersion }) {
|
|
2600
3037
|
this.screen = screen;
|
|
2601
3038
|
this.term = term;
|
|
2602
3039
|
this.api = api;
|
|
2603
3040
|
this.log = log ?? (() => {});
|
|
3041
|
+
this.versionFetcher = versionFetcher;
|
|
2604
3042
|
this.popup = null;
|
|
2605
3043
|
this.activePrompt = null;
|
|
2606
3044
|
this.promptQueue = [];
|
|
@@ -2621,13 +3059,18 @@ export class App {
|
|
|
2621
3059
|
this.sessionEpoch = 0;
|
|
2622
3060
|
this.refreshSessionsSeq = 0;
|
|
2623
3061
|
this.searchSeq = 0;
|
|
2624
|
-
this.searchSelected = 0;
|
|
2625
3062
|
this.connState = "connecting";
|
|
2626
3063
|
this.tokenUsage = null;
|
|
2627
3064
|
this.sessions = [];
|
|
2628
3065
|
this.currentSession = null;
|
|
3066
|
+
this.dshVersion = installedDshVersion();
|
|
3067
|
+
this.versionChecks = {
|
|
3068
|
+
dsh: { state: "idle", latest: null },
|
|
3069
|
+
tui: { state: "idle", latest: null },
|
|
3070
|
+
};
|
|
2629
3071
|
this.searchActive = false;
|
|
2630
3072
|
this.overlay = null; // Picker / Popup / ImagePopup modal
|
|
3073
|
+
this.fullBuffer = null; // full-screen panel buffer (workspace/settings/models/subagent/skills)
|
|
2631
3074
|
this.mode = "chat"; // chat | workspace | trajectory
|
|
2632
3075
|
this.sidebarWanted = true;
|
|
2633
3076
|
this.sidebarVisible = true; // auto-collapses on narrow terminals
|
|
@@ -2650,7 +3093,8 @@ export class App {
|
|
|
2650
3093
|
this.sidebar = new SidebarTree(this);
|
|
2651
3094
|
this.sidebar.w = this.sidebarWidth;
|
|
2652
3095
|
this.sidebar.h = screen.h - 1;
|
|
2653
|
-
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;
|
|
2654
3098
|
this.chat = new ChatView({ x: this.sidebarWidth, y: 0, w: screen.w - this.sidebarWidth, h: screen.h - 1, app: this });
|
|
2655
3099
|
this.status = new StatusBar({ x: 0, y: screen.h - 1, w: screen.w, h: 1 });
|
|
2656
3100
|
this.focus(this.chat);
|
|
@@ -2674,9 +3118,8 @@ export class App {
|
|
|
2674
3118
|
this.sidebar.x = 0; this.sidebar.y = 0; this.sidebar.w = this.sidebarWidth; this.sidebar.h = this.screen.h - 1;
|
|
2675
3119
|
this.searchInput.w = this.sidebarWidth;
|
|
2676
3120
|
this.chat.resize(x, 1, w, mainH);
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
}
|
|
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);
|
|
2680
3123
|
this.status.y = this.screen.h - footerH;
|
|
2681
3124
|
this.status.h = footerH;
|
|
2682
3125
|
this.status.w = this.screen.w;
|
|
@@ -2693,6 +3136,52 @@ export class App {
|
|
|
2693
3136
|
this.setMode(this.mode === "trajectory" ? "chat" : "trajectory");
|
|
2694
3137
|
}
|
|
2695
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
|
+
|
|
3160
|
+
async checkUpdates(target = null, notify = false) {
|
|
3161
|
+
const specs = {
|
|
3162
|
+
dsh: { package: "@deepseek-ai/dsh", current: this.dshVersion, label: "DeepSeek Harness" },
|
|
3163
|
+
tui: { package: "dsh-neotui", current: TUI_VERSION, label: "dsh-neotui" },
|
|
3164
|
+
};
|
|
3165
|
+
const keys = target ? [target] : Object.keys(specs);
|
|
3166
|
+
await Promise.all(keys.map(async (key) => {
|
|
3167
|
+
const spec = specs[key];
|
|
3168
|
+
if (!spec) return;
|
|
3169
|
+
this.versionChecks[key] = { state: "checking", latest: null };
|
|
3170
|
+
this.redraw();
|
|
3171
|
+
try {
|
|
3172
|
+
const latest = await this.versionFetcher(spec.package);
|
|
3173
|
+
const comparison = compareSemver(spec.current, latest);
|
|
3174
|
+
const state = comparison === null ? (latest === spec.current ? "current" : "update") : comparison < 0 ? "update" : "current";
|
|
3175
|
+
this.versionChecks[key] = { state, latest };
|
|
3176
|
+
if (notify) this.toast(state === "current" ? `${spec.label} 已是最新版本 ${spec.current}` : `${spec.label} 可更新: ${spec.current} → ${latest}`);
|
|
3177
|
+
} catch (error) {
|
|
3178
|
+
this.versionChecks[key] = { state: "error", latest: null };
|
|
3179
|
+
if (notify) this.toast(`${spec.label} 更新检查失败: ${error.message}`);
|
|
3180
|
+
}
|
|
3181
|
+
this.redraw();
|
|
3182
|
+
}));
|
|
3183
|
+
}
|
|
3184
|
+
|
|
2696
3185
|
/** Chat → trajectory: open the trajectory panel at the step containing a
|
|
2697
3186
|
* chat node (right-click menu), loading older steps on demand. */
|
|
2698
3187
|
async jumpToTrajectoryNode(nodeIdx) {
|
|
@@ -2825,6 +3314,7 @@ export class App {
|
|
|
2825
3314
|
this.provider = host.provider ?? "";
|
|
2826
3315
|
this.model = host.model ?? "";
|
|
2827
3316
|
} catch (e) { this.log(`[app] host.describe: ${e.message}`); }
|
|
3317
|
+
void this.checkUpdates();
|
|
2828
3318
|
await this.refreshSessions();
|
|
2829
3319
|
// A /restart handoff carries the session to reopen: resume it instead of
|
|
2830
3320
|
// minting a fresh blank session (which was leaking a stray "new session"
|
|
@@ -3237,6 +3727,7 @@ export class App {
|
|
|
3237
3727
|
? { workspaceId: group.workspaceId }
|
|
3238
3728
|
: { cwd: group?.path ?? process.cwd() };
|
|
3239
3729
|
const { sessionId } = await this.api.call("session.create", payload);
|
|
3730
|
+
if (typeof sessionId !== "string" || !sessionId) throw new Error("Host 未返回会话 ID");
|
|
3240
3731
|
await this.refreshSessions();
|
|
3241
3732
|
this.openSession(sessionId);
|
|
3242
3733
|
} catch (e) { this.toast(`创建失败: ${e.message}`); }
|
|
@@ -3250,6 +3741,7 @@ export class App {
|
|
|
3250
3741
|
async newSession() { return this.newSessionIn(null); }
|
|
3251
3742
|
|
|
3252
3743
|
async openSession(sessionId) {
|
|
3744
|
+
if (typeof sessionId !== "string" || !sessionId) { this.toast("无法打开会话:缺少会话 ID"); return; }
|
|
3253
3745
|
const epoch = ++this.sessionEpoch;
|
|
3254
3746
|
this.currentSession = sessionId;
|
|
3255
3747
|
this.projections = { ...(this.projectionsBySession.get(sessionId) ?? {}) };
|
|
@@ -3269,6 +3761,12 @@ export class App {
|
|
|
3269
3761
|
}
|
|
3270
3762
|
await this.chat.open(sessionId, epoch);
|
|
3271
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;
|
|
3272
3770
|
this.loadFeedback(sessionId, epoch);
|
|
3273
3771
|
this.updateModel(sessionId, epoch);
|
|
3274
3772
|
this.refreshSubagentStats(sessionId);
|
|
@@ -3307,47 +3805,50 @@ export class App {
|
|
|
3307
3805
|
}
|
|
3308
3806
|
|
|
3309
3807
|
setMode(mode) {
|
|
3310
|
-
this.mode = mode;
|
|
3311
|
-
if (mode === "
|
|
3312
|
-
if (!this.workspacePanel) {
|
|
3313
|
-
this.workspacePanel = new WorkspacePanel(this);
|
|
3314
|
-
this.workspacePanel.load();
|
|
3315
|
-
} else {
|
|
3316
|
-
this.workspacePanel.load();
|
|
3317
|
-
}
|
|
3318
|
-
} else if (mode === "trajectory") {
|
|
3808
|
+
this.mode = mode === "trajectory" ? "trajectory" : "chat";
|
|
3809
|
+
if (this.mode === "trajectory") {
|
|
3319
3810
|
if (!this.currentSession) { this.toast("先打开一个会话"); this.mode = "chat"; this.redraw(); return; }
|
|
3320
3811
|
if (!this.trajectoryPanel) this.trajectoryPanel = new TrajectoryPanel(this);
|
|
3321
3812
|
this.trajectoryPanel.load(this.currentSession);
|
|
3322
|
-
} else if (mode === "settings") {
|
|
3323
|
-
if (!this.settingsPanel) this.settingsPanel = new SettingsPanel(this);
|
|
3324
|
-
this.settingsPanel.load();
|
|
3325
|
-
} else if (mode === "models") {
|
|
3326
|
-
if (!this.modelPanel) this.modelPanel = new ModelPanel(this);
|
|
3327
|
-
this.modelPanel.load();
|
|
3328
|
-
} else if (mode === "subagent") {
|
|
3329
|
-
if (!this.currentSession) { this.toast("先打开一个会话"); this.mode = "chat"; this.redraw(); return; }
|
|
3330
|
-
if (!this.subagentPanel) this.subagentPanel = new SubagentPanel(this);
|
|
3331
|
-
this.subagentPanel.load(this.currentSession);
|
|
3332
|
-
} else if (mode === "skills") {
|
|
3333
|
-
if (!this.currentSession) { this.toast("先打开一个会话"); this.mode = "chat"; this.redraw(); return; }
|
|
3334
|
-
if (!this.skillsPanel) this.skillsPanel = new SkillsPanel(this);
|
|
3335
|
-
this.skillsPanel.load();
|
|
3336
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);
|
|
3337
3817
|
this.layout();
|
|
3338
3818
|
this.redraw();
|
|
3339
3819
|
}
|
|
3340
3820
|
|
|
3341
|
-
panelForMode() {
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
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();
|
|
3351
3852
|
}
|
|
3352
3853
|
|
|
3353
3854
|
closeOverlay() { this.overlay = null; this.redraw(); }
|
|
@@ -3409,7 +3910,6 @@ export class App {
|
|
|
3409
3910
|
this.chat.nodes = [];
|
|
3410
3911
|
this.chat.collapsedBlocks.clear();
|
|
3411
3912
|
this.chat.expanded.clear();
|
|
3412
|
-
this.chat.expandedTools.clear();
|
|
3413
3913
|
this.chat.queueRebuild();
|
|
3414
3914
|
this.chat.view.anchorLock = null;
|
|
3415
3915
|
this.mode = "chat";
|
|
@@ -3437,7 +3937,7 @@ export class App {
|
|
|
3437
3937
|
const argv = process.argv.slice(1);
|
|
3438
3938
|
// hand the CURRENT session to the new instance so it reopens it instead
|
|
3439
3939
|
// of minting a fresh blank session (the "strange new session" in 未分组)
|
|
3440
|
-
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" };
|
|
3441
3941
|
const child = spawn("sh", ["-c", 'sleep 1; exec "$@"', "sh", ...argv], { detached: true, stdio: "inherit", env });
|
|
3442
3942
|
child.unref();
|
|
3443
3943
|
} catch (e) {
|
|
@@ -3498,7 +3998,9 @@ export class App {
|
|
|
3498
3998
|
}
|
|
3499
3999
|
try {
|
|
3500
4000
|
await this.api.call("agentPreset.select", { sessionId: this.currentSession, agentPreset: id });
|
|
4001
|
+
if (sess) sess.agentPreset = id;
|
|
3501
4002
|
this.toast(`模式已切换: ${modeName(id)}`);
|
|
4003
|
+
this.redraw();
|
|
3502
4004
|
this.refreshSessions();
|
|
3503
4005
|
} catch (e) {
|
|
3504
4006
|
if (e.code === "agent-preset-locked") { this.toast("会话已开始,模式固定;已设为新会话默认"); this.setDefaultPreset(id); }
|
|
@@ -3547,7 +4049,7 @@ export class App {
|
|
|
3547
4049
|
} catch (e) { this.toast(`权限切换失败: ${e.message}`); }
|
|
3548
4050
|
}
|
|
3549
4051
|
|
|
3550
|
-
/** F8: cycle read-only → workspace-write → danger-full-access
|
|
4052
|
+
/** F8: cycle read-only → workspace-write → danger-full-access. */
|
|
3551
4053
|
rotatePermission() {
|
|
3552
4054
|
const order = ["read-only", "workspace-write", "danger-full-access"];
|
|
3553
4055
|
const cur = this.projections.permissions?.currentValue;
|
|
@@ -3556,6 +4058,58 @@ export class App {
|
|
|
3556
4058
|
this.switchPermission(next);
|
|
3557
4059
|
}
|
|
3558
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
|
+
|
|
3559
4113
|
showFilePicker() {
|
|
3560
4114
|
const session = this.sessions.find((s) => s.sessionId === this.currentSession);
|
|
3561
4115
|
this.overlay = new UploadPicker(this, { startPath: session?.cwd ?? process.cwd(), onUpload: (files) => {
|
|
@@ -3582,27 +4136,22 @@ export class App {
|
|
|
3582
4136
|
}
|
|
3583
4137
|
|
|
3584
4138
|
#modeTabs() {
|
|
3585
|
-
//
|
|
4139
|
+
// Ctrl+Left/Right pane targets. The other panels are full-screen buffers.
|
|
3586
4140
|
return [
|
|
3587
4141
|
["chat", "对话"],
|
|
3588
4142
|
["trajectory", "轨迹"],
|
|
3589
4143
|
];
|
|
3590
4144
|
}
|
|
3591
4145
|
|
|
3592
|
-
#panelLabel(mode) {
|
|
3593
|
-
return { workspace: "工作区", settings: "设置", models: "模型供应商", skills: "技能", subagent: "子代理" }[mode] ?? null;
|
|
3594
|
-
}
|
|
3595
|
-
|
|
3596
4146
|
#renderTabBar(s) {
|
|
3597
4147
|
const x = this.sidebarVisible ? this.sidebarWidth : 0;
|
|
3598
4148
|
const w = this.screen.w - x;
|
|
3599
4149
|
s.fillRect(x, 0, x + w - 1, 0, " ", { bg: T.PANEL });
|
|
3600
4150
|
const tabs = [...this.#modeTabs()];
|
|
3601
|
-
const panelLabel = this.#panelLabel(this.mode);
|
|
3602
|
-
if (panelLabel) tabs.push([this.mode, panelLabel]);
|
|
3603
4151
|
let tx = x;
|
|
4152
|
+
const sidebarFocused = this.focused === this.sidebar;
|
|
3604
4153
|
for (const [id, label] of tabs) {
|
|
3605
|
-
const sel =
|
|
4154
|
+
const sel = !sidebarFocused && id === this.mode;
|
|
3606
4155
|
const seg = ` ${label} `;
|
|
3607
4156
|
s.text(tx, 0, seg, { fg: sel ? T.SELFG : T.DIM, bg: sel ? T.ACCENT : T.PANEL, attrs: sel ? 1 : 0 });
|
|
3608
4157
|
tx += strWidth(seg);
|
|
@@ -3613,14 +4162,11 @@ export class App {
|
|
|
3613
4162
|
#clickTab(px) {
|
|
3614
4163
|
const x = this.sidebarVisible ? this.sidebarWidth : 0;
|
|
3615
4164
|
const tabs = [...this.#modeTabs()];
|
|
3616
|
-
const panelLabel = this.#panelLabel(this.mode);
|
|
3617
|
-
if (panelLabel) tabs.push([this.mode, panelLabel]);
|
|
3618
4165
|
let tx = x;
|
|
3619
4166
|
for (const [id, label] of tabs) {
|
|
3620
4167
|
const seg = ` ${label} `;
|
|
3621
4168
|
if (px >= tx && px < tx + strWidth(seg)) {
|
|
3622
|
-
|
|
3623
|
-
this.setMode(id === "chat" || id === "trajectory" || this.#panelLabel(id) ? id : "chat");
|
|
4169
|
+
this.setMode(id);
|
|
3624
4170
|
return true;
|
|
3625
4171
|
}
|
|
3626
4172
|
tx += strWidth(seg);
|
|
@@ -3701,6 +4247,21 @@ export class App {
|
|
|
3701
4247
|
this.redraw();
|
|
3702
4248
|
return;
|
|
3703
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
|
+
}
|
|
3704
4265
|
|
|
3705
4266
|
// tab bar clicks (row 0 of the main area)
|
|
3706
4267
|
if (ev.type === "mouse" && ev.kind === "press" && ev.button === 0 && ev.y === 0 && ev.x >= (this.sidebarVisible ? this.sidebarWidth : 0)) {
|
|
@@ -3714,11 +4275,15 @@ export class App {
|
|
|
3714
4275
|
return;
|
|
3715
4276
|
}
|
|
3716
4277
|
const panel = this.panelForMode();
|
|
3717
|
-
|
|
3718
|
-
|
|
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);
|
|
3719
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; }
|
|
3720
4285
|
}
|
|
3721
|
-
// unhandled
|
|
4286
|
+
// unhandled key events fall through to global shortcuts
|
|
3722
4287
|
}
|
|
3723
4288
|
if (ev.type === "mouse") {
|
|
3724
4289
|
// input drag-selection: the gesture continues across motion events
|
|
@@ -3774,7 +4339,7 @@ export class App {
|
|
|
3774
4339
|
} else if (this.chat.inside(ev.x, ev.y)) {
|
|
3775
4340
|
if (this.focused !== this.chat.input) this.focus(this.chat); // INSERT exits only via Esc
|
|
3776
4341
|
if (this.chat.onMouse(ev)) this.redraw();
|
|
3777
|
-
} else if (this.focused?.onMouse(ev)) {
|
|
4342
|
+
} else if (this.focused?.onMouse?.(ev)) {
|
|
3778
4343
|
this.redraw();
|
|
3779
4344
|
}
|
|
3780
4345
|
return;
|
|
@@ -3811,29 +4376,21 @@ export class App {
|
|
|
3811
4376
|
this.redraw();
|
|
3812
4377
|
return;
|
|
3813
4378
|
}
|
|
3814
|
-
if (this.searchActive && (ev.ctrl && ev.key === " " || ev.name === "f7")) {
|
|
3815
|
-
this.overlay = new ControlPanel(this, { startPage: 0 });
|
|
3816
|
-
this.redraw();
|
|
3817
|
-
return;
|
|
3818
|
-
}
|
|
3819
4379
|
if (this.searchActive) {
|
|
3820
4380
|
this.#onSearchKey(ev);
|
|
3821
4381
|
this.redraw();
|
|
3822
4382
|
return;
|
|
3823
4383
|
}
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
this.redraw();
|
|
3833
|
-
return;
|
|
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;
|
|
3834
4392
|
}
|
|
3835
|
-
if (ev.ctrl && ev.key === "
|
|
3836
|
-
if (ev.ctrl && ev.key === "c") {
|
|
4393
|
+
if (ev.ctrl && ev.key === "c" && !ev.shift) {
|
|
3837
4394
|
// NORMAL-mode Ctrl+C: two presses within the toast window exit the
|
|
3838
4395
|
// process; the first press just warns (insert mode owns Ctrl+C for
|
|
3839
4396
|
// clearing the input).
|
|
@@ -3844,34 +4401,9 @@ export class App {
|
|
|
3844
4401
|
this.toast("再按一次 Ctrl+C 退出 TUI");
|
|
3845
4402
|
return;
|
|
3846
4403
|
}
|
|
3847
|
-
if (ev.ctrl && ev.key === "
|
|
3848
|
-
if (ev.ctrl && ev.key === "o") { this.overlay = new AttachmentPanel(this); this.focus(this.overlay); this.redraw(); return; }
|
|
3849
|
-
if (ev.ctrl && ev.key === "b") { this.toggleSidebar(); return; }
|
|
4404
|
+
if (ev.ctrl && ev.shift && ev.key === "w") { this.addWorkspace(); return; }
|
|
3850
4405
|
if (ev.ctrl && ev.key === "p") { this.overlay = new ControlPanel(this, { startPage: 1 }); this.redraw(); return; }
|
|
3851
|
-
if (ev.ctrl && ev.key === "m") { this.overlay = buildModelPicker(this); this.redraw(); return; }
|
|
3852
|
-
if (ev.name === "f8") { this.rotatePermission(); return; }
|
|
3853
4406
|
if (ev.name === "f9") { this.showModePicker(); return; }
|
|
3854
|
-
if (ev.ctrl && ev.key === "w") {
|
|
3855
|
-
if (ev.shift) { this.addWorkspace(); return; }
|
|
3856
|
-
this.setMode("workspace"); return;
|
|
3857
|
-
}
|
|
3858
|
-
if (ev.ctrl && ev.key === "t") { this.setMode("trajectory"); return; }
|
|
3859
|
-
if (ev.ctrl && ev.key === "e") { this.quickJumpStep(); return; }
|
|
3860
|
-
if (ev.ctrl && ev.key === "j") { this.showJobs(); return; }
|
|
3861
|
-
if (ev.ctrl && ev.key === "n") { this.showQueue(); return; }
|
|
3862
|
-
if (ev.ctrl && ev.key === "y") {
|
|
3863
|
-
const next = busyEnter() === "queue" ? "steer" : "queue";
|
|
3864
|
-
saveTuiConfig({ busyEnter: next });
|
|
3865
|
-
this.toast(`运行中 Enter:${next === "steer" ? "追加到当前回合" : "加入队列"}`);
|
|
3866
|
-
return;
|
|
3867
|
-
}
|
|
3868
|
-
if (ev.ctrl && ev.key === "g") { this.showGoal(); return; }
|
|
3869
|
-
if (ev.ctrl && ev.key === "f") { this.startSearch(); return; }
|
|
3870
|
-
if (ev.ctrl && ev.key === "s") { this.setMode("settings"); return; }
|
|
3871
|
-
if (ev.ctrl && ev.key === "a") { this.setMode("subagent"); return; }
|
|
3872
|
-
if (ev.ctrl && ev.key === "k") { this.setMode("skills"); return; }
|
|
3873
|
-
if (ev.name === "char" && ev.key === "/" && !ev.ctrl && this.focused !== this.chat.input) { this.startSearch(); this.redraw(); return; }
|
|
3874
|
-
if (ev.name === "char" && ev.key === "n" && !ev.ctrl && this.focused === this.sidebar) { this.newSession(); return; }
|
|
3875
4407
|
if (ev.name === "escape") {
|
|
3876
4408
|
// Esc in NORMAL mode interrupts a running turn (one press, regardless
|
|
3877
4409
|
// of focus); otherwise it steps back toward the chat view.
|
|
@@ -3880,28 +4412,28 @@ export class App {
|
|
|
3880
4412
|
else if (this.mode !== "chat") this.setMode("chat");
|
|
3881
4413
|
return;
|
|
3882
4414
|
}
|
|
3883
|
-
if (ev.name === "char" && ev.key === "i" && this.focused === this.sidebar) { this.focus(this.chat.input); this.redraw(); return; }
|
|
3884
4415
|
}
|
|
3885
4416
|
// nvim-style normal mode: single chars are shortcuts (chat first, then the
|
|
3886
4417
|
// focused pane). Multi-char text (paste/IME) still types into the input.
|
|
3887
4418
|
if ((ev.type === "text" || ev.type === "paste") && this.focused !== this.chat.input) {
|
|
3888
4419
|
if (this.searchActive) {
|
|
3889
|
-
this
|
|
3890
|
-
this.#refreshSearch();
|
|
4420
|
+
this.#onSearchKey(ev);
|
|
3891
4421
|
this.redraw();
|
|
3892
4422
|
return;
|
|
3893
4423
|
}
|
|
3894
|
-
if (ev.text.length === 1) {
|
|
3895
|
-
// Legacy terminals deliver Shift+letter
|
|
3896
|
-
//
|
|
3897
|
-
//
|
|
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];
|
|
3898
4429
|
const asKey = {
|
|
3899
4430
|
type: "key", name: "char",
|
|
3900
|
-
key:
|
|
3901
|
-
ctrl: false, alt: false, shift:
|
|
4431
|
+
key: text.toLowerCase(), text,
|
|
4432
|
+
ctrl: false, alt: false, shift: text !== text.toLowerCase(),
|
|
3902
4433
|
};
|
|
4434
|
+
if (this.focused && this.focused !== this.chat && this.focused !== this.chat.input && this.focused.onKey?.(asKey)) { this.redraw(); return; }
|
|
3903
4435
|
if (this.chat.onKey(asKey)) { this.redraw(); return; }
|
|
3904
|
-
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; }
|
|
3905
4437
|
this.toast("按 i 进入输入");
|
|
3906
4438
|
return;
|
|
3907
4439
|
}
|
|
@@ -3912,47 +4444,249 @@ export class App {
|
|
|
3912
4444
|
}
|
|
3913
4445
|
// focused widget
|
|
3914
4446
|
if (this.focused) {
|
|
3915
|
-
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);
|
|
3916
4448
|
if (handled) this.redraw();
|
|
3917
4449
|
}
|
|
3918
4450
|
}
|
|
3919
4451
|
|
|
3920
4452
|
startSearch() {
|
|
4453
|
+
this.searchSeq++;
|
|
3921
4454
|
this.searchActive = true;
|
|
3922
4455
|
this.searchInput.setValue("");
|
|
3923
|
-
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 };
|
|
3924
4457
|
this.focus(this.searchInput);
|
|
4458
|
+
this.redraw();
|
|
3925
4459
|
}
|
|
3926
4460
|
|
|
3927
|
-
#
|
|
3928
|
-
const
|
|
3929
|
-
|
|
3930
|
-
const fuzzy = (text) => { let i=0; for(const ch of String(text).toLowerCase()) if(ch===query[i]) i++; return i===query.length; };
|
|
3931
|
-
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: "未分组" };
|
|
3932
4464
|
}
|
|
3933
4465
|
|
|
3934
|
-
#
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
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);
|
|
3945
4481
|
}
|
|
3946
|
-
|
|
3947
|
-
|
|
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()];
|
|
3948
4541
|
}
|
|
3949
4542
|
|
|
3950
|
-
#
|
|
3951
|
-
const
|
|
3952
|
-
const
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
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
|
+
}
|
|
4590
|
+
}
|
|
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();
|
|
4635
|
+
}
|
|
4636
|
+
|
|
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 });
|
|
3956
4690
|
}
|
|
3957
4691
|
|
|
3958
4692
|
redraw() {
|
|
@@ -4006,17 +4740,27 @@ export class App {
|
|
|
4006
4740
|
this.term.output.write(s.render() + "\x1b[?25l");
|
|
4007
4741
|
return;
|
|
4008
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
|
+
}
|
|
4009
4760
|
this.#renderTabBar(s);
|
|
4010
4761
|
if (this.sidebarVisible) {
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
this.sidebar.y = 1; this.sidebar.h = s.h - 2;
|
|
4014
|
-
if (this.searchResults !== null) this.#renderSearchResults(s);
|
|
4015
|
-
else this.sidebar.render(s);
|
|
4016
|
-
} else {
|
|
4017
|
-
this.sidebar.y = 0; this.sidebar.h = s.h - 1;
|
|
4018
|
-
this.sidebar.render(s);
|
|
4019
|
-
}
|
|
4762
|
+
this.sidebar.y = 0; this.sidebar.h = s.h - 1;
|
|
4763
|
+
this.sidebar.render(s);
|
|
4020
4764
|
s.put(this.sidebar.w - 1, 0, "│", { fg: T.BORDER });
|
|
4021
4765
|
for (let y = 1; y < s.h - 1; y++) s.put(this.sidebar.w - 1, y, "│", { fg: T.BORDER });
|
|
4022
4766
|
}
|
|
@@ -4050,7 +4794,7 @@ export class App {
|
|
|
4050
4794
|
row0.left.push({ t: ` ${badge} `, fg: T.SELFG, bg: T.ACCENT, bold: true });
|
|
4051
4795
|
const rawGoal = this.goalData?.goal ?? this.goalData;
|
|
4052
4796
|
if (rawGoal && !["complete", "completed", "cleared"].includes(rawGoal.phase)) {
|
|
4053
|
-
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 });
|
|
4054
4798
|
}
|
|
4055
4799
|
if (this.sidebarVisible) row0.left.push({ t: " " + truncate(t || "(未选择会话)", 40) + " ", fg: T.TXT, bg: T.STATUSBG });
|
|
4056
4800
|
else row0.left.push({ t: " " + truncate(t || "(未选择会话)", 40) + " ", fg: T.TXT, bg: T.STATUSBG });
|
|
@@ -4131,15 +4875,32 @@ export class App {
|
|
|
4131
4875
|
const sub = this.projections.subagent;
|
|
4132
4876
|
const subTiming = this.projections.subagentTiming;
|
|
4133
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.
|
|
4134
4880
|
row2.left.push({
|
|
4135
4881
|
t: ` ${running > 0 ? `${running} 个后台任务运行中` : "没有后台任务运行"} `,
|
|
4136
|
-
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,
|
|
4891
|
+
});
|
|
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,
|
|
4137
4902
|
});
|
|
4138
|
-
row2.left.push({
|
|
4139
|
-
row2.left.push({ t: ` ${subStats.running > 0 ? `${subStats.running} 个子代理运行中` : "没有子代理运行"} ${subStats.completed}已完成 `, fg: subStats.running > 0 ? T.PURPLE : T.FAINT, bg: T.STATUSBG, bold: subStats.running > 0 });
|
|
4140
|
-
if (sub) row2.left.push({ t: ` 🛰 ${truncate(sub.label ?? sub.mode ?? "子代理", 20)} `, fg: T.PURPLE, bg: T.STATUSBG });
|
|
4141
|
-
if(this.queueItems.length)row2.left.push({t:` 有${this.queueItems.length}条命令正在排队 Ctrl+N查看详情 `,fg:T.SELFG,bg:T.WARN,bold:true});
|
|
4142
|
-
row2.right.push({ t: " Ctrl+J 任务/子代理 ", fg: T.DIM, bg: T.STATUSBG });
|
|
4903
|
+
if(this.queueItems.length)row2.left.push({t:` 有${this.queueItems.length}条命令正在排队 Ctrl+N查看详情 `,fg:0x000000,bg:T.WARN,bold:true});
|
|
4143
4904
|
rows.push(row2);
|
|
4144
4905
|
}
|
|
4145
4906
|
this.status.rows = rows;
|
|
@@ -4148,15 +4909,7 @@ export class App {
|
|
|
4148
4909
|
if (this.popup) this.popup.render(s);
|
|
4149
4910
|
if (this.menu) this.menu.render(s);
|
|
4150
4911
|
if (this.overlay) this.overlay.render(s);
|
|
4151
|
-
|
|
4152
|
-
// Toasts land in the LOWER half (just above the input/footer) where the
|
|
4153
|
-
// user's attention is while pressing shortcuts — a solid color block.
|
|
4154
|
-
const w = Math.min(s.w - 4, strWidth(this.toastMsg) + 6);
|
|
4155
|
-
const x0 = Math.max(2, Math.floor((s.w - w) / 2));
|
|
4156
|
-
const y = Math.max(1, this.chat.input.y - this.chat.todoHeight() - 2);
|
|
4157
|
-
s.fillRect(x0, y, x0 + w - 1, y, " ", { bg: T.ACCENT });
|
|
4158
|
-
s.text(x0 + 1, y, truncate(this.toastMsg, w - 2), { fg: T.SELFG, bg: T.ACCENT, attrs: 1 });
|
|
4159
|
-
}
|
|
4912
|
+
this.#renderToast(s);
|
|
4160
4913
|
if (this.renameInput && this.popup) this.renameInput.render(s);
|
|
4161
4914
|
|
|
4162
4915
|
const out = s.render();
|
|
@@ -4174,6 +4927,17 @@ export class App {
|
|
|
4174
4927
|
this.term.output.write(out + tail);
|
|
4175
4928
|
}
|
|
4176
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
|
+
|
|
4177
4941
|
titleOf() {
|
|
4178
4942
|
const s = this.sessions.find((x) => x.sessionId === this.currentSession);
|
|
4179
4943
|
if (s) return s.projections?.values?.title ?? s.sessionId.slice(0, 8);
|