pi-message-sidebar 1.6.0 → 2.0.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.
- package/CHANGELOG.md +75 -0
- package/README.md +26 -23
- package/message-sidebar.ts +42 -12
- package/package.json +1 -1
- package/src/anim.ts +134 -0
- package/src/files.ts +58 -0
- package/src/flag.ts +29 -0
- package/src/git-status.ts +123 -0
- package/src/goal-card.ts +115 -0
- package/src/messages.ts +363 -0
- package/src/palette.ts +248 -0
- package/src/sections.ts +165 -0
- package/src/sidebar-component.ts +214 -192
- package/src/slots.ts +130 -0
- package/src/status-dock.ts +3 -135
- package/src/style.ts +109 -44
- package/src/summaries.ts +323 -0
- package/src/types.ts +6 -0
package/src/goal-card.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { breathe, isVictory, type EasedMeter } from "./anim.ts";
|
|
3
|
+
import type { ThreadGoal } from "./goal.ts";
|
|
4
|
+
import type { Palette, RGB } from "./palette.ts";
|
|
5
|
+
import { fgRgb, rgbLerp } from "./palette.ts";
|
|
6
|
+
import { ghostHeader, meterTrack, pressureColor, railRow } from "./sections.ts";
|
|
7
|
+
import { RST, clip, formatElapsed, formatTokens, meterCells } from "./style.ts";
|
|
8
|
+
|
|
9
|
+
const METER_CELLS = 12;
|
|
10
|
+
|
|
11
|
+
function goalStatus(palette: Palette, goal: ThreadGoal): { color: string; label: string } {
|
|
12
|
+
switch (goal.status) {
|
|
13
|
+
case "active": return { color: palette.badgeAdded, label: "ACTIVE" };
|
|
14
|
+
case "complete": return { color: palette.badgeAdded, label: "COMPLETE" };
|
|
15
|
+
case "paused": return { color: palette.badgeModified, label: "PAUSED" };
|
|
16
|
+
case "budgetLimited": return { color: palette.badgeDeleted, label: "BUDGET" };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The ambient status dot: a slow breath, brighter than the trough so it never vanishes. */
|
|
21
|
+
function statusDot(palette: Palette, now: number): string {
|
|
22
|
+
const level = 0.35 + 0.65 * breathe(now);
|
|
23
|
+
if (palette.truecolor && palette.dotDim && palette.dotPeak) {
|
|
24
|
+
return `${fgRgb(rgbLerp(palette.dotDim, palette.dotPeak, level) as RGB)}●${RST}`;
|
|
25
|
+
}
|
|
26
|
+
const step = Math.round(level * (palette.dotFallback.length - 1));
|
|
27
|
+
return `${palette.dotFallback[step] ?? palette.ghost}●${RST}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Wraps the objective into at most three lines, clipping the last. */
|
|
31
|
+
function titleLines(objective: string, width: number, maxLines: number): string[] {
|
|
32
|
+
const words = objective.replace(/\s+/g, " ").trim().split(" ");
|
|
33
|
+
const lines: string[] = [];
|
|
34
|
+
let current = "";
|
|
35
|
+
let index = 0;
|
|
36
|
+
for (; index < words.length; index++) {
|
|
37
|
+
const candidate = current ? `${current} ${words[index]}` : words[index]!;
|
|
38
|
+
if (visibleWidth(candidate) > width && current) {
|
|
39
|
+
lines.push(current);
|
|
40
|
+
current = words[index]!;
|
|
41
|
+
if (lines.length === maxLines) break;
|
|
42
|
+
} else {
|
|
43
|
+
current = candidate;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (lines.length < maxLines && current) {
|
|
47
|
+
lines.push(current);
|
|
48
|
+
index++;
|
|
49
|
+
}
|
|
50
|
+
if (lines.length === maxLines && index < words.length) {
|
|
51
|
+
lines[maxLines - 1] = clip(`${lines[maxLines - 1]} ${words.slice(index).join(" ")}`, width);
|
|
52
|
+
}
|
|
53
|
+
return lines;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The goal card: the rail's hero. It sits on the raised panel step, opens
|
|
58
|
+
* with a breathing status dot and the budget share, carries the objective in
|
|
59
|
+
* bold across up to three lines, and closes with a smooth budget meter whose
|
|
60
|
+
* fill eases toward the live ratio. A missing goal is a single ghost row.
|
|
61
|
+
*/
|
|
62
|
+
export function renderGoalSection(
|
|
63
|
+
goal: ThreadGoal | null,
|
|
64
|
+
rows: number,
|
|
65
|
+
palette: Palette,
|
|
66
|
+
now: number,
|
|
67
|
+
budgetMeter: EasedMeter,
|
|
68
|
+
width = 39,
|
|
69
|
+
shimmer: number | null = null,
|
|
70
|
+
victoryAt: number | null = null,
|
|
71
|
+
): string[] {
|
|
72
|
+
if (rows <= 0) return [];
|
|
73
|
+
const lines: string[] = [];
|
|
74
|
+
const push = (line: string) => { if (lines.length < rows) lines.push(line); };
|
|
75
|
+
|
|
76
|
+
if (!goal) {
|
|
77
|
+
push(ghostHeader(palette, "no goal · /goal <objective>", palette.bgDeep, "", width));
|
|
78
|
+
while (lines.length < rows) push(railRow(palette, "", palette.bgDeep, width));
|
|
79
|
+
return lines;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const bg = palette.bgPanel;
|
|
83
|
+
const status = goalStatus(palette, goal);
|
|
84
|
+
const hasBudget = Boolean(goal.tokenBudget);
|
|
85
|
+
const ratio = hasBudget ? goal.usage.tokensUsed / goal.tokenBudget! : null;
|
|
86
|
+
const share = ratio === null ? "" : `${Math.round(Math.min(ratio, 9.99) * 100)}%`;
|
|
87
|
+
|
|
88
|
+
push(railRow(palette, `${statusDot(palette, now)} ${status.color}${palette.bold(status.label)}${RST}${share ? `${palette.ghostBright}${" ".repeat(Math.max(1, width - status.label.length - 2 - share.length))}${share}${RST}` : ""}`, bg, width));
|
|
89
|
+
if (rows >= 6) push(railRow(palette, "", bg, width));
|
|
90
|
+
|
|
91
|
+
const maxLines = rows >= 7 ? 3 : 2;
|
|
92
|
+
// The completion flash: for a beat after the goal flips to complete, the
|
|
93
|
+
// title wears the success color before settling back to content white.
|
|
94
|
+
const titleColor = victoryAt !== null && isVictory(now, victoryAt) ? palette.badgeAdded : palette.textNew;
|
|
95
|
+
for (const line of titleLines(goal.objective, width, maxLines)) {
|
|
96
|
+
push(railRow(palette, `${palette.bold(`${titleColor}${clip(line, width)}${RST}`)}`, bg, width));
|
|
97
|
+
}
|
|
98
|
+
if (rows >= 8) push(railRow(palette, "", bg, width));
|
|
99
|
+
|
|
100
|
+
const meterColor = goal.status === "complete" ? palette.badgeAdded
|
|
101
|
+
: goal.status === "budgetLimited" || (hasBudget && goal.usage.tokensUsed > goal.tokenBudget!) ? palette.badgeDeleted
|
|
102
|
+
: pressureColor(palette, ratio);
|
|
103
|
+
const meter = meterCells(budgetMeter.get() ?? ratio, METER_CELLS, meterColor, meterTrack(palette), "─", shimmer);
|
|
104
|
+
const label = `${palette.ghost}bdg${RST} `;
|
|
105
|
+
const used = formatTokens(goal.usage.tokensUsed);
|
|
106
|
+
const counts = hasBudget ? `${used}/${formatTokens(goal.tokenBudget!)}` : `${used} tokens`;
|
|
107
|
+
const elapsed = formatElapsed(goal.usage.activeSeconds);
|
|
108
|
+
const full = `${label}${meter ? `${meter} ` : ""}${palette.textMid}${counts}${RST} ${palette.ghost}·${RST} ${palette.textMid}${elapsed}${RST}`;
|
|
109
|
+
const fits = 4 + (meter ? METER_CELLS + 1 : 0) + visibleWidth(counts) + 3 + visibleWidth(elapsed) <= width;
|
|
110
|
+
push(railRow(palette, fits ? full : `${label}${meter ? `${meter} ` : ""}${palette.textMid}${counts}${RST}`, bg, width));
|
|
111
|
+
if (rows >= 9) push(railRow(palette, "", bg, width));
|
|
112
|
+
|
|
113
|
+
while (lines.length < rows) push(railRow(palette, "", bg, width));
|
|
114
|
+
return lines;
|
|
115
|
+
}
|
package/src/messages.ts
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { matchesKey, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { GLOW_MS, isArriving, isSettling } from "./anim.ts";
|
|
3
|
+
import type { Palette } from "./palette.ts";
|
|
4
|
+
import { RAIL_CONTENT, ghostHeader, railRow } from "./sections.ts";
|
|
5
|
+
import { RST, formatCount, formatTime, wrapText } from "./style.ts";
|
|
6
|
+
import { slotRows } from "./slots.ts";
|
|
7
|
+
import type { UserMessage } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
export type { UserMessage };
|
|
10
|
+
|
|
11
|
+
type MessagePanelOptions = {
|
|
12
|
+
/** Display summary for a message: model summary when present, preview otherwise. */
|
|
13
|
+
getSummary: (messageId: string, text: string) => string;
|
|
14
|
+
/** Whether the model has written this message's summary yet. */
|
|
15
|
+
hasSummary: (messageId: string) => boolean;
|
|
16
|
+
/** Whether a summary request for this message is still in flight. */
|
|
17
|
+
isPending: (messageId: string) => boolean;
|
|
18
|
+
/** Whether the summary gateway is configured at all (shows the setup hint). */
|
|
19
|
+
summariesConfigured: () => boolean;
|
|
20
|
+
requestRefresh: () => void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** One message always occupies a two-row slot: summary line plus its wrap. */
|
|
24
|
+
const ROWS_PER_MESSAGE = 2;
|
|
25
|
+
const SETUP_HINT = "AI summaries need FORNACE_LLM_API_KEY";
|
|
26
|
+
|
|
27
|
+
export function detailCapacity(rows: number, wrappedCount: number): { textCapacity: number; hasIndicator: boolean; maxScroll: number } {
|
|
28
|
+
const available = Math.max(0, rows - 1);
|
|
29
|
+
if (wrappedCount <= available) {
|
|
30
|
+
return { textCapacity: available, hasIndicator: false, maxScroll: 0 };
|
|
31
|
+
}
|
|
32
|
+
// A two-row grant leaves no room for text plus indicator: the indicator
|
|
33
|
+
// becomes the only body row and reports the scroll position instead.
|
|
34
|
+
if (available <= 1) {
|
|
35
|
+
return { textCapacity: 0, hasIndicator: true, maxScroll: Math.max(0, wrappedCount - 1) };
|
|
36
|
+
}
|
|
37
|
+
const textCapacity = available - 1;
|
|
38
|
+
const maxScroll = Math.max(0, wrappedCount - textCapacity);
|
|
39
|
+
return { textCapacity, hasIndicator: true, maxScroll };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Owns the message list: selection, follow-tail, the two-row message grid,
|
|
44
|
+
* the hidden-count ellipsis rows, and the expanded detail view. Rendering is
|
|
45
|
+
* pure row arithmetic: every entry point returns exactly the rows it was
|
|
46
|
+
* granted, because a short section surfaces as a fatal height mismatch in
|
|
47
|
+
* pi's render loop.
|
|
48
|
+
*/
|
|
49
|
+
export class MessagePanel {
|
|
50
|
+
private messages: UserMessage[];
|
|
51
|
+
private selectedId: string | null;
|
|
52
|
+
private detailId: string | null = null;
|
|
53
|
+
private detailScroll = 0;
|
|
54
|
+
private lastDetailRows = 1;
|
|
55
|
+
private followTail = true;
|
|
56
|
+
private viewportStartId: string | null = null;
|
|
57
|
+
private wrapCache: { id: string; lines: string[] } | null = null;
|
|
58
|
+
private readonly seenSummary = new Map<string, boolean>();
|
|
59
|
+
private readonly landedAt = new Map<string, number>();
|
|
60
|
+
private readonly arrivedAt = new Map<string, number>();
|
|
61
|
+
/** Ids seen so far; the first updateMessages seeds history without animating
|
|
62
|
+
* it, so a resumed session loads calm and only live arrivals reveal. */
|
|
63
|
+
private readonly known = new Set<string>();
|
|
64
|
+
|
|
65
|
+
constructor(
|
|
66
|
+
private readonly options: MessagePanelOptions,
|
|
67
|
+
messages: UserMessage[],
|
|
68
|
+
) {
|
|
69
|
+
this.messages = messages;
|
|
70
|
+
this.selectedId = messages.at(-1)?.id ?? null;
|
|
71
|
+
for (const message of messages) this.known.add(message.id);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
getSelectedMessageId(): string | null { return this.selectedId; }
|
|
75
|
+
isFollowingTail(): boolean { return this.followTail; }
|
|
76
|
+
|
|
77
|
+
/** Full prompt text of the selected message, for the focused rail's copy key. */
|
|
78
|
+
selectedMessageText(): string | null {
|
|
79
|
+
return this.messages.find((message) => message.id === this.selectedId)?.text ?? null;
|
|
80
|
+
}
|
|
81
|
+
isExpanded(messageId: string): boolean { return this.detailId === messageId; }
|
|
82
|
+
isDetailOpen(): boolean { return this.detailId !== null; }
|
|
83
|
+
|
|
84
|
+
updateMessages(messages: UserMessage[]): void {
|
|
85
|
+
const previousId = this.selectedId;
|
|
86
|
+
const previousStartId = this.viewportStartId;
|
|
87
|
+
this.messages = messages;
|
|
88
|
+
for (const message of messages) {
|
|
89
|
+
if (!this.known.has(message.id)) {
|
|
90
|
+
this.known.add(message.id);
|
|
91
|
+
this.arrivedAt.set(message.id, Date.now());
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const ids = new Set(messages.map((message) => message.id));
|
|
95
|
+
if (this.detailId && !ids.has(this.detailId)) { this.detailId = null; this.detailScroll = 0; }
|
|
96
|
+
this.viewportStartId = previousStartId && ids.has(previousStartId) ? previousStartId : null;
|
|
97
|
+
|
|
98
|
+
if (this.followTail || !previousId || !ids.has(previousId)) {
|
|
99
|
+
this.selectedId = messages.at(-1)?.id ?? null;
|
|
100
|
+
this.followTail = true;
|
|
101
|
+
this.viewportStartId = null;
|
|
102
|
+
} else {
|
|
103
|
+
this.selectedId = previousId;
|
|
104
|
+
}
|
|
105
|
+
this.options.requestRefresh();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
handleInput(data: string): void {
|
|
109
|
+
if (this.detailId) return this.handleDetailInput(data);
|
|
110
|
+
const current = this.selectedIndex();
|
|
111
|
+
let target: number | null = null;
|
|
112
|
+
if (matchesKey(data, "up")) target = Math.max(0, current - 1);
|
|
113
|
+
else if (matchesKey(data, "down")) target = Math.min(this.messages.length - 1, current + 1);
|
|
114
|
+
else if (matchesKey(data, "pageUp")) target = Math.max(0, current - 10);
|
|
115
|
+
else if (matchesKey(data, "pageDown")) target = Math.min(this.messages.length - 1, current + 10);
|
|
116
|
+
else if (matchesKey(data, "home")) target = 0;
|
|
117
|
+
else if (matchesKey(data, "end")) target = Math.max(0, this.messages.length - 1);
|
|
118
|
+
else if (matchesKey(data, "return") || matchesKey(data, "enter") || data === " ") {
|
|
119
|
+
if (this.selectedId) { this.detailId = this.selectedId; this.detailScroll = 0; this.options.requestRefresh(); }
|
|
120
|
+
return;
|
|
121
|
+
} else return;
|
|
122
|
+
|
|
123
|
+
if (target < 0 || !this.messages[target]) return;
|
|
124
|
+
this.selectedId = this.messages[target].id;
|
|
125
|
+
this.followTail = target === this.messages.length - 1;
|
|
126
|
+
if (this.followTail) this.viewportStartId = null;
|
|
127
|
+
this.options.requestRefresh();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
closeDetail(): void {
|
|
131
|
+
if (!this.detailId) return;
|
|
132
|
+
this.detailId = null;
|
|
133
|
+
this.detailScroll = 0;
|
|
134
|
+
this.options.requestRefresh();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** True while a pulsing dot or a settle sweep needs the animation tick. */
|
|
138
|
+
needsAnim(now: number): boolean {
|
|
139
|
+
for (const message of this.messages) {
|
|
140
|
+
if (this.options.isPending(message.id)) return true;
|
|
141
|
+
}
|
|
142
|
+
for (const [id, at] of this.landedAt) {
|
|
143
|
+
if (isSettling(now, at) || now - at < GLOW_MS) return true;
|
|
144
|
+
if (now - at > 5000) this.landedAt.delete(id);
|
|
145
|
+
}
|
|
146
|
+
for (const [id, at] of this.arrivedAt) {
|
|
147
|
+
if (isArriving(now, at)) return true;
|
|
148
|
+
if (now - at > 5000) this.arrivedAt.delete(id);
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Renders exactly `rows` lines: heading, optional setup hint, viewport, hint strip. */
|
|
154
|
+
renderSection(rows: number, focused: boolean, palette: Palette, now: number): string[] {
|
|
155
|
+
const total = this.messages.length;
|
|
156
|
+
const detailIndex = this.detailId ? this.indexForId(this.detailId) : -1;
|
|
157
|
+
const position = detailIndex >= 0 ? detailIndex + 1 : this.selectedIndex() + 1;
|
|
158
|
+
const heading = this.headingRow(
|
|
159
|
+
palette,
|
|
160
|
+
this.detailId ? "MESSAGE" : "MESSAGES",
|
|
161
|
+
total > 0 || this.detailId ? `${position}/${total}` : `0/${total}`,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
if (this.detailId) {
|
|
165
|
+
const detailRows = Math.max(1, rows - 2);
|
|
166
|
+
const detail = this.renderDetail(this.detailId, detailRows, palette).slice(0, detailRows);
|
|
167
|
+
// Only offer scrolling when there is something below the fold.
|
|
168
|
+
const message = this.messages[this.indexForId(this.detailId)];
|
|
169
|
+
const scrollable = message !== undefined
|
|
170
|
+
&& detailCapacity(detailRows, this.wrappedDetail(message).length).hasIndicator;
|
|
171
|
+
return [heading, ...detail, this.hintRow(palette, scrollable ? "[Esc] back [↑↓] scroll" : "[Esc] back")].slice(0, rows);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// The setup hint replaces the spacer row under the heading; a message
|
|
175
|
+
// slot is never sacrificed for it. An air row opens the section so every
|
|
176
|
+
// ghost header sits one row below the content above it.
|
|
177
|
+
const showSetupHint = !this.options.summariesConfigured() && rows >= 5;
|
|
178
|
+
const spacer = rows >= (showSetupHint ? 6 : 5);
|
|
179
|
+
const consumed = 2 + (showSetupHint ? 1 : 0) + (spacer ? 1 : 0) + 1;
|
|
180
|
+
const viewportRows = Math.max(2, rows - consumed);
|
|
181
|
+
const sections = [
|
|
182
|
+
railRow(palette, "", palette.bgDeep),
|
|
183
|
+
heading,
|
|
184
|
+
...(showSetupHint ? [railRow(palette, `${palette.ghostBright}${SETUP_HINT}${RST}`, palette.bgDeep)] : []),
|
|
185
|
+
...(spacer ? [railRow(palette, "", palette.bgDeep)] : []),
|
|
186
|
+
...this.renderViewport(viewportRows, focused, palette, now),
|
|
187
|
+
this.hintRow(palette, focused ? "[↑↓] select [↵] open [c] copy" : "[Ctrl+Shift+H] focus"),
|
|
188
|
+
];
|
|
189
|
+
while (sections.length < rows) sections.push(railRow(palette, "", palette.bgDeep));
|
|
190
|
+
return sections.slice(0, rows);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// --- list rendering ------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
private headingRow(palette: Palette, label: string, right: string): string {
|
|
196
|
+
return ghostHeader(palette, label, palette.bgDeep, right);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private hintRow(palette: Palette, text: string): string {
|
|
200
|
+
return railRow(palette, `${palette.ghost}${text}${RST}`, palette.bgPanel);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Always returns exactly `rows` lines: ellipsis rows, message pairs, padding. */
|
|
204
|
+
private renderViewport(rows: number, focused: boolean, palette: Palette, now: number): string[] {
|
|
205
|
+
if (this.messages.length === 0) {
|
|
206
|
+
const empty = [railRow(palette, `${palette.ghost}No messages yet${RST}`, palette.bgDeep)];
|
|
207
|
+
while (empty.length < rows) empty.push(railRow(palette, "", palette.bgDeep));
|
|
208
|
+
return empty;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const window = this.resolveWindow(rows);
|
|
212
|
+
const pairs: string[] = [];
|
|
213
|
+
for (let index = window.start; index < window.end; index++) {
|
|
214
|
+
pairs.push(...this.messageRows(index, palette, now));
|
|
215
|
+
}
|
|
216
|
+
// A message pair outranks an ellipsis row when both cannot fit.
|
|
217
|
+
const topCount = window.start > 0 && 1 + pairs.length <= rows;
|
|
218
|
+
const bottomCount = window.end < this.messages.length && (topCount ? 2 : 1) + pairs.length <= rows;
|
|
219
|
+
const lines = [
|
|
220
|
+
...(topCount ? [this.countRow(palette, window.start, "earlier")] : []),
|
|
221
|
+
...pairs,
|
|
222
|
+
...(bottomCount ? [this.countRow(palette, this.messages.length - window.end, "later")] : []),
|
|
223
|
+
];
|
|
224
|
+
// A history shorter than the viewport hugs the hint strip: a message
|
|
225
|
+
// stream reads bottom-anchored, and the spare air sits under the
|
|
226
|
+
// heading instead of opening a hole above the hint.
|
|
227
|
+
if (!topCount && !bottomCount) {
|
|
228
|
+
while (lines.length < rows) lines.unshift(railRow(palette, "", palette.bgDeep));
|
|
229
|
+
} else {
|
|
230
|
+
while (lines.length < rows) lines.push(railRow(palette, "", palette.bgDeep));
|
|
231
|
+
}
|
|
232
|
+
return lines.slice(0, rows);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private countRow(palette: Palette, count: number, direction: "earlier" | "later"): string {
|
|
236
|
+
return railRow(palette, `${palette.ghost}… ${count} ${direction}${RST}`, palette.bgDeep);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private messageRows(index: number, palette: Palette, now: number): string[] {
|
|
240
|
+
const message = this.messages[index]!;
|
|
241
|
+
return slotRows(message, index, this.messages.length, message.id === this.selectedId, palette, now, {
|
|
242
|
+
getSummary: this.options.getSummary,
|
|
243
|
+
hasSummary: this.options.hasSummary,
|
|
244
|
+
isPending: this.options.isPending,
|
|
245
|
+
}, {
|
|
246
|
+
seenSummary: this.seenSummary,
|
|
247
|
+
landedAt: this.landedAt,
|
|
248
|
+
arrivedAt: this.arrivedAt,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Fits the message window into `rows`, then shrinks it again to make room
|
|
254
|
+
* for the ellipsis rows that count what stays hidden. Two passes always
|
|
255
|
+
* converge: each pass only removes slots, so the hidden counts can grow
|
|
256
|
+
* but the indicator count cannot exceed two.
|
|
257
|
+
*/
|
|
258
|
+
private resolveWindow(rows: number): { start: number; end: number } {
|
|
259
|
+
const total = this.messages.length;
|
|
260
|
+
const selected = this.selectedIndex();
|
|
261
|
+
let slots = Math.floor(rows / ROWS_PER_MESSAGE);
|
|
262
|
+
let window = this.windowFor(slots, selected);
|
|
263
|
+
for (let pass = 0; pass < 3; pass++) {
|
|
264
|
+
const indicators = (window.start > 0 ? 1 : 0) + (window.end < total ? 1 : 0);
|
|
265
|
+
const next = Math.floor((rows - indicators) / ROWS_PER_MESSAGE);
|
|
266
|
+
if (next === slots) break;
|
|
267
|
+
slots = next;
|
|
268
|
+
window = this.windowFor(slots, selected);
|
|
269
|
+
}
|
|
270
|
+
if (slots === 0 && Math.floor(rows / ROWS_PER_MESSAGE) >= 1) {
|
|
271
|
+
// One visible message beats a second ellipsis row.
|
|
272
|
+
window = this.windowFor(1, selected);
|
|
273
|
+
}
|
|
274
|
+
this.viewportStartId = this.messages[window.start]?.id ?? null;
|
|
275
|
+
return window;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private windowFor(slots: number, selected: number): { start: number; end: number } {
|
|
279
|
+
if (slots >= this.messages.length) return { start: 0, end: this.messages.length };
|
|
280
|
+
let start = this.followTail
|
|
281
|
+
? this.messages.length - slots
|
|
282
|
+
: Math.min(this.startForSelection(slots, selected), this.messages.length - slots);
|
|
283
|
+
// Browsing within the current window keeps the window stable instead of
|
|
284
|
+
// re-pinning the selection to the top on every step.
|
|
285
|
+
const preserved = this.indexForId(this.viewportStartId);
|
|
286
|
+
if (!this.followTail && preserved >= 0 && selected >= preserved && selected - preserved < slots) {
|
|
287
|
+
start = preserved;
|
|
288
|
+
}
|
|
289
|
+
// A grow or a compaction can leave the preserved anchor beyond the new
|
|
290
|
+
// list; an unclamped start would run the window past the last message.
|
|
291
|
+
start = Math.max(0, Math.min(start, this.messages.length - slots));
|
|
292
|
+
return { start, end: start + slots };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private startForSelection(slots: number, selected: number): number {
|
|
296
|
+
return Math.max(0, Math.min(selected, this.messages.length - slots));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// --- detail view ---------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
private renderDetail(messageId: string, rows: number, palette: Palette): string[] {
|
|
302
|
+
const message = this.messages[this.indexForId(messageId)];
|
|
303
|
+
if (!message) {
|
|
304
|
+
// The message left the branch under an open detail. Fill the section:
|
|
305
|
+
// returning a single row would surface as a fatal height mismatch.
|
|
306
|
+
const lines = [railRow(palette, `${palette.ghost}Message unavailable${RST}`, palette.bgDeep)];
|
|
307
|
+
while (lines.length < rows) lines.push(railRow(palette, "", palette.bgDeep));
|
|
308
|
+
return lines;
|
|
309
|
+
}
|
|
310
|
+
this.lastDetailRows = rows;
|
|
311
|
+
const wrapped = this.wrappedDetail(message);
|
|
312
|
+
const size = `${palette.ghostBright}${formatCount(message.text.length)} chars · ${wrapped.length} lines${RST}`;
|
|
313
|
+
const head = `#${message.index} ${formatTime(message.timestamp)}`;
|
|
314
|
+
const gap = Math.max(1, RAIL_CONTENT - visibleWidth(head) - visibleWidth(`${formatCount(message.text.length)} chars · ${wrapped.length} lines`));
|
|
315
|
+
const header = railRow(palette, `${palette.ghostBright}${head}${RST}${" ".repeat(gap)}${size}`, palette.bgPanel);
|
|
316
|
+
const { textCapacity, hasIndicator, maxScroll } = detailCapacity(rows, wrapped.length);
|
|
317
|
+
this.detailScroll = Math.max(0, Math.min(this.detailScroll, maxScroll));
|
|
318
|
+
const visible = wrapped.slice(this.detailScroll, this.detailScroll + textCapacity);
|
|
319
|
+
const lines = [header, ...visible.map((line) => railRow(palette, `${palette.textMid}${line}${RST}`, palette.bgPanel))];
|
|
320
|
+
if (hasIndicator) {
|
|
321
|
+
const position = visible.length > 0
|
|
322
|
+
? `${this.detailScroll + 1}-${this.detailScroll + visible.length} of ${wrapped.length}`
|
|
323
|
+
: `line ${Math.min(this.detailScroll + 1, wrapped.length)} of ${wrapped.length}`;
|
|
324
|
+
lines.push(railRow(palette, `${palette.ghost}${position} · ↑↓ scroll${RST}`, palette.bgPanel));
|
|
325
|
+
}
|
|
326
|
+
while (lines.length < rows) lines.push(railRow(palette, "", palette.bgPanel));
|
|
327
|
+
return lines.slice(0, rows);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Wrapping is O(message length); a detail stays open across many keystrokes and renders. */
|
|
331
|
+
private wrappedDetail(message: UserMessage): string[] {
|
|
332
|
+
if (this.wrapCache?.id !== message.id) {
|
|
333
|
+
this.wrapCache = { id: message.id, lines: wrapText(message.text, RAIL_CONTENT) };
|
|
334
|
+
}
|
|
335
|
+
return this.wrapCache.lines;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private handleDetailInput(data: string): void {
|
|
339
|
+
const message = this.messages[this.indexForId(this.detailId)];
|
|
340
|
+
if (!message) { this.detailId = null; return; }
|
|
341
|
+
const wrapped = this.wrappedDetail(message);
|
|
342
|
+
const { maxScroll } = detailCapacity(this.lastDetailRows, wrapped.length);
|
|
343
|
+
if (matchesKey(data, "up") || matchesKey(data, "pageUp")) {
|
|
344
|
+
this.detailScroll = Math.max(0, this.detailScroll - (matchesKey(data, "pageUp") ? 10 : 1));
|
|
345
|
+
} else if (matchesKey(data, "down") || matchesKey(data, "pageDown")) {
|
|
346
|
+
this.detailScroll = Math.min(maxScroll, this.detailScroll + (matchesKey(data, "pageDown") ? 10 : 1));
|
|
347
|
+
} else return;
|
|
348
|
+
this.options.requestRefresh();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// --- state helpers -------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
private selectedIndex(): number {
|
|
354
|
+
if (this.messages.length === 0) return -1;
|
|
355
|
+
const index = this.indexForId(this.selectedId);
|
|
356
|
+
return index >= 0 ? index : this.messages.length - 1;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private indexForId(id: string | null): number {
|
|
360
|
+
return id ? this.messages.findIndex((message) => message.id === id) : -1;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|