@pi-archimedes/core 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,266 @@
1
+ import { clampLine, stripAnsi } from "../text.js";
2
+ import { gray, rgb, extractRgb, lerp } from "../color.js";
3
+ import { TRUECOLOR } from "./logo.js";
4
+ import type { Theme } from "@earendil-works/pi-coding-agent";
5
+ import { visibleWidth } from "@earendil-works/pi-tui";
6
+
7
+ // ── Types ──────────────────────────────────────────────────────
8
+
9
+ export const SECTION_KEYS = ["Models", "Context", "Prompts", "Skills", "Extensions", "Themes"] as const;
10
+ export type SectionKey = (typeof SECTION_KEYS)[number];
11
+ type RenderSectionKey = SectionKey | "Version";
12
+
13
+ export interface ParsedSection {
14
+ name: SectionKey;
15
+ items: string[];
16
+ }
17
+
18
+ interface RenderSection {
19
+ name: RenderSectionKey;
20
+ items: string[];
21
+ }
22
+
23
+ // ── Animation constants ────────────────────────────────────────
24
+
25
+ export const RAMP_FRAMES = 22;
26
+ export const STAGGER_FRAMES = 0;
27
+ export const BASE_FADE_DELAY = 3;
28
+ export const MAX_STAGGER = BASE_FADE_DELAY + 5 * STAGGER_FRAMES;
29
+
30
+ // ── Section detection & parsing ────────────────────────────────
31
+
32
+ export function detectSection(plain: string): SectionKey | undefined {
33
+ for (const key of SECTION_KEYS) {
34
+ if (plain.includes(`[${key}]`)) return key;
35
+ }
36
+ return undefined;
37
+ }
38
+
39
+ export function parseSectionText(plain: string): ParsedSection | undefined {
40
+ const sectionName = detectSection(plain);
41
+ if (!sectionName) return undefined;
42
+
43
+ const names: string[] = [];
44
+ const lines = plain.split("\n");
45
+ let currentSource = "";
46
+ let sourceIndent = 0;
47
+
48
+ for (const line of lines) {
49
+ const trimmed = line.trim();
50
+ if (!trimmed) continue;
51
+ if (trimmed.startsWith("[")) continue;
52
+ if (/^(user|project|path)$/.test(trimmed)) { currentSource = ""; sourceIndent = 0; continue; }
53
+
54
+ const indent = line.length - line.trimStart().length;
55
+
56
+ // Track package source headers (e.g. "git:github.com/...", "npm:@foo/bar")
57
+ // Extract name from the header itself + let children inherit the prefix
58
+ if (/^(git:|npm:)\S+\//.test(trimmed)) {
59
+ currentSource = trimmed.startsWith("git:") ? "git:" : "npm:";
60
+ sourceIndent = indent;
61
+ // Extract name from source header (e.g. "npm:@foo/pi-tavily-tools" → "npm:pi-tavily-tools")
62
+ const showSource = sectionName === "Extensions" || sectionName === "Skills";
63
+ const name = extractName(trimmed, sectionName);
64
+ if (name && showSource) names.push(name);
65
+ continue;
66
+ }
67
+
68
+ // Reset source prefix when indent returns to source level or shallower
69
+ if (currentSource && indent <= sourceIndent) {
70
+ currentSource = "";
71
+ sourceIndent = 0;
72
+ }
73
+
74
+ if (/^(index\.(ts|js)|src|dist|out|build|lib|bin)$/i.test(trimmed)) continue;
75
+ // Skip resolved file paths only under source headers (e.g. "dist/index.js" under npm:)
76
+ if (currentSource) {
77
+ if (/\/(src|dist|out|build|lib|bin)\//.test(trimmed)) continue;
78
+ if (/\.(ts|js)$/.test(trimmed) && trimmed.includes("/") && !/SKILL\.(ts|js)$/i.test(trimmed)) continue;
79
+ }
80
+
81
+ const name = extractName(trimmed, sectionName);
82
+ // Prompts/Context don't need source prefix — only Extensions/Skills
83
+ const showSource = sectionName === "Extensions" || sectionName === "Skills";
84
+ if (name) names.push(showSource && currentSource ? currentSource + name : name);
85
+ }
86
+
87
+ // Deduplicate by bare name (without prefix) — prefer prefixed version
88
+ const seen = new Map<string, string>();
89
+ for (const n of names) {
90
+ if (/^(index|dist|src|out|lib|bin)$/i.test(n)) continue;
91
+ const bare = n.replace(/^(npm:|git:)/, "");
92
+ if (!seen.has(bare) || n.includes(":")) seen.set(bare, n);
93
+ }
94
+ return { name: sectionName, items: [...seen.values()] };
95
+ }
96
+
97
+ export function parseModelScope(plain: string): ParsedSection | undefined {
98
+ const m = plain.match(/Model scope:\s*(.+)/i);
99
+ if (!m) return undefined;
100
+ const raw = m[1].replace(/\s*\(Ctrl\+\w[\w\s]*\)/gi, "");
101
+ const items = raw.split(",").map(s => s.trim()).filter(Boolean);
102
+ return items.length > 0 ? { name: "Models", items } : undefined;
103
+ }
104
+
105
+ // ── Name extraction helpers ────────────────────────────────────
106
+
107
+ function detectOrigin(path: string): { prefix: string; clean: string } {
108
+ if (/^npm:/.test(path)) return { prefix: "npm:", clean: path.slice(4) };
109
+ if (/^git:/.test(path)) return { prefix: "git:", clean: path.slice(4) };
110
+ if (/^https?:\/\//.test(path)) return { prefix: "git:", clean: path };
111
+ return { prefix: "", clean: path };
112
+ }
113
+
114
+ function cleanName(name: string): string {
115
+ return name.replace(/\.(ts|js|json|md|git)$/i, "");
116
+ }
117
+
118
+ type Extractor = (clean: string, prefix: string) => string;
119
+
120
+ const defaultExtract: Extractor = (clean, prefix) =>
121
+ prefix + cleanName(clean.split("/").pop() ?? clean);
122
+
123
+ const sectionExtractors: Record<SectionKey, Extractor> = {
124
+ Models: defaultExtract,
125
+ Themes: defaultExtract,
126
+ Prompts: (clean) => {
127
+ const base = clean.split("/").pop() ?? clean;
128
+ return cleanName(base) || clean;
129
+ },
130
+ Context: (clean) => clean.split("/").pop() ?? clean,
131
+ Skills: (clean, prefix) => {
132
+ if (!clean.includes("/")) return defaultExtract(clean, prefix);
133
+ const parts = clean.split("/");
134
+ const file = parts.pop() ?? "";
135
+ if (/^SKILL\.(md|ts|js)$/i.test(file)) {
136
+ return prefix + cleanName(parts.pop() ?? file);
137
+ }
138
+ return prefix + cleanName(file);
139
+ },
140
+ Extensions: (clean, prefix) => {
141
+ const stripped = clean.replace(/^https?:\/\/[^/]+\//, "");
142
+ const parts = stripped.split("/").filter(p => {
143
+ const lower = p.toLowerCase();
144
+ return p && !/^(index\.(ts|js)|src|dist|out|build|lib|bin)$/.test(lower);
145
+ });
146
+ if (parts.length > 0) return prefix + cleanName(parts.pop()!);
147
+ return prefix + cleanName(clean);
148
+ },
149
+ };
150
+
151
+ export function extractName(path: string, section: SectionKey): string {
152
+ const trimmed = path.trim();
153
+ const { prefix, clean } = detectOrigin(trimmed);
154
+ return sectionExtractors[section](clean, prefix);
155
+ }
156
+
157
+ // ── Column formatter ───────────────────────────────────────────
158
+
159
+ function pad(s: string, w: number): string {
160
+ const vw = visibleWidth(s);
161
+ return vw >= w ? s : s + " ".repeat(w - vw);
162
+ }
163
+
164
+ export function formatColumns(sections: RenderSection[], theme: Theme, maxW: number, ref: { frame: number; revealed: boolean; revealedAt: number; scaffoldAt: number; settled: boolean }): string[] {
165
+ if (sections.length === 0) return [];
166
+
167
+ const dim = (t: string) => theme.fg("dim", t);
168
+ const muted = (t: string) => theme.fg("muted", t);
169
+
170
+ const headerW = Math.max(...sections.map(s => s.name.length + 2)) + 2;
171
+
172
+ const itemAge = ref.revealed ? ref.frame - ref.revealedAt : 0;
173
+ const labelAge = ref.revealed ? ref.frame - ref.scaffoldAt : 0;
174
+
175
+ // RGB endpoints for fade ramps (truecolor only)
176
+ const fadeStartRgb: [number, number, number] = [20, 20, 20];
177
+ let dimRgb: [number, number, number] | undefined;
178
+ let mutedRgb: [number, number, number] | undefined;
179
+ const labelRamping = TRUECOLOR && ref.revealed && labelAge < RAMP_FRAMES + MAX_STAGGER;
180
+ const itemRamping = TRUECOLOR && ref.revealed && itemAge < RAMP_FRAMES + MAX_STAGGER;
181
+ if (labelRamping || itemRamping) {
182
+ dimRgb = extractRgb(theme.fg("dim", " "));
183
+ mutedRgb = extractRgb(theme.fg("muted", " "));
184
+ }
185
+
186
+ const lines: string[] = [];
187
+
188
+ for (let si = 0; si < sections.length; si++) {
189
+ const sec = sections[si];
190
+ if (sec.items.length === 0) continue;
191
+
192
+ const availableW = maxW - headerW - 1;
193
+
194
+ // Label fade: near-invisible → dim (static dim when not revealed)
195
+ const secLabelAge = Math.max(0, labelAge - BASE_FADE_DELAY - si * STAGGER_FRAMES);
196
+ const wrapLabel = ref.revealed
197
+ ? buildItemWrapper(secLabelAge, true, fadeStartRgb, dimRgb, dim)
198
+ : dim;
199
+ const header = wrapLabel(`[${sec.name}]`);
200
+ const paddedHeader = header + " ".repeat(Math.max(0, headerW - sec.name.length - 2));
201
+
202
+ // Item fade: near-invisible → muted
203
+ const secItemAge = Math.max(0, itemAge - BASE_FADE_DELAY - si * STAGGER_FRAMES);
204
+ const wrapItems = buildItemWrapper(secItemAge, ref.revealed, fadeStartRgb, mutedRgb, muted);
205
+
206
+ // Style prefix (npm:/git:) dimmer than the name
207
+ const styleItem = (raw: string): string => {
208
+ const prefixMatch = raw.match(/^(npm:|git:)/);
209
+ if (prefixMatch) {
210
+ const pfx = prefixMatch[1];
211
+ const name = raw.slice(pfx.length);
212
+ return wrapLabel(pfx) + wrapItems(name);
213
+ }
214
+ return wrapItems(raw);
215
+ };
216
+
217
+ let currentLine = "";
218
+ let currentStyled = "";
219
+ let firstLine = true;
220
+
221
+ for (const item of sec.items) {
222
+ const itemW = visibleWidth(item);
223
+ const currentW = visibleWidth(currentLine);
224
+
225
+ if (currentLine && currentW + 2 + itemW > availableW) {
226
+ lines.push(firstLine ? `${paddedHeader} ${currentStyled}` : " ".repeat(headerW + 1) + currentStyled);
227
+ currentLine = item;
228
+ currentStyled = styleItem(item);
229
+ firstLine = false;
230
+ } else {
231
+ currentLine = currentLine ? currentLine + " " + item : item;
232
+ currentStyled = currentStyled ? currentStyled + " " + styleItem(item) : styleItem(item);
233
+ }
234
+ }
235
+ if (currentLine) {
236
+ const rawLine = firstLine ? `${paddedHeader} ${currentStyled}` : " ".repeat(headerW + 1) + currentStyled;
237
+ lines.push(clampLine(rawLine, maxW));
238
+ }
239
+
240
+ if (sec.name === "Version") {
241
+ lines.push("");
242
+ }
243
+ }
244
+
245
+ return lines;
246
+ }
247
+
248
+ export function buildItemWrapper(
249
+ sectionAge: number,
250
+ revealed: boolean,
251
+ startRgb: [number, number, number] | undefined,
252
+ mutedRgb: [number, number, number] | undefined,
253
+ muted: (t: string) => string,
254
+ ): (text: string) => string {
255
+ if (!revealed) return (text) => text; // placeholders already styled
256
+
257
+ // No truecolor or ramp done → static muted
258
+ if (!startRgb || !mutedRgb || sectionAge >= RAMP_FRAMES) return muted;
259
+
260
+ const t = Math.min(1, sectionAge / RAMP_FRAMES);
261
+ const eased = 1 - (1 - t) * (1 - t);
262
+ const r = lerp(startRgb[0], mutedRgb[0], eased);
263
+ const g = lerp(startRgb[1], mutedRgb[1], eased);
264
+ const b = lerp(startRgb[2], mutedRgb[2], eased);
265
+ return (text) => rgb(r, g, b, text);
266
+ }
@@ -0,0 +1,26 @@
1
+ const NPM_REGISTRY_URL = "https://registry.npmjs.org/@earendil-works/pi-coding-agent/latest";
2
+ const FETCH_TIMEOUT_MS = 4000;
3
+
4
+ export async function fetchLatestVersion(): Promise<string | undefined> {
5
+ try {
6
+ const controller = new AbortController();
7
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
8
+ const res = await fetch(NPM_REGISTRY_URL, { signal: controller.signal });
9
+ clearTimeout(timeout);
10
+ if (!res.ok) return undefined;
11
+ const data = (await res.json()) as { version?: string };
12
+ return data.version;
13
+ } catch {
14
+ return undefined;
15
+ }
16
+ }
17
+
18
+ export function compareVersions(a: string, b: string): number {
19
+ const pa = a.replace(/^v/, "").split(".").map(Number);
20
+ const pb = b.replace(/^v/, "").split(".").map(Number);
21
+ for (let i = 0; i < 3; i++) {
22
+ if ((pa[i] ?? 0) > (pb[i] ?? 0)) return 1;
23
+ if ((pa[i] ?? 0) < (pb[i] ?? 0)) return -1;
24
+ }
25
+ return 0;
26
+ }
package/src/text.ts ADDED
@@ -0,0 +1,42 @@
1
+ import { truncateToWidth } from "@earendil-works/pi-tui";
2
+
3
+ // Inline stripSgr — narrow SGR-only strip (no trim) for char-level checks
4
+ const stripSgr = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
5
+
6
+ /** Strip ALL ANSI escapes (SGR color/style + OSC sequences). Trims whitespace. Use for text extraction. */
7
+ export function stripAnsi(s: string): string {
8
+ return s.replace(/\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?(?:\x07|\x1b\\)/g, "").trim();
9
+ }
10
+
11
+ /** Clamp a line to maxW visible characters, preserving ANSI escapes. */
12
+ export function clampLine(line: string, maxW: number): string {
13
+ return truncateToWidth(line, maxW);
14
+ }
15
+
16
+ /** Clamp an array of lines to maxW visible characters each. */
17
+ export function clampLines(lines: string[], maxW: number): string[] {
18
+ return lines.map((l) => clampLine(l, maxW));
19
+ }
20
+
21
+ // isParentBorder uses the narrow SGR-only strip (no trim) for char-level checks
22
+ export const isParentBorder = (s: string) => {
23
+ const clean = stripSgr(s);
24
+ return clean.length > 0 && clean[0] === "─";
25
+ };
26
+
27
+ export function formatKey(key: string | undefined): string {
28
+ if (!key) return "that key";
29
+ return key
30
+ .split("+")
31
+ .map((part) => {
32
+ const lower = part.toLowerCase();
33
+ if (lower === "ctrl") return "Ctrl";
34
+ if (lower === "alt") return "Alt";
35
+ if (lower === "shift") return "Shift";
36
+ if (lower === "cmd" || lower === "meta") return "Cmd";
37
+ return part.length === 1
38
+ ? part.toUpperCase()
39
+ : part[0]!.toUpperCase() + part.slice(1);
40
+ })
41
+ .join("+");
42
+ }
@@ -0,0 +1,150 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { AssistantMessageComponent } from "@earendil-works/pi-coding-agent";
3
+ import { Markdown, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui";
4
+ import { buildMutedMarkdownTheme } from "./theme.js";
5
+
6
+ // The label we prepend to visible thinking content.
7
+ const THINKING_LABEL = "\x1b[1m\x1b[38;2;255;215;0mThinking...\x1b[39m\x1b[22m";
8
+
9
+ /**
10
+ * Patches `AssistantMessageComponent.prototype.updateContent` so thinking
11
+ * blocks render with a muted `MarkdownTheme`. Called on every session_start
12
+ * to capture a fresh `getTheme` closure (required for /resume).
13
+ */
14
+ export function patchThinkingRenderer(getTheme: () => Theme): void {
15
+ if (!AssistantMessageComponent) return;
16
+
17
+ const proto = AssistantMessageComponent.prototype;
18
+ if (
19
+ !proto ||
20
+ typeof proto.updateContent !== "function" ||
21
+ AssistantMessageComponent.name !== "AssistantMessageComponent"
22
+ ) {
23
+ return;
24
+ }
25
+
26
+ const src = proto.updateContent.toString();
27
+ if (
28
+ !src.includes('content.type === "thinking"') ||
29
+ !src.includes("this.markdownTheme")
30
+ ) {
31
+ return;
32
+ }
33
+
34
+ // Re-patch every time — /resume needs a fresh getTheme closure
35
+ (proto as any).updateContent = function (this: any, message: any): void {
36
+ this.lastMessage = message;
37
+
38
+ this.markdownTheme.codeBlockIndent = "";
39
+ this.contentContainer.clear();
40
+
41
+ const hasVisibleContent = message.content.some(
42
+ (c: any) =>
43
+ (c.type === "text" && c.text.trim()) ||
44
+ (c.type === "thinking" && c.thinking.trim()),
45
+ );
46
+
47
+ if (hasVisibleContent) {
48
+ this.contentContainer.addChild(new Spacer(1));
49
+ }
50
+
51
+ // Lazy muted theme: built once per updateContent call.
52
+ let mutedTheme: ReturnType<typeof buildMutedMarkdownTheme> | undefined;
53
+ let theme: Theme | undefined;
54
+ let themeFailed = false;
55
+
56
+ const ensureTheme = (): Theme | undefined => {
57
+ if (themeFailed) return undefined;
58
+ if (!theme) {
59
+ try {
60
+ theme = getTheme();
61
+ } catch {
62
+ themeFailed = true;
63
+ return undefined;
64
+ }
65
+ }
66
+ return theme;
67
+ };
68
+
69
+ const ensureMuted = (): MarkdownTheme | undefined => {
70
+ if (!mutedTheme) {
71
+ const t = ensureTheme();
72
+ if (!t) return undefined;
73
+ mutedTheme = buildMutedMarkdownTheme(t);
74
+ }
75
+ return mutedTheme;
76
+ };
77
+
78
+ // Render content in order.
79
+ for (let i = 0; i < message.content.length; i++) {
80
+ const content = message.content[i];
81
+ if (content.type === "text" && content.text.trim()) {
82
+ this.contentContainer.addChild(
83
+ new Markdown(content.text.trim(), 1, 0, this.markdownTheme),
84
+ );
85
+ } else if (content.type === "thinking" && content.thinking.trim()) {
86
+ const hasVisibleContentAfter = message.content
87
+ .slice(i + 1)
88
+ .some(
89
+ (c: any) =>
90
+ (c.type === "text" && c.text.trim()) ||
91
+ (c.type === "thinking" && c.thinking.trim()),
92
+ );
93
+
94
+ if (this.hideThinkingBlock) {
95
+ const t = ensureTheme();
96
+ if (!t) continue;
97
+ this.contentContainer.addChild(
98
+ new Text(t.italic(t.fg("thinkingText", this.hiddenThinkingLabel)), 1, 0),
99
+ );
100
+ if (hasVisibleContentAfter) {
101
+ this.contentContainer.addChild(new Spacer(1));
102
+ }
103
+ } else {
104
+ let thinkingContent = content.thinking.trim();
105
+ if (!thinkingContent.startsWith(THINKING_LABEL)) {
106
+ thinkingContent = `${THINKING_LABEL}\n\n${thinkingContent}`;
107
+ }
108
+ const t = ensureTheme();
109
+ if (!t) continue;
110
+ const muted = ensureMuted();
111
+ this.contentContainer.addChild(
112
+ new Markdown(thinkingContent, 1, 0, muted ?? this.markdownTheme, {
113
+ color: (text: string) => t.fg("thinkingText", text),
114
+ italic: true,
115
+ }),
116
+ );
117
+ if (hasVisibleContentAfter) {
118
+ this.contentContainer.addChild(new Spacer(1));
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ // Aborted/error rendering.
125
+ const hasToolCalls = message.content.some((c: any) => c.type === "toolCall");
126
+ if (!hasToolCalls) {
127
+ if (message.stopReason === "aborted") {
128
+ const abortMessage =
129
+ message.errorMessage && message.errorMessage !== "Request was aborted"
130
+ ? message.errorMessage
131
+ : "Operation aborted";
132
+ this.contentContainer.addChild(new Spacer(1));
133
+ const t = ensureTheme();
134
+ if (t) this.contentContainer.addChild(new Text(t.fg("error", abortMessage), 1, 0));
135
+ } else if (message.stopReason === "error") {
136
+ const errorMsg = message.errorMessage || "Unknown error";
137
+ this.contentContainer.addChild(new Spacer(1));
138
+ const t = ensureTheme();
139
+ if (t) {
140
+ this.contentContainer.addChild(
141
+ new Text(t.fg("error", `Error: ${errorMsg}`), 1, 0),
142
+ );
143
+ }
144
+ }
145
+ }
146
+
147
+ // Bottom padding so next message has breathing room
148
+ this.contentContainer.addChild(new Spacer(1));
149
+ };
150
+ }
@@ -0,0 +1,132 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { highlightCode as piHighlightCode } from "@earendil-works/pi-coding-agent";
3
+ import type { MarkdownTheme } from "@earendil-works/pi-tui";
4
+ import {
5
+ deriveDimColor,
6
+ hslToRgb,
7
+ parseAnsiFgToRgb,
8
+ rgbToHsl,
9
+ rgbToTruecolorFg,
10
+ hexToRgb,
11
+ } from "../color.js";
12
+
13
+ export interface MutedThemeOptions {
14
+ saturationFactor?: number; // default 0.5
15
+ codeDefaultLightness?: number; // default 0.85 — lighter than the token dim floor
16
+ }
17
+
18
+ const DEFAULT_ANCHOR_L = 0.4;
19
+ const DEFAULT_CODE_DEFAULT_L = 0.85;
20
+
21
+ // Matches any foreground-color SGR escape: truecolor (38;2;r;g;b) or 256-palette (38;5;n).
22
+ // We intentionally only target fg color escapes; bg (48;...) and style escapes
23
+ // (bold/italic/reset/etc.) are left untouched.
24
+ const FG_COLOR_ESCAPE_RE = /\x1b\[38;(?:2;\d{1,3};\d{1,3};\d{1,3}|5;\d{1,3})m/g;
25
+
26
+ /**
27
+ * Rewrite every foreground-color SGR escape in `line` to its dimmed truecolor
28
+ * variant, preserving all other content (text, non-color escapes, resets).
29
+ *
30
+ * The cache memoizes raw→dim escape strings; callers should share a single
31
+ * cache across all lines in one theme-build invocation — highlightCode runs
32
+ * on every streaming chunk so the rewrite must be cheap.
33
+ */
34
+ export function dimAnsiLine(
35
+ line: string,
36
+ anchorL: number,
37
+ saturationFactor: number,
38
+ cache: Map<string, string>,
39
+ ): string {
40
+ return line.replace(FG_COLOR_ESCAPE_RE, (match) => {
41
+ const cached = cache.get(match);
42
+ if (cached !== undefined) return cached;
43
+ const rgb = parseAnsiFgToRgb(match);
44
+ if (!rgb) {
45
+ cache.set(match, match);
46
+ return match;
47
+ }
48
+ // deriveDimColor takes hex; we already have rgb, so build a hex string.
49
+ const hex =
50
+ "#" +
51
+ [rgb.r, rgb.g, rgb.b]
52
+ .map((v) => v.toString(16).padStart(2, "0"))
53
+ .join("");
54
+ const dimHex = deriveDimColor(hex, anchorL, saturationFactor);
55
+ const dimmed = rgbToTruecolorFg(hexToRgb(dimHex));
56
+ cache.set(match, dimmed);
57
+ return dimmed;
58
+ });
59
+ }
60
+
61
+ export function buildMutedMarkdownTheme(
62
+ piTheme: Theme,
63
+ opts: MutedThemeOptions = {},
64
+ ): MarkdownTheme {
65
+ const saturationFactor = opts.saturationFactor ?? 0.5;
66
+ const codeDefaultL = opts.codeDefaultLightness ?? DEFAULT_CODE_DEFAULT_L;
67
+
68
+ // Anchor lightness: derived from the theme's thinkingText foreground color.
69
+ const thinkingAnsi = piTheme.getFgAnsi("thinkingText");
70
+ const anchorRgb = parseAnsiFgToRgb(thinkingAnsi);
71
+ const anchorHsl = anchorRgb ? rgbToHsl(anchorRgb) : null;
72
+ const anchorL = anchorHsl ? anchorHsl.l : DEFAULT_ANCHOR_L;
73
+
74
+ // Default fg color used for UN-highlighted chars inside code blocks.
75
+ // cli-highlight only emits color escapes for recognized tokens — the gaps
76
+ // between them (operators, whitespace, unrecognized identifiers) render in
77
+ // the terminal's default color (often bright white on dark themes).
78
+ //
79
+ // We build a color that harmonizes with thinkingText (same hue/saturation)
80
+ // but sits at a BRIGHTER lightness than the token dim floor (anchorL),
81
+ // so white text is dimmed noticeably less than the colored tokens. Prepend
82
+ // this escape per line and re-emit it after every `\x1b[39m` fg reset so
83
+ // the gaps inherit this color instead of the terminal default.
84
+ const codeDefaultFg = anchorHsl
85
+ ? rgbToTruecolorFg(
86
+ hslToRgb({
87
+ h: anchorHsl.h,
88
+ s: anchorHsl.s,
89
+ l: codeDefaultL,
90
+ }),
91
+ )
92
+ : "";
93
+
94
+ // Shared memoization cache for the entire theme lifetime.
95
+ const dimCache = new Map<string, string>();
96
+
97
+ const fg = (token: string, text: string) =>
98
+ piTheme.fg(token as Parameters<Theme["fg"]>[0], text);
99
+
100
+ return {
101
+ codeBlockIndent: "",
102
+ heading: (text) => `\x1b[1m${rgbToTruecolorFg(hexToRgb("#FFD700"))}${text}\x1b[39m\x1b[22m`,
103
+ link: (text) => `\x1b[4m${fg("thinkingText", text)}\x1b[24m`,
104
+ linkUrl: (text) => fg("dim", text),
105
+ code: (text) => fg("dim", text),
106
+ codeBlock: (text) => fg("thinkingText", text),
107
+ codeBlockBorder: (text) => fg("dim", text),
108
+ quote: (text) => fg("thinkingText", text),
109
+ quoteBorder: (text) => fg("dim", text),
110
+ hr: (text) => fg("dim", text),
111
+ listBullet: (text) => fg("dim", text),
112
+ bold: (text) => `\x1b[1m${rgbToTruecolorFg(hexToRgb("#FFD700"))}${text}\x1b[39m\x1b[22m`,
113
+ italic: (text) => `\x1b[3m${text}\x1b[23m`,
114
+ strikethrough: (text) => `\x1b[9m${fg("dim", text)}\x1b[29m`,
115
+ underline: (text) => `\x1b[4m${fg("thinkingText", text)}\x1b[24m`,
116
+ highlightCode: (code, lang) => {
117
+ const lines = piHighlightCode(code, lang);
118
+ return lines.map((l) => {
119
+ const dimmed = dimAnsiLine(l, anchorL, saturationFactor, dimCache);
120
+ if (!codeDefaultFg) return dimmed;
121
+ // Blanket the line in the code-default color; after every fg reset
122
+ // (emitted by cli-highlight between colored tokens) re-open the
123
+ // default so un-tokenized chars inherit it.
124
+ const withDefault = dimmed.replace(
125
+ /\x1b\[39m/g,
126
+ `\x1b[39m${codeDefaultFg}`,
127
+ );
128
+ return `${codeDefaultFg}${withDefault}\x1b[39m`;
129
+ });
130
+ },
131
+ };
132
+ }
@@ -0,0 +1,21 @@
1
+ import { unindentCodeBlocks } from "./unindent.js";
2
+
3
+ /**
4
+ * message_end handler: mutate thinking content before it reaches the UI.
5
+ *
6
+ * Only does unindent of fenced code blocks. The "Thinking..." label is
7
+ * prepended inside updateContent (patch.ts) so it appears during streaming,
8
+ * not just after thinking completes.
9
+ */
10
+ export function transformThinkingContent(
11
+ message: { role: string; content: Array<{ type: string; thinking?: string }> },
12
+ ): void {
13
+ if (message.role !== "assistant") return;
14
+
15
+ for (const content of message.content) {
16
+ if (content.type === "thinking" && content.thinking?.trim()) {
17
+ const trimmed = content.thinking.trim();
18
+ content.thinking = unindentCodeBlocks(trimmed);
19
+ }
20
+ }
21
+ }