@saifymatteo/pi-extensions 0.0.0-stage → 0.1.1

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 (183) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE.md +20 -0
  3. package/README.md +151 -2
  4. package/inventory.json +73 -0
  5. package/package.json +74 -4
  6. package/packages/pi-bg-bash/LICENSE +661 -0
  7. package/packages/pi-bg-bash/NOTICE.md +3 -0
  8. package/packages/pi-bg-bash/README.md +41 -0
  9. package/packages/pi-bg-bash/examples/settings.example.json +6 -0
  10. package/packages/pi-bg-bash/package.json +38 -0
  11. package/packages/pi-bg-bash/src/index.ts +969 -0
  12. package/packages/pi-bg-bash/src/lib/async-work.ts +27 -0
  13. package/packages/pi-bg-bash/test/bg-bash.test.mjs +34 -0
  14. package/packages/pi-claude-usage/LICENSE +661 -0
  15. package/packages/pi-claude-usage/NOTICE.md +3 -0
  16. package/packages/pi-claude-usage/README.md +27 -0
  17. package/packages/pi-claude-usage/package.json +35 -0
  18. package/packages/pi-claude-usage/src/index.ts +130 -0
  19. package/packages/pi-clipboard-image/LICENSE +661 -0
  20. package/packages/pi-clipboard-image/NOTICE.md +3 -0
  21. package/packages/pi-clipboard-image/README.md +27 -0
  22. package/packages/pi-clipboard-image/package.json +36 -0
  23. package/packages/pi-clipboard-image/src/index.ts +527 -0
  24. package/packages/pi-codeblock-borders/LICENSE +661 -0
  25. package/packages/pi-codeblock-borders/NOTICE.md +3 -0
  26. package/packages/pi-codeblock-borders/README.md +30 -0
  27. package/packages/pi-codeblock-borders/package.json +35 -0
  28. package/packages/pi-codeblock-borders/src/index.ts +302 -0
  29. package/packages/pi-codegraph/LICENSE +661 -0
  30. package/packages/pi-codegraph/NOTICE.md +5 -0
  31. package/packages/pi-codegraph/README.md +35 -0
  32. package/packages/pi-codegraph/examples/codegraph.example.json +4 -0
  33. package/packages/pi-codegraph/package.json +37 -0
  34. package/packages/pi-codegraph/src/index.ts +573 -0
  35. package/packages/pi-codegraph/test/codegraph.test.mjs +21 -0
  36. package/packages/pi-compact-tools/LICENSE +661 -0
  37. package/packages/pi-compact-tools/NOTICE.md +3 -0
  38. package/packages/pi-compact-tools/README.md +27 -0
  39. package/packages/pi-compact-tools/package.json +36 -0
  40. package/packages/pi-compact-tools/src/index.ts +1161 -0
  41. package/packages/pi-decisions/LICENSE +661 -0
  42. package/packages/pi-decisions/NOTICE.md +3 -0
  43. package/packages/pi-decisions/README.md +37 -0
  44. package/packages/pi-decisions/examples/service-models.example.json +27 -0
  45. package/packages/pi-decisions/package.json +37 -0
  46. package/packages/pi-decisions/src/index.ts +364 -0
  47. package/packages/pi-decisions/src/lib/service-models.ts +95 -0
  48. package/packages/pi-decisions/test/service-models.test.mjs +71 -0
  49. package/packages/pi-load-pi-dotenv/LICENSE +661 -0
  50. package/packages/pi-load-pi-dotenv/NOTICE.md +3 -0
  51. package/packages/pi-load-pi-dotenv/README.md +27 -0
  52. package/packages/pi-load-pi-dotenv/package.json +32 -0
  53. package/packages/pi-load-pi-dotenv/src/index.ts +66 -0
  54. package/packages/pi-model-go/LICENSE +661 -0
  55. package/packages/pi-model-go/NOTICE.md +5 -0
  56. package/packages/pi-model-go/README.md +33 -0
  57. package/packages/pi-model-go/package.json +36 -0
  58. package/packages/pi-model-go/src/index.ts +171 -0
  59. package/packages/pi-model-go/src/lib/dialog.ts +31 -0
  60. package/packages/pi-model-go/src/lib/model-go.ts +239 -0
  61. package/packages/pi-model-go/test/behavior.test.mjs +17 -0
  62. package/packages/pi-prestart-working/LICENSE +661 -0
  63. package/packages/pi-prestart-working/NOTICE.md +3 -0
  64. package/packages/pi-prestart-working/README.md +25 -0
  65. package/packages/pi-prestart-working/package.json +35 -0
  66. package/packages/pi-prestart-working/src/index.ts +86 -0
  67. package/packages/pi-prestart-working/test/behavior.test.mjs +56 -0
  68. package/packages/pi-root-guard/LICENSE +661 -0
  69. package/packages/pi-root-guard/NOTICE.md +3 -0
  70. package/packages/pi-root-guard/README.md +27 -0
  71. package/packages/pi-root-guard/package.json +32 -0
  72. package/packages/pi-root-guard/src/index.ts +202 -0
  73. package/packages/pi-session-finder/LICENSE +661 -0
  74. package/packages/pi-session-finder/NOTICE.md +3 -0
  75. package/packages/pi-session-finder/README.md +33 -0
  76. package/packages/pi-session-finder/package.json +36 -0
  77. package/packages/pi-session-finder/src/index.ts +306 -0
  78. package/packages/pi-session-finder/src/lib/dialog.ts +31 -0
  79. package/packages/pi-session-finder/test/behavior.test.mjs +18 -0
  80. package/packages/pi-session-id/LICENSE +661 -0
  81. package/packages/pi-session-id/NOTICE.md +3 -0
  82. package/packages/pi-session-id/README.md +31 -0
  83. package/packages/pi-session-id/package.json +35 -0
  84. package/packages/pi-session-id/src/index.ts +20 -0
  85. package/packages/pi-session-id/test/behavior.test.mjs +25 -0
  86. package/packages/pi-session-nav/LICENSE +661 -0
  87. package/packages/pi-session-nav/NOTICE.md +3 -0
  88. package/packages/pi-session-nav/README.md +32 -0
  89. package/packages/pi-session-nav/package.json +36 -0
  90. package/packages/pi-session-nav/src/index.ts +203 -0
  91. package/packages/pi-session-nav/src/lib/dialog.ts +31 -0
  92. package/packages/pi-session-nav/test/behavior.test.mjs +36 -0
  93. package/packages/pi-session-rename/LICENSE +661 -0
  94. package/packages/pi-session-rename/NOTICE.md +3 -0
  95. package/packages/pi-session-rename/README.md +31 -0
  96. package/packages/pi-session-rename/package.json +35 -0
  97. package/packages/pi-session-rename/src/index.ts +456 -0
  98. package/packages/pi-session-rename/test/behavior.test.mjs +10 -0
  99. package/packages/pi-session-retention/LICENSE +661 -0
  100. package/packages/pi-session-retention/NOTICE.md +3 -0
  101. package/packages/pi-session-retention/README.md +62 -0
  102. package/packages/pi-session-retention/package.json +36 -0
  103. package/packages/pi-session-retention/src/README.md +42 -0
  104. package/packages/pi-session-retention/src/core.ts +333 -0
  105. package/packages/pi-session-retention/src/dialog.ts +118 -0
  106. package/packages/pi-session-retention/src/index.ts +311 -0
  107. package/packages/pi-session-retention/src/lib/dialog.ts +31 -0
  108. package/packages/pi-session-retention/src/safety.ts +50 -0
  109. package/packages/pi-session-retention/test/behavior.test.mjs +42 -0
  110. package/packages/pi-skill-usage-log/LICENSE +661 -0
  111. package/packages/pi-skill-usage-log/NOTICE.md +3 -0
  112. package/packages/pi-skill-usage-log/README.md +32 -0
  113. package/packages/pi-skill-usage-log/package.json +36 -0
  114. package/packages/pi-skill-usage-log/src/index.ts +316 -0
  115. package/packages/pi-skill-usage-log/src/lib/dialog.ts +31 -0
  116. package/packages/pi-skill-usage-log/src/lib/usage-log.ts +62 -0
  117. package/packages/pi-skill-usage-log/test/behavior.test.mjs +34 -0
  118. package/packages/pi-tool-usage-log/LICENSE +661 -0
  119. package/packages/pi-tool-usage-log/NOTICE.md +5 -0
  120. package/packages/pi-tool-usage-log/README.md +32 -0
  121. package/packages/pi-tool-usage-log/package.json +36 -0
  122. package/packages/pi-tool-usage-log/src/index.ts +233 -0
  123. package/packages/pi-tool-usage-log/src/lib/dialog.ts +31 -0
  124. package/packages/pi-tool-usage-log/src/lib/usage-log.ts +62 -0
  125. package/packages/pi-tool-usage-log/src/skill-usage-log.ts +316 -0
  126. package/packages/pi-tool-usage-log/test/behavior.test.mjs +23 -0
  127. package/packages/pi-tts/LICENSE +661 -0
  128. package/packages/pi-tts/NOTICE.md +3 -0
  129. package/packages/pi-tts/README.md +37 -0
  130. package/packages/pi-tts/examples/service-models.example.json +27 -0
  131. package/packages/pi-tts/package.json +37 -0
  132. package/packages/pi-tts/src/index.ts +135 -0
  133. package/packages/pi-tts/src/lib/service-models.ts +95 -0
  134. package/packages/pi-tts/test/tts-output.test.mjs +47 -0
  135. package/packages/pi-tts/test/tts.test.mjs +57 -0
  136. package/packages/pi-user-image-previews/LICENSE +661 -0
  137. package/packages/pi-user-image-previews/NOTICE.md +3 -0
  138. package/packages/pi-user-image-previews/README.md +27 -0
  139. package/packages/pi-user-image-previews/package.json +36 -0
  140. package/packages/pi-user-image-previews/src/index.ts +168 -0
  141. package/packages/pi-vision/LICENSE +661 -0
  142. package/packages/pi-vision/NOTICE.md +5 -0
  143. package/packages/pi-vision/README.md +43 -0
  144. package/packages/pi-vision/examples/vision-toolkit.example.json +12 -0
  145. package/packages/pi-vision/package.json +38 -0
  146. package/packages/pi-vision/skills/vision-tools/LICENSE +21 -0
  147. package/packages/pi-vision/skills/vision-tools/SKILL.md +134 -0
  148. package/packages/pi-vision/skills/vision-tools/agents/openai.yaml +4 -0
  149. package/packages/pi-vision/skills/vision-tools/bin/crop +94 -0
  150. package/packages/pi-vision/skills/vision-tools/bin/detect +18 -0
  151. package/packages/pi-vision/skills/vision-tools/bin/glance +98 -0
  152. package/packages/pi-vision/skills/vision-tools/bin/ground +18 -0
  153. package/packages/pi-vision/skills/vision-tools/bin/trace +134 -0
  154. package/packages/pi-vision/skills/vision-tools/bin/vision-doctor +168 -0
  155. package/packages/pi-vision/skills/vision-tools/detect.py +56 -0
  156. package/packages/pi-vision/skills/vision-tools/ground.py +216 -0
  157. package/packages/pi-vision/skills/vision-tools/references/gui.md +93 -0
  158. package/packages/pi-vision/skills/vision-tools/references/long-screenshot-ocr.md +103 -0
  159. package/packages/pi-vision/skills/vision-tools/references/restore-graphic.md +92 -0
  160. package/packages/pi-vision/skills/vision-tools/references/restore-structure.md +47 -0
  161. package/packages/pi-vision/skills/vision-tools/references/restore-ui.md +200 -0
  162. package/packages/pi-vision/skills/vision-tools/scripts/dominant_colors.py +224 -0
  163. package/packages/pi-vision/skills/vision-tools/scripts/extract_fg.py +278 -0
  164. package/packages/pi-vision/skills/vision-tools/scripts/html_shot.py +111 -0
  165. package/packages/pi-vision/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
  166. package/packages/pi-vision/skills/vision-tools/scripts/pixel_diff.py +88 -0
  167. package/packages/pi-vision/skills/vision-tools/vision_client.py +581 -0
  168. package/packages/pi-vision/src/index.ts +728 -0
  169. package/packages/pi-vision/test/vision.test.mjs +30 -0
  170. package/packages/pi-wait/LICENSE +661 -0
  171. package/packages/pi-wait/NOTICE.md +3 -0
  172. package/packages/pi-wait/README.md +37 -0
  173. package/packages/pi-wait/package.json +37 -0
  174. package/packages/pi-wait/src/index.ts +88 -0
  175. package/packages/pi-wait/src/lib/async-work.ts +27 -0
  176. package/packages/pi-wait/test/wait.test.mjs +43 -0
  177. package/packages/pi-zvec/LICENSE +661 -0
  178. package/packages/pi-zvec/NOTICE.md +3 -0
  179. package/packages/pi-zvec/README.md +37 -0
  180. package/packages/pi-zvec/examples/zvec.example.json +5 -0
  181. package/packages/pi-zvec/package.json +37 -0
  182. package/packages/pi-zvec/src/index.ts +754 -0
  183. package/packages/pi-zvec/test/zvec.test.mjs +19 -0
@@ -0,0 +1,30 @@
1
+ # pi-codeblock-borders
2
+
3
+ Render markdown code fences as full-width rules spanning the content area, and upgrade the fence info string with language decoration, titles, and line numbers that Pi's built-in renderer does not support.
4
+
5
+ ## Running locally
6
+
7
+ From the monorepo root:
8
+
9
+ ```sh
10
+ pi -e ./packages/pi-codeblock-borders
11
+ ```
12
+
13
+ **Warning:** this extension uses your existing Pi resources and settings unless you load it in an isolated home (see the root README). Do not load two copies of the same extension (for example an older standalone file alongside this package): commands and hooks can duplicate. The package never installs or relocates personal settings.
14
+
15
+ ## Behavior
16
+
17
+ - Replaces the literal ``` fence markers with horizontal rules sized to the terminal width.
18
+ - Decorates the opening info string: `lang`, `lang` separators, optional `title`, and `:line-numbers`.
19
+ - Patches the theme's `codeBlockBorder`/`highlightCode` callbacks for the session and restores the originals on `session_shutdown`.
20
+ - Applies to every markdown code block; there is no per-block opt-out.
21
+
22
+ ## Settings and storage
23
+
24
+ None. The extension holds no configuration file and reads no user data beyond the rendered token stream.
25
+
26
+ ## Compatibility
27
+
28
+ Node.js >=24 for development checks; run extensions through Pi's TypeScript loader. Host imports are wildcard peers, not bundled runtime copies. The patch reaches into Pi-TUI `Markdown` internals, so host-version changes can break it; verify against your target host before relying on it. Overlapping UI patches (for example from pi-atelier) need integration testing; load one renderer patch at a time in an isolated session first.
29
+
30
+ See [NOTICE.md](NOTICE.md) for provenance. Tests patch a stubbed renderer and never contact services or write to user-home resources.
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@saifymatteo/pi-codeblock-borders",
3
+ "version": "0.1.1",
4
+ "license": "AGPL-3.0-only",
5
+ "description": "Decorated code fences",
6
+ "engines": {
7
+ "node": ">=24"
8
+ },
9
+ "pi": {
10
+ "extensions": [
11
+ "./src/index.ts"
12
+ ]
13
+ },
14
+ "files": [
15
+ "src",
16
+ "README.md",
17
+ "NOTICE.md",
18
+ "LICENSE"
19
+ ],
20
+ "peerDependencies": {
21
+ "@earendil-works/pi-coding-agent": "*",
22
+ "@earendil-works/pi-tui": "*"
23
+ },
24
+ "keywords": [
25
+ "pi-package"
26
+ ],
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/saifymatteo/pi-extensions.git",
33
+ "directory": "packages/pi-codeblock-borders"
34
+ }
35
+ }
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Codeblock borders for pi.
3
+ *
4
+ * Renders markdown code fences as full-width rules spanning the content area
5
+ * instead of the literal ``` markers, and upgrades the fence info string with
6
+ * features pi's built-in renderer does not support:
7
+ *
8
+ * bash── deploy.sh ──────────────────── ```bash title="deploy.sh"
9
+ * #!/usr/bin/env bash (title embedded in top rule)
10
+ * ────────────────────────────────────
11
+ *
12
+ * ts────────────────────────────────── ```ts:line-numbers
13
+ * 1 │ const a = 1; (line numbers)
14
+ * ────────────────────────────────────
15
+ *
16
+ * bash──────────────────────────────── ```bash {2,4-6}
17
+ * const a = 1; (lines 2, 4-6 highlighted;
18
+ * ... the rest dimmed)
19
+ *
20
+ * Supported info-string syntax (all combinable, any order):
21
+ * lang - first word: bash, ts, diff, ...
22
+ * :line-numbers - prefix each code line with a dim line number;
23
+ * optional start offset: :line-numbers=5
24
+ * {2,4-6} - keep those lines at full color, dim the rest
25
+ * title="..." - embed a title in the top rule (quotes optional for
26
+ * single words; also title='...' and title=word)
27
+ *
28
+ * Why an info string like "bash {2,4-6}" degrades rendering without this
29
+ * extension: marked keeps the FULL info string in token.lang, pi passes it to
30
+ * highlight.js as the language name, which then fails to match and falls back
31
+ * to unhighlighted code. This extension parses the info string itself and
32
+ * feeds highlight.js only the leading language word, restoring highlighting.
33
+ *
34
+ * `diff` fences are natively supported by pi (+/- lines are colored via the
35
+ * toolDiffAdded / toolDiffRemoved theme colors) and are left untouched.
36
+ *
37
+ * Implementation: wraps Markdown.prototype.renderToken from the bundled
38
+ * @earendil-works/pi-tui, swapping theme.codeBlockBorder and
39
+ * theme.highlightCode for the duration of each call. Widths come from the
40
+ * actual render call, so rules and gutters size correctly in nested contexts
41
+ * (blockquotes, list items). Caching, streaming, and theme colors are
42
+ * untouched. Display-only: session content and LLM context keep the original
43
+ * fences.
44
+ *
45
+ * Remove this file to restore the previous rendering, then run /reload or
46
+ * restart pi.
47
+ */
48
+
49
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
50
+ import { Markdown, visibleWidth } from "@earendil-works/pi-tui";
51
+
52
+ /** Character used for the rule lines. Use "-" for plain ASCII dashes. */
53
+ const BORDER_CHAR = "─";
54
+
55
+ /**
56
+ * Set to false for plain rules on fenced blocks too. When true, fenced blocks
57
+ * with a language show it as a prefix of the top rule, e.g. `bash──────`.
58
+ */
59
+ const SHOW_LANGUAGE = true;
60
+
61
+ /** Separator between the line number and the code in the gutter. */
62
+ const GUTTER_SEP = " │ ";
63
+
64
+ /** ANSI dim (SGR 2) used for gutter numbers and non-focused lines. */
65
+ const DIM = "\x1b[2m";
66
+ const DIM_OFF = "\x1b[22m";
67
+
68
+ interface FenceInfo {
69
+ /** Leading language word, e.g. "bash" from "bash:line-numbers {1}". */
70
+ lang: string;
71
+ /** Title from title="..." / title='...' / title=word, if present. */
72
+ title?: string;
73
+ /** :line-numbers flag present. */
74
+ lineNumbers: boolean;
75
+ /** Start value for line numbers (:line-numbers=5). */
76
+ lineNumbersStart: number;
77
+ /** Focus specification, resolved only after the highlighted line count is known. */
78
+ focus?: string;
79
+ }
80
+
81
+ function stripAnsi(text: string): string {
82
+ return text.replace(/\x1b\[[0-9;]*m/g, "");
83
+ }
84
+
85
+ function dimText(text: string): string {
86
+ // Re-assert dim after every ANSI reset so syntax colors inside the line do
87
+ // not cancel it; finish with DIM_OFF so nothing leaks past the line.
88
+ return DIM + text.replaceAll("\x1b[0m", `\x1b[0m${DIM}`) + DIM_OFF;
89
+ }
90
+
91
+ function parseFocus(spec: string, lineCount: number): Set<number> | undefined {
92
+ const set = new Set<number>();
93
+ let valid = false;
94
+ for (const part of spec.split(",")) {
95
+ const seg = part.trim();
96
+ if (!seg) continue;
97
+ const range = /^([0-9]+)\s*-\s*([0-9]+)$/.exec(seg);
98
+ if (range) {
99
+ const a = Number(range[1]);
100
+ const b = Number(range[2]);
101
+ if (!Number.isSafeInteger(a) || !Number.isSafeInteger(b)) continue;
102
+ valid = true;
103
+ // Never enumerate outside the rendered block, even for reversed ranges.
104
+ const end = Math.min(lineCount, Math.max(a, b));
105
+ for (let n = Math.max(1, Math.min(a, b)); n <= end; n++) set.add(n);
106
+ } else if (/^[0-9]+$/.test(seg)) {
107
+ const n = Number(seg);
108
+ if (!Number.isSafeInteger(n)) continue;
109
+ valid = true;
110
+ if (n >= 1 && n <= lineCount) set.add(n);
111
+ }
112
+ }
113
+ // A valid but out-of-bounds selection still dims every line, as before.
114
+ return valid ? set : undefined;
115
+ }
116
+
117
+ function parseFenceInfo(info: string): FenceInfo {
118
+ const parsed: FenceInfo = { lang: "", lineNumbers: false, lineNumbersStart: 1 };
119
+ let rest = (info ?? "").trim();
120
+ if (!rest) return parsed;
121
+
122
+ // title="..." | title='...' | title=word (extract first so flag searches
123
+ // below cannot match inside the title text)
124
+ const titleMatch = /(?:^|\s)title=(?:"([^"]*)"|'([^']*)'|(\S+))/.exec(rest);
125
+ if (titleMatch) {
126
+ const value = (titleMatch[1] ?? titleMatch[2] ?? titleMatch[3] ?? "").trim();
127
+ if (value) parsed.title = value;
128
+ rest = (rest.slice(0, titleMatch.index) + " " + rest.slice(titleMatch.index + titleMatch[0].length)).trim();
129
+ }
130
+
131
+ // {2,4-6} line focus
132
+ const braces = /\{\s*([0-9][0-9\s,-]*)\}/.exec(rest);
133
+ if (braces) {
134
+ parsed.focus = braces[1];
135
+ rest = (rest.slice(0, braces.index) + " " + rest.slice(braces.index + braces[0].length)).trim();
136
+ }
137
+
138
+ // :line-numbers with optional start offset. Matches when the flag attaches
139
+ // directly to the language ("bash:line-numbers") or stands alone.
140
+ const lineNums = /:(?:line-numbers)(?:=([0-9]+))?(?![\w-])/.exec(rest);
141
+ if (lineNums) {
142
+ parsed.lineNumbers = true;
143
+ if (lineNums[1]) parsed.lineNumbersStart = Math.max(1, Number(lineNums[1]));
144
+ rest = (rest.slice(0, lineNums.index) + " " + rest.slice(lineNums.index + lineNums[0].length)).trim();
145
+ }
146
+
147
+ // Language: first word of what remains (stops at space, ":" or "{").
148
+ const lang = /^[^ \t:{}]+/.exec(rest);
149
+ if (lang) parsed.lang = lang[0];
150
+ return parsed;
151
+ }
152
+
153
+ /** Top rule: language prefix, optional embedded title, dashes to full width. */
154
+ function composeTopRule(width: number, parsed: FenceInfo): string {
155
+ const w = Math.max(1, Math.floor(Number.isFinite(width) ? width : 1));
156
+
157
+ const lang = /[\r\n]/.test(parsed.lang) ? "" : parsed.lang;
158
+ if (parsed.title && !/[\r\n]/.test(parsed.title)) {
159
+ const label = SHOW_LANGUAGE && lang ? `${lang}${BORDER_CHAR.repeat(2)} ` : `${BORDER_CHAR.repeat(2)} `;
160
+ const candidate = `${label}${parsed.title} `;
161
+ const candidateWidth = visibleWidth(candidate);
162
+ if (candidateWidth + 1 <= w) {
163
+ return candidate + BORDER_CHAR.repeat(w - candidateWidth);
164
+ }
165
+ // Title too long for the rule; fall through to language-only rule.
166
+ }
167
+
168
+ const langWidth = visibleWidth(lang);
169
+ if (SHOW_LANGUAGE && lang && langWidth < w) {
170
+ return lang + BORDER_CHAR.repeat(w - langWidth);
171
+ }
172
+ return BORDER_CHAR.repeat(w);
173
+ }
174
+
175
+ interface HighlightCodeFn {
176
+ (code: string, lang: string): string[];
177
+ }
178
+
179
+ interface ThemeLike {
180
+ codeBlockBorder?: (text: string) => string;
181
+ highlightCode?: HighlightCodeFn;
182
+ }
183
+
184
+ interface MarkdownLike {
185
+ theme?: ThemeLike;
186
+ }
187
+
188
+ type RenderTokenFn = (
189
+ this: MarkdownLike,
190
+ token: unknown,
191
+ width: number,
192
+ nextTokenType?: string,
193
+ styleContext?: unknown,
194
+ ) => string[];
195
+
196
+ /** Apply :line-numbers and {2,4-6} to highlighted code lines. */
197
+ function decorateLines(lines: string[], parsed: FenceInfo): string[] {
198
+ let out = lines;
199
+
200
+ if (parsed.lineNumbers && out.length > 0) {
201
+ // Drop one trailing empty line so a numbered blank line does not sit
202
+ // against the bottom rule.
203
+ const last = out[out.length - 1];
204
+ if (out.length > 1 && stripAnsi(last).trim() === "") {
205
+ out = out.slice(0, -1);
206
+ }
207
+ const start = parsed.lineNumbersStart;
208
+ const gutterWidth = String(start + out.length - 1).length;
209
+ out = out.map((line, i) => {
210
+ const n = String(start + i).padStart(gutterWidth, " ");
211
+ return dimText(`${n}${GUTTER_SEP}`) + line;
212
+ });
213
+ }
214
+
215
+ const focus = parsed.focus ? parseFocus(parsed.focus, out.length) : undefined;
216
+ if (focus) {
217
+ out = out.map((line, i) => (focus.has(i + 1) ? line : dimText(line)));
218
+ }
219
+
220
+ return out;
221
+ }
222
+
223
+ export default function (pi: ExtensionAPI) {
224
+ const proto = Markdown.prototype as unknown as {
225
+ renderToken?: RenderTokenFn;
226
+ __codeblockBordersPatched?: boolean | RenderTokenFn;
227
+ };
228
+
229
+ // Legacy boolean markers cannot be safely unwrapped: restart once to migrate.
230
+ // New markers identify the owner so stale shutdowns cannot remove a reload.
231
+ if (proto.__codeblockBordersPatched) return;
232
+
233
+ const originalRenderToken = proto.renderToken;
234
+ if (typeof originalRenderToken !== "function") {
235
+ // pi-tui internals changed; stay inert instead of breaking rendering.
236
+ pi.on("session_start", async (_event, ctx) => {
237
+ ctx.ui.notify(
238
+ "codeblock-borders: Markdown.renderToken not found, extension inactive",
239
+ "warning",
240
+ );
241
+ });
242
+ return;
243
+ }
244
+
245
+ let active = true;
246
+ const renderToken: RenderTokenFn = function (token, width, nextTokenType, styleContext) {
247
+ // A later wrapper may retain us in its chain after shutdown.
248
+ if (!active) return originalRenderToken.call(this, token, width, nextTokenType, styleContext);
249
+ const theme = this.theme;
250
+ const originalBorder = theme?.codeBlockBorder;
251
+ const originalHighlight = theme?.highlightCode;
252
+ if (!theme || typeof originalBorder !== "function") {
253
+ return originalRenderToken.call(this, token, width, nextTokenType, styleContext);
254
+ }
255
+
256
+ // codeBlockBorder is called once for the opening fence ("```info") and
257
+ // once for the closing fence ("```"). Nested renders (fenced code inside a
258
+ // list/blockquote) swap this wrapper again while the outer swap is still
259
+ // active, so the inner wrapper's composed rule can arrive here as "text"
260
+ // through the outer wrapper's originalBorder — it does not start with
261
+ // "```", so pass it through unchanged instead of re-composing it at the
262
+ // outer (larger) width, which overflowed the nested content width.
263
+ theme.codeBlockBorder = (text: string) => {
264
+ if (!text.startsWith("```")) {
265
+ return originalBorder(text);
266
+ }
267
+ return originalBorder(composeTopRule(width, parseFenceInfo(text.slice(3))));
268
+ };
269
+
270
+ if (typeof originalHighlight === "function") {
271
+ // The renderer passes the full info string as the language. Feed
272
+ // highlight.js only the leading language word so decorated fences
273
+ // ("bash:line-numbers {2}" etc.) keep syntax highlighting, then
274
+ // apply the gutter/focus decorations.
275
+ theme.highlightCode = (code: string, infoLang: string) => {
276
+ const parsed = parseFenceInfo(infoLang ?? "");
277
+ const lines = originalHighlight(code, parsed.lang || infoLang || "");
278
+ return decorateLines(lines, parsed);
279
+ };
280
+ }
281
+
282
+ try {
283
+ return originalRenderToken.call(this, token, width, nextTokenType, styleContext);
284
+ } finally {
285
+ theme.codeBlockBorder = originalBorder;
286
+ if (theme && typeof originalHighlight === "function") {
287
+ theme.highlightCode = originalHighlight;
288
+ }
289
+ }
290
+ };
291
+
292
+ proto.renderToken = renderToken;
293
+ proto.__codeblockBordersPatched = renderToken;
294
+ pi.on("session_shutdown", () => {
295
+ if (!active) return;
296
+ active = false;
297
+ // Do not overwrite another extension's wrapper. Our retained wrapper is
298
+ // now pass-through, and a replacement can safely wrap the current chain.
299
+ if (proto.renderToken === renderToken) proto.renderToken = originalRenderToken;
300
+ if (proto.__codeblockBordersPatched === renderToken) delete proto.__codeblockBordersPatched;
301
+ });
302
+ }