pi-umbra 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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +27 -0
  3. package/node_modules/pi-umbra-ask/LICENSE +21 -0
  4. package/node_modules/pi-umbra-ask/README.md +21 -0
  5. package/node_modules/pi-umbra-ask/checks/umbra-ask.check.ts +132 -0
  6. package/node_modules/pi-umbra-ask/extensions/umbra-ask.ts +484 -0
  7. package/node_modules/pi-umbra-ask/lib/clean.ts +25 -0
  8. package/node_modules/pi-umbra-ask/package.json +37 -0
  9. package/node_modules/pi-umbra-copy-chat/LICENSE +21 -0
  10. package/node_modules/pi-umbra-copy-chat/README.md +19 -0
  11. package/node_modules/pi-umbra-copy-chat/extensions/umbra-copy-chat.ts +154 -0
  12. package/node_modules/pi-umbra-copy-chat/package.json +32 -0
  13. package/node_modules/pi-umbra-help/LICENSE +21 -0
  14. package/node_modules/pi-umbra-help/README.md +21 -0
  15. package/node_modules/pi-umbra-help/checks/umbra-help.check.ts +108 -0
  16. package/node_modules/pi-umbra-help/extensions/umbra-help.ts +223 -0
  17. package/node_modules/pi-umbra-help/lib/umbra-settings.ts +52 -0
  18. package/node_modules/pi-umbra-help/package.json +34 -0
  19. package/node_modules/pi-umbra-inputbar/LICENSE +21 -0
  20. package/node_modules/pi-umbra-inputbar/README.md +19 -0
  21. package/node_modules/pi-umbra-inputbar/checks/umbra-inputbar.check.ts +53 -0
  22. package/node_modules/pi-umbra-inputbar/extensions/umbra-inputbar.ts +130 -0
  23. package/node_modules/pi-umbra-inputbar/package.json +33 -0
  24. package/node_modules/pi-umbra-preview/LICENSE +21 -0
  25. package/node_modules/pi-umbra-preview/README.md +32 -0
  26. package/node_modules/pi-umbra-preview/checks/umbra-preview.check.ts +38 -0
  27. package/node_modules/pi-umbra-preview/extensions/umbra-preview.ts +147 -0
  28. package/node_modules/pi-umbra-preview/package.json +33 -0
  29. package/node_modules/pi-umbra-rename/LICENSE +21 -0
  30. package/node_modules/pi-umbra-rename/README.md +19 -0
  31. package/node_modules/pi-umbra-rename/checks/umbra-rename.check.ts +55 -0
  32. package/node_modules/pi-umbra-rename/extensions/umbra-rename.ts +22 -0
  33. package/node_modules/pi-umbra-rename/package.json +33 -0
  34. package/node_modules/pi-umbra-shimmer/LICENSE +21 -0
  35. package/node_modules/pi-umbra-shimmer/README.md +19 -0
  36. package/node_modules/pi-umbra-shimmer/checks/umbra-shimmer.check.ts +89 -0
  37. package/node_modules/pi-umbra-shimmer/extensions/umbra-shimmer.ts +183 -0
  38. package/node_modules/pi-umbra-shimmer/package.json +33 -0
  39. package/node_modules/pi-umbra-skill-matcher/LICENSE +21 -0
  40. package/node_modules/pi-umbra-skill-matcher/README.md +19 -0
  41. package/node_modules/pi-umbra-skill-matcher/checks/umbra-skill-matcher.check.ts +94 -0
  42. package/node_modules/pi-umbra-skill-matcher/extensions/umbra-skill-matcher.ts +124 -0
  43. package/node_modules/pi-umbra-skill-matcher/package.json +35 -0
  44. package/node_modules/pi-umbra-theme/LICENSE +21 -0
  45. package/node_modules/pi-umbra-theme/README.md +38 -0
  46. package/node_modules/pi-umbra-theme/checks/umbra-background.check.ts +158 -0
  47. package/node_modules/pi-umbra-theme/checks/umbra-footer.check.ts +72 -0
  48. package/node_modules/pi-umbra-theme/checks/umbra-gutter.check.ts +59 -0
  49. package/node_modules/pi-umbra-theme/checks/umbra-toolbox.check.ts +71 -0
  50. package/node_modules/pi-umbra-theme/checks/umbra-working.check.ts +120 -0
  51. package/node_modules/pi-umbra-theme/extensions/umbra-background.ts +155 -0
  52. package/node_modules/pi-umbra-theme/extensions/umbra-footer.ts +150 -0
  53. package/node_modules/pi-umbra-theme/extensions/umbra-gutter.ts +35 -0
  54. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox.ts +129 -0
  55. package/node_modules/pi-umbra-theme/extensions/umbra-working.ts +209 -0
  56. package/node_modules/pi-umbra-theme/lib/umbra-format.ts +16 -0
  57. package/node_modules/pi-umbra-theme/lib/umbra-settings.ts +52 -0
  58. package/node_modules/pi-umbra-theme/package.json +45 -0
  59. package/node_modules/pi-umbra-theme/themes/umbra-astral-veil.json +88 -0
  60. package/node_modules/pi-umbra-theme/themes/umbra-deep-current.json +88 -0
  61. package/node_modules/pi-umbra-theme/themes/umbra-ember-ash.json +88 -0
  62. package/node_modules/pi-umbra-theme/themes/umbra-onyx-slate.json +88 -0
  63. package/node_modules/pi-umbra-theme/themes/umbra-tidal-drift.json +88 -0
  64. package/node_modules/pi-umbra-theme/themes/umbra-venom-dusk.json +88 -0
  65. package/node_modules/pi-umbra-theme/themes/umbra-violet-forge.json +88 -0
  66. package/package.json +68 -0
@@ -0,0 +1,155 @@
1
+ // pi picks a theme for its own text but never touches the window behind it. The only OSC 11 it
2
+ // ever sends is the query form (`ESC ] 11 ; ? BEL`), so it reads your terminal background and
3
+ // leaves it alone. A pi theme has no window-background token either: it paints the selected line,
4
+ // the user message and the three tool boxes, nothing else.
5
+ //
6
+ // pi-omp-theme declares a `theme.terminalBackgroundSync` setting, but nothing in its source ever
7
+ // reads that value and it never writes an OSC 11 set sequence. The knob does nothing.
8
+ //
9
+ // So this does it: on start it reads the active theme's own `export.pageBg` and sets the terminal
10
+ // background to it, and on shutdown it puts the background back with OSC 111. Nothing is guessed —
11
+ // a theme without an `export.pageBg` is left alone.
12
+ import { readFileSync } from "node:fs";
13
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
+
15
+ const SETTINGS_KEY = "piUmbraTheme";
16
+ // How often the active theme's name is compared. It is an in-memory string, so this costs a
17
+ // comparison; the theme file is only read when the name moves. Short enough that arrowing through
18
+ // the theme picker looks live.
19
+ const POLL_MS = 250;
20
+ const HEX = /^#[0-9a-fA-F]{6}$/;
21
+
22
+ // OSC 11 sets the default background; OSC 111 restores the one the terminal started with. BEL
23
+ // terminates both, which every terminal accepts, unlike ST.
24
+ const setBackground = (hex: string) => process.stdout.write(`\x1b]11;${hex}\x07`);
25
+ const resetBackground = () => process.stdout.write("\x1b]111\x07");
26
+
27
+ type Settings = { background?: string };
28
+
29
+ const readSetting = (): string => {
30
+ const path = `${process.env.PI_AGENT_DIR ?? `${process.env.USERPROFILE ?? process.env.HOME}/.pi/agent`}/settings.json`;
31
+ try {
32
+ const settings = JSON.parse(readFileSync(path, "utf8")) as Record<string, Settings>;
33
+ return settings[SETTINGS_KEY]?.background ?? "auto";
34
+ } catch {
35
+ return "auto";
36
+ }
37
+ };
38
+
39
+ // The window colour a theme wants is its `export.pageBg` — the same value it uses for the page
40
+ // behind an exported transcript. Render tokens are no help here; none of them is the window.
41
+ // `ui.theme` is the loaded Theme. The file it was read from is only guaranteed on the list
42
+ // `getAllThemes()` returns, and that list names the field `path`, not `sourcePath`. The instance is
43
+ // tried first so a theme registered by another extension still resolves.
44
+ const themeFile = (ctx: ExtensionContext): string | undefined => {
45
+ const theme = ctx.ui.theme;
46
+ if (theme === undefined) return undefined;
47
+ const own = (theme as { sourcePath?: string }).sourcePath;
48
+ if (own !== undefined) return own;
49
+ const listed = ctx.ui.getAllThemes().find((entry) => entry.name === theme.name);
50
+ return (listed as { path?: string } | undefined)?.path;
51
+ };
52
+
53
+ const themeBackground = (ctx: ExtensionContext): string | undefined => {
54
+ const path = themeFile(ctx);
55
+ if (path === undefined) return undefined;
56
+ const theme = JSON.parse(readFileSync(path, "utf8")) as { export?: { pageBg?: string } };
57
+ const pageBg = theme.export?.pageBg;
58
+ return pageBg !== undefined && HEX.test(pageBg) ? pageBg : undefined;
59
+ };
60
+
61
+ const resolve = (ctx: ExtensionContext): string | undefined => {
62
+ const setting = readSetting();
63
+ if (setting === "off") return undefined;
64
+ if (HEX.test(setting)) return setting;
65
+ return themeBackground(ctx);
66
+ };
67
+
68
+ export default function umbraBackground(pi: ExtensionAPI) {
69
+ // The colour currently on the terminal, or undefined when we have not touched it. One variable
70
+ // rather than a colour plus an "applied" flag: the two can disagree and the flag adds nothing.
71
+ let current: string | undefined;
72
+ // The theme name as of the last look, and the timer that does the looking.
73
+ let watched: string | undefined;
74
+ let watcher: ReturnType<typeof setInterval> | undefined;
75
+
76
+ const apply = (ctx: ExtensionContext): string | undefined => {
77
+ const hex = resolve(ctx);
78
+ if (hex === undefined) return undefined;
79
+ setBackground(hex);
80
+ current = hex;
81
+ return hex;
82
+ };
83
+
84
+ pi.on("session_start", (_event, ctx: ExtensionContext) => {
85
+ if (ctx.mode !== "tui") return;
86
+ apply(ctx);
87
+ // pi fires no theme-change event. Its internal onThemeChange slot holds a single callback
88
+ // that the TUI already owns, and ui_prompt_end does not cover pi's own theme picker, so the
89
+ // name is watched instead. unref keeps this timer from holding the process open at exit.
90
+ if (watcher !== undefined) clearInterval(watcher);
91
+ watched = ctx.ui.theme?.name;
92
+ watcher = setInterval(() => {
93
+ const name = ctx.ui.theme?.name;
94
+ if (name === watched) return;
95
+ watched = name;
96
+ apply(ctx);
97
+ }, POLL_MS);
98
+ watcher.unref?.();
99
+ });
100
+
101
+ // Leaving a recoloured terminal behind is the one unfriendly thing this could do, so undo it
102
+ // whether or not the shell would have reset it.
103
+ pi.on("session_shutdown", () => {
104
+ if (watcher !== undefined) clearInterval(watcher);
105
+ watcher = undefined;
106
+ if (current === undefined) return;
107
+ resetBackground();
108
+ current = undefined;
109
+ });
110
+
111
+ // Two arguments, not one object: pi's signature is registerCommand(name, definition), and a
112
+ // single object lands in the `name` slot, where the autocomplete filter calls toLowerCase on
113
+ // it and takes the whole process down the first time anyone types "/".
114
+ pi.registerCommand("umb-bg", {
115
+ description: "Set the terminal background from the active theme, a hex colour, or reset it",
116
+ handler: async (args: string, ctx: ExtensionContext) => {
117
+ if (ctx.mode !== "tui") return;
118
+ const argument = args.trim();
119
+
120
+ if (argument === "off" || argument === "reset") {
121
+ resetBackground();
122
+ current = undefined;
123
+ ctx.ui.notify("Terminal background restored.", "info");
124
+ return;
125
+ }
126
+
127
+ if (HEX.test(argument)) {
128
+ setBackground(argument);
129
+ current = argument;
130
+ ctx.ui.notify(`Terminal background set to ${argument}.`, "info");
131
+ return;
132
+ }
133
+
134
+ if (argument !== "") {
135
+ ctx.ui.notify(`Expected a #rrggbb colour, "off", or nothing. Got "${argument}".`, "error");
136
+ return;
137
+ }
138
+
139
+ // Three outcomes, three messages. "Nothing happened" is useless when the cause can be
140
+ // the theme having no colour, pi not listing the theme's file, or the terminal
141
+ // dropping the escape - and only the last one is out of this extension's hands.
142
+ const name = ctx.ui.theme?.name ?? "?";
143
+ const file = themeFile(ctx);
144
+ const hex = apply(ctx);
145
+ ctx.ui.notify(
146
+ hex !== undefined
147
+ ? `Terminal background set to ${hex} from theme "${name}". If it did not change, this terminal ignores OSC 11.`
148
+ : file === undefined
149
+ ? `pi lists no file for theme "${name}", so its background cannot be read. Use /umb-bg #rrggbb.`
150
+ : `Theme "${name}" (${file}) defines no export.pageBg, so nothing was changed.`,
151
+ hex === undefined ? "warning" : "info",
152
+ );
153
+ },
154
+ });
155
+ }
@@ -0,0 +1,150 @@
1
+ import type { AssistantMessage, Usage } from "@earendil-works/pi-ai";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
+ import { compact } from "../lib/umbra-format.ts";
5
+
6
+ const ANSI_CODES = /\x1b\[[0-9;]*m/g;
7
+
8
+ const ponytailLevel = (statuses: ReadonlyMap<string, string>) => {
9
+ const status = statuses.get("ponytail")?.replace(ANSI_CODES, "").toLowerCase() ?? "";
10
+ return status.match(/lite|full|ultra/)?.[0];
11
+ };
12
+
13
+ // One column in on the left, the same gutter bar/bar-line.ts uses, so the footer and the agent
14
+ // rows under it start on the same column. Two on the right, matching the input bar: the right
15
+ // edge is where a long path ends, and one column there reads as the text touching the border.
16
+ const PAD_LEFT = 1;
17
+ const PAD_RIGHT = 2;
18
+
19
+ // Two columns on one row, with the gap doing the alignment. A terminal too narrow to hold both
20
+ // drops the right column whole rather than truncating a path into something unreadable — the
21
+ // left column is the one that changes every turn, so it is the one worth keeping.
22
+ const SEP = " · ";
23
+
24
+ /** As many groups per line as fit, in order. A group too wide for a line of its own is cut. */
25
+ const pack = (groups: string[], inner: number): string[] => {
26
+ const lines: string[] = [];
27
+ let line = "";
28
+ for (const group of groups) {
29
+ if (line === "") line = truncateToWidth(group, inner);
30
+ else if (visibleWidth(line) + SEP.length + visibleWidth(group) <= inner) line += SEP + group;
31
+ else {
32
+ lines.push(line);
33
+ line = truncateToWidth(group, inner);
34
+ }
35
+ }
36
+ if (line !== "") lines.push(line);
37
+ return lines;
38
+ };
39
+
40
+ /**
41
+ * The status groups over as many rows as they need, and the path on the last one when it fits.
42
+ * Truncating instead would drop whichever group came last - and the groups are ordered by how
43
+ * often they are read, so the cut always landed on the ones nobody had seen yet.
44
+ *
45
+ * Packed while plain: colour is applied per finished line, so an escape is never cut in half and
46
+ * visibleWidth is measuring characters rather than ANSI.
47
+ */
48
+ const compose = (left: string[], right: string[], width: number, paint: (t: string) => string): string[] => {
49
+ const inner = Math.max(0, width - PAD_LEFT - PAD_RIGHT);
50
+ const pad = (line: string) => " ".repeat(PAD_LEFT) + paint(line) + " ".repeat(PAD_RIGHT);
51
+ const lines = pack(left, inner);
52
+ const tail = right.join(SEP);
53
+ if (tail === "") return lines.map(pad);
54
+
55
+ const last = lines.pop() ?? "";
56
+ const gap = inner - visibleWidth(last) - visibleWidth(tail);
57
+ if (gap >= 2) lines.push(last + " ".repeat(gap) + tail);
58
+ else lines.push(last, truncateToWidth(tail, inner));
59
+ return lines.map(pad);
60
+ };
61
+
62
+ export default function (pi: ExtensionAPI) {
63
+ // Computed once per turn and read on every frame, rather than the other way round.
64
+ // getBranch() is documented as a tree traversal from the current leaf and builds a fresh
65
+ // array each call, so scanning the whole session inside render() re-walked it on every
66
+ // paint — dozens of times a second while a reply streams, and O(session) each time.
67
+ let cost = 0;
68
+ let lastTurn: Usage | undefined;
69
+ let repaint: (() => void) | undefined;
70
+
71
+ const refresh = (ctx: ExtensionContext) => {
72
+ const messages = ctx.sessionManager
73
+ .getBranch()
74
+ .filter((entry) => entry.type === "message" && entry.message.role === "assistant")
75
+ .map((entry) => (entry as { message: AssistantMessage }).message);
76
+ // On a subscription this is not what was billed — it is what the provider priced the
77
+ // turns at, and only a provider that reports per-turn cost fills it in at all.
78
+ cost = messages.reduce((sum, message) => sum + message.usage.cost.total, 0);
79
+ lastTurn = messages.at(-1)?.usage;
80
+ repaint?.();
81
+ };
82
+
83
+ // agent_end, not message_end. core/cache-stats.d.ts:46 states that message_end fires BEFORE
84
+ // persistence, so getBranch() there would still be missing the turn that just finished and
85
+ // the footer would report the previous one. agent_end runs after every message_end, and that
86
+ // gap is where the numbers were arriving a frame late: nothing asked for a repaint once the
87
+ // entry landed, so new usage waited for whatever redrew the screen next — usually a keystroke.
88
+ pi.on("agent_end", (_event, ctx) => refresh(ctx));
89
+
90
+ pi.on("session_start", async (_event, ctx) => {
91
+ // A resumed session already has turns behind it, and no agent_end will fire for them.
92
+ refresh(ctx);
93
+ ctx.ui.setFooter((tui, theme, footerData) => {
94
+ repaint = () => tui.requestRender();
95
+ return {
96
+ dispose: footerData.onBranchChange(() => tui.requestRender()),
97
+ invalidate() {},
98
+ render(width: number) {
99
+ const promptTokens =
100
+ (lastTurn?.input ?? 0) + (lastTurn?.cacheRead ?? 0) + (lastTurn?.cacheWrite ?? 0);
101
+ const cacheHitRate = promptTokens > 0 ? ((lastTurn?.cacheRead ?? 0) / promptTokens) * 100 : 0;
102
+ const context = ctx.getContextUsage();
103
+
104
+ // "MiniMax: MiniMax M3 (CC)" → "MiniMax M3". pi names a model "<provider>: <model>"
105
+ // and the provider is already implied by the model, so both the prefix and the
106
+ // trailing parenthetical are noise on a line this crowded.
107
+ const modelName = (ctx.model?.name ?? "no model")
108
+ .replace(/^[^:]*:\s*/, "")
109
+ .replace(/\s*\([^)]*\)$/, "");
110
+ const parts = [
111
+ `${modelName} [${ctx.thinkingLevel}]`,
112
+ `${compact(context?.tokens ?? 0)}/${compact(context?.contextWindow ?? 0)}`,
113
+ ];
114
+ // A free model prices every turn at zero, and a column that only ever reads $0.00
115
+ // is a column that teaches you to stop looking at it. Each group below appears
116
+ // once it has something to say and takes its space back when it does not.
117
+ if (cost > 0) parts.push(`$${cost.toFixed(2)}`);
118
+ const cached = (lastTurn?.cacheRead ?? 0) + (lastTurn?.cacheWrite ?? 0);
119
+ if (cached > 0) {
120
+ parts.push(
121
+ `R${compact(lastTurn?.cacheRead ?? 0)} W${compact(lastTurn?.cacheWrite ?? 0)} CH${cacheHitRate.toFixed(1)}%`,
122
+ );
123
+ }
124
+ // `input` is the fresh half of the prompt. cacheRead and cacheWrite are already
125
+ // their own group, so counting them here too would make the arrow repeat what the
126
+ // R and the W just said.
127
+ const moved = (lastTurn?.input ?? 0) + (lastTurn?.output ?? 0);
128
+ if (moved > 0) parts.push(`↑ ${compact(lastTurn?.input ?? 0)} ↓ ${compact(lastTurn?.output ?? 0)}`);
129
+ const statuses = footerData.getExtensionStatuses();
130
+ const level = ponytailLevel(statuses);
131
+ if (level) parts.push(`ponytail:${level}`);
132
+ const loop = statuses.get("loop")?.replace(ANSI_CODES, "");
133
+ if (loop) parts.push(loop);
134
+
135
+ // getGitBranch() returns null outside a repo and "detached" on a detached HEAD, so
136
+ // the branch shows itself when there is one and costs nothing when there is not.
137
+ const right = [`🗀 ${ctx.sessionManager.getCwd()}`];
138
+ const branch = footerData.getGitBranch();
139
+ if (branch) right.push(`⎇ ${branch}`);
140
+
141
+ // One line. The rule that used to live here is drawn by the editor now - see the
142
+ // repatch entry "the autocomplete list has a closing edge" - which puts it in the
143
+ // border colour and only when there is a list to close. Drawing it here as well
144
+ // showed up as two rules with a blank row between them on every screen with no list.
145
+ return compose(parts, right, width, (text) => theme.fg("muted", text));
146
+ },
147
+ };
148
+ });
149
+ });
150
+ }
@@ -0,0 +1,35 @@
1
+ // Model text and your own text run into each other in a long session. This puts a gutter beside
2
+ // everything the model says, so the two are separable at a glance.
3
+ //
4
+ // It is drawn as a Markdown quote rather than a literal "│ " prefix, because the transformer hook
5
+ // is display-only and operates on Markdown source: a literal prefix would turn "```ts" into
6
+ // "│ ```ts", which is no longer a fence, and every code block in the answer would render as
7
+ // paragraph text. A quote keeps fences, lists and tables intact, and pi's own renderer draws the
8
+ // bar from the active theme.
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import { readUmbraSettings } from "../lib/umbra-settings.ts";
11
+
12
+ const QUOTE = "> ";
13
+
14
+ /**
15
+ * A blank line inside a quote ends it, so blank lines carry a bare marker. Without that, one
16
+ * empty line between two paragraphs would split the answer into two separate quote blocks.
17
+ */
18
+ export const asQuote = (markdown: string): string =>
19
+ markdown
20
+ .split("\n")
21
+ .map((line) => (line.trim() === "" ? QUOTE.trimEnd() : QUOTE + line))
22
+ .join("\n");
23
+
24
+ export default function umbraGutter(pi: ExtensionAPI) {
25
+ // Read once. The transformer runs on every streaming update and every terminal resize, so a
26
+ // file read per call would put disk I/O on the render path.
27
+ const isGutterEnabled = readUmbraSettings().messages?.assistantPrefix === true;
28
+ if (!isGutterEnabled) return;
29
+
30
+ pi.registerMarkdownTransformer((markdown: string, { messageType }: { messageType: string }) => {
31
+ if (messageType !== "assistant") return markdown;
32
+ if (markdown.trim() === "") return markdown;
33
+ return asQuote(markdown);
34
+ });
35
+ }
@@ -0,0 +1,129 @@
1
+ // Draws a frame around every tool call, so a long transcript reads as blocks instead of one
2
+ // stream. pi paints a background behind tool output but no border, and there is no public hook
3
+ // for the frame: a tool's own `renderCall`/`renderResult` only cover that one tool, and most
4
+ // tools are pi's own.
5
+ //
6
+ // So this patches `ToolExecutionComponent.prototype.render`, which is the safest joint available:
7
+ //
8
+ // - The class is exported from `@earendil-works/pi-coding-agent`, not reached out of a private
9
+ // module, so pi means for extensions to hold it.
10
+ // - `render(width): string[]` is the `Component` contract every pi component implements, so it
11
+ // is the least likely method on the class to change shape.
12
+ // - The patch is purely additive. It reads pi's finished lines and returns them with a border
13
+ // around them. It never reproduces what pi drew, so syntax highlighting, diffs, images and
14
+ // the expand affordance all keep working untouched.
15
+ //
16
+ // It is still a patch, and a pi upgrade can still invalidate it. Two guards make that harmless:
17
+ // the shape of `render` is verified before anything is replaced, and any throw inside the frame
18
+ // returns pi's own lines. The failure mode is "the frames disappear", never "the session breaks".
19
+ import { type Theme, ToolExecutionComponent } from "@earendil-works/pi-coding-agent";
20
+ import { visibleWidth } from "@earendil-works/pi-tui";
21
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
22
+ import { readUmbraSettings } from "../lib/umbra-settings.ts";
23
+
24
+ const TOP_LEFT = "╭";
25
+ const TOP_RIGHT = "╮";
26
+ const BOTTOM_LEFT = "╰";
27
+ const BOTTOM_RIGHT = "╯";
28
+ const HORIZONTAL = "─";
29
+ const VERTICAL = "│";
30
+
31
+ /** A border plus one space of padding on each side. */
32
+ const FRAME_COLUMNS = 4;
33
+ /** Below this there is no room for content between the two verticals. */
34
+ const MIN_WIDTH = 12;
35
+
36
+ // The scrollbar owns the last column of the chat, and a frame drawn out to the full width puts
37
+ // its right border directly against it. One column short of the edge is what separates them.
38
+ export const RIGHT_MARGIN = 1;
39
+
40
+ /** pi's own `render` takes the available width and returns the drawn lines. */
41
+ const RENDER_ARITY = 1;
42
+
43
+ type Render = (width: number) => string[];
44
+
45
+ /**
46
+ * The lines carry ANSI colour, so their length in characters is not their width on screen.
47
+ * `visibleWidth` is pi's own measurement and is what the frame has to line up against.
48
+ */
49
+ const padToWidth = (line: string, columns: number): string => {
50
+ const missing = columns - visibleWidth(line);
51
+ return missing > 0 ? line + " ".repeat(missing) : line;
52
+ };
53
+
54
+ /**
55
+ * pi's tool component opens with a blank spacer line. Framing that would put an empty row inside
56
+ * the box, so leading and trailing blanks are lifted out and re-emitted around the frame.
57
+ */
58
+ const splitBlanks = (lines: string[]): { before: string[]; body: string[]; after: string[] } => {
59
+ let first = 0;
60
+ let last = lines.length;
61
+ while (first < last && lines[first]?.trim() === "") first++;
62
+ while (last > first && lines[last - 1]?.trim() === "") last--;
63
+ return { before: lines.slice(0, first), body: lines.slice(first, last), after: lines.slice(last) };
64
+ };
65
+
66
+ /** The width the frame itself is drawn at: everything but the column the scrollbar sits in. */
67
+ export const framedWidth = (width: number): number => Math.max(0, width - RIGHT_MARGIN);
68
+
69
+ /** What pi may draw into, once the border, its padding and that margin have taken their columns. */
70
+ export const usableWidth = (width: number): number =>
71
+ framedWidth(width) < MIN_WIDTH ? width : framedWidth(width) - FRAME_COLUMNS;
72
+
73
+ export const frame = (lines: string[], width: number, paint: (text: string) => string): string[] => {
74
+ if (width < MIN_WIDTH) return lines;
75
+ const { before, body, after } = splitBlanks(lines);
76
+ if (body.length === 0) return lines;
77
+
78
+ const inner = width - FRAME_COLUMNS;
79
+ const rule = HORIZONTAL.repeat(width - 2);
80
+ const side = paint(VERTICAL);
81
+ return [
82
+ ...before,
83
+ paint(TOP_LEFT + rule + TOP_RIGHT),
84
+ ...body.map((line) => `${side} ${padToWidth(line, inner)} ${side}`),
85
+ paint(BOTTOM_LEFT + rule + BOTTOM_RIGHT),
86
+ ...after,
87
+ ];
88
+ };
89
+
90
+ /**
91
+ * Refuse to patch a method that is not the one this was written against. An upgrade that changes
92
+ * `render` leaves pi drawing its own rows, which is a worse look and a working session.
93
+ */
94
+ const isPatchable = (): boolean => {
95
+ const render: unknown = ToolExecutionComponent?.prototype?.render;
96
+ return typeof render === "function" && (render as Render).length === RENDER_ARITY;
97
+ };
98
+
99
+ export default function umbraToolbox(pi: ExtensionAPI) {
100
+ if (readUmbraSettings().tools?.chrome !== "boxed") return;
101
+ if (!isPatchable()) return;
102
+
103
+ pi.on("session_start", (_event, ctx: ExtensionContext) => {
104
+ if (ctx.mode !== "tui") return;
105
+
106
+ const prototype = ToolExecutionComponent.prototype;
107
+ // SAFETY: isPatchable() checked above that this is a one-argument function, which is the
108
+ // whole contract the wrapper depends on.
109
+ const original = prototype.render as Render;
110
+ if ((prototype.render as { umbra?: boolean }).umbra === true) return;
111
+
112
+ const paint = (text: string) => (ctx.ui.theme as Theme | undefined)?.fg("borderMuted", text) ?? text;
113
+
114
+ const patched: Render & { umbra?: boolean } = function (this: unknown, width: number) {
115
+ // pi is asked for the inner width, not the full one. Rendering at `width` and then
116
+ // putting a border around it pushes every full-width line four columns past the right
117
+ // edge, which is what made long tool output break out of the box.
118
+ const lines = original.call(this, usableWidth(width));
119
+ if (lines.length === 0) return lines;
120
+ try {
121
+ return frame(lines, framedWidth(width), paint);
122
+ } catch {
123
+ return lines;
124
+ }
125
+ };
126
+ patched.umbra = true;
127
+ prototype.render = patched;
128
+ });
129
+ }