dsh-neotui 0.0.9 → 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 +9 -3
- package/package.json +1 -1
- package/src/config.js +64 -0
- package/src/panels.js +274 -57
- package/src/text.js +10 -0
- package/src/views.js +133 -56
package/README.md
CHANGED
|
@@ -61,6 +61,9 @@ tui/app/ dsh-neotui-app bundle:cordis.patch.yml + tui-startup/tui-r
|
|
|
61
61
|
| 输入模式 | `i` 或点击进入,**Esc 退出**(nvim normal/insert);未聚焦时字母=快捷键,多字粘贴直接输入 |
|
|
62
62
|
| 思考/工具折叠 | `t` 思考块展开折叠,`b` 工具块(bash 等)展开折叠;右键消息 →「展开 / 折叠」折叠单个输出块 |
|
|
63
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 查看详情)」 |
|
|
64
67
|
| 右键文件夹 → 新建会话(归属该工作区) | `n` |
|
|
65
68
|
| 滚轮翻页 / 拖拽 | PgUp / PgDn / gg / G |
|
|
66
69
|
| 点击展开思考块/工具卡 | — |
|
|
@@ -95,14 +98,17 @@ tui/app/ dsh-neotui-app bundle:cordis.patch.yml + tui-startup/tui-r
|
|
|
95
98
|
- footer(powerline 风格,2–3 行):第 1 行身份(工作区/会话/运行态/🎯目标/✎计划/模型),第 2 行用量(ctx 条+%、入/出/缓存/共 token、⚙步/回合/首响),第 3 行任务(有 job 时)
|
|
96
99
|
- 折叠块预览:bash 折叠后显示命令概要(view.title 或 command),思考折叠后显示前 3 行
|
|
97
100
|
- 会话内搜索:**Ctrl+F** 模糊搜本会话消息/工具名,回车跳转
|
|
98
|
-
- 轨迹详情:点击回合行弹窗(工具调用 + 耗时),`/` 过滤回合;**增量加载**(每页 20
|
|
99
|
-
- 轨迹 详细/简略:每个 step
|
|
101
|
+
- 轨迹详情:点击回合行弹窗(工具调用 + 耗时),`/` 过滤回合;**增量加载**(每页 20 回合逐页渲染,最近回合秒开,历史步骤按需翻页,不再阻塞等待全量历史)
|
|
102
|
+
- 轨迹 详细/简略:每个 step 左键单击(▸/▾)或右键菜单第一项 →「展开(详细)/ 折叠(简略)」内联展开事件列表(仿对话页);跳转定位的 step 自动展开 + 高亮闪烁
|
|
103
|
+
- 轨迹窗口模型:转跳只显示 `step±20`;PgUp/PgDn 按 10 步扩展窗口(按需向后翻页加载),Home 加载到 step 1–20,End 回到最新 20 步,窗口指示行显示「窗口 step A–B · 共 N 步」
|
|
100
104
|
- 工作区文件搜索:工作区面板内直接打字(`/`)模糊搜文件名,回车预览
|
|
101
105
|
- 消息反馈:助手消息右键 👍/👎(`messageFeedback/put` Typert RPC),已评状态回显、可删除
|
|
102
106
|
- 图片画廊:多图时 ←/→ 切换,标题显示 (N/M) + 尺寸
|
|
103
107
|
- 会话内搜索高亮:Ctrl+F 跳转后匹配词高亮(黄底),Esc 清除
|
|
104
108
|
- 轨迹逐事件详情:点击回合 → 事件列表 → 选事件看全文
|
|
105
|
-
- 用户消息前缀:自己发的消息第一行直接显示内容并带 `用户名 > `
|
|
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 可据此快速转跳
|
|
106
112
|
- 主题持久化:切主题写入 `$DSH_HOME/tui-theme.txt`(或 XDG config),重启保留
|
|
107
113
|
- 输入模式标识:标签栏右侧 NORMAL/INSERT 指示,Esc 退出输入
|
|
108
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
|
|
|
@@ -555,17 +556,32 @@ export class TrajectoryPanel extends Widget {
|
|
|
555
556
|
this.allEvents = [];
|
|
556
557
|
this.sessionId = null;
|
|
557
558
|
this.expandedSteps = new Set(); // step identity keys rendered 详细 (expanded)
|
|
558
|
-
this.flashStep = null; // step just jumped to (brief highlight)
|
|
559
|
+
this.flashStep = null; // step NUMBER just jumped to (brief highlight)
|
|
559
560
|
this.flashUntil = 0;
|
|
560
561
|
this.loadPromise = null; // dedupes concurrent load(currentSession)
|
|
561
562
|
this.loadTarget = null;
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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).
|
|
566
|
+
this.view = new ScrollView({ x: this.x, y: this.y, w: this.w, h: this.h, showScrollbar: true, onClick: (y) => this.#clickLine(y) });
|
|
565
567
|
this.stepLines = [];
|
|
566
568
|
this.query = "";
|
|
567
569
|
}
|
|
568
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
|
+
|
|
569
585
|
#eventSummary(e) {
|
|
570
586
|
const d = e.data ?? {};
|
|
571
587
|
if (e.type === "user/message") return `❯ ${String(d.content?.[0]?.text ?? "").slice(0, 40)}`;
|
|
@@ -619,9 +635,17 @@ export class TrajectoryPanel extends Widget {
|
|
|
619
635
|
const step = this.steps[si];
|
|
620
636
|
if (!step) return;
|
|
621
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;
|
|
622
642
|
if (this.expandedSteps.has(key)) this.expandedSteps.delete(key);
|
|
623
643
|
else this.expandedSteps.add(key);
|
|
624
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
|
+
}
|
|
625
649
|
this.app.redraw();
|
|
626
650
|
}
|
|
627
651
|
|
|
@@ -641,15 +665,98 @@ export class TrajectoryPanel extends Widget {
|
|
|
641
665
|
return -1;
|
|
642
666
|
}
|
|
643
667
|
|
|
644
|
-
/**
|
|
645
|
-
|
|
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) {
|
|
646
702
|
if (si < 0 || si >= this.steps.length) return;
|
|
703
|
+
const S = this.steps[si].step;
|
|
647
704
|
this.expandedSteps.add(this.stepKey(this.steps[si]));
|
|
648
|
-
this.
|
|
649
|
-
const li = this.stepLines.indexOf(si);
|
|
650
|
-
this.view.scrollY = li >= 0 ? Math.max(0, li - 2) : 0;
|
|
651
|
-
this.flashStep = si;
|
|
705
|
+
this.flashStep = S;
|
|
652
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})`);
|
|
653
760
|
this.app.redraw();
|
|
654
761
|
}
|
|
655
762
|
|
|
@@ -664,11 +771,17 @@ export class TrajectoryPanel extends Widget {
|
|
|
664
771
|
await this.loadOlder();
|
|
665
772
|
si = this.indexOfMessage(messageId);
|
|
666
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
|
+
}
|
|
667
779
|
if (si >= 0) {
|
|
668
|
-
this.
|
|
669
|
-
this.
|
|
780
|
+
const S = this.steps[si].step;
|
|
781
|
+
await this.jumpToStep(si);
|
|
782
|
+
this.app.toast(`已定位到 step ${S}`);
|
|
670
783
|
} else if (this.steps.length) {
|
|
671
|
-
this.jumpToStep(this.steps.length - 1);
|
|
784
|
+
await this.jumpToStep(this.steps.length - 1);
|
|
672
785
|
this.app.toast(messageId ? "对应步骤不在已加载窗口" : "消息未关联步骤,已到最新步骤");
|
|
673
786
|
}
|
|
674
787
|
}
|
|
@@ -751,19 +864,24 @@ export class TrajectoryPanel extends Widget {
|
|
|
751
864
|
if (cur) cur.events.push(event);
|
|
752
865
|
}
|
|
753
866
|
if (cur && cur.events.length) steps.push(cur);
|
|
754
|
-
|
|
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;
|
|
755
870
|
}
|
|
756
|
-
buildLines(
|
|
871
|
+
buildLines() {
|
|
757
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;
|
|
758
876
|
const lines = [];
|
|
759
|
-
lines.push([{ t: "轨迹 —
|
|
760
|
-
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 }]);
|
|
761
879
|
else lines.push([{ t: "" }]);
|
|
762
880
|
const st = this.stats;
|
|
763
881
|
if (st) {
|
|
764
882
|
lines.push([{ t: `回合 ${st.turns} · 步骤 ${st.steps} · LLM ${fmtMs(st.llmMs)} · 工具 ${fmtMs(st.toolMs)}`, fg: K.DIM }]);
|
|
765
883
|
}
|
|
766
|
-
lines.push([{ t:
|
|
884
|
+
lines.push([{ t: `窗口 step ${lo}–${hi} · 共 ${N} 步${this.winLo == null ? "(跟随最新)" : ""}:`, fg: K.DIM, underline: true }]);
|
|
767
885
|
this.stepLines = [];
|
|
768
886
|
const list = this.query
|
|
769
887
|
? this.steps.filter((t) => t.events.some((e) => {
|
|
@@ -771,15 +889,8 @@ export class TrajectoryPanel extends Widget {
|
|
|
771
889
|
const hay = `${e.type} ${d.name ?? ""} ${typeof d.content === "string" ? d.content : ""}`.toLowerCase();
|
|
772
890
|
return hay.includes(this.query.toLowerCase());
|
|
773
891
|
}))
|
|
774
|
-
: this.steps;
|
|
775
|
-
|
|
776
|
-
// step centers the window around it so its line actually exists.
|
|
777
|
-
let windowStart = Math.max(0, list.length - 40);
|
|
778
|
-
if (!this.query && aroundSi !== null && aroundSi >= 0) {
|
|
779
|
-
const li = list.indexOf(this.steps[aroundSi]);
|
|
780
|
-
if (li >= 0) windowStart = Math.max(0, Math.min(li - 8, list.length - 40));
|
|
781
|
-
}
|
|
782
|
-
for (const step of list.slice(windowStart, windowStart + 40).reverse()) {
|
|
892
|
+
: this.steps.filter((s) => s.step >= lo && s.step <= hi);
|
|
893
|
+
for (const step of list.reverse()) {
|
|
783
894
|
const si = this.steps.indexOf(step);
|
|
784
895
|
const tools = [...new Set(step.events.filter((e) => e.type === "tool/call").map((e) => e.data?.name))];
|
|
785
896
|
const hasResult = step.events.some((e) => e.type === "tool/result");
|
|
@@ -789,7 +900,7 @@ export class TrajectoryPanel extends Widget {
|
|
|
789
900
|
const bg = tools.length ? (hasResult ? T.TOOLOK : T.TOOLBG) : hasReasoning ? T.THINKBG : T.CARD;
|
|
790
901
|
const summary = tools.slice(0, 3).join(",") || (hasReasoning ? "模型推理" : "纯文本");
|
|
791
902
|
const open = this.expandedSteps.has(this.stepKey(step)); // 详细
|
|
792
|
-
const flash = this.flashStep ===
|
|
903
|
+
const flash = this.flashStep === step.step && Date.now() < this.flashUntil;
|
|
793
904
|
const rowBg = flash ? T.ACCENT : bg;
|
|
794
905
|
const label = `${open ? "▾" : "▸"} step ${String(step.step).padStart(3)} ${pad(dur, 8)} ${summary} ${open ? "[折叠]" : "[展开]"}`;
|
|
795
906
|
const segs = [{ t: label, fg: flash ? T.SELFG : K.TXT, bg: rowBg, bold: true }];
|
|
@@ -843,15 +954,11 @@ export class TrajectoryPanel extends Widget {
|
|
|
843
954
|
// leak into the chat view's context menu underneath.
|
|
844
955
|
return this.view.inside(ev.x, ev.y);
|
|
845
956
|
}
|
|
846
|
-
// wheel / scrollbar
|
|
957
|
+
// wheel / scrollbar / LEFT-click toggle keep working (view.onClick)
|
|
847
958
|
if (this.view.onMouse(ev)) return true;
|
|
848
|
-
//
|
|
849
|
-
// through to the chat view underneath (which shares this rectangle).
|
|
850
|
-
if (ev.kind === "press" && ev.button === 0 && this.view.inside(ev.x, ev.y))
|
|
851
|
-
const y = ev.y - this.view.y + this.view.scrollY;
|
|
852
|
-
if (this.hasMore && y === 1) this.loadOlder(); // "▲ 更早步骤" row
|
|
853
|
-
return true;
|
|
854
|
-
}
|
|
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;
|
|
855
962
|
return false;
|
|
856
963
|
}
|
|
857
964
|
onKey(ev) {
|
|
@@ -863,14 +970,22 @@ export class TrajectoryPanel extends Widget {
|
|
|
863
970
|
return true;
|
|
864
971
|
}
|
|
865
972
|
if (ev.name === "backspace") { this.query = this.query.slice(0, -1); this.buildLines(); this.app.redraw(); return true; }
|
|
866
|
-
if (ev.name === "char" && ev.key === "r" && !ev.ctrl) {
|
|
867
|
-
|
|
868
|
-
|
|
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);
|
|
869
984
|
return false;
|
|
870
985
|
}
|
|
871
986
|
}
|
|
872
987
|
|
|
873
|
-
function fmtMs(ms) {
|
|
988
|
+
export function fmtMs(ms) {
|
|
874
989
|
if (ms == null || isNaN(ms)) return "—";
|
|
875
990
|
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
876
991
|
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
@@ -1069,7 +1184,8 @@ export class ControlPanel extends Widget {
|
|
|
1069
1184
|
["Ctrl+A", "子代理", () => { this.app.closeOverlay(); this.app.setMode("subagent"); }],
|
|
1070
1185
|
["Ctrl+K", "技能", () => { this.app.closeOverlay(); this.app.setMode("skills"); }],
|
|
1071
1186
|
["Ctrl+G", "目标", () => this.app.showGoal()],
|
|
1072
|
-
["Ctrl+J", "
|
|
1187
|
+
["Ctrl+J", "后台任务", () => this.app.showJobs()],
|
|
1188
|
+
["Ctrl+E", "步骤转跳(fzf 式)", () => this.app.quickJumpStep()],
|
|
1073
1189
|
["Ctrl+B", "侧栏 显示/隐藏", () => this.app.toggleSidebar()],
|
|
1074
1190
|
["Ctrl+Q", "退出", () => this.app.stop()],
|
|
1075
1191
|
];
|
|
@@ -1217,21 +1333,98 @@ export class ControlPanel extends Widget {
|
|
|
1217
1333
|
|
|
1218
1334
|
// ---- Jobs & goal popups ----
|
|
1219
1335
|
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
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];
|
|
1225
1375
|
const icon = j.status === "running" ? "⚙" : j.status === "completed" ? "✓" : j.status === "failed" ? "✗" : "·";
|
|
1226
1376
|
const color = j.status === "running" ? K.WARN : j.status === "completed" ? K.OK : j.status === "failed" ? K.ERR : K.DIM;
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
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
|
+
}
|
|
1235
1428
|
}
|
|
1236
1429
|
|
|
1237
1430
|
export function buildGoalPopup(app) {
|
|
@@ -1298,11 +1491,19 @@ export class SettingsPanel extends Widget {
|
|
|
1298
1491
|
const d = await this.app.api.call("settings.describe");
|
|
1299
1492
|
this.namespaces = d.namespaces ?? [];
|
|
1300
1493
|
this.writable = d.writable;
|
|
1301
|
-
this.selectNs(0);
|
|
1302
1494
|
} catch (e) {
|
|
1303
1495
|
this.app.toast(`设置加载失败: ${e.message}`);
|
|
1304
1496
|
this.app.setMode("chat");
|
|
1497
|
+
return;
|
|
1305
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);
|
|
1306
1507
|
}
|
|
1307
1508
|
selectNs(i) {
|
|
1308
1509
|
this.nsIdx = Math.max(0, Math.min(this.namespaces.length - 1, i));
|
|
@@ -1354,9 +1555,10 @@ export class SettingsPanel extends Widget {
|
|
|
1354
1555
|
this.nsList.render(screen);
|
|
1355
1556
|
const ns = this.currentNs();
|
|
1356
1557
|
if (ns) {
|
|
1357
|
-
|
|
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 });
|
|
1358
1560
|
const pend = this.pendingOps.length ? ` ⚠ ${this.pendingOps.length} 项待保存` : "";
|
|
1359
|
-
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 });
|
|
1360
1562
|
}
|
|
1361
1563
|
this.tree.render(screen);
|
|
1362
1564
|
if (this.editing) {
|
|
@@ -1426,6 +1628,21 @@ export class SettingsPanel extends Widget {
|
|
|
1426
1628
|
async save() {
|
|
1427
1629
|
const ns = this.currentNs();
|
|
1428
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
|
+
}
|
|
1429
1646
|
try {
|
|
1430
1647
|
await this.app.api.call("settings.mutate", { ns: ns.ns, ops: this.pendingOps, expectedRevision: ns.revision });
|
|
1431
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,36 +1,22 @@
|
|
|
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
|
-
import { userInfo } from "node:os";
|
|
7
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";
|
|
8
9
|
import {
|
|
9
10
|
Picker, buildCommandPalette, buildModelPicker, buildModePicker, buildPermissionPicker,
|
|
10
11
|
modeName, permName, WorkspacePanel, TrajectoryPanel, DirPicker,
|
|
11
|
-
ImagePopup, kittyCapable,
|
|
12
|
-
SkillsPanel, ControlPanel,
|
|
12
|
+
ImagePopup, kittyCapable, buildGoalPopup, SettingsPanel, SubagentPanel,
|
|
13
|
+
SkillsPanel, ControlPanel, JobsPanel, fmtMs,
|
|
13
14
|
} from "./panels.js";
|
|
14
15
|
|
|
15
16
|
import { T, themeName } from "./theme.js";
|
|
16
17
|
// Live theme accessor: K.K.DIM etc. resolve against the active palette at render time.
|
|
17
18
|
const K = new Proxy({}, { get(_k, key) { return T[key]; } });
|
|
18
19
|
|
|
19
|
-
// ---- User-message prefix ----
|
|
20
|
-
// "edabchann > " style marker on the first line of every message the user
|
|
21
|
-
// sent. Customizable via the DSH_TUI_USER_PREFIX env var; defaults to the
|
|
22
|
-
// OS login username (process.env.USER as a fallback).
|
|
23
|
-
let OS_USERNAME = null;
|
|
24
|
-
function osUsername() {
|
|
25
|
-
if (OS_USERNAME !== null) return OS_USERNAME;
|
|
26
|
-
try { OS_USERNAME = userInfo().username; } catch { OS_USERNAME = ""; }
|
|
27
|
-
return OS_USERNAME;
|
|
28
|
-
}
|
|
29
|
-
export function userPrefix() {
|
|
30
|
-
const name = process.env.DSH_TUI_USER_PREFIX || osUsername() || process.env.USER || process.env.LOGNAME || "user";
|
|
31
|
-
return `${name} > `;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
20
|
// ---- Tool card renderers (host-computed view models) ----
|
|
35
21
|
|
|
36
22
|
function renderToolCard(view, width, expanded) {
|
|
@@ -128,17 +114,23 @@ function diffText(oldText, newText, width) {
|
|
|
128
114
|
// ---- Chat node model ----
|
|
129
115
|
|
|
130
116
|
/** Apply ONE event onto a mutable nodes array (shared by full-window
|
|
131
|
-
* derivation and the incremental mux merge — the two paths must agree).
|
|
132
|
-
|
|
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 };
|
|
133
121
|
const cur = () => nodes[nodes.length - 1];
|
|
134
122
|
const d = event.data ?? {};
|
|
135
123
|
switch (event.type) {
|
|
124
|
+
case "step/start": {
|
|
125
|
+
st.step = d.step ?? (st.step ?? 0) + 1;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
136
128
|
case "user/message": {
|
|
137
129
|
const text = partsToText(d.content ?? d.message?.content);
|
|
138
130
|
const images = partsToImages(d.content ?? d.message?.content);
|
|
139
131
|
const id = d.id ?? null;
|
|
140
|
-
if (text !== null) nodes.push({ kind: "user", text, images, id });
|
|
141
|
-
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 });
|
|
142
134
|
break;
|
|
143
135
|
}
|
|
144
136
|
case "assistant/message": {
|
|
@@ -153,6 +145,10 @@ function applyEvent(nodes, event, view, log) {
|
|
|
153
145
|
else if (p.type === "tool-call") { /* handled by tool/call event */ }
|
|
154
146
|
else blocks.push({ kind: "other", text: JSON.stringify(p).slice(0, 500) });
|
|
155
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();
|
|
156
152
|
const images = partsToImages(d.message?.content);
|
|
157
153
|
const id = d.message?.id ?? null;
|
|
158
154
|
const last = cur();
|
|
@@ -162,7 +158,7 @@ function applyEvent(nodes, event, view, log) {
|
|
|
162
158
|
last.id = id ?? last.id;
|
|
163
159
|
last.streaming = false;
|
|
164
160
|
} else {
|
|
165
|
-
nodes.push({ kind: "assistant", blocks, images, id, streaming: false });
|
|
161
|
+
nodes.push({ kind: "assistant", blocks, images, id, streaming: false, step: st.step });
|
|
166
162
|
}
|
|
167
163
|
break;
|
|
168
164
|
}
|
|
@@ -170,7 +166,7 @@ function applyEvent(nodes, event, view, log) {
|
|
|
170
166
|
const ch = d.chunk ?? {};
|
|
171
167
|
let node = cur();
|
|
172
168
|
if (!node || node.kind !== "assistant" || node.finalized) {
|
|
173
|
-
node = { kind: "assistant", blocks: [], streaming: true, finalized: false };
|
|
169
|
+
node = { kind: "assistant", blocks: [], streaming: true, finalized: false, step: st.step };
|
|
174
170
|
nodes.push(node);
|
|
175
171
|
}
|
|
176
172
|
node.streaming = true;
|
|
@@ -192,7 +188,7 @@ function applyEvent(nodes, event, view, log) {
|
|
|
192
188
|
}
|
|
193
189
|
} else if (ch.type === "block-end") {
|
|
194
190
|
const b = node.blocks[ch.index ?? 0];
|
|
195
|
-
if (b) b.streaming = false;
|
|
191
|
+
if (b) { b.streaming = false; b.endedAt = event.time ?? b.endedAt; }
|
|
196
192
|
}
|
|
197
193
|
break;
|
|
198
194
|
}
|
|
@@ -212,14 +208,15 @@ function applyEvent(nodes, event, view, log) {
|
|
|
212
208
|
if (d.arguments !== undefined) block.args = d.arguments;
|
|
213
209
|
if (view?.view !== undefined) block.view = view.view;
|
|
214
210
|
block.result = null;
|
|
211
|
+
if (block.startedAt === undefined) block.startedAt = event.time ?? Date.now();
|
|
215
212
|
break;
|
|
216
213
|
}
|
|
217
214
|
let node = cur();
|
|
218
215
|
if (!node || node.kind !== "assistant") {
|
|
219
|
-
node = { kind: "assistant", blocks: [], streaming: true, finalized: false };
|
|
216
|
+
node = { kind: "assistant", blocks: [], streaming: true, finalized: false, step: st.step };
|
|
220
217
|
nodes.push(node);
|
|
221
218
|
}
|
|
222
|
-
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() });
|
|
223
220
|
break;
|
|
224
221
|
}
|
|
225
222
|
case "tool/result": {
|
|
@@ -227,12 +224,23 @@ function applyEvent(nodes, event, view, log) {
|
|
|
227
224
|
const text = partsToText(d.message?.content);
|
|
228
225
|
const node = nodes.findLast((nd) => nd.kind === "assistant" && nd.blocks.some((b) => b.kind === "tool" && b.callId === callId));
|
|
229
226
|
const block = node?.blocks.find((b) => b.kind === "tool" && b.callId === callId);
|
|
230
|
-
if (block)
|
|
227
|
+
if (block) {
|
|
228
|
+
block.result = text ?? JSON.stringify(d).slice(0, 400);
|
|
229
|
+
block.endedAt = event.time ?? Date.now();
|
|
230
|
+
}
|
|
231
231
|
break;
|
|
232
232
|
}
|
|
233
233
|
case "step/end": {
|
|
234
234
|
const node = cur();
|
|
235
|
-
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
|
+
}
|
|
236
244
|
break;
|
|
237
245
|
}
|
|
238
246
|
case "session/title": {
|
|
@@ -245,7 +253,7 @@ function applyEvent(nodes, event, view, log) {
|
|
|
245
253
|
}
|
|
246
254
|
default: {
|
|
247
255
|
// known benign control events: silently ignored
|
|
248
|
-
const KNOWN = new Set(["turn/start", "turn/end", "
|
|
256
|
+
const KNOWN = new Set(["turn/start", "turn/end", "todo/write",
|
|
249
257
|
"agent/inbox/spliced", "request/header", "request/context", "permission/preset",
|
|
250
258
|
"sandbox/mode", "approval/policy", "session/title-llm-request", "command/done", "command/failed"]);
|
|
251
259
|
if (!KNOWN.has(event.type) && !SEEN_TYPES.has(event.type)) {
|
|
@@ -259,7 +267,8 @@ function applyEvent(nodes, event, view, log) {
|
|
|
259
267
|
/** Re-derive the whole node list from a complete event window (open/poll). */
|
|
260
268
|
function nodeForEvents(events, log) {
|
|
261
269
|
const nodes = [];
|
|
262
|
-
|
|
270
|
+
const state = { step: null };
|
|
271
|
+
for (const { event, view } of events) applyEvent(nodes, event, view, log, state);
|
|
263
272
|
return nodes;
|
|
264
273
|
}
|
|
265
274
|
|
|
@@ -583,6 +592,7 @@ export class ChatView extends Widget {
|
|
|
583
592
|
this.pressY = null;
|
|
584
593
|
this.selStart = null;
|
|
585
594
|
this.selEnd = null;
|
|
595
|
+
this.stepState = { step: null }; // step/start tracking for the mux merge path
|
|
586
596
|
}
|
|
587
597
|
|
|
588
598
|
/** Queue a rebuild; flushed on the next frame render (throttles streaming). */
|
|
@@ -592,7 +602,7 @@ export class ChatView extends Widget {
|
|
|
592
602
|
* existing block instead of wiping it (the old "shows then deleted" bug). */
|
|
593
603
|
mergeEvents(entries) {
|
|
594
604
|
for (const { event, view } of entries) {
|
|
595
|
-
applyEvent(this.nodes, event, view, this.app.log);
|
|
605
|
+
applyEvent(this.nodes, event, view, this.app.log, this.stepState);
|
|
596
606
|
}
|
|
597
607
|
this.running = this.nodes.some((n) => n.kind === "assistant" && n.streaming);
|
|
598
608
|
this.queueRebuild();
|
|
@@ -682,6 +692,7 @@ export class ChatView extends Widget {
|
|
|
682
692
|
}
|
|
683
693
|
|
|
684
694
|
jumpToNode(idx) {
|
|
695
|
+
this.flushRebuild(); // lineMap must reflect the current nodes array
|
|
685
696
|
if (idx < 0 || idx >= this.nodes.length) return false;
|
|
686
697
|
for (let li = 0; li < this.lineMap.length; li++) {
|
|
687
698
|
if (this.lineMap[li]?.nodeIdx === idx) {
|
|
@@ -825,6 +836,9 @@ export class ChatView extends Widget {
|
|
|
825
836
|
}
|
|
826
837
|
|
|
827
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();
|
|
828
842
|
// map rendered line back to node via cached line→node map
|
|
829
843
|
const info = this.lineMap?.[y];
|
|
830
844
|
if (info) {
|
|
@@ -891,7 +905,7 @@ export class ChatView extends Widget {
|
|
|
891
905
|
return ".";
|
|
892
906
|
}).join("")
|
|
893
907
|
: "";
|
|
894
|
-
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()}`;
|
|
895
909
|
// Streaming nodes re-render every frame: their text grows without any
|
|
896
910
|
// change to the cache key, so caching them freezes the live think/tool/text.
|
|
897
911
|
const hit = node.streaming ? undefined : this.cache.get(ckey);
|
|
@@ -966,15 +980,22 @@ export class ChatView extends Widget {
|
|
|
966
980
|
if (blocks.length === 0) { lines.push([{ t: " …", fg: K.FAINT }]); mark(realIdx); break; }
|
|
967
981
|
for (let bi = 0; bi < blocks.length; bi++) {
|
|
968
982
|
const b = blocks[bi];
|
|
983
|
+
const stepTag = node.step != null ? ` (step ${node.step})` : "";
|
|
969
984
|
if (b.kind === "reasoning") {
|
|
970
985
|
const key = `${realIdx}:${bi}`;
|
|
971
986
|
const manuallyCollapsed = this.collapsedBlocks.has(key);
|
|
972
987
|
const manuallyExpanded = this.expanded.has(key);
|
|
973
988
|
const open = manuallyExpanded || (!manuallyCollapsed && this.thinkMode === "expanded");
|
|
974
989
|
beginCard("THINKBG");
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
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}`;
|
|
978
999
|
lines.push([{ t: "💭 思考" + (b.streaming ? "…" : "") + thinkMeta + (open ? " [t 折叠]" : " [t 展开]"), fg: K.FAINT }]);
|
|
979
1000
|
mark(realIdx, bi);
|
|
980
1001
|
if (open) {
|
|
@@ -995,10 +1016,18 @@ export class ChatView extends Widget {
|
|
|
995
1016
|
const glyph = b.result == null && !b.done ? "⏳" : exitCode !== undefined && exitCode !== 0 ? "✗" : "✓";
|
|
996
1017
|
const card = b.view ? renderToolCard(b.view, w, open) : [];
|
|
997
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
|
+
}
|
|
998
1026
|
lines.push([
|
|
999
1027
|
{ t: open ? "▾ " : "▸ ", fg: K.ACCENT },
|
|
1000
1028
|
{ t: ` ${b.name ?? "tool"}`, fg: K.TXT, bold: true },
|
|
1001
1029
|
{ t: ` ${glyph}`, fg: status === "TOOLOK" ? K.OK : status === "TOOLERR" ? K.ERR : K.WARN },
|
|
1030
|
+
{ t: stepTag + timing, fg: K.DIM },
|
|
1002
1031
|
{ t: open ? " [b 折叠]" : " [b 展开]", fg: K.FAINT },
|
|
1003
1032
|
]);
|
|
1004
1033
|
mark(realIdx, bi);
|
|
@@ -1025,14 +1054,25 @@ export class ChatView extends Widget {
|
|
|
1025
1054
|
sep();
|
|
1026
1055
|
} else if (b.kind === "other") {
|
|
1027
1056
|
beginCard("THINKBG");
|
|
1028
|
-
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 }]);
|
|
1029
1058
|
mark(realIdx, bi);
|
|
1030
1059
|
sep();
|
|
1031
1060
|
} else {
|
|
1032
1061
|
beginCard("CARD");
|
|
1033
1062
|
const text = b.text ?? "";
|
|
1034
|
-
|
|
1035
|
-
|
|
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
|
+
}
|
|
1036
1076
|
sep();
|
|
1037
1077
|
}
|
|
1038
1078
|
}
|
|
@@ -1214,6 +1254,7 @@ export class ChatView extends Widget {
|
|
|
1214
1254
|
return true;
|
|
1215
1255
|
}
|
|
1216
1256
|
if (ev.kind === "press" && ev.button === 2) {
|
|
1257
|
+
this.flushRebuild(); // keep lineMap current with the nodes array
|
|
1217
1258
|
const y = ev.y - this.view.y + this.view.scrollY;
|
|
1218
1259
|
const info = this.lineMap?.[y];
|
|
1219
1260
|
if (info) {
|
|
@@ -1317,13 +1358,6 @@ function toolSummary(b) {
|
|
|
1317
1358
|
return null;
|
|
1318
1359
|
}
|
|
1319
1360
|
|
|
1320
|
-
function fmtElapsed(ms) {
|
|
1321
|
-
if (ms == null || isNaN(ms) || ms < 0) return "0.0s";
|
|
1322
|
-
if (ms < 1000) return `${(ms / 1000).toFixed(1)}s`;
|
|
1323
|
-
if (ms < 60000) return `${(ms / 1000).toFixed(0)}s`;
|
|
1324
|
-
return `${Math.floor(ms / 60000)}m${Math.floor((ms % 60000) / 1000)}s`;
|
|
1325
|
-
}
|
|
1326
|
-
|
|
1327
1361
|
function fmtTokens(n) {
|
|
1328
1362
|
if (n == null || isNaN(n)) return "0";
|
|
1329
1363
|
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
|
|
@@ -2054,11 +2088,51 @@ export class App {
|
|
|
2054
2088
|
else this.toast("先打开一个会话");
|
|
2055
2089
|
}
|
|
2056
2090
|
|
|
2057
|
-
showJobs() { this.overlay =
|
|
2091
|
+
showJobs() { this.overlay = new JobsPanel(this); this.redraw(); }
|
|
2058
2092
|
showGoal() { this.overlay = buildGoalPopup(this); this.redraw(); }
|
|
2059
2093
|
showModePicker() { this.overlay = buildModePicker(this); this.redraw(); }
|
|
2060
2094
|
showPermissionPicker() { this.overlay = buildPermissionPicker(this); this.redraw(); }
|
|
2061
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
|
+
|
|
2062
2136
|
/** Select one of the four agent presets (modes). */
|
|
2063
2137
|
async selectPreset(id) {
|
|
2064
2138
|
if (!this.currentSession) { this.toast("先打开一个会话"); return; }
|
|
@@ -2338,6 +2412,7 @@ export class App {
|
|
|
2338
2412
|
this.setMode("workspace"); return;
|
|
2339
2413
|
}
|
|
2340
2414
|
if (ev.ctrl && ev.key === "t") { this.setMode("trajectory"); return; }
|
|
2415
|
+
if (ev.ctrl && ev.key === "e") { this.quickJumpStep(); return; }
|
|
2341
2416
|
if (ev.ctrl && ev.key === "j") { this.showJobs(); return; }
|
|
2342
2417
|
if (ev.ctrl && ev.key === "g") { this.showGoal(); return; }
|
|
2343
2418
|
if (ev.ctrl && ev.key === "f") { this.findInConversation(); return; }
|
|
@@ -2532,16 +2607,18 @@ export class App {
|
|
|
2532
2607
|
if (stats.ttftMs) row1.right.push({ t: ` 首响${Math.round(stats.ttftMs / stats.ttftSteps)}ms `, fg: T.FAINT, bg: T.STATUSBG });
|
|
2533
2608
|
}
|
|
2534
2609
|
rows.push(row1);
|
|
2535
|
-
// ── row 2: jobs
|
|
2610
|
+
// ── row 2: background jobs — one summary line, never per-job noise ──
|
|
2536
2611
|
if (this.jobs?.length) {
|
|
2537
|
-
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;
|
|
2538
2615
|
const row2 = { left: [], right: [] };
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
}
|
|
2544
|
-
|
|
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 });
|
|
2545
2622
|
rows.push(row2);
|
|
2546
2623
|
}
|
|
2547
2624
|
this.status.rows = rows;
|