@pi-archimedes/core 2.7.3 → 2.9.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.
- package/README.md +6 -27
- package/package.json +8 -6
- package/src/bridge/channel.test.ts +52 -0
- package/src/bridge/channel.ts +322 -0
- package/src/bridge/events.ts +197 -0
- package/src/bridge/index.test.ts +722 -0
- package/src/bridge/index.ts +166 -0
- package/src/bridge/self-usage.test.ts +146 -0
- package/src/bridge/self-usage.ts +37 -0
- package/src/bus.ts +8 -1
- package/src/config.test.ts +8 -103
- package/src/config.ts +4 -66
- package/src/index.test.ts +19 -350
- package/src/index.ts +12 -269
- package/src/tool-render.ts +1 -1
- package/src/editor/index.test.ts +0 -820
- package/src/editor/index.ts +0 -379
- package/src/editor/spin-quips.test.ts +0 -106
- package/src/editor/spin-quips.ts +0 -44
- package/src/editor/spin.test.ts +0 -702
- package/src/editor/spin.ts +0 -471
- package/src/startup/capture.ts +0 -47
- package/src/startup/index.ts +0 -330
- package/src/startup/logo.test.ts +0 -260
- package/src/startup/logo.ts +0 -85
- package/src/startup/sections.test.ts +0 -314
- package/src/startup/sections.ts +0 -284
- package/src/startup/version.test.ts +0 -98
- package/src/startup/version.ts +0 -26
- package/src/thinking/patch.test.ts +0 -389
- package/src/thinking/patch.ts +0 -288
- package/src/thinking/theme.test.ts +0 -216
- package/src/thinking/theme.ts +0 -156
- package/src/thinking/transform.test.ts +0 -89
- package/src/thinking/transform.ts +0 -21
- package/src/thinking/unindent.test.ts +0 -74
- package/src/thinking/unindent.ts +0 -74
|
@@ -1,216 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import * as fc from "fast-check";
|
|
3
|
-
import { dimAnsiLine, buildMutedMarkdownTheme } from "./theme.js";
|
|
4
|
-
import { stripAnsi } from "../text.js";
|
|
5
|
-
|
|
6
|
-
// ── dimAnsiLine ──────────────────────────────────────────────────────────────
|
|
7
|
-
|
|
8
|
-
describe("dimAnsiLine", () => {
|
|
9
|
-
const makeCache = () => new Map<string, string>();
|
|
10
|
-
|
|
11
|
-
it("dims a single truecolor fg escape", () => {
|
|
12
|
-
const cache = makeCache();
|
|
13
|
-
const input = "\x1b[38;2;255;128;64mhello";
|
|
14
|
-
const result = dimAnsiLine(input, 0.4, 0.5, cache);
|
|
15
|
-
// Output should still be a truecolor fg escape (38;2;...)
|
|
16
|
-
expect(result).toMatch(/\x1b\[38;2;\d+;\d+;\d+mhello/);
|
|
17
|
-
// But the color should be different (dimmed)
|
|
18
|
-
expect(result).not.toBe(input);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it("dims multiple fg escapes in one line", () => {
|
|
22
|
-
const cache = makeCache();
|
|
23
|
-
const input = "\x1b[38;2;255;0;0mred\x1b[38;2;0;0;255mblue";
|
|
24
|
-
const result = dimAnsiLine(input, 0.4, 0.5, cache);
|
|
25
|
-
// Both escapes should be rewritten to dimmed versions
|
|
26
|
-
expect(result).not.toContain("\x1b[38;2;255;0;0m");
|
|
27
|
-
expect(result).not.toContain("\x1b[38;2;0;0;255m");
|
|
28
|
-
// Text content preserved
|
|
29
|
-
expect(stripAnsi(result)).toBe("redblue");
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it("leaves lines with no fg escapes unchanged", () => {
|
|
33
|
-
const cache = makeCache();
|
|
34
|
-
const input = "plain text without escapes";
|
|
35
|
-
const result = dimAnsiLine(input, 0.4, 0.5, cache);
|
|
36
|
-
expect(result).toBe(input);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("preserves non-fg escapes (bold, italic, reset)", () => {
|
|
40
|
-
const cache = makeCache();
|
|
41
|
-
const input = "\x1b[1mbold\x1b[0m \x1b[3mitalic\x1b[23m";
|
|
42
|
-
const result = dimAnsiLine(input, 0.4, 0.5, cache);
|
|
43
|
-
expect(result).toContain("\x1b[1m");
|
|
44
|
-
expect(result).toContain("\x1b[0m");
|
|
45
|
-
expect(result).toContain("\x1b[3m");
|
|
46
|
-
expect(result).toContain("\x1b[23m");
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it("cache hit returns same result", () => {
|
|
50
|
-
const cache = makeCache();
|
|
51
|
-
const escape = "\x1b[38;2;200;100;50m";
|
|
52
|
-
const input = `${escape}text`;
|
|
53
|
-
const first = dimAnsiLine(input, 0.4, 0.5, cache);
|
|
54
|
-
const second = dimAnsiLine(input, 0.4, 0.5, cache);
|
|
55
|
-
expect(first).toBe(second);
|
|
56
|
-
// Cache should have the entry
|
|
57
|
-
expect(cache.has(escape)).toBe(true);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("passes through unrecognized escapes unchanged", () => {
|
|
61
|
-
const cache = makeCache();
|
|
62
|
-
// A bg escape (48;2;...) should not be matched by FG_COLOR_ESCAPE_RE
|
|
63
|
-
const input = "\x1b[48;2;10;10;10mbg text";
|
|
64
|
-
const result = dimAnsiLine(input, 0.4, 0.5, cache);
|
|
65
|
-
expect(result).toBe(input);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("dims 256-palette fg escapes to truecolor", () => {
|
|
69
|
-
const cache = makeCache();
|
|
70
|
-
const input = "\x1b[38;5;196mred"; // 256-palette red
|
|
71
|
-
const result = dimAnsiLine(input, 0.4, 0.5, cache);
|
|
72
|
-
// Output should be truecolor (38;2;...), not 256-palette
|
|
73
|
-
expect(result).toMatch(/\x1b\[38;2;\d+;\d+;\d+mred/);
|
|
74
|
-
expect(result).not.toContain("\x1b[38;5;");
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it("handles empty string", () => {
|
|
78
|
-
const cache = makeCache();
|
|
79
|
-
expect(dimAnsiLine("", 0.4, 0.5, cache)).toBe("");
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("property: preserves non-ANSI text content", () => {
|
|
83
|
-
fc.assert(
|
|
84
|
-
fc.property(
|
|
85
|
-
fc.string({ maxLength: 200 }),
|
|
86
|
-
(s) => {
|
|
87
|
-
const cache = makeCache();
|
|
88
|
-
const result = dimAnsiLine(s, 0.4, 0.5, cache);
|
|
89
|
-
return stripAnsi(s) === stripAnsi(result);
|
|
90
|
-
},
|
|
91
|
-
),
|
|
92
|
-
);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it("property: never introduces 38;5; escapes (only produces truecolor 38;2;)", () => {
|
|
96
|
-
fc.assert(
|
|
97
|
-
fc.property(
|
|
98
|
-
fc.string({ maxLength: 200 }),
|
|
99
|
-
(s) => {
|
|
100
|
-
const cache = makeCache();
|
|
101
|
-
const result = dimAnsiLine(s, 0.4, 0.5, cache);
|
|
102
|
-
// Any 38;5; in output must have been in the input
|
|
103
|
-
// (dimAnsiLine only produces 38;2; output)
|
|
104
|
-
const inputHas256 = s.includes("38;5;");
|
|
105
|
-
const outputHas256 = result.includes("38;5;");
|
|
106
|
-
// If input didn't have 38;5;, output shouldn't either
|
|
107
|
-
// (because we only dim via truecolor)
|
|
108
|
-
if (!inputHas256) {
|
|
109
|
-
return !outputHas256;
|
|
110
|
-
}
|
|
111
|
-
// If input had 38;5;, they get replaced with 38;2;
|
|
112
|
-
// So output should NOT have 38;5; either
|
|
113
|
-
return !outputHas256;
|
|
114
|
-
},
|
|
115
|
-
),
|
|
116
|
-
);
|
|
117
|
-
});
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
// ── buildMutedMarkdownTheme ──────────────────────────────────────────────────
|
|
121
|
-
|
|
122
|
-
describe("buildMutedMarkdownTheme", () => {
|
|
123
|
-
const mockTheme = {
|
|
124
|
-
fg: (token: string, text: string) => `\x1b[38;2;180;180;180m${text}\x1b[0m`,
|
|
125
|
-
getFgAnsi: (token: string) => "\x1b[38;2;180;180;180m",
|
|
126
|
-
italic: (text: string) => `\x1b[3m${text}\x1b[23m`,
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
it("returns MarkdownTheme with all required fields", () => {
|
|
130
|
-
const theme = buildMutedMarkdownTheme(mockTheme as any);
|
|
131
|
-
expect(typeof theme.codeBlockIndent).toBe("string");
|
|
132
|
-
expect(typeof theme.heading).toBe("function");
|
|
133
|
-
expect(typeof theme.link).toBe("function");
|
|
134
|
-
expect(typeof theme.linkUrl).toBe("function");
|
|
135
|
-
expect(typeof theme.code).toBe("function");
|
|
136
|
-
expect(typeof theme.codeBlock).toBe("function");
|
|
137
|
-
expect(typeof theme.codeBlockBorder).toBe("function");
|
|
138
|
-
expect(typeof theme.quote).toBe("function");
|
|
139
|
-
expect(typeof theme.quoteBorder).toBe("function");
|
|
140
|
-
expect(typeof theme.hr).toBe("function");
|
|
141
|
-
expect(typeof theme.listBullet).toBe("function");
|
|
142
|
-
expect(typeof theme.bold).toBe("function");
|
|
143
|
-
expect(typeof theme.italic).toBe("function");
|
|
144
|
-
expect(typeof theme.strikethrough).toBe("function");
|
|
145
|
-
expect(typeof theme.underline).toBe("function");
|
|
146
|
-
expect(typeof theme.highlightCode).toBe("function");
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it("heading uses gold color (#FFD700)", () => {
|
|
150
|
-
const theme = buildMutedMarkdownTheme(mockTheme as any);
|
|
151
|
-
const result = theme.heading("Title");
|
|
152
|
-
// Gold is #FFD700 = rgb(255, 215, 0)
|
|
153
|
-
expect(result).toContain("\x1b[38;2;255;215;0m");
|
|
154
|
-
expect(result).toContain("Title");
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it("bold uses gold color (#FFD700)", () => {
|
|
158
|
-
const theme = buildMutedMarkdownTheme(mockTheme as any);
|
|
159
|
-
const result = theme.bold("Strong");
|
|
160
|
-
expect(result).toContain("\x1b[38;2;255;215;0m");
|
|
161
|
-
expect(result).toContain("Strong");
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it("codeBlock uses thinkingText", () => {
|
|
165
|
-
const theme = buildMutedMarkdownTheme(mockTheme as any);
|
|
166
|
-
const result = theme.codeBlock("code here");
|
|
167
|
-
expect(result).toContain("code here");
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it("italic wraps with ANSI italic codes", () => {
|
|
171
|
-
const theme = buildMutedMarkdownTheme(mockTheme as any);
|
|
172
|
-
const result = theme.italic("slanted");
|
|
173
|
-
expect(result).toContain("\x1b[3m");
|
|
174
|
-
expect(result).toContain("\x1b[23m");
|
|
175
|
-
expect(result).toContain("slanted");
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
it("codeBlockIndent is empty string", () => {
|
|
179
|
-
const theme = buildMutedMarkdownTheme(mockTheme as any);
|
|
180
|
-
expect(theme.codeBlockIndent).toBe("");
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it("highlightCode returns array of strings", () => {
|
|
184
|
-
const theme = buildMutedMarkdownTheme(mockTheme as any);
|
|
185
|
-
const result = theme.highlightCode!("const x = 1;", "javascript");
|
|
186
|
-
expect(Array.isArray(result)).toBe(true);
|
|
187
|
-
expect(result.length).toBeGreaterThan(0);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it("highlightCode dims ANSI escapes in output", () => {
|
|
191
|
-
const theme = buildMutedMarkdownTheme(mockTheme as any);
|
|
192
|
-
const result = theme.highlightCode!("const x = 1;", "javascript");
|
|
193
|
-
// Output should contain dimmed truecolor escapes
|
|
194
|
-
const joined = result.join("\n");
|
|
195
|
-
expect(joined).toMatch(/\x1b\[38;2;\d+;\d+;\d+m/);
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it("different saturationFactor produces different dimmed output", () => {
|
|
199
|
-
const cache = new Map<string, string>();
|
|
200
|
-
const input = "\x1b[38;2;255;128;64mhello";
|
|
201
|
-
const resultLow = dimAnsiLine(input, 0.4, 0.3, cache);
|
|
202
|
-
const cache2 = new Map<string, string>();
|
|
203
|
-
const resultHigh = dimAnsiLine(input, 0.4, 0.7, cache2);
|
|
204
|
-
// Same input with different saturation factors must produce different escapes
|
|
205
|
-
expect(resultLow).not.toBe(resultHigh);
|
|
206
|
-
// Both must still be valid truecolor fg escapes
|
|
207
|
-
expect(resultLow).toMatch(/\x1b\[38;2;\d+;\d+;\d+mhello/);
|
|
208
|
-
expect(resultHigh).toMatch(/\x1b\[38;2;\d+;\d+;\d+mhello/);
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
it("accepts custom codeDefaultLightness", () => {
|
|
212
|
-
const theme = buildMutedMarkdownTheme(mockTheme as any, { codeDefaultLightness: 0.9 });
|
|
213
|
-
const result = theme.highlightCode!("test", "plaintext");
|
|
214
|
-
expect(Array.isArray(result)).toBe(true);
|
|
215
|
-
});
|
|
216
|
-
});
|
package/src/thinking/theme.ts
DELETED
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
3
|
-
highlightCode as piHighlightCode,
|
|
4
|
-
initTheme as piInitTheme,
|
|
5
|
-
} from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import type { MarkdownTheme } from "@earendil-works/pi-tui";
|
|
7
|
-
import {
|
|
8
|
-
deriveDimColor,
|
|
9
|
-
hslToRgb,
|
|
10
|
-
parseAnsiFgToRgb,
|
|
11
|
-
rgbToHsl,
|
|
12
|
-
rgbToTruecolorFg,
|
|
13
|
-
hexToRgb,
|
|
14
|
-
} from "../color.js";
|
|
15
|
-
|
|
16
|
-
export interface MutedThemeOptions {
|
|
17
|
-
saturationFactor?: number; // default 0.5
|
|
18
|
-
codeDefaultLightness?: number; // default 0.85 — lighter than the token dim floor
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const DEFAULT_ANCHOR_L = 0.4;
|
|
22
|
-
const DEFAULT_CODE_DEFAULT_L = 0.85;
|
|
23
|
-
|
|
24
|
-
// Matches any foreground-color SGR escape: truecolor (38;2;r;g;b) or 256-palette (38;5;n).
|
|
25
|
-
// We intentionally only target fg color escapes; bg (48;...) and style escapes
|
|
26
|
-
// (bold/italic/reset/etc.) are left untouched.
|
|
27
|
-
const FG_COLOR_ESCAPE_RE = /\x1b\[38;(?:2;\d{1,3};\d{1,3};\d{1,3}|5;\d{1,3})m/g;
|
|
28
|
-
|
|
29
|
-
// pi 0.84.4's module-level `highlightCode` reads a *global* Theme
|
|
30
|
-
// singleton that throws until `initTheme()` runs. pi's interactive
|
|
31
|
-
// mode initializes it at startup, but contexts that build the muted
|
|
32
|
-
// theme without going through that initialization (e.g. unit tests)
|
|
33
|
-
// would otherwise get "Theme not initialized. Call initTheme() first."
|
|
34
|
-
// We probe on first use and, only when the global is not set, call
|
|
35
|
-
// `initTheme("dark")` — built-in theme, no file watcher attached.
|
|
36
|
-
let piThemeReady = false;
|
|
37
|
-
function ensurePiHighlightable(): void {
|
|
38
|
-
if (piThemeReady) return;
|
|
39
|
-
piThemeReady = true;
|
|
40
|
-
try {
|
|
41
|
-
piHighlightCode(""); // probe: throws when the global is unset
|
|
42
|
-
} catch (err) {
|
|
43
|
-
// Only fall back when the probe failed for the known reason, so an
|
|
44
|
-
// already-established pi theme is never clobbered by a surprise.
|
|
45
|
-
if (String(err).includes("Theme not initialized")) piInitTheme("dark");
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Rewrite every foreground-color SGR escape in `line` to its dimmed truecolor
|
|
51
|
-
* variant, preserving all other content (text, non-color escapes, resets).
|
|
52
|
-
*
|
|
53
|
-
* The cache memoizes raw→dim escape strings; callers should share a single
|
|
54
|
-
* cache across all lines in one theme-build invocation — highlightCode runs
|
|
55
|
-
* on every streaming chunk so the rewrite must be cheap.
|
|
56
|
-
*/
|
|
57
|
-
export function dimAnsiLine(
|
|
58
|
-
line: string,
|
|
59
|
-
anchorL: number,
|
|
60
|
-
saturationFactor: number,
|
|
61
|
-
cache: Map<string, string>,
|
|
62
|
-
): string {
|
|
63
|
-
return line.replace(FG_COLOR_ESCAPE_RE, (match) => {
|
|
64
|
-
const cached = cache.get(match);
|
|
65
|
-
if (cached !== undefined) return cached;
|
|
66
|
-
const rgb = parseAnsiFgToRgb(match);
|
|
67
|
-
if (!rgb) {
|
|
68
|
-
cache.set(match, match);
|
|
69
|
-
return match;
|
|
70
|
-
}
|
|
71
|
-
// deriveDimColor takes hex; we already have rgb, so build a hex string.
|
|
72
|
-
const hex =
|
|
73
|
-
"#" +
|
|
74
|
-
[rgb.r, rgb.g, rgb.b]
|
|
75
|
-
.map((v) => v.toString(16).padStart(2, "0"))
|
|
76
|
-
.join("");
|
|
77
|
-
const dimHex = deriveDimColor(hex, anchorL, saturationFactor);
|
|
78
|
-
const dimmed = rgbToTruecolorFg(hexToRgb(dimHex));
|
|
79
|
-
cache.set(match, dimmed);
|
|
80
|
-
return dimmed;
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export function buildMutedMarkdownTheme(
|
|
85
|
-
piTheme: Theme,
|
|
86
|
-
opts: MutedThemeOptions = {},
|
|
87
|
-
): MarkdownTheme {
|
|
88
|
-
const saturationFactor = opts.saturationFactor ?? 0.5;
|
|
89
|
-
const codeDefaultL = opts.codeDefaultLightness ?? DEFAULT_CODE_DEFAULT_L;
|
|
90
|
-
|
|
91
|
-
// Anchor lightness: derived from the theme's thinkingText foreground color.
|
|
92
|
-
const thinkingAnsi = piTheme.getFgAnsi("thinkingText");
|
|
93
|
-
const anchorRgb = parseAnsiFgToRgb(thinkingAnsi);
|
|
94
|
-
const anchorHsl = anchorRgb ? rgbToHsl(anchorRgb) : null;
|
|
95
|
-
const anchorL = anchorHsl ? anchorHsl.l : DEFAULT_ANCHOR_L;
|
|
96
|
-
|
|
97
|
-
// Default fg color used for UN-highlighted chars inside code blocks.
|
|
98
|
-
// cli-highlight only emits color escapes for recognized tokens — the gaps
|
|
99
|
-
// between them (operators, whitespace, unrecognized identifiers) render in
|
|
100
|
-
// the terminal's default color (often bright white on dark themes).
|
|
101
|
-
//
|
|
102
|
-
// We build a color that harmonizes with thinkingText (same hue/saturation)
|
|
103
|
-
// but sits at a BRIGHTER lightness than the token dim floor (anchorL),
|
|
104
|
-
// so white text is dimmed noticeably less than the colored tokens. Prepend
|
|
105
|
-
// this escape per line and re-emit it after every `\x1b[39m` fg reset so
|
|
106
|
-
// the gaps inherit this color instead of the terminal default.
|
|
107
|
-
const codeDefaultFg = anchorHsl
|
|
108
|
-
? rgbToTruecolorFg(
|
|
109
|
-
hslToRgb({
|
|
110
|
-
h: anchorHsl.h,
|
|
111
|
-
s: anchorHsl.s,
|
|
112
|
-
l: codeDefaultL,
|
|
113
|
-
}),
|
|
114
|
-
)
|
|
115
|
-
: "";
|
|
116
|
-
|
|
117
|
-
// Shared memoization cache for the entire theme lifetime.
|
|
118
|
-
const dimCache = new Map<string, string>();
|
|
119
|
-
|
|
120
|
-
const fg = (token: string, text: string) =>
|
|
121
|
-
piTheme.fg(token as Parameters<Theme["fg"]>[0], text);
|
|
122
|
-
|
|
123
|
-
return {
|
|
124
|
-
codeBlockIndent: "",
|
|
125
|
-
heading: (text) => `\x1b[1m${rgbToTruecolorFg(hexToRgb("#FFD700"))}${text}\x1b[39m\x1b[22m`,
|
|
126
|
-
link: (text) => `\x1b[4m${fg("thinkingText", text)}\x1b[24m`,
|
|
127
|
-
linkUrl: (text) => fg("dim", text),
|
|
128
|
-
code: (text) => fg("dim", text),
|
|
129
|
-
codeBlock: (text) => fg("thinkingText", text),
|
|
130
|
-
codeBlockBorder: (text) => fg("dim", text),
|
|
131
|
-
quote: (text) => fg("thinkingText", text),
|
|
132
|
-
quoteBorder: (text) => fg("dim", text),
|
|
133
|
-
hr: (text) => fg("dim", text),
|
|
134
|
-
listBullet: (text) => fg("dim", text),
|
|
135
|
-
bold: (text) => `\x1b[1m${rgbToTruecolorFg(hexToRgb("#FFD700"))}${text}\x1b[39m\x1b[22m`,
|
|
136
|
-
italic: (text) => `\x1b[3m${text}\x1b[23m`,
|
|
137
|
-
strikethrough: (text) => `\x1b[9m${fg("dim", text)}\x1b[29m`,
|
|
138
|
-
underline: (text) => `\x1b[4m${fg("thinkingText", text)}\x1b[24m`,
|
|
139
|
-
highlightCode: (code, lang) => {
|
|
140
|
-
ensurePiHighlightable();
|
|
141
|
-
const lines = piHighlightCode(code, lang);
|
|
142
|
-
return lines.map((l) => {
|
|
143
|
-
const dimmed = dimAnsiLine(l, anchorL, saturationFactor, dimCache);
|
|
144
|
-
if (!codeDefaultFg) return dimmed;
|
|
145
|
-
// Blanket the line in the code-default color; after every fg reset
|
|
146
|
-
// (emitted by cli-highlight between colored tokens) re-open the
|
|
147
|
-
// default so un-tokenized chars inherit it.
|
|
148
|
-
const withDefault = dimmed.replace(
|
|
149
|
-
/\x1b\[39m/g,
|
|
150
|
-
`\x1b[39m${codeDefaultFg}`,
|
|
151
|
-
);
|
|
152
|
-
return `${codeDefaultFg}${withDefault}\x1b[39m`;
|
|
153
|
-
});
|
|
154
|
-
},
|
|
155
|
-
};
|
|
156
|
-
}
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { transformThinkingContent } from "./transform.js";
|
|
3
|
-
|
|
4
|
-
describe("transformThinkingContent", () => {
|
|
5
|
-
it("modifies thinking content on assistant messages", () => {
|
|
6
|
-
const message = {
|
|
7
|
-
role: "assistant" as const,
|
|
8
|
-
content: [
|
|
9
|
-
{ type: "thinking" as const, thinking: " some thinking " },
|
|
10
|
-
],
|
|
11
|
-
};
|
|
12
|
-
transformThinkingContent(message);
|
|
13
|
-
expect(message.content[0]!.thinking).toBe("some thinking");
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it("skips non-assistant messages", () => {
|
|
17
|
-
const message = {
|
|
18
|
-
role: "user" as const,
|
|
19
|
-
content: [
|
|
20
|
-
{ type: "thinking" as const, thinking: " should not change " },
|
|
21
|
-
],
|
|
22
|
-
};
|
|
23
|
-
transformThinkingContent(message);
|
|
24
|
-
expect(message.content[0]!.thinking).toBe(" should not change ");
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("skips empty thinking", () => {
|
|
28
|
-
const message = {
|
|
29
|
-
role: "assistant" as const,
|
|
30
|
-
content: [
|
|
31
|
-
{ type: "thinking" as const, thinking: " " },
|
|
32
|
-
],
|
|
33
|
-
};
|
|
34
|
-
transformThinkingContent(message);
|
|
35
|
-
expect(message.content[0]!.thinking).toBe(" ");
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("skips thinking with undefined value", () => {
|
|
39
|
-
const message = {
|
|
40
|
-
role: "assistant" as const,
|
|
41
|
-
content: [
|
|
42
|
-
{ type: "thinking" as const } as { type: string; thinking?: string },
|
|
43
|
-
],
|
|
44
|
-
};
|
|
45
|
-
transformThinkingContent(message);
|
|
46
|
-
expect(message.content[0]!.thinking).toBeUndefined();
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it("calls unindentCodeBlocks on thinking content", () => {
|
|
50
|
-
const message = {
|
|
51
|
-
role: "assistant" as const,
|
|
52
|
-
content: [
|
|
53
|
-
{
|
|
54
|
-
type: "thinking" as const,
|
|
55
|
-
thinking: "```\n indented code\n more code\n```",
|
|
56
|
-
},
|
|
57
|
-
],
|
|
58
|
-
};
|
|
59
|
-
transformThinkingContent(message);
|
|
60
|
-
expect(message.content[0]!.thinking).toBe("```\nindented code\nmore code\n```");
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("handles mixed content types", () => {
|
|
64
|
-
const message = {
|
|
65
|
-
role: "assistant" as const,
|
|
66
|
-
content: [
|
|
67
|
-
{ type: "text" as const, text: "some text" },
|
|
68
|
-
{ type: "thinking" as const, thinking: " thinking content " },
|
|
69
|
-
{ type: "text" as const, text: "more text" },
|
|
70
|
-
],
|
|
71
|
-
};
|
|
72
|
-
transformThinkingContent(message);
|
|
73
|
-
expect(message.content[0]).toEqual({ type: "text", text: "some text" });
|
|
74
|
-
expect(message.content[1]!.thinking).toBe("thinking content");
|
|
75
|
-
expect(message.content[2]).toEqual({ type: "text", text: "more text" });
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it("after transform, thinking is trimmed", () => {
|
|
79
|
-
const message = {
|
|
80
|
-
role: "assistant" as const,
|
|
81
|
-
content: [
|
|
82
|
-
{ type: "thinking" as const, thinking: " hello world " },
|
|
83
|
-
],
|
|
84
|
-
};
|
|
85
|
-
transformThinkingContent(message);
|
|
86
|
-
const thinking = message.content[0]!.thinking;
|
|
87
|
-
expect(thinking).toBe(thinking?.trim());
|
|
88
|
-
});
|
|
89
|
-
});
|
|
@@ -1,21 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import * as fc from "fast-check";
|
|
3
|
-
import { unindentCodeBlocks } from "./unindent.js";
|
|
4
|
-
|
|
5
|
-
describe("unindentCodeBlocks", () => {
|
|
6
|
-
it("strips common leading whitespace from fenced code blocks", () => {
|
|
7
|
-
const input = "```\n line1\n line2\n```";
|
|
8
|
-
expect(unindentCodeBlocks(input)).toBe("```\nline1\nline2\n```");
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
it("preserves empty lines structure", () => {
|
|
12
|
-
const input = "```\n line1\n\n line2\n```";
|
|
13
|
-
expect(unindentCodeBlocks(input)).toBe("```\nline1\n\nline2\n```");
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it("leaves whitespace-only blocks untouched", () => {
|
|
17
|
-
const input = "```\n \n \n```";
|
|
18
|
-
expect(unindentCodeBlocks(input)).toBe("```\n \n \n```");
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it("handles CRLF → LF normalization", () => {
|
|
22
|
-
const input = "```\r\n line1\r\n line2\r\n```";
|
|
23
|
-
expect(unindentCodeBlocks(input)).toBe("```\nline1\nline2\n```");
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("handles blocks with 0 indent on some lines (no stripping)", () => {
|
|
27
|
-
const input = "```\n indented\nnotindented\n indented\n```";
|
|
28
|
-
expect(unindentCodeBlocks(input)).toBe("```\n indented\nnotindented\n indented\n```");
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("strips trailing empty lines from code blocks", () => {
|
|
32
|
-
const input = "```\n line1\n line2\n\n\n```";
|
|
33
|
-
expect(unindentCodeBlocks(input)).toBe("```\nline1\nline2\n```");
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it("handles language tags", () => {
|
|
37
|
-
const input = "```python\n def foo():\n pass\n```";
|
|
38
|
-
expect(unindentCodeBlocks(input)).toBe("```python\ndef foo():\n pass\n```");
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it("leaves text outside code blocks unchanged", () => {
|
|
42
|
-
const input = "Some text\n```\n code\n```\nMore text";
|
|
43
|
-
expect(unindentCodeBlocks(input)).toBe("Some text\n```\ncode\n```\nMore text");
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("handles multiple code blocks", () => {
|
|
47
|
-
const input = "```\n block1\n```\n```\n block2\n```";
|
|
48
|
-
expect(unindentCodeBlocks(input)).toBe("```\nblock1\n```\n```\nblock2\n```");
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("handles empty code blocks with content", () => {
|
|
52
|
-
const input = "```\n```";
|
|
53
|
-
expect(unindentCodeBlocks(input)).toBe("```\n```");
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("property: idempotence — unindentCodeBlocks(unindentCodeBlocks(x)) === unindentCodeBlocks(x)", () => {
|
|
57
|
-
fc.assert(
|
|
58
|
-
fc.property(fc.string({ maxLength: 500 }), (s) => {
|
|
59
|
-
const once = unindentCodeBlocks(s);
|
|
60
|
-
const twice = unindentCodeBlocks(once);
|
|
61
|
-
return once === twice;
|
|
62
|
-
}),
|
|
63
|
-
);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("property: output never contains CRLF (\\r\\n)", () => {
|
|
67
|
-
fc.assert(
|
|
68
|
-
fc.property(fc.string({ maxLength: 500 }), (s) => {
|
|
69
|
-
const result = unindentCodeBlocks(s);
|
|
70
|
-
return !result.includes("\r\n");
|
|
71
|
-
}),
|
|
72
|
-
);
|
|
73
|
-
});
|
|
74
|
-
});
|
package/src/thinking/unindent.ts
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Strip common leading whitespace and trailing blank lines from every fenced
|
|
3
|
-
* code block in `text`. Returns the transformed string.
|
|
4
|
-
*
|
|
5
|
-
* Algorithm per block:
|
|
6
|
-
* 0. Normalize `\r\n` → `\n` in the full input text before processing.
|
|
7
|
-
* 1. Find the minimum leading whitespace across all non-empty lines.
|
|
8
|
-
* 2. Strip that many characters from the start of every line
|
|
9
|
-
* (empty lines stay empty — they contribute no content but preserve structure).
|
|
10
|
-
* 3. Pop trailing empty lines.
|
|
11
|
-
*
|
|
12
|
-
* Trailing blank lines are ALWAYS stripped, even from 0-indent blocks.
|
|
13
|
-
* Leading whitespace is only stripped when minIndent > 0.
|
|
14
|
-
* Blocks containing only whitespace are left untouched.
|
|
15
|
-
*
|
|
16
|
-
* Known limitations:
|
|
17
|
-
* - Only space-based indentation is handled (tabs are not expanded).
|
|
18
|
-
* - If one line has 0 indent and others have N indent, nothing is stripped
|
|
19
|
-
* (minIndent === 0). This is standard textwrap.dedent behavior.
|
|
20
|
-
* - Fenced code blocks using 4+ backticks may be misparsed. The regex uses a
|
|
21
|
-
* negative lookahead to reject 4+ backtick openings (`/^(```(?!`))/
|
|
22
|
-
* - Line endings in the output are always `\n` (CRLF input is normalized).
|
|
23
|
-
*/
|
|
24
|
-
export function unindentCodeBlocks(text: string): string {
|
|
25
|
-
// Step 0: normalize CRLF → LF
|
|
26
|
-
text = text.replace(/\r\n/g, "\n");
|
|
27
|
-
|
|
28
|
-
// Regex matches fenced code blocks with 3 backticks (not 4+).
|
|
29
|
-
// Group 1: opening fence
|
|
30
|
-
// Group 2: language tag (may be empty)
|
|
31
|
-
// Group 3: block content (everything between fences)
|
|
32
|
-
// Group 4: closing fence
|
|
33
|
-
const regex = /^(```(?!`))([^\n]*)\n([\s\S]*?)^(```(?!`))[ \t]*$/gm;
|
|
34
|
-
|
|
35
|
-
return text.replace(regex, (match, opening, lang, content, closing) => {
|
|
36
|
-
const lines = content.split("\n");
|
|
37
|
-
|
|
38
|
-
// Check if all lines are empty or whitespace-only → leave untouched
|
|
39
|
-
const allWhitespace = lines.every((line: string) => line.trim() === "");
|
|
40
|
-
if (allWhitespace) {
|
|
41
|
-
return match;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Find minimum leading space count across non-empty lines
|
|
45
|
-
let minIndent = Infinity;
|
|
46
|
-
for (const line of lines) {
|
|
47
|
-
if (line.length === 0) continue; // skip empty lines
|
|
48
|
-
const m = line.match(/^( +)/);
|
|
49
|
-
if (m) {
|
|
50
|
-
minIndent = Math.min(minIndent, m[1].length);
|
|
51
|
-
} else {
|
|
52
|
-
// Line has no leading spaces → minIndent is 0
|
|
53
|
-
minIndent = 0;
|
|
54
|
-
break;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Strip common leading whitespace (only if minIndent > 0)
|
|
59
|
-
if (minIndent > 0 && minIndent !== Infinity) {
|
|
60
|
-
for (let i = 0; i < lines.length; i++) {
|
|
61
|
-
if (lines[i].length >= minIndent) {
|
|
62
|
-
lines[i] = lines[i].slice(minIndent);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Pop trailing empty lines
|
|
68
|
-
while (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
69
|
-
lines.pop();
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
return `${opening}${lang}\n${lines.join("\n")}\n${closing}`;
|
|
73
|
-
});
|
|
74
|
-
}
|