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/src/palette.ts ADDED
@@ -0,0 +1,248 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+
3
+ export type RGB = readonly [number, number, number];
4
+
5
+ export function parseHex(hex: string): RGB | null {
6
+ const h = hex.replace(/^#/, "");
7
+ const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : "";
8
+ if (!/^[0-9a-fA-F]{6}$/.test(full)) return null;
9
+ const n = parseInt(full, 16);
10
+ return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
11
+ }
12
+
13
+ export function fgRgb(rgb: RGB): string {
14
+ return `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m`;
15
+ }
16
+
17
+ export function bgRgb(rgb: RGB): string {
18
+ return `\x1b[48;2;${rgb[0]};${rgb[1]};${rgb[2]}m`;
19
+ }
20
+
21
+ export function rgbLerp(a: RGB, b: RGB, t: number): RGB {
22
+ const k = Math.max(0, Math.min(1, t));
23
+ return [
24
+ Math.round(a[0] + (b[0] - a[0]) * k),
25
+ Math.round(a[1] + (b[1] - a[1]) * k),
26
+ Math.round(a[2] + (b[2] - a[2]) * k),
27
+ ];
28
+ }
29
+
30
+ /** Mix `over` onto `under` at alpha, for accent tints on the rail canvas. */
31
+ export function blend(under: RGB, over: RGB, alpha: number): RGB {
32
+ return rgbLerp(under, over, Math.max(0, Math.min(1, alpha)));
33
+ }
34
+
35
+ /** Light vs dark terminal background, via COLORFGBG like pi-recap. */
36
+ export function isLightBg(): boolean {
37
+ const fgbg = process.env.COLORFGBG;
38
+ if (fgbg) {
39
+ const bg = parseInt(fgbg.split(";").at(-1) ?? "0", 10);
40
+ if (!Number.isNaN(bg)) return bg >= 8;
41
+ }
42
+ return false;
43
+ }
44
+
45
+ function parseTruecolor(ansi: string): RGB | null {
46
+ const match = ansi.match(/38;2;(\d+);(\d+);(\d+)/);
47
+ if (!match) return null;
48
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
49
+ }
50
+
51
+ /**
52
+ * The obsidian rail palette: one deep well for content, one raised panel for
53
+ * the goal card, and two ghost tiers for chrome that should be found, not
54
+ * seen. Truecolor when the theme reports it, a 256-color ladder otherwise,
55
+ * and a light-terminal variant throughout.
56
+ */
57
+ export type Palette = {
58
+ /** The rail canvas: the deepest step, where content floats. */
59
+ bgDeep: string;
60
+ /** The raised card step: goal block and the hint strip. */
61
+ bgPanel: string;
62
+ /** Soft accent tint for a focused selection. */
63
+ bgSelect: string;
64
+ /** Half-strength tint: unfocused selection and landing glow. */
65
+ bgSelectSoft: string;
66
+ /** The left boundary hairline, nearly invisible. */
67
+ edge: string;
68
+ /** Ghost header labels. */
69
+ ghost: string;
70
+ /** Ghost header metadata and actionable hints. */
71
+ ghostBright: string;
72
+ textNew: string;
73
+ textMid: string;
74
+ textOld: string;
75
+ preview: string;
76
+ accent: string;
77
+ badgeAdded: string;
78
+ badgeModified: string;
79
+ badgeDeleted: string;
80
+ badgeRenamed: string;
81
+ /** Truecolor endpoints for breathing and pulsing dots. */
82
+ dotDim: RGB | null;
83
+ dotPeak: RGB | null;
84
+ dotFallback: string[];
85
+ /** Truecolor glow endpoints, null off truecolor. */
86
+ glowFrom: RGB | null;
87
+ glowTo: RGB | null;
88
+ bold: (text: string) => string;
89
+ truecolor: boolean;
90
+ };
91
+
92
+ const DARK = {
93
+ deep: [12, 13, 20] as RGB,
94
+ panel: [20, 22, 34] as RGB,
95
+ edge: [34, 37, 55] as RGB,
96
+ ghost: [56, 60, 84] as RGB,
97
+ ghostBright: [86, 91, 122] as RGB,
98
+ textNew: [232, 233, 240] as RGB,
99
+ textMid: [176, 180, 196] as RGB,
100
+ textOld: [110, 114, 134] as RGB,
101
+ preview: [88, 92, 112] as RGB,
102
+ };
103
+
104
+ const LIGHT = {
105
+ deep: [238, 240, 246] as RGB,
106
+ panel: [226, 229, 239] as RGB,
107
+ edge: [204, 208, 222] as RGB,
108
+ ghost: [152, 157, 178] as RGB,
109
+ ghostBright: [118, 123, 148] as RGB,
110
+ textNew: [24, 26, 36] as RGB,
111
+ textMid: [62, 66, 84] as RGB,
112
+ textOld: [112, 116, 136] as RGB,
113
+ preview: [140, 144, 164] as RGB,
114
+ };
115
+
116
+ const FALLBACK_256 = {
117
+ bgDeep: "\x1b[48;5;233m",
118
+ bgPanel: "\x1b[48;5;235m",
119
+ bgSelect: "\x1b[48;5;238m",
120
+ bgSelectSoft: "\x1b[48;5;236m",
121
+ edge: "\x1b[38;5;238m",
122
+ ghost: "\x1b[38;5;240m",
123
+ ghostBright: "\x1b[38;5;244m",
124
+ textNew: "\x1b[38;5;255m",
125
+ textMid: "\x1b[38;5;250m",
126
+ textOld: "\x1b[38;5;244m",
127
+ preview: "\x1b[38;5;240m",
128
+ accent: "\x1b[38;5;75m",
129
+ badgeAdded: "\x1b[38;5;150m",
130
+ badgeModified: "\x1b[38;5;221m",
131
+ badgeDeleted: "\x1b[38;5;203m",
132
+ badgeRenamed: "\x1b[38;5;117m",
133
+ };
134
+
135
+ const LIGHT_256 = {
136
+ bgDeep: "\x1b[48;5;254m",
137
+ bgPanel: "\x1b[48;5;251m",
138
+ bgSelect: "\x1b[48;5;249m",
139
+ bgSelectSoft: "\x1b[48;5;252m",
140
+ edge: "\x1b[38;5;249m",
141
+ ghost: "\x1b[38;5;244m",
142
+ ghostBright: "\x1b[38;5;240m",
143
+ };
144
+
145
+ /**
146
+ * The Fornace mark sampled from the production logo asset: seven hues in
147
+ * rainbow order, painted as a solid band. Truecolor carries the exact brand
148
+ * values; the 256 ladder keeps the hue order readable on older terminals.
149
+ */
150
+ export const FORNACE_FLAG: RGB[] = [
151
+ [224, 48, 64],
152
+ [240, 144, 32],
153
+ [240, 208, 96],
154
+ [176, 208, 48],
155
+ [16, 128, 176],
156
+ [80, 64, 144],
157
+ [160, 32, 144],
158
+ ];
159
+
160
+ export const FORNACE_FLAG_256 = [167, 208, 221, 148, 31, 61, 127];
161
+
162
+ /**
163
+ * Universal content colors, theme-agnostic like pi-recap's: a theme's `text`
164
+ * token can be a saturated hue that clashes with a dense rail, so content
165
+ * text keeps a neutral ladder while decorative elements stay on tokens.
166
+ */
167
+ function universal(rgb: RGB): string {
168
+ return fgRgb(rgb);
169
+ }
170
+
171
+ export function resolvePalette(theme: Theme | null): Palette {
172
+ const light = isLightBg();
173
+ const steps = light ? LIGHT : DARK;
174
+
175
+ if (!theme) {
176
+ const f = light ? { ...FALLBACK_256, ...LIGHT_256 } : FALLBACK_256;
177
+ return {
178
+ bgDeep: f.bgDeep,
179
+ bgPanel: f.bgPanel,
180
+ bgSelect: f.bgSelect,
181
+ bgSelectSoft: f.bgSelectSoft,
182
+ edge: f.edge,
183
+ ghost: f.ghost,
184
+ ghostBright: f.ghostBright,
185
+ textNew: universal(steps.textNew),
186
+ textMid: universal(steps.textMid),
187
+ textOld: universal(steps.textOld),
188
+ preview: universal(steps.preview),
189
+ accent: f.accent,
190
+ badgeAdded: f.badgeAdded,
191
+ badgeModified: f.badgeModified,
192
+ badgeDeleted: f.badgeDeleted,
193
+ badgeRenamed: f.badgeRenamed,
194
+ dotDim: null,
195
+ dotPeak: null,
196
+ dotFallback: [f.ghost, f.ghostBright, f.accent],
197
+ glowFrom: null,
198
+ glowTo: null,
199
+ bold: (text) => `\x1b[1m${text}`,
200
+ truecolor: false,
201
+ };
202
+ }
203
+
204
+ const fg = (token: Parameters<Theme["getFgAnsi"]>[0], fallback: string) => {
205
+ try {
206
+ return theme.getFgAnsi(token);
207
+ } catch {
208
+ return fallback;
209
+ }
210
+ };
211
+ const accentAnsi = fg("accent", FALLBACK_256.accent);
212
+ const dimAnsi = fg("dim", FALLBACK_256.ghost);
213
+ const truecolor = theme.getColorMode() === "truecolor";
214
+ const accentRgb = truecolor ? parseTruecolor(accentAnsi) : null;
215
+
216
+ const select = accentRgb
217
+ ? bgRgb(blend(steps.deep, accentRgb, 0.16))
218
+ : (light ? LIGHT_256 : FALLBACK_256).bgSelect;
219
+ const selectSoft = accentRgb
220
+ ? bgRgb(blend(steps.deep, accentRgb, 0.08))
221
+ : (light ? LIGHT_256 : FALLBACK_256).bgSelectSoft;
222
+
223
+ return {
224
+ bgDeep: truecolor ? bgRgb(steps.deep) : (light ? LIGHT_256 : FALLBACK_256).bgDeep,
225
+ bgPanel: truecolor ? bgRgb(steps.panel) : (light ? LIGHT_256 : FALLBACK_256).bgPanel,
226
+ bgSelect: select,
227
+ bgSelectSoft: selectSoft,
228
+ edge: truecolor ? fgRgb(steps.edge) : (light ? LIGHT_256 : FALLBACK_256).edge,
229
+ ghost: truecolor ? fgRgb(steps.ghost) : (light ? LIGHT_256 : FALLBACK_256).ghost,
230
+ ghostBright: truecolor ? fgRgb(steps.ghostBright) : (light ? LIGHT_256 : FALLBACK_256).ghostBright,
231
+ textNew: universal(steps.textNew),
232
+ textMid: universal(steps.textMid),
233
+ textOld: universal(steps.textOld),
234
+ preview: universal(steps.preview),
235
+ accent: accentAnsi,
236
+ badgeAdded: fg("toolDiffAdded", FALLBACK_256.badgeAdded),
237
+ badgeModified: fg("warning", FALLBACK_256.badgeModified),
238
+ badgeDeleted: fg("toolDiffRemoved", FALLBACK_256.badgeDeleted),
239
+ badgeRenamed: fg("borderAccent", FALLBACK_256.badgeRenamed),
240
+ dotDim: truecolor ? parseTruecolor(dimAnsi) : null,
241
+ dotPeak: accentRgb ?? (truecolor ? parseHex("#cba6f7") : null),
242
+ dotFallback: [dimAnsi, fg("muted", FALLBACK_256.ghostBright), accentAnsi],
243
+ glowFrom: accentRgb ? blend(steps.deep, accentRgb, 0.10) : null,
244
+ glowTo: steps.deep,
245
+ bold: (text) => theme.bold(text),
246
+ truecolor,
247
+ };
248
+ }
@@ -0,0 +1,165 @@
1
+ import type {
2
+ ExtensionContext,
3
+ ReadonlyFooterDataProvider,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { visibleWidth } from "@earendil-works/pi-tui";
6
+ import type { CmuxContext } from "./cmux.ts";
7
+ import type { FileEdit } from "./files.ts";
8
+ import type { Palette } from "./palette.ts";
9
+ import { computeUsage } from "./status-dock.ts";
10
+ import { RST, clip, ellipsizePath, formatCost, formatCwd, meterCells } from "./style.ts";
11
+
12
+ /**
13
+ * Content width inside the rail: 42 columns minus the boundary and the two
14
+ * pads. Every budget below measures against this, so a right-aligned element
15
+ * lands one pad short of the rail edge.
16
+ */
17
+ export const RAIL_CONTENT = 39;
18
+
19
+ /** Cells a rail row paints to the right of the boundary: pad + content + pad. */
20
+ export function railFill(width = RAIL_CONTENT): number {
21
+ return width + 2;
22
+ }
23
+
24
+ export function railRow(palette: Palette, content: string, bg: string, width = RAIL_CONTENT): string {
25
+ const injected = content.replace(/\x1b\[0m/g, `${RST}${bg}`);
26
+ const pad = " ".repeat(Math.max(0, width + 1 - visibleWidth(injected)));
27
+ return `${palette.edge}│${RST}${bg} ${injected}${pad}${RST}`;
28
+ }
29
+
30
+ /**
31
+ * A ghost header: a quiet label flush with the content edge, metadata in the
32
+ * brighter ghost tier on the right. No rules, no dashes, no indent: sections
33
+ * separate by air and background steps, and chrome stays below content.
34
+ */
35
+ export function ghostHeader(palette: Palette, label: string, bg: string, right = "", width = RAIL_CONTENT): string {
36
+ const rightCells = right ? visibleWidth(right) + 1 : 0;
37
+ const budget = Math.max(0, width - visibleWidth(label) - rightCells);
38
+ const content = [
39
+ palette.ghost, label,
40
+ " ".repeat(budget),
41
+ ...(right ? [palette.ghostBright, right] : []),
42
+ ].join("");
43
+ return railRow(palette, content, bg, width);
44
+ }
45
+
46
+ /** Meter color by pressure: accent, warning at 70 percent, danger at 90. */
47
+ export function pressureColor(palette: Palette, ratio: number | null, override?: string): string {
48
+ if (override) return override;
49
+ if (ratio === null || !Number.isFinite(ratio)) return palette.accent;
50
+ if (ratio >= 0.9) return palette.badgeDeleted;
51
+ if (ratio >= 0.7) return palette.badgeModified;
52
+ return palette.accent;
53
+ }
54
+
55
+ export function meterTrack(palette: Palette): string {
56
+ return palette.edge;
57
+ }
58
+
59
+ // --- session and files ------------------------------------------------------
60
+
61
+ function fileBadge(palette: Palette, letter: string | null): string {
62
+ switch (letter) {
63
+ case "M": return `${palette.badgeModified}M${RST}`;
64
+ case "A": return `${palette.badgeAdded}A${RST}`;
65
+ case "U": return `${palette.badgeAdded}U${RST}`;
66
+ case "D": return `${palette.badgeDeleted}D${RST}`;
67
+ case "R": return `${palette.badgeRenamed}R${RST}`;
68
+ default: return `${palette.ghost}·${RST}`;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Session identity plus the write footprint in one quiet block: a blank
74
+ * separator row, a ghost header carrying the branch, the surface and
75
+ * workspace, the cwd, then an optional session id row and a FILES subsection
76
+ * whose rows carry the git letter convention in front of front-trimmed paths.
77
+ */
78
+ export function renderSessionSection(
79
+ ctx: ExtensionContext,
80
+ footerData: ReadonlyFooterDataProvider | null,
81
+ cmux: CmuxContext | null,
82
+ rows: number,
83
+ palette: Palette,
84
+ files: FileEdit[],
85
+ statusFor: (path: string) => string | null,
86
+ width = RAIL_CONTENT,
87
+ ): string[] {
88
+ if (rows <= 0) return [];
89
+ const lines: string[] = [];
90
+ const push = (line: string) => { if (lines.length < rows) lines.push(line); };
91
+ const bg = palette.bgDeep;
92
+
93
+ push(railRow(palette, "", bg, width));
94
+ const branch = footerData?.getGitBranch() ?? null;
95
+ push(ghostHeader(palette, "SESSION", bg, branch ? clip(branch, width - 12) : "", width));
96
+
97
+ const surface = cmux?.surfaceRef ?? "surface n/a";
98
+ const workspace = cmux?.workspaceTitle ?? cmux?.workspaceRef ?? "";
99
+ const identityRow = workspace
100
+ ? `${palette.accent}${clip(surface, 12)}${RST} ${palette.ghost}·${RST} ${palette.textMid}${clip(workspace, Math.max(1, width - 16))}${RST}`
101
+ : `${palette.accent}${clip(surface, width)}${RST}`;
102
+ push(railRow(palette, identityRow, bg, width));
103
+ push(railRow(palette, `${palette.textMid}${ellipsizePath(formatCwd(ctx.sessionManager.getCwd()), width)}${RST}`, bg, width));
104
+ if (rows >= 5) {
105
+ const sessionId = ctx.sessionManager.getSessionId().replace(/-/g, "").slice(0, 8);
106
+ push(railRow(palette, `${palette.badgeRenamed}session${RST} ${palette.textMid}${sessionId}${RST}`, bg, width));
107
+ }
108
+ // The FILES subsection needs its air, header, plus at least one file row.
109
+ if (files.length > 0 && rows - lines.length >= 3) {
110
+ const noun = files.length === 1 ? "file" : "files";
111
+ push(railRow(palette, "", bg, width));
112
+ push(ghostHeader(palette, "FILES", bg, `${files.length} ${noun}`, width));
113
+ for (const file of files) {
114
+ if (lines.length >= rows) break;
115
+ const badge = fileBadge(palette, statusFor(file.path));
116
+ const repeats = file.edits > 1 ? `${palette.ghost} ×${file.edits}${RST}` : "";
117
+ const pathBudget = width - 2 - visibleWidth(repeats);
118
+ push(railRow(palette, `${badge} ${palette.textMid}${ellipsizePath(file.path, Math.max(1, pathBudget))}${RST}${repeats}`, bg, width));
119
+ }
120
+ }
121
+ while (lines.length < rows) push(railRow(palette, "", bg, width));
122
+ return lines;
123
+ }
124
+
125
+ // --- runtime ----------------------------------------------------------------
126
+
127
+ /**
128
+ * The runtime block: ghost header with the thinking level, the model route,
129
+ * and a context meter whose fill eases toward the live reading.
130
+ */
131
+ export function renderRuntimeSection(
132
+ ctx: ExtensionContext,
133
+ footerData: ReadonlyFooterDataProvider | null,
134
+ thinkingLevel: string,
135
+ rows: number,
136
+ palette: Palette,
137
+ width = RAIL_CONTENT,
138
+ contextRatio: number | null = null,
139
+ shimmer: number | null = null,
140
+ ): string[] {
141
+ if (rows <= 0) return [];
142
+ const lines: string[] = [];
143
+ const push = (line: string) => { if (lines.length < rows) lines.push(line); };
144
+ const bg = palette.bgDeep;
145
+
146
+ push(railRow(palette, "", bg, width));
147
+ const model = ctx.model;
148
+ const thinkingRight = model?.reasoning ? clip(thinkingLevel, width - 12) : "";
149
+ push(ghostHeader(palette, "RUNTIME", bg, thinkingRight, width));
150
+ if (model) {
151
+ const provider = footerData && footerData.getAvailableProviderCount() > 1 ? `${model.provider}/` : "";
152
+ push(railRow(palette, `${palette.textMid}${clip(`${provider}${model.id}`, width)}${RST}`, bg, width));
153
+ }
154
+ const usage = computeUsage(ctx);
155
+ const percent = usage.contextPercent === null ? null : Math.round(usage.contextPercent);
156
+ const ratio = contextRatio ?? (percent === null ? null : percent / 100);
157
+ const meter = meterCells(ratio, 10, pressureColor(palette, ratio), meterTrack(palette), "─", shimmer);
158
+ const percentText = percent === null ? "?" : `${percent}%`;
159
+ const meterText = meter
160
+ ? `${palette.ghost}ctx${RST} ${meter} ${palette.textMid}${percentText}${RST}`
161
+ : `${palette.ghost}ctx${RST} ${palette.textMid}${percentText}${RST}`;
162
+ push(railRow(palette, `${meterText} ${palette.ghost}·${RST} ${palette.textMid}${formatCost(usage.cost)}${RST}`, bg, width));
163
+ while (lines.length < rows) push(railRow(palette, "", bg, width));
164
+ return lines;
165
+ }