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 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 回合逐页渲染,最近回合秒开,600 回合封顶,不再阻塞等待全量历史)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "description": "Neo-TUI: mouse-driven terminal UI client for DeepSeek Harness (B-tier per dsh-tui-design.md)",
5
5
  "type": "module",
6
6
  "bin": { "dsh-neotui": "bin/dsh-tui.js" },
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
- #clickLine(lineIdx) {
562
- if (this.hasMore && lineIdx === 1) { this.loadOlder(); return true; }
563
- const si = this.stepLines[lineIdx];
564
- if (si === undefined) return false;
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 false;
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
- return true;
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
- this.steps = steps.slice(-600);
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: "轨迹 — 步骤时间轴(每行一个色块,点击查看详情 · r 刷新)", fg: K.ACCENT, bold: true }]);
689
- if (this.hasMore) lines.push([{ t: "▲ 更早步骤(点击 / PgUp 加载)", fg: K.FAINT }]);
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: `步骤(已加载 ${this.steps.length}${this.hasMore ? "+" : ""}):`, fg: K.DIM, underline: true }]);
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.slice(-40).reverse()) {
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 label = ` step ${String(step.step).padStart(3)} ${pad(dur, 8)} ${summary}`;
714
- const segs = [{ t: label, fg: K.TXT, bg, bold: true }];
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) { return this.view.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) { this.steps = []; this.load(this.sessionId); return true; }
741
- if (ev.name === "pgup") { if (this.view.scrollY === 0 && this.hasMore) { this.loadOlder(); return true; } return this.view.scroll(-this.view.h); }
742
- if (ev.name === "pgdn" || ev.name === "up" || ev.name === "down") return this.view.onKey(ev);
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", "任务", () => this.app.showJobs()],
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
- export function buildJobsPopup(app) {
1095
- const jobs = app.jobs ?? [];
1096
- const lines = jobs.length === 0
1097
- ? [[{ t: "(当前没有任务帧)", fg: K.FAINT }]]
1098
- : jobs.map((j) => {
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
- return [{ t: ` ${icon} ${truncate(j.kind, 14)}`, fg: color, bold: true }, { t: ` ${truncate(j.label, 44)}`, fg: K.TXT }, { t: ` ${j.status}`, fg: K.DIM }];
1102
- });
1103
- return new Popup({
1104
- x: 6, y: 3, w: Math.min(80, app.screen.w - 12), h: Math.min(jobs.length + 4, 20), title: "任务",
1105
- lines: [[{ t: "" }], ...lines],
1106
- buttons: [{ label: "关闭", action: "close" }],
1107
- onAction: () => app.closeOverlay(),
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
- screen.text(this.x + 28, this.y, ` ${ns.ns} rev${ns.revision} ${this.writable === false ? "(只读)" : ""}`, { fg: K.ACCENT, bold: true });
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} rev${ns.revision} `), this.y, pend, { fg: K.WARN });
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, buildJobsPopup, buildGoalPopup, SettingsPanel, SubagentPanel,
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
- function applyEvent(nodes, event, view, log) {
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) block.result = text ?? JSON.stringify(d).slice(0, 400);
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") { node.streaming = false; node.finalized = true; }
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", "step/start", "step/end", "todo/write",
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
- for (const { event, view } of events) applyEvent(nodes, event, view, log);
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
- if (idx < 0 || idx >= this.nodes.length) return;
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
- lines.push([{ t: " ", fg: K.OK }]);
920
- mark(realIdx);
921
- for (const ln of renderMd(shown, w - 4)) { lines.push([{ t: " " }, ...ln]); mark(realIdx); }
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
- const thinkMeta = b.streaming
949
- ? ` · ${fmtElapsed(Date.now() - (b.startedAt ?? Date.now()))}`
950
- : `(${b.text?.length ?? 0} 字)`;
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
- for (const ln of renderMd(text, w - 4)) { lines.push([{ t: " " }, ...ln]); mark(realIdx); }
1008
- mark(realIdx, bi);
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 = buildJobsPopup(this); this.redraw(); }
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 (only when present) ──
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
- for (const j of this.jobs.slice(0, 4)) {
2478
- const icon = j.status === "running" ? "⚙" : j.status === "completed" ? "✓" : j.status === "failed" ? "✗" : "·";
2479
- const fg = j.status === "running" ? T.WARN : j.status === "completed" ? T.OK : j.status === "failed" ? T.ERR : T.DIM;
2480
- row2.left.push({ t: ` ${icon} ${truncate(j.label ?? j.kind, 22)} `, fg, bg: T.STATUSBG });
2481
- }
2482
- if (running.length) row2.right.push({ t: ` ${running.length} 运行中 `, fg: T.WARN, bg: T.STATUSBG });
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;