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.
- package/CHANGELOG.md +87 -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 -186
- package/src/slots.ts +130 -0
- package/src/status-dock.ts +3 -124
- package/src/style.ts +109 -44
- package/src/summaries.ts +323 -0
- package/src/types.ts +6 -0
package/src/sidebar-component.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
59
|
-
|
|
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.
|
|
64
|
-
isFollowingTail(): boolean { return this.
|
|
65
|
-
isExpanded(messageId: string): boolean { return this.
|
|
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
|
-
|
|
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"))
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (matchesKey(data, "
|
|
110
|
-
|
|
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
|
-
}
|
|
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
|
|
137
|
-
const
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
+
}
|