dsh-neotui 0.0.9 → 0.0.11

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,8 +59,12 @@ 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 等)展开折叠;右键消息 →「展开 / 折叠」折叠单个输出块;正文文本块同样支持点击/右键折叠(▸/▾ + 「…共 N 字」尾注);展开的思考块渲染**全文**,不再截断 |
63
+ | 右键菜单 | 菜单打开后**再次右键**任意位置:立即关闭当前菜单并在新位置打开新菜单(OS 式);左键点击菜单外仍是关闭 |
63
64
  | 对话 ↔ 轨迹转跳 | 对话里右键消息 →「转跳轨迹」定位到对应 step(自动展开+高亮);轨迹里右键 step →「转跳对话」回到该消息 |
65
+ | 步骤快速转跳 | **Ctrl+E**:fzf 式过滤 step 列表(编号/耗时/工具/消息预览),回车定位到该 step 的轨迹窗口 |
66
+ | 轨迹窗口导航 | 转跳后只显示 `step±20` 窗口;**PgUp/PgDn** 上下各加载 10 步,**Home/End** 跳到最早/最新 20 步,`r` 回最新 |
67
+ | 后台任务 | **Ctrl+J** 后台任务面板(Enter/→/l 展开详情,←/h 折叠,q 关闭);状态栏只显示「N 个任务正在后台运行 · M已完成 (Ctrl+J 查看详情)」 |
64
68
  | 右键文件夹 → 新建会话(归属该工作区) | `n` |
65
69
  | 滚轮翻页 / 拖拽 | PgUp / PgDn / gg / G |
66
70
  | 点击展开思考块/工具卡 | — |
@@ -95,14 +99,17 @@ tui/app/ dsh-neotui-app bundle:cordis.patch.yml + tui-startup/tui-r
95
99
  - footer(powerline 风格,2–3 行):第 1 行身份(工作区/会话/运行态/🎯目标/✎计划/模型),第 2 行用量(ctx 条+%、入/出/缓存/共 token、⚙步/回合/首响),第 3 行任务(有 job 时)
96
100
  - 折叠块预览:bash 折叠后显示命令概要(view.title 或 command),思考折叠后显示前 3 行
97
101
  - 会话内搜索:**Ctrl+F** 模糊搜本会话消息/工具名,回车跳转
98
- - 轨迹详情:点击回合行弹窗(工具调用 + 耗时),`/` 过滤回合;**增量加载**(每页 20 回合逐页渲染,最近回合秒开,600 回合封顶,不再阻塞等待全量历史)
99
- - 轨迹 详细/简略:每个 step 右键 →「展开(详细)/ 折叠(简略)」内联展开事件列表(仿对话页);step 左键单击无反应(详情走右键菜单);跳转定位的 step 自动展开 + 高亮闪烁
102
+ - 轨迹详情:点击回合行弹窗(工具调用 + 耗时),`/` 过滤回合;**增量加载**(每页 20 回合逐页渲染,最近回合秒开,历史步骤按需翻页,不再阻塞等待全量历史)
103
+ - 轨迹 详细/简略:每个 step 左键单击(▸/▾)或右键菜单第一项 →「展开(详细)/ 折叠(简略)」内联展开事件列表(仿对话页);跳转定位的 step 自动展开 + 高亮闪烁
104
+ - 轨迹窗口模型:转跳只显示 `step±20`;PgUp/PgDn 按 10 步扩展窗口(按需向后翻页加载),Home 加载到 step 1–20,End 回到最新 20 步,窗口指示行显示「窗口 step A–B · 共 N 步」
100
105
  - 工作区文件搜索:工作区面板内直接打字(`/`)模糊搜文件名,回车预览
101
- - 消息反馈:助手消息右键 👍/👎(`messageFeedback/put` Typert RPC),已评状态回显、可删除
106
+ - 消息反馈:助手消息右键 👍/👎(`messageFeedback/put` Typert RPC,`{request:…}` 载荷),已评状态回显、可删除
102
107
  - 图片画廊:多图时 ←/→ 切换,标题显示 (N/M) + 尺寸
103
108
  - 会话内搜索高亮:Ctrl+F 跳转后匹配词高亮(黄底),Esc 清除
104
109
  - 轨迹逐事件详情:点击回合 → 事件列表 → 选事件看全文
105
- - 用户消息前缀:自己发的消息第一行直接显示内容并带 `用户名 > ` 前缀(默认取系统登录名),环境变量 `DSH_TUI_USER_PREFIX` 可自定义,如 `DSH_TUI_USER_PREFIX=edabchann dsh --profile dsh-neotui`
110
+ - 用户消息前缀:自己发的消息第一行直接显示内容并带 `用户名 > ` 前缀(默认取系统登录名);**设置页新增「TUI 界面」命名空间**,编辑 `userPrefix` Ctrl+S 即时生效(持久化到 `$DSH_HOME/tui-config.json`);环境变量 `DSH_TUI_USER_PREFIX` 亦可覆盖
111
+ - 块计时:流式块实时显示 `已经过 X秒/分/小时`,完成后冻结为 `已完成,耗时 X`(工具失败显示 `失败,耗时 X`);缺失 block-end 也不会出现永远走动的计时器
112
+ - 块编号:think/tool/text 块头部标注所属 `(step N)`,与轨迹页的 step 编号对应,Ctrl+E 可据此快速转跳
106
113
  - 主题持久化:切主题写入 `$DSH_HOME/tui-theme.txt`(或 XDG config),重启保留
107
114
  - 输入模式标识:标签栏右侧 NORMAL/INSERT 指示,Esc 退出输入
108
115
  - 实时流:merge 修复(新回合不再覆盖旧回合)、活跃时 500ms 轮询、流式思考/工具自动展开
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
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
 
@@ -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
- // No onClick: LEFT click on a step does nothing (details live in the
563
- // right-click menu and in the per-step 详细/简略 expansion).
564
- this.view = new ScrollView({ x: this.x, y: this.y, w: this.w, h: this.h, showScrollbar: true });
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
- /** Scroll to a step: auto-expand, highlight, center its line. */
645
- jumpToStep(si) {
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.buildLines(si);
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.jumpToStep(si);
669
- this.app.toast(`已定位到 step ${this.steps[si].step}`);
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
- 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;
755
870
  }
756
- buildLines(aroundSi = null) {
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: "轨迹 — 步骤时间轴(右键:展开/折叠 · 转跳对话 · 查看详情;r 刷新)", fg: K.ACCENT, bold: true }]);
760
- 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 }]);
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: `步骤(已加载 ${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 }]);
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
- // Render a window of 40 steps (newest at the top). A jump to an older
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 === si && Date.now() < this.flashUntil;
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 drag keep working
957
+ // wheel / scrollbar / LEFT-click toggle keep working (view.onClick)
847
958
  if (this.view.onMouse(ev)) return true;
848
- // LEFT click on a step: no reaction — but swallow it so it cannot leak
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) { this.steps = []; this.load(this.sessionId); return true; }
867
- if (ev.name === "pgup") { if (this.view.scrollY === 0 && this.hasMore) { this.loadOlder(); return true; } return this.view.scroll(-this.view.h); }
868
- 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);
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", "任务", () => this.app.showJobs()],
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
- export function buildJobsPopup(app) {
1221
- const jobs = app.jobs ?? [];
1222
- const lines = jobs.length === 0
1223
- ? [[{ t: "(当前没有任务帧)", fg: K.FAINT }]]
1224
- : 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];
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
- 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 }];
1228
- });
1229
- return new Popup({
1230
- x: 6, y: 3, w: Math.min(80, app.screen.w - 12), h: Math.min(jobs.length + 4, 20), title: "任务",
1231
- lines: [[{ t: "" }], ...lines],
1232
- buttons: [{ label: "关闭", action: "close" }],
1233
- onAction: () => app.closeOverlay(),
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
- 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 });
1358
1560
  const pend = this.pendingOps.length ? ` ⚠ ${this.pendingOps.length} 项待保存` : "";
1359
- 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 });
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, buildJobsPopup, buildGoalPopup, SettingsPanel, SubagentPanel,
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
- 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 };
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) 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
+ }
231
231
  break;
232
232
  }
233
233
  case "step/end": {
234
234
  const node = cur();
235
- 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
+ }
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", "step/start", "step/end", "todo/write",
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
- 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);
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) {
@@ -836,7 +850,7 @@ export class ChatView extends Widget {
836
850
  const node = this.nodes[info.nodeIdx];
837
851
  if (node?.kind === "assistant" && info.blockIdx !== null) {
838
852
  const b = node.blocks[info.blockIdx];
839
- if (b && (b.kind === "tool" || b.kind === "reasoning" || b.kind === "other")) {
853
+ if (b && (b.kind === "tool" || b.kind === "reasoning" || b.kind === "other" || b.kind === "text")) {
840
854
  const key = `${info.nodeIdx}:${info.blockIdx}`;
841
855
  if (b.kind === "reasoning") {
842
856
  // clean two-state override: expand ⇄ collapse (never a no-op click)
@@ -877,7 +891,6 @@ export class ChatView extends Widget {
877
891
  if (this.hasMore) { lines.push([{ t: "▲ 更早的记录", fg: K.FAINT }]); mark(-1); }
878
892
  if (skipCount > 0) { lines.push([{ t: `… 更早 ${skipCount} 条记录(PgUp 加载)`, fg: K.FAINT }]); mark(-1); }
879
893
 
880
- const MAX_TEXT = 4000;
881
894
  for (let ni = 0; ni < nodes.length; ni++) {
882
895
  const node = nodes[ni];
883
896
  const realIdx = ni + skipCount;
@@ -891,7 +904,7 @@ export class ChatView extends Widget {
891
904
  return ".";
892
905
  }).join("")
893
906
  : "";
894
- const ckey = `${realIdx}|${w}|${expKey}|${blockKeys}|${this.thinkMode}|${this.bashMode}|${node.streaming ? "s" : "f"}|${themeName()}`;
907
+ const ckey = `${realIdx}|${w}|${expKey}|${blockKeys}|${this.thinkMode}|${this.bashMode}|${node.streaming ? "s" : "f"}|${themeName()}|${node.step ?? "-"}|${userPrefix()}`;
895
908
  // Streaming nodes re-render every frame: their text grows without any
896
909
  // change to the cache key, so caching them freezes the live think/tool/text.
897
910
  const hit = node.streaming ? undefined : this.cache.get(ckey);
@@ -966,19 +979,27 @@ export class ChatView extends Widget {
966
979
  if (blocks.length === 0) { lines.push([{ t: " …", fg: K.FAINT }]); mark(realIdx); break; }
967
980
  for (let bi = 0; bi < blocks.length; bi++) {
968
981
  const b = blocks[bi];
982
+ const stepTag = node.step != null ? ` (step ${node.step})` : "";
969
983
  if (b.kind === "reasoning") {
970
984
  const key = `${realIdx}:${bi}`;
971
985
  const manuallyCollapsed = this.collapsedBlocks.has(key);
972
986
  const manuallyExpanded = this.expanded.has(key);
973
987
  const open = manuallyExpanded || (!manuallyCollapsed && this.thinkMode === "expanded");
974
988
  beginCard("THINKBG");
975
- const thinkMeta = b.streaming
976
- ? ` · ${fmtElapsed(Date.now() - (b.startedAt ?? Date.now()))}`
977
- : `(${b.text?.length ?? 0} 字)`;
989
+ // Live blocks tick (已经过…); finished blocks freeze at their
990
+ // total (已完成,耗时…). No timestamps no bogus timer shown.
991
+ let timing = "";
992
+ if (b.streaming) {
993
+ timing = ` 已经过 ${fmtDuration(Date.now() - (b.startedAt ?? Date.now()))}`;
994
+ } else if (b.startedAt !== undefined && b.endedAt !== undefined) {
995
+ timing = ` 已完成,耗时 ${fmtDuration(b.endedAt - b.startedAt)}`;
996
+ }
997
+ const thinkMeta = `${stepTag}(${b.text?.length ?? 0} 字)${timing}`;
978
998
  lines.push([{ t: "💭 思考" + (b.streaming ? "…" : "") + thinkMeta + (open ? " [t 折叠]" : " [t 展开]"), fg: K.FAINT }]);
979
999
  mark(realIdx, bi);
980
1000
  if (open) {
981
- for (const ln of renderMd(truncateText(b.text, MAX_TEXT), w - 4)) { lines.push([{ t: " " }, ...ln]); mark(realIdx, bi); }
1001
+ // Expanded = the WHOLE reasoning (no hidden remainder).
1002
+ for (const ln of renderMd(b.text ?? "", w - 4)) { lines.push([{ t: " " }, ...ln]); mark(realIdx, bi); }
982
1003
  } else {
983
1004
  // collapsed: three-line preview
984
1005
  for (const ln of renderMd(truncateText(b.text, 400), w - 4).slice(0, 3)) {
@@ -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,34 @@ 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");
1062
+ // Text blocks collapse per-block like tool/reasoning blocks:
1063
+ // collapsed shows a 3-line preview + trailer, expanded the whole
1064
+ // text. Clicking (or the right-click 展开/折叠) toggles it.
1065
+ const key = `${realIdx}:${bi}`;
1066
+ const open = !this.collapsedBlocks.has(key);
1033
1067
  const text = b.text ?? "";
1034
- for (const ln of renderMd(text, w - 4)) { lines.push([{ t: " " }, ...ln]); mark(realIdx); }
1035
- mark(realIdx, bi);
1068
+ const glyph = open ? "▾" : "";
1069
+ const mdW = Math.max(10, w - 6 - strWidth(stepTag));
1070
+ const md = open
1071
+ ? renderMd(text, mdW)
1072
+ : renderMd(truncateText(text, 600), mdW).slice(0, 3);
1073
+ if (md.length > 0) {
1074
+ lines.push([{ t: " " }, { t: glyph + " ", fg: K.FAINT }, { t: stepTag, fg: K.FAINT }, ...md[0]]);
1075
+ mark(realIdx, bi);
1076
+ for (const ln of md.slice(1)) { lines.push([{ t: " " }, ...ln]); mark(realIdx, bi); }
1077
+ } else {
1078
+ lines.push([{ t: " " + glyph + " " + stepTag, fg: K.FAINT }]);
1079
+ mark(realIdx, bi);
1080
+ }
1081
+ if (!open && text.length > 0) {
1082
+ lines.push([{ t: ` …共 ${text.length} 字(点击展开)`, fg: K.FAINT }]);
1083
+ mark(realIdx, bi);
1084
+ }
1036
1085
  sep();
1037
1086
  }
1038
1087
  }
@@ -1214,6 +1263,7 @@ export class ChatView extends Widget {
1214
1263
  return true;
1215
1264
  }
1216
1265
  if (ev.kind === "press" && ev.button === 2) {
1266
+ this.flushRebuild(); // keep lineMap current with the nodes array
1217
1267
  const y = ev.y - this.view.y + this.view.scrollY;
1218
1268
  const info = this.lineMap?.[y];
1219
1269
  if (info) {
@@ -1257,13 +1307,13 @@ export class ChatView extends Widget {
1257
1307
  case "pgup": if (this.view.scrollY === 0) { this.loadOlder(); return true; } return this.view.scroll(-this.view.h);
1258
1308
  case "pgdn": return this.view.scroll(this.view.h);
1259
1309
  }
1260
- if (ev.name === "char" && ev.key === "g" && !ev.ctrl) {
1310
+ if (ev.name === "char" && ev.key === "g" && !ev.ctrl && !ev.shift) {
1261
1311
  if (this.gKey) { this.gKey = false; this.view.scrollY = 0; return true; }
1262
1312
  this.gKey = true;
1263
1313
  this.app.toast("再按 g 回顶");
1264
1314
  return true;
1265
1315
  }
1266
- if (ev.name === "char" && ev.key === "G") { this.view.scrollY = this.view.maxScroll(); return true; }
1316
+ if (ev.name === "char" && ev.key === "g" && ev.shift) { this.view.scrollY = this.view.maxScroll(); return true; }
1267
1317
  if (ev.name === "escape" && this.app.searchQuery) { this.app.searchQuery = null; this.queueRebuild(); return true; }
1268
1318
  if (ev.name === "escape" && this.selStart !== null) { this.selStart = this.selEnd = null; this.app.redraw(); return true; }
1269
1319
  if (ev.name === "char" && ev.key === "i" && !ev.ctrl) { this.app.focus(this.input); return true; }
@@ -1317,13 +1367,6 @@ function toolSummary(b) {
1317
1367
  return null;
1318
1368
  }
1319
1369
 
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
1370
  function fmtTokens(n) {
1328
1371
  if (n == null || isNaN(n)) return "0";
1329
1372
  if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
@@ -1857,18 +1900,26 @@ export class App {
1857
1900
  async loadFeedback() {
1858
1901
  if (!this.currentSession) return;
1859
1902
  try {
1860
- const res = await this.api.rpcCall("messageFeedback/list", { sessionId: this.currentSession });
1903
+ const res = await this.api.rpcCall("messageFeedback/list", { request: { sessionId: this.currentSession } });
1861
1904
  this.feedbackMap = new Map();
1862
- for (const item of res.items ?? []) this.feedbackMap.set(item.messageId, item);
1905
+ for (const item of res?.value?.items ?? res?.items ?? []) this.feedbackMap.set(item.messageId, item);
1863
1906
  } catch { this.feedbackMap = new Map(); }
1864
1907
  }
1865
1908
 
1866
1909
  async feedback(messageId, rating) {
1867
1910
  const existing = this.feedbackMap?.get(messageId);
1868
1911
  try {
1869
- const item = await this.api.rpcCall("messageFeedback/put", {
1870
- sessionId: this.currentSession, messageId, rating, ifVersion: existing?.version ?? null,
1912
+ const res = await this.api.rpcCall("messageFeedback/put", {
1913
+ request: {
1914
+ sessionId: this.currentSession, messageId, rating,
1915
+ ifVersion: existing?.version ?? null,
1916
+ },
1871
1917
  });
1918
+ if (res?.ok === false) {
1919
+ this.toast(`反馈失败: ${res.error?.code ?? res.error?.message ?? "unknown"}`);
1920
+ return;
1921
+ }
1922
+ const item = res?.value ?? res;
1872
1923
  this.feedbackMap = this.feedbackMap ?? new Map();
1873
1924
  this.feedbackMap.set(messageId, item);
1874
1925
  this.toast(rating === "positive" ? "已记录 👍" : "已记录 👎");
@@ -1879,7 +1930,13 @@ export class App {
1879
1930
  const existing = this.feedbackMap?.get(messageId);
1880
1931
  if (!existing) return;
1881
1932
  try {
1882
- await this.api.rpcCall("messageFeedback/delete", { sessionId: this.currentSession, messageId, ifVersion: existing.version });
1933
+ const res = await this.api.rpcCall("messageFeedback/delete", {
1934
+ request: { sessionId: this.currentSession, messageId, ifVersion: existing.version },
1935
+ });
1936
+ if (res?.ok === false) {
1937
+ this.toast(`删除反馈失败: ${res.error?.code ?? res.error?.message ?? "unknown"}`);
1938
+ return;
1939
+ }
1883
1940
  this.feedbackMap.delete(messageId);
1884
1941
  this.toast("已删除反馈");
1885
1942
  } catch (e) { this.toast(`删除反馈失败: ${e.message}`); }
@@ -2054,11 +2111,51 @@ export class App {
2054
2111
  else this.toast("先打开一个会话");
2055
2112
  }
2056
2113
 
2057
- showJobs() { this.overlay = buildJobsPopup(this); this.redraw(); }
2114
+ showJobs() { this.overlay = new JobsPanel(this); this.redraw(); }
2058
2115
  showGoal() { this.overlay = buildGoalPopup(this); this.redraw(); }
2059
2116
  showModePicker() { this.overlay = buildModePicker(this); this.redraw(); }
2060
2117
  showPermissionPicker() { this.overlay = buildPermissionPicker(this); this.redraw(); }
2061
2118
 
2119
+ /** Ctrl+E: fzf-style quick jump to a step — type to fuzzy-filter the step
2120
+ * list, Enter opens the trajectory window around the picked step. */
2121
+ async quickJumpStep() {
2122
+ if (!this.currentSession) { this.toast("先打开一个会话"); return; }
2123
+ if (!this.trajectoryPanel) this.trajectoryPanel = new TrajectoryPanel(this);
2124
+ const tp = this.trajectoryPanel;
2125
+ if (tp.sessionId !== this.currentSession || tp.steps.length === 0) {
2126
+ this.setStatus("加载步骤列表…");
2127
+ await tp.load(this.currentSession);
2128
+ this.setStatus("");
2129
+ }
2130
+ const total = tp.stats?.steps ?? (tp.steps[tp.steps.length - 1]?.step ?? tp.steps.length);
2131
+ const items = [...tp.steps].reverse().map((st) => {
2132
+ const si = tp.steps.indexOf(st);
2133
+ const tools = [...new Set(st.events.filter((e) => e.type === "tool/call").map((e) => e.data?.name))];
2134
+ const t0 = st.events[0]?.time, t1 = st.events[st.events.length - 1]?.time;
2135
+ const dur = t0 && t1 ? fmtMs(t1 - t0) : "—";
2136
+ const userMsg = st.events.find((e) => e.type === "user/message")?.data?.content?.[0]?.text ?? "";
2137
+ return {
2138
+ label: `step ${st.step} ${dur} ${tools.slice(0, 3).join(",") || "纯文本"} ${truncate(String(userMsg), 24)}`,
2139
+ hint: `${st.events.length} 事件`,
2140
+ keywords: `step ${st.step} ${tools.join(" ")} ${userMsg}`,
2141
+ stepIdx: si,
2142
+ };
2143
+ });
2144
+ const w = Math.min(76, this.screen.w - 8), h = Math.min(20, this.screen.h - 4);
2145
+ this.overlay = new Picker({
2146
+ x: Math.floor((this.screen.w - w) / 2), y: Math.floor((this.screen.h - h) / 2),
2147
+ w, h, title: `步骤转跳(step 1–${total} · Ctrl+E)— 输入过滤,回车定位`,
2148
+ items,
2149
+ onCancel: () => this.closeOverlay(),
2150
+ onPick: (it) => {
2151
+ this.closeOverlay();
2152
+ this.setMode("trajectory");
2153
+ this.trajectoryPanel.jumpToStep(it.stepIdx);
2154
+ },
2155
+ });
2156
+ this.redraw();
2157
+ }
2158
+
2062
2159
  /** Select one of the four agent presets (modes). */
2063
2160
  async selectPreset(id) {
2064
2161
  if (!this.currentSession) { this.toast("先打开一个会话"); return; }
@@ -2216,16 +2313,29 @@ export class App {
2216
2313
  }
2217
2314
  if (this.menu) {
2218
2315
  const before = this.menu;
2219
- if (ev.type === "key") this.menu.onKey(ev);
2220
- else if (ev.type === "mouse") {
2221
- if (ev.kind === "press" && ev.button === 0 && !this.menu.inside(ev.x, ev.y)) { this.menu = null; this.swallowRelease = true; }
2222
- else {
2316
+ if (ev.type === "key") { this.menu.onKey(ev); this.redraw(); return; }
2317
+ if (ev.type === "mouse") {
2318
+ if (ev.kind === "press" && ev.button === 0 && !this.menu.inside(ev.x, ev.y)) {
2319
+ this.menu = null; this.swallowRelease = true; this.redraw(); return;
2320
+ }
2321
+ if (ev.kind === "press" && ev.button === 2) {
2322
+ if (this.menu.inside(ev.x, ev.y)) { this.redraw(); return; } // right-click on the menu itself: keep it open
2323
+ // OS-style: a right-click anywhere else closes the current menu and
2324
+ // the SAME press falls through to open a fresh menu at the new spot.
2325
+ this.menu = null;
2326
+ this.swallowRelease = true;
2327
+ this.redraw();
2328
+ // fall through to the normal mouse routing below
2329
+ } else {
2223
2330
  this.menu.onMouse(ev);
2224
2331
  if (ev.kind === "press" && this.menu !== before) this.swallowRelease = true;
2332
+ this.redraw();
2333
+ return;
2225
2334
  }
2335
+ } else {
2336
+ this.redraw();
2337
+ return;
2226
2338
  }
2227
- this.redraw();
2228
- return;
2229
2339
  }
2230
2340
  if (this.overlay) {
2231
2341
  const before = this.overlay;
@@ -2338,6 +2448,7 @@ export class App {
2338
2448
  this.setMode("workspace"); return;
2339
2449
  }
2340
2450
  if (ev.ctrl && ev.key === "t") { this.setMode("trajectory"); return; }
2451
+ if (ev.ctrl && ev.key === "e") { this.quickJumpStep(); return; }
2341
2452
  if (ev.ctrl && ev.key === "j") { this.showJobs(); return; }
2342
2453
  if (ev.ctrl && ev.key === "g") { this.showGoal(); return; }
2343
2454
  if (ev.ctrl && ev.key === "f") { this.findInConversation(); return; }
@@ -2368,7 +2479,14 @@ export class App {
2368
2479
  return;
2369
2480
  }
2370
2481
  if (ev.text.length === 1) {
2371
- const asKey = { type: "key", name: "char", key: ev.text, text: ev.text, ctrl: false, alt: false, shift: false };
2482
+ // Legacy terminals deliver Shift+letter as the UPPERCASE char with no
2483
+ // modifier info — recover the shift flag from the case so Shift+T
2484
+ // (todo fold) and G (scroll bottom) work everywhere.
2485
+ const asKey = {
2486
+ type: "key", name: "char",
2487
+ key: ev.text.toLowerCase(), text: ev.text,
2488
+ ctrl: false, alt: false, shift: ev.text !== ev.text.toLowerCase(),
2489
+ };
2372
2490
  if (this.chat.onKey(asKey)) { this.redraw(); return; }
2373
2491
  if (this.focused && this.focused !== this.chat.input && this.focused.onKey(asKey)) { this.redraw(); return; }
2374
2492
  this.toast("按 i 进入输入");
@@ -2532,16 +2650,18 @@ export class App {
2532
2650
  if (stats.ttftMs) row1.right.push({ t: ` 首响${Math.round(stats.ttftMs / stats.ttftSteps)}ms `, fg: T.FAINT, bg: T.STATUSBG });
2533
2651
  }
2534
2652
  rows.push(row1);
2535
- // ── row 2: jobs (only when present) ──
2653
+ // ── row 2: background jobs one summary line, never per-job noise ──
2536
2654
  if (this.jobs?.length) {
2537
- const running = this.jobs.filter((j) => j.status === "running");
2655
+ const running = this.jobs.filter((j) => j.status === "running").length;
2656
+ const done = this.jobs.filter((j) => j.status === "completed").length;
2657
+ const failed = this.jobs.filter((j) => j.status === "failed").length;
2538
2658
  const row2 = { left: [], right: [] };
2539
- for (const j of this.jobs.slice(0, 4)) {
2540
- const icon = j.status === "running" ? "⚙" : j.status === "completed" ? "✓" : j.status === "failed" ? "✗" : "·";
2541
- const fg = j.status === "running" ? T.WARN : j.status === "completed" ? T.OK : j.status === "failed" ? T.ERR : T.DIM;
2542
- row2.left.push({ t: ` ${icon} ${truncate(j.label ?? j.kind, 22)} `, fg, bg: T.STATUSBG });
2543
- }
2544
- if (running.length) row2.right.push({ t: ` ${running.length} 运行中 `, fg: T.WARN, bg: T.STATUSBG });
2659
+ row2.left.push({
2660
+ t: ` ${running > 0 ? `${running} 个任务正在后台运行` : "没有任务正在后台运行"} `,
2661
+ fg: running > 0 ? T.WARN : T.FAINT, bg: T.STATUSBG,
2662
+ });
2663
+ row2.left.push({ t: ` ${done}已完成${failed > 0 ? ` ${failed}失败` : ""} `, fg: done > 0 ? T.OK : T.FAINT, bg: T.STATUSBG });
2664
+ row2.right.push({ t: " Ctrl+J 查看详情 ", fg: T.DIM, bg: T.STATUSBG });
2545
2665
  rows.push(row2);
2546
2666
  }
2547
2667
  this.status.rows = rows;