pi-precompact 0.0.0-stage → 0.2.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 +28 -0
- package/CONTRIBUTING.md +152 -0
- package/LICENSE +21 -0
- package/README.md +219 -2
- package/package.json +71 -4
- package/src/card.ts +194 -0
- package/src/controller.ts +446 -0
- package/src/files.ts +24 -0
- package/src/footer.ts +471 -0
- package/src/format.ts +20 -0
- package/src/index.ts +107 -0
- package/src/native.ts +47 -0
- package/src/validation.ts +82 -0
package/src/footer.ts
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
import type { ExtensionContext, FooterComponent, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
type Color,
|
|
5
|
+
type Component,
|
|
6
|
+
getTerminalColorMode,
|
|
7
|
+
mixColors,
|
|
8
|
+
rgbColor,
|
|
9
|
+
sliceByColumn,
|
|
10
|
+
type TerminalColors,
|
|
11
|
+
truncateToWidth,
|
|
12
|
+
visibleWidth,
|
|
13
|
+
} from "@earendil-works/pi-tui";
|
|
14
|
+
import type { FooterState, Label } from "./controller.ts";
|
|
15
|
+
|
|
16
|
+
type Theme = ExtensionContext["ui"]["theme"];
|
|
17
|
+
type Token = Parameters<Theme["fg"]>[0];
|
|
18
|
+
type Render = FooterComponent["render"];
|
|
19
|
+
type Segment = readonly [text: string, token: Token];
|
|
20
|
+
|
|
21
|
+
export const STATUS_KEY = "precompact";
|
|
22
|
+
const WIDGET_KEY = "precompact-footer";
|
|
23
|
+
/** Marks our render wrapper. `Symbol.for` keeps the key stable across extension reloads. */
|
|
24
|
+
const MARK = Symbol.for("pi-precompact.footer-render");
|
|
25
|
+
const MIN_PADDING = 2;
|
|
26
|
+
const BAR_MIN_WIDTH = 100;
|
|
27
|
+
const CELLS = 24;
|
|
28
|
+
// Pi's context segment, for example "91.6%/272k (auto)" or "?/272k".
|
|
29
|
+
const CONTEXT = /(^| )(\d+(?:\.\d+)?%|\?)(\/\d+(?:\.\d+)?[kM]?)( \(auto\))?(?= |$)/;
|
|
30
|
+
// A slice keeps the opening colour of its text but not the closing code.
|
|
31
|
+
const RESET_FG = "\x1b[39m";
|
|
32
|
+
const STATE_COLOR: Record<Label, Token> = {
|
|
33
|
+
idle: "dim",
|
|
34
|
+
running: "accent",
|
|
35
|
+
ready: "success",
|
|
36
|
+
paused: "warning",
|
|
37
|
+
off: "dim",
|
|
38
|
+
unavailable: "dim",
|
|
39
|
+
};
|
|
40
|
+
const FILL_COLOR: Record<Label, Token> = { ...STATE_COLOR, idle: "muted" };
|
|
41
|
+
|
|
42
|
+
// Fade-in (design: design/figcraft, page 04). A frame every 40 ms; each glyph fades in over
|
|
43
|
+
// 120 ms, 5 ms later than its left neighbour; the fill grows from 80 to 240 ms.
|
|
44
|
+
const FRAME_MS = 40;
|
|
45
|
+
const WAVE_MS = 5;
|
|
46
|
+
const FADE_MS = 120;
|
|
47
|
+
const FILL_START_MS = 80;
|
|
48
|
+
const FILL_MS = 160;
|
|
49
|
+
/** Long enough for 26 bar glyphs and the suffix after them. */
|
|
50
|
+
export const REVEAL_MS = 26 * WAVE_MS + FADE_MS;
|
|
51
|
+
|
|
52
|
+
function cubicBezier(x1: number, y1: number, x2: number, y2: number) {
|
|
53
|
+
const at = (t: number, a: number, b: number) => 3 * a * t * (1 - t) ** 2 + 3 * b * t * t * (1 - t) + t ** 3;
|
|
54
|
+
return (x: number) => {
|
|
55
|
+
if (x <= 0) return 0;
|
|
56
|
+
if (x >= 1) return 1;
|
|
57
|
+
let low = 0;
|
|
58
|
+
let high = 1;
|
|
59
|
+
for (let i = 0; i < 30; i++) {
|
|
60
|
+
const mid = (low + high) / 2;
|
|
61
|
+
if (at(mid, x1, x2) < x) low = mid;
|
|
62
|
+
else high = mid;
|
|
63
|
+
}
|
|
64
|
+
return at((low + high) / 2, y1, y2);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const easeOut = cubicBezier(0.23, 1, 0.32, 1);
|
|
68
|
+
const progress = (elapsed: number, start: number, length: number) =>
|
|
69
|
+
easeOut(Math.min(1, Math.max(0, (elapsed - start) / length)));
|
|
70
|
+
|
|
71
|
+
/** Whether Pi has measured a response yet; before that Pi shows no usage numbers either. */
|
|
72
|
+
export function hasMeasuredUsage(entries: readonly SessionEntry[]): boolean {
|
|
73
|
+
return entries.some(
|
|
74
|
+
(entry) =>
|
|
75
|
+
entry.type === "message" &&
|
|
76
|
+
entry.message.role === "assistant" &&
|
|
77
|
+
entry.message.usage.input +
|
|
78
|
+
entry.message.usage.output +
|
|
79
|
+
entry.message.usage.cacheRead +
|
|
80
|
+
entry.message.usage.cacheWrite >
|
|
81
|
+
0,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** What the renderer needs from the reveal: the animation phase and the terminal background. */
|
|
86
|
+
export interface RevealView {
|
|
87
|
+
/** Undefined: hidden. A number: milliseconds since the bar appeared. Infinity: settled. */
|
|
88
|
+
phase(key: string): number | undefined;
|
|
89
|
+
readonly background?: Color;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const SETTLED: RevealView = { phase: () => Infinity };
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Keeps the bar hidden until the session has a measured response, then fades it in once.
|
|
96
|
+
* A session that already has one, for example after /resume or /reload, shows it at once.
|
|
97
|
+
*/
|
|
98
|
+
export class Reveal implements RevealView {
|
|
99
|
+
background?: Color;
|
|
100
|
+
private measured: boolean;
|
|
101
|
+
private leaf: string | null;
|
|
102
|
+
private startedAt?: number;
|
|
103
|
+
private key?: string;
|
|
104
|
+
private timer?: ReturnType<typeof setInterval>;
|
|
105
|
+
|
|
106
|
+
constructor(
|
|
107
|
+
private readonly session: Pick<ExtensionContext["sessionManager"], "getLeafId" | "getEntries">,
|
|
108
|
+
private readonly motion: boolean,
|
|
109
|
+
private readonly requestRender: () => void,
|
|
110
|
+
private readonly now: () => number = Date.now,
|
|
111
|
+
) {
|
|
112
|
+
this.leaf = session.getLeafId();
|
|
113
|
+
this.measured = hasMeasuredUsage(session.getEntries());
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Whether a later first response will animate. */
|
|
117
|
+
get pending(): boolean {
|
|
118
|
+
return !this.measured && this.motion;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
phase(key: string): number | undefined {
|
|
122
|
+
if (!this.measured) {
|
|
123
|
+
// Every appended entry moves the leaf; only then can a measured response have arrived.
|
|
124
|
+
const leaf = this.session.getLeafId();
|
|
125
|
+
if (leaf === this.leaf) return undefined;
|
|
126
|
+
this.leaf = leaf;
|
|
127
|
+
if (!hasMeasuredUsage(this.session.getEntries())) return undefined;
|
|
128
|
+
this.measured = true;
|
|
129
|
+
if (!this.motion) return Infinity;
|
|
130
|
+
this.startedAt = this.now();
|
|
131
|
+
this.key = key;
|
|
132
|
+
this.timer = setInterval(() => this.requestRender(), FRAME_MS);
|
|
133
|
+
this.timer.unref?.();
|
|
134
|
+
}
|
|
135
|
+
if (this.startedAt === undefined) return Infinity;
|
|
136
|
+
const elapsed = this.now() - this.startedAt;
|
|
137
|
+
// A new width, value or state ends the animation: data first, motion second.
|
|
138
|
+
if (key !== this.key || elapsed >= REVEAL_MS) {
|
|
139
|
+
this.settle();
|
|
140
|
+
return Infinity;
|
|
141
|
+
}
|
|
142
|
+
return elapsed;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
settle(): void {
|
|
146
|
+
if (this.timer) clearInterval(this.timer);
|
|
147
|
+
this.timer = undefined;
|
|
148
|
+
this.startedAt = undefined;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Paint text that fades in from the terminal background as glyph `index` of the wave. */
|
|
153
|
+
function painter(theme: Theme, elapsed: number, background: Color | undefined) {
|
|
154
|
+
return (text: string, token: Token, index: number) => {
|
|
155
|
+
if (elapsed === Infinity) return theme.fg(token, text);
|
|
156
|
+
const amount = progress(elapsed, index * WAVE_MS, FADE_MS);
|
|
157
|
+
if (amount <= 0) return " ".repeat(visibleWidth(text));
|
|
158
|
+
if (amount >= 1 || !background) return theme.fg(token, text);
|
|
159
|
+
return theme.style(text, { fg: mixColors(background, theme.colors[token], amount, "srgb") });
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
interface Mark {
|
|
164
|
+
readonly original: Render;
|
|
165
|
+
readonly active: boolean;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
169
|
+
return typeof value === "object" && value !== null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isMark(value: unknown): value is Mark {
|
|
173
|
+
return isRecord(value) && typeof value.original === "function" && typeof value.active === "boolean";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function markOf(render: unknown): Mark | undefined {
|
|
177
|
+
if (typeof render !== "function") return undefined;
|
|
178
|
+
const mark: unknown = Reflect.get(render, MARK);
|
|
179
|
+
return isMark(mark) ? mark : undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function isComponent(value: unknown): value is Component {
|
|
183
|
+
return (
|
|
184
|
+
isRecord(value) &&
|
|
185
|
+
"render" in value &&
|
|
186
|
+
typeof value.render === "function" &&
|
|
187
|
+
"invalidate" in value &&
|
|
188
|
+
typeof value.invalidate === "function"
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function isBuiltInFooter(component: Component): component is FooterComponent {
|
|
193
|
+
// Host and extension modules can load separate class identities. Require Pi's
|
|
194
|
+
// component name and its distinctive public API instead of instanceof.
|
|
195
|
+
return (
|
|
196
|
+
component.constructor.name === "FooterComponent" &&
|
|
197
|
+
"setSession" in component &&
|
|
198
|
+
typeof component.setSession === "function" &&
|
|
199
|
+
"setAutoCompactEnabled" in component &&
|
|
200
|
+
typeof component.setAutoCompactEnabled === "function"
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The single mounted built-in footer, or undefined for custom or ambiguous layouts. */
|
|
205
|
+
export function findBuiltInFooter(roots: readonly Component[]): FooterComponent | undefined {
|
|
206
|
+
const pending = [...roots];
|
|
207
|
+
const visited = new Set<Component>();
|
|
208
|
+
const footers: FooterComponent[] = [];
|
|
209
|
+
while (pending.length > 0) {
|
|
210
|
+
const component = pending.pop();
|
|
211
|
+
if (!component || visited.has(component)) continue;
|
|
212
|
+
visited.add(component);
|
|
213
|
+
if (isBuiltInFooter(component)) footers.push(component);
|
|
214
|
+
else if ("children" in component) {
|
|
215
|
+
const children: unknown = component.children;
|
|
216
|
+
if (Array.isArray(children) && children.every(isComponent)) pending.push(...children);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return footers.length === 1 ? footers[0] : undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function readStatuses(footer: FooterComponent): ReadonlyMap<string, string> | undefined {
|
|
223
|
+
// Pi has no public status-placement API. Read the instance's provider defensively;
|
|
224
|
+
// anything unexpected keeps Pi's own status row.
|
|
225
|
+
const provider: unknown = Reflect.get(footer, "footerData");
|
|
226
|
+
if (!isRecord(provider) || typeof provider.getExtensionStatuses !== "function") return undefined;
|
|
227
|
+
const statuses: unknown = provider.getExtensionStatuses();
|
|
228
|
+
if (
|
|
229
|
+
!(statuses instanceof Map) ||
|
|
230
|
+
![...statuses].every(([key, text]) => typeof key === "string" && typeof text === "string")
|
|
231
|
+
)
|
|
232
|
+
return undefined;
|
|
233
|
+
return statuses;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Pi's status row, optionally without this plugin's entry. */
|
|
237
|
+
function statusRow(statuses: ReadonlyMap<string, string>, theme: Theme, width: number, omitOwn = false) {
|
|
238
|
+
const entries = [...statuses].filter(([key]) => !omitOwn || key !== STATUS_KEY);
|
|
239
|
+
if (entries.length === 0) return undefined;
|
|
240
|
+
const text = entries
|
|
241
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
242
|
+
.map(([, value]) =>
|
|
243
|
+
value
|
|
244
|
+
.replace(/[\r\n\t]/g, " ")
|
|
245
|
+
.replace(/ +/g, " ")
|
|
246
|
+
.trim(),
|
|
247
|
+
)
|
|
248
|
+
.join(" ");
|
|
249
|
+
return truncateToWidth(text, width, theme.fg("dim", "..."));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Context bar with the background start (accent) and Pi's threshold (error) as marks. */
|
|
253
|
+
function gauge(percent: number | null, state: FooterState, elapsed: number): Segment[] {
|
|
254
|
+
const measured = percent === null ? 0 : Math.round((Math.min(Math.max(percent, 0), 100) / 100) * CELLS);
|
|
255
|
+
const filled =
|
|
256
|
+
elapsed === Infinity ? measured : Math.round(measured * progress(elapsed, FILL_START_MS, FILL_MS));
|
|
257
|
+
const fill = state.applied ? "muted" : FILL_COLOR[state.state];
|
|
258
|
+
const marks = new Map<number, Token>();
|
|
259
|
+
if (state.start !== undefined) marks.set(Math.min(CELLS - 1, Math.floor(state.start * CELLS)), "accent");
|
|
260
|
+
if (state.threshold !== undefined)
|
|
261
|
+
marks.set(Math.min(CELLS - 1, Math.floor(state.threshold * CELLS)), "error");
|
|
262
|
+
const glyphs: Segment[] = [];
|
|
263
|
+
for (let cell = 0; cell < CELLS; cell++) {
|
|
264
|
+
const mark = marks.get(cell);
|
|
265
|
+
if (mark) glyphs.push(["│", mark]);
|
|
266
|
+
glyphs.push(cell < filled ? ["━", fill] : ["─", "dim"]);
|
|
267
|
+
}
|
|
268
|
+
return glyphs;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Put `left` in place of everything up to the context segment's end; use padding before truncating. */
|
|
272
|
+
function replaceUpToContext(
|
|
273
|
+
stats: string,
|
|
274
|
+
plain: string,
|
|
275
|
+
context: RegExpExecArray,
|
|
276
|
+
left: string,
|
|
277
|
+
width: number,
|
|
278
|
+
) {
|
|
279
|
+
const leftWidth = visibleWidth(left);
|
|
280
|
+
if (leftWidth + MIN_PADDING > width) return undefined;
|
|
281
|
+
const end = context.index + context[0].length;
|
|
282
|
+
const endColumn = visibleWidth(plain.slice(0, end));
|
|
283
|
+
const padding = /^ */.exec(plain.slice(end))?.[0].length ?? 0;
|
|
284
|
+
const grow = leftWidth - endColumn;
|
|
285
|
+
const consumed = Math.max(0, Math.min(grow, padding - MIN_PADDING));
|
|
286
|
+
const fill = grow < 0 ? " ".repeat(-grow) : "";
|
|
287
|
+
const room = width - leftWidth - fill.length;
|
|
288
|
+
return left + fill + truncateToWidth(sliceByColumn(stats, endColumn + consumed, room), room, "");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Expected context after adoption, or the state, after Pi's segment. */
|
|
292
|
+
function suffixOf(state: FooterState, percent: number | null): Segment[] {
|
|
293
|
+
if (state.state === "ready" && state.after !== undefined && percent !== null)
|
|
294
|
+
return [
|
|
295
|
+
[" → ", "dim"],
|
|
296
|
+
[`~${Math.round(state.after * 100)}%`, "success"],
|
|
297
|
+
];
|
|
298
|
+
if (state.applied && state.state === "idle") return [[" ✓ pre", "success"]];
|
|
299
|
+
if (state.state === "idle") return [];
|
|
300
|
+
return [
|
|
301
|
+
[" pre ", "dim"],
|
|
302
|
+
[state.state, STATE_COLOR[state.state]],
|
|
303
|
+
];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Bar before Pi's unchanged context segment; the expected context or the state after it. */
|
|
307
|
+
function barStats(
|
|
308
|
+
stats: string,
|
|
309
|
+
plain: string,
|
|
310
|
+
context: RegExpExecArray,
|
|
311
|
+
state: FooterState,
|
|
312
|
+
theme: Theme,
|
|
313
|
+
width: number,
|
|
314
|
+
elapsed: number,
|
|
315
|
+
background: Color | undefined,
|
|
316
|
+
) {
|
|
317
|
+
const [, lead = "", percentText = "", windowText = "", autoText = ""] = context;
|
|
318
|
+
const column = visibleWidth(plain.slice(0, context.index + lead.length));
|
|
319
|
+
const percent = percentText === "?" ? null : Number.parseFloat(percentText);
|
|
320
|
+
// Pi's own segment and colours appear at once; only the bar and the suffix fade in.
|
|
321
|
+
const native = sliceByColumn(stats, column, visibleWidth(percentText + windowText + autoText)) + RESET_FG;
|
|
322
|
+
const paint = painter(theme, elapsed, background);
|
|
323
|
+
const glyphs = gauge(percent, state, elapsed);
|
|
324
|
+
const bar = glyphs.map(([text, token], index) => paint(text, token, index)).join("");
|
|
325
|
+
const suffix = suffixOf(state, percent)
|
|
326
|
+
.map(([text, token]) => paint(text, token, glyphs.length))
|
|
327
|
+
.join("");
|
|
328
|
+
const left = `${sliceByColumn(stats, 0, column)}${bar} ${native}${suffix}`;
|
|
329
|
+
return replaceUpToContext(stats, plain, context, left, width);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Compact form: Pi's context segment unchanged, then the coloured state. */
|
|
333
|
+
function textStats(
|
|
334
|
+
stats: string,
|
|
335
|
+
plain: string,
|
|
336
|
+
context: RegExpExecArray,
|
|
337
|
+
state: FooterState,
|
|
338
|
+
theme: Theme,
|
|
339
|
+
width: number,
|
|
340
|
+
) {
|
|
341
|
+
const end = context.index + context[0].length;
|
|
342
|
+
const column = visibleWidth(plain.slice(0, end));
|
|
343
|
+
const label =
|
|
344
|
+
state.applied && state.state === "idle"
|
|
345
|
+
? theme.fg("success", " ✓ pre")
|
|
346
|
+
: theme.fg("dim", " pre: ") + theme.fg(STATE_COLOR[state.state], state.state);
|
|
347
|
+
const labelWidth = visibleWidth(label);
|
|
348
|
+
if (column + labelWidth > width) return undefined;
|
|
349
|
+
const padding = /^ */.exec(plain.slice(end))?.[0].length ?? 0;
|
|
350
|
+
const consumed = Math.min(labelWidth, Math.max(0, padding - MIN_PADDING));
|
|
351
|
+
const room = width - column - labelWidth;
|
|
352
|
+
return (
|
|
353
|
+
sliceByColumn(stats, 0, column) +
|
|
354
|
+
label +
|
|
355
|
+
truncateToWidth(sliceByColumn(stats, column + consumed, room), room, "")
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Move this plugin's status from Pi's status row into the statistics line.
|
|
361
|
+
* Before the first measured response, show nothing at all, like Pi.
|
|
362
|
+
* Only Pi's known three-line layout is changed; anything else is returned unchanged.
|
|
363
|
+
*/
|
|
364
|
+
export function inlineStatus(
|
|
365
|
+
lines: string[],
|
|
366
|
+
statuses: ReadonlyMap<string, string> | undefined,
|
|
367
|
+
state: FooterState | undefined,
|
|
368
|
+
theme: Theme,
|
|
369
|
+
width: number,
|
|
370
|
+
reveal: RevealView = SETTLED,
|
|
371
|
+
): string[] {
|
|
372
|
+
if (!state || !statuses || statuses.get(STATUS_KEY) !== `precompact: ${state.state}`) return lines;
|
|
373
|
+
if (lines.length !== 3 || lines[2] !== statusRow(statuses, theme, width)) return lines;
|
|
374
|
+
const stats = lines[1];
|
|
375
|
+
const plain = stripVTControlCharacters(stats);
|
|
376
|
+
const context = CONTEXT.exec(plain);
|
|
377
|
+
if (!context) return lines;
|
|
378
|
+
// Before the first measured response Pi shows no usage; show nothing either.
|
|
379
|
+
const elapsed = reveal.phase(`${width}|${state.state}|${state.applied}|${context[2]}`);
|
|
380
|
+
const inline =
|
|
381
|
+
elapsed === undefined
|
|
382
|
+
? stats
|
|
383
|
+
: ((width >= BAR_MIN_WIDTH
|
|
384
|
+
? barStats(stats, plain, context, state, theme, width, elapsed, reveal.background)
|
|
385
|
+
: undefined) ?? textStats(stats, plain, context, state, theme, width));
|
|
386
|
+
if (inline === undefined) return lines;
|
|
387
|
+
const rest = statusRow(statuses, theme, width, true);
|
|
388
|
+
return rest === undefined ? [lines[0], inline] : [lines[0], inline, rest];
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Decorate the render method of this one footer instance. Other decorators may wrap it
|
|
393
|
+
* before or after; each keeps the other's lines. Returns an idempotent undo.
|
|
394
|
+
*/
|
|
395
|
+
export function decorateFooter(
|
|
396
|
+
footer: FooterComponent,
|
|
397
|
+
theme: Theme,
|
|
398
|
+
state: () => FooterState | undefined,
|
|
399
|
+
reveal: RevealView = SETTLED,
|
|
400
|
+
) {
|
|
401
|
+
// Drop our own inactive wrappers from earlier runtimes when they are on top.
|
|
402
|
+
for (let mark = markOf(footer.render); mark && !mark.active; mark = markOf(footer.render)) {
|
|
403
|
+
footer.render = mark.original;
|
|
404
|
+
}
|
|
405
|
+
const original = footer.render;
|
|
406
|
+
let active = true;
|
|
407
|
+
const render = function (this: FooterComponent, width: number): string[] {
|
|
408
|
+
const lines = original.call(this, width);
|
|
409
|
+
return active ? inlineStatus(lines, readStatuses(this), state(), theme, width, reveal) : lines;
|
|
410
|
+
};
|
|
411
|
+
const mark: Mark = {
|
|
412
|
+
original,
|
|
413
|
+
get active() {
|
|
414
|
+
return active;
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
Reflect.defineProperty(render, MARK, { value: mark });
|
|
418
|
+
footer.render = render;
|
|
419
|
+
return () => {
|
|
420
|
+
if (!active) return;
|
|
421
|
+
active = false;
|
|
422
|
+
// A later decorator keeps working; our wrapper then only passes through.
|
|
423
|
+
if (footer.render === render) footer.render = original;
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Pi exposes the live TUI only to widget factories. A zero-row widget below the editor
|
|
429
|
+
* finds the built-in footer without adding layout space. Without it, Pi's status row remains.
|
|
430
|
+
*/
|
|
431
|
+
export function attachFooter(
|
|
432
|
+
ctx: ExtensionContext,
|
|
433
|
+
state: () => FooterState | undefined,
|
|
434
|
+
options: { motion: boolean },
|
|
435
|
+
): () => void {
|
|
436
|
+
let undo: (() => void) | undefined;
|
|
437
|
+
let reveal: Reveal | undefined;
|
|
438
|
+
ctx.ui.setWidget(
|
|
439
|
+
WIDGET_KEY,
|
|
440
|
+
(tui, theme) => {
|
|
441
|
+
const footer = findBuiltInFooter(tui.children);
|
|
442
|
+
const current = new Reveal(ctx.sessionManager, options.motion, () => tui.requestRender());
|
|
443
|
+
reveal = current;
|
|
444
|
+
undo = footer ? decorateFooter(footer, theme, state, current) : undefined;
|
|
445
|
+
// The fade needs the real terminal background. Without an answer or truecolor,
|
|
446
|
+
// each glyph appears in its final colour when the wave reaches it.
|
|
447
|
+
if (footer && current.pending && getTerminalColorMode() === "truecolor") {
|
|
448
|
+
// Startup can outlast the timeout; like Pi's theme, accept a late reply too.
|
|
449
|
+
const apply = ({ background }: TerminalColors) => {
|
|
450
|
+
if (background) current.background = rgbColor(background.r, background.g, background.b);
|
|
451
|
+
};
|
|
452
|
+
tui.queryTerminalColors({ timeoutMs: 150, onLateReply: apply }).then(apply, () => {});
|
|
453
|
+
}
|
|
454
|
+
return {
|
|
455
|
+
render: () => [],
|
|
456
|
+
invalidate() {},
|
|
457
|
+
dispose: () => {
|
|
458
|
+
current.settle();
|
|
459
|
+
undo?.();
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
},
|
|
463
|
+
{ placement: "belowEditor" },
|
|
464
|
+
);
|
|
465
|
+
return () => {
|
|
466
|
+
reveal?.settle();
|
|
467
|
+
undo?.();
|
|
468
|
+
undo = undefined;
|
|
469
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
470
|
+
};
|
|
471
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Compact token counts in the style of Pi's footer. */
|
|
2
|
+
export function formatTokens(count: number): string {
|
|
3
|
+
if (count < 1000) return `${Math.round(count)}`;
|
|
4
|
+
if (count < 10_000) return `${(count / 1000).toFixed(1)}k`;
|
|
5
|
+
if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
|
|
6
|
+
if (count < 10_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
7
|
+
return `${Math.round(count / 1_000_000)}M`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** "48s" below one minute, otherwise "2:05". */
|
|
11
|
+
export function formatDuration(ms: number): string {
|
|
12
|
+
const seconds = Math.max(0, Math.round(ms / 1000));
|
|
13
|
+
if (seconds < 60) return `${seconds}s`;
|
|
14
|
+
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A share of the context window, for example 0.824 → "82.4%". */
|
|
18
|
+
export function formatShare(share: number): string {
|
|
19
|
+
return `${(share * 100).toFixed(1)}%`;
|
|
20
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { statusCard } from "./card.ts";
|
|
3
|
+
import { BackgroundCompaction } from "./controller.ts";
|
|
4
|
+
import { inheritOwnFiles } from "./files.ts";
|
|
5
|
+
import { attachFooter, STATUS_KEY } from "./footer.ts";
|
|
6
|
+
import { loadNativeCompaction } from "./native.ts";
|
|
7
|
+
|
|
8
|
+
export default function precompact(pi: ExtensionAPI): void {
|
|
9
|
+
let controller: BackgroundCompaction | undefined;
|
|
10
|
+
let unavailable = "Not started";
|
|
11
|
+
let detachFooter: (() => void) | undefined;
|
|
12
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
13
|
+
detachFooter?.();
|
|
14
|
+
detachFooter = undefined;
|
|
15
|
+
try {
|
|
16
|
+
const started = new BackgroundCompaction(pi, await loadNativeCompaction());
|
|
17
|
+
controller = started;
|
|
18
|
+
started.reset(ctx, "Waiting for enough context");
|
|
19
|
+
// Draw the status beside Pi's context usage. Pi's status row stays the fallback.
|
|
20
|
+
if (ctx.mode === "tui") {
|
|
21
|
+
const motion = process.env.PI_PRECOMPACT_MOTION !== "off";
|
|
22
|
+
detachFooter = attachFooter(ctx, () => started.footerState(), { motion });
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
controller = undefined;
|
|
26
|
+
if (ctx.mode === "tui") ctx.ui.setStatus(STATUS_KEY, "precompact: unavailable");
|
|
27
|
+
unavailable =
|
|
28
|
+
"Unsupported Pi installation. Native compaction remains active; Pi 1.4.0 with dist modules is required.";
|
|
29
|
+
if (ctx.hasUI) ctx.ui.notify(unavailable, "warning");
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
pi.on("turn_end", (event, ctx) => {
|
|
33
|
+
if (event.outcome !== "completed") {
|
|
34
|
+
controller?.reset(ctx, "Turn aborted or failed");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
// Other boundary extensions may be about to rewrite this context.
|
|
38
|
+
if (event.entries.some((entry) => entry.type === "context_edit" || entry.type === "compaction")) {
|
|
39
|
+
controller?.reset(ctx, "Another extension is changing the context");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
controller?.consider(ctx);
|
|
43
|
+
});
|
|
44
|
+
pi.on("session_before_compact", async (event, ctx) => {
|
|
45
|
+
// Also preserve our structured file lists when falling back to native compaction.
|
|
46
|
+
inheritOwnFiles(event.preparation, event.branchEntries);
|
|
47
|
+
return controller?.beforeCompact(event, ctx);
|
|
48
|
+
});
|
|
49
|
+
pi.on("session_compact", (event, ctx) => {
|
|
50
|
+
const notice = controller?.compacted(event, ctx);
|
|
51
|
+
if (notice && ctx.hasUI) ctx.ui.notify(notice, "info");
|
|
52
|
+
});
|
|
53
|
+
pi.on("session_compact_failed", (_event, ctx) =>
|
|
54
|
+
controller?.reset(ctx, "Foreground compaction failed or cancelled"),
|
|
55
|
+
);
|
|
56
|
+
pi.on("model_select", (_event, ctx) => controller?.reset(ctx, "Model changed"));
|
|
57
|
+
pi.on("thinking_level_select", (_event, ctx) => controller?.reset(ctx, "Thinking level changed"));
|
|
58
|
+
pi.on("session_before_switch", (_event, ctx) => controller?.reset(ctx, "Session switching"));
|
|
59
|
+
pi.on("session_before_fork", (_event, ctx) => controller?.reset(ctx, "Session forking"));
|
|
60
|
+
pi.on("session_before_tree", (_event, ctx) => controller?.reset(ctx, "Tree navigation"));
|
|
61
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
62
|
+
controller?.reset(ctx, "Session shutdown");
|
|
63
|
+
controller = undefined;
|
|
64
|
+
detachFooter?.();
|
|
65
|
+
detachFooter = undefined;
|
|
66
|
+
if (ctx.mode === "tui") ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
67
|
+
});
|
|
68
|
+
pi.on("agent_settled", (event, ctx) => {
|
|
69
|
+
if (event.aborted) controller?.reset(ctx, "Agent aborted");
|
|
70
|
+
});
|
|
71
|
+
pi.registerCommand("precompact", {
|
|
72
|
+
description: "Precompact: status, on, off, or reset (session only)",
|
|
73
|
+
handler: async (args, ctx) => {
|
|
74
|
+
const action = args.trim() || "status";
|
|
75
|
+
if (!controller) {
|
|
76
|
+
ctx.ui.notify(unavailable, "warning");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (action === "on" || action === "off") controller.setEnabled(action === "on", ctx);
|
|
80
|
+
else if (action === "reset") controller.reset(ctx, "Manually reset");
|
|
81
|
+
else if (action !== "status") {
|
|
82
|
+
ctx.ui.notify("Usage: /precompact [status|on|off|reset]", "warning");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
controller.refreshStatus(ctx);
|
|
86
|
+
// The card appears only when the user explicitly asks for the status.
|
|
87
|
+
if (action === "status" && ctx.mode === "tui") await showCard(controller, ctx);
|
|
88
|
+
else ctx.ui.notify(controller.status(), "info");
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function showCard(controller: BackgroundCompaction, ctx: ExtensionCommandContext): Promise<void> {
|
|
94
|
+
await ctx.ui.custom<void>((tui, theme, keybindings, done) =>
|
|
95
|
+
statusCard(
|
|
96
|
+
{
|
|
97
|
+
details: () => controller.details(ctx),
|
|
98
|
+
reset: () => controller.reset(ctx, "Manually reset"),
|
|
99
|
+
setEnabled: (enabled) => controller.setEnabled(enabled, ctx),
|
|
100
|
+
},
|
|
101
|
+
tui,
|
|
102
|
+
theme,
|
|
103
|
+
keybindings,
|
|
104
|
+
() => done(undefined),
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
}
|
package/src/native.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import {
|
|
5
|
+
type compact,
|
|
6
|
+
getPackageDir,
|
|
7
|
+
type SessionBeforeCompactEvent,
|
|
8
|
+
type SessionEntry,
|
|
9
|
+
VERSION,
|
|
10
|
+
} from "@earendil-works/pi-coding-agent";
|
|
11
|
+
|
|
12
|
+
export type Preparation = SessionBeforeCompactEvent["preparation"];
|
|
13
|
+
export type CompactionSettings = Preparation["settings"];
|
|
14
|
+
|
|
15
|
+
export interface NativeCompaction {
|
|
16
|
+
prepareCompaction(entries: SessionEntry[], settings: CompactionSettings): Preparation | undefined;
|
|
17
|
+
compact: typeof compact;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
21
|
+
return typeof value === "object" && value !== null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isNativeCompaction(value: unknown): value is NativeCompaction {
|
|
25
|
+
return (
|
|
26
|
+
isRecord(value) && typeof value.prepareCompaction === "function" && typeof value.compact === "function"
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The one private API dependency. Never ship a copied preparation algorithm. */
|
|
31
|
+
export async function loadNativeCompaction(
|
|
32
|
+
root = getPackageDir(),
|
|
33
|
+
version = VERSION,
|
|
34
|
+
): Promise<NativeCompaction> {
|
|
35
|
+
if (version !== "1.4.0") throw new Error("Precompact requires Pi 1.4.0");
|
|
36
|
+
const metadata: unknown = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
|
|
37
|
+
if (
|
|
38
|
+
!isRecord(metadata) ||
|
|
39
|
+
metadata.name !== "@earendil-works/pi-coding-agent" ||
|
|
40
|
+
metadata.version !== version
|
|
41
|
+
) {
|
|
42
|
+
throw new Error("Pi package and runtime versions do not match");
|
|
43
|
+
}
|
|
44
|
+
const module: unknown = await import(pathToFileURL(join(root, "dist/core/compaction/compaction.js")).href);
|
|
45
|
+
if (!isNativeCompaction(module)) throw new Error("Pi native compaction API is unavailable");
|
|
46
|
+
return module;
|
|
47
|
+
}
|