pi-minimal 0.1.0 → 0.2.2

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/dist/tools.js CHANGED
@@ -1,103 +1,172 @@
1
- import { createBashToolDefinition, createEditToolDefinition, createFindToolDefinition, createGrepToolDefinition, createLsToolDefinition, createReadToolDefinition, createWriteToolDefinition, SettingsManager, } from "@earendil-works/pi-coding-agent";
2
- import { Container, Text, truncateToWidth } from "@earendil-works/pi-tui";
3
- import { activityBullet, rowIsRunning } from "./activity.js";
1
+ import { Container, truncateToWidth } from "@earendil-works/pi-tui";
2
+ import { rowIsRunning } from "./activity.js";
3
+ import * as chrome from "./chrome.js";
4
4
  import { createLiveWatch } from "./live-watch.js";
5
- import { nextGroupedRow, toolItem } from "./tool-rows.js";
5
+ import { bashBody, formatDuration, formatOutcome, layoutRow, resultText, rowGlyph, toolItem, } from "./tool-rows.js";
6
+ /** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
7
+ const margin = () => (typeof chrome.chromeMargin === "function" ? chrome.chromeMargin() : (chrome.CHROME_MARGIN ?? 1));
6
8
  const live = createLiveWatch(400);
9
+ /** Left indent of every tool row: chrome margin plus one cell so rows sit inside message text. */
10
+ const rowIndent = () => margin() + 1;
11
+ /** Max lines of output shown when a row is expanded. */
12
+ const EXPANDED_MAX_LINES = 400;
7
13
  export function stopLiveWatch() {
8
14
  live.stop();
9
15
  }
10
16
  function liveId(context, item) {
11
17
  return context.toolCallId || item;
12
18
  }
13
- function contentText(result) {
14
- if (!result?.content)
15
- return "";
16
- return result.content
17
- .filter((part) => part.type === "text" && typeof part.text === "string")
18
- .map((part) => part.text)
19
- .join("\n");
19
+ function rowStatus(context) {
20
+ if (context.isError)
21
+ return "error";
22
+ if (context.state.outcome?.errorLine)
23
+ return "error";
24
+ return rowIsRunning(context) ? "running" : "ok";
20
25
  }
21
- function collapsedResult() {
22
- return new Container();
26
+ function statusColor(status) {
27
+ if (status === "error")
28
+ return "error";
29
+ if (status === "running")
30
+ return "warning";
31
+ return "success";
23
32
  }
24
- function expandedContent(result, options) {
25
- if (!options.expanded)
26
- return collapsedResult();
27
- const text = contentText(result);
28
- return text ? new Text(text, 0, 0) : collapsedResult();
33
+ function expandedLines(kind, result) {
34
+ let text = resultText(result);
35
+ if (kind === "bash")
36
+ text = bashBody(text).body;
37
+ if (!text)
38
+ return [];
39
+ const lines = text.split("\n");
40
+ if (lines.length > EXPANDED_MAX_LINES) {
41
+ const hidden = lines.length - EXPANDED_MAX_LINES;
42
+ return [...lines.slice(0, EXPANDED_MAX_LINES), `… ${hidden} more lines`];
43
+ }
44
+ return lines;
45
+ }
46
+ function outputBlock(kind, result, theme) {
47
+ const lines = expandedLines(kind, result);
48
+ if (lines.length === 0)
49
+ return new Container();
50
+ const indent = " ".repeat(rowIndent() + 2);
51
+ const rule = theme.fg("borderMuted", "│");
52
+ return {
53
+ render(width) {
54
+ const inner = Math.max(0, width - indent.length - 2);
55
+ return lines.map((line) => `${indent}${rule} ${theme.fg("dim", truncateToWidth(line, inner))}`);
56
+ },
57
+ invalidate() { },
58
+ };
29
59
  }
30
- function activityBlock(kind, args, theme, context) {
60
+ function errorLineBlock(line, theme) {
61
+ if (!line)
62
+ return undefined;
63
+ const indent = " ".repeat(rowIndent() + 2);
64
+ const rule = theme.fg("error", "│");
65
+ return {
66
+ render(width) {
67
+ const inner = Math.max(0, width - indent.length - 2);
68
+ return [`${indent}${rule} ${theme.fg("error", truncateToWidth(line, inner))}`];
69
+ },
70
+ invalidate() { },
71
+ };
72
+ }
73
+ function activityRow(kind, args, theme, context) {
31
74
  const item = toolItem(kind, args);
32
75
  const id = liveId(context, item);
33
- const row = nextGroupedRow(kind, item, id);
34
76
  const running = rowIsRunning(context);
35
77
  context.state.running = running;
36
- if (running)
78
+ if (running) {
79
+ context.state.startedAt ??= Date.now();
37
80
  live.watch(id, context.invalidate);
38
- else
81
+ }
82
+ else {
39
83
  live.unwatch(id);
40
- const color = context.isError ? "error" : running ? "warning" : "dim";
84
+ }
41
85
  return {
42
86
  render(width) {
43
- const pad = 1;
44
- const inner = Math.max(0, width - pad);
45
- const lines = [];
46
- if (row.header) {
47
- const blink = Boolean(context.state.running) && live.has(id);
48
- const bullet = activityBullet(blink, live.frame);
49
- lines.push(" ".repeat(pad) + theme.fg(color, truncateToWidth(`${bullet} ${row.header}`, inner)));
87
+ const status = rowStatus(context);
88
+ const blink = status === "running" && live.has(id);
89
+ const glyph = rowGlyph(status, blink ? live.frame : 0);
90
+ const outcome = context.state.outcome;
91
+ const diff = status !== "running" ? outcome?.diff : undefined;
92
+ let right = "";
93
+ if (status === "running") {
94
+ const started = context.state.startedAt;
95
+ right = started ? formatDuration(Date.now() - started) : "";
50
96
  }
51
- lines.push(" ".repeat(pad) + " " + theme.fg(color, truncateToWidth(row.item, Math.max(0, inner - 2))));
52
- return lines;
97
+ else if (outcome) {
98
+ right = outcome.text;
99
+ if (diff) {
100
+ const stats = `+${diff.added} −${diff.removed}`;
101
+ right = right ? `${stats} · ${right}` : stats;
102
+ }
103
+ }
104
+ const inner = Math.max(8, width - rowIndent());
105
+ const row = layoutRow(inner, kind, item, right);
106
+ let coloredRight;
107
+ if (status === "running")
108
+ coloredRight = theme.fg("warning", row.outcome);
109
+ else if (status === "error")
110
+ coloredRight = theme.fg("error", row.outcome);
111
+ else if (diff && row.outcome) {
112
+ const rest = row.outcome.slice(`+${diff.added} −${diff.removed}`.length);
113
+ coloredRight =
114
+ theme.fg("success", `+${diff.added}`) + " " + theme.fg("error", `−${diff.removed}`) + theme.fg("dim", rest);
115
+ }
116
+ else
117
+ coloredRight = theme.fg("dim", row.outcome);
118
+ const line = " ".repeat(rowIndent()) +
119
+ theme.fg(statusColor(status), glyph) +
120
+ " " +
121
+ theme.fg("dim", row.verb) +
122
+ " " +
123
+ theme.fg(status === "error" ? "text" : "muted", row.item) +
124
+ " ".repeat(row.gap) +
125
+ coloredRight;
126
+ return [line];
53
127
  },
54
128
  invalidate() { },
55
129
  };
56
130
  }
57
- function wrapOne(pi, kind, factory) {
58
- try {
59
- const tool = factory();
60
- pi.registerTool({
61
- ...tool,
62
- renderShell: "self",
63
- renderCall(args, theme, context) {
64
- return activityBlock(kind, args, theme, context);
65
- },
66
- renderResult(result, options, _theme, context) {
67
- const renderContext = context;
68
- if (!renderContext.isPartial) {
69
- renderContext.state.done = true;
70
- renderContext.state.running = false;
71
- live.unwatch(liveId(renderContext, toolItem(kind, renderContext.args)));
72
- }
73
- return expandedContent(result, options);
74
- },
75
- });
76
- }
77
- catch {
78
- // A missing built-in tool leaves the rest of the compact renderers available.
79
- }
131
+ const TOOL_KINDS = new Set(["bash", "read", "edit", "write", "grep", "find", "ls"]);
132
+ export function isToolKind(name) {
133
+ return TOOL_KINDS.has(name);
80
134
  }
81
- export function wrapBuiltinTools(pi, ctx) {
82
- let commandPrefix;
83
- let shellPath;
84
- let autoResizeImages;
85
- try {
86
- const settings = SettingsManager.create(ctx.cwd, undefined, {
87
- projectTrusted: ctx.isProjectTrusted(),
88
- });
89
- commandPrefix = settings.getShellCommandPrefix();
90
- shellPath = settings.getShellPath();
91
- autoResizeImages = settings.getImageAutoResize();
92
- }
93
- catch {
94
- // Tool wrapping still works when optional shell or image settings are unavailable.
95
- }
96
- wrapOne(pi, "bash", () => createBashToolDefinition(ctx.cwd, { commandPrefix, shellPath }));
97
- wrapOne(pi, "read", () => createReadToolDefinition(ctx.cwd, { autoResizeImages }));
98
- wrapOne(pi, "edit", () => createEditToolDefinition(ctx.cwd));
99
- wrapOne(pi, "write", () => createWriteToolDefinition(ctx.cwd));
100
- wrapOne(pi, "grep", () => createGrepToolDefinition(ctx.cwd));
101
- wrapOne(pi, "find", () => createFindToolDefinition(ctx.cwd));
102
- wrapOne(pi, "ls", () => createLsToolDefinition(ctx.cwd));
135
+ export function compactRenderers(kind) {
136
+ return {
137
+ renderShell: "self",
138
+ renderCall(args, theme, context) {
139
+ return activityRow(kind, args, theme, context);
140
+ },
141
+ renderResult(result, options, theme, context) {
142
+ const renderContext = context;
143
+ const typedResult = result;
144
+ if (!renderContext.isPartial) {
145
+ renderContext.state.done = true;
146
+ renderContext.state.running = false;
147
+ renderContext.state.outcome = formatOutcome(kind, renderContext.args, typedResult, Boolean(renderContext.isError), renderContext.durationMs);
148
+ live.unwatch(liveId(renderContext, toolItem(kind, renderContext.args)));
149
+ }
150
+ const container = new Container();
151
+ const errorLine = errorLineBlock(renderContext.state.outcome?.errorLine, theme);
152
+ if (errorLine && !options.expanded)
153
+ container.addChild(errorLine);
154
+ if (options.expanded)
155
+ container.addChild(outputBlock(kind, typedResult, theme));
156
+ return container;
157
+ },
158
+ };
159
+ }
160
+ /**
161
+ * Attach the compact renderers to the built-in tools without re-registering them. Registered at
162
+ * extension load so a resumed transcript draws its earlier tool calls the same way as new ones.
163
+ */
164
+ export function installToolRenderers(pi) {
165
+ if (typeof pi.registerToolRenderer !== "function")
166
+ return;
167
+ pi.registerToolRenderer((toolName, next) => {
168
+ if (!isToolKind(toolName))
169
+ return next();
170
+ return compactRenderers(toolName);
171
+ });
103
172
  }
@@ -0,0 +1,50 @@
1
+ export const THINKING_GUTTER = "┊ ";
2
+ const FENCE = /^\s*(```|~~~)/;
3
+ const BLOCK_START = /^\s*(#{1,6}\s|[-*+]\s|\d+[.)]\s|>|\||```|~~~|---\s*$|\*\*\*\s*$|___\s*$)/;
4
+ /**
5
+ * Prefix the first line of every plain paragraph in thinking Markdown with a gutter glyph.
6
+ * Lines that start a Markdown block (heading, list, quote, table, fence, rule) are left alone so
7
+ * they keep parsing, and nothing inside a fenced code block is touched.
8
+ */
9
+ export function gutterThinking(markdown) {
10
+ if (!markdown)
11
+ return markdown;
12
+ const lines = markdown.split("\n");
13
+ const out = [];
14
+ let inFence = false;
15
+ let atParagraphStart = true;
16
+ for (const line of lines) {
17
+ if (FENCE.test(line)) {
18
+ inFence = !inFence;
19
+ out.push(line);
20
+ atParagraphStart = !inFence;
21
+ continue;
22
+ }
23
+ if (inFence) {
24
+ out.push(line);
25
+ continue;
26
+ }
27
+ if (line.trim() === "") {
28
+ out.push(line);
29
+ atParagraphStart = true;
30
+ continue;
31
+ }
32
+ if (atParagraphStart && !BLOCK_START.test(line) && !line.startsWith(THINKING_GUTTER)) {
33
+ out.push(`${THINKING_GUTTER}${line}`);
34
+ }
35
+ else {
36
+ out.push(line);
37
+ }
38
+ atParagraphStart = false;
39
+ }
40
+ return out.join("\n");
41
+ }
42
+ export function installTranscript(pi) {
43
+ if (typeof pi.registerMarkdownTransformer !== "function")
44
+ return;
45
+ pi.registerMarkdownTransformer((markdown, context) => {
46
+ if (context.messageType !== "assistant-thinking")
47
+ return markdown;
48
+ return gutterThinking(markdown);
49
+ });
50
+ }
@@ -0,0 +1,121 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import * as chrome from "./chrome.js";
3
+ import { formatElapsed } from "./working-status.js";
4
+ /** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
5
+ const margin = () => (typeof chrome.chromeMargin === "function" ? chrome.chromeMargin() : (chrome.CHROME_MARGIN ?? 1));
6
+ export const TURN_ENTRY_TYPE = "pi-minimal.turn";
7
+ export function formatClock(epochMs) {
8
+ const d = new Date(epochMs);
9
+ const hh = String(d.getHours()).padStart(2, "0");
10
+ const mm = String(d.getMinutes()).padStart(2, "0");
11
+ return `${hh}:${mm}`;
12
+ }
13
+ export function turnLabel(summary) {
14
+ const parts = [formatClock(summary.endedAt), formatElapsed(summary.elapsedMs)];
15
+ if (summary.tools > 0)
16
+ parts.push(`${summary.tools} tool${summary.tools === 1 ? "" : "s"}`);
17
+ if (summary.outcome === "aborted")
18
+ parts.push("aborted");
19
+ if (summary.outcome === "error")
20
+ parts.push("error");
21
+ return parts.join(" · ");
22
+ }
23
+ /** `── label ─────────` filling `width`; label is dropped when the row is too narrow. */
24
+ export function ruleLine(width, label) {
25
+ const lead = "── ";
26
+ const tail = " ";
27
+ const minRule = 4;
28
+ if (width <= 0)
29
+ return "";
30
+ if (visibleWidth(lead) + visibleWidth(label) + visibleWidth(tail) + minRule > width) {
31
+ return "─".repeat(width);
32
+ }
33
+ const shown = truncateToWidth(label, width - lead.length - tail.length - minRule, "…");
34
+ const rest = Math.max(minRule, width - visibleWidth(lead) - visibleWidth(shown) - visibleWidth(tail));
35
+ return `${lead}${shown}${tail}${"─".repeat(rest)}`;
36
+ }
37
+ export function runOutcome(messages) {
38
+ if (!Array.isArray(messages))
39
+ return "completed";
40
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
41
+ const message = messages[i];
42
+ if (message?.role !== "assistant")
43
+ continue;
44
+ if (message.stopReason === "aborted")
45
+ return "aborted";
46
+ if (message.stopReason === "error")
47
+ return "error";
48
+ return "completed";
49
+ }
50
+ return "completed";
51
+ }
52
+ export function isTurnSummary(data) {
53
+ if (!data || typeof data !== "object")
54
+ return false;
55
+ const d = data;
56
+ return (typeof d.endedAt === "number" &&
57
+ typeof d.elapsedMs === "number" &&
58
+ typeof d.tools === "number" &&
59
+ (d.outcome === "completed" || d.outcome === "aborted" || d.outcome === "error"));
60
+ }
61
+ export function installTurnRule(pi) {
62
+ let ctx;
63
+ let startedAt = 0;
64
+ let tools = 0;
65
+ if (typeof pi.registerEntryRenderer !== "function" || typeof pi.appendEntry !== "function") {
66
+ return () => { };
67
+ }
68
+ pi.registerEntryRenderer(TURN_ENTRY_TYPE, (entry, _options, theme) => {
69
+ if (!isTurnSummary(entry.data))
70
+ return undefined;
71
+ const label = turnLabel(entry.data);
72
+ const color = entry.data.outcome === "completed" ? "borderMuted" : entry.data.outcome === "aborted" ? "warning" : "error";
73
+ return {
74
+ render(width) {
75
+ const inner = Math.max(0, width - margin() * 2);
76
+ const rule = ruleLine(inner, label);
77
+ const colored = rule.replace(label, theme.fg("dim", label));
78
+ return ["", `${" ".repeat(margin())}${theme.fg(color, colored)}`];
79
+ },
80
+ invalidate() { },
81
+ };
82
+ });
83
+ pi.on("session_start", (_event, next) => {
84
+ if (next.mode !== "tui")
85
+ return;
86
+ ctx = next;
87
+ });
88
+ pi.on("agent_start", () => {
89
+ startedAt = Date.now();
90
+ tools = 0;
91
+ });
92
+ pi.on("tool_execution_end", () => {
93
+ tools += 1;
94
+ });
95
+ pi.on("agent_end", (event) => {
96
+ if (!ctx || ctx.mode !== "tui" || !startedAt)
97
+ return;
98
+ const ev = event;
99
+ const summary = {
100
+ endedAt: Date.now(),
101
+ elapsedMs: Date.now() - startedAt,
102
+ tools,
103
+ outcome: runOutcome(ev.messages),
104
+ };
105
+ startedAt = 0;
106
+ try {
107
+ pi.appendEntry(TURN_ENTRY_TYPE, summary);
108
+ }
109
+ catch {
110
+ // A session that cannot accept entries just goes without the separator.
111
+ }
112
+ });
113
+ pi.on("session_shutdown", () => {
114
+ ctx = undefined;
115
+ startedAt = 0;
116
+ });
117
+ return () => {
118
+ ctx = undefined;
119
+ startedAt = 0;
120
+ };
121
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-minimal",
3
- "version": "0.1.0",
3
+ "version": "0.2.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
@@ -10,9 +10,20 @@
10
10
  "keywords": [
11
11
  "pi-package"
12
12
  ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/raviy06/pi-minimal.git"
16
+ },
17
+ "homepage": "https://github.com/raviy06/pi-minimal#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/raviy06/pi-minimal/issues"
20
+ },
13
21
  "pi": {
14
22
  "extensions": [
15
23
  "./dist/index.js"
24
+ ],
25
+ "themes": [
26
+ "./themes/minimal-dark.json"
16
27
  ]
17
28
  },
18
29
  "peerDependencies": {
@@ -20,8 +31,8 @@
20
31
  "@earendil-works/pi-tui": "*"
21
32
  },
22
33
  "devDependencies": {
23
- "@earendil-works/pi-coding-agent": "*",
24
- "@earendil-works/pi-tui": "*",
34
+ "@earendil-works/pi-coding-agent": "^1.4.0",
35
+ "@earendil-works/pi-tui": "^1.4.0",
25
36
  "@eslint/js": "^10.0.1",
26
37
  "@types/node": "*",
27
38
  "eslint": "^10.11.0",
@@ -0,0 +1,32 @@
1
+ ### Added
2
+ - Fenced code renders as a closed box (`╭─ lang ─╮` / `│ code │` / `╰───╯`)
3
+ instead of literal ``` lines, keeping syntax highlighting; long lines wrap
4
+ inside the box. pi-tui exposes no hook for this, so pi-minimal post-processes
5
+ the code token's rendered lines.
6
+
7
+ ### Changed
8
+ - Chrome margin (status line, footer, turn rule, tool rows) follows pi's
9
+ `outputPad` setting so it lines up with transcript text. Set
10
+ `editorPaddingX` to the same value for a flush left edge.
11
+ - Chrome modules tolerate a stale `chrome` module after `/reload`, which
12
+ previously could crash the renderer when the package was updated in place.
13
+ - `mdCodeBlockBorder` in `minimal-dark` is a faint grey again (it frames the
14
+ box now) instead of background-coloured.
15
+
16
+ ## What's Changed
17
+ * Fix trusted publishing: upgrade npm, make Publish re-runnable by @raviy06 in https://github.com/raviy06/pi-minimal/pull/7
18
+ * Add Dependabot configuration file by @raviy06 in https://github.com/raviy06/pi-minimal/pull/9
19
+ * Create SECURITY.md for security policy by @raviy06 in https://github.com/raviy06/pi-minimal/pull/10
20
+ * Pin npm 11 for trusted publishing by @raviy06 in https://github.com/raviy06/pi-minimal/pull/8
21
+ * Drop registry-url so npm uses OIDC instead of a placeholder token by @raviy06 in https://github.com/raviy06/pi-minimal/pull/11
22
+ * Add repository metadata required for npm provenance by @raviy06 in https://github.com/raviy06/pi-minimal/pull/12
23
+ * Frame fenced code, align chrome with outputPad (0.2.2) by @raviy06 in https://github.com/raviy06/pi-minimal/pull/13
24
+
25
+
26
+ **Full Changelog**: https://github.com/raviy06/pi-minimal/compare/v0.2.1...v0.2.2
27
+
28
+ ---
29
+ **Install / upgrade**
30
+ ```bash
31
+ pi install npm:pi-minimal@0.2.2 # or: pi update --extensions
32
+ ```
package/src/activity.ts CHANGED
@@ -10,7 +10,3 @@ export function rowIsRunning(context: ActivityContext): boolean {
10
10
  if (context.isPartial === false) return false
11
11
  return true
12
12
  }
13
-
14
- export function activityBullet(running: boolean, frame: 0 | 1): "◆" | "◇" {
15
- return running && frame === 1 ? "◇" : "◆"
16
- }
package/src/chrome.ts CHANGED
@@ -1 +1,19 @@
1
+ /**
2
+ * Horizontal margin for pi-minimal's chrome (status line, footer, turn rule, tool rows).
3
+ * Follows pi's `outputPad` setting so chrome lines up with transcript text. Pi's editor uses
4
+ * the separate `editorPaddingX` setting (default 0); set it to the same value for a flush
5
+ * left edge.
6
+ */
7
+ const KEY = Symbol.for("pi-minimal.chrome-margin")
8
+ const store = globalThis as unknown as Record<symbol, number | undefined>
9
+
10
+ /** Kept for modules linked against an older copy of this file across a hot reload. */
1
11
  export const CHROME_MARGIN = 1
12
+
13
+ export function chromeMargin(): number {
14
+ return store[KEY] ?? CHROME_MARGIN
15
+ }
16
+
17
+ export function setChromeMargin(value: number): void {
18
+ store[KEY] = value === 0 ? 0 : 1
19
+ }
@@ -0,0 +1,86 @@
1
+ import { Markdown, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui"
2
+
3
+ /**
4
+ * pi-tui renders fenced code as literal ``` lines. Pi exposes no hook for the code token, so
5
+ * pi-minimal post-processes the rendered lines of every `code` token into a closed box:
6
+ *
7
+ * ╭─ json ──────────────╮
8
+ * │ { "a": 1 } │
9
+ * ╰─────────────────────╯
10
+ *
11
+ * Code lines longer than the box are wrapped inside it so the right edge stays intact.
12
+ */
13
+
14
+ type CodeToken = { type?: string; lang?: string; text?: string }
15
+ type MarkdownThemeLike = {
16
+ codeBlockBorder: (text: string) => string
17
+ codeBlockIndent?: string
18
+ }
19
+
20
+ const PATCHED = Symbol.for("pi-minimal.code-box")
21
+ /** Current framing implementation. Lives on globalThis so a /reload swaps the behaviour of the
22
+ * wrapper that an earlier load already installed on pi-tui's shared Markdown prototype. */
23
+ const FRAME_SLOT = Symbol.for("pi-minimal.code-box.frame")
24
+
25
+ type Framer = (lines: string[], lang: string | undefined, width: number, theme: MarkdownThemeLike) => string[]
26
+ const slots = globalThis as unknown as Record<symbol, Framer | undefined>
27
+
28
+ export function frameCodeLines(lines: string[], lang: string | undefined, width: number, theme: MarkdownThemeLike): string[] {
29
+ if (lines.length < 2) return lines
30
+ // Trailing spacer ("" after the closing fence) is preserved as-is.
31
+ let end = lines.length
32
+ while (end > 0 && lines[end - 1] === "") end -= 1
33
+ if (end < 2) return lines
34
+ const trailer = lines.slice(end)
35
+ const body = lines.slice(1, end - 1)
36
+ const indent = theme.codeBlockIndent ?? " "
37
+ const outer = Math.max(6, width)
38
+ // "│ " + content + " │"
39
+ const innerWidth = outer - 4
40
+
41
+ const label = lang ? ` ${lang} ` : ""
42
+ const topRule = "─".repeat(Math.max(1, outer - 3 - visibleWidth(label)))
43
+ const top = theme.codeBlockBorder(`╭─${label}${topRule}╮`)
44
+ const bottom = theme.codeBlockBorder(`╰${"─".repeat(outer - 2)}╯`)
45
+ const edge = theme.codeBlockBorder("│")
46
+
47
+ const framed: string[] = []
48
+ for (const line of body) {
49
+ const content = line.startsWith(indent) ? line.slice(indent.length) : line
50
+ for (const piece of wrapTextWithAnsi(content, innerWidth)) {
51
+ const pad = Math.max(0, innerWidth - visibleWidth(piece))
52
+ framed.push(`${edge} ${piece}${" ".repeat(pad)} ${edge}`)
53
+ }
54
+ }
55
+ if (framed.length === 0) framed.push(`${edge} ${" ".repeat(innerWidth)} ${edge}`)
56
+ return [top, ...framed, bottom, ...trailer]
57
+ }
58
+
59
+ type RenderToken = (this: { theme: MarkdownThemeLike }, token: CodeToken, width: number, next?: string, style?: unknown) => string[]
60
+
61
+ /**
62
+ * Patch pi-tui's Markdown renderer. The prototype wrapper is installed once per process; every
63
+ * call (including after /reload) points it at this module's `frameCodeLines`.
64
+ * Returns false when the renderer is not patchable.
65
+ */
66
+ export function installCodeBox(): boolean {
67
+ slots[FRAME_SLOT] = frameCodeLines
68
+ const proto = Markdown.prototype as unknown as Record<string | symbol, unknown>
69
+ if (proto[PATCHED]) return true
70
+ const original = proto.renderToken
71
+ if (typeof original !== "function") return false
72
+ const originalRender = original as RenderToken
73
+ proto.renderToken = function (this: { theme: MarkdownThemeLike }, token: CodeToken, width: number, next?: string, style?: unknown) {
74
+ const lines = originalRender.call(this, token, width, next, style)
75
+ if (token?.type !== "code" || !this.theme?.codeBlockBorder) return lines
76
+ const frame = slots[FRAME_SLOT]
77
+ if (!frame) return lines
78
+ try {
79
+ return frame(lines, token.lang, width, this.theme)
80
+ } catch {
81
+ return lines
82
+ }
83
+ }
84
+ proto[PATCHED] = true
85
+ return true
86
+ }