@quandev104/pi-style 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.
- package/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.md +193 -0
- package/dist/extensions/pi-style.js +7897 -0
- package/dist/extensions/pi-style.js.map +1 -0
- package/extension-src/pi-style/app/command-service.ts +244 -0
- package/extension-src/pi-style/app/commands.ts +12 -0
- package/extension-src/pi-style/app/config-storage.ts +98 -0
- package/extension-src/pi-style/app/doctor.ts +53 -0
- package/extension-src/pi-style/app/index.ts +322 -0
- package/extension-src/pi-style/app/providers.ts +268 -0
- package/extension-src/pi-style/app/render-scheduler.ts +48 -0
- package/extension-src/pi-style/app/runtime.ts +404 -0
- package/extension-src/pi-style/app/snapshot.ts +15 -0
- package/extension-src/pi-style/domain/capabilities.ts +34 -0
- package/extension-src/pi-style/domain/config-authorization.ts +20 -0
- package/extension-src/pi-style/domain/config-diagnostics.ts +20 -0
- package/extension-src/pi-style/domain/config-diff.ts +30 -0
- package/extension-src/pi-style/domain/config-migrations.ts +52 -0
- package/extension-src/pi-style/domain/config-normalization.ts +567 -0
- package/extension-src/pi-style/domain/config-presets.ts +51 -0
- package/extension-src/pi-style/domain/config-types.ts +115 -0
- package/extension-src/pi-style/domain/providers.ts +37 -0
- package/extension-src/pi-style/domain/status-presets.ts +60 -0
- package/extension-src/pi-style/domain/status-renderer.ts +142 -0
- package/extension-src/pi-style/domain/status.ts +430 -0
- package/extension-src/pi-style/domain/theme.ts +232 -0
- package/extension-src/pi-style/features/.gitkeep +0 -0
- package/extension-src/pi-style/features/editor/index.ts +304 -0
- package/extension-src/pi-style/features/messages/boxed-block.ts +74 -0
- package/extension-src/pi-style/features/messages/index.ts +145 -0
- package/extension-src/pi-style/features/messages/special-blocks.ts +281 -0
- package/extension-src/pi-style/features/startup/index.ts +564 -0
- package/extension-src/pi-style/features/startup/logo.ts +151 -0
- package/extension-src/pi-style/features/status-line/index.ts +319 -0
- package/extension-src/pi-style/features/tools/boxed/bash.ts +347 -0
- package/extension-src/pi-style/features/tools/boxed/edit.ts +113 -0
- package/extension-src/pi-style/features/tools/boxed/fallback.ts +67 -0
- package/extension-src/pi-style/features/tools/boxed/find.ts +65 -0
- package/extension-src/pi-style/features/tools/boxed/grep.ts +108 -0
- package/extension-src/pi-style/features/tools/boxed/index.ts +64 -0
- package/extension-src/pi-style/features/tools/boxed/ls.ts +65 -0
- package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +190 -0
- package/extension-src/pi-style/features/tools/boxed/read.ts +204 -0
- package/extension-src/pi-style/features/tools/boxed/session-config.ts +45 -0
- package/extension-src/pi-style/features/tools/boxed/shared.ts +157 -0
- package/extension-src/pi-style/features/tools/boxed/write.ts +89 -0
- package/extension-src/pi-style/features/tools/index.ts +480 -0
- package/extension-src/pi-style/pi/commands.ts +17 -0
- package/extension-src/pi-style/pi/compatibility-coordinator.ts +195 -0
- package/extension-src/pi-style/pi/compatibility-probe.ts +645 -0
- package/extension-src/pi-style/pi/compatibility-registry.ts +329 -0
- package/extension-src/pi-style/pi/config-host.ts +52 -0
- package/extension-src/pi-style/pi/config-session.ts +105 -0
- package/extension-src/pi-style/pi/index.ts +77 -0
- package/extension-src/pi-style/pi/operational-state.ts +29 -0
- package/extension-src/pi-style/pi/session-coordinator.ts +187 -0
- package/extension-src/pi-style/pi/session-usage.ts +62 -0
- package/extension-src/pi-style/pi/startup-resources.ts +70 -0
- package/extension-src/pi-style/shared/.gitkeep +0 -0
- package/extension-src/pi-style/shared/ansi.ts +386 -0
- package/extension-src/pi-style/shared/box.ts +805 -0
- package/extension-src/pi-style/shared/disposable-store.ts +28 -0
- package/extension-src/pi-style/shared/elapsed.ts +88 -0
- package/extension-src/pi-style/shared/render-budget.ts +367 -0
- package/extension-src/pi-style/shared/split-diff.ts +692 -0
- package/extension-src/pi-style/shared/theme-extras.ts +292 -0
- package/package.json +68 -0
- package/themes/.gitkeep +0 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { type EditorComponent, visibleWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import type { NormalizedPiStyleConfig } from "../../domain/config-types.js";
|
|
5
|
+
import { contextPercent, type StatusSnapshot, type ThinkingLevel } from "../../domain/status.js";
|
|
6
|
+
import type { ResolvedTheme } from "../../domain/theme.js";
|
|
7
|
+
import { resolveTheme } from "../../domain/theme.js";
|
|
8
|
+
import { stripAnsi, truncateAnsi } from "../../shared/ansi.js";
|
|
9
|
+
|
|
10
|
+
export const EDITOR_DIAGNOSTIC_KEY = "pi-style.editor";
|
|
11
|
+
type SetEditorComponent = NonNullable<ExtensionUIContext["setEditorComponent"]>;
|
|
12
|
+
type EditorFactory = NonNullable<Parameters<SetEditorComponent>[0]>;
|
|
13
|
+
type Tui = Parameters<EditorFactory>[0];
|
|
14
|
+
type PiEditorTheme = Parameters<EditorFactory>[1];
|
|
15
|
+
type Keybindings = Parameters<EditorFactory>[2];
|
|
16
|
+
type EditorHost = Pick<ExtensionUIContext, "setEditorComponent"> & {
|
|
17
|
+
getEditorComponent?: () => EditorFactory | undefined;
|
|
18
|
+
notify?: (message: string, type?: "info" | "warning" | "error") => void;
|
|
19
|
+
/** Full Pi theme; provides thinking-level border colors when available. */
|
|
20
|
+
readonly theme?: { getThinkingBorderColor?: (level: ThinkingLevel) => (str: string) => string };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export interface EditorInstallation {
|
|
24
|
+
readonly generation: number;
|
|
25
|
+
readonly installedFactory: EditorFactory;
|
|
26
|
+
readonly previousFactory: EditorFactory | undefined;
|
|
27
|
+
readonly preservedPrevious: boolean;
|
|
28
|
+
update(snapshot: StatusSnapshot): void;
|
|
29
|
+
configure(config: NormalizedPiStyleConfig): void;
|
|
30
|
+
dispose(): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface EditorOptions {
|
|
34
|
+
config: NormalizedPiStyleConfig;
|
|
35
|
+
snapshot: StatusSnapshot;
|
|
36
|
+
theme: PiEditorTheme;
|
|
37
|
+
/** Full Pi theme for thinking-level border colors (optional; falls back to borderColor). */
|
|
38
|
+
fullTheme?: { getThinkingBorderColor?: (level: ThinkingLevel) => (str: string) => string };
|
|
39
|
+
onSnapshot: (snapshot: StatusSnapshot) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const widthOf = visibleWidth;
|
|
43
|
+
|
|
44
|
+
function widthSafe(value: string, width: number): string {
|
|
45
|
+
if (width <= 0) return "";
|
|
46
|
+
const fitted = widthOf(value) > width ? truncateAnsi(value, width, "") : value;
|
|
47
|
+
const current = widthOf(fitted);
|
|
48
|
+
return current < width ? fitted + " ".repeat(width - current) : fitted;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isNativeBorderLine(line: string): boolean {
|
|
52
|
+
const stripped = stripAnsi(line);
|
|
53
|
+
return /^─{2,}$/.test(stripped) || /^─── [↑↓] \d+ more /.test(stripped);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function semanticTheme(theme: PiEditorTheme, config: NormalizedPiStyleConfig): ResolvedTheme {
|
|
57
|
+
const editorTheme = theme;
|
|
58
|
+
return resolveTheme(
|
|
59
|
+
{
|
|
60
|
+
fg: (token) => {
|
|
61
|
+
if (token === "borderActive" || token.startsWith("thinking")) return editorTheme.borderColor("");
|
|
62
|
+
return "";
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
config,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A thin CustomEditor treatment: all text, cursor, paste, autocomplete, history,
|
|
71
|
+
* and keybinding state remains owned by Pi's editor implementation.
|
|
72
|
+
*/
|
|
73
|
+
export class StyledEditor extends CustomEditor implements EditorComponent {
|
|
74
|
+
private config: NormalizedPiStyleConfig;
|
|
75
|
+
private snapshot: StatusSnapshot;
|
|
76
|
+
private readonly piTheme: PiEditorTheme;
|
|
77
|
+
private readonly fullTheme: EditorOptions["fullTheme"];
|
|
78
|
+
private readonly onSnapshot: (snapshot: StatusSnapshot) => void;
|
|
79
|
+
private semantic: ResolvedTheme;
|
|
80
|
+
private disposed = false;
|
|
81
|
+
|
|
82
|
+
constructor(tui: Tui, theme: PiEditorTheme, keybindings: Keybindings, options: EditorOptions) {
|
|
83
|
+
super(tui, theme, keybindings);
|
|
84
|
+
this.config = options.config;
|
|
85
|
+
this.snapshot = options.snapshot;
|
|
86
|
+
this.piTheme = theme;
|
|
87
|
+
this.fullTheme = options.fullTheme;
|
|
88
|
+
this.onSnapshot = options.onSnapshot;
|
|
89
|
+
this.semantic = semanticTheme(theme, options.config);
|
|
90
|
+
this.setPaddingX(0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
update(snapshot: StatusSnapshot): void {
|
|
94
|
+
if (this.disposed) return;
|
|
95
|
+
this.snapshot = snapshot;
|
|
96
|
+
this.invalidate();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
configure(config: NormalizedPiStyleConfig): void {
|
|
100
|
+
if (this.disposed) return;
|
|
101
|
+
this.config = config;
|
|
102
|
+
this.semantic = semanticTheme(this.piTheme, config);
|
|
103
|
+
this.invalidate();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
override handleInput(data: string): void {
|
|
107
|
+
if (this.disposed) return;
|
|
108
|
+
super.handleInput(data);
|
|
109
|
+
this.onSnapshot({ ...this.snapshot });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
override invalidate(): void {
|
|
113
|
+
super.invalidate();
|
|
114
|
+
this.semantic = semanticTheme(this.piTheme, this.config);
|
|
115
|
+
this.tui.requestRender();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
override render(width: number): string[] {
|
|
119
|
+
if (width <= 0) return [];
|
|
120
|
+
const nativeLines = super.render(width);
|
|
121
|
+
// Autocomplete (slash menu / @-mentions) restructure: Pi draws the
|
|
122
|
+
// suggestions after its own bottom border, which pushes the below-editor
|
|
123
|
+
// widgets (status line) down. Re-frame the native output so the dropdown
|
|
124
|
+
// lives INSIDE the input box, keeping the footer directly below the input.
|
|
125
|
+
// Native layout: [top border, text lines, bottom border, dropdown lines...].
|
|
126
|
+
if ((this as unknown as { autocompleteState?: unknown }).autocompleteState) {
|
|
127
|
+
const prompt = this.prompt();
|
|
128
|
+
const padding = this.paddingFor(width, this.styleFor(width));
|
|
129
|
+
const promptWidth = widthOf(prompt) + 1;
|
|
130
|
+
const prefix = `${" ".repeat(padding)}${prompt} `;
|
|
131
|
+
const continuation = " ".repeat(padding + promptWidth);
|
|
132
|
+
const borderIndex = nativeLines.slice(1).findIndex((line) => isNativeBorderLine(line));
|
|
133
|
+
const split = borderIndex >= 0 ? borderIndex + 1 : nativeLines.length;
|
|
134
|
+
const body = nativeLines.slice(1, split);
|
|
135
|
+
const dropdown = nativeLines.slice(split);
|
|
136
|
+
const border = this.borderFor(width);
|
|
137
|
+
const renderedBody = body.map((line, index) => widthSafe(`${index === 0 ? prefix : continuation}${line}`, width));
|
|
138
|
+
return [
|
|
139
|
+
border("─".repeat(width)),
|
|
140
|
+
...renderedBody,
|
|
141
|
+
...dropdown.map((line) => widthSafe(line, width)),
|
|
142
|
+
border("─".repeat(width)),
|
|
143
|
+
];
|
|
144
|
+
}
|
|
145
|
+
const style = this.styleFor(width);
|
|
146
|
+
if (style === "native") return nativeLines.map((line) => widthSafe(line, width));
|
|
147
|
+
|
|
148
|
+
const prompt = this.prompt();
|
|
149
|
+
const promptWidth = widthOf(prompt) + 1;
|
|
150
|
+
const padding = this.paddingFor(width, style);
|
|
151
|
+
const innerWidth = Math.max(1, width - promptWidth - padding * 2);
|
|
152
|
+
const innerLines = super.render(innerWidth);
|
|
153
|
+
if (innerLines.length === 0) return [];
|
|
154
|
+
|
|
155
|
+
const body = innerLines.slice(1, -1);
|
|
156
|
+
const prefix = `${" ".repeat(padding)}${prompt} `;
|
|
157
|
+
const continuation = " ".repeat(padding + promptWidth);
|
|
158
|
+
const renderedBody = body.map((line, index) => {
|
|
159
|
+
const lead = index === 0 ? prefix : continuation;
|
|
160
|
+
return widthSafe(`${lead}${line}`, width);
|
|
161
|
+
});
|
|
162
|
+
const metadata = this.metadata(width, style);
|
|
163
|
+
const framed = this.frame(width, style, renderedBody, metadata);
|
|
164
|
+
return framed.map((line) => widthSafe(line, width));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
dispose(): void {
|
|
168
|
+
if (this.disposed) return;
|
|
169
|
+
this.disposed = true;
|
|
170
|
+
this.invalidate();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private prompt(): string {
|
|
174
|
+
const configured = this.config.theme.glyphs.prompt;
|
|
175
|
+
if (configured) return configured;
|
|
176
|
+
return this.semantic.mode === "ascii" ? ">" : "❯";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private styleFor(width: number): "compact" | "boxed" | "dock" | "native" {
|
|
180
|
+
if (width < 20) return "native";
|
|
181
|
+
if (["compact", "boxed", "dock", "native"].includes(this.config.editor.style)) {
|
|
182
|
+
if (this.config.editor.style === "native") return "native";
|
|
183
|
+
if (width < 40 && this.config.editor.style !== "compact") return "compact";
|
|
184
|
+
return this.config.editor.style as "compact" | "boxed" | "dock";
|
|
185
|
+
}
|
|
186
|
+
return "compact";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private paddingFor(width: number, style: "compact" | "boxed" | "dock" | "native"): number {
|
|
190
|
+
if (width < 50) return 0;
|
|
191
|
+
return style === "boxed" ? 2 : style === "dock" ? 1 : 1;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private borderFor(width: number): (line: string) => string {
|
|
195
|
+
// Sync the frame color with the active thinking level, matching Pi's native editor.
|
|
196
|
+
const level = this.snapshot.thinkingLevel;
|
|
197
|
+
const thinking = this.fullTheme?.getThinkingBorderColor?.(level ?? "off");
|
|
198
|
+
const border = thinking ?? this.piTheme.borderColor;
|
|
199
|
+
return (line: string) => border(widthSafe(line, width));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private frame(
|
|
203
|
+
width: number,
|
|
204
|
+
style: "compact" | "boxed" | "dock" | "native",
|
|
205
|
+
body: string[],
|
|
206
|
+
metadata: string[],
|
|
207
|
+
): string[] {
|
|
208
|
+
const border = this.borderFor(width);
|
|
209
|
+
const lineMode = this.config.editor.frame === "line" || this.config.editor.frame === "solid";
|
|
210
|
+
if (style === "compact" || lineMode) {
|
|
211
|
+
// Match Pi's native editor: a horizontal border above and below the input.
|
|
212
|
+
return [border("─".repeat(width)), ...body, border("─".repeat(width)), ...metadata];
|
|
213
|
+
}
|
|
214
|
+
if (style === "boxed") {
|
|
215
|
+
const glyph = this.config.editor.frame === "halfblock" ? "▀" : "━";
|
|
216
|
+
return [border(glyph.repeat(width)), ...body, border(glyph.repeat(width)), ...metadata];
|
|
217
|
+
}
|
|
218
|
+
if (this.config.editor.frame === "native") return body;
|
|
219
|
+
return [
|
|
220
|
+
border(`┌${"─".repeat(Math.max(0, width - 2))}┐`),
|
|
221
|
+
...body,
|
|
222
|
+
border(`└${"─".repeat(Math.max(0, width - 2))}┘`),
|
|
223
|
+
...metadata,
|
|
224
|
+
];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private metadata(width: number, style: "compact" | "boxed" | "dock" | "native"): string[] {
|
|
228
|
+
if (!this.config.editor.showMetadata || width < 60) return [];
|
|
229
|
+
const percent = contextPercent(this.snapshot.context ?? {});
|
|
230
|
+
if (percent === undefined) return [];
|
|
231
|
+
const label = `ctx ${Math.round(percent)}%`;
|
|
232
|
+
return [this.piTheme.borderColor(` ${style === "boxed" ? "· " : ""}${label}`)];
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function installEditor(options: {
|
|
237
|
+
host: EditorHost;
|
|
238
|
+
config: NormalizedPiStyleConfig;
|
|
239
|
+
generation: number;
|
|
240
|
+
initialSnapshot: StatusSnapshot;
|
|
241
|
+
isCurrent?: () => boolean;
|
|
242
|
+
}): EditorInstallation | undefined {
|
|
243
|
+
if (!options.host.setEditorComponent) return undefined;
|
|
244
|
+
const previous = options.host.getEditorComponent?.();
|
|
245
|
+
if (previous && options.config.compatibility.preferExistingEditor) {
|
|
246
|
+
return {
|
|
247
|
+
generation: options.generation,
|
|
248
|
+
installedFactory: previous,
|
|
249
|
+
previousFactory: previous,
|
|
250
|
+
preservedPrevious: true,
|
|
251
|
+
update() {},
|
|
252
|
+
configure() {},
|
|
253
|
+
dispose() {},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
let config = options.config;
|
|
257
|
+
let snapshot = options.initialSnapshot;
|
|
258
|
+
let disposed = false;
|
|
259
|
+
const components = new Set<StyledEditor>();
|
|
260
|
+
const factory = ((tui: Tui, theme: PiEditorTheme, keybindings: Keybindings) => {
|
|
261
|
+
const editor = new StyledEditor(tui, theme, keybindings, {
|
|
262
|
+
config,
|
|
263
|
+
snapshot,
|
|
264
|
+
theme,
|
|
265
|
+
...(options.host.theme ? { fullTheme: options.host.theme } : {}),
|
|
266
|
+
onSnapshot: (next) => {
|
|
267
|
+
if (!disposed && options.isCurrent?.() !== false) snapshot = next;
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
components.add(editor);
|
|
271
|
+
return editor;
|
|
272
|
+
}) as EditorFactory;
|
|
273
|
+
try {
|
|
274
|
+
options.host.setEditorComponent(factory);
|
|
275
|
+
} catch {
|
|
276
|
+
options.host.notify?.("pi-style editor unavailable; keeping the native editor", "warning");
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
generation: options.generation,
|
|
281
|
+
installedFactory: factory,
|
|
282
|
+
previousFactory: previous,
|
|
283
|
+
preservedPrevious: false,
|
|
284
|
+
update(next) {
|
|
285
|
+
if (disposed || options.isCurrent?.() === false) return;
|
|
286
|
+
snapshot = next;
|
|
287
|
+
for (const component of components) component.update(next);
|
|
288
|
+
},
|
|
289
|
+
configure(next) {
|
|
290
|
+
if (disposed || options.isCurrent?.() === false) return;
|
|
291
|
+
config = next;
|
|
292
|
+
for (const component of components) component.configure(next);
|
|
293
|
+
},
|
|
294
|
+
dispose() {
|
|
295
|
+
if (disposed) return;
|
|
296
|
+
disposed = true;
|
|
297
|
+
for (const component of components) component.dispose();
|
|
298
|
+
components.clear();
|
|
299
|
+
if (options.host.getEditorComponent?.() === factory) {
|
|
300
|
+
options.host.setEditorComponent(previous);
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Boxed message-block shell (skill/compaction/branch/custom/MCP blocks).
|
|
2
|
+
// Returns only border/content lines with foreground styling; the parent Box
|
|
3
|
+
// applies the customMessageBg background (the native components already set
|
|
4
|
+
// that bgFn).
|
|
5
|
+
|
|
6
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
7
|
+
import type { BoxTheme } from "../../shared/box.js";
|
|
8
|
+
import { boxBorder, boxInnerWidth, boxInsetDivider, boxLine, boxLineWithRight, boxWidth } from "../../shared/box.js";
|
|
9
|
+
|
|
10
|
+
export type MessageBlockOptions = {
|
|
11
|
+
kind: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
right?: string;
|
|
14
|
+
body: (contentWidth: number) => string[];
|
|
15
|
+
hasDivider?: boolean | "auto";
|
|
16
|
+
icon?: string;
|
|
17
|
+
cache?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function formatMessageBlockTitle(theme: BoxTheme, kind: string, title?: string, icon = "➔"): string {
|
|
21
|
+
const rawTitle = title ? `${icon} ${kind} | ${title}` : `${icon} ${kind}`;
|
|
22
|
+
const coloredTitle = theme.fg("accent", rawTitle);
|
|
23
|
+
return typeof theme?.bold === "function" ? theme.bold(coloredTitle) : coloredTitle;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Render a boxed message block.
|
|
28
|
+
*
|
|
29
|
+
* Returns only border/content lines with foreground styling. Background is
|
|
30
|
+
* applied by the parent Box (all patched components extend Box with a
|
|
31
|
+
* customMessageBg bgFn), so this helper must NOT apply background itself —
|
|
32
|
+
* that would create a double-background conflict.
|
|
33
|
+
*/
|
|
34
|
+
export function renderBoxedMessageBlock(theme: BoxTheme, options: MessageBlockOptions): Component {
|
|
35
|
+
const { kind, title, right, body, icon = "➔", hasDivider = true, cache: shouldCache = true } = options;
|
|
36
|
+
let cache: { width: number; lines: string[] } | null = null;
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
invalidate() {
|
|
40
|
+
cache = null;
|
|
41
|
+
},
|
|
42
|
+
render(width: number): string[] {
|
|
43
|
+
if (shouldCache && cache?.width === width) return cache.lines;
|
|
44
|
+
|
|
45
|
+
const renderedWidth = boxWidth(width);
|
|
46
|
+
const contentWidth = boxInnerWidth(renderedWidth);
|
|
47
|
+
const titleLine = formatMessageBlockTitle(theme, kind, title, icon);
|
|
48
|
+
|
|
49
|
+
const lines: string[] = [];
|
|
50
|
+
lines.push(boxBorder(theme, "┌", "┐", renderedWidth));
|
|
51
|
+
|
|
52
|
+
if (right) {
|
|
53
|
+
const rightStyled = theme.fg("dim", right);
|
|
54
|
+
lines.push(boxLineWithRight(theme, titleLine, rightStyled, renderedWidth));
|
|
55
|
+
} else {
|
|
56
|
+
lines.push(boxLine(theme, titleLine, renderedWidth));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const bodyLines = body(contentWidth);
|
|
60
|
+
const showDivider = hasDivider === "auto" ? bodyLines.length > 0 : hasDivider;
|
|
61
|
+
if (showDivider) {
|
|
62
|
+
lines.push(boxInsetDivider(theme, renderedWidth));
|
|
63
|
+
}
|
|
64
|
+
for (const line of bodyLines) {
|
|
65
|
+
lines.push(boxLine(theme, line, renderedWidth));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
lines.push(boxBorder(theme, "└", "┘", renderedWidth));
|
|
69
|
+
|
|
70
|
+
if (shouldCache) cache = { width, lines };
|
|
71
|
+
return lines;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
const OSC133_ZONE_START = "\x1b]133;A\x07";
|
|
4
|
+
const OSC133_ZONE_END = "\x1b]133;B\x07";
|
|
5
|
+
const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
|
|
6
|
+
type OscParts = { start: string; body: string; end: string };
|
|
7
|
+
|
|
8
|
+
function extractOscEnvelope(line: string): OscParts | undefined {
|
|
9
|
+
if (!line.startsWith(OSC133_ZONE_START)) return undefined;
|
|
10
|
+
const bodyEnd = line.indexOf(OSC133_ZONE_END, OSC133_ZONE_START.length);
|
|
11
|
+
if (bodyEnd < 0 || !line.endsWith(OSC133_ZONE_FINAL)) return undefined;
|
|
12
|
+
return { start: OSC133_ZONE_START, body: line.slice(OSC133_ZONE_START.length, bodyEnd), end: line.slice(bodyEnd) };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function prefixAtFirstContent(line: string, prefix: string): string {
|
|
16
|
+
let index = 0;
|
|
17
|
+
while (index < line.length) {
|
|
18
|
+
if (line[index] === "\x1b" && line[index + 1] === "[") {
|
|
19
|
+
index += 2;
|
|
20
|
+
while (index < line.length && (line.charCodeAt(index) < 64 || line.charCodeAt(index) > 126)) index++;
|
|
21
|
+
if (index < line.length) index++;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (/\s/u.test(line[index] ?? "")) {
|
|
25
|
+
index++;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
return `${line.slice(0, index)}${prefix}${line.slice(index)}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function decorateMessageLine(
|
|
34
|
+
line: string,
|
|
35
|
+
index: number,
|
|
36
|
+
lastIndex: number,
|
|
37
|
+
contentIndex: number,
|
|
38
|
+
options: {
|
|
39
|
+
firstEnvelope: OscParts | undefined;
|
|
40
|
+
firstHasStart: boolean;
|
|
41
|
+
multilineEnvelope: boolean;
|
|
42
|
+
prefix: string;
|
|
43
|
+
},
|
|
44
|
+
): string {
|
|
45
|
+
const { firstEnvelope, firstHasStart, multilineEnvelope, prefix } = options;
|
|
46
|
+
const prefixWidth = visibleWidth(prefix);
|
|
47
|
+
if (index === contentIndex && firstEnvelope)
|
|
48
|
+
return `${firstEnvelope.start}${prefixAtFirstContent(firstEnvelope.body, prefix)}${firstEnvelope.end}`;
|
|
49
|
+
if (index === contentIndex && firstHasStart)
|
|
50
|
+
return `${OSC133_ZONE_START}${prefix}${line.slice(OSC133_ZONE_START.length)}`;
|
|
51
|
+
if (index === lastIndex && multilineEnvelope && index !== contentIndex)
|
|
52
|
+
return `${OSC133_ZONE_END}${OSC133_ZONE_FINAL}${line.slice((OSC133_ZONE_END + OSC133_ZONE_FINAL).length)}`;
|
|
53
|
+
if (index === contentIndex) return `${prefix}${line}`;
|
|
54
|
+
return index > contentIndex ? `${" ".repeat(prefixWidth)}${line}` : line;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function contentText(line: string): string {
|
|
58
|
+
let output = "";
|
|
59
|
+
for (let index = 0; index < line.length; index++) {
|
|
60
|
+
if (line.charCodeAt(index) !== 27) {
|
|
61
|
+
output += line[index];
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const next = line[index + 1];
|
|
65
|
+
if (next === "]") {
|
|
66
|
+
index += 2;
|
|
67
|
+
while (index < line.length && line.charCodeAt(index) !== 7) index++;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (next === "[") {
|
|
71
|
+
index += 2;
|
|
72
|
+
while (index < line.length && (line.charCodeAt(index) < 64 || line.charCodeAt(index) > 126)) index++;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return output.replaceAll(OSC133_ZONE_START, "").replaceAll(OSC133_ZONE_END, "").replaceAll(OSC133_ZONE_FINAL, "");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function hasContent(line: string): boolean {
|
|
79
|
+
return [...contentText(line)].some((character) => !/\s/u.test(character));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function prefixNative(lines: unknown, width: number, prefix: string): string[] | undefined {
|
|
83
|
+
if (!Array.isArray(lines) || lines.length === 0 || !lines.every((line) => typeof line === "string")) return undefined;
|
|
84
|
+
const nativeLines = lines as string[];
|
|
85
|
+
const prefixWidth = visibleWidth(prefix);
|
|
86
|
+
if (width <= prefixWidth) return undefined;
|
|
87
|
+
const bodyWidth = width - prefixWidth;
|
|
88
|
+
const first = nativeLines[0] ?? "";
|
|
89
|
+
const last = nativeLines.at(-1) ?? "";
|
|
90
|
+
const multilineEnvelope = nativeLines.length > 1 && last.startsWith(OSC133_ZONE_END + OSC133_ZONE_FINAL);
|
|
91
|
+
const firstContentIndex = nativeLines.findIndex((line, index) =>
|
|
92
|
+
index !== nativeLines.length - 1 || !multilineEnvelope ? hasContent(line) : false,
|
|
93
|
+
);
|
|
94
|
+
if (firstContentIndex < 0) return nativeLines;
|
|
95
|
+
const firstEnvelope = firstContentIndex === 0 ? extractOscEnvelope(first) : undefined;
|
|
96
|
+
const firstHasStart = firstContentIndex === 0 && first.startsWith(OSC133_ZONE_START);
|
|
97
|
+
const decorated = nativeLines.map((line, index) => {
|
|
98
|
+
if (index === firstContentIndex)
|
|
99
|
+
return decorateMessageLine(line, index, nativeLines.length - 1, firstContentIndex, {
|
|
100
|
+
firstEnvelope,
|
|
101
|
+
firstHasStart,
|
|
102
|
+
multilineEnvelope,
|
|
103
|
+
prefix,
|
|
104
|
+
});
|
|
105
|
+
if (index > firstContentIndex && hasContent(line)) return `${" ".repeat(prefixWidth)}${line}`;
|
|
106
|
+
return line;
|
|
107
|
+
});
|
|
108
|
+
if (!decorated.every((line) => visibleWidth(line) <= width)) return undefined;
|
|
109
|
+
if (!nativeLines.every((line) => visibleWidth(line) <= bodyWidth)) return undefined;
|
|
110
|
+
return decorated;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type MessageDecorationSnapshot = Readonly<{
|
|
114
|
+
userPrefix: string;
|
|
115
|
+
assistantPrefix: string;
|
|
116
|
+
userEnabled: boolean;
|
|
117
|
+
assistantEnabled: boolean;
|
|
118
|
+
}>;
|
|
119
|
+
|
|
120
|
+
export function decorateMessageRender(
|
|
121
|
+
subtype: "native-user-message" | "native-assistant-message",
|
|
122
|
+
original: unknown,
|
|
123
|
+
instance: object,
|
|
124
|
+
args: unknown[],
|
|
125
|
+
snapshot: MessageDecorationSnapshot = {
|
|
126
|
+
userPrefix: "❯ ",
|
|
127
|
+
assistantPrefix: "│ ",
|
|
128
|
+
userEnabled: true,
|
|
129
|
+
assistantEnabled: true,
|
|
130
|
+
},
|
|
131
|
+
): unknown {
|
|
132
|
+
if (typeof original !== "function") return undefined;
|
|
133
|
+
const width = typeof args[0] === "number" ? args[0] : 0;
|
|
134
|
+
const enabled = subtype === "native-user-message" ? snapshot.userEnabled : snapshot.assistantEnabled;
|
|
135
|
+
const prefix = subtype === "native-user-message" ? snapshot.userPrefix : snapshot.assistantPrefix;
|
|
136
|
+
if (!enabled) return Reflect.apply(original, instance, args);
|
|
137
|
+
if (width <= visibleWidth(prefix)) return Reflect.apply(original, instance, args);
|
|
138
|
+
// Exactly one native invocation. If the reduced render cannot be certified, the
|
|
139
|
+
// already-obtained result is the only safe fallback; retrying can mutate state.
|
|
140
|
+
const reducedWidth = width - visibleWidth(prefix);
|
|
141
|
+
const native = Reflect.apply(original, instance, [reducedWidth, ...args.slice(1)]);
|
|
142
|
+
return prefixNative(native, width, prefix) ?? native;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Special private layouts are intentionally not installed; their prototypes remain native.
|