dsh-neotui 0.0.8 → 0.0.10
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/README.md +11 -2
- package/package.json +1 -1
- package/src/config.js +64 -0
- package/src/panels.js +394 -51
- package/src/text.js +10 -0
- package/src/views.js +185 -46
package/README.md
CHANGED
|
@@ -59,7 +59,11 @@ tui/app/ dsh-neotui-app bundle:cordis.patch.yml + tui-startup/tui-r
|
|
|
59
59
|
| 设置次级页 | 在「设置」页内 **Shift+Tab** 翻次级页(常规 / 插件) |
|
|
60
60
|
| 主页切换 | **Shift+Tab** 在对话 ↔ 轨迹之间切换;顶部标签页(对话/轨迹/工作区/设置/技能/子代理)可点击直达 |
|
|
61
61
|
| 输入模式 | `i` 或点击进入,**Esc 退出**(nvim normal/insert);未聚焦时字母=快捷键,多字粘贴直接输入 |
|
|
62
|
-
| 思考/工具折叠 | `t` 思考块展开折叠,`b` 工具块(bash
|
|
62
|
+
| 思考/工具折叠 | `t` 思考块展开折叠,`b` 工具块(bash 等)展开折叠;右键消息 →「展开 / 折叠」折叠单个输出块 |
|
|
63
|
+
| 对话 ↔ 轨迹转跳 | 对话里右键消息 →「转跳轨迹」定位到对应 step(自动展开+高亮);轨迹里右键 step →「转跳对话」回到该消息 |
|
|
64
|
+
| 步骤快速转跳 | **Ctrl+E**:fzf 式过滤 step 列表(编号/耗时/工具/消息预览),回车定位到该 step 的轨迹窗口 |
|
|
65
|
+
| 轨迹窗口导航 | 转跳后只显示 `step±20` 窗口;**PgUp/PgDn** 上下各加载 10 步,**Home/End** 跳到最早/最新 20 步,`r` 回最新 |
|
|
66
|
+
| 后台任务 | **Ctrl+J** 后台任务面板(Enter/→/l 展开详情,←/h 折叠,q 关闭);状态栏只显示「N 个任务正在后台运行 · M已完成 (Ctrl+J 查看详情)」 |
|
|
63
67
|
| 右键文件夹 → 新建会话(归属该工作区) | `n` |
|
|
64
68
|
| 滚轮翻页 / 拖拽 | PgUp / PgDn / gg / G |
|
|
65
69
|
| 点击展开思考块/工具卡 | — |
|
|
@@ -94,12 +98,17 @@ tui/app/ dsh-neotui-app bundle:cordis.patch.yml + tui-startup/tui-r
|
|
|
94
98
|
- footer(powerline 风格,2–3 行):第 1 行身份(工作区/会话/运行态/🎯目标/✎计划/模型),第 2 行用量(ctx 条+%、入/出/缓存/共 token、⚙步/回合/首响),第 3 行任务(有 job 时)
|
|
95
99
|
- 折叠块预览:bash 折叠后显示命令概要(view.title 或 command),思考折叠后显示前 3 行
|
|
96
100
|
- 会话内搜索:**Ctrl+F** 模糊搜本会话消息/工具名,回车跳转
|
|
97
|
-
- 轨迹详情:点击回合行弹窗(工具调用 + 耗时),`/` 过滤回合;**增量加载**(每页 20
|
|
101
|
+
- 轨迹详情:点击回合行弹窗(工具调用 + 耗时),`/` 过滤回合;**增量加载**(每页 20 回合逐页渲染,最近回合秒开,历史步骤按需翻页,不再阻塞等待全量历史)
|
|
102
|
+
- 轨迹 详细/简略:每个 step 左键单击(▸/▾)或右键菜单第一项 →「展开(详细)/ 折叠(简略)」内联展开事件列表(仿对话页);跳转定位的 step 自动展开 + 高亮闪烁
|
|
103
|
+
- 轨迹窗口模型:转跳只显示 `step±20`;PgUp/PgDn 按 10 步扩展窗口(按需向后翻页加载),Home 加载到 step 1–20,End 回到最新 20 步,窗口指示行显示「窗口 step A–B · 共 N 步」
|
|
98
104
|
- 工作区文件搜索:工作区面板内直接打字(`/`)模糊搜文件名,回车预览
|
|
99
105
|
- 消息反馈:助手消息右键 👍/👎(`messageFeedback/put` Typert RPC),已评状态回显、可删除
|
|
100
106
|
- 图片画廊:多图时 ←/→ 切换,标题显示 (N/M) + 尺寸
|
|
101
107
|
- 会话内搜索高亮:Ctrl+F 跳转后匹配词高亮(黄底),Esc 清除
|
|
102
108
|
- 轨迹逐事件详情:点击回合 → 事件列表 → 选事件看全文
|
|
109
|
+
- 用户消息前缀:自己发的消息第一行直接显示内容并带 `用户名 > ` 前缀(默认取系统登录名);**设置页新增「TUI 界面」命名空间**,编辑 `userPrefix` 并 Ctrl+S 即时生效(持久化到 `$DSH_HOME/tui-config.json`);环境变量 `DSH_TUI_USER_PREFIX` 亦可覆盖
|
|
110
|
+
- 块计时:流式块实时显示 `已经过 X秒/分/小时`,完成后冻结为 `已完成,耗时 X`(工具失败显示 `失败,耗时 X`);缺失 block-end 也不会出现永远走动的计时器
|
|
111
|
+
- 块编号:think/tool/text 块头部标注所属 `(step N)`,与轨迹页的 step 编号对应,Ctrl+E 可据此快速转跳
|
|
103
112
|
- 主题持久化:切主题写入 `$DSH_HOME/tui-theme.txt`(或 XDG config),重启保留
|
|
104
113
|
- 输入模式标识:标签栏右侧 NORMAL/INSERT 指示,Esc 退出输入
|
|
105
114
|
- 实时流:merge 修复(新回合不再覆盖旧回合)、活跃时 500ms 轮询、流式思考/工具自动展开
|
package/package.json
CHANGED
package/src/config.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// config.js — TUI-local config (display name/prefix etc.), persisted as JSON
|
|
2
|
+
// next to the theme file: $DSH_HOME/tui-config.json (or XDG config dir).
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { userInfo } from "node:os";
|
|
6
|
+
|
|
7
|
+
export function tuiConfigFile() {
|
|
8
|
+
const base = process.env.DSH_HOME ?? (process.env.XDG_CONFIG_HOME ?? join(process.env.HOME ?? ".", ".config"));
|
|
9
|
+
return join(base, "tui-config.json");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let cache = { file: null, data: null, at: 0 };
|
|
13
|
+
|
|
14
|
+
/** Read the TUI config ({} when absent); cached for 1s per file path. */
|
|
15
|
+
export function loadTuiConfig() {
|
|
16
|
+
const now = Date.now();
|
|
17
|
+
const file = tuiConfigFile();
|
|
18
|
+
if (cache.file === file && cache.data && now - cache.at < 1000) return cache.data;
|
|
19
|
+
let data;
|
|
20
|
+
try {
|
|
21
|
+
data = JSON.parse(readFileSync(file, "utf8"));
|
|
22
|
+
} catch {
|
|
23
|
+
data = {};
|
|
24
|
+
}
|
|
25
|
+
cache = { file, data, at: now };
|
|
26
|
+
return data;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Merge a patch into the TUI config and write it back. Returns success. */
|
|
30
|
+
export function saveTuiConfig(patch) {
|
|
31
|
+
const file = tuiConfigFile();
|
|
32
|
+
const cfg = { ...loadTuiConfig(), ...patch };
|
|
33
|
+
try {
|
|
34
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
35
|
+
writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n");
|
|
36
|
+
cache = { file, data: cfg, at: Date.now() };
|
|
37
|
+
return true;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let OS_USERNAME = null;
|
|
44
|
+
function osUsername() {
|
|
45
|
+
if (OS_USERNAME !== null) return OS_USERNAME;
|
|
46
|
+
try { OS_USERNAME = userInfo().username; } catch { OS_USERNAME = ""; }
|
|
47
|
+
return OS_USERNAME;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Effective display name: config → env → OS login → fallback. */
|
|
51
|
+
export function userName() {
|
|
52
|
+
const cfg = loadTuiConfig();
|
|
53
|
+
return cfg.userPrefix
|
|
54
|
+
|| process.env.DSH_TUI_USER_PREFIX
|
|
55
|
+
|| osUsername()
|
|
56
|
+
|| process.env.USER
|
|
57
|
+
|| process.env.LOGNAME
|
|
58
|
+
|| "user";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** "edabchann > " style prefix for the user's own messages. */
|
|
62
|
+
export function userPrefix() {
|
|
63
|
+
return `${userName()} > `;
|
|
64
|
+
}
|
package/src/panels.js
CHANGED
|
@@ -10,6 +10,7 @@ import { join, basename, extname } from "node:path";
|
|
|
10
10
|
import { spawn, execFileSync } from "node:child_process";
|
|
11
11
|
|
|
12
12
|
import { T, cycleTheme, themeName } from "./theme.js";
|
|
13
|
+
import { loadTuiConfig, saveTuiConfig, userPrefix, userName } from "./config.js";
|
|
13
14
|
// Live theme accessor: K.K.DIM etc. resolve against the active palette at render time.
|
|
14
15
|
const K = new Proxy({}, { get(_k, key) { return T[key]; } });
|
|
15
16
|
|
|
@@ -554,30 +555,52 @@ export class TrajectoryPanel extends Widget {
|
|
|
554
555
|
this.minSeq = null;
|
|
555
556
|
this.allEvents = [];
|
|
556
557
|
this.sessionId = null;
|
|
558
|
+
this.expandedSteps = new Set(); // step identity keys rendered 详细 (expanded)
|
|
559
|
+
this.flashStep = null; // step NUMBER just jumped to (brief highlight)
|
|
560
|
+
this.flashUntil = 0;
|
|
561
|
+
this.loadPromise = null; // dedupes concurrent load(currentSession)
|
|
562
|
+
this.loadTarget = null;
|
|
563
|
+
this.winLo = null; // visible step-number window [winLo, winHi]; null = follow the tail
|
|
564
|
+
this.winHi = null;
|
|
565
|
+
// LEFT click toggles a step's 详细/简略 expansion (the ▸/▾ triangle).
|
|
557
566
|
this.view = new ScrollView({ x: this.x, y: this.y, w: this.w, h: this.h, showScrollbar: true, onClick: (y) => this.#clickLine(y) });
|
|
558
567
|
this.stepLines = [];
|
|
559
568
|
this.query = "";
|
|
560
569
|
}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
570
|
+
|
|
571
|
+
/** Total step count from the session stats (falls back to the newest loaded). */
|
|
572
|
+
totalSteps() {
|
|
573
|
+
return this.stats?.steps ?? (this.steps[this.steps.length - 1]?.step ?? this.steps.length);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** LEFT click: toggle the step under the cursor; the "▲ 更早步骤" row loads
|
|
577
|
+
* one more window-width upward. */
|
|
578
|
+
#clickLine(y) {
|
|
579
|
+
if (this.hasMore && y === 1) { this.extendUp(); return true; }
|
|
580
|
+
const si = this.stepLines[y];
|
|
581
|
+
if (si !== undefined) { this.#toggleStep(si); return true; }
|
|
582
|
+
return false;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
#eventSummary(e) {
|
|
586
|
+
const d = e.data ?? {};
|
|
587
|
+
if (e.type === "user/message") return `❯ ${String(d.content?.[0]?.text ?? "").slice(0, 40)}`;
|
|
588
|
+
if (e.type === "tool/call") return `⚙ ${d.name ?? "tool"} ${String(d.arguments ?? "").slice(0, 30)}`;
|
|
589
|
+
if (e.type === "tool/result") return "↳ 结果";
|
|
590
|
+
if (e.type === "assistant/message") return `◉ ${String(d.message?.content?.find((c) => c.type === "text")?.text ?? "").slice(0, 40)}`;
|
|
591
|
+
if (e.type === "assistant/chunk") {
|
|
592
|
+
const ch = d.chunk ?? {};
|
|
593
|
+
return ch.type === "text-delta" ? String(ch.delta ?? "").slice(0, 40) : `[${ch.blockType ?? ch.type}]`;
|
|
594
|
+
}
|
|
595
|
+
return e.type;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** Right-click menu entry: the old event-detail picker for one step. */
|
|
599
|
+
#showDetail(si) {
|
|
565
600
|
const step = this.steps[si];
|
|
566
|
-
if (!step) return
|
|
601
|
+
if (!step) return;
|
|
567
602
|
const t0 = step.events[0]?.time, t1 = step.events[step.events.length - 1]?.time;
|
|
568
|
-
const items = step.events.map((e, i) => {
|
|
569
|
-
const d = e.data ?? {};
|
|
570
|
-
let summary = e.type;
|
|
571
|
-
if (e.type === "user/message") summary = `❯ ${String(d.content?.[0]?.text ?? "").slice(0, 40)}`;
|
|
572
|
-
else if (e.type === "tool/call") summary = `⚙ ${d.name ?? "tool"} ${String(d.arguments ?? "").slice(0, 30)}`;
|
|
573
|
-
else if (e.type === "tool/result") summary = "↳ 结果";
|
|
574
|
-
else if (e.type === "assistant/message") summary = `◉ ${String(d.message?.content?.find((c) => c.type === "text")?.text ?? "").slice(0, 40)}`;
|
|
575
|
-
else if (e.type === "assistant/chunk") {
|
|
576
|
-
const ch = d.chunk ?? {};
|
|
577
|
-
summary = ch.type === "text-delta" ? String(ch.delta ?? "").slice(0, 40) : `[${ch.blockType ?? ch.type}]`;
|
|
578
|
-
}
|
|
579
|
-
return { label: truncate(summary, 60), hint: `#${e.seq}`, idx: i, event: e };
|
|
580
|
-
});
|
|
603
|
+
const items = step.events.map((e, i) => ({ label: truncate(this.#eventSummary(e), 60), hint: `#${e.seq}`, idx: i, event: e }));
|
|
581
604
|
if (t0 && t1) items.unshift({ label: `⏱ 步骤 ${step.step} · ${step.events.length} 事件 · ${fmtMs(t1 - t0)}`, hint: "", idx: -1, event: null });
|
|
582
605
|
const w = Math.min(78, this.app.screen.w - 8), h = Math.min(22, this.app.screen.h - 4);
|
|
583
606
|
this.app.overlay = new Picker({
|
|
@@ -594,7 +617,6 @@ export class TrajectoryPanel extends Widget {
|
|
|
594
617
|
else if (e.type === "assistant/message") body = String(d.message?.content?.map((c) => c.text ?? "").join("\n") ?? "");
|
|
595
618
|
else body = JSON.stringify(d, null, 2);
|
|
596
619
|
const lines = body.split("\n").slice(0, 40).map((l) => [{ t: truncate(l, w - 6), fg: K.TXT }]);
|
|
597
|
-
// centered detail popup (never the stale top-left corner)
|
|
598
620
|
const pw = Math.min(80, this.app.screen.w - 4);
|
|
599
621
|
const ph = Math.min(lines.length + 5, 24);
|
|
600
622
|
this.app.overlay = new Popup({
|
|
@@ -607,7 +629,161 @@ export class TrajectoryPanel extends Widget {
|
|
|
607
629
|
},
|
|
608
630
|
});
|
|
609
631
|
this.app.redraw();
|
|
610
|
-
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
#toggleStep(si) {
|
|
635
|
+
const step = this.steps[si];
|
|
636
|
+
if (!step) return;
|
|
637
|
+
const key = this.stepKey(step);
|
|
638
|
+
// Anchor: keep the step header at its current viewport row across the
|
|
639
|
+
// expand/collapse layout change (rows are added/removed BELOW it).
|
|
640
|
+
const headerLine = this.stepLines.indexOf(si);
|
|
641
|
+
const topRow = headerLine >= 0 ? headerLine - this.view.scrollY : null;
|
|
642
|
+
if (this.expandedSteps.has(key)) this.expandedSteps.delete(key);
|
|
643
|
+
else this.expandedSteps.add(key);
|
|
644
|
+
this.buildLines();
|
|
645
|
+
if (topRow !== null) {
|
|
646
|
+
const li2 = this.stepLines.indexOf(si);
|
|
647
|
+
if (li2 >= 0) this.view.scrollY = Math.max(0, Math.min(li2 - topRow, this.view.maxScroll()));
|
|
648
|
+
}
|
|
649
|
+
this.app.redraw();
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** Stable identity of a step across loadOlder re-segmentation: the seq of
|
|
653
|
+
* its first event (step indexes shift when older steps are prepended). */
|
|
654
|
+
stepKey(step) { return step.events[0]?.seq ?? `step-${step.step}`; }
|
|
655
|
+
|
|
656
|
+
/** Step index whose events carry the given message id (-1 when absent). */
|
|
657
|
+
indexOfMessage(messageId) {
|
|
658
|
+
if (!messageId) return -1;
|
|
659
|
+
for (let si = this.steps.length - 1; si >= 0; si--) {
|
|
660
|
+
if (this.steps[si].events.some((e) => {
|
|
661
|
+
const d = e.data ?? {};
|
|
662
|
+
return (d.id ?? d.message?.id) === messageId;
|
|
663
|
+
})) return si;
|
|
664
|
+
}
|
|
665
|
+
return -1;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Load older pages until step `loStep` is covered (or the session's first
|
|
669
|
+
* step is reached). Used by jumps and Home — many pages when going far. */
|
|
670
|
+
async ensureLoaded(loStep, maxPages = 80) {
|
|
671
|
+
for (let i = 0; i < maxPages; i++) {
|
|
672
|
+
if (!this.hasMore) break;
|
|
673
|
+
if (this.steps.length && this.steps[0].step <= loStep) break;
|
|
674
|
+
this.app.setStatus(`加载更早轨迹…(当前最早 step ${this.steps[0]?.step ?? "?"})`);
|
|
675
|
+
await this.loadOlder();
|
|
676
|
+
}
|
|
677
|
+
this.app.setStatus("");
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** Set the visible step-number window and re-render. */
|
|
681
|
+
setWindow(lo, hi) {
|
|
682
|
+
this.winLo = lo;
|
|
683
|
+
this.winHi = hi;
|
|
684
|
+
this.buildLines();
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/** Step number at the top of the viewport (for anchoring after growth). */
|
|
688
|
+
#topVisibleStep() {
|
|
689
|
+
const si = this.stepLines[this.view.scrollY];
|
|
690
|
+
return si !== undefined ? this.steps[si]?.step : null;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** Scroll so the given step number sits at the top of the viewport. */
|
|
694
|
+
#anchorScroll(stepNum) {
|
|
695
|
+
const li = this.stepLines.findIndex((si) => this.steps[si]?.step === stepNum);
|
|
696
|
+
if (li >= 0) this.view.scrollY = Math.max(0, li);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Scroll to a step: open a ±20 window around it (loading older pages on
|
|
700
|
+
* demand), auto-expand and highlight the step. */
|
|
701
|
+
async jumpToStep(si) {
|
|
702
|
+
if (si < 0 || si >= this.steps.length) return;
|
|
703
|
+
const S = this.steps[si].step;
|
|
704
|
+
this.expandedSteps.add(this.stepKey(this.steps[si]));
|
|
705
|
+
this.flashStep = S;
|
|
706
|
+
this.flashUntil = Date.now() + 3000;
|
|
707
|
+
await this.ensureLoaded(Math.max(1, S - 20));
|
|
708
|
+
// ensureLoaded re-segments → re-find the step index by its number
|
|
709
|
+
const si2 = this.steps.findIndex((s) => s.step === S);
|
|
710
|
+
this.setWindow(Math.max(1, S - 20), Math.min(this.totalSteps(), S + 20));
|
|
711
|
+
const li = si2 >= 0 ? this.stepLines.indexOf(si2) : -1;
|
|
712
|
+
this.view.scrollY = li >= 0 ? Math.max(0, li - 2) : 0;
|
|
713
|
+
this.app.redraw();
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** PgUp: extend the window 10 steps upward (loading older if needed),
|
|
717
|
+
* keeping the view anchored on the step that was at the top. */
|
|
718
|
+
async extendUp() {
|
|
719
|
+
const N = this.totalSteps();
|
|
720
|
+
if (this.winLo == null) { this.winLo = Math.max(1, N - 19); this.winHi = N; }
|
|
721
|
+
if (this.winLo <= 1 && this.steps.length && this.steps[0].step <= 1) {
|
|
722
|
+
this.app.toast("已到最早步骤");
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
const anchor = this.#topVisibleStep() ?? this.winLo;
|
|
726
|
+
this.winLo = Math.max(1, this.winLo - 10);
|
|
727
|
+
if (!this.steps.length || this.steps[0].step > this.winLo) await this.ensureLoaded(this.winLo);
|
|
728
|
+
this.buildLines();
|
|
729
|
+
this.#anchorScroll(anchor);
|
|
730
|
+
this.app.redraw();
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** PgDn: extend the window 10 steps downward (the newer steps are already
|
|
734
|
+
* loaded — the tail is always kept). */
|
|
735
|
+
extendDown() {
|
|
736
|
+
const N = this.totalSteps();
|
|
737
|
+
if (this.winLo == null) { this.winLo = Math.max(1, N - 19); this.winHi = N; }
|
|
738
|
+
if (this.winHi >= N) { this.app.toast("已到最新步骤"); return; }
|
|
739
|
+
this.winHi = Math.min(N, this.winHi + 10);
|
|
740
|
+
this.buildLines();
|
|
741
|
+
this.app.redraw();
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** Home: jump to the very first steps (loading all the way back). */
|
|
745
|
+
async gotoHome() {
|
|
746
|
+
await this.ensureLoaded(1);
|
|
747
|
+
this.setWindow(1, 20);
|
|
748
|
+
const li = this.stepLines.findIndex((si) => this.steps[si]?.step === 1);
|
|
749
|
+
this.view.scrollY = li >= 0 ? li : 0;
|
|
750
|
+
this.app.toast("已跳到最早步骤(step 1–20)");
|
|
751
|
+
this.app.redraw();
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** End: jump to the newest steps. */
|
|
755
|
+
gotoEnd() {
|
|
756
|
+
const N = this.totalSteps();
|
|
757
|
+
this.setWindow(Math.max(1, N - 19), N);
|
|
758
|
+
this.view.scrollY = this.view.maxScroll();
|
|
759
|
+
this.app.toast(`已跳到最新步骤(step ${Math.max(1, N - 19)}–${N})`);
|
|
760
|
+
this.app.redraw();
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** Chat → trajectory jump target: load the current session's steps (if not
|
|
764
|
+
* already), page back until the message's step is loaded, then jump. */
|
|
765
|
+
async focusMessage(messageId) {
|
|
766
|
+
if (this.sessionId !== this.app.currentSession || this.steps.length === 0) {
|
|
767
|
+
await this.load(this.app.currentSession);
|
|
768
|
+
}
|
|
769
|
+
let si = this.indexOfMessage(messageId);
|
|
770
|
+
for (let i = 0; si < 0 && this.hasMore && i < 10; i++) {
|
|
771
|
+
await this.loadOlder();
|
|
772
|
+
si = this.indexOfMessage(messageId);
|
|
773
|
+
}
|
|
774
|
+
if (si < 0 && messageId) {
|
|
775
|
+
// still not found — the message is far back; scan everything (bounded)
|
|
776
|
+
await this.ensureLoaded(1, 60);
|
|
777
|
+
si = this.indexOfMessage(messageId);
|
|
778
|
+
}
|
|
779
|
+
if (si >= 0) {
|
|
780
|
+
const S = this.steps[si].step;
|
|
781
|
+
await this.jumpToStep(si);
|
|
782
|
+
this.app.toast(`已定位到 step ${S}`);
|
|
783
|
+
} else if (this.steps.length) {
|
|
784
|
+
await this.jumpToStep(this.steps.length - 1);
|
|
785
|
+
this.app.toast(messageId ? "对应步骤不在已加载窗口" : "消息未关联步骤,已到最新步骤");
|
|
786
|
+
}
|
|
611
787
|
}
|
|
612
788
|
relayout(x, y, w, h) {
|
|
613
789
|
this.x = x; this.y = y; this.w = w; this.h = h;
|
|
@@ -624,6 +800,14 @@ export class TrajectoryPanel extends Widget {
|
|
|
624
800
|
this.app.redraw();
|
|
625
801
|
return;
|
|
626
802
|
}
|
|
803
|
+
// Dedupe concurrent loads of the same session (setMode + focusMessage).
|
|
804
|
+
if (this.loadPromise && this.loadTarget === sessionId) return this.loadPromise;
|
|
805
|
+
this.loadTarget = sessionId;
|
|
806
|
+
this.loadPromise = this.#doLoad(sessionId);
|
|
807
|
+
try { await this.loadPromise; }
|
|
808
|
+
finally { this.loadPromise = null; this.loadTarget = null; }
|
|
809
|
+
}
|
|
810
|
+
async #doLoad(sessionId) {
|
|
627
811
|
this.sessionId = sessionId;
|
|
628
812
|
this.loading = true;
|
|
629
813
|
this.steps = [];
|
|
@@ -680,19 +864,24 @@ export class TrajectoryPanel extends Widget {
|
|
|
680
864
|
if (cur) cur.events.push(event);
|
|
681
865
|
}
|
|
682
866
|
if (cur && cur.events.length) steps.push(cur);
|
|
683
|
-
|
|
867
|
+
// Keep every loaded step: Home/End navigation pages across the whole
|
|
868
|
+
// session, so older steps must survive until `r` re-fetches fresh.
|
|
869
|
+
this.steps = steps;
|
|
684
870
|
}
|
|
685
871
|
buildLines() {
|
|
686
872
|
const w = Math.max(40, this.w - 2);
|
|
873
|
+
const N = this.totalSteps();
|
|
874
|
+
const lo = this.winLo ?? Math.max(1, N - 19);
|
|
875
|
+
const hi = this.winHi ?? N;
|
|
687
876
|
const lines = [];
|
|
688
|
-
lines.push([{ t: "轨迹 —
|
|
689
|
-
if (this.hasMore) lines.push([{ t: "▲ 更早步骤(点击 / PgUp
|
|
877
|
+
lines.push([{ t: "轨迹 — 步骤时间轴(左键展开/折叠 · PgUp/PgDn 上下加载 · Home/End 首尾 · Ctrl+E 转跳 · r 刷新)", fg: K.ACCENT, bold: true }]);
|
|
878
|
+
if (this.hasMore) lines.push([{ t: "▲ 更早步骤(点击 / PgUp 向上加载 10 步)", fg: K.FAINT }]);
|
|
690
879
|
else lines.push([{ t: "" }]);
|
|
691
880
|
const st = this.stats;
|
|
692
881
|
if (st) {
|
|
693
882
|
lines.push([{ t: `回合 ${st.turns} · 步骤 ${st.steps} · LLM ${fmtMs(st.llmMs)} · 工具 ${fmtMs(st.toolMs)}`, fg: K.DIM }]);
|
|
694
883
|
}
|
|
695
|
-
lines.push([{ t:
|
|
884
|
+
lines.push([{ t: `窗口 step ${lo}–${hi} · 共 ${N} 步${this.winLo == null ? "(跟随最新)" : ""}:`, fg: K.DIM, underline: true }]);
|
|
696
885
|
this.stepLines = [];
|
|
697
886
|
const list = this.query
|
|
698
887
|
? this.steps.filter((t) => t.events.some((e) => {
|
|
@@ -700,8 +889,8 @@ export class TrajectoryPanel extends Widget {
|
|
|
700
889
|
const hay = `${e.type} ${d.name ?? ""} ${typeof d.content === "string" ? d.content : ""}`.toLowerCase();
|
|
701
890
|
return hay.includes(this.query.toLowerCase());
|
|
702
891
|
}))
|
|
703
|
-
: this.steps;
|
|
704
|
-
for (const step of list.
|
|
892
|
+
: this.steps.filter((s) => s.step >= lo && s.step <= hi);
|
|
893
|
+
for (const step of list.reverse()) {
|
|
705
894
|
const si = this.steps.indexOf(step);
|
|
706
895
|
const tools = [...new Set(step.events.filter((e) => e.type === "tool/call").map((e) => e.data?.name))];
|
|
707
896
|
const hasResult = step.events.some((e) => e.type === "tool/result");
|
|
@@ -710,12 +899,27 @@ export class TrajectoryPanel extends Widget {
|
|
|
710
899
|
const dur = t0 && t1 ? fmtMs(t1 - t0) : "—";
|
|
711
900
|
const bg = tools.length ? (hasResult ? T.TOOLOK : T.TOOLBG) : hasReasoning ? T.THINKBG : T.CARD;
|
|
712
901
|
const summary = tools.slice(0, 3).join(",") || (hasReasoning ? "模型推理" : "纯文本");
|
|
713
|
-
const
|
|
714
|
-
const
|
|
902
|
+
const open = this.expandedSteps.has(this.stepKey(step)); // 详细
|
|
903
|
+
const flash = this.flashStep === step.step && Date.now() < this.flashUntil;
|
|
904
|
+
const rowBg = flash ? T.ACCENT : bg;
|
|
905
|
+
const label = `${open ? "▾" : "▸"} step ${String(step.step).padStart(3)} ${pad(dur, 8)} ${summary} ${open ? "[折叠]" : "[展开]"}`;
|
|
906
|
+
const segs = [{ t: label, fg: flash ? T.SELFG : K.TXT, bg: rowBg, bold: true }];
|
|
715
907
|
const fill = w - strWidth(label);
|
|
716
|
-
if (fill > 0) segs.push({ t: " ".repeat(fill), bg });
|
|
908
|
+
if (fill > 0) segs.push({ t: " ".repeat(fill), bg: rowBg });
|
|
717
909
|
lines.push(segs);
|
|
718
910
|
this.stepLines[lines.length - 1] = si;
|
|
911
|
+
if (open) {
|
|
912
|
+
// 详细 mode: the step's events inline under its color block.
|
|
913
|
+
const evs = step.events.slice(0, 12);
|
|
914
|
+
for (const e of evs) {
|
|
915
|
+
lines.push([{ t: ` #${String(e.seq).padStart(4)} ${truncate(this.#eventSummary(e), w - 12)}`, fg: K.DIM, bg }]);
|
|
916
|
+
this.stepLines[lines.length - 1] = si;
|
|
917
|
+
}
|
|
918
|
+
if (step.events.length > evs.length) {
|
|
919
|
+
lines.push([{ t: ` …共 ${step.events.length} 个事件(右键 → 查看详情)`, fg: K.FAINT, bg }]);
|
|
920
|
+
this.stepLines[lines.length - 1] = si;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
719
923
|
}
|
|
720
924
|
this.view.setLines(lines);
|
|
721
925
|
}
|
|
@@ -727,7 +931,36 @@ export class TrajectoryPanel extends Widget {
|
|
|
727
931
|
}
|
|
728
932
|
this.view.render(screen);
|
|
729
933
|
}
|
|
730
|
-
onMouse(ev) {
|
|
934
|
+
onMouse(ev) {
|
|
935
|
+
// RIGHT click on a step: context menu (expand/collapse · jump · detail).
|
|
936
|
+
if (ev.kind === "press" && ev.button === 2) {
|
|
937
|
+
const y = ev.y - this.view.y + this.view.scrollY;
|
|
938
|
+
const si = this.stepLines[y];
|
|
939
|
+
const step = si !== undefined ? this.steps[si] : null;
|
|
940
|
+
if (step) {
|
|
941
|
+
const open = this.expandedSteps.has(this.stepKey(step));
|
|
942
|
+
this.app.openMenu([
|
|
943
|
+
{ label: open ? "折叠(简略)" : "展开(详细)", action: () => this.#toggleStep(si) },
|
|
944
|
+
{ label: "转跳对话", action: () => this.app.jumpToChatStep(si) },
|
|
945
|
+
{ label: "查看详情", action: () => this.#showDetail(si) },
|
|
946
|
+
], ev);
|
|
947
|
+
return true;
|
|
948
|
+
}
|
|
949
|
+
if (this.hasMore && y === 1) {
|
|
950
|
+
this.app.openMenu([{ label: "加载更早步骤", action: () => this.loadOlder() }], ev);
|
|
951
|
+
return true;
|
|
952
|
+
}
|
|
953
|
+
// swallow right-clicks on non-step rows (header/stats) so they cannot
|
|
954
|
+
// leak into the chat view's context menu underneath.
|
|
955
|
+
return this.view.inside(ev.x, ev.y);
|
|
956
|
+
}
|
|
957
|
+
// wheel / scrollbar / LEFT-click toggle keep working (view.onClick)
|
|
958
|
+
if (this.view.onMouse(ev)) return true;
|
|
959
|
+
// swallow left-clicks on non-step rows (header/stats) so they cannot
|
|
960
|
+
// leak through to the chat view underneath (which shares this rectangle).
|
|
961
|
+
if (ev.kind === "press" && ev.button === 0 && this.view.inside(ev.x, ev.y)) return true;
|
|
962
|
+
return false;
|
|
963
|
+
}
|
|
731
964
|
onKey(ev) {
|
|
732
965
|
if (ev.type === "text") { this.query += ev.text; this.buildLines(); this.app.redraw(); return true; }
|
|
733
966
|
if (ev.type !== "key") return false;
|
|
@@ -737,14 +970,22 @@ export class TrajectoryPanel extends Widget {
|
|
|
737
970
|
return true;
|
|
738
971
|
}
|
|
739
972
|
if (ev.name === "backspace") { this.query = this.query.slice(0, -1); this.buildLines(); this.app.redraw(); return true; }
|
|
740
|
-
if (ev.name === "char" && ev.key === "r" && !ev.ctrl) {
|
|
741
|
-
|
|
742
|
-
|
|
973
|
+
if (ev.name === "char" && ev.key === "r" && !ev.ctrl) {
|
|
974
|
+
this.winLo = this.winHi = null;
|
|
975
|
+
this.steps = [];
|
|
976
|
+
this.load(this.sessionId);
|
|
977
|
+
return true;
|
|
978
|
+
}
|
|
979
|
+
if (ev.name === "pgup") { this.extendUp(); return true; }
|
|
980
|
+
if (ev.name === "pgdn") { this.extendDown(); return true; }
|
|
981
|
+
if (ev.name === "home") { this.gotoHome(); return true; }
|
|
982
|
+
if (ev.name === "end") { this.gotoEnd(); return true; }
|
|
983
|
+
if (ev.name === "up" || ev.name === "down") return this.view.onKey(ev);
|
|
743
984
|
return false;
|
|
744
985
|
}
|
|
745
986
|
}
|
|
746
987
|
|
|
747
|
-
function fmtMs(ms) {
|
|
988
|
+
export function fmtMs(ms) {
|
|
748
989
|
if (ms == null || isNaN(ms)) return "—";
|
|
749
990
|
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
750
991
|
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
@@ -943,7 +1184,8 @@ export class ControlPanel extends Widget {
|
|
|
943
1184
|
["Ctrl+A", "子代理", () => { this.app.closeOverlay(); this.app.setMode("subagent"); }],
|
|
944
1185
|
["Ctrl+K", "技能", () => { this.app.closeOverlay(); this.app.setMode("skills"); }],
|
|
945
1186
|
["Ctrl+G", "目标", () => this.app.showGoal()],
|
|
946
|
-
["Ctrl+J", "
|
|
1187
|
+
["Ctrl+J", "后台任务", () => this.app.showJobs()],
|
|
1188
|
+
["Ctrl+E", "步骤转跳(fzf 式)", () => this.app.quickJumpStep()],
|
|
947
1189
|
["Ctrl+B", "侧栏 显示/隐藏", () => this.app.toggleSidebar()],
|
|
948
1190
|
["Ctrl+Q", "退出", () => this.app.stop()],
|
|
949
1191
|
];
|
|
@@ -1091,21 +1333,98 @@ export class ControlPanel extends Widget {
|
|
|
1091
1333
|
|
|
1092
1334
|
// ---- Jobs & goal popups ----
|
|
1093
1335
|
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1336
|
+
/** Background-job list (Ctrl+J) with per-job expand/collapse: Enter/→/l 展开,
|
|
1337
|
+
* ←/h 折叠, ↑↓/j k 选择, q/Esc 关闭, click toggles too. */
|
|
1338
|
+
export class JobsPanel extends Popup {
|
|
1339
|
+
constructor(app) {
|
|
1340
|
+
const jobs = app.jobs ?? [];
|
|
1341
|
+
super({
|
|
1342
|
+
x: 5, y: 3, w: Math.min(84, app.screen.w - 8), h: Math.min(Math.max(jobs.length + 5, 7), 24),
|
|
1343
|
+
title: "后台任务(Ctrl+J 查看详情)", lines: [],
|
|
1344
|
+
buttons: [{ label: "关闭(q)", action: "close" }],
|
|
1345
|
+
onAction: () => app.closeOverlay(),
|
|
1346
|
+
});
|
|
1347
|
+
this.app = app;
|
|
1348
|
+
this.jobs = jobs;
|
|
1349
|
+
this.expanded = new Set(); // job indexes rendered expanded
|
|
1350
|
+
this.sel = 0;
|
|
1351
|
+
this.rowOf = []; // rendered line → job index (-1 = chrome/detail)
|
|
1352
|
+
this.rebuild();
|
|
1353
|
+
}
|
|
1354
|
+
#detailLines(j) {
|
|
1355
|
+
const lines = [];
|
|
1356
|
+
for (const [k, v] of Object.entries(j)) {
|
|
1357
|
+
if (["status", "kind", "label"].includes(k)) continue;
|
|
1358
|
+
const s = v !== null && typeof v === "object" ? JSON.stringify(v) : String(v ?? "");
|
|
1359
|
+
if (s === "") continue;
|
|
1360
|
+
lines.push([{ t: ` ${k}: ${truncate(s, this.w - 14)}`, fg: K.DIM }]);
|
|
1361
|
+
if (lines.length > 8) break;
|
|
1362
|
+
}
|
|
1363
|
+
return lines;
|
|
1364
|
+
}
|
|
1365
|
+
rebuild() {
|
|
1366
|
+
let lines = [[{ t: " 这些任务在后台运行,不阻塞会话 — Enter/→/l 展开,←/h 折叠,q 关闭", fg: K.DIM }]];
|
|
1367
|
+
const rowOf = [-1];
|
|
1368
|
+
const jobs = this.jobs;
|
|
1369
|
+
if (jobs.length === 0) {
|
|
1370
|
+
lines.push([{ t: " (当前没有任务帧)", fg: K.FAINT }]);
|
|
1371
|
+
rowOf.push(-1);
|
|
1372
|
+
}
|
|
1373
|
+
for (let i = 0; i < jobs.length; i++) {
|
|
1374
|
+
const j = jobs[i];
|
|
1099
1375
|
const icon = j.status === "running" ? "⚙" : j.status === "completed" ? "✓" : j.status === "failed" ? "✗" : "·";
|
|
1100
1376
|
const color = j.status === "running" ? K.WARN : j.status === "completed" ? K.OK : j.status === "failed" ? K.ERR : K.DIM;
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1377
|
+
const open = this.expanded.has(i);
|
|
1378
|
+
const bg = i === this.sel ? T.MENUSEL : T.BG2;
|
|
1379
|
+
lines.push([
|
|
1380
|
+
{ t: ` ${open ? "▾" : "▸"} ${icon} ${truncate(j.kind, 14)}`, fg: color, bold: true, bg },
|
|
1381
|
+
{ t: ` ${truncate(j.label, 36)}`, fg: K.TXT, bg },
|
|
1382
|
+
{ t: ` ${j.status}`, fg: K.DIM, bg },
|
|
1383
|
+
]);
|
|
1384
|
+
rowOf.push(i);
|
|
1385
|
+
if (open) for (const fl of this.#detailLines(j)) { lines.push(fl); rowOf.push(-1); }
|
|
1386
|
+
}
|
|
1387
|
+
if (lines.length > this.h - 3) {
|
|
1388
|
+
lines = [...lines.slice(0, this.h - 4), [{ t: ` …共 ${jobs.length} 个任务`, fg: K.FAINT }]];
|
|
1389
|
+
}
|
|
1390
|
+
this.lines = lines;
|
|
1391
|
+
this.rowOf = rowOf;
|
|
1392
|
+
}
|
|
1393
|
+
#toggle(i) {
|
|
1394
|
+
if (this.expanded.has(i)) this.expanded.delete(i);
|
|
1395
|
+
else this.expanded.add(i);
|
|
1396
|
+
this.rebuild();
|
|
1397
|
+
this.app.redraw();
|
|
1398
|
+
}
|
|
1399
|
+
onKey(ev) {
|
|
1400
|
+
if (ev.type === "key") {
|
|
1401
|
+
if (ev.name === "escape" || (ev.name === "char" && ev.key === "q" && !ev.ctrl)) { this.app.closeOverlay(); return true; }
|
|
1402
|
+
if (this.jobs.length === 0) return super.onKey(ev);
|
|
1403
|
+
if (ev.name === "up" || (ev.name === "char" && ev.key === "k" && !ev.ctrl)) {
|
|
1404
|
+
this.sel = Math.max(0, this.sel - 1); this.rebuild(); return true;
|
|
1405
|
+
}
|
|
1406
|
+
if (ev.name === "down" || (ev.name === "char" && ev.key === "j" && !ev.ctrl)) {
|
|
1407
|
+
this.sel = Math.min(this.jobs.length - 1, this.sel + 1); this.rebuild(); return true;
|
|
1408
|
+
}
|
|
1409
|
+
if (ev.name === "right" || ev.name === "enter" || (ev.name === "char" && ev.key === "l" && !ev.ctrl)) {
|
|
1410
|
+
if (this.jobs[this.sel]) { this.expanded.add(this.sel); this.rebuild(); } return true;
|
|
1411
|
+
}
|
|
1412
|
+
if (ev.name === "left" || (ev.name === "char" && ev.key === "h" && !ev.ctrl)) {
|
|
1413
|
+
this.expanded.delete(this.sel); this.rebuild(); return true;
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
return super.onKey(ev);
|
|
1417
|
+
}
|
|
1418
|
+
onMouse(ev) {
|
|
1419
|
+
if (super.onMouse(ev)) return true; // buttons
|
|
1420
|
+
if (ev.kind === "press" && ev.button === 0) {
|
|
1421
|
+
const i = ev.y - this.y - 1;
|
|
1422
|
+
const jIdx = this.rowOf[i];
|
|
1423
|
+
if (jIdx >= 0) { this.sel = jIdx; this.#toggle(jIdx); return true; }
|
|
1424
|
+
return true;
|
|
1425
|
+
}
|
|
1426
|
+
return false;
|
|
1427
|
+
}
|
|
1109
1428
|
}
|
|
1110
1429
|
|
|
1111
1430
|
export function buildGoalPopup(app) {
|
|
@@ -1172,11 +1491,19 @@ export class SettingsPanel extends Widget {
|
|
|
1172
1491
|
const d = await this.app.api.call("settings.describe");
|
|
1173
1492
|
this.namespaces = d.namespaces ?? [];
|
|
1174
1493
|
this.writable = d.writable;
|
|
1175
|
-
this.selectNs(0);
|
|
1176
1494
|
} catch (e) {
|
|
1177
1495
|
this.app.toast(`设置加载失败: ${e.message}`);
|
|
1178
1496
|
this.app.setMode("chat");
|
|
1497
|
+
return;
|
|
1179
1498
|
}
|
|
1499
|
+
// TUI-local settings ride the same tree editor, but persist to the TUI
|
|
1500
|
+
// config file instead of the host settings (settings.mutate knows nothing
|
|
1501
|
+
// about them). userPrefix = the chat's "edabchann > " display name.
|
|
1502
|
+
this.namespaces.unshift({
|
|
1503
|
+
ns: "TUI 界面", applies: "live", local: true,
|
|
1504
|
+
value: { userPrefix: userName() },
|
|
1505
|
+
});
|
|
1506
|
+
this.selectNs(0);
|
|
1180
1507
|
}
|
|
1181
1508
|
selectNs(i) {
|
|
1182
1509
|
this.nsIdx = Math.max(0, Math.min(this.namespaces.length - 1, i));
|
|
@@ -1228,9 +1555,10 @@ export class SettingsPanel extends Widget {
|
|
|
1228
1555
|
this.nsList.render(screen);
|
|
1229
1556
|
const ns = this.currentNs();
|
|
1230
1557
|
if (ns) {
|
|
1231
|
-
|
|
1558
|
+
const revTag = ns.local ? "" : ` rev${ns.revision}`;
|
|
1559
|
+
screen.text(this.x + 28, this.y, ` ${ns.ns}${revTag} ${this.writable === false && !ns.local ? "(只读)" : ""}`, { fg: K.ACCENT, bold: true });
|
|
1232
1560
|
const pend = this.pendingOps.length ? ` ⚠ ${this.pendingOps.length} 项待保存` : "";
|
|
1233
|
-
if (pend) screen.text(this.x + 28 + strWidth(` ${ns.ns}
|
|
1561
|
+
if (pend) screen.text(this.x + 28 + strWidth(` ${ns.ns}${revTag} `), this.y, pend, { fg: K.WARN });
|
|
1234
1562
|
}
|
|
1235
1563
|
this.tree.render(screen);
|
|
1236
1564
|
if (this.editing) {
|
|
@@ -1300,6 +1628,21 @@ export class SettingsPanel extends Widget {
|
|
|
1300
1628
|
async save() {
|
|
1301
1629
|
const ns = this.currentNs();
|
|
1302
1630
|
if (!ns || this.pendingOps.length === 0) { this.app.toast("没有待保存的修改"); return; }
|
|
1631
|
+
if (ns.local) {
|
|
1632
|
+
// TUI-local config: write the config file, apply instantly.
|
|
1633
|
+
const v = applyOps(ns.value, this.pendingOps);
|
|
1634
|
+
const name = String(v.userPrefix ?? "").trim();
|
|
1635
|
+
if (saveTuiConfig({ userPrefix: name })) {
|
|
1636
|
+
this.pendingOps = [];
|
|
1637
|
+
this.app.toast(name ? `已保存显示名 “${name}”(即时生效)` : "已清除自定义显示名(回到系统用户名)");
|
|
1638
|
+
this.app.chat.cache.clear();
|
|
1639
|
+
this.app.chat.queueRebuild();
|
|
1640
|
+
await this.load();
|
|
1641
|
+
} else {
|
|
1642
|
+
this.app.toast("保存失败:无法写入 TUI 配置文件");
|
|
1643
|
+
}
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1303
1646
|
try {
|
|
1304
1647
|
await this.app.api.call("settings.mutate", { ns: ns.ns, ops: this.pendingOps, expectedRevision: ns.revision });
|
|
1305
1648
|
this.pendingOps = [];
|
package/src/text.js
CHANGED
|
@@ -53,6 +53,16 @@ export function pad(s, w, align = "left") {
|
|
|
53
53
|
return align === "right" ? sp + s : s + sp;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/** Chinese-unit duration: 12秒 / 3分05秒 / 1小时02分03秒. */
|
|
57
|
+
export function fmtDuration(ms) {
|
|
58
|
+
if (ms == null || isNaN(ms) || ms < 0) return "—";
|
|
59
|
+
const s = Math.floor(ms / 1000);
|
|
60
|
+
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
|
|
61
|
+
if (h > 0) return `${h}小时${String(m).padStart(2, "0")}分${String(sec).padStart(2, "0")}秒`;
|
|
62
|
+
if (m > 0) return `${m}分${String(sec).padStart(2, "0")}秒`;
|
|
63
|
+
return `${sec}秒`;
|
|
64
|
+
}
|
|
65
|
+
|
|
56
66
|
export function hexRgb(hex) {
|
|
57
67
|
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex).trim());
|
|
58
68
|
if (!m) return null;
|
package/src/views.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
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 } from "./text.js";
|
|
4
|
+
import { truncate, strWidth, bars, fmtDuration } from "./text.js";
|
|
5
5
|
import { readFileSync } from "node:fs";
|
|
6
6
|
import { Widget, List, ScrollView, Input, Popup, Menu, StatusBar } from "./widgets.js";
|
|
7
|
+
import { userPrefix, saveTuiConfig, loadTuiConfig, userName } from "./config.js";
|
|
8
|
+
export { userPrefix, saveTuiConfig, loadTuiConfig, userName } from "./config.js";
|
|
7
9
|
import {
|
|
8
10
|
Picker, buildCommandPalette, buildModelPicker, buildModePicker, buildPermissionPicker,
|
|
9
11
|
modeName, permName, WorkspacePanel, TrajectoryPanel, DirPicker,
|
|
10
|
-
ImagePopup, kittyCapable,
|
|
11
|
-
SkillsPanel, ControlPanel,
|
|
12
|
+
ImagePopup, kittyCapable, buildGoalPopup, SettingsPanel, SubagentPanel,
|
|
13
|
+
SkillsPanel, ControlPanel, JobsPanel, fmtMs,
|
|
12
14
|
} from "./panels.js";
|
|
13
15
|
|
|
14
16
|
import { T, themeName } from "./theme.js";
|
|
@@ -112,17 +114,23 @@ function diffText(oldText, newText, width) {
|
|
|
112
114
|
// ---- Chat node model ----
|
|
113
115
|
|
|
114
116
|
/** Apply ONE event onto a mutable nodes array (shared by full-window
|
|
115
|
-
* derivation and the incremental mux merge — the two paths must agree).
|
|
116
|
-
|
|
117
|
+
* derivation and the incremental mux merge — the two paths must agree).
|
|
118
|
+
* `state` carries the current step number across calls ({ step: number }). */
|
|
119
|
+
function applyEvent(nodes, event, view, log, state = null) {
|
|
120
|
+
const st = state ?? { step: null };
|
|
117
121
|
const cur = () => nodes[nodes.length - 1];
|
|
118
122
|
const d = event.data ?? {};
|
|
119
123
|
switch (event.type) {
|
|
124
|
+
case "step/start": {
|
|
125
|
+
st.step = d.step ?? (st.step ?? 0) + 1;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
120
128
|
case "user/message": {
|
|
121
129
|
const text = partsToText(d.content ?? d.message?.content);
|
|
122
130
|
const images = partsToImages(d.content ?? d.message?.content);
|
|
123
131
|
const id = d.id ?? null;
|
|
124
|
-
if (text !== null) nodes.push({ kind: "user", text, images, id });
|
|
125
|
-
else if (images) nodes.push({ kind: "user", text: "", images, id });
|
|
132
|
+
if (text !== null) nodes.push({ kind: "user", text, images, id, step: st.step });
|
|
133
|
+
else if (images) nodes.push({ kind: "user", text: "", images, id, step: st.step });
|
|
126
134
|
break;
|
|
127
135
|
}
|
|
128
136
|
case "assistant/message": {
|
|
@@ -137,6 +145,10 @@ function applyEvent(nodes, event, view, log) {
|
|
|
137
145
|
else if (p.type === "tool-call") { /* handled by tool/call event */ }
|
|
138
146
|
else blocks.push({ kind: "other", text: JSON.stringify(p).slice(0, 500) });
|
|
139
147
|
}
|
|
148
|
+
// Finalization stamps: endedAt marks when the message completed. The
|
|
149
|
+
// block start time is left untouched so syncTail's inheritStarts can
|
|
150
|
+
// carry the ORIGINAL block start across re-derivations.
|
|
151
|
+
for (const b of blocks) b.endedAt = event.time ?? Date.now();
|
|
140
152
|
const images = partsToImages(d.message?.content);
|
|
141
153
|
const id = d.message?.id ?? null;
|
|
142
154
|
const last = cur();
|
|
@@ -146,7 +158,7 @@ function applyEvent(nodes, event, view, log) {
|
|
|
146
158
|
last.id = id ?? last.id;
|
|
147
159
|
last.streaming = false;
|
|
148
160
|
} else {
|
|
149
|
-
nodes.push({ kind: "assistant", blocks, images, id, streaming: false });
|
|
161
|
+
nodes.push({ kind: "assistant", blocks, images, id, streaming: false, step: st.step });
|
|
150
162
|
}
|
|
151
163
|
break;
|
|
152
164
|
}
|
|
@@ -154,7 +166,7 @@ function applyEvent(nodes, event, view, log) {
|
|
|
154
166
|
const ch = d.chunk ?? {};
|
|
155
167
|
let node = cur();
|
|
156
168
|
if (!node || node.kind !== "assistant" || node.finalized) {
|
|
157
|
-
node = { kind: "assistant", blocks: [], streaming: true, finalized: false };
|
|
169
|
+
node = { kind: "assistant", blocks: [], streaming: true, finalized: false, step: st.step };
|
|
158
170
|
nodes.push(node);
|
|
159
171
|
}
|
|
160
172
|
node.streaming = true;
|
|
@@ -176,7 +188,7 @@ function applyEvent(nodes, event, view, log) {
|
|
|
176
188
|
}
|
|
177
189
|
} else if (ch.type === "block-end") {
|
|
178
190
|
const b = node.blocks[ch.index ?? 0];
|
|
179
|
-
if (b) b.streaming = false;
|
|
191
|
+
if (b) { b.streaming = false; b.endedAt = event.time ?? b.endedAt; }
|
|
180
192
|
}
|
|
181
193
|
break;
|
|
182
194
|
}
|
|
@@ -196,14 +208,15 @@ function applyEvent(nodes, event, view, log) {
|
|
|
196
208
|
if (d.arguments !== undefined) block.args = d.arguments;
|
|
197
209
|
if (view?.view !== undefined) block.view = view.view;
|
|
198
210
|
block.result = null;
|
|
211
|
+
if (block.startedAt === undefined) block.startedAt = event.time ?? Date.now();
|
|
199
212
|
break;
|
|
200
213
|
}
|
|
201
214
|
let node = cur();
|
|
202
215
|
if (!node || node.kind !== "assistant") {
|
|
203
|
-
node = { kind: "assistant", blocks: [], streaming: true, finalized: false };
|
|
216
|
+
node = { kind: "assistant", blocks: [], streaming: true, finalized: false, step: st.step };
|
|
204
217
|
nodes.push(node);
|
|
205
218
|
}
|
|
206
|
-
node.blocks.push({ kind: "tool", name: d.name, args: d.arguments, callId, view: view?.view, result: null });
|
|
219
|
+
node.blocks.push({ kind: "tool", name: d.name, args: d.arguments, callId, view: view?.view, result: null, startedAt: event.time ?? Date.now() });
|
|
207
220
|
break;
|
|
208
221
|
}
|
|
209
222
|
case "tool/result": {
|
|
@@ -211,12 +224,23 @@ function applyEvent(nodes, event, view, log) {
|
|
|
211
224
|
const text = partsToText(d.message?.content);
|
|
212
225
|
const node = nodes.findLast((nd) => nd.kind === "assistant" && nd.blocks.some((b) => b.kind === "tool" && b.callId === callId));
|
|
213
226
|
const block = node?.blocks.find((b) => b.kind === "tool" && b.callId === callId);
|
|
214
|
-
if (block)
|
|
227
|
+
if (block) {
|
|
228
|
+
block.result = text ?? JSON.stringify(d).slice(0, 400);
|
|
229
|
+
block.endedAt = event.time ?? Date.now();
|
|
230
|
+
}
|
|
215
231
|
break;
|
|
216
232
|
}
|
|
217
233
|
case "step/end": {
|
|
218
234
|
const node = cur();
|
|
219
|
-
if (node && node.kind === "assistant") {
|
|
235
|
+
if (node && node.kind === "assistant") {
|
|
236
|
+
node.streaming = false;
|
|
237
|
+
node.finalized = true;
|
|
238
|
+
// a missed block-end must not leave a forever-running timer
|
|
239
|
+
for (const b of node.blocks ?? []) {
|
|
240
|
+
b.streaming = false;
|
|
241
|
+
if (b.endedAt === undefined) b.endedAt = event.time ?? Date.now();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
220
244
|
break;
|
|
221
245
|
}
|
|
222
246
|
case "session/title": {
|
|
@@ -229,7 +253,7 @@ function applyEvent(nodes, event, view, log) {
|
|
|
229
253
|
}
|
|
230
254
|
default: {
|
|
231
255
|
// known benign control events: silently ignored
|
|
232
|
-
const KNOWN = new Set(["turn/start", "turn/end", "
|
|
256
|
+
const KNOWN = new Set(["turn/start", "turn/end", "todo/write",
|
|
233
257
|
"agent/inbox/spliced", "request/header", "request/context", "permission/preset",
|
|
234
258
|
"sandbox/mode", "approval/policy", "session/title-llm-request", "command/done", "command/failed"]);
|
|
235
259
|
if (!KNOWN.has(event.type) && !SEEN_TYPES.has(event.type)) {
|
|
@@ -243,7 +267,8 @@ function applyEvent(nodes, event, view, log) {
|
|
|
243
267
|
/** Re-derive the whole node list from a complete event window (open/poll). */
|
|
244
268
|
function nodeForEvents(events, log) {
|
|
245
269
|
const nodes = [];
|
|
246
|
-
|
|
270
|
+
const state = { step: null };
|
|
271
|
+
for (const { event, view } of events) applyEvent(nodes, event, view, log, state);
|
|
247
272
|
return nodes;
|
|
248
273
|
}
|
|
249
274
|
|
|
@@ -567,6 +592,7 @@ export class ChatView extends Widget {
|
|
|
567
592
|
this.pressY = null;
|
|
568
593
|
this.selStart = null;
|
|
569
594
|
this.selEnd = null;
|
|
595
|
+
this.stepState = { step: null }; // step/start tracking for the mux merge path
|
|
570
596
|
}
|
|
571
597
|
|
|
572
598
|
/** Queue a rebuild; flushed on the next frame render (throttles streaming). */
|
|
@@ -576,7 +602,7 @@ export class ChatView extends Widget {
|
|
|
576
602
|
* existing block instead of wiping it (the old "shows then deleted" bug). */
|
|
577
603
|
mergeEvents(entries) {
|
|
578
604
|
for (const { event, view } of entries) {
|
|
579
|
-
applyEvent(this.nodes, event, view, this.app.log);
|
|
605
|
+
applyEvent(this.nodes, event, view, this.app.log, this.stepState);
|
|
580
606
|
}
|
|
581
607
|
this.running = this.nodes.some((n) => n.kind === "assistant" && n.streaming);
|
|
582
608
|
this.queueRebuild();
|
|
@@ -666,14 +692,16 @@ export class ChatView extends Widget {
|
|
|
666
692
|
}
|
|
667
693
|
|
|
668
694
|
jumpToNode(idx) {
|
|
669
|
-
|
|
695
|
+
this.flushRebuild(); // lineMap must reflect the current nodes array
|
|
696
|
+
if (idx < 0 || idx >= this.nodes.length) return false;
|
|
670
697
|
for (let li = 0; li < this.lineMap.length; li++) {
|
|
671
698
|
if (this.lineMap[li]?.nodeIdx === idx) {
|
|
672
699
|
this.view.scrollY = Math.max(0, li - 2);
|
|
673
700
|
this.app.redraw();
|
|
674
|
-
return;
|
|
701
|
+
return true;
|
|
675
702
|
}
|
|
676
703
|
}
|
|
704
|
+
return false; // node exists but is outside the rendered tail window
|
|
677
705
|
}
|
|
678
706
|
|
|
679
707
|
queueRebuild() { this.rebuildQueued = true; }
|
|
@@ -808,6 +836,9 @@ export class ChatView extends Widget {
|
|
|
808
836
|
}
|
|
809
837
|
|
|
810
838
|
#clickLine(y, ev) {
|
|
839
|
+
// A queued (deferred) rebuild would leave lineMap one frame behind the
|
|
840
|
+
// nodes array; flush first so the click maps to the CURRENT node/block.
|
|
841
|
+
this.flushRebuild();
|
|
811
842
|
// map rendered line back to node via cached line→node map
|
|
812
843
|
const info = this.lineMap?.[y];
|
|
813
844
|
if (info) {
|
|
@@ -853,7 +884,7 @@ export class ChatView extends Widget {
|
|
|
853
884
|
const markImg = (nodeIdx, imgIdx) => lineMap.push({ nodeIdx, imgIdx });
|
|
854
885
|
// render only the tail of very long sessions; earlier nodes load via pagination
|
|
855
886
|
const MAX_NODES = 150;
|
|
856
|
-
const skipCount = this.nodes.length - MAX_NODES;
|
|
887
|
+
const skipCount = Math.max(0, this.nodes.length - MAX_NODES);
|
|
857
888
|
const nodes = skipCount > 0 ? this.nodes.slice(skipCount) : this.nodes;
|
|
858
889
|
lines.push([{ t: truncate(this.title || this.sessionId?.slice(0, 8) || "", w - 2), fg: K.DIM }]);
|
|
859
890
|
mark(-1);
|
|
@@ -874,7 +905,7 @@ export class ChatView extends Widget {
|
|
|
874
905
|
return ".";
|
|
875
906
|
}).join("")
|
|
876
907
|
: "";
|
|
877
|
-
const ckey = `${realIdx}|${w}|${expKey}|${blockKeys}|${this.thinkMode}|${this.bashMode}|${node.streaming ? "s" : "f"}|${themeName()}`;
|
|
908
|
+
const ckey = `${realIdx}|${w}|${expKey}|${blockKeys}|${this.thinkMode}|${this.bashMode}|${node.streaming ? "s" : "f"}|${themeName()}|${node.step ?? "-"}|${userPrefix()}`;
|
|
878
909
|
// Streaming nodes re-render every frame: their text grows without any
|
|
879
910
|
// change to the cache key, so caching them freezes the live think/tool/text.
|
|
880
911
|
const hit = node.streaming ? undefined : this.cache.get(ckey);
|
|
@@ -916,9 +947,19 @@ export class ChatView extends Widget {
|
|
|
916
947
|
const text = node.text ?? "";
|
|
917
948
|
const shown = isExp ? text : text.slice(0, 2000);
|
|
918
949
|
beginCard("USERBG");
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
950
|
+
// First line carries the customizable "name > " marker and the
|
|
951
|
+
// message text starts on that same line (no blank first row).
|
|
952
|
+
const prefix = userPrefix();
|
|
953
|
+
const pw = strWidth(prefix);
|
|
954
|
+
const md = renderMd(shown, Math.max(10, w - 4 - pw));
|
|
955
|
+
if (md.length === 0) {
|
|
956
|
+
lines.push([{ t: " " + prefix, fg: K.OK, bold: true }]);
|
|
957
|
+
mark(realIdx);
|
|
958
|
+
} else {
|
|
959
|
+
lines.push([{ t: " " + prefix, fg: K.OK, bold: true }, ...md[0]]);
|
|
960
|
+
mark(realIdx);
|
|
961
|
+
for (const ln of md.slice(1)) { lines.push([{ t: " " + " ".repeat(pw) }, ...ln]); mark(realIdx); }
|
|
962
|
+
}
|
|
922
963
|
if (!isExp && text.length > 2000) lines.push([{ t: " …", fg: K.FAINT }]);
|
|
923
964
|
if (node.images) {
|
|
924
965
|
for (let ii = 0; ii < node.images.length; ii++) {
|
|
@@ -939,15 +980,22 @@ export class ChatView extends Widget {
|
|
|
939
980
|
if (blocks.length === 0) { lines.push([{ t: " …", fg: K.FAINT }]); mark(realIdx); break; }
|
|
940
981
|
for (let bi = 0; bi < blocks.length; bi++) {
|
|
941
982
|
const b = blocks[bi];
|
|
983
|
+
const stepTag = node.step != null ? ` (step ${node.step})` : "";
|
|
942
984
|
if (b.kind === "reasoning") {
|
|
943
985
|
const key = `${realIdx}:${bi}`;
|
|
944
986
|
const manuallyCollapsed = this.collapsedBlocks.has(key);
|
|
945
987
|
const manuallyExpanded = this.expanded.has(key);
|
|
946
988
|
const open = manuallyExpanded || (!manuallyCollapsed && this.thinkMode === "expanded");
|
|
947
989
|
beginCard("THINKBG");
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
990
|
+
// Live blocks tick (已经过…); finished blocks freeze at their
|
|
991
|
+
// total (已完成,耗时…). No timestamps → no bogus timer shown.
|
|
992
|
+
let timing = "";
|
|
993
|
+
if (b.streaming) {
|
|
994
|
+
timing = ` 已经过 ${fmtDuration(Date.now() - (b.startedAt ?? Date.now()))}`;
|
|
995
|
+
} else if (b.startedAt !== undefined && b.endedAt !== undefined) {
|
|
996
|
+
timing = ` 已完成,耗时 ${fmtDuration(b.endedAt - b.startedAt)}`;
|
|
997
|
+
}
|
|
998
|
+
const thinkMeta = `${stepTag}(${b.text?.length ?? 0} 字)${timing}`;
|
|
951
999
|
lines.push([{ t: "💭 思考" + (b.streaming ? "…" : "") + thinkMeta + (open ? " [t 折叠]" : " [t 展开]"), fg: K.FAINT }]);
|
|
952
1000
|
mark(realIdx, bi);
|
|
953
1001
|
if (open) {
|
|
@@ -968,10 +1016,18 @@ export class ChatView extends Widget {
|
|
|
968
1016
|
const glyph = b.result == null && !b.done ? "⏳" : exitCode !== undefined && exitCode !== 0 ? "✗" : "✓";
|
|
969
1017
|
const card = b.view ? renderToolCard(b.view, w, open) : [];
|
|
970
1018
|
beginCard(status);
|
|
1019
|
+
let timing = "";
|
|
1020
|
+
if (b.result == null && !b.done) {
|
|
1021
|
+
timing = ` 已经过 ${fmtDuration(Date.now() - (b.startedAt ?? Date.now()))}`;
|
|
1022
|
+
} else if (b.startedAt !== undefined && b.endedAt !== undefined) {
|
|
1023
|
+
const failed = exitCode !== undefined && exitCode !== 0;
|
|
1024
|
+
timing = ` ${failed ? "失败" : "已完成"},耗时 ${fmtDuration(b.endedAt - b.startedAt)}`;
|
|
1025
|
+
}
|
|
971
1026
|
lines.push([
|
|
972
1027
|
{ t: open ? "▾ " : "▸ ", fg: K.ACCENT },
|
|
973
1028
|
{ t: ` ${b.name ?? "tool"}`, fg: K.TXT, bold: true },
|
|
974
1029
|
{ t: ` ${glyph}`, fg: status === "TOOLOK" ? K.OK : status === "TOOLERR" ? K.ERR : K.WARN },
|
|
1030
|
+
{ t: stepTag + timing, fg: K.DIM },
|
|
975
1031
|
{ t: open ? " [b 折叠]" : " [b 展开]", fg: K.FAINT },
|
|
976
1032
|
]);
|
|
977
1033
|
mark(realIdx, bi);
|
|
@@ -998,14 +1054,25 @@ export class ChatView extends Widget {
|
|
|
998
1054
|
sep();
|
|
999
1055
|
} else if (b.kind === "other") {
|
|
1000
1056
|
beginCard("THINKBG");
|
|
1001
|
-
lines.push([{ t: " " + truncate(b.text, w - 4), fg: K.DIM }]);
|
|
1057
|
+
lines.push([{ t: " " + stepTag + truncate(b.text, w - 4 - strWidth(stepTag)), fg: K.DIM }]);
|
|
1002
1058
|
mark(realIdx, bi);
|
|
1003
1059
|
sep();
|
|
1004
1060
|
} else {
|
|
1005
1061
|
beginCard("CARD");
|
|
1006
1062
|
const text = b.text ?? "";
|
|
1007
|
-
|
|
1008
|
-
|
|
1063
|
+
const md = renderMd(text, w - 4 - strWidth(stepTag));
|
|
1064
|
+
if (md.length > 0) {
|
|
1065
|
+
lines.push([{ t: " " }, { t: stepTag, fg: K.FAINT }, ...md[0]]);
|
|
1066
|
+
mark(realIdx);
|
|
1067
|
+
for (const ln of md.slice(1)) { lines.push([{ t: " " }, ...ln]); mark(realIdx); }
|
|
1068
|
+
// Label the block's LAST line with its blockIdx IN PLACE. A
|
|
1069
|
+
// pushed mark without a line would desync lineMap from lines
|
|
1070
|
+
// and shift every later click mapping (the off-by-one bug).
|
|
1071
|
+
lineMap[lineMap.length - 1] = { nodeIdx: realIdx, blockIdx: bi };
|
|
1072
|
+
} else {
|
|
1073
|
+
lines.push([{ t: " " + stepTag, fg: K.FAINT }]);
|
|
1074
|
+
mark(realIdx, bi);
|
|
1075
|
+
}
|
|
1009
1076
|
sep();
|
|
1010
1077
|
}
|
|
1011
1078
|
}
|
|
@@ -1187,6 +1254,7 @@ export class ChatView extends Widget {
|
|
|
1187
1254
|
return true;
|
|
1188
1255
|
}
|
|
1189
1256
|
if (ev.kind === "press" && ev.button === 2) {
|
|
1257
|
+
this.flushRebuild(); // keep lineMap current with the nodes array
|
|
1190
1258
|
const y = ev.y - this.view.y + this.view.scrollY;
|
|
1191
1259
|
const info = this.lineMap?.[y];
|
|
1192
1260
|
if (info) {
|
|
@@ -1195,6 +1263,9 @@ export class ChatView extends Widget {
|
|
|
1195
1263
|
{ label: "复制消息", action: () => this.app.copyNode(info.nodeIdx) },
|
|
1196
1264
|
{ label: "展开 / 折叠", action: () => this.#clickLine(y, ev) },
|
|
1197
1265
|
];
|
|
1266
|
+
if (node?.id) {
|
|
1267
|
+
items.push({ label: "转跳轨迹", action: () => this.app.jumpToTrajectoryNode(info.nodeIdx) });
|
|
1268
|
+
}
|
|
1198
1269
|
if (node?.kind === "assistant" && node.id) {
|
|
1199
1270
|
const fb = this.app.feedbackMap.get(node.id);
|
|
1200
1271
|
const cur = fb?.rating === "positive" ? " ✓已好评" : fb?.rating === "negative" ? " ✓已差评" : "";
|
|
@@ -1287,13 +1358,6 @@ function toolSummary(b) {
|
|
|
1287
1358
|
return null;
|
|
1288
1359
|
}
|
|
1289
1360
|
|
|
1290
|
-
function fmtElapsed(ms) {
|
|
1291
|
-
if (ms == null || isNaN(ms) || ms < 0) return "0.0s";
|
|
1292
|
-
if (ms < 1000) return `${(ms / 1000).toFixed(1)}s`;
|
|
1293
|
-
if (ms < 60000) return `${(ms / 1000).toFixed(0)}s`;
|
|
1294
|
-
return `${Math.floor(ms / 60000)}m${Math.floor((ms % 60000) / 1000)}s`;
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
1361
|
function fmtTokens(n) {
|
|
1298
1362
|
if (n == null || isNaN(n)) return "0";
|
|
1299
1363
|
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
|
|
@@ -1518,6 +1582,38 @@ export class App {
|
|
|
1518
1582
|
this.setMode(this.mode === "trajectory" ? "chat" : "trajectory");
|
|
1519
1583
|
}
|
|
1520
1584
|
|
|
1585
|
+
/** Chat → trajectory: open the trajectory panel at the step containing a
|
|
1586
|
+
* chat node (right-click menu), loading older steps on demand. */
|
|
1587
|
+
async jumpToTrajectoryNode(nodeIdx) {
|
|
1588
|
+
if (!this.currentSession) { this.toast("先打开一个会话"); return; }
|
|
1589
|
+
const node = this.chat.nodes[nodeIdx];
|
|
1590
|
+
if (!node) { this.toast("消息不存在"); return; }
|
|
1591
|
+
if (!this.trajectoryPanel) this.trajectoryPanel = new TrajectoryPanel(this);
|
|
1592
|
+
this.setMode("trajectory"); // also kicks load(currentSession)
|
|
1593
|
+
await this.trajectoryPanel.focusMessage(node.id ?? null);
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
/** Trajectory → chat: switch back to the chat view scrolled at the message
|
|
1597
|
+
* the step belongs to (right-click menu). */
|
|
1598
|
+
jumpToChatStep(si) {
|
|
1599
|
+
const step = this.trajectoryPanel?.steps?.[si];
|
|
1600
|
+
if (!step) { this.toast("该步骤不可用"); return; }
|
|
1601
|
+
let messageId = null;
|
|
1602
|
+
for (const e of step.events) {
|
|
1603
|
+
const d = e.data ?? {};
|
|
1604
|
+
const id = d.id ?? d.message?.id;
|
|
1605
|
+
if (id) { messageId = id; break; }
|
|
1606
|
+
}
|
|
1607
|
+
if (!messageId) { this.toast("该步骤没有关联消息 ID"); return; }
|
|
1608
|
+
this.setMode("chat");
|
|
1609
|
+
const idx = this.chat.nodes.findIndex((n) => n.id === messageId);
|
|
1610
|
+
if (idx >= 0 && this.chat.jumpToNode(idx)) {
|
|
1611
|
+
this.toast(`已转跳到消息 ${messageId.slice(0, 8)}`);
|
|
1612
|
+
} else {
|
|
1613
|
+
this.toast(idx >= 0 ? "该消息在更早的记录中(PgUp 加载后再试)" : "对应消息不在已加载的对话窗口");
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1521
1617
|
toggleSidebar() {
|
|
1522
1618
|
this.sidebarVisible = !this.sidebarVisible;
|
|
1523
1619
|
this.layout();
|
|
@@ -1992,11 +2088,51 @@ export class App {
|
|
|
1992
2088
|
else this.toast("先打开一个会话");
|
|
1993
2089
|
}
|
|
1994
2090
|
|
|
1995
|
-
showJobs() { this.overlay =
|
|
2091
|
+
showJobs() { this.overlay = new JobsPanel(this); this.redraw(); }
|
|
1996
2092
|
showGoal() { this.overlay = buildGoalPopup(this); this.redraw(); }
|
|
1997
2093
|
showModePicker() { this.overlay = buildModePicker(this); this.redraw(); }
|
|
1998
2094
|
showPermissionPicker() { this.overlay = buildPermissionPicker(this); this.redraw(); }
|
|
1999
2095
|
|
|
2096
|
+
/** Ctrl+E: fzf-style quick jump to a step — type to fuzzy-filter the step
|
|
2097
|
+
* list, Enter opens the trajectory window around the picked step. */
|
|
2098
|
+
async quickJumpStep() {
|
|
2099
|
+
if (!this.currentSession) { this.toast("先打开一个会话"); return; }
|
|
2100
|
+
if (!this.trajectoryPanel) this.trajectoryPanel = new TrajectoryPanel(this);
|
|
2101
|
+
const tp = this.trajectoryPanel;
|
|
2102
|
+
if (tp.sessionId !== this.currentSession || tp.steps.length === 0) {
|
|
2103
|
+
this.setStatus("加载步骤列表…");
|
|
2104
|
+
await tp.load(this.currentSession);
|
|
2105
|
+
this.setStatus("");
|
|
2106
|
+
}
|
|
2107
|
+
const total = tp.stats?.steps ?? (tp.steps[tp.steps.length - 1]?.step ?? tp.steps.length);
|
|
2108
|
+
const items = [...tp.steps].reverse().map((st) => {
|
|
2109
|
+
const si = tp.steps.indexOf(st);
|
|
2110
|
+
const tools = [...new Set(st.events.filter((e) => e.type === "tool/call").map((e) => e.data?.name))];
|
|
2111
|
+
const t0 = st.events[0]?.time, t1 = st.events[st.events.length - 1]?.time;
|
|
2112
|
+
const dur = t0 && t1 ? fmtMs(t1 - t0) : "—";
|
|
2113
|
+
const userMsg = st.events.find((e) => e.type === "user/message")?.data?.content?.[0]?.text ?? "";
|
|
2114
|
+
return {
|
|
2115
|
+
label: `step ${st.step} ${dur} ${tools.slice(0, 3).join(",") || "纯文本"} ${truncate(String(userMsg), 24)}`,
|
|
2116
|
+
hint: `${st.events.length} 事件`,
|
|
2117
|
+
keywords: `step ${st.step} ${tools.join(" ")} ${userMsg}`,
|
|
2118
|
+
stepIdx: si,
|
|
2119
|
+
};
|
|
2120
|
+
});
|
|
2121
|
+
const w = Math.min(76, this.screen.w - 8), h = Math.min(20, this.screen.h - 4);
|
|
2122
|
+
this.overlay = new Picker({
|
|
2123
|
+
x: Math.floor((this.screen.w - w) / 2), y: Math.floor((this.screen.h - h) / 2),
|
|
2124
|
+
w, h, title: `步骤转跳(step 1–${total} · Ctrl+E)— 输入过滤,回车定位`,
|
|
2125
|
+
items,
|
|
2126
|
+
onCancel: () => this.closeOverlay(),
|
|
2127
|
+
onPick: (it) => {
|
|
2128
|
+
this.closeOverlay();
|
|
2129
|
+
this.setMode("trajectory");
|
|
2130
|
+
this.trajectoryPanel.jumpToStep(it.stepIdx);
|
|
2131
|
+
},
|
|
2132
|
+
});
|
|
2133
|
+
this.redraw();
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2000
2136
|
/** Select one of the four agent presets (modes). */
|
|
2001
2137
|
async selectPreset(id) {
|
|
2002
2138
|
if (!this.currentSession) { this.toast("先打开一个会话"); return; }
|
|
@@ -2276,6 +2412,7 @@ export class App {
|
|
|
2276
2412
|
this.setMode("workspace"); return;
|
|
2277
2413
|
}
|
|
2278
2414
|
if (ev.ctrl && ev.key === "t") { this.setMode("trajectory"); return; }
|
|
2415
|
+
if (ev.ctrl && ev.key === "e") { this.quickJumpStep(); return; }
|
|
2279
2416
|
if (ev.ctrl && ev.key === "j") { this.showJobs(); return; }
|
|
2280
2417
|
if (ev.ctrl && ev.key === "g") { this.showGoal(); return; }
|
|
2281
2418
|
if (ev.ctrl && ev.key === "f") { this.findInConversation(); return; }
|
|
@@ -2470,16 +2607,18 @@ export class App {
|
|
|
2470
2607
|
if (stats.ttftMs) row1.right.push({ t: ` 首响${Math.round(stats.ttftMs / stats.ttftSteps)}ms `, fg: T.FAINT, bg: T.STATUSBG });
|
|
2471
2608
|
}
|
|
2472
2609
|
rows.push(row1);
|
|
2473
|
-
// ── row 2: jobs
|
|
2610
|
+
// ── row 2: background jobs — one summary line, never per-job noise ──
|
|
2474
2611
|
if (this.jobs?.length) {
|
|
2475
|
-
const running = this.jobs.filter((j) => j.status === "running");
|
|
2612
|
+
const running = this.jobs.filter((j) => j.status === "running").length;
|
|
2613
|
+
const done = this.jobs.filter((j) => j.status === "completed").length;
|
|
2614
|
+
const failed = this.jobs.filter((j) => j.status === "failed").length;
|
|
2476
2615
|
const row2 = { left: [], right: [] };
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
}
|
|
2482
|
-
|
|
2616
|
+
row2.left.push({
|
|
2617
|
+
t: ` ${running > 0 ? `${running} 个任务正在后台运行` : "没有任务正在后台运行"} `,
|
|
2618
|
+
fg: running > 0 ? T.WARN : T.FAINT, bg: T.STATUSBG,
|
|
2619
|
+
});
|
|
2620
|
+
row2.left.push({ t: ` ${done}已完成${failed > 0 ? ` ${failed}失败` : ""} `, fg: done > 0 ? T.OK : T.FAINT, bg: T.STATUSBG });
|
|
2621
|
+
row2.right.push({ t: " Ctrl+J 查看详情 ", fg: T.DIM, bg: T.STATUSBG });
|
|
2483
2622
|
rows.push(row2);
|
|
2484
2623
|
}
|
|
2485
2624
|
this.status.rows = rows;
|