pi-jar 0.1.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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +288 -0
  3. package/docs/DESIGN.md +85 -0
  4. package/docs/DEVELOPMENT.md +95 -0
  5. package/docs/FLAME.md +54 -0
  6. package/docs/INTEGRATIONS.md +82 -0
  7. package/docs/PLAN.md +68 -0
  8. package/docs/SHOWCASE.md +22 -0
  9. package/docs/WORKFLOWS.md +141 -0
  10. package/extensions/index.ts +773 -0
  11. package/package.json +62 -0
  12. package/src/accent.ts +36 -0
  13. package/src/advisor.ts +194 -0
  14. package/src/animations.ts +23 -0
  15. package/src/ask-tool.ts +185 -0
  16. package/src/attachments.ts +79 -0
  17. package/src/changes.ts +146 -0
  18. package/src/commit.ts +61 -0
  19. package/src/compact-tools.ts +129 -0
  20. package/src/composer.ts +404 -0
  21. package/src/context-view.ts +141 -0
  22. package/src/delegate.ts +208 -0
  23. package/src/dialogs.ts +127 -0
  24. package/src/diff-view.ts +160 -0
  25. package/src/flame.ts +245 -0
  26. package/src/footer-settings.ts +32 -0
  27. package/src/footer.ts +147 -0
  28. package/src/goal-loop.ts +313 -0
  29. package/src/goals.ts +114 -0
  30. package/src/history-ui.ts +136 -0
  31. package/src/history.ts +168 -0
  32. package/src/info-panels.ts +75 -0
  33. package/src/mascot.ts +103 -0
  34. package/src/model-roles.ts +332 -0
  35. package/src/panel.ts +90 -0
  36. package/src/plan-utils.ts +191 -0
  37. package/src/plan-view.ts +149 -0
  38. package/src/plan.ts +460 -0
  39. package/src/prompt-search.ts +83 -0
  40. package/src/quota.ts +134 -0
  41. package/src/roles-ui.ts +190 -0
  42. package/src/roles.ts +25 -0
  43. package/src/session-gallery.ts +52 -0
  44. package/src/session-ui.ts +76 -0
  45. package/src/settings-ui.ts +154 -0
  46. package/src/settings.ts +96 -0
  47. package/src/shells.ts +319 -0
  48. package/src/side-model.ts +48 -0
  49. package/src/split-view.ts +74 -0
  50. package/src/status.ts +71 -0
  51. package/src/suggest.ts +89 -0
  52. package/src/task-tool.ts +135 -0
  53. package/src/tasks-ui.ts +73 -0
  54. package/src/tasks.ts +147 -0
  55. package/src/usage-view.ts +98 -0
  56. package/src/usage.ts +16 -0
  57. package/src/welcome.ts +260 -0
  58. package/src/working.ts +60 -0
  59. package/themes/pi-jar-dark-amber.json +84 -0
  60. package/themes/pi-jar-dark-azure.json +84 -0
  61. package/themes/pi-jar-dark-gray.json +84 -0
  62. package/themes/pi-jar-dark-pink.json +84 -0
  63. package/themes/pi-jar-dark-teal.json +84 -0
  64. package/themes/pi-jar-dark-violet.json +84 -0
  65. package/themes/pi-jar-dark.json +84 -0
@@ -0,0 +1,404 @@
1
+ import { CustomEditor, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Key, matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, type EditorComponent, type EditorTheme, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
+ import { Mascot, MASCOT_FACE_WIDTH, mascotFace, paintFace, paintTip, type MascotMood } from "./mascot.ts";
4
+ import type { SuggestionState } from "./suggest.ts";
5
+ import type { WorkingPhase } from "./working.ts";
6
+ import { imageChip, imageInfo, imagePaths } from "./attachments.ts";
7
+
8
+ type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
9
+ type MouseEvent = Parameters<NonNullable<EditorComponent["handleMouse"]>>[0];
10
+
11
+ const PHASE_MOODS: Record<WorkingPhase, readonly MascotMood[]> = {
12
+ idle: ["idle"], generating: ["happy", "thinking"], tool: ["tool"], waiting: ["waiting"]
13
+ };
14
+ /** Plain one-line face for a phase; every phase has the same width. */
15
+ export function composerIcon(phase: WorkingPhase, frame = 0): string {
16
+ const moods = PHASE_MOODS[phase];
17
+ return mascotFace(moods[frame % moods.length] ?? "idle");
18
+ }
19
+ export function shortSessionId(id?: string): string {
20
+ const clean = id?.trim();
21
+ if (!clean) return "";
22
+ const tail = clean.includes("-") ? clean.split("-").at(-1)! : clean;
23
+ return tail.slice(0, 8);
24
+ }
25
+
26
+ const SESSION_FIRST = ["ember", "quiet", "steady", "silver", "warm", "clear", "gentle", "lunar"] as const;
27
+ const SESSION_LAST = ["trail", "lantern", "forge", "orbit", "harbor", "path", "spark", "grove"] as const;
28
+
29
+ function sessionHash(value: string): number {
30
+ let hash = 2166136261;
31
+ for (const char of value) {
32
+ hash ^= char.codePointAt(0) ?? 0;
33
+ hash = Math.imul(hash, 16777619);
34
+ }
35
+ return hash >>> 0;
36
+ }
37
+
38
+ /** Prefer Pi's human session title; otherwise derive a stable readable alias from the thread id. */
39
+ export function sessionDisplayName(name?: string, id?: string): string {
40
+ const explicit = name?.trim().replace(/\s+/g, " ");
41
+ if (explicit) return explicit;
42
+ const hash = sessionHash(id?.trim() || "pi-jar");
43
+ const first = SESSION_FIRST[hash % SESSION_FIRST.length]!;
44
+ const last = SESSION_LAST[Math.floor(hash / SESSION_FIRST.length) % SESSION_LAST.length]!;
45
+ return `${first}-${last}`;
46
+ }
47
+
48
+ const CURSOR = "\x1b[7m \x1b[0m";
49
+ const BORDER = /^─+(?:\s*[↑↓]\s*\d+\s*more\s*─*)?$/;
50
+ /** Index of the editor's bottom border: known row count when available, else the first plain border row. */
51
+ function bottomBorderIndex(lines: readonly string[], visibleRows?: number): number {
52
+ if (visibleRows != null && visibleRows >= 0 && BORDER.test(stripTerminalSequences(lines[visibleRows + 1] ?? "").trim())) return visibleRows + 1;
53
+ for (let index = 1; index < lines.length; index++) if (BORDER.test(stripTerminalSequences(lines[index]!).trim())) return index;
54
+ return lines.length - 1;
55
+ }
56
+ const overflowLabel = (line: string | undefined) => /[↑↓]\s*\d+\s*more/.exec(stripTerminalSequences(line ?? ""))?.[0];
57
+
58
+ export interface RoundedInputOptions {
59
+ /** Dim inline suggestion shown after the cursor when the draft is empty. */
60
+ ghost?: string;
61
+ /** Dim key hints in the bottom border. */
62
+ hint?: string;
63
+ /** Pre-painted icon (overrides `icon` for rendering, `icon` still sets width). */
64
+ paintedIcon?: string;
65
+ /** Known number of editor content rows (lets the frame skip heuristics). */
66
+ visibleRows?: number;
67
+ /** A painted row directly below the frame (image attachment chips). */
68
+ below?: string;
69
+ dim?: (text: string) => string;
70
+ }
71
+
72
+ /** Frame a real editor without changing its keyboard, history or autocomplete implementation. */
73
+ export function roundedInput(lines: string[], width: number, focused: boolean, theme: EditorTheme, focusPaint?: (text: string) => string,
74
+ icon = composerIcon("idle"), session = "", options: RoundedInputOptions = {}): string[] {
75
+ if (width < 8 || lines.length < 2) return lines.map((line) => truncateToWidth(line, Math.max(0, width)));
76
+ const border = focused && focusPaint ? focusPaint : theme.borderColor;
77
+ const dim = options.dim ?? ((text: string) => text);
78
+ const bottom = bottomBorderIndex(lines, options.visibleRows);
79
+ const inner = width - 2;
80
+ const meta = session ? `${icon} · session ${session}` : icon;
81
+ const title = truncateToWidth(` ${meta} `, width - 3);
82
+ const paintedTitle = options.paintedIcon && title.includes(icon) ? title.replace(icon, options.paintedIcon) : title;
83
+ const above = overflowLabel(lines[0]);
84
+ const topRoom = width - 3 - visibleWidth(title);
85
+ const topRight = above && topRoom > visibleWidth(above) + 3 ? " " + above + " ─" : "";
86
+ const top = border("╭─") + paintedTitle + border("─".repeat(Math.max(0, topRoom - visibleWidth(topRight))) + topRight + "╮");
87
+ const below = overflowLabel(lines[bottom]);
88
+ const hint = options.hint && width >= 60 ? ` ${options.hint} ` : "";
89
+ const left = below ? `─ ${below} ` : "";
90
+ const fill = Math.max(0, width - 2 - visibleWidth(left) - visibleWidth(hint) - 1);
91
+ const bottomLine = border("╰" + left + "─".repeat(fill)) + (hint ? dim(hint) : "") + border((hint ? "─" : "─") + "╯");
92
+ const content = lines.slice(1, bottom).map((line, index) => {
93
+ let row = line;
94
+ if (index === 0 && options.ghost && row.includes(CURSOR)) {
95
+ const base = row.replace(/ +$/, "");
96
+ const room = inner - visibleWidth(base);
97
+ if (room > 2) row = base + dim(truncateToWidth(options.ghost + " ⇥ tab", room, "…"));
98
+ }
99
+ const fitted = truncateToWidth(row, inner);
100
+ return border("│") + fitted + " ".repeat(Math.max(0, inner - visibleWidth(fitted))) + border("│");
101
+ });
102
+ // Autocomplete rows stay below the frame, indented to line up with the text.
103
+ const trailing = lines.slice(bottom + 1).map((line) => truncateToWidth(" " + line, width));
104
+ return [truncateToWidth(top, width), ...content, truncateToWidth(bottomLine, width),
105
+ ...(options.below ? [truncateToWidth(" " + options.below, width)] : []), ...trailing];
106
+ }
107
+
108
+ export interface ComposerDecor {
109
+ /** Title icon width source (plain text). */
110
+ icon(): string;
111
+ /** Painted icon, or undefined to use theme border colors. */
112
+ paintedIcon(): string | undefined;
113
+ /** Flame-tip row above the frame, or undefined when there is no room. */
114
+ tip(width: number): string | undefined;
115
+ session(): string;
116
+ ghost(): string | undefined;
117
+ hint(): string | undefined;
118
+ accept(): string | undefined;
119
+ typed(): void;
120
+ poke(): void;
121
+ dim(text: string): string;
122
+ /** Chips for images referenced in the draft, or undefined. */
123
+ attachments(text: string): string | undefined;
124
+ }
125
+
126
+ /** The face starts after `╭─ ` in the top border. */
127
+ const faceColumns = (x: number) => x >= 3 && x < 3 + MASCOT_FACE_WIDTH;
128
+
129
+ class RoundedEditor extends CustomEditor {
130
+ private readonly colors: EditorTheme;
131
+ private readonly paint: (text: string) => string;
132
+ private readonly decor: ComposerDecor;
133
+ private tipRows = 0;
134
+ private ghostShown = false;
135
+ constructor(tui: TUI, colors: EditorTheme, keys: ConstructorParameters<typeof CustomEditor>[2], paint: (text: string) => string, decor: ComposerDecor) {
136
+ super(tui, colors, keys);
137
+ this.colors = colors;
138
+ this.paint = paint;
139
+ this.decor = decor;
140
+ }
141
+
142
+ /** Let drafts grow to ~60% of the terminal (Pi's editor caps at 30%) before scrolling. */
143
+ private renderTall(width: number): string[] {
144
+ const self = this as unknown as { tui?: { terminal?: { rows?: number } } };
145
+ const tui = self.tui;
146
+ const rows = tui?.terminal?.rows;
147
+ if (!tui || !rows || !Number.isFinite(rows)) return super.render(width);
148
+ const tall = new Proxy(tui, { get: (target, key, receiver) => key === "terminal"
149
+ ? new Proxy(target.terminal!, { get: (term, name) => name === "rows" ? rows * 2 : Reflect.get(term, name, term) })
150
+ : Reflect.get(target, key, receiver) });
151
+ try {
152
+ self.tui = tall;
153
+ return super.render(width);
154
+ } finally {
155
+ self.tui = tui;
156
+ }
157
+ }
158
+
159
+ override render(width: number): string[] {
160
+ const inner = this.renderTall(width < 8 ? width : width - 2);
161
+ const empty = this.getText() === "";
162
+ const ghost = empty && !this.isShowingAutocomplete() ? this.decor.ghost() : undefined;
163
+ this.ghostShown = !!ghost;
164
+ const visibleRows = (this as unknown as { renderedVisibleLineCount?: number }).renderedVisibleLineCount;
165
+ const framed = roundedInput(inner, width, this.focused, this.colors, this.paint, this.decor.icon(), this.decor.session(), {
166
+ ...(ghost ? { ghost } : {}), ...(this.decor.hint() ? { hint: this.decor.hint()! } : {}),
167
+ ...(this.decor.paintedIcon() ? { paintedIcon: this.decor.paintedIcon()! } : {}),
168
+ ...(visibleRows != null ? { visibleRows } : {}), dim: this.decor.dim,
169
+ ...(this.decor.attachments(this.getText()) ? { below: this.decor.attachments(this.getText())! } : {})
170
+ });
171
+ const tip = width >= 8 ? this.decor.tip(width) : undefined;
172
+ this.tipRows = tip ? 1 : 0;
173
+ return tip ? [truncateToWidth(tip, width), ...framed] : framed;
174
+ }
175
+
176
+ override handleInput(data: string): void {
177
+ if (this.getText() === "" && !this.isShowingAutocomplete() && (matchesKey(data, Key.tab) || matchesKey(data, Key.right))) {
178
+ const accepted = this.decor.accept();
179
+ if (accepted) { this.setText(accepted); return; }
180
+ }
181
+ super.handleInput(data);
182
+ if (this.getText() !== "") this.decor.typed();
183
+ }
184
+
185
+ override handleMouse(event: MouseEvent) {
186
+ const y = event.y - this.tipRows;
187
+ if ((y === 0 || y < 0) && faceColumns(event.x)) {
188
+ if (event.type === "click" && event.button === "left") { this.decor.poke(); return { handled: true, focus: true }; }
189
+ return undefined;
190
+ }
191
+ if (y === 1 && this.ghostShown && event.type === "click" && event.button === "left" && event.x > 2) {
192
+ const accepted = this.decor.accept();
193
+ if (accepted) { this.setText(accepted); return { handled: true, focus: true }; }
194
+ }
195
+ if (y < 0) return undefined;
196
+ return super.handleMouse({ ...event, y, x: event.x - 1, width: Math.max(1, event.width - 2), height: Math.max(1, event.height - this.tipRows) });
197
+ }
198
+ }
199
+
200
+ class ThemedEditor implements EditorComponent {
201
+ private readonly base: EditorComponent;
202
+ private readonly theme: EditorTheme;
203
+ private readonly paint: (text: string) => string;
204
+ private readonly decor: ComposerDecor;
205
+ private tipRows = 0;
206
+ constructor(base: EditorComponent, theme: EditorTheme, paint: (text: string) => string, decor: ComposerDecor) {
207
+ this.base = base;
208
+ this.theme = theme;
209
+ this.paint = paint;
210
+ this.decor = decor;
211
+ }
212
+ get focused(): boolean { return "focused" in this.base ? !!this.base.focused : false; }
213
+ set focused(value: boolean) { if ("focused" in this.base) this.base.focused = value; }
214
+ get onSubmit() { return this.base.onSubmit; }
215
+ set onSubmit(handler: ((text: string) => void) | undefined) { this.base.onSubmit = handler; }
216
+ get onChange() { return this.base.onChange; }
217
+ set onChange(handler: ((text: string) => void) | undefined) { this.base.onChange = handler; }
218
+ get borderColor() { return this.base.borderColor; }
219
+ set borderColor(color: ((str: string) => string) | undefined) { this.base.borderColor = color; }
220
+ getText() { return this.base.getText(); }
221
+ setText(text: string) { this.base.setText(text); }
222
+ handleInput(data: string) {
223
+ if (this.base.getText() === "" && matchesKey(data, Key.tab)) {
224
+ const accepted = this.decor.accept();
225
+ if (accepted) { this.base.setText(accepted); return; }
226
+ }
227
+ this.base.handleInput(data);
228
+ if (this.base.getText() !== "") this.decor.typed();
229
+ }
230
+ invalidate() { this.base.invalidate(); }
231
+ render(width: number): string[] {
232
+ const ghost = this.base.getText() === "" ? this.decor.ghost() : undefined;
233
+ const framed = roundedInput(this.base.render(width < 8 ? width : width - 2), width, this.focused, this.theme, this.paint, this.decor.icon(), this.decor.session(), {
234
+ ...(ghost ? { ghost } : {}), ...(this.decor.paintedIcon() ? { paintedIcon: this.decor.paintedIcon()! } : {}), dim: this.decor.dim,
235
+ ...(this.decor.attachments(this.base.getText()) ? { below: this.decor.attachments(this.base.getText())! } : {})
236
+ });
237
+ const tip = width >= 8 ? this.decor.tip(width) : undefined;
238
+ this.tipRows = tip ? 1 : 0;
239
+ return tip ? [truncateToWidth(tip, width), ...framed] : framed;
240
+ }
241
+ addToHistory(text: string) { this.base.addToHistory?.(text); }
242
+ insertTextAtCursor(text: string) { this.base.insertTextAtCursor?.(text); }
243
+ getExpandedText() { return this.base.getExpandedText?.() ?? this.base.getText(); }
244
+ setAutocompleteProvider(provider: Parameters<NonNullable<EditorComponent["setAutocompleteProvider"]>>[0]) { this.base.setAutocompleteProvider?.(provider); }
245
+ setPaddingX(value: number) { this.base.setPaddingX?.(value); }
246
+ setAutocompleteMaxVisible(value: number) { this.base.setAutocompleteMaxVisible?.(value); }
247
+ handleMouse(event: MouseEvent) {
248
+ const y = event.y - this.tipRows;
249
+ if (y < 0) return undefined;
250
+ return this.base.handleMouse?.({ ...event, y, x: event.x - 1, width: Math.max(1, event.width - 2) });
251
+ }
252
+ }
253
+
254
+ export class ComposerStyle {
255
+ enabled = false;
256
+ private owner?: EditorFactory;
257
+ private previous?: EditorFactory;
258
+ private tui?: TUI;
259
+ private timer?: ReturnType<typeof setInterval>;
260
+ private phase: WorkingPhase = "idle";
261
+ private frame = 0;
262
+ private animations = true;
263
+ private mascotOn = true;
264
+ private session = "";
265
+ private cwd = process.cwd();
266
+ private lastKey = "";
267
+ private readonly mascot = new Mascot();
268
+ private suggestions?: SuggestionState;
269
+ private unsubscribe?: () => void;
270
+ private colors?: { fg(color: string, text: string): string };
271
+
272
+ private readonly decor: ComposerDecor = {
273
+ icon: () => this.mascotOn ? mascotFace(this.animations ? this.mascot.mood() : "idle") : "pi",
274
+ paintedIcon: () => {
275
+ if (!this.mascotOn) return undefined;
276
+ const mood = this.animations ? this.mascot.mood() : "idle";
277
+ return paintFace(mascotFace(mood), mood, this.colors ? (color, text) => this.colors!.fg(color, text) : undefined);
278
+ },
279
+ tip: (width) => {
280
+ if (!this.mascotOn || width < 40) return undefined;
281
+ const mood = this.animations ? this.mascot.mood() : "idle";
282
+ const tip = this.animations ? this.mascot.tip(Date.now(), this.frame) : this.mascot.tip(0, 0);
283
+ return " ".repeat(3) + paintTip(tip, mood, this.colors ? (color, text) => this.colors!.fg(color, text) : undefined);
284
+ },
285
+ session: () => this.session,
286
+ ghost: () => this.suggestions?.text,
287
+ hint: () => this.suggestions?.text ? "⇥ accept · ⏎ send · ⇧⏎ newline" : "⏎ send · ⇧⏎ newline · /plan · /goal",
288
+ accept: () => {
289
+ const text = this.suggestions?.text;
290
+ if (text) this.suggestions!.clear();
291
+ return text;
292
+ },
293
+ typed: () => this.suggestions?.clear(),
294
+ poke: () => { this.mascot.flash("poke", 1500); this.tui?.requestRender(); },
295
+ dim: (text) => this.colors?.fg("dim", text) ?? `\x1b[2m${text}\x1b[22m`,
296
+ attachments: (text) => {
297
+ if (!/\.(png|jpe?g|gif|webp)\b/i.test(text)) return undefined;
298
+ const chips = imagePaths(text, this.cwd).map(imageInfo).filter((info) => !!info).map((info) => imageChip(info!));
299
+ if (!chips.length) return undefined;
300
+ return chips.map((chip) => this.colors?.fg("accent", chip) ?? chip).join(this.colors?.fg("dim", " ") ?? " ");
301
+ }
302
+ };
303
+
304
+ /** Share the suggestion state rendered as ghost text. */
305
+ attachSuggestions(state: SuggestionState): void {
306
+ this.unsubscribe?.();
307
+ this.suggestions = state;
308
+ this.unsubscribe = state.onChange(() => { if (this.enabled) this.tui?.requestRender(); });
309
+ }
310
+
311
+ setMascot(on: boolean): void {
312
+ if (this.mascotOn === on) return;
313
+ this.mascotOn = on;
314
+ this.restartTimer();
315
+ if (this.enabled) this.tui?.requestRender();
316
+ }
317
+
318
+ /** Short-lived expression, e.g. on errors or a completed goal. */
319
+ flash(mood: "error" | "complete" | "poke", ms = 3000): void {
320
+ this.mascot.flash(mood, ms);
321
+ if (this.enabled) this.tui?.requestRender();
322
+ }
323
+
324
+ setActivity(phase: WorkingPhase, animations: boolean): void {
325
+ const changed = this.phase !== phase || this.animations !== animations;
326
+ this.mascot.setPhase(phase);
327
+ if (!changed && (this.timer || !this.enabled || !animations)) return;
328
+ this.phase = phase;
329
+ this.animations = animations;
330
+ if (changed) this.frame = 0;
331
+ this.restartTimer();
332
+ if (changed && this.enabled) this.tui?.requestRender();
333
+ }
334
+
335
+ /** One unref'd timer drives flicker, blinks and sleepiness; it renders only when the sprite changes. */
336
+ private restartTimer(): void {
337
+ this.stopTimer();
338
+ if (!this.enabled || !this.animations) return;
339
+ const active = this.phase === "generating" || this.phase === "tool";
340
+ if (!active && !this.mascotOn) return;
341
+ this.timer = setInterval(() => {
342
+ this.frame++;
343
+ const key = this.mascotOn ? this.mascot.key(Date.now(), this.frame) : String(this.frame);
344
+ if (key === this.lastKey) return;
345
+ this.lastKey = key;
346
+ this.tui?.requestRender();
347
+ }, 240);
348
+ this.timer.unref?.();
349
+ }
350
+ private stopTimer(): void { if (this.timer) clearInterval(this.timer); this.timer = undefined; }
351
+ refreshSession(ctx: ExtensionContext): void {
352
+ const manager = ctx.sessionManager as { getSessionId?: () => string; getSessionName?: () => string | undefined } | undefined;
353
+ const next = sessionDisplayName(manager?.getSessionName?.(), manager?.getSessionId?.());
354
+ if (next === this.session) return;
355
+ this.session = next;
356
+ if (this.enabled) this.tui?.requestRender();
357
+ }
358
+ enable(ctx: ExtensionContext): boolean {
359
+ if (!ctx.hasUI || ctx.mode !== "tui") return false;
360
+ if (this.enabled) return true;
361
+ try {
362
+ const previous = ctx.ui.getEditorComponent();
363
+ const draft = ctx.ui.getEditorText();
364
+ if (ctx.cwd) this.cwd = ctx.cwd;
365
+ const manager = ctx.sessionManager as { getSessionId?: () => string; getSessionName?: () => string | undefined } | undefined;
366
+ this.session = sessionDisplayName(manager?.getSessionName?.(), manager?.getSessionId?.());
367
+ const factory: EditorFactory = (tui, theme, keys) => {
368
+ this.tui = tui;
369
+ this.colors = ctx.ui.theme;
370
+ const paint = (text: string) => ctx.ui.theme?.fg("accent", text) ?? theme.borderColor(text);
371
+ return previous
372
+ ? new ThemedEditor(previous(tui, theme, keys), theme, paint, this.decor)
373
+ : new RoundedEditor(tui, theme, keys, paint, this.decor);
374
+ };
375
+ this.previous = previous;
376
+ this.owner = factory;
377
+ ctx.ui.setEditorComponent(factory);
378
+ ctx.ui.setEditorText(draft);
379
+ this.enabled = true;
380
+ this.restartTimer();
381
+ return true;
382
+ } catch {
383
+ this.disable(ctx);
384
+ return false;
385
+ }
386
+ }
387
+ disable(ctx: ExtensionContext): void {
388
+ this.stopTimer();
389
+ this.tui = undefined;
390
+ this.phase = "idle";
391
+ this.frame = 0;
392
+ this.session = "";
393
+ try {
394
+ if (ctx.hasUI && ctx.mode === "tui" && this.owner && ctx.ui.getEditorComponent() === this.owner) {
395
+ const draft = ctx.ui.getEditorText();
396
+ ctx.ui.setEditorComponent(this.previous);
397
+ ctx.ui.setEditorText(draft);
398
+ }
399
+ } catch { /* Never let optional editor styling block Pi. */ }
400
+ this.owner = undefined;
401
+ this.previous = undefined;
402
+ this.enabled = false;
403
+ }
404
+ }
@@ -0,0 +1,141 @@
1
+ import { visibleWidth } from "@earendil-works/pi-tui";
2
+ import { row, tokens, type Paint } from "./panel.ts";
3
+
4
+ /** Rough token estimate used where the provider does not report per-part counts. */
5
+ export const estimate = (chars: number) => Math.ceil(chars / 4);
6
+
7
+ export interface ContextPart { key: string; label: string; tokens: number; color: string }
8
+ export interface ContextFileInfo { path: string; tokens: number }
9
+ export interface ContextBreakdown {
10
+ model: string;
11
+ window: number;
12
+ /** Provider-reported total, when known. */
13
+ reported: number | null;
14
+ parts: ContextPart[];
15
+ buffer: number;
16
+ files: ContextFileInfo[];
17
+ skills: ContextFileInfo[];
18
+ tools: ContextFileInfo[];
19
+ }
20
+
21
+ type Message = { role?: string; content?: unknown; summary?: string; command?: string; output?: string; toolName?: string };
22
+ interface Inputs {
23
+ model: string;
24
+ window: number;
25
+ reported: number | null;
26
+ systemPrompt: string;
27
+ contextFiles: readonly { path: string; content: string }[];
28
+ skills: readonly { name: string; description: string; filePath: string }[];
29
+ tools: readonly { name: string; description?: string; parameters?: unknown }[];
30
+ messages: readonly Message[];
31
+ reserve: number;
32
+ }
33
+
34
+ const partChars = (content: unknown): number => typeof content === "string" ? content.length
35
+ : Array.isArray(content) ? content.reduce((sum: number, part: { type?: string; text?: string; thinking?: string; arguments?: unknown; data?: string }) =>
36
+ sum + (part.type === "text" ? part.text?.length ?? 0 : part.type === "thinking" ? part.thinking?.length ?? 0
37
+ : part.type === "toolCall" ? JSON.stringify(part.arguments ?? {}).length + 40 : part.type === "image" ? 6400 : 0), 0)
38
+ : 0;
39
+
40
+ /**
41
+ * Split the context into Claude-style categories. Parts are estimated at ~4 characters per token;
42
+ * when the provider reported a total, the estimate is scaled to match it.
43
+ */
44
+ export function contextBreakdown(input: Inputs): ContextBreakdown {
45
+ const files = input.contextFiles.map((file) => ({ path: file.path, tokens: estimate(file.content.length) }));
46
+ const skills = input.skills.map((skill) => ({ path: skill.name, tokens: estimate(skill.name.length + skill.description.length + skill.filePath.length + 30) }));
47
+ const tools = input.tools.map((tool) => ({ path: tool.name, tokens: estimate(tool.name.length + (tool.description?.length ?? 0) + JSON.stringify(tool.parameters ?? {}).length) }));
48
+ const sum = (items: readonly ContextFileInfo[]) => items.reduce((total, item) => total + item.tokens, 0);
49
+ const system = Math.max(0, estimate(input.systemPrompt.length) - sum(files) - sum(skills));
50
+ const kinds = { user: 0, assistant: 0, tool: 0, custom: 0, summary: 0 };
51
+ for (const message of input.messages) {
52
+ const chars = partChars(message.content);
53
+ if (message.role === "user") kinds.user += chars;
54
+ else if (message.role === "assistant") kinds.assistant += chars;
55
+ else if (message.role === "toolResult") kinds.tool += chars;
56
+ else if (message.role === "bashExecution") kinds.tool += (message.command?.length ?? 0) + (message.output?.length ?? 0);
57
+ else if (message.role === "compactionSummary" || message.role === "branchSummary") kinds.summary += message.summary?.length ?? chars;
58
+ else kinds.custom += chars;
59
+ }
60
+ const parts: ContextPart[] = [
61
+ { key: "system", label: "System prompt", tokens: system, color: "muted" },
62
+ { key: "tools", label: "System tools", tokens: sum(tools), color: "syntaxType" },
63
+ { key: "files", label: "Context files", tokens: sum(files), color: "warning" },
64
+ { key: "skills", label: "Skills", tokens: sum(skills), color: "syntaxFunction" },
65
+ { key: "summary", label: "Compaction summary", tokens: estimate(kinds.summary), color: "thinkingText" },
66
+ { key: "user", label: "User messages", tokens: estimate(kinds.user), color: "accent" },
67
+ { key: "assistant", label: "Assistant messages", tokens: estimate(kinds.assistant), color: "success" },
68
+ { key: "tool", label: "Tool results", tokens: estimate(kinds.tool), color: "syntaxKeyword" },
69
+ { key: "custom", label: "Extension messages", tokens: estimate(kinds.custom), color: "customMessageLabel" }
70
+ ];
71
+ const estimated = parts.reduce((total, part) => total + part.tokens, 0);
72
+ if (input.reported && estimated > 0) {
73
+ const scale = input.reported / estimated;
74
+ for (const item of [...parts, ...files, ...skills, ...tools]) item.tokens = Math.round(item.tokens * scale);
75
+ }
76
+ return { model: input.model, window: input.window, reported: input.reported, parts: parts.filter((part) => part.tokens > 0),
77
+ buffer: Math.min(input.reserve, input.window), files, skills, tools };
78
+ }
79
+
80
+ const GRID = 10;
81
+
82
+ /** Claude-style `/context`: a 10×10 grid (1 cell ≈ 1% of the window) beside the legend, then per-item detail. */
83
+ export function contextLines(view: ContextBreakdown, width: number, fg: Paint): string[] {
84
+ const used = view.parts.reduce((total, part) => total + part.tokens, 0);
85
+ if (view.window <= 0) {
86
+ return [fg("accent", view.model) + fg("dim", ` · ~${tokens(used)} tokens · context window unknown (select a model)`),
87
+ ...view.parts.map((part) => fg(part.color, "⛁ ") + `${part.label}: ` + fg("dim", tokens(part.tokens)))];
88
+ }
89
+ const windowSize = Math.max(1, view.window);
90
+ const free = Math.max(0, windowSize - used - view.buffer);
91
+ const pct = (value: number) => (value / windowSize * 100).toFixed(1).replace(/\.0$/, "") + "%";
92
+ // Each cell is filled by the category holding most of its 1% slice.
93
+ const cells: { color: string; glyph: string }[] = [];
94
+ const segments = [...view.parts.map((part) => ({ tokens: part.tokens, color: part.color, glyph: "⛁" })),
95
+ { tokens: free, color: "dim", glyph: "⛶" }, { tokens: view.buffer, color: "dim", glyph: "⛝" }];
96
+ const unit = windowSize / (GRID * GRID);
97
+ let cursor = 0;
98
+ let segment = 0;
99
+ let offset = 0;
100
+ for (let cell = 0; cell < GRID * GRID; cell++) {
101
+ const end = (cell + 1) * unit;
102
+ const shares = new Map<number, number>();
103
+ while (cursor < end && segment < segments.length) {
104
+ const take = Math.min(segments[segment]!.tokens - offset, end - cursor);
105
+ if (take > 0) { shares.set(segment, (shares.get(segment) ?? 0) + take); cursor += take; offset += take; }
106
+ if (offset >= segments[segment]!.tokens) { segment++; offset = 0; }
107
+ }
108
+ const [best, share] = [...shares.entries()].sort((a, b) => b[1] - a[1])[0] ?? [segments.length - 1, unit];
109
+ const chosen = segments[best]!;
110
+ cells.push({ color: chosen.color, glyph: chosen.glyph === "⛁" && share < unit / 2 ? "⛀" : chosen.glyph });
111
+ }
112
+ const grid = Array.from({ length: GRID }, (_, y) => cells.slice(y * GRID, y * GRID + GRID).map((cell) => fg(cell.color, cell.glyph)).join(" "));
113
+ const legend = [
114
+ fg("accent", view.model) + fg("dim", ` · ${tokens(used)}/${tokens(windowSize)} tokens (${pct(used)})`),
115
+ fg("dim", view.reported ? "estimated per part, scaled to the reported total" : "estimated (~4 chars/token); exact after the next response"),
116
+ ...view.parts.map((part) => fg(part.color, "⛁ ") + `${part.label}: ` + fg("dim", `${tokens(part.tokens)} (${pct(part.tokens)})`)),
117
+ fg("dim", "⛶ ") + "Free space: " + fg("dim", `${tokens(free)} (${pct(free)})`),
118
+ fg("dim", "⛝ ") + "Autocompact buffer: " + fg("dim", `${tokens(view.buffer)} (${pct(view.buffer)})`)
119
+ ];
120
+ const gridWidth = GRID * 2 - 1;
121
+ const lines: string[] = [];
122
+ if (width >= gridWidth + 4 + 36) {
123
+ for (let index = 0; index < Math.max(grid.length, legend.length); index++) {
124
+ lines.push((grid[index] ?? " ".repeat(gridWidth)) + " " + (legend[index] ?? ""));
125
+ }
126
+ } else lines.push(...grid, "", ...legend);
127
+ const section = (title: string, items: readonly ContextFileInfo[]) => {
128
+ if (!items.length) return;
129
+ lines.push("", fg("accent", title));
130
+ for (const item of [...items].sort((a, b) => b.tokens - a.tokens)) {
131
+ const value = fg("dim", tokens(item.tokens));
132
+ const room = Math.max(8, width - 4 - visibleWidth(value));
133
+ const label = item.path.length > room ? "…" + item.path.slice(-(room - 1)) : item.path;
134
+ lines.push(" " + row(label, value, width - 2));
135
+ }
136
+ };
137
+ section("Context files", view.files);
138
+ section("Skills", view.skills);
139
+ section("Tools", view.tools);
140
+ return lines;
141
+ }