pi-message-sidebar 1.5.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.
@@ -2,36 +2,28 @@ import {
2
2
  copyToClipboard,
3
3
  type ExtensionContext,
4
4
  type ReadonlyFooterDataProvider,
5
+ type Theme,
5
6
  } from "@earendil-works/pi-coding-agent";
6
7
  import { basename } from "node:path";
7
8
  import type { Component, TUI } from "@earendil-works/pi-tui";
8
- import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
+ import { matchesKey } from "@earendil-works/pi-tui";
10
+ import { ANIM_TICK_MS, EasedMeter, isVictory } from "./anim.ts";
9
11
  import type { CmuxContext } from "./cmux.ts";
10
12
  import { SIDEBAR_WIDTH } from "./constants.ts";
13
+ import type { FileEdit } from "./files.ts";
14
+ import { flagRow } from "./flag.ts";
11
15
  import { readSessionGoal } from "./goal.ts";
12
16
  import { assertLinesFit } from "./layout.ts";
13
- import { renderStatusDock } from "./status-dock.ts";
14
- import {
15
- BG,
16
- BG_HDR,
17
- BG_SEL,
18
- BOLD,
19
- DIM,
20
- FG_ACC,
21
- FG_BRIGHT,
22
- FG_DIM,
23
- FG_EXP,
24
- FG_FAINT,
25
- FG_MID,
26
- FG_NORM,
27
- FG_TIME,
28
- RST,
29
- fillRow,
30
- formatTime,
31
- wrapText,
32
- } from "./style.ts";
17
+ import { MessagePanel } from "./messages.ts";
18
+ import { resolvePalette } from "./palette.ts";
19
+ import { renderGoalSection } from "./goal-card.ts";
20
+ import { renderRuntimeSection, renderSessionSection } from "./sections.ts";
21
+ import { computeUsage } from "./status-dock.ts";
22
+ import { RST, fillRow } from "./style.ts";
23
+
24
+ import type { UserMessage } from "./types.ts";
33
25
 
34
- export type UserMessage = { id: string; text: string; index: number; timestamp: string };
26
+ export type { UserMessage };
35
27
 
36
28
  type SidebarOptions = {
37
29
  tui: TUI;
@@ -39,30 +31,105 @@ type SidebarOptions = {
39
31
  getFooterData: () => ReadonlyFooterDataProvider | null;
40
32
  getThinkingLevel: () => string;
41
33
  getCmuxContext: () => CmuxContext | null;
34
+ getTheme: () => Theme | null;
35
+ getSummary: (messageId: string, text: string) => string;
36
+ hasSummary: (messageId: string) => boolean;
37
+ isPending: (messageId: string) => boolean;
38
+ summariesConfigured: () => boolean;
39
+ getEditedFiles: () => FileEdit[];
40
+ getGitStatus: (path: string) => string | null;
42
41
  messages: UserMessage[];
43
42
  };
44
43
 
44
+ type Layout = { goal: number; session: number; runtime: number; messages: number };
45
+
46
+ /** Rows a section cannot render without losing content it is required to show.
47
+ * Section headers embed their own rules, so no separator rows are budgeted. */
48
+ const MANDATORY = {
49
+ /** the flag crown row. */
50
+ crown: 1,
51
+ /** status chip + two title rows + budget meter; the no-goal state is one ghost row. */
52
+ goal: (hasGoal: boolean) => (hasGoal ? 4 : 1),
53
+ /** separator + ghost header + surface/workspace + cwd. */
54
+ session: 4,
55
+ /** air + ghost header + one two-row message + hint strip. */
56
+ messages: 5,
57
+ /** separator + ghost header + model route + ctx meter. */
58
+ runtime: 4,
59
+ } as const;
60
+
61
+ /** Smallest terminal that can hold every mandatory row; below it the rail shows a notice. */
62
+ export function minimumHeight(hasGoal: boolean): number {
63
+ return MANDATORY.crown + MANDATORY.goal(hasGoal) + MANDATORY.session + MANDATORY.messages + MANDATORY.runtime;
64
+ }
65
+
66
+ /**
67
+ * Mandatory rows first, then optional rows in priority order, then every
68
+ * remaining row to the message viewport. Returns null when the mandatory
69
+ * budget does not fit, so the caller renders a notice instead of silently
70
+ * slicing content away.
71
+ */
72
+ function allocate(height: number, hasGoal: boolean, fileCount: number): Layout | null {
73
+ if (height < minimumHeight(hasGoal)) return null;
74
+
75
+ let goal = MANDATORY.goal(hasGoal);
76
+ let session = MANDATORY.session;
77
+ let runtime = MANDATORY.runtime;
78
+ let spare = height - minimumHeight(hasGoal);
79
+
80
+ const grow = (rows: number, take: (granted: number) => void) => {
81
+ const granted = Math.min(rows, spare);
82
+ if (granted <= 0) return;
83
+ take(granted);
84
+ spare -= granted;
85
+ };
86
+ if (hasGoal) grow(5, (granted) => { goal += granted; }); // card air: pad, third title line, meter pad
87
+ grow(1, (granted) => { session += granted; }); // session id row
88
+ if (fileCount > 0) {
89
+ // FILES lives inside the session block: air, header, plus file rows. It
90
+ // needs the air, the header and one row to be worth anything, so a
91
+ // cramped rail leaves it out entirely.
92
+ const granted = Math.min(2 + fileCount, spare);
93
+ if (granted >= 3) { session += granted; spare -= granted; }
94
+ }
95
+ grow(1, (granted) => { runtime += granted; }); // trailing breath under the meter
96
+
97
+ return { goal, session, runtime, messages: MANDATORY.messages + spare };
98
+ }
99
+
45
100
  export class SidebarComponent implements Component {
46
101
  private focused = false;
47
- private selectedId: string | null;
48
- private readonly expandedIds = new Set<string>();
49
- private followTail = true;
50
- private messages: UserMessage[];
102
+ private panel: MessagePanel;
51
103
  private version = 0;
52
104
  private restoreFocus: Component | null = null;
53
105
  private cachedSignature = "";
54
106
  private cachedLines: string[] = [];
55
- private viewportStartId: string | null = null;
107
+ private animTimer: ReturnType<typeof setInterval> | null = null;
108
+ private readonly goalMeter = new EasedMeter();
109
+ private readonly ctxMeter = new EasedMeter();
110
+ private goalTarget: number | null = null;
111
+ private ctxTarget: number | null = null;
112
+ private previousGoalStatus: string | null = null;
113
+ private victoryAt: number | null = null;
56
114
 
57
115
  constructor(private readonly options: SidebarOptions) {
58
- this.messages = options.messages;
59
- this.selectedId = this.messages.at(-1)?.id ?? null;
116
+ this.panel = new MessagePanel(
117
+ {
118
+ getSummary: options.getSummary,
119
+ hasSummary: options.hasSummary,
120
+ isPending: options.isPending,
121
+ summariesConfigured: options.summariesConfigured,
122
+ requestRefresh: () => this.refresh(),
123
+ },
124
+ options.messages,
125
+ );
60
126
  }
61
127
 
62
128
  isFocused(): boolean { return this.focused; }
63
- getSelectedMessageId(): string | null { return this.selectedId; }
64
- isFollowingTail(): boolean { return this.followTail; }
65
- isExpanded(messageId: string): boolean { return this.expandedIds.has(messageId); }
129
+ getSelectedMessageId(): string | null { return this.panel.getSelectedMessageId(); }
130
+ isFollowingTail(): boolean { return this.panel.isFollowingTail(); }
131
+ isExpanded(messageId: string): boolean { return this.panel.isExpanded(messageId); }
132
+ isDetailOpen(): boolean { return this.panel.isDetailOpen(); }
66
133
 
67
134
  setFocused(focused: boolean): void {
68
135
  if (this.focused === focused) return;
@@ -72,6 +139,7 @@ export class SidebarComponent implements Component {
72
139
  this.options.tui.setFocus(this);
73
140
  } else {
74
141
  this.focused = false;
142
+ this.panel.closeDetail();
75
143
  if ((this.options.tui as any).getFocusedComponent?.() === this) this.options.tui.setFocus(this.restoreFocus);
76
144
  this.restoreFocus = null;
77
145
  }
@@ -79,52 +147,58 @@ export class SidebarComponent implements Component {
79
147
  }
80
148
 
81
149
  updateMessages(messages: UserMessage[]): void {
82
- const previousId = this.selectedId;
83
- const previousStartId = this.viewportStartId;
84
- this.messages = messages;
85
- const ids = new Set(messages.map((message) => message.id));
86
- for (const id of this.expandedIds) if (!ids.has(id)) this.expandedIds.delete(id);
87
- this.viewportStartId = previousStartId && ids.has(previousStartId) ? previousStartId : null;
88
-
89
- if (this.followTail || !previousId || !ids.has(previousId)) {
90
- this.selectedId = messages.at(-1)?.id ?? null;
91
- this.followTail = true;
92
- this.viewportStartId = null;
93
- } else {
94
- this.selectedId = previousId;
95
- }
96
- this.refresh();
150
+ this.panel.updateMessages(messages);
97
151
  }
98
152
 
99
153
  refresh(): void {
100
154
  this.version++;
101
155
  this.options.tui.requestRender();
156
+ this.ensureAnim();
157
+ }
158
+
159
+ /** Stops the animation tick; the extension calls this on session shutdown. */
160
+ stopAnimations(): void {
161
+ if (this.animTimer) clearInterval(this.animTimer);
162
+ this.animTimer = null;
163
+ }
164
+
165
+ /** The tick exists only while a dot pulses or a summary settles. */
166
+ /** The rail is alive while a dot pulses, a glow decays, a meter eases, or
167
+ * an active goal breathes: the ambient breath is the session's heartbeat,
168
+ * and it stops the moment the goal completes or clears. */
169
+ private needsAnim(now: number): boolean {
170
+ if (this.panel.needsAnim(now)) return true;
171
+ if (this.goalMeter.moving(this.goalTarget) || this.ctxMeter.moving(this.ctxTarget)) return true;
172
+ if (this.victoryAt !== null && isVictory(now, this.victoryAt)) return true;
173
+ return readSessionGoal(this.options.ctx)?.status === "active";
174
+ }
175
+
176
+ private ensureAnim(): void {
177
+ const live = this.needsAnim(Date.now());
178
+ if (live && !this.animTimer) {
179
+ this.animTimer = setInterval(() => {
180
+ if (!this.panel.needsAnim(Date.now())) {
181
+ this.stopAnimations();
182
+ this.refresh();
183
+ return;
184
+ }
185
+ this.version++;
186
+ this.options.tui.requestRender();
187
+ }, ANIM_TICK_MS);
188
+ (this.animTimer as { unref?: () => void }).unref?.();
189
+ }
102
190
  }
103
191
 
104
192
  handleInput(data: string): void {
105
- if (matchesKey(data, "escape")) return this.setFocused(false);
106
- if (matchesKey(data, "c")) { void this.copySessionPath(); return; }
107
- const current = this.selectedIndex();
108
- let target: number | null = null;
109
- if (matchesKey(data, "up")) target = Math.max(0, current - 1);
110
- else if (matchesKey(data, "down")) target = Math.min(this.messages.length - 1, current + 1);
111
- else if (matchesKey(data, "pageUp")) target = Math.max(0, current - 10);
112
- else if (matchesKey(data, "pageDown")) target = Math.min(this.messages.length - 1, current + 10);
113
- else if (matchesKey(data, "home")) target = 0;
114
- else if (matchesKey(data, "end")) target = Math.max(0, this.messages.length - 1);
115
- else if (matchesKey(data, "return") || matchesKey(data, "enter") || data === " ") {
116
- if (!this.selectedId) return;
117
- if (this.expandedIds.has(this.selectedId)) this.expandedIds.delete(this.selectedId);
118
- else this.expandedIds.add(this.selectedId);
119
- this.refresh();
193
+ if (matchesKey(data, "escape")) {
194
+ if (this.panel.isDetailOpen()) { this.panel.closeDetail(); return; }
195
+ return this.setFocused(false);
196
+ }
197
+ if (!this.panel.isDetailOpen() && matchesKey(data, "c")) {
198
+ void this.copyRailTarget();
120
199
  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.refresh();
200
+ }
201
+ this.panel.handleInput(data);
128
202
  }
129
203
 
130
204
  render(width: number): string[] {
@@ -133,27 +207,19 @@ export class SidebarComponent implements Component {
133
207
  const signature = this.signature(safeWidth, targetHeight);
134
208
  if (signature === this.cachedSignature) return this.cachedLines;
135
209
 
136
- const fullHeader = this.renderHeader(safeWidth);
137
- const headerRows = this.messages.length > 0 && targetHeight === 1 ? 0 : targetHeight <= 5 ? 1 : 2;
138
- const header = fullHeader.slice(0, Math.min(headerRows, targetHeight));
139
- const available = Math.max(0, targetHeight - header.length);
140
- // At tiny heights, keep one chronological message row before allocating dock chrome.
141
- const messageReserve = this.messages.length > 0 && available > 0 ? 1 : 0;
142
- const dock = renderStatusDock(
143
- safeWidth,
144
- this.options.ctx,
145
- this.options.getFooterData(),
146
- this.options.getThinkingLevel(),
147
- this.focused,
148
- this.options.getCmuxContext,
149
- Math.min(4, Math.max(0, available - messageReserve)),
150
- );
151
- const bodyHeight = Math.max(0, targetHeight - header.length - dock.length);
152
- const result = [...header, ...this.renderBody(safeWidth, bodyHeight), ...dock];
210
+ const hasGoal = readSessionGoal(this.options.ctx) !== null;
211
+ const files = this.options.getEditedFiles();
212
+ const layout = safeWidth < SIDEBAR_WIDTH ? null : allocate(targetHeight, hasGoal, files.length);
213
+ const result = layout
214
+ ? this.renderRail(safeWidth, targetHeight, layout, files)
215
+ : this.renderNotice(safeWidth, targetHeight, hasGoal);
153
216
  assertLinesFit(result, safeWidth, "sidebar");
154
- if (result.length !== targetHeight) throw new Error(`sidebar height mismatch (${result.length} != ${targetHeight})`);
217
+ if (result.length !== targetHeight) {
218
+ throw new Error(`sidebar height mismatch (${result.length} != ${targetHeight})`);
219
+ }
155
220
  this.cachedSignature = signature;
156
221
  this.cachedLines = result;
222
+ this.ensureAnim();
157
223
  return result;
158
224
  }
159
225
 
@@ -162,111 +228,52 @@ export class SidebarComponent implements Component {
162
228
  this.cachedLines = [];
163
229
  }
164
230
 
165
- private renderHeader(width: number): string[] {
166
- const position = this.selectedIndex() + 1;
167
- const count = this.messages.length;
168
- const location = count > 0 ? `${position}/${count}` : "0";
169
- const selected = this.messages[this.selectedIndex()];
170
- const detail = selected
171
- ? `${this.focused ? "Selected" : "Current"} #${selected.index} ${formatTime(selected.timestamp)}`
172
- : "User prompts";
173
- return [
174
- fillRow(` ${BOLD}${FG_BRIGHT}Messages${RST} ${FG_FAINT}${location}${RST}`, width, BG_HDR),
175
- fillRow(` ${FG_DIM}${detail}${RST}`, width, BG),
176
- ];
177
- }
178
-
179
- private renderBody(width: number, height: number): string[] {
180
- if (height === 0) return [];
181
- if (this.messages.length === 0) return this.padBottom([fillRow(` ${FG_DIM}No messages yet${RST}`, width, BG)], height, width);
182
-
183
- const selected = this.selectedIndex();
184
- let start = this.followTail ? this.tailStart(width, height) : this.startForSelection(width, height, selected);
185
- const preserved = this.indexForId(this.viewportStartId);
186
- if (!this.followTail && preserved >= 0 && selected >= preserved && this.rangeFitsSelection(width, height, preserved, selected)) start = preserved;
231
+ // --- rail ---------------------------------------------------------------
187
232
 
233
+ private renderRail(_width: number, height: number, layout: Layout, files: FileEdit[]): string[] {
234
+ const ctx = this.options.ctx;
235
+ const palette = resolvePalette(this.options.getTheme());
236
+ const now = Date.now();
188
237
  const lines: string[] = [];
189
- for (let index = start; index < this.messages.length && lines.length < height; index++) {
190
- const remaining = height - lines.length;
191
- lines.push(...this.renderMessage(index, width, remaining));
192
- }
193
- this.viewportStartId = this.messages[start]?.id ?? null;
194
- return this.padBottom(lines.slice(0, height), height, width);
195
- }
196
-
197
- private tailStart(width: number, height: number): number {
198
- let start = this.messages.length - 1;
199
- let rows = this.messageRowCount(start, width, height);
200
- while (start > 0) {
201
- const next = this.messageRowCount(start - 1, width, height);
202
- if (rows + next > height) break;
203
- rows += next;
204
- start--;
205
- }
206
- return start;
207
- }
238
+ const goal = readSessionGoal(ctx);
239
+ if (goal?.status === "complete" && this.previousGoalStatus !== "complete") this.victoryAt = now;
240
+ this.previousGoalStatus = goal?.status ?? null;
241
+ const live = this.needsAnim(now);
242
+ lines.push(flagRow(palette, SIDEBAR_WIDTH - 1, now, live));
243
+ this.goalTarget = goal?.tokenBudget ? Math.min(1, goal.usage.tokensUsed / goal.tokenBudget) : null;
244
+ this.goalMeter.tick(this.goalTarget);
245
+ const usage = computeUsage(ctx);
246
+ this.ctxTarget = usage.contextPercent === null ? null : Math.min(1, usage.contextPercent / 100);
247
+ this.ctxMeter.tick(this.ctxTarget);
248
+ const goalShimmer = this.goalMeter.moving(this.goalTarget) ? Math.floor(now / 120) % 12 : null;
249
+ const ctxShimmer = this.ctxMeter.moving(this.ctxTarget) ? Math.floor(now / 120) % 10 : null;
208
250
 
209
- private startForSelection(width: number, height: number, selected: number): number {
210
- let start = selected;
211
- let rows = this.messageRowCount(selected, width, height);
212
- while (start > 0) {
213
- const next = this.messageRowCount(start - 1, width, height);
214
- if (rows + next > height) break;
215
- rows += next;
216
- start--;
217
- }
218
- return start;
219
- }
220
-
221
- private rangeFitsSelection(width: number, height: number, start: number, selected: number): boolean {
222
- let rows = 0;
223
- for (let index = start; index <= selected; index++) {
224
- rows += this.messageRowCount(index, width, height);
225
- if (rows > height) return false;
226
- }
227
- return true;
228
- }
229
-
230
- private messageRowCount(index: number, width: number, maxRows: number): number {
231
- return this.renderMessage(index, width, maxRows).length;
232
- }
233
-
234
- private padBottom(lines: string[], height: number, width: number): string[] {
235
- const padding = Array.from({ length: Math.max(0, height - lines.length) }, () => fillRow(" ", width, BG));
236
- return [...lines, ...padding];
237
- }
238
-
239
- private renderMessage(index: number, width: number, maxRows = Number.MAX_SAFE_INTEGER): string[] {
240
- const message = this.messages[index]!;
241
- const selected = message.id === this.selectedId;
242
- const background = selected ? BG_SEL : BG;
243
- const arrow = selected && this.focused ? `${FG_ACC}›${RST}` : " ";
244
- if (!this.expandedIds.has(message.id) || maxRows <= 1) {
245
- const prefix = ` ${arrow} `;
246
- const text = truncateToWidth(message.text.replace(/\s+/g, " "), Math.max(0, width - visibleWidth(prefix)), "…");
247
- return [fillRow(`${prefix}${selected ? FG_BRIGHT : FG_NORM}${text}${RST}`, width, background)];
248
- }
249
-
250
- const number = `${FG_FAINT}#${message.index}${RST}`;
251
- const time = `${FG_TIME}${formatTime(message.timestamp)}${RST}`;
252
- const lines = [fillRow(` ${arrow} ${number} ${time}`, width, background)];
253
- const wrapped = wrapText(message.text, Math.max(1, width - 4));
254
- const contentRows = Math.max(0, maxRows - 1);
255
- for (const line of wrapped.slice(0, contentRows)) lines.push(fillRow(` ${FG_EXP}${line}${RST}`, width, background));
256
- if (wrapped.length > contentRows && lines.length === maxRows) {
257
- lines[lines.length - 1] = fillRow(` ${FG_DIM}${DIM}…+${wrapped.length - contentRows + 1} lines${RST}`, width, background);
258
- }
251
+ lines.push(...renderGoalSection(goal, layout.goal, palette, now, this.goalMeter, undefined, goalShimmer, this.victoryAt));
252
+ lines.push(...renderSessionSection(
253
+ ctx, this.options.getFooterData(), this.options.getCmuxContext(),
254
+ layout.session, palette, files, this.options.getGitStatus,
255
+ ));
256
+ lines.push(...this.panel.renderSection(layout.messages, this.focused, palette, now));
257
+ lines.push(...renderRuntimeSection(
258
+ ctx, this.options.getFooterData(), this.options.getThinkingLevel(),
259
+ layout.runtime, palette, undefined, this.ctxMeter.get(), ctxShimmer,
260
+ ));
259
261
  return lines;
260
262
  }
261
263
 
262
- private selectedIndex(): number {
263
- if (this.messages.length === 0) return -1;
264
- const index = this.indexForId(this.selectedId);
265
- return index >= 0 ? index : this.messages.length - 1;
266
- }
267
-
268
- private indexForId(id: string | null): number {
269
- return id ? this.messages.findIndex((message) => message.id === id) : -1;
264
+ /** Bounded to the width actually offered, so a narrow slot never overflows its column. */
265
+ private renderNotice(width: number, height: number, hasGoal: boolean): string[] {
266
+ const palette = resolvePalette(this.options.getTheme());
267
+ const row = (text: string) =>
268
+ width <= 1
269
+ ? fillRow("", width, palette.bgDeep)
270
+ : `${palette.edge}│${RST}${fillRow(` ${text}`, width - 1, palette.bgDeep)}`;
271
+ const lines: string[] = [];
272
+ const push = (text: string) => { if (lines.length < height) lines.push(row(text)); };
273
+ push(`${palette.bold(`${palette.textNew}Sidebar${RST}`)}`);
274
+ push(`${palette.ghost}needs ${SIDEBAR_WIDTH}×${minimumHeight(hasGoal)}${RST}`);
275
+ while (lines.length < height) lines.push(row(""));
276
+ return lines;
270
277
  }
271
278
 
272
279
  private signature(width: number, height: number): string {
@@ -274,6 +281,8 @@ export class SidebarComponent implements Component {
274
281
  const statuses = this.options.getFooterData()?.getExtensionStatuses();
275
282
  const goal = readSessionGoal(this.options.ctx);
276
283
  const cmux = this.options.getCmuxContext();
284
+ const files = this.options.getEditedFiles();
285
+ const theme = this.options.getTheme();
277
286
  return JSON.stringify({
278
287
  width,
279
288
  height,
@@ -284,9 +293,28 @@ export class SidebarComponent implements Component {
284
293
  statuses: statuses && typeof (statuses as any).entries === "function" ? [...statuses.entries()] : [],
285
294
  goal: goal ? `${goal.goalId}:${goal.status}:${goal.usage.tokensUsed}:${goal.usage.activeSeconds}:${goal.updatedAt}` : null,
286
295
  cmux: cmux ? `${cmux.workspaceTitle}:${cmux.workspaceRef}:${cmux.surfaceRef}` : null,
296
+ files: `${files.length}:${files[0]?.path ?? ""}`,
297
+ theme: theme?.name ?? null,
298
+ goalMeter: this.goalMeter.get()?.toFixed(3) ?? null,
299
+ ctxMeter: this.ctxMeter.get()?.toFixed(3) ?? null,
287
300
  });
288
301
  }
289
302
 
303
+ /** Focused with a selection copies that prompt; the unfocused rail copies the session identity. */
304
+ private async copyRailTarget(): Promise<void> {
305
+ const message = this.focused ? this.panel.selectedMessageText() : null;
306
+ if (message !== null) {
307
+ try {
308
+ await copyToClipboard(message);
309
+ this.options.ctx.ui.notify("Copied prompt", "info");
310
+ } catch {
311
+ this.options.ctx.ui.notify("Copy failed", "warning");
312
+ }
313
+ return;
314
+ }
315
+ await this.copySessionPath();
316
+ }
317
+
290
318
  private async copySessionPath(): Promise<void> {
291
319
  const target = this.options.ctx.sessionManager.getSessionFile() ?? this.options.ctx.sessionManager.getSessionId();
292
320
  try {
package/src/slots.ts ADDED
@@ -0,0 +1,130 @@
1
+ import { DOT_GLYPH, arriveProgress, glowStrength, pulse, settlePhase } from "./anim.ts";
2
+ import type { Palette } from "./palette.ts";
3
+ import { bgRgb, fgRgb, rgbLerp } from "./palette.ts";
4
+ import { RAIL_CONTENT, railRow } from "./sections.ts";
5
+ import { RST, clip, formatTime, wrapText } from "./style.ts";
6
+ import type { UserMessage } from "./types.ts";
7
+
8
+ /** Cells in front of the summary text: marker, time, space. The ordinal is
9
+ * gone: its four cells buy summary text instead. */
10
+ export const META_CELLS = 7;
11
+ export const TEXT_CELLS = RAIL_CONTENT - META_CELLS;
12
+ /** Messages this far from the newest read as current; older ones fade. */
13
+ const FRESH_WINDOW = 4;
14
+
15
+ export type SlotDeps = {
16
+ getSummary: (messageId: string, text: string) => string;
17
+ hasSummary: (messageId: string) => boolean;
18
+ isPending: (messageId: string) => boolean;
19
+ };
20
+
21
+ /** Per-message animation memory the panel owns and slots read. */
22
+ export type SlotMemory = {
23
+ seenSummary: Map<string, boolean>;
24
+ landedAt: Map<string, number>;
25
+ arrivedAt: Map<string, number>;
26
+ };
27
+
28
+ /**
29
+ * One message slot: two rail rows carrying the marker, the time, and the
30
+ * summary wrapped across both text cells. The selection bar spans both rows
31
+ * as a yellow edge; pending summaries pulse; landed summaries sweep accent
32
+ * and glow-decay; arrivals reveal left to right; age fades the rest.
33
+ */
34
+ export function slotRows(
35
+ message: UserMessage,
36
+ index: number,
37
+ total: number,
38
+ selected: boolean,
39
+ palette: Palette,
40
+ now: number,
41
+ deps: SlotDeps,
42
+ memory: SlotMemory,
43
+ ): string[] {
44
+ const pending = deps.isPending(message.id);
45
+ const bar = selected ? `${palette.badgeModified}▎${RST}` : " ";
46
+ const marker = pending ? pendingDot(palette, now) : bar;
47
+ const secondMarker = pending ? " " : bar;
48
+ const time = formatTime(message.timestamp).padEnd(5);
49
+ const meta = `${palette.ghost}${time}${RST} `;
50
+ const arrived = memory.arrivedAt.get(message.id);
51
+ const progress = arrived === undefined ? 1 : arriveProgress(now, arrived);
52
+ const text = summaryLines(message, progress, deps);
53
+ const color = textColor(message, index, total, selected, palette, now, deps, memory);
54
+ const background = slotBackground(message, selected, palette, now, memory);
55
+ const first = `${marker}${meta}${color}${text[0]}${RST}`;
56
+ const second = `${secondMarker}${" ".repeat(META_CELLS - 1)}${color}${text[1]}${RST}`;
57
+ return [
58
+ railRow(palette, first, background),
59
+ railRow(palette, second, background),
60
+ ];
61
+ }
62
+
63
+ /**
64
+ * The slot surface: a selection is the soft accent tint, and a message whose
65
+ * summary just landed keeps a decaying glow over the deep canvas. Everything
66
+ * else floats on deep.
67
+ */
68
+ function slotBackground(message: UserMessage, selected: boolean, palette: Palette, now: number, memory: SlotMemory): string {
69
+ if (selected) return palette.bgSelect;
70
+ const landed = memory.landedAt.get(message.id);
71
+ if (landed === undefined || !palette.truecolor || !palette.glowFrom || !palette.glowTo) return palette.bgDeep;
72
+ const strength = glowStrength(now, landed);
73
+ return strength > 0 ? bgRgb(rgbLerp(palette.glowTo, palette.glowFrom, strength)) : palette.bgDeep;
74
+ }
75
+
76
+ /** The working dot: a fast pulse while the summary is still in flight. */
77
+ function pendingDot(palette: Palette, now: number): string {
78
+ if (palette.truecolor && palette.dotDim && palette.dotPeak) {
79
+ return `${fgRgb(rgbLerp(palette.dotDim, palette.dotPeak, pulse(now, 0)))}${DOT_GLYPH}${RST}`;
80
+ }
81
+ const step = Math.round(pulse(now, 0) * (palette.dotFallback.length - 1));
82
+ return `${palette.dotFallback[step] ?? palette.ghost}${DOT_GLYPH}${RST}`;
83
+ }
84
+
85
+ /** Age fade like pi-recap: newest bright, recent normal, older muted; a
86
+ * landing summary sweeps accent then bold accent before settling. */
87
+ function textColor(
88
+ message: UserMessage,
89
+ index: number,
90
+ total: number,
91
+ selected: boolean,
92
+ palette: Palette,
93
+ now: number,
94
+ deps: SlotDeps,
95
+ memory: SlotMemory,
96
+ ): string {
97
+ const has = deps.hasSummary(message.id);
98
+ const was = memory.seenSummary.get(message.id);
99
+ if (has && was === false) memory.landedAt.set(message.id, now);
100
+ memory.seenSummary.set(message.id, has);
101
+
102
+ const landed = memory.landedAt.get(message.id);
103
+ if (has && landed !== undefined) {
104
+ const phase = settlePhase(now, landed);
105
+ if (phase === 1) return palette.accent;
106
+ if (phase === 2) return palette.bold(palette.accent);
107
+ }
108
+ if (selected) return palette.textNew;
109
+ if (!has) return palette.preview;
110
+ const distance = total - 1 - index;
111
+ if (distance === 0) return palette.textNew;
112
+ if (distance <= FRESH_WINDOW) return palette.textMid;
113
+ return palette.textOld;
114
+ }
115
+
116
+ /** The summary wrapped into the two text cells of a message slot. While a
117
+ * slot is arriving, the first line reveals left to right and the second
118
+ * waits its turn. */
119
+ function summaryLines(message: UserMessage, progress: number, deps: SlotDeps): [string, string] {
120
+ const wrapped = wrapText(deps.getSummary(message.id, message.text), TEXT_CELLS);
121
+ if (progress < 1) {
122
+ const reveal = Math.max(1, Math.floor(progress * TEXT_CELLS));
123
+ return [clip(wrapped[0] ?? "", reveal), ""];
124
+ }
125
+ if (wrapped.length <= 1) return [wrapped[0] ?? "", ""];
126
+ const second = wrapped.length > 2
127
+ ? clip(wrapped.slice(1).join(" "), TEXT_CELLS)
128
+ : wrapped[1]!;
129
+ return [wrapped[0]!, second];
130
+ }