pi-lazy-panel 0.1.0

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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +104 -0
  3. package/README.zh.md +104 -0
  4. package/docs/design.md +235 -0
  5. package/docs/keybindings.md +172 -0
  6. package/docs/keybindings.zh.md +172 -0
  7. package/package.json +67 -0
  8. package/src/actions/session-actions.ts +585 -0
  9. package/src/actions/tree-actions.ts +161 -0
  10. package/src/config/config.ts +155 -0
  11. package/src/config/keymap.ts +219 -0
  12. package/src/config/keys.ts +279 -0
  13. package/src/config/pi-settings.ts +54 -0
  14. package/src/constants.ts +48 -0
  15. package/src/data/changelog.ts +63 -0
  16. package/src/data/content.ts +178 -0
  17. package/src/data/search.ts +183 -0
  18. package/src/data/sessions.ts +148 -0
  19. package/src/data/tree-fold.ts +163 -0
  20. package/src/data/tree.ts +258 -0
  21. package/src/i18n/index.ts +103 -0
  22. package/src/i18n/locales/en.json +341 -0
  23. package/src/i18n/locales/zh.json +341 -0
  24. package/src/index.ts +155 -0
  25. package/src/types.ts +346 -0
  26. package/src/ui/app.ts +2892 -0
  27. package/src/ui/frame.ts +116 -0
  28. package/src/ui/mouse-input.ts +197 -0
  29. package/src/ui/mouse.ts +99 -0
  30. package/src/ui/panes/content-pane.ts +132 -0
  31. package/src/ui/panes/sessions-pane.ts +161 -0
  32. package/src/ui/panes/tree-pane.ts +152 -0
  33. package/src/ui/search-highlight.ts +108 -0
  34. package/src/ui/tree-lines.ts +111 -0
  35. package/src/ui/tree-outline.ts +80 -0
  36. package/src/ui/widgets/changelog-dialog.ts +196 -0
  37. package/src/ui/widgets/compact-dialog.ts +29 -0
  38. package/src/ui/widgets/confirm-dialog.ts +98 -0
  39. package/src/ui/widgets/export-dialog.ts +62 -0
  40. package/src/ui/widgets/footer.ts +117 -0
  41. package/src/ui/widgets/fork-dialog.ts +28 -0
  42. package/src/ui/widgets/help-overlay.ts +220 -0
  43. package/src/ui/widgets/import-dialog.ts +32 -0
  44. package/src/ui/widgets/input-dialog.ts +136 -0
  45. package/src/ui/widgets/label-dialog.ts +28 -0
  46. package/src/ui/widgets/new-session-dialog.ts +29 -0
  47. package/src/ui/widgets/prompt-bar.ts +92 -0
  48. package/src/ui/widgets/rename-dialog.ts +29 -0
  49. package/src/ui/widgets/restore-dialog.ts +60 -0
  50. package/src/ui/widgets/search-bar.ts +65 -0
  51. package/src/ui/widgets/select-dialog.ts +193 -0
  52. package/src/ui/widgets/session-info-dialog.ts +189 -0
  53. package/src/ui/widgets/tree-dialog.ts +293 -0
  54. package/src/utils/format.ts +83 -0
  55. package/src/utils/paths.ts +33 -0
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Pure box-drawing helpers shared by the panes. No I/O, no pi APIs.
3
+ *
4
+ * Every returned line has a visible width of exactly `width`, which is what
5
+ * pi-tui requires from `Component.render()`.
6
+ */
7
+
8
+ import { compositeTuiLine, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
+
10
+ export type Style = (s: string) => string;
11
+
12
+ export interface FrameOptions {
13
+ width: number;
14
+ height: number;
15
+ title: string;
16
+ /** Applied to border characters. */
17
+ border: Style;
18
+ /** Applied to the title text. */
19
+ titleStyle: Style;
20
+ /** Optional right-aligned text on the top border (e.g. "1/12"). */
21
+ meta?: string;
22
+ metaStyle?: Style;
23
+ }
24
+
25
+ /** Pad or truncate `line` to exactly `width` visible columns. */
26
+ export function fit(line: string, width: number): string {
27
+ if (width <= 0) return "";
28
+ const w = visibleWidth(line);
29
+ if (w === width) return line;
30
+ if (w < width) return line + " ".repeat(width - w);
31
+ return truncateToWidth(line, width, "…", true);
32
+ }
33
+
34
+ /**
35
+ * Max visible columns of `meta` text that `frame()` can show next to `title`
36
+ * without dropping it. Panes use this to pick a shorter meta variant instead of
37
+ * silently losing the whole "1/15 · Current · recent" hint in a narrow column.
38
+ *
39
+ * 推导:顶边 = "┌─" + " title " + fill + " meta " + "─" + "┐",fill >= 0 时 meta 才保留,
40
+ * 即 meta 文本宽度 <= width - 8 - visibleWidth(title)。
41
+ */
42
+ export function metaBudget(width: number, title: string): number {
43
+ return Math.max(0, width - 8 - visibleWidth(title));
44
+ }
45
+
46
+ /**
47
+ * Body line that `frame()` draws as a horizontal divider (`├────┤`) instead of
48
+ * a `│ … │` row. Used by dialogs with a header / list / hints layout.
49
+ */
50
+ export const FRAME_DIVIDER = "\u0000divider";
51
+
52
+ /** Build a bordered frame around `body`. Body lines are clipped/padded to the inner size. */
53
+ export function frame(body: string[], o: FrameOptions): string[] {
54
+ const { width, height } = o;
55
+ if (width < 4 || height < 2) return Array.from({ length: Math.max(0, height) }, () => fit("", width));
56
+ const inner = width - 2;
57
+ const top = topBorder(o, inner);
58
+ const bottom = o.border(`└${"─".repeat(inner)}┘`);
59
+ const rows = height - 2;
60
+ const lines: string[] = [top];
61
+ for (let i = 0; i < rows; i++) {
62
+ const line = body[i] ?? "";
63
+ if (line === FRAME_DIVIDER) lines.push(o.border(`├${"─".repeat(inner)}┤`));
64
+ else lines.push(o.border("│") + fit(line, inner) + o.border("│"));
65
+ }
66
+ lines.push(bottom);
67
+ return lines;
68
+ }
69
+
70
+ function topBorder(o: FrameOptions, inner: number): string {
71
+ const title = ` ${o.title} `;
72
+ const meta = o.meta ? ` ${o.meta} ` : "";
73
+ const titleW = visibleWidth(title);
74
+ const metaW = visibleWidth(meta);
75
+ // "┌─ TITLE ───── meta ─┐"
76
+ const fill = inner - 1 - titleW - metaW - (meta ? 1 : 0);
77
+ if (fill < 0) {
78
+ // Not enough room: drop meta, then clip title.
79
+ const t = truncateToWidth(title, Math.max(0, inner - 2), "…", false);
80
+ return o.border(`┌─`) + o.titleStyle(t) + o.border("─".repeat(Math.max(0, inner - 1 - visibleWidth(t))) + "┐");
81
+ }
82
+ const metaPart = meta ? (o.metaStyle ?? o.titleStyle)(meta) + o.border("─") : "";
83
+ return o.border("┌─") + o.titleStyle(title) + o.border("─".repeat(fill)) + metaPart + o.border("┐");
84
+ }
85
+
86
+ /**
87
+ * Composite `box` (lines of exactly `width` columns) centered over the
88
+ * already-rendered `base` lines of a `termW`-column terminal. Shared by the
89
+ * help overlay and the dialogs; box rows beyond `base` are dropped.
90
+ *
91
+ * 居中叠加:帮助弹窗和打标签弹窗都用这个把自己画到面板上面。
92
+ */
93
+ export function overlayCentered(base: string[], box: string[], width: number, termW: number): string[] {
94
+ const top = Math.max(0, Math.floor((base.length - box.length) / 2));
95
+ const left = Math.max(0, Math.floor((termW - width) / 2));
96
+ const out = [...base];
97
+ for (let i = 0; i < box.length && top + i < out.length; i++) {
98
+ out[top + i] = compositeTuiLine(out[top + i] ?? "", box[i]!, left, width, termW);
99
+ }
100
+ return out;
101
+ }
102
+
103
+ /** Width of a centered dialog box (input prompt, menu) for a `termW`-column terminal. */
104
+ export function dialogWidth(termW: number): number {
105
+ return Math.max(24, Math.min(termW - 4, 60));
106
+ }
107
+
108
+ /** Place `left` and `right` column line arrays side by side. Both must already be the right width. */
109
+ export function sideBySide(left: string[], right: string[], leftWidth: number, rightWidth: number): string[] {
110
+ const n = Math.max(left.length, right.length);
111
+ const out: string[] = [];
112
+ for (let i = 0; i < n; i++) {
113
+ out.push(fit(left[i] ?? "", leftWidth) + fit(right[i] ?? "", rightWidth));
114
+ }
115
+ return out;
116
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Mouse input for regular (non-fullscreen) TUI mode.
3
+ *
4
+ * pi only turns on terminal mouse tracking in fullscreen mode; in the default
5
+ * regular mode `TuiMainScreen` never enables it, so an overlay's `handleMouse`
6
+ * is never called. When the panel opens in regular mode `index.ts` enables SGR
7
+ * mouse reporting itself (`\x1b[?1000h\x1b[?1006h`), feeds each raw stdin chunk
8
+ * through `parseSgrMouse` + `MouseTracker`, and routes the result to
9
+ * `LazyPanel.handleMouse`. Pure parsing here; no I/O, tested in test/mouse-input.test.ts.
10
+ */
11
+
12
+ import type { TuiMouseEvent } from "@earendil-works/pi-tui";
13
+
14
+ /** Enable SGR mouse reporting: button press / release (1000) with SGR encoding (1006). */
15
+ export const ENABLE_MOUSE = "\x1b[?1000h\x1b[?1006h";
16
+ /** Disable what ENABLE_MOUSE turned on (restores the terminal's own scroll / selection). */
17
+ export const DISABLE_MOUSE = "\x1b[?1006l\x1b[?1000l";
18
+
19
+ /** SGR mouse sequence: ESC [ < Cb ; Cx ; Cy (M = press, m = release). */
20
+ const SGR_MOUSE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
21
+
22
+ /** One decoded SGR mouse report (coordinates are zero-based). */
23
+ export interface RawMouse {
24
+ /** SGR button code (bit 6 = wheel, bit 5 = motion, bits 0-1 = button, 2 = shift, 3 = alt, 4 = ctrl). */
25
+ button: number;
26
+ x: number;
27
+ y: number;
28
+ /** true for the `m` (release) form. */
29
+ release: boolean;
30
+ }
31
+
32
+ /** Parse one raw stdin chunk into a mouse report, or undefined when it is not an SGR mouse sequence. */
33
+ export function parseSgrMouse(data: string): RawMouse | undefined {
34
+ const m = SGR_MOUSE.exec(data);
35
+ if (!m) return undefined;
36
+ return { button: Number.parseInt(m[1]!, 10), x: Number.parseInt(m[2]!, 10) - 1, y: Number.parseInt(m[3]!, 10) - 1, release: m[4] === "m" };
37
+ }
38
+
39
+ /** Same shape without the trailing anchor, so a chunk can be scanned for several reports at once. */
40
+ const SGR_MOUSE_G = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
41
+
42
+ /**
43
+ * Every SGR mouse report in `data`, but only when the whole chunk is nothing but
44
+ * mouse sequences (terminals batch several wheel reports into one read during a
45
+ * fast trackpad scroll). Returns undefined when the chunk holds anything else,
46
+ * so the caller passes it through to normal key handling instead of consuming it.
47
+ */
48
+ export function parseSgrMouseChunk(data: string): RawMouse[] | undefined {
49
+ if (!data.startsWith("\x1b[<")) return undefined;
50
+ const out: RawMouse[] = [];
51
+ let last = 0;
52
+ SGR_MOUSE_G.lastIndex = 0;
53
+ for (let m = SGR_MOUSE_G.exec(data); m; m = SGR_MOUSE_G.exec(data)) {
54
+ if (m.index !== last) return undefined; // 中间夹了非鼠标字节
55
+ out.push({ button: Number.parseInt(m[1]!, 10), x: Number.parseInt(m[2]!, 10) - 1, y: Number.parseInt(m[3]!, 10) - 1, release: m[4] === "m" });
56
+ last = SGR_MOUSE_G.lastIndex;
57
+ }
58
+ return last === data.length && out.length > 0 ? out : undefined;
59
+ }
60
+
61
+ /** true when `data` looks like any SGR mouse sequence (so the caller can consume it even if we take no action). */
62
+ export function isMouseSequence(data: string): boolean {
63
+ return SGR_MOUSE.test(data);
64
+ }
65
+
66
+ export interface MouseTrackerOptions {
67
+ /** Terminal size for the synthesized event bounds (width drives the hit-test columns). */
68
+ size: () => { width: number; height: number };
69
+ /** Lines per wheel notch (default 3). */
70
+ wheelScrollLines?: number;
71
+ /** A second click on the same cell within this many ms is a double-click (default 400). */
72
+ doubleClickMs?: number;
73
+ /** Injectable clock for the tests. */
74
+ now?: () => number;
75
+ }
76
+
77
+ /**
78
+ * Turns raw SGR reports into the high-level `TuiMouseEvent`s the panel acts on:
79
+ * a `wheel` event per wheel notch, and a `click` (with `clickCount` for
80
+ * double-clicks) on release when press and release land on the same cell. Only
81
+ * the left button drives clicks; drags (press then release on another cell) are
82
+ * dropped. Motion reports are not requested, so a moved pointer simply releases
83
+ * on a different cell and is ignored.
84
+ */
85
+ export class MouseTracker {
86
+ private readonly size: () => { width: number; height: number };
87
+ private readonly wheelScrollLines: number;
88
+ private readonly doubleClickMs: number;
89
+ private readonly now: () => number;
90
+
91
+ private pressX = -1;
92
+ private pressY = -1;
93
+ private pressed = false;
94
+ private lastClickX = -1;
95
+ private lastClickY = -1;
96
+ private lastClickAt = 0;
97
+ private clickCount = 0;
98
+
99
+ constructor(o: MouseTrackerOptions) {
100
+ this.size = o.size;
101
+ this.wheelScrollLines = o.wheelScrollLines ?? 3;
102
+ this.doubleClickMs = o.doubleClickMs ?? 400;
103
+ this.now = o.now ?? (() => Date.now());
104
+ }
105
+
106
+ /** Feed a raw report; returns an event to dispatch, or undefined (a press, drag, or non-left button). */
107
+ feed(raw: RawMouse): TuiMouseEvent | undefined {
108
+ // 滚轮:bit 6 置位,低两位 0 = 上滚、1 = 下滚。
109
+ if ((raw.button & 64) !== 0) {
110
+ const dir = raw.button & 3;
111
+ if (dir !== 0 && dir !== 1) return undefined;
112
+ return this.event("wheel", raw, { wheelDelta: (dir === 0 ? -1 : 1) * this.wheelScrollLines });
113
+ }
114
+ // 只有左键驱动单击 / 双击;中键、右键忽略(仍会被调用方 consume 掉)。
115
+ if ((raw.button & 3) !== 0) {
116
+ this.pressed = false;
117
+ return undefined;
118
+ }
119
+ if (!raw.release) {
120
+ // 按下:记住位置,等释放时判定是否为一次点击。
121
+ this.pressed = true;
122
+ this.pressX = raw.x;
123
+ this.pressY = raw.y;
124
+ return undefined;
125
+ }
126
+ // 释放:按下和释放落在同一格才算点击(拖拽会落在别的格,丢弃)。
127
+ const isClick = this.pressed && this.pressX === raw.x && this.pressY === raw.y;
128
+ this.pressed = false;
129
+ if (!isClick) return undefined;
130
+ const now = this.now();
131
+ const again = raw.x === this.lastClickX && raw.y === this.lastClickY && now - this.lastClickAt <= this.doubleClickMs;
132
+ this.clickCount = again ? this.clickCount + 1 : 1;
133
+ this.lastClickX = raw.x;
134
+ this.lastClickY = raw.y;
135
+ this.lastClickAt = now;
136
+ return this.event("click", raw, { clickCount: this.clickCount });
137
+ }
138
+
139
+ private event(type: "wheel" | "click", raw: RawMouse, extra: { wheelDelta?: number; clickCount?: number }): TuiMouseEvent {
140
+ const { width, height } = this.size();
141
+ return {
142
+ type,
143
+ button: type === "wheel" ? "none" : "left",
144
+ x: raw.x,
145
+ y: raw.y,
146
+ screenX: raw.x,
147
+ screenY: raw.y,
148
+ width: Math.max(1, width),
149
+ height: Math.max(1, height),
150
+ shift: (raw.button & 4) !== 0,
151
+ alt: (raw.button & 8) !== 0,
152
+ ctrl: (raw.button & 16) !== 0,
153
+ ...(extra.wheelDelta === undefined ? {} : { wheelDelta: extra.wheelDelta }),
154
+ ...(extra.clickCount === undefined ? {} : { clickCount: extra.clickCount }),
155
+ };
156
+ }
157
+ }
158
+
159
+ /** Just enough of pi-tui's TUI for `attachMouse` (also lets the test use a fake). */
160
+ export interface MouseHost {
161
+ /** "regular" or "fullscreen" — pi only tracks the mouse itself in fullscreen. */
162
+ readonly mode: string;
163
+ terminal: { write(data: string): void; columns: number; rows: number };
164
+ addInputListener(listener: (data: string) => { consume?: boolean; data?: string } | undefined): () => void;
165
+ }
166
+
167
+ /** The panel side `attachMouse` drives. */
168
+ export interface MouseSink {
169
+ handleMouse(event: TuiMouseEvent): unknown;
170
+ }
171
+
172
+ /**
173
+ * Wire mouse input for a panel. In fullscreen mode pi already tracks the mouse
174
+ * and dispatches to the overlay's `handleMouse`, so this is a no-op. In regular
175
+ * mode pi tracks nothing, so we enable SGR reporting, parse each stdin chunk and
176
+ * route the events to the sink, consuming the raw sequences. Returns a cleanup
177
+ * function that removes the listener and disables reporting.
178
+ */
179
+ export function attachMouse(host: MouseHost, sink: MouseSink): () => void {
180
+ if (host.mode === "fullscreen") return () => {};
181
+ host.terminal.write(ENABLE_MOUSE);
182
+ const tracker = new MouseTracker({ size: () => ({ width: host.terminal.columns, height: host.terminal.rows }) });
183
+ const remove = host.addInputListener((data) => {
184
+ const raws = parseSgrMouseChunk(data);
185
+ if (!raws) return undefined;
186
+ for (const raw of raws) {
187
+ const event = tracker.feed(raw);
188
+ if (event) sink.handleMouse(event);
189
+ }
190
+ // 鼠标序列一律吞掉,避免被 pi 当成按键。
191
+ return { consume: true };
192
+ });
193
+ return () => {
194
+ remove();
195
+ host.terminal.write(DISABLE_MOUSE);
196
+ };
197
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Pure mouse hit-testing for the panel. Maps a terminal cell (x, y) to the pane
3
+ * under it and, for the two list panes, the row index the cell falls on.
4
+ *
5
+ * The geometry MUST match LazyPanel.render(): the panel calls `panelGeometry()`
6
+ * from both places, so a layout tweak can never desync clicks from what is
7
+ * drawn. No I/O, no pi APIs — just arithmetic, tested in test/ui.test.ts.
8
+ */
9
+
10
+ import { LEFT_COLUMN_RATIO } from "../constants.ts";
11
+ import type { PaneId } from "../types.ts";
12
+ import { SESSIONS_ROW_HEIGHT } from "./panes/sessions-pane.ts";
13
+
14
+ export interface VerticalSplit {
15
+ /** Rows of the body above the one-line footer. */
16
+ bodyH: number;
17
+ /** Rows of the (top) sessions pane; the tree pane takes the rest of the left column. */
18
+ sessionsH: number;
19
+ treeH: number;
20
+ }
21
+
22
+ /** The body / sessions / tree row split for `height` rows (independent of width). */
23
+ export function verticalSplit(height: number): VerticalSplit {
24
+ const bodyH = height - 1; // footer 占最后一行
25
+ const sessionsH = Math.max(4, Math.floor(bodyH / 2));
26
+ return { bodyH, sessionsH, treeH: bodyH - sessionsH };
27
+ }
28
+
29
+ export interface PanelGeometry extends VerticalSplit {
30
+ /** Width of the left column (sessions + tree); the right column takes the rest. */
31
+ leftW: number;
32
+ rightW: number;
33
+ }
34
+
35
+ /**
36
+ * The panel's column / row split for a `width` × `height` viewport. Shared with
37
+ * `LazyPanel.render()` so the drawn layout and the mouse hit-test never diverge.
38
+ */
39
+ export function panelGeometry(width: number, height: number, ratio: number = LEFT_COLUMN_RATIO): PanelGeometry {
40
+ const v = verticalSplit(height);
41
+ const leftW = Math.max(24, Math.min(width - 30, Math.floor(width * ratio)));
42
+ return { ...v, leftW, rightW: width - leftW };
43
+ }
44
+
45
+ /** Visible list rows in each left pane for `height` rows (sessions take 2 lines per row, tree 1). */
46
+ export function listVisibleRows(height: number): { sessions: number; tree: number } {
47
+ const { sessionsH, treeH } = verticalSplit(height);
48
+ return { sessions: Math.max(1, Math.floor((sessionsH - 2) / SESSIONS_ROW_HEIGHT)), tree: Math.max(1, treeH - 2) };
49
+ }
50
+
51
+ /** Pane under the pointer; list panes also carry the row hit (undefined on a border / blank cell). */
52
+ export type MouseTarget = { pane: PaneId; row: number | undefined };
53
+
54
+ export interface HitInput {
55
+ width: number;
56
+ height: number;
57
+ ratio: number;
58
+ /** Cell local to the panel (top-left overlay, so this equals the screen cell). */
59
+ x: number;
60
+ y: number;
61
+ /** First visible row of each list pane (the offset render actually used). */
62
+ sessionsFirst: number;
63
+ sessionsTotal: number;
64
+ treeFirst: number;
65
+ treeTotal: number;
66
+ }
67
+
68
+ /**
69
+ * Which pane the cell (x, y) is over, and for a list pane the row index it hits
70
+ * (undefined when it lands on a border or a blank area past the last row).
71
+ * Returns undefined for the footer row or anything outside the body.
72
+ */
73
+ export function hitTest(i: HitInput): MouseTarget | undefined {
74
+ const g = panelGeometry(i.width, i.height, i.ratio);
75
+ if (i.y < 0 || i.y >= g.bodyH || i.x < 0 || i.x >= i.width) return undefined; // 页脚 / 面板之外
76
+ // 右侧 CONTENT 面板占满 body 高度,只切焦点、不选行。
77
+ if (i.x >= g.leftW) return { pane: "content", row: undefined };
78
+ if (i.y < g.sessionsH) {
79
+ return { pane: "sessions", row: listRow(i.y, g.sessionsH, SESSIONS_ROW_HEIGHT, i.sessionsFirst, i.sessionsTotal) };
80
+ }
81
+ return { pane: "tree", row: listRow(i.y - g.sessionsH, g.treeH, 1, i.treeFirst, i.treeTotal) };
82
+ }
83
+
84
+ /**
85
+ * Row index a cell at pane-local `y` (0 = the pane's top border) hits inside a
86
+ * framed list pane whose first visible row is `first`, or undefined for the
87
+ * border rows / a cell past the last row. `rowHeight` is the lines per item
88
+ * (2 for sessions, 1 for tree).
89
+ */
90
+ function listRow(y: number, paneH: number, rowHeight: number, first: number, total: number): number | undefined {
91
+ const bodyRows = paneH - 2; // 去掉上下边框
92
+ const bodyY = y - 1; // 第一行是上边框
93
+ if (bodyY < 0 || bodyY >= bodyRows) return undefined;
94
+ const visible = Math.max(1, Math.floor(bodyRows / rowHeight));
95
+ const within = Math.floor(bodyY / rowHeight);
96
+ if (within >= visible) return undefined; // 底部不足一整行的空白行
97
+ const index = first + within;
98
+ return index >= 0 && index < total ? index : undefined;
99
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Content pane (right, full height, read-only).
3
+ *
4
+ * Renders `ContentBlock[]` as boxed messages ("YOU · 22:18" / "ASSISTANT · 22:18")
5
+ * using pi's native Markdown renderer from @earendil-works/pi-tui.
6
+ *
7
+ * The pane is a line-scrolled viewport: `layoutContent` turns the blocks into
8
+ * body lines once (the panel caches it), and `renderContentPane` shows the
9
+ * window starting at `scroll`. One block can be highlighted (the node selected
10
+ * in the tree pane): its header gets a `›` marker and the selected background.
11
+ * A `/` search matches the rendered text lines (`ContentLayout.searchable`
12
+ * marks them; box borders and headers are skipped); the hits on the visible
13
+ * lines are painted over the cached lines at render time, nothing is re-rendered.
14
+ */
15
+
16
+ import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
17
+ import { Markdown, visibleWidth } from "@earendil-works/pi-tui";
18
+ import { t } from "../../i18n/index.ts";
19
+ import type { ContentBlock, SearchView } from "../../types.ts";
20
+ import { formatTime } from "../../utils/format.ts";
21
+ import { fit, frame, metaBudget } from "../frame.ts";
22
+ import { highlightLine, matchStyle, searchMeta } from "../search-highlight.ts";
23
+
24
+ /** Body lines of the whole conversation plus where each block starts. */
25
+ export interface ContentLayout {
26
+ lines: string[];
27
+ /** entryId → index of the block's header line in `lines`. */
28
+ starts: Map<string, number>;
29
+ /** Per line: true for rendered message text (what `/` searches), false for box borders, headers and gaps. */
30
+ searchable: boolean[];
31
+ }
32
+
33
+ export interface ContentPaneProps {
34
+ blocks: ContentBlock[];
35
+ /** Precomputed layout (see `layoutContent`); computed on the fly when omitted. */
36
+ layout?: ContentLayout;
37
+ /** First body line shown; clamped to the last full page. */
38
+ scroll: number;
39
+ /** entryId of the block to highlight (the tree cursor), if any. */
40
+ highlightEntryId?: string;
41
+ /** Active `/` search of this pane (indices into the layout lines): hits on the visible lines are painted, the header shows the count. */
42
+ search?: SearchView;
43
+ focused: boolean;
44
+ title?: string;
45
+ emptyMessage?: string;
46
+ theme: Theme;
47
+ }
48
+
49
+ /** Layout all blocks into body lines of exactly `inner` width. */
50
+ export function layoutContent(blocks: ContentBlock[], inner: number, theme: Theme, highlightEntryId?: string): ContentLayout {
51
+ const lines: string[] = [];
52
+ const searchable: boolean[] = [];
53
+ const starts = new Map<string, number>();
54
+ const mdTheme = getMarkdownTheme();
55
+ // One column of margin on each side of every message box.
56
+ const boxW = Math.max(10, inner - 2);
57
+ const textW = boxW - 4; // "│ " + " │"
58
+ const push = (line: string, text: boolean) => {
59
+ lines.push(line);
60
+ searchable.push(text);
61
+ };
62
+
63
+ for (const block of blocks) {
64
+ const isUser = block.role === "user";
65
+ const isHighlight = block.entryId === highlightEntryId;
66
+ const who = isUser ? t("pane.you") : t("pane.assistant");
67
+ const head = ` ${who} · ${formatTime(block.timestamp)} `;
68
+ const borderStyle = (s: string) => theme.fg(isUser ? "userMessageText" : "accent", s);
69
+ const headStyle = (s: string) => theme.bold(theme.fg(isUser ? "userMessageText" : "accent", s));
70
+
71
+ // 被 tree 选中的消息:左边距变成 › 箭头,头部整行加选中背景,方便一眼定位。
72
+ // fill 必须按可见宽度算:中文标签(你 / AI助手)是全角字符,用 head.length 会算漏,
73
+ // 导致 ┐ 被推到右边、和下边框对不齐,甚至溢出面板被截成 …。
74
+ const fill = Math.max(0, boxW - 3 - visibleWidth(head));
75
+ const headerRaw = borderStyle("┌─") + headStyle(head) + borderStyle("─".repeat(fill) + "┐");
76
+ starts.set(block.entryId, lines.length);
77
+ push(isHighlight ? theme.bg("selectedBg", fit(theme.bold(theme.fg("accent", "›")) + headerRaw, inner)) : " " + headerRaw, false);
78
+
79
+ const md = new Markdown(block.markdown, 0, 0, mdTheme);
80
+ let rendered: string[];
81
+ try {
82
+ rendered = md.render(textW);
83
+ } catch {
84
+ rendered = block.markdown.split("\n");
85
+ }
86
+ if (rendered.length === 0) rendered = [""];
87
+ for (const raw of rendered) {
88
+ push(" " + borderStyle("│") + " " + fit(raw, textW) + " " + borderStyle("│"), true);
89
+ }
90
+ push(" " + borderStyle(`└${"─".repeat(boxW - 2)}┘`), false);
91
+ push("", false);
92
+ }
93
+ return { lines, starts, searchable };
94
+ }
95
+
96
+ /** Largest valid scroll offset for `total` lines in a viewport of `visible` lines. */
97
+ export function maxScroll(total: number, visible: number): number {
98
+ return Math.max(0, total - visible);
99
+ }
100
+
101
+ export function renderContentPane(p: ContentPaneProps, width: number, height: number): string[] {
102
+ const { theme } = p;
103
+ const inner = width - 2;
104
+ const visible = Math.max(1, height - 2);
105
+ let body: string[];
106
+ if (p.blocks.length === 0) {
107
+ body = ["", theme.fg("muted", ` ${p.emptyMessage ?? t("pane.contentEmpty")}`)];
108
+ } else {
109
+ const all = (p.layout ?? layoutContent(p.blocks, inner, theme, p.highlightEntryId)).lines;
110
+ const start = Math.min(Math.max(0, p.scroll), maxScroll(all.length, visible));
111
+ body = all.slice(start, start + visible);
112
+ // 搜索命中:只给窗口里的命中行叠加高亮,缓存的排版结果本身不动。
113
+ const search = p.search;
114
+ if (search && search.total > 0) {
115
+ body = body.map((line, k) => {
116
+ const index = start + k;
117
+ return search.matches.has(index) ? highlightLine(line, search.terms, matchStyle(theme, index === search.current)) : line;
118
+ });
119
+ }
120
+ }
121
+ const title = p.title ?? "CONTENT";
122
+ const meta = p.search ? searchMeta(p.search.position, p.search.total, metaBudget(width, title)) : p.blocks.length ? t("pane.messages", { count: p.blocks.length }) : "";
123
+ return frame(body, {
124
+ width,
125
+ height,
126
+ title,
127
+ meta,
128
+ border: (s) => theme.fg(p.focused ? "borderAccent" : "border", s),
129
+ titleStyle: (s) => (p.focused ? theme.bold(theme.fg("accent", s)) : theme.fg("muted", s)),
130
+ metaStyle: (s) => theme.fg("dim", s),
131
+ });
132
+ }