@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/LICENSE +21 -0
  3. package/README.md +193 -0
  4. package/dist/extensions/pi-style.js +7897 -0
  5. package/dist/extensions/pi-style.js.map +1 -0
  6. package/extension-src/pi-style/app/command-service.ts +244 -0
  7. package/extension-src/pi-style/app/commands.ts +12 -0
  8. package/extension-src/pi-style/app/config-storage.ts +98 -0
  9. package/extension-src/pi-style/app/doctor.ts +53 -0
  10. package/extension-src/pi-style/app/index.ts +322 -0
  11. package/extension-src/pi-style/app/providers.ts +268 -0
  12. package/extension-src/pi-style/app/render-scheduler.ts +48 -0
  13. package/extension-src/pi-style/app/runtime.ts +404 -0
  14. package/extension-src/pi-style/app/snapshot.ts +15 -0
  15. package/extension-src/pi-style/domain/capabilities.ts +34 -0
  16. package/extension-src/pi-style/domain/config-authorization.ts +20 -0
  17. package/extension-src/pi-style/domain/config-diagnostics.ts +20 -0
  18. package/extension-src/pi-style/domain/config-diff.ts +30 -0
  19. package/extension-src/pi-style/domain/config-migrations.ts +52 -0
  20. package/extension-src/pi-style/domain/config-normalization.ts +567 -0
  21. package/extension-src/pi-style/domain/config-presets.ts +51 -0
  22. package/extension-src/pi-style/domain/config-types.ts +115 -0
  23. package/extension-src/pi-style/domain/providers.ts +37 -0
  24. package/extension-src/pi-style/domain/status-presets.ts +60 -0
  25. package/extension-src/pi-style/domain/status-renderer.ts +142 -0
  26. package/extension-src/pi-style/domain/status.ts +430 -0
  27. package/extension-src/pi-style/domain/theme.ts +232 -0
  28. package/extension-src/pi-style/features/.gitkeep +0 -0
  29. package/extension-src/pi-style/features/editor/index.ts +304 -0
  30. package/extension-src/pi-style/features/messages/boxed-block.ts +74 -0
  31. package/extension-src/pi-style/features/messages/index.ts +145 -0
  32. package/extension-src/pi-style/features/messages/special-blocks.ts +281 -0
  33. package/extension-src/pi-style/features/startup/index.ts +564 -0
  34. package/extension-src/pi-style/features/startup/logo.ts +151 -0
  35. package/extension-src/pi-style/features/status-line/index.ts +319 -0
  36. package/extension-src/pi-style/features/tools/boxed/bash.ts +347 -0
  37. package/extension-src/pi-style/features/tools/boxed/edit.ts +113 -0
  38. package/extension-src/pi-style/features/tools/boxed/fallback.ts +67 -0
  39. package/extension-src/pi-style/features/tools/boxed/find.ts +65 -0
  40. package/extension-src/pi-style/features/tools/boxed/grep.ts +108 -0
  41. package/extension-src/pi-style/features/tools/boxed/index.ts +64 -0
  42. package/extension-src/pi-style/features/tools/boxed/ls.ts +65 -0
  43. package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +190 -0
  44. package/extension-src/pi-style/features/tools/boxed/read.ts +204 -0
  45. package/extension-src/pi-style/features/tools/boxed/session-config.ts +45 -0
  46. package/extension-src/pi-style/features/tools/boxed/shared.ts +157 -0
  47. package/extension-src/pi-style/features/tools/boxed/write.ts +89 -0
  48. package/extension-src/pi-style/features/tools/index.ts +480 -0
  49. package/extension-src/pi-style/pi/commands.ts +17 -0
  50. package/extension-src/pi-style/pi/compatibility-coordinator.ts +195 -0
  51. package/extension-src/pi-style/pi/compatibility-probe.ts +645 -0
  52. package/extension-src/pi-style/pi/compatibility-registry.ts +329 -0
  53. package/extension-src/pi-style/pi/config-host.ts +52 -0
  54. package/extension-src/pi-style/pi/config-session.ts +105 -0
  55. package/extension-src/pi-style/pi/index.ts +77 -0
  56. package/extension-src/pi-style/pi/operational-state.ts +29 -0
  57. package/extension-src/pi-style/pi/session-coordinator.ts +187 -0
  58. package/extension-src/pi-style/pi/session-usage.ts +62 -0
  59. package/extension-src/pi-style/pi/startup-resources.ts +70 -0
  60. package/extension-src/pi-style/shared/.gitkeep +0 -0
  61. package/extension-src/pi-style/shared/ansi.ts +386 -0
  62. package/extension-src/pi-style/shared/box.ts +805 -0
  63. package/extension-src/pi-style/shared/disposable-store.ts +28 -0
  64. package/extension-src/pi-style/shared/elapsed.ts +88 -0
  65. package/extension-src/pi-style/shared/render-budget.ts +367 -0
  66. package/extension-src/pi-style/shared/split-diff.ts +692 -0
  67. package/extension-src/pi-style/shared/theme-extras.ts +292 -0
  68. package/package.json +68 -0
  69. package/themes/.gitkeep +0 -0
@@ -0,0 +1,564 @@
1
+ import type { Component, OverlayHandle, OverlayOptions } from "@earendil-works/pi-tui";
2
+ import type { NormalizedPiStyleConfig } from "../../domain/config-types.js";
3
+ import type { StatusSnapshot } from "../../domain/status.js";
4
+ import { type ActiveTheme, type ResolvedTheme, resolveTheme } from "../../domain/theme.js";
5
+ import { fitAnsiWidth, truncateAnsi, visibleWidth } from "../../shared/ansi.js";
6
+ import { compactLogoHeader } from "./logo.js";
7
+
8
+ export type StartupReason = "startup" | "reload" | "new" | "resume" | "fork";
9
+
10
+ export interface StartupDetailItem {
11
+ readonly kind: "system" | "append" | "context";
12
+ readonly path: string;
13
+ readonly words: number;
14
+ readonly lines: number;
15
+ }
16
+
17
+ export interface StartupToolItem {
18
+ readonly source: string;
19
+ readonly name: string;
20
+ }
21
+
22
+ export interface StartupResources {
23
+ readonly contextFiles?: number;
24
+ readonly extensions?: number;
25
+ readonly skills?: number;
26
+ readonly prompts?: number;
27
+ readonly tools?: number;
28
+ readonly models?: number;
29
+ readonly details?: readonly StartupDetailItem[];
30
+ readonly toolDetails?: readonly StartupToolItem[];
31
+ readonly error?: string;
32
+ }
33
+
34
+ export interface StartupSnapshot extends StatusSnapshot {
35
+ readonly reason: StartupReason;
36
+ readonly project?: string | undefined;
37
+ readonly startupProvider?: string | undefined;
38
+ readonly resources?: StartupResources | undefined;
39
+ readonly preset?: string | undefined;
40
+ readonly compatibility?: string | undefined;
41
+ }
42
+
43
+ export type StartupTheme = ActiveTheme;
44
+
45
+ function activeThemeFromPi(theme: unknown): ActiveTheme {
46
+ if (!theme || typeof theme !== "object") return {};
47
+ const candidate = theme as { fg?: (token: string, text?: string) => string; colors?: Record<string, string> };
48
+ return {
49
+ ...(candidate.colors ? { colors: candidate.colors } : {}),
50
+ ...(candidate.fg ? { fg: (color: string, text: string) => candidate.fg?.(color, text) ?? text } : {}),
51
+ };
52
+ }
53
+
54
+ export interface StartupHost {
55
+ setHeader?: (factory: ((tui: unknown, theme: unknown) => Component & { dispose?(): void }) | undefined) => void;
56
+ setWidget?: (key: string, content: unknown, options?: unknown) => void;
57
+ custom?: <T>(
58
+ factory: (
59
+ tui: unknown,
60
+ theme: unknown,
61
+ keybindings: unknown,
62
+ done: (value: T) => void,
63
+ ) => Component & { dispose?(): void },
64
+ options?: { overlay?: boolean; overlayOptions?: OverlayOptions; onHandle?: (handle: OverlayHandle) => void },
65
+ ) => Promise<T>;
66
+ onTerminalInput?: (handler: (...args: readonly unknown[]) => unknown) => () => void;
67
+ readonly mode?: string;
68
+ readonly hasUI?: boolean;
69
+ /** Optional public-adapter capability used to avoid overwriting a later header owner. */
70
+ getHeaderFactory?: () => unknown;
71
+ }
72
+
73
+ export interface StartupInstallation {
74
+ readonly generation: number;
75
+ update(snapshot: StartupSnapshot): void;
76
+ dismiss(): void;
77
+ configure(config: NormalizedPiStyleConfig): void;
78
+ dispose(): void;
79
+ }
80
+
81
+ export interface StartupInstallOptions {
82
+ host: StartupHost;
83
+ config: NormalizedPiStyleConfig;
84
+ snapshot: StartupSnapshot;
85
+ generation: number;
86
+ requestRender?: () => void;
87
+ timeoutMs?: number;
88
+ isCurrent?: () => boolean;
89
+ }
90
+
91
+ export const STARTUP_WIDGET_KEY = "pi-style.startup";
92
+ const owners = new WeakMap<object, Map<string, symbol>>();
93
+
94
+ function ownerMap(host: object): Map<string, symbol> {
95
+ let map = owners.get(host);
96
+ if (!map) {
97
+ map = new Map();
98
+ owners.set(host, map);
99
+ }
100
+ return map;
101
+ }
102
+
103
+ function safeCall(fn: () => void): boolean {
104
+ try {
105
+ fn();
106
+ return true;
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ function shouldShow(reason: StartupReason, mode: "off" | "compact" | "overlay"): boolean {
113
+ if (mode === "off") return false;
114
+ return reason === "startup" || reason === "reload" || reason === "new" || reason === "resume" || reason === "fork";
115
+ }
116
+
117
+ function overlayAllowed(reason: StartupReason): boolean {
118
+ return reason === "startup";
119
+ }
120
+
121
+ function resourceChipRows(resources: StartupResources | undefined): { label: string; count: number }[] {
122
+ if (!resources) return [];
123
+ const rows: { label: string; count: number }[] = [];
124
+ if (resources.contextFiles !== undefined) rows.push({ label: "context", count: resources.contextFiles });
125
+ if (resources.extensions !== undefined) rows.push({ label: "extensions", count: resources.extensions });
126
+ if (resources.skills !== undefined) rows.push({ label: "skills", count: resources.skills });
127
+ if (resources.prompts !== undefined) rows.push({ label: "prompts", count: resources.prompts });
128
+ if (resources.tools !== undefined) rows.push({ label: "tools", count: resources.tools });
129
+ if (resources.models !== undefined) rows.push({ label: "models", count: resources.models });
130
+ return rows;
131
+ }
132
+
133
+ const PANEL_SIDE_PADDING = 2;
134
+ const PANEL_MIN_WIDTH = 64;
135
+ const PANEL_OUTER_WIDTH = PANEL_SIDE_PADDING * 2 + 2;
136
+ /** Panels render only when the surface is wide enough to keep the box intact. */
137
+ const MIN_PANELS_WIDTH = PANEL_MIN_WIDTH + PANEL_OUTER_WIDTH;
138
+ const RESOURCE_ROW_GAP = " · ";
139
+ const CONTEXT_KIND_RANK: Record<StartupDetailItem["kind"], number> = { system: 0, append: 1, context: 2 };
140
+
141
+ /** `◆ Resources` summary chips. */
142
+ function renderResourceChips(resolved: ResolvedTheme, resources: StartupResources | undefined): string {
143
+ const rows = resourceChipRows(resources);
144
+ if (rows.length === 0) return "";
145
+ const marker = resolved.apply("accent", "◆ Resources");
146
+ const chips = rows.map((row, index) => {
147
+ const label = resolved.apply(index === 0 ? "text" : "muted", row.label);
148
+ const count = resolved.apply("success", String(row.count));
149
+ return `${label} ${count}`;
150
+ });
151
+ return [marker, ...chips].join(resolved.apply("dim", RESOURCE_ROW_GAP));
152
+ }
153
+
154
+ function sortedContextItems(items: readonly StartupDetailItem[]): StartupDetailItem[] {
155
+ return [...items].sort((a, b) => (CONTEXT_KIND_RANK[a.kind] ?? 9) - (CONTEXT_KIND_RANK[b.kind] ?? 9));
156
+ }
157
+
158
+ function renderPanelBorder(resolved: ResolvedTheme, left: string, right: string, panelWidth: number): string {
159
+ return resolved.apply("dim", `${left}${"─".repeat(panelWidth + PANEL_SIDE_PADDING * 2)}${right}`);
160
+ }
161
+
162
+ function renderPanelLine(resolved: ResolvedTheme, content: string, panelWidth: number): string {
163
+ const sidePadding = " ".repeat(PANEL_SIDE_PADDING);
164
+ const padding = " ".repeat(Math.max(0, panelWidth - visibleWidth(content)));
165
+ return `${resolved.apply("dim", "│")}${sidePadding}${content}${padding}${sidePadding}${resolved.apply("dim", "│")}`;
166
+ }
167
+
168
+ /** Boxed System & Context table. */
169
+ function renderSystemContextPanel(
170
+ resolved: ResolvedTheme,
171
+ items: readonly StartupDetailItem[],
172
+ minTotalWidth: number,
173
+ ): string[] {
174
+ const sorted = sortedContextItems(items);
175
+ const titleLine = resolved.apply("accent", "System & Context");
176
+ if (sorted.length === 0) return [];
177
+ const typeHeader = "Type";
178
+ const pathHeader = "Path";
179
+ const metricLabel = "Words/Lines";
180
+ const typeWidth = Math.max(typeHeader.length, ...sorted.map((item) => visibleWidth(item.kind)));
181
+ const divider = resolved.apply("muted", " | ");
182
+ const dividerWidth = visibleWidth(divider);
183
+ const metricWidth = Math.max(metricLabel.length, ...sorted.map((item) => `${item.words}/${item.lines}`.length));
184
+ const fixedColumnsWidth = typeWidth + dividerWidth + dividerWidth + metricWidth;
185
+ const panelWidth = Math.max(PANEL_MIN_WIDTH, minTotalWidth - PANEL_OUTER_WIDTH, visibleWidth(titleLine));
186
+ const pathWidth = Math.max(pathHeader.length, panelWidth - fixedColumnsWidth);
187
+ const header = `${resolved.apply("text", typeHeader.padEnd(typeWidth))}${divider}${resolved.apply(
188
+ "text",
189
+ pathHeader.padEnd(pathWidth),
190
+ )}${divider}${resolved.apply("text", metricLabel.padStart(metricWidth))}`;
191
+ const separator = `${resolved.apply("dim", "─".repeat(typeWidth))}${divider}${resolved.apply(
192
+ "dim",
193
+ "─".repeat(pathWidth),
194
+ )}${divider}${resolved.apply("dim", "─".repeat(metricWidth))}`;
195
+ const lines = [
196
+ renderPanelBorder(resolved, "┌", "┐", panelWidth),
197
+ renderPanelLine(resolved, titleLine, panelWidth),
198
+ renderPanelLine(resolved, header, panelWidth),
199
+ renderPanelLine(resolved, separator, panelWidth),
200
+ ];
201
+ for (const item of sorted) {
202
+ const metric = `${item.words}/${item.lines}`;
203
+ const typePadding = " ".repeat(Math.max(0, typeWidth - visibleWidth(item.kind)));
204
+ const path = fitAnsiWidth(item.path, pathWidth);
205
+ const pathPadding = " ".repeat(Math.max(0, pathWidth - visibleWidth(path)));
206
+ const metricPadding = " ".repeat(Math.max(0, metricWidth - visibleWidth(metric)));
207
+ lines.push(
208
+ renderPanelLine(
209
+ resolved,
210
+ `${resolved.apply("text", item.kind)}${typePadding}${divider}${resolved.apply(
211
+ "text",
212
+ path,
213
+ )}${pathPadding}${divider}${metricPadding}${resolved.apply("text", metric)}`,
214
+ panelWidth,
215
+ ),
216
+ );
217
+ }
218
+ lines.push(renderPanelBorder(resolved, "└", "┘", panelWidth));
219
+ return lines;
220
+ }
221
+
222
+ function groupToolDetails(tools: readonly StartupToolItem[]): { source: string; names: string[] }[] {
223
+ const groups = new Map<string, Set<string>>();
224
+ for (const tool of tools) {
225
+ const source = tool.source.trim() || "extension";
226
+ const name = tool.name.trim();
227
+ if (!name) continue;
228
+ let names = groups.get(source);
229
+ if (!names) {
230
+ names = new Set();
231
+ groups.set(source, names);
232
+ }
233
+ names.add(name);
234
+ }
235
+ return [...groups.entries()]
236
+ .map(([source, names]) => ({ source, names: [...names].sort((a, b) => a.localeCompare(b)) }))
237
+ .sort((a, b) => {
238
+ if (a.source === "core") return -1;
239
+ if (b.source === "core") return 1;
240
+ return a.source.localeCompare(b.source);
241
+ });
242
+ }
243
+
244
+ /** Boxed Available Tools table. */
245
+ function renderToolsPanel(resolved: ResolvedTheme, tools: readonly StartupToolItem[], minTotalWidth: number): string[] {
246
+ const groups = groupToolDetails(tools);
247
+ if (groups.length === 0) return [];
248
+ const titleLine = resolved.apply("accent", "Available Tools");
249
+ const sourceHeader = "Source";
250
+ const countHeader = "Count";
251
+ const toolsHeader = "Tools";
252
+ const countWidth = Math.max(countHeader.length, ...groups.map((group) => String(group.names.length).length));
253
+ const divider = resolved.apply("muted", " | ");
254
+ const dividerWidth = visibleWidth(divider);
255
+ const panelWidth = Math.max(PANEL_MIN_WIDTH, minTotalWidth - PANEL_OUTER_WIDTH, visibleWidth(titleLine));
256
+ const availableTextWidth = Math.max(
257
+ sourceHeader.length + toolsHeader.length,
258
+ panelWidth - countWidth - dividerWidth * 2,
259
+ );
260
+ const maxSourceWidth = Math.max(sourceHeader.length, ...groups.map((group) => visibleWidth(group.source)));
261
+ const sourceWidth = Math.min(maxSourceWidth, Math.max(sourceHeader.length, Math.floor(availableTextWidth * 0.28)));
262
+ const toolsWidth = Math.max(toolsHeader.length, availableTextWidth - sourceWidth);
263
+ const header = `${resolved.apply("text", sourceHeader.padEnd(sourceWidth))}${divider}${resolved.apply(
264
+ "text",
265
+ countHeader.padStart(countWidth),
266
+ )}${divider}${resolved.apply("text", toolsHeader.padEnd(toolsWidth))}`;
267
+ const separator = `${resolved.apply("dim", "─".repeat(sourceWidth))}${divider}${resolved.apply(
268
+ "dim",
269
+ "─".repeat(countWidth),
270
+ )}${divider}${resolved.apply("dim", "─".repeat(toolsWidth))}`;
271
+ const lines = [
272
+ renderPanelBorder(resolved, "┌", "┐", panelWidth),
273
+ renderPanelLine(resolved, titleLine, panelWidth),
274
+ renderPanelLine(resolved, header, panelWidth),
275
+ renderPanelLine(resolved, separator, panelWidth),
276
+ ];
277
+ for (const group of groups) {
278
+ const count = String(group.names.length);
279
+ const toolList = fitAnsiWidth(group.names.join(", "), toolsWidth);
280
+ const source = fitAnsiWidth(group.source, sourceWidth);
281
+ const sourcePadding = " ".repeat(Math.max(0, sourceWidth - visibleWidth(source)));
282
+ const countPadding = " ".repeat(Math.max(0, countWidth - count.length));
283
+ lines.push(
284
+ renderPanelLine(
285
+ resolved,
286
+ `${resolved.apply("text", source)}${sourcePadding}${divider}${countPadding}${resolved.apply(
287
+ "success",
288
+ count,
289
+ )}${divider}${resolved.apply("text", toolList)}`,
290
+ panelWidth,
291
+ ),
292
+ );
293
+ }
294
+ lines.push(renderPanelBorder(resolved, "└", "┘", panelWidth));
295
+ return lines;
296
+ }
297
+
298
+ /** Left margin for the whole startup block so it does not touch the terminal edge. */
299
+ const STARTUP_INDENT = " ";
300
+ /** Blank rows above the block, separating it from the status line / terminal top. */
301
+ const STARTUP_PADDING_TOP = 2;
302
+ /** Blank rows below the block, separating it from the editor / chat. */
303
+ const STARTUP_PADDING_BOTTOM = 2;
304
+
305
+ function styledLines(
306
+ theme: ActiveTheme,
307
+ config: NormalizedPiStyleConfig,
308
+ snapshot: StartupSnapshot,
309
+ overlay: boolean,
310
+ width: number,
311
+ ): string[] {
312
+ if (width <= 0 || config.startup.mode === "off") return [];
313
+ const resolved = resolveTheme(theme, config);
314
+ const lines: string[] = [];
315
+ const indentWidth = visibleWidth(STARTUP_INDENT);
316
+ const bodyWidth = Math.max(1, width - indentWidth);
317
+ const indent = (content: string): string => `${STARTUP_INDENT}${content}`;
318
+
319
+ // Breathing room above the block (separates it from the status line / terminal top).
320
+ lines.push(...Array.from({ length: STARTUP_PADDING_TOP }, () => ""));
321
+
322
+ const logoTitle = resolved.mode === "ascii" ? "pi-style" : `${resolved.glyph("pi")} pi-style`;
323
+ lines.push(
324
+ ...compactLogoHeader(
325
+ resolved,
326
+ [
327
+ resolved.apply("accent", logoTitle),
328
+ resolved.apply("muted", "/ commands · ! bash"),
329
+ resolved.apply("success", "● ready"),
330
+ ],
331
+ bodyWidth,
332
+ ).map(indent),
333
+ );
334
+
335
+ const info: string[] = [];
336
+ if (config.startup.showResources) {
337
+ const chips = renderResourceChips(resolved, snapshot.resources);
338
+ if (chips) info.push(chips);
339
+ if (snapshot.resources?.error)
340
+ info.push(resolved.apply("muted", `resources unavailable · ${snapshot.resources.error}`));
341
+ }
342
+ if (info.length > 0) lines.push("", ...info.map(indent));
343
+
344
+ const expanded = overlay || config.startup.alwaysExpanded;
345
+ if (expanded && bodyWidth >= MIN_PANELS_WIDTH && config.startup.showResources) {
346
+ const contextItems = snapshot.resources?.details ?? [];
347
+ const toolItems = snapshot.resources?.toolDetails ?? [];
348
+ if (contextItems.length > 0) {
349
+ lines.push("");
350
+ lines.push(...renderSystemContextPanel(resolved, contextItems, bodyWidth).map(indent));
351
+ }
352
+ if (toolItems.length > 0) {
353
+ lines.push("");
354
+ lines.push(...renderToolsPanel(resolved, toolItems, bodyWidth).map(indent));
355
+ }
356
+ }
357
+
358
+ // Breathing room below the block (separates it from the editor / chat).
359
+ lines.push(...Array.from({ length: STARTUP_PADDING_BOTTOM }, () => ""));
360
+ if (overlay) lines.push(indent(resolved.apply("dim", "enter prompt to continue · esc dismiss")));
361
+
362
+ return lines.map((line) => (visibleWidth(line) <= width ? line : truncateAnsi(line, width, "")));
363
+ }
364
+
365
+ class StartupComponent implements Component {
366
+ private snapshot: StartupSnapshot;
367
+ private config: NormalizedPiStyleConfig;
368
+ private readonly theme: ActiveTheme;
369
+ private readonly overlay: boolean;
370
+ private readonly requestRender: () => void;
371
+
372
+ constructor(
373
+ theme: ActiveTheme,
374
+ config: NormalizedPiStyleConfig,
375
+ snapshot: StartupSnapshot,
376
+ overlay: boolean,
377
+ requestRender: () => void,
378
+ ) {
379
+ this.theme = theme;
380
+ this.config = config;
381
+ this.snapshot = snapshot;
382
+ this.overlay = overlay;
383
+ this.requestRender = requestRender;
384
+ }
385
+
386
+ setSnapshot(snapshot: StartupSnapshot): void {
387
+ this.snapshot = snapshot;
388
+ this.invalidate();
389
+ }
390
+
391
+ setConfig(config: NormalizedPiStyleConfig): void {
392
+ this.config = config;
393
+ this.invalidate();
394
+ }
395
+
396
+ render(width: number): string[] {
397
+ return styledLines(this.theme, this.config, this.snapshot, this.overlay, width);
398
+ }
399
+
400
+ invalidate(): void {
401
+ this.requestRender();
402
+ }
403
+ }
404
+
405
+ export function renderStartup(
406
+ snapshot: StartupSnapshot,
407
+ config: NormalizedPiStyleConfig,
408
+ theme: ActiveTheme,
409
+ width: number,
410
+ overlay = false,
411
+ ): string[] {
412
+ return styledLines(theme, config, snapshot, overlay, width);
413
+ }
414
+
415
+ export function installStartup(options: StartupInstallOptions): StartupInstallation | undefined {
416
+ const { host } = options;
417
+ if (options.config.startup.mode === "off" || !shouldShow(options.snapshot.reason, options.config.startup.mode))
418
+ return undefined;
419
+ if (!host.hasUI || host.mode !== "tui") return undefined;
420
+ const token = Symbol("pi-style.startup");
421
+ const map = ownerMap(host as object);
422
+ let config = options.config;
423
+ let snapshot = options.snapshot;
424
+ let disposed = false;
425
+ let dismissed = false;
426
+ let headerInstalled = false;
427
+ let installedHeaderFactory: unknown;
428
+ let widgetInstalled = false;
429
+ let overlayHandle: OverlayHandle | undefined;
430
+ let removeInput: (() => void) | undefined;
431
+ let timeout: ReturnType<typeof setTimeout> | undefined;
432
+ let overlayDone: ((value: undefined) => void) | undefined;
433
+ const components: StartupComponent[] = [];
434
+ const timeoutMs = options.timeoutMs;
435
+ const component = (theme: unknown, isOverlay: boolean, tui: { requestRender?: () => void }) => {
436
+ const result = new StartupComponent(activeThemeFromPi(theme), config, snapshot, isOverlay, () => {
437
+ tui.requestRender?.();
438
+ options.requestRender?.();
439
+ });
440
+ components.push(result);
441
+ return result;
442
+ };
443
+ const clearTimer = () => {
444
+ if (timeout) clearTimeout(timeout);
445
+ timeout = undefined;
446
+ };
447
+ const dismiss = () => {
448
+ if (disposed || dismissed) return;
449
+ dismissed = true;
450
+ clearTimer();
451
+ overlayHandle?.hide();
452
+ overlayHandle = undefined;
453
+ overlayDone?.(undefined);
454
+ overlayDone = undefined;
455
+ };
456
+ const clearHeader = () => {
457
+ if (!headerInstalled || map.get("header") !== token) return;
458
+ const current = host.getHeaderFactory?.();
459
+ if (host.getHeaderFactory && current !== installedHeaderFactory) return;
460
+ if (host.setHeader) safeCall(() => host.setHeader?.(undefined));
461
+ map.delete("header");
462
+ };
463
+ const clearWidget = () => {
464
+ if (!widgetInstalled || map.get(STARTUP_WIDGET_KEY) !== token) return;
465
+ if (host.setWidget) safeCall(() => host.setWidget?.(STARTUP_WIDGET_KEY, undefined));
466
+ map.delete(STARTUP_WIDGET_KEY);
467
+ };
468
+ const mountCompact = (): boolean => {
469
+ const factory = (tui: unknown, theme: unknown) => component(theme, false, tui as { requestRender?: () => void });
470
+ // Pi's public `setHeader` is the intended startup-header surface ("shown at
471
+ // startup, above chat"). Prefer it so the startup renders at the top of the
472
+ // terminal rather than inside the editor area. The injected ownership
473
+ // adapter, when present, still guards against overwriting a later owner;
474
+ // the widget remains the fallback when the header API is unavailable.
475
+ let currentHeader: unknown = Symbol("unreadable");
476
+ const observable =
477
+ host.getHeaderFactory &&
478
+ safeCall(() => {
479
+ currentHeader = host.getHeaderFactory?.();
480
+ });
481
+ const headerAvailable = host.setHeader !== undefined && (!observable || currentHeader === undefined);
482
+ if (headerAvailable && safeCall(() => host.setHeader?.(factory))) {
483
+ installedHeaderFactory = factory;
484
+ headerInstalled = true;
485
+ map.set("header", token);
486
+ return true;
487
+ }
488
+ if (host.setWidget && safeCall(() => host.setWidget?.(STARTUP_WIDGET_KEY, factory, { placement: "aboveEditor" }))) {
489
+ widgetInstalled = true;
490
+ map.set(STARTUP_WIDGET_KEY, token);
491
+ return true;
492
+ }
493
+ return false;
494
+ };
495
+ const mountOverlay = () => {
496
+ if (!host.custom || !overlayAllowed(snapshot.reason)) {
497
+ mountCompact();
498
+ return;
499
+ }
500
+ const overlayOptions: OverlayOptions = {
501
+ anchor: "center",
502
+ width: "80%",
503
+ maxHeight: "60%",
504
+ minWidth: 40,
505
+ visible: (width, height) => width >= 40 && height >= 8,
506
+ };
507
+ void host
508
+ .custom<undefined>(
509
+ (tui, theme, _keybindings, done) => {
510
+ overlayDone = done;
511
+ return component(theme, true, tui as { requestRender?: () => void });
512
+ },
513
+ { overlay: true, overlayOptions, onHandle: (handle) => (overlayHandle = handle) },
514
+ )
515
+ .catch(() => {
516
+ if (!disposed && !dismissed) {
517
+ clearTimer();
518
+ mountCompact();
519
+ }
520
+ });
521
+ };
522
+ const installation: StartupInstallation = {
523
+ generation: options.generation,
524
+ update(next) {
525
+ if (disposed || options.isCurrent?.() === false) return;
526
+ snapshot = next;
527
+ for (const item of components) item.setSnapshot(next);
528
+ options.requestRender?.();
529
+ },
530
+ dismiss,
531
+ configure(next) {
532
+ if (disposed || options.isCurrent?.() === false) return;
533
+ config = next;
534
+ for (const item of components) item.setConfig(next);
535
+ if (next.startup.mode === "off") {
536
+ dismiss();
537
+ clearHeader();
538
+ clearWidget();
539
+ }
540
+ options.requestRender?.();
541
+ },
542
+ dispose() {
543
+ if (disposed) return;
544
+ dismiss();
545
+ disposed = true;
546
+ removeInput?.();
547
+ removeInput = undefined;
548
+ clearHeader();
549
+ clearWidget();
550
+ map.delete("installation");
551
+ },
552
+ };
553
+ map.set("installation", token);
554
+ removeInput = host.onTerminalInput?.(() => dismiss());
555
+ if (config.startup.mode === "compact" && !mountCompact()) {
556
+ installation.dispose();
557
+ return undefined;
558
+ }
559
+ if (config.startup.mode === "overlay") mountOverlay();
560
+ if (config.startup.mode === "overlay" && timeoutMs !== undefined && timeoutMs >= 0) {
561
+ timeout = setTimeout(() => dismiss(), timeoutMs);
562
+ }
563
+ return installation;
564
+ }