dsh-neotui 0.0.7 → 0.0.9

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,8 @@ 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 →「转跳对话」回到该消息 |
63
64
  | 右键文件夹 → 新建会话(归属该工作区) | `n` |
64
65
  | 滚轮翻页 / 拖拽 | PgUp / PgDn / gg / G |
65
66
  | 点击展开思考块/工具卡 | — |
@@ -95,11 +96,13 @@ tui/app/ dsh-neotui-app bundle:cordis.patch.yml + tui-startup/tui-r
95
96
  - 折叠块预览:bash 折叠后显示命令概要(view.title 或 command),思考折叠后显示前 3 行
96
97
  - 会话内搜索:**Ctrl+F** 模糊搜本会话消息/工具名,回车跳转
97
98
  - 轨迹详情:点击回合行弹窗(工具调用 + 耗时),`/` 过滤回合;**增量加载**(每页 20 回合逐页渲染,最近回合秒开,600 回合封顶,不再阻塞等待全量历史)
99
+ - 轨迹 详细/简略:每个 step 右键 →「展开(详细)/ 折叠(简略)」内联展开事件列表(仿对话页);step 左键单击无反应(详情走右键菜单);跳转定位的 step 自动展开 + 高亮闪烁
98
100
  - 工作区文件搜索:工作区面板内直接打字(`/`)模糊搜文件名,回车预览
99
101
  - 消息反馈:助手消息右键 👍/👎(`messageFeedback/put` Typert RPC),已评状态回显、可删除
100
102
  - 图片画廊:多图时 ←/→ 切换,标题显示 (N/M) + 尺寸
101
103
  - 会话内搜索高亮:Ctrl+F 跳转后匹配词高亮(黄底),Esc 清除
102
104
  - 轨迹逐事件详情:点击回合 → 事件列表 → 选事件看全文
105
+ - 用户消息前缀:自己发的消息第一行直接显示内容并带 `用户名 > ` 前缀(默认取系统登录名),环境变量 `DSH_TUI_USER_PREFIX` 可自定义,如 `DSH_TUI_USER_PREFIX=edabchann dsh --profile dsh-neotui`
103
106
  - 主题持久化:切主题写入 `$DSH_HOME/tui-theme.txt`(或 XDG config),重启保留
104
107
  - 输入模式标识:标签栏右侧 NORMAL/INSERT 指示,Esc 退出输入
105
108
  - 实时流:merge 修复(新回合不再覆盖旧回合)、活跃时 500ms 轮询、流式思考/工具自动展开
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
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/panels.js CHANGED
@@ -222,6 +222,7 @@ export function buildCommandPalette(app) {
222
222
  const w = Math.min(70, app.screen.w - 4), h = Math.min(26, app.screen.h - 4);
223
223
  const items = [
224
224
  { label: "新建会话", hint: "n", action: () => app.newSession(), keywords: "new session create" },
225
+ { label: "新建工作区…", action: () => app.addWorkspace(), keywords: "new workspace create directory" },
225
226
  { label: "打开会话…", hint: "o", action: () => app.openSessionPicker(), keywords: "open session" },
226
227
  { label: "搜索会话", hint: "/", action: () => app.startSearch(), keywords: "search find" },
227
228
  { label: "重命名当前会话", action: () => app.renameCurrent(), keywords: "rename title" },
@@ -553,30 +554,37 @@ export class TrajectoryPanel extends Widget {
553
554
  this.minSeq = null;
554
555
  this.allEvents = [];
555
556
  this.sessionId = null;
556
- this.view = new ScrollView({ x: this.x, y: this.y, w: this.w, h: this.h, showScrollbar: true, onClick: (y) => this.#clickLine(y) });
557
+ this.expandedSteps = new Set(); // step identity keys rendered 详细 (expanded)
558
+ this.flashStep = null; // step just jumped to (brief highlight)
559
+ this.flashUntil = 0;
560
+ this.loadPromise = null; // dedupes concurrent load(currentSession)
561
+ 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 });
557
565
  this.stepLines = [];
558
566
  this.query = "";
559
567
  }
560
- #clickLine(lineIdx) {
561
- if (this.hasMore && lineIdx === 1) { this.loadOlder(); return true; }
562
- const si = this.stepLines[lineIdx];
563
- if (si === undefined) return false;
568
+
569
+ #eventSummary(e) {
570
+ const d = e.data ?? {};
571
+ if (e.type === "user/message") return `❯ ${String(d.content?.[0]?.text ?? "").slice(0, 40)}`;
572
+ if (e.type === "tool/call") return `⚙ ${d.name ?? "tool"} ${String(d.arguments ?? "").slice(0, 30)}`;
573
+ if (e.type === "tool/result") return "↳ 结果";
574
+ if (e.type === "assistant/message") return `◉ ${String(d.message?.content?.find((c) => c.type === "text")?.text ?? "").slice(0, 40)}`;
575
+ if (e.type === "assistant/chunk") {
576
+ const ch = d.chunk ?? {};
577
+ return ch.type === "text-delta" ? String(ch.delta ?? "").slice(0, 40) : `[${ch.blockType ?? ch.type}]`;
578
+ }
579
+ return e.type;
580
+ }
581
+
582
+ /** Right-click menu entry: the old event-detail picker for one step. */
583
+ #showDetail(si) {
564
584
  const step = this.steps[si];
565
- if (!step) return false;
585
+ if (!step) return;
566
586
  const t0 = step.events[0]?.time, t1 = step.events[step.events.length - 1]?.time;
567
- const items = step.events.map((e, i) => {
568
- const d = e.data ?? {};
569
- let summary = e.type;
570
- if (e.type === "user/message") summary = `❯ ${String(d.content?.[0]?.text ?? "").slice(0, 40)}`;
571
- else if (e.type === "tool/call") summary = `⚙ ${d.name ?? "tool"} ${String(d.arguments ?? "").slice(0, 30)}`;
572
- else if (e.type === "tool/result") summary = "↳ 结果";
573
- else if (e.type === "assistant/message") summary = `◉ ${String(d.message?.content?.find((c) => c.type === "text")?.text ?? "").slice(0, 40)}`;
574
- else if (e.type === "assistant/chunk") {
575
- const ch = d.chunk ?? {};
576
- summary = ch.type === "text-delta" ? String(ch.delta ?? "").slice(0, 40) : `[${ch.blockType ?? ch.type}]`;
577
- }
578
- return { label: truncate(summary, 60), hint: `#${e.seq}`, idx: i, event: e };
579
- });
587
+ const items = step.events.map((e, i) => ({ label: truncate(this.#eventSummary(e), 60), hint: `#${e.seq}`, idx: i, event: e }));
580
588
  if (t0 && t1) items.unshift({ label: `⏱ 步骤 ${step.step} · ${step.events.length} 事件 · ${fmtMs(t1 - t0)}`, hint: "", idx: -1, event: null });
581
589
  const w = Math.min(78, this.app.screen.w - 8), h = Math.min(22, this.app.screen.h - 4);
582
590
  this.app.overlay = new Picker({
@@ -593,7 +601,6 @@ export class TrajectoryPanel extends Widget {
593
601
  else if (e.type === "assistant/message") body = String(d.message?.content?.map((c) => c.text ?? "").join("\n") ?? "");
594
602
  else body = JSON.stringify(d, null, 2);
595
603
  const lines = body.split("\n").slice(0, 40).map((l) => [{ t: truncate(l, w - 6), fg: K.TXT }]);
596
- // centered detail popup (never the stale top-left corner)
597
604
  const pw = Math.min(80, this.app.screen.w - 4);
598
605
  const ph = Math.min(lines.length + 5, 24);
599
606
  this.app.overlay = new Popup({
@@ -606,7 +613,64 @@ export class TrajectoryPanel extends Widget {
606
613
  },
607
614
  });
608
615
  this.app.redraw();
609
- return true;
616
+ }
617
+
618
+ #toggleStep(si) {
619
+ const step = this.steps[si];
620
+ if (!step) return;
621
+ const key = this.stepKey(step);
622
+ if (this.expandedSteps.has(key)) this.expandedSteps.delete(key);
623
+ else this.expandedSteps.add(key);
624
+ this.buildLines();
625
+ this.app.redraw();
626
+ }
627
+
628
+ /** Stable identity of a step across loadOlder re-segmentation: the seq of
629
+ * its first event (step indexes shift when older steps are prepended). */
630
+ stepKey(step) { return step.events[0]?.seq ?? `step-${step.step}`; }
631
+
632
+ /** Step index whose events carry the given message id (-1 when absent). */
633
+ indexOfMessage(messageId) {
634
+ if (!messageId) return -1;
635
+ for (let si = this.steps.length - 1; si >= 0; si--) {
636
+ if (this.steps[si].events.some((e) => {
637
+ const d = e.data ?? {};
638
+ return (d.id ?? d.message?.id) === messageId;
639
+ })) return si;
640
+ }
641
+ return -1;
642
+ }
643
+
644
+ /** Scroll to a step: auto-expand, highlight, center its line. */
645
+ jumpToStep(si) {
646
+ if (si < 0 || si >= this.steps.length) return;
647
+ 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;
652
+ this.flashUntil = Date.now() + 3000;
653
+ this.app.redraw();
654
+ }
655
+
656
+ /** Chat → trajectory jump target: load the current session's steps (if not
657
+ * already), page back until the message's step is loaded, then jump. */
658
+ async focusMessage(messageId) {
659
+ if (this.sessionId !== this.app.currentSession || this.steps.length === 0) {
660
+ await this.load(this.app.currentSession);
661
+ }
662
+ let si = this.indexOfMessage(messageId);
663
+ for (let i = 0; si < 0 && this.hasMore && i < 10; i++) {
664
+ await this.loadOlder();
665
+ si = this.indexOfMessage(messageId);
666
+ }
667
+ if (si >= 0) {
668
+ this.jumpToStep(si);
669
+ this.app.toast(`已定位到 step ${this.steps[si].step}`);
670
+ } else if (this.steps.length) {
671
+ this.jumpToStep(this.steps.length - 1);
672
+ this.app.toast(messageId ? "对应步骤不在已加载窗口" : "消息未关联步骤,已到最新步骤");
673
+ }
610
674
  }
611
675
  relayout(x, y, w, h) {
612
676
  this.x = x; this.y = y; this.w = w; this.h = h;
@@ -623,6 +687,14 @@ export class TrajectoryPanel extends Widget {
623
687
  this.app.redraw();
624
688
  return;
625
689
  }
690
+ // Dedupe concurrent loads of the same session (setMode + focusMessage).
691
+ if (this.loadPromise && this.loadTarget === sessionId) return this.loadPromise;
692
+ this.loadTarget = sessionId;
693
+ this.loadPromise = this.#doLoad(sessionId);
694
+ try { await this.loadPromise; }
695
+ finally { this.loadPromise = null; this.loadTarget = null; }
696
+ }
697
+ async #doLoad(sessionId) {
626
698
  this.sessionId = sessionId;
627
699
  this.loading = true;
628
700
  this.steps = [];
@@ -681,10 +753,10 @@ export class TrajectoryPanel extends Widget {
681
753
  if (cur && cur.events.length) steps.push(cur);
682
754
  this.steps = steps.slice(-600);
683
755
  }
684
- buildLines() {
756
+ buildLines(aroundSi = null) {
685
757
  const w = Math.max(40, this.w - 2);
686
758
  const lines = [];
687
- lines.push([{ t: "轨迹 — 步骤时间轴(每行一个色块,点击查看详情 · r 刷新)", fg: K.ACCENT, bold: true }]);
759
+ lines.push([{ t: "轨迹 — 步骤时间轴(右键:展开/折叠 · 转跳对话 · 查看详情;r 刷新)", fg: K.ACCENT, bold: true }]);
688
760
  if (this.hasMore) lines.push([{ t: "▲ 更早步骤(点击 / PgUp 加载)", fg: K.FAINT }]);
689
761
  else lines.push([{ t: "" }]);
690
762
  const st = this.stats;
@@ -700,7 +772,14 @@ export class TrajectoryPanel extends Widget {
700
772
  return hay.includes(this.query.toLowerCase());
701
773
  }))
702
774
  : this.steps;
703
- for (const step of list.slice(-40).reverse()) {
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()) {
704
783
  const si = this.steps.indexOf(step);
705
784
  const tools = [...new Set(step.events.filter((e) => e.type === "tool/call").map((e) => e.data?.name))];
706
785
  const hasResult = step.events.some((e) => e.type === "tool/result");
@@ -709,12 +788,27 @@ export class TrajectoryPanel extends Widget {
709
788
  const dur = t0 && t1 ? fmtMs(t1 - t0) : "—";
710
789
  const bg = tools.length ? (hasResult ? T.TOOLOK : T.TOOLBG) : hasReasoning ? T.THINKBG : T.CARD;
711
790
  const summary = tools.slice(0, 3).join(",") || (hasReasoning ? "模型推理" : "纯文本");
712
- const label = ` step ${String(step.step).padStart(3)} ${pad(dur, 8)} ${summary}`;
713
- const segs = [{ t: label, fg: K.TXT, bg, bold: true }];
791
+ const open = this.expandedSteps.has(this.stepKey(step)); // 详细
792
+ const flash = this.flashStep === si && Date.now() < this.flashUntil;
793
+ const rowBg = flash ? T.ACCENT : bg;
794
+ const label = `${open ? "▾" : "▸"} step ${String(step.step).padStart(3)} ${pad(dur, 8)} ${summary} ${open ? "[折叠]" : "[展开]"}`;
795
+ const segs = [{ t: label, fg: flash ? T.SELFG : K.TXT, bg: rowBg, bold: true }];
714
796
  const fill = w - strWidth(label);
715
- if (fill > 0) segs.push({ t: " ".repeat(fill), bg });
797
+ if (fill > 0) segs.push({ t: " ".repeat(fill), bg: rowBg });
716
798
  lines.push(segs);
717
799
  this.stepLines[lines.length - 1] = si;
800
+ if (open) {
801
+ // 详细 mode: the step's events inline under its color block.
802
+ const evs = step.events.slice(0, 12);
803
+ for (const e of evs) {
804
+ lines.push([{ t: ` #${String(e.seq).padStart(4)} ${truncate(this.#eventSummary(e), w - 12)}`, fg: K.DIM, bg }]);
805
+ this.stepLines[lines.length - 1] = si;
806
+ }
807
+ if (step.events.length > evs.length) {
808
+ lines.push([{ t: ` …共 ${step.events.length} 个事件(右键 → 查看详情)`, fg: K.FAINT, bg }]);
809
+ this.stepLines[lines.length - 1] = si;
810
+ }
811
+ }
718
812
  }
719
813
  this.view.setLines(lines);
720
814
  }
@@ -726,7 +820,40 @@ export class TrajectoryPanel extends Widget {
726
820
  }
727
821
  this.view.render(screen);
728
822
  }
729
- onMouse(ev) { return this.view.onMouse(ev); }
823
+ onMouse(ev) {
824
+ // RIGHT click on a step: context menu (expand/collapse · jump · detail).
825
+ if (ev.kind === "press" && ev.button === 2) {
826
+ const y = ev.y - this.view.y + this.view.scrollY;
827
+ const si = this.stepLines[y];
828
+ const step = si !== undefined ? this.steps[si] : null;
829
+ if (step) {
830
+ const open = this.expandedSteps.has(this.stepKey(step));
831
+ this.app.openMenu([
832
+ { label: open ? "折叠(简略)" : "展开(详细)", action: () => this.#toggleStep(si) },
833
+ { label: "转跳对话", action: () => this.app.jumpToChatStep(si) },
834
+ { label: "查看详情", action: () => this.#showDetail(si) },
835
+ ], ev);
836
+ return true;
837
+ }
838
+ if (this.hasMore && y === 1) {
839
+ this.app.openMenu([{ label: "加载更早步骤", action: () => this.loadOlder() }], ev);
840
+ return true;
841
+ }
842
+ // swallow right-clicks on non-step rows (header/stats) so they cannot
843
+ // leak into the chat view's context menu underneath.
844
+ return this.view.inside(ev.x, ev.y);
845
+ }
846
+ // wheel / scrollbar drag keep working
847
+ 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
+ }
855
+ return false;
856
+ }
730
857
  onKey(ev) {
731
858
  if (ev.type === "text") { this.query += ev.text; this.buildLines(); this.app.redraw(); return true; }
732
859
  if (ev.type !== "key") return false;
package/src/views.js CHANGED
@@ -3,6 +3,7 @@ import { Screen } from "./screen.js";
3
3
  import { renderMd, C } from "./md.js";
4
4
  import { truncate, strWidth, bars } from "./text.js";
5
5
  import { readFileSync } from "node:fs";
6
+ import { userInfo } from "node:os";
6
7
  import { Widget, List, ScrollView, Input, Popup, Menu, StatusBar } from "./widgets.js";
7
8
  import {
8
9
  Picker, buildCommandPalette, buildModelPicker, buildModePicker, buildPermissionPicker,
@@ -15,6 +16,21 @@ import { T, themeName } from "./theme.js";
15
16
  // Live theme accessor: K.K.DIM etc. resolve against the active palette at render time.
16
17
  const K = new Proxy({}, { get(_k, key) { return T[key]; } });
17
18
 
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
+
18
34
  // ---- Tool card renderers (host-computed view models) ----
19
35
 
20
36
  function renderToolCard(view, width, expanded) {
@@ -460,11 +476,19 @@ class SidebarTree extends Widget {
460
476
  if (ev.kind === "press" && ev.button === 2) {
461
477
  const idx = this.scrollY + (ev.y - this.y - 2);
462
478
  const row = this.rows[idx];
463
- if (!row) return false;
479
+ if (!row) {
480
+ // right-click on empty sidebar space → workspace-level actions
481
+ this.app.openMenu([
482
+ { label: "新建工作区…", action: () => this.app.addWorkspace() },
483
+ { label: "新建会话", action: () => this.app.newSessionIn(null) },
484
+ ], ev);
485
+ return true;
486
+ }
464
487
  this.sel = idx;
465
488
  if (row.kind === "group") {
466
489
  const items = [
467
490
  { label: "新建会话", action: () => this.app.newSessionIn(row.group) },
491
+ { label: "新建工作区…", action: () => this.app.addWorkspace() },
468
492
  { label: "折叠全部", action: () => { this.collapseAll(); this.app.redraw(); } },
469
493
  { label: "展开全部", action: () => { this.expandAll(); this.app.redraw(); } },
470
494
  ];
@@ -658,14 +682,15 @@ export class ChatView extends Widget {
658
682
  }
659
683
 
660
684
  jumpToNode(idx) {
661
- if (idx < 0 || idx >= this.nodes.length) return;
685
+ if (idx < 0 || idx >= this.nodes.length) return false;
662
686
  for (let li = 0; li < this.lineMap.length; li++) {
663
687
  if (this.lineMap[li]?.nodeIdx === idx) {
664
688
  this.view.scrollY = Math.max(0, li - 2);
665
689
  this.app.redraw();
666
- return;
690
+ return true;
667
691
  }
668
692
  }
693
+ return false; // node exists but is outside the rendered tail window
669
694
  }
670
695
 
671
696
  queueRebuild() { this.rebuildQueued = true; }
@@ -845,7 +870,7 @@ export class ChatView extends Widget {
845
870
  const markImg = (nodeIdx, imgIdx) => lineMap.push({ nodeIdx, imgIdx });
846
871
  // render only the tail of very long sessions; earlier nodes load via pagination
847
872
  const MAX_NODES = 150;
848
- const skipCount = this.nodes.length - MAX_NODES;
873
+ const skipCount = Math.max(0, this.nodes.length - MAX_NODES);
849
874
  const nodes = skipCount > 0 ? this.nodes.slice(skipCount) : this.nodes;
850
875
  lines.push([{ t: truncate(this.title || this.sessionId?.slice(0, 8) || "", w - 2), fg: K.DIM }]);
851
876
  mark(-1);
@@ -908,9 +933,19 @@ export class ChatView extends Widget {
908
933
  const text = node.text ?? "";
909
934
  const shown = isExp ? text : text.slice(0, 2000);
910
935
  beginCard("USERBG");
911
- lines.push([{ t: " ", fg: K.OK }]);
912
- mark(realIdx);
913
- for (const ln of renderMd(shown, w - 4)) { lines.push([{ t: " " }, ...ln]); mark(realIdx); }
936
+ // First line carries the customizable "name > " marker and the
937
+ // message text starts on that same line (no blank first row).
938
+ const prefix = userPrefix();
939
+ const pw = strWidth(prefix);
940
+ const md = renderMd(shown, Math.max(10, w - 4 - pw));
941
+ if (md.length === 0) {
942
+ lines.push([{ t: " " + prefix, fg: K.OK, bold: true }]);
943
+ mark(realIdx);
944
+ } else {
945
+ lines.push([{ t: " " + prefix, fg: K.OK, bold: true }, ...md[0]]);
946
+ mark(realIdx);
947
+ for (const ln of md.slice(1)) { lines.push([{ t: " " + " ".repeat(pw) }, ...ln]); mark(realIdx); }
948
+ }
914
949
  if (!isExp && text.length > 2000) lines.push([{ t: " …", fg: K.FAINT }]);
915
950
  if (node.images) {
916
951
  for (let ii = 0; ii < node.images.length; ii++) {
@@ -1187,6 +1222,9 @@ export class ChatView extends Widget {
1187
1222
  { label: "复制消息", action: () => this.app.copyNode(info.nodeIdx) },
1188
1223
  { label: "展开 / 折叠", action: () => this.#clickLine(y, ev) },
1189
1224
  ];
1225
+ if (node?.id) {
1226
+ items.push({ label: "转跳轨迹", action: () => this.app.jumpToTrajectoryNode(info.nodeIdx) });
1227
+ }
1190
1228
  if (node?.kind === "assistant" && node.id) {
1191
1229
  const fb = this.app.feedbackMap.get(node.id);
1192
1230
  const cur = fb?.rating === "positive" ? " ✓已好评" : fb?.rating === "negative" ? " ✓已差评" : "";
@@ -1510,6 +1548,38 @@ export class App {
1510
1548
  this.setMode(this.mode === "trajectory" ? "chat" : "trajectory");
1511
1549
  }
1512
1550
 
1551
+ /** Chat → trajectory: open the trajectory panel at the step containing a
1552
+ * chat node (right-click menu), loading older steps on demand. */
1553
+ async jumpToTrajectoryNode(nodeIdx) {
1554
+ if (!this.currentSession) { this.toast("先打开一个会话"); return; }
1555
+ const node = this.chat.nodes[nodeIdx];
1556
+ if (!node) { this.toast("消息不存在"); return; }
1557
+ if (!this.trajectoryPanel) this.trajectoryPanel = new TrajectoryPanel(this);
1558
+ this.setMode("trajectory"); // also kicks load(currentSession)
1559
+ await this.trajectoryPanel.focusMessage(node.id ?? null);
1560
+ }
1561
+
1562
+ /** Trajectory → chat: switch back to the chat view scrolled at the message
1563
+ * the step belongs to (right-click menu). */
1564
+ jumpToChatStep(si) {
1565
+ const step = this.trajectoryPanel?.steps?.[si];
1566
+ if (!step) { this.toast("该步骤不可用"); return; }
1567
+ let messageId = null;
1568
+ for (const e of step.events) {
1569
+ const d = e.data ?? {};
1570
+ const id = d.id ?? d.message?.id;
1571
+ if (id) { messageId = id; break; }
1572
+ }
1573
+ if (!messageId) { this.toast("该步骤没有关联消息 ID"); return; }
1574
+ this.setMode("chat");
1575
+ const idx = this.chat.nodes.findIndex((n) => n.id === messageId);
1576
+ if (idx >= 0 && this.chat.jumpToNode(idx)) {
1577
+ this.toast(`已转跳到消息 ${messageId.slice(0, 8)}`);
1578
+ } else {
1579
+ this.toast(idx >= 0 ? "该消息在更早的记录中(PgUp 加载后再试)" : "对应消息不在已加载的对话窗口");
1580
+ }
1581
+ }
1582
+
1513
1583
  toggleSidebar() {
1514
1584
  this.sidebarVisible = !this.sidebarVisible;
1515
1585
  this.layout();
@@ -2263,7 +2333,10 @@ export class App {
2263
2333
  if (ev.ctrl && ev.key === "m") { this.overlay = buildModelPicker(this); this.redraw(); return; }
2264
2334
  if (ev.name === "f8") { this.rotatePermission(); return; }
2265
2335
  if (ev.name === "f9") { this.showModePicker(); return; }
2266
- if (ev.ctrl && ev.key === "w") { this.setMode("workspace"); return; }
2336
+ if (ev.ctrl && ev.key === "w") {
2337
+ if (ev.shift) { this.addWorkspace(); return; }
2338
+ this.setMode("workspace"); return;
2339
+ }
2267
2340
  if (ev.ctrl && ev.key === "t") { this.setMode("trajectory"); return; }
2268
2341
  if (ev.ctrl && ev.key === "j") { this.showJobs(); return; }
2269
2342
  if (ev.ctrl && ev.key === "g") { this.showGoal(); return; }