dsh-neotui 0.0.8 → 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 +4 -1
- package/package.json +1 -1
- package/src/panels.js +154 -28
- package/src/views.js +68 -6
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
package/src/panels.js
CHANGED
|
@@ -554,30 +554,37 @@ export class TrajectoryPanel extends Widget {
|
|
|
554
554
|
this.minSeq = null;
|
|
555
555
|
this.allEvents = [];
|
|
556
556
|
this.sessionId = null;
|
|
557
|
-
this.
|
|
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 });
|
|
558
565
|
this.stepLines = [];
|
|
559
566
|
this.query = "";
|
|
560
567
|
}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
const
|
|
564
|
-
if (
|
|
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) {
|
|
565
584
|
const step = this.steps[si];
|
|
566
|
-
if (!step) return
|
|
585
|
+
if (!step) return;
|
|
567
586
|
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
|
-
});
|
|
587
|
+
const items = step.events.map((e, i) => ({ label: truncate(this.#eventSummary(e), 60), hint: `#${e.seq}`, idx: i, event: e }));
|
|
581
588
|
if (t0 && t1) items.unshift({ label: `⏱ 步骤 ${step.step} · ${step.events.length} 事件 · ${fmtMs(t1 - t0)}`, hint: "", idx: -1, event: null });
|
|
582
589
|
const w = Math.min(78, this.app.screen.w - 8), h = Math.min(22, this.app.screen.h - 4);
|
|
583
590
|
this.app.overlay = new Picker({
|
|
@@ -594,7 +601,6 @@ export class TrajectoryPanel extends Widget {
|
|
|
594
601
|
else if (e.type === "assistant/message") body = String(d.message?.content?.map((c) => c.text ?? "").join("\n") ?? "");
|
|
595
602
|
else body = JSON.stringify(d, null, 2);
|
|
596
603
|
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
604
|
const pw = Math.min(80, this.app.screen.w - 4);
|
|
599
605
|
const ph = Math.min(lines.length + 5, 24);
|
|
600
606
|
this.app.overlay = new Popup({
|
|
@@ -607,7 +613,64 @@ export class TrajectoryPanel extends Widget {
|
|
|
607
613
|
},
|
|
608
614
|
});
|
|
609
615
|
this.app.redraw();
|
|
610
|
-
|
|
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
|
+
}
|
|
611
674
|
}
|
|
612
675
|
relayout(x, y, w, h) {
|
|
613
676
|
this.x = x; this.y = y; this.w = w; this.h = h;
|
|
@@ -624,6 +687,14 @@ export class TrajectoryPanel extends Widget {
|
|
|
624
687
|
this.app.redraw();
|
|
625
688
|
return;
|
|
626
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) {
|
|
627
698
|
this.sessionId = sessionId;
|
|
628
699
|
this.loading = true;
|
|
629
700
|
this.steps = [];
|
|
@@ -682,10 +753,10 @@ export class TrajectoryPanel extends Widget {
|
|
|
682
753
|
if (cur && cur.events.length) steps.push(cur);
|
|
683
754
|
this.steps = steps.slice(-600);
|
|
684
755
|
}
|
|
685
|
-
buildLines() {
|
|
756
|
+
buildLines(aroundSi = null) {
|
|
686
757
|
const w = Math.max(40, this.w - 2);
|
|
687
758
|
const lines = [];
|
|
688
|
-
lines.push([{ t: "轨迹 —
|
|
759
|
+
lines.push([{ t: "轨迹 — 步骤时间轴(右键:展开/折叠 · 转跳对话 · 查看详情;r 刷新)", fg: K.ACCENT, bold: true }]);
|
|
689
760
|
if (this.hasMore) lines.push([{ t: "▲ 更早步骤(点击 / PgUp 加载)", fg: K.FAINT }]);
|
|
690
761
|
else lines.push([{ t: "" }]);
|
|
691
762
|
const st = this.stats;
|
|
@@ -701,7 +772,14 @@ export class TrajectoryPanel extends Widget {
|
|
|
701
772
|
return hay.includes(this.query.toLowerCase());
|
|
702
773
|
}))
|
|
703
774
|
: this.steps;
|
|
704
|
-
|
|
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()) {
|
|
705
783
|
const si = this.steps.indexOf(step);
|
|
706
784
|
const tools = [...new Set(step.events.filter((e) => e.type === "tool/call").map((e) => e.data?.name))];
|
|
707
785
|
const hasResult = step.events.some((e) => e.type === "tool/result");
|
|
@@ -710,12 +788,27 @@ export class TrajectoryPanel extends Widget {
|
|
|
710
788
|
const dur = t0 && t1 ? fmtMs(t1 - t0) : "—";
|
|
711
789
|
const bg = tools.length ? (hasResult ? T.TOOLOK : T.TOOLBG) : hasReasoning ? T.THINKBG : T.CARD;
|
|
712
790
|
const summary = tools.slice(0, 3).join(",") || (hasReasoning ? "模型推理" : "纯文本");
|
|
713
|
-
const
|
|
714
|
-
const
|
|
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 }];
|
|
715
796
|
const fill = w - strWidth(label);
|
|
716
|
-
if (fill > 0) segs.push({ t: " ".repeat(fill), bg });
|
|
797
|
+
if (fill > 0) segs.push({ t: " ".repeat(fill), bg: rowBg });
|
|
717
798
|
lines.push(segs);
|
|
718
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
|
+
}
|
|
719
812
|
}
|
|
720
813
|
this.view.setLines(lines);
|
|
721
814
|
}
|
|
@@ -727,7 +820,40 @@ export class TrajectoryPanel extends Widget {
|
|
|
727
820
|
}
|
|
728
821
|
this.view.render(screen);
|
|
729
822
|
}
|
|
730
|
-
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
|
+
}
|
|
731
857
|
onKey(ev) {
|
|
732
858
|
if (ev.type === "text") { this.query += ev.text; this.buildLines(); this.app.redraw(); return true; }
|
|
733
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) {
|
|
@@ -666,14 +682,15 @@ export class ChatView extends Widget {
|
|
|
666
682
|
}
|
|
667
683
|
|
|
668
684
|
jumpToNode(idx) {
|
|
669
|
-
if (idx < 0 || idx >= this.nodes.length) return;
|
|
685
|
+
if (idx < 0 || idx >= this.nodes.length) return false;
|
|
670
686
|
for (let li = 0; li < this.lineMap.length; li++) {
|
|
671
687
|
if (this.lineMap[li]?.nodeIdx === idx) {
|
|
672
688
|
this.view.scrollY = Math.max(0, li - 2);
|
|
673
689
|
this.app.redraw();
|
|
674
|
-
return;
|
|
690
|
+
return true;
|
|
675
691
|
}
|
|
676
692
|
}
|
|
693
|
+
return false; // node exists but is outside the rendered tail window
|
|
677
694
|
}
|
|
678
695
|
|
|
679
696
|
queueRebuild() { this.rebuildQueued = true; }
|
|
@@ -853,7 +870,7 @@ export class ChatView extends Widget {
|
|
|
853
870
|
const markImg = (nodeIdx, imgIdx) => lineMap.push({ nodeIdx, imgIdx });
|
|
854
871
|
// render only the tail of very long sessions; earlier nodes load via pagination
|
|
855
872
|
const MAX_NODES = 150;
|
|
856
|
-
const skipCount = this.nodes.length - MAX_NODES;
|
|
873
|
+
const skipCount = Math.max(0, this.nodes.length - MAX_NODES);
|
|
857
874
|
const nodes = skipCount > 0 ? this.nodes.slice(skipCount) : this.nodes;
|
|
858
875
|
lines.push([{ t: truncate(this.title || this.sessionId?.slice(0, 8) || "", w - 2), fg: K.DIM }]);
|
|
859
876
|
mark(-1);
|
|
@@ -916,9 +933,19 @@ export class ChatView extends Widget {
|
|
|
916
933
|
const text = node.text ?? "";
|
|
917
934
|
const shown = isExp ? text : text.slice(0, 2000);
|
|
918
935
|
beginCard("USERBG");
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
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
|
+
}
|
|
922
949
|
if (!isExp && text.length > 2000) lines.push([{ t: " …", fg: K.FAINT }]);
|
|
923
950
|
if (node.images) {
|
|
924
951
|
for (let ii = 0; ii < node.images.length; ii++) {
|
|
@@ -1195,6 +1222,9 @@ export class ChatView extends Widget {
|
|
|
1195
1222
|
{ label: "复制消息", action: () => this.app.copyNode(info.nodeIdx) },
|
|
1196
1223
|
{ label: "展开 / 折叠", action: () => this.#clickLine(y, ev) },
|
|
1197
1224
|
];
|
|
1225
|
+
if (node?.id) {
|
|
1226
|
+
items.push({ label: "转跳轨迹", action: () => this.app.jumpToTrajectoryNode(info.nodeIdx) });
|
|
1227
|
+
}
|
|
1198
1228
|
if (node?.kind === "assistant" && node.id) {
|
|
1199
1229
|
const fb = this.app.feedbackMap.get(node.id);
|
|
1200
1230
|
const cur = fb?.rating === "positive" ? " ✓已好评" : fb?.rating === "negative" ? " ✓已差评" : "";
|
|
@@ -1518,6 +1548,38 @@ export class App {
|
|
|
1518
1548
|
this.setMode(this.mode === "trajectory" ? "chat" : "trajectory");
|
|
1519
1549
|
}
|
|
1520
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
|
+
|
|
1521
1583
|
toggleSidebar() {
|
|
1522
1584
|
this.sidebarVisible = !this.sidebarVisible;
|
|
1523
1585
|
this.layout();
|