@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,151 @@
|
|
|
1
|
+
import { hexToAnsiPrefix, type ResolvedTheme } from "../../domain/theme.js";
|
|
2
|
+
import { fitAnsiWidth, parseAnsiFgToRgb, visibleWidth } from "../../shared/ansi.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Compact startup logo header. Renders the block-art Pi logo with a
|
|
6
|
+
* per-character accent gradient plus side details, with stacked/truncated
|
|
7
|
+
* fallbacks for narrow widths. Pure render: color comes only from the
|
|
8
|
+
* resolved theme.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const PI_LOGO_LINES = [
|
|
12
|
+
"████████████╗",
|
|
13
|
+
"████████████║",
|
|
14
|
+
"████╔═══████║",
|
|
15
|
+
"████║ ████║",
|
|
16
|
+
"████████╬═══████╗",
|
|
17
|
+
"████████║ ████║ ",
|
|
18
|
+
"████╔═══╝ ████║",
|
|
19
|
+
"████║ ████║",
|
|
20
|
+
"╚═══╝ ╚═══╝",
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
const LOGO_PALETTE_STEPS = 24;
|
|
24
|
+
const LOGO_MAX_DARKEN = 0.18;
|
|
25
|
+
const LOGO_MAX_LIGHTEN = 0.18;
|
|
26
|
+
const LOGO_ROW_PHASE_STEP = 0.12;
|
|
27
|
+
const LOGO_GAP = " ";
|
|
28
|
+
/** Minimum side-detail width before the logo collapses to stacked lines. */
|
|
29
|
+
const LOGO_SIDE_DETAIL_MIN_WIDTH = 12;
|
|
30
|
+
|
|
31
|
+
type Rgb = { r: number; g: number; b: number };
|
|
32
|
+
|
|
33
|
+
function clampChannel(value: number): number {
|
|
34
|
+
return Math.max(0, Math.min(255, Math.round(value)));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function interpolateRgb(start: Rgb, end: Rgb, factor: number): Rgb {
|
|
38
|
+
return {
|
|
39
|
+
r: clampChannel(start.r + (end.r - start.r) * factor),
|
|
40
|
+
g: clampChannel(start.g + (end.g - start.g) * factor),
|
|
41
|
+
b: clampChannel(start.b + (end.b - start.b) * factor),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function darkenRgb(rgb: Rgb, amount: number): Rgb {
|
|
46
|
+
return {
|
|
47
|
+
r: clampChannel(rgb.r * (1 - amount)),
|
|
48
|
+
g: clampChannel(rgb.g * (1 - amount)),
|
|
49
|
+
b: clampChannel(rgb.b * (1 - amount)),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function lightenRgb(rgb: Rgb, amount: number): Rgb {
|
|
54
|
+
return {
|
|
55
|
+
r: clampChannel(rgb.r + (255 - rgb.r) * amount),
|
|
56
|
+
g: clampChannel(rgb.g + (255 - rgb.g) * amount),
|
|
57
|
+
b: clampChannel(rgb.b + (255 - rgb.b) * amount),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function buildLogoPalette(accent: Rgb): Rgb[] {
|
|
62
|
+
return Array.from({ length: LOGO_PALETTE_STEPS }, (_, index) => {
|
|
63
|
+
const progress = index / LOGO_PALETTE_STEPS;
|
|
64
|
+
const wave = -Math.cos(progress * Math.PI * 2);
|
|
65
|
+
return wave < 0 ? darkenRgb(accent, LOGO_MAX_DARKEN * -wave) : lightenRgb(accent, LOGO_MAX_LIGHTEN * wave);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sampleLogoGradient(palette: Rgb[], position: number): Rgb {
|
|
70
|
+
const wrapped = ((position % 1) + 1) % 1;
|
|
71
|
+
const scaled = wrapped * palette.length;
|
|
72
|
+
const baseIndex = Math.floor(scaled) % palette.length;
|
|
73
|
+
const nextIndex = (baseIndex + 1) % palette.length;
|
|
74
|
+
const base = palette[baseIndex];
|
|
75
|
+
const next = palette[nextIndex];
|
|
76
|
+
if (!base || !next) return { r: 0, g: 0, b: 0 };
|
|
77
|
+
return interpolateRgb(base, next, scaled - Math.floor(scaled));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function renderLogoGradientLine(line: string, palette: Rgb[], phase: number): string {
|
|
81
|
+
const characters = [...line];
|
|
82
|
+
const span = Math.max(characters.length - 1, 1);
|
|
83
|
+
return characters
|
|
84
|
+
.map((character, index) => {
|
|
85
|
+
if (character === " ") return character;
|
|
86
|
+
const color = sampleLogoGradient(palette, index / span + phase);
|
|
87
|
+
return `${hexToAnsiPrefix(rgbToHex(color))}${character}`;
|
|
88
|
+
})
|
|
89
|
+
.join("");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function rgbToHex(rgb: Rgb): string {
|
|
93
|
+
return `#${[rgb.r, rgb.g, rgb.b].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let logoGradientCacheKey: string | undefined;
|
|
97
|
+
let logoGradientCacheLines: string[] | undefined;
|
|
98
|
+
|
|
99
|
+
/** Accent RGB resolved from the active theme, or undefined when uncolored. */
|
|
100
|
+
export function resolveAccentRgb(resolved: ResolvedTheme): Rgb | undefined {
|
|
101
|
+
const accentAnsi = resolved.color("accent");
|
|
102
|
+
if (!accentAnsi) return undefined;
|
|
103
|
+
return parseAnsiFgToRgb(accentAnsi);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The block-art logo lines, gradient-styled when an accent color is resolvable. */
|
|
107
|
+
export function styledLogoLines(resolved: ResolvedTheme): string[] {
|
|
108
|
+
const accent = resolveAccentRgb(resolved);
|
|
109
|
+
if (!accent) return [...PI_LOGO_LINES];
|
|
110
|
+
const cacheKey = `${resolved.color("accent")}|${resolved.mode}`;
|
|
111
|
+
if (cacheKey === logoGradientCacheKey && logoGradientCacheLines) return logoGradientCacheLines;
|
|
112
|
+
const palette = buildLogoPalette(accent);
|
|
113
|
+
logoGradientCacheLines = PI_LOGO_LINES.map((line, rowIndex) =>
|
|
114
|
+
renderLogoGradientLine(line, palette, rowIndex * LOGO_ROW_PHASE_STEP),
|
|
115
|
+
);
|
|
116
|
+
logoGradientCacheKey = cacheKey;
|
|
117
|
+
return logoGradientCacheLines;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Assemble the compact startup header: gradient logo with side details when
|
|
122
|
+
* wide enough, stacked logo + details next, and a minimal title/status pair
|
|
123
|
+
* for very narrow terminals. Every returned line fits within `width`.
|
|
124
|
+
*/
|
|
125
|
+
export function compactLogoHeader(
|
|
126
|
+
resolved: ResolvedTheme,
|
|
127
|
+
details: readonly [title: string, hints: string, status: string],
|
|
128
|
+
width: number,
|
|
129
|
+
): string[] {
|
|
130
|
+
const logoLines = styledLogoLines(resolved);
|
|
131
|
+
const logoWidth = Math.max(...PI_LOGO_LINES.map((line) => visibleWidth(line)));
|
|
132
|
+
const safeWidth = Math.max(1, width);
|
|
133
|
+
const detailWidth = safeWidth - logoWidth - visibleWidth(LOGO_GAP);
|
|
134
|
+
|
|
135
|
+
if (detailWidth >= LOGO_SIDE_DETAIL_MIN_WIDTH) {
|
|
136
|
+
const detailStartRow = Math.max(0, Math.floor((PI_LOGO_LINES.length - details.length) / 2));
|
|
137
|
+
return PI_LOGO_LINES.map((plainLine, index) => {
|
|
138
|
+
const logoPadding = " ".repeat(Math.max(0, logoWidth - visibleWidth(plainLine)));
|
|
139
|
+
const detailIndex = index - detailStartRow;
|
|
140
|
+
const detailText = details[detailIndex];
|
|
141
|
+
const detail = detailText ? fitAnsiWidth(detailText, detailWidth) : "";
|
|
142
|
+
return `${logoLines[index]}${logoPadding}${detail ? `${LOGO_GAP}${detail}` : ""}`;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (safeWidth >= logoWidth) {
|
|
147
|
+
return [...logoLines, ...details.map((detail) => fitAnsiWidth(detail, safeWidth))];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return [details[0], details[2]].map((detail) => fitAnsiWidth(detail, safeWidth));
|
|
151
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import type { NormalizedPiStyleConfig } from "../../domain/config-types.js";
|
|
2
|
+
import { createBuiltinSegments, type SegmentContext, type StatusSnapshot } from "../../domain/status.js";
|
|
3
|
+
import { renderStatus } from "../../domain/status-renderer.js";
|
|
4
|
+
import { type ResolvedTheme, resolveTheme } from "../../domain/theme.js";
|
|
5
|
+
import { visibleWidth } from "../../shared/ansi.js";
|
|
6
|
+
|
|
7
|
+
export const PRIMARY_WIDGET_KEY = "pi-style.status.primary";
|
|
8
|
+
export const SECONDARY_WIDGET_KEY = "pi-style.status.secondary";
|
|
9
|
+
|
|
10
|
+
type WidgetPlacement = "aboveEditor" | "belowEditor";
|
|
11
|
+
type RenderComponent = { render(width: number): string[]; invalidate(): void; dispose?(): void };
|
|
12
|
+
type WidgetFactory = (tui: { requestRender?: () => void }, theme: ActivePiTheme) => RenderComponent;
|
|
13
|
+
|
|
14
|
+
/** Read-only view of Pi's footer data provider, kept out of the domain layer. */
|
|
15
|
+
export interface FooterDataProviderView {
|
|
16
|
+
getGitBranch(): string | null;
|
|
17
|
+
getExtensionStatuses(): ReadonlyMap<string, string>;
|
|
18
|
+
onBranchChange(callback: () => void): () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type FooterFactory = (
|
|
22
|
+
tui: { requestRender?: (force?: boolean) => void },
|
|
23
|
+
// The footer renders nothing, so the theme is intentionally untyped.
|
|
24
|
+
theme: unknown,
|
|
25
|
+
footerData: FooterDataProviderView,
|
|
26
|
+
) => RenderComponent;
|
|
27
|
+
|
|
28
|
+
/** Minimal structural view of Pi's theme, kept out of the domain layer. */
|
|
29
|
+
export interface ActivePiTheme {
|
|
30
|
+
fg?: (token: string, text: string) => string;
|
|
31
|
+
colors?: Record<string, string>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface StatusLineWidgetHost {
|
|
35
|
+
setWidget(
|
|
36
|
+
key: string,
|
|
37
|
+
content: string[] | WidgetFactory | undefined,
|
|
38
|
+
options?: { placement?: WidgetPlacement },
|
|
39
|
+
): void;
|
|
40
|
+
/** Replace the native Pi footer with a pi-style owned component; undefined restores it. */
|
|
41
|
+
setFooter(factory: FooterFactory | undefined): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface StatusLineInstallation {
|
|
45
|
+
readonly generation: number;
|
|
46
|
+
readonly primaryKey: typeof PRIMARY_WIDGET_KEY;
|
|
47
|
+
readonly secondaryKey: typeof SECONDARY_WIDGET_KEY;
|
|
48
|
+
update(snapshot: StatusSnapshot): void;
|
|
49
|
+
configure(config: NormalizedPiStyleConfig): void;
|
|
50
|
+
dispose(): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface StatusLineInstallOptions {
|
|
54
|
+
host: StatusLineWidgetHost;
|
|
55
|
+
config: NormalizedPiStyleConfig;
|
|
56
|
+
generation: number;
|
|
57
|
+
initialSnapshot: StatusSnapshot;
|
|
58
|
+
isCurrent?: () => boolean;
|
|
59
|
+
/** Force a full terminal redraw (clear screen + scrollback) on the first footer mount. */
|
|
60
|
+
clearOnStartup?: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Ownership {
|
|
64
|
+
token: symbol;
|
|
65
|
+
generation: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const ownership = new WeakMap<object, Map<string, Ownership>>();
|
|
69
|
+
const activeInstallations = new WeakMap<object, Map<number, StatusLineInstallation>>();
|
|
70
|
+
|
|
71
|
+
function ownerMap(host: object): Map<string, Ownership> {
|
|
72
|
+
let map = ownership.get(host);
|
|
73
|
+
if (!map) {
|
|
74
|
+
map = new Map();
|
|
75
|
+
ownership.set(host, map);
|
|
76
|
+
}
|
|
77
|
+
return map;
|
|
78
|
+
}
|
|
79
|
+
function installationMap(host: object): Map<number, StatusLineInstallation> {
|
|
80
|
+
let map = activeInstallations.get(host);
|
|
81
|
+
if (!map) {
|
|
82
|
+
map = new Map();
|
|
83
|
+
activeInstallations.set(host, map);
|
|
84
|
+
}
|
|
85
|
+
return map;
|
|
86
|
+
}
|
|
87
|
+
function safeWidget(
|
|
88
|
+
host: StatusLineWidgetHost,
|
|
89
|
+
key: string,
|
|
90
|
+
content: string[] | WidgetFactory | undefined,
|
|
91
|
+
placement?: WidgetPlacement,
|
|
92
|
+
): boolean {
|
|
93
|
+
try {
|
|
94
|
+
host.setWidget(key, content, placement ? { placement } : undefined);
|
|
95
|
+
return true;
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function placementFor(config: NormalizedPiStyleConfig): WidgetPlacement {
|
|
101
|
+
return config.placement === "below" ? "belowEditor" : "aboveEditor";
|
|
102
|
+
}
|
|
103
|
+
function separatorsFor(config: NormalizedPiStyleConfig, theme: ResolvedTheme): string {
|
|
104
|
+
const style = config.statusLine.separator;
|
|
105
|
+
if (style === "powerline") return theme.apply("separator", theme.glyph("powerlineLeft"));
|
|
106
|
+
if (style === "powerline-thin" || style === "" || style === undefined) {
|
|
107
|
+
return theme.apply("separator", theme.glyph("powerlineThinLeft"));
|
|
108
|
+
}
|
|
109
|
+
if (style === "none") return " ";
|
|
110
|
+
return theme.apply("separator", style);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function installStatusLine(options: StatusLineInstallOptions): StatusLineInstallation {
|
|
114
|
+
const existing = installationMap(options.host).get(options.generation);
|
|
115
|
+
if (existing) return existing;
|
|
116
|
+
const token = Symbol("pi-style.status-line");
|
|
117
|
+
const owners = ownerMap(options.host);
|
|
118
|
+
let config = options.config;
|
|
119
|
+
let snapshot: StatusSnapshot = options.initialSnapshot;
|
|
120
|
+
let disposed = false;
|
|
121
|
+
let primaryComponent: RenderComponent | undefined;
|
|
122
|
+
let secondaryComponent: RenderComponent | undefined;
|
|
123
|
+
let footerData: FooterDataProviderView | undefined;
|
|
124
|
+
let footerOwner = false;
|
|
125
|
+
let footerUnsubscribe: (() => void) | undefined;
|
|
126
|
+
let clearedOnStartup = false;
|
|
127
|
+
const segments = new Map(createBuiltinSegments());
|
|
128
|
+
for (const item of config.statusLine.customItems) {
|
|
129
|
+
if (!item.id || !item.statusKey) continue;
|
|
130
|
+
segments.set(item.id, {
|
|
131
|
+
id: item.id,
|
|
132
|
+
defaultPriority: item.priority ?? 40,
|
|
133
|
+
overflow: "secondary",
|
|
134
|
+
render: ({ snapshot }: SegmentContext) => {
|
|
135
|
+
const status = snapshot.extensionStatuses?.find(
|
|
136
|
+
(entry: { readonly key: string; readonly value: string }) => entry.key === item.statusKey,
|
|
137
|
+
);
|
|
138
|
+
if (!status) return { visible: false, content: "" };
|
|
139
|
+
return { visible: true, content: `${item.label ? `${item.label}:` : ""}${status.value}`, truncatable: true };
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const render = (activeTheme: ActivePiTheme, width: number, secondary: boolean): string[] => {
|
|
145
|
+
if (width <= 0 || !config.enabled || !config.statusLine.enabled) return [];
|
|
146
|
+
const resolved = resolveTheme(
|
|
147
|
+
activeTheme.colors || activeTheme.fg
|
|
148
|
+
? {
|
|
149
|
+
...(activeTheme.colors ? { colors: activeTheme.colors } : {}),
|
|
150
|
+
// Call through the theme instance so `this` binds correctly inside Pi's fg().
|
|
151
|
+
...(activeTheme.fg ? { fg: (color: string, text: string) => activeTheme.fg?.(color, text) ?? text } : {}),
|
|
152
|
+
}
|
|
153
|
+
: undefined,
|
|
154
|
+
config,
|
|
155
|
+
);
|
|
156
|
+
const result = renderStatus(config.statusLine.layout, effectiveSnapshot(snapshot), width, {
|
|
157
|
+
separator: separatorsFor(config, resolved),
|
|
158
|
+
segments,
|
|
159
|
+
theme: resolved,
|
|
160
|
+
options: {
|
|
161
|
+
...Object.fromEntries(config.statusLine.disabledSegments.map((id) => [id, { disabled: true }])),
|
|
162
|
+
context_bar: { width: config.statusLine.contextBarWidth },
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
const lines = secondary ? result.lines.slice(1) : result.lines.slice(0, 1);
|
|
166
|
+
const rendered = lines.filter((line) => visibleWidth(line) <= width);
|
|
167
|
+
if (!secondary && rendered.length > 0 && config.statusLine.bottomMargin > 0) {
|
|
168
|
+
// Blank rows below the primary row keep the status line off the terminal edge.
|
|
169
|
+
return [...rendered, ...Array.from({ length: config.statusLine.bottomMargin }, () => "")];
|
|
170
|
+
}
|
|
171
|
+
return rendered;
|
|
172
|
+
};
|
|
173
|
+
/** Merge authoritative native footer data (branch + extension statuses) into the snapshot. */
|
|
174
|
+
const effectiveSnapshot = (input: StatusSnapshot): StatusSnapshot => {
|
|
175
|
+
if (!footerData) return input;
|
|
176
|
+
const statuses = footerData.getExtensionStatuses();
|
|
177
|
+
const extensionStatuses = statuses.size > 0 ? [...statuses].map(([key, value]) => ({ key, value })) : undefined;
|
|
178
|
+
const branch = footerData.getGitBranch();
|
|
179
|
+
const git = branch && input.git ? { ...input.git, branch } : input.git;
|
|
180
|
+
return {
|
|
181
|
+
...input,
|
|
182
|
+
...(extensionStatuses ? { extensionStatuses } : {}),
|
|
183
|
+
...(git ? { git } : {}),
|
|
184
|
+
};
|
|
185
|
+
};
|
|
186
|
+
const releaseFooterData = (): void => {
|
|
187
|
+
footerUnsubscribe?.();
|
|
188
|
+
footerUnsubscribe = undefined;
|
|
189
|
+
footerData = undefined;
|
|
190
|
+
};
|
|
191
|
+
const footerFactory: FooterFactory = (tui, _theme, data) => {
|
|
192
|
+
footerData = data;
|
|
193
|
+
footerUnsubscribe?.();
|
|
194
|
+
footerUnsubscribe = data.onBranchChange(() => {
|
|
195
|
+
primaryComponent?.invalidate();
|
|
196
|
+
secondaryComponent?.invalidate();
|
|
197
|
+
tui.requestRender?.();
|
|
198
|
+
});
|
|
199
|
+
if (options.clearOnStartup && !clearedOnStartup) {
|
|
200
|
+
clearedOnStartup = true;
|
|
201
|
+
// Force a full redraw on the first frame: clears the screen and scrollback.
|
|
202
|
+
tui.requestRender?.(true);
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
// The native footer is replaced by an empty component; visible status lives in widgets.
|
|
206
|
+
render() {
|
|
207
|
+
return [];
|
|
208
|
+
},
|
|
209
|
+
invalidate() {
|
|
210
|
+
tui.requestRender?.();
|
|
211
|
+
},
|
|
212
|
+
dispose() {
|
|
213
|
+
releaseFooterData();
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
};
|
|
217
|
+
const mountFooter = (): void => {
|
|
218
|
+
if (disposed || options.isCurrent?.() === false) return;
|
|
219
|
+
if (!config.enabled || !config.statusLine.enabled) {
|
|
220
|
+
clearFooter();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
options.host.setFooter(footerFactory);
|
|
225
|
+
footerOwner = true;
|
|
226
|
+
} catch {
|
|
227
|
+
footerOwner = false;
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
const clearFooter = (): void => {
|
|
231
|
+
if (!footerOwner) return;
|
|
232
|
+
footerOwner = false;
|
|
233
|
+
releaseFooterData();
|
|
234
|
+
try {
|
|
235
|
+
options.host.setFooter(undefined);
|
|
236
|
+
} catch {
|
|
237
|
+
// Best-effort restore; cleanup must never throw.
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
const factory =
|
|
241
|
+
(secondary: boolean): WidgetFactory =>
|
|
242
|
+
(tui, theme) => {
|
|
243
|
+
const currentTheme = theme;
|
|
244
|
+
const component: RenderComponent = {
|
|
245
|
+
render(width) {
|
|
246
|
+
const lines = render(currentTheme, width, secondary);
|
|
247
|
+
return lines;
|
|
248
|
+
},
|
|
249
|
+
invalidate() {
|
|
250
|
+
// Pi supplies a fresh theme to the factory on theme replacement. Do not retain
|
|
251
|
+
// pre-rendered ANSI strings; the next render reads the current component theme.
|
|
252
|
+
primaryComponent = secondary ? primaryComponent : component;
|
|
253
|
+
secondaryComponent = secondary ? component : secondaryComponent;
|
|
254
|
+
if (tui.requestRender) tui.requestRender();
|
|
255
|
+
},
|
|
256
|
+
dispose() {},
|
|
257
|
+
};
|
|
258
|
+
if (secondary) secondaryComponent = component;
|
|
259
|
+
else primaryComponent = component;
|
|
260
|
+
return component;
|
|
261
|
+
};
|
|
262
|
+
const claim = (key: string) => owners.set(key, { token, generation: options.generation });
|
|
263
|
+
const mount = () => {
|
|
264
|
+
if (disposed || options.isCurrent?.() === false) return;
|
|
265
|
+
if (!config.enabled || !config.statusLine.enabled) {
|
|
266
|
+
clear(PRIMARY_WIDGET_KEY);
|
|
267
|
+
clear(SECONDARY_WIDGET_KEY);
|
|
268
|
+
clearFooter();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (safeWidget(options.host, PRIMARY_WIDGET_KEY, factory(false), placementFor(config))) claim(PRIMARY_WIDGET_KEY);
|
|
272
|
+
if (safeWidget(options.host, SECONDARY_WIDGET_KEY, factory(true), "belowEditor")) claim(SECONDARY_WIDGET_KEY);
|
|
273
|
+
mountFooter();
|
|
274
|
+
};
|
|
275
|
+
function clear(key: string): void {
|
|
276
|
+
const current = owners.get(key);
|
|
277
|
+
if (current?.token !== token || current.generation !== options.generation) return;
|
|
278
|
+
if (safeWidget(options.host, key, undefined)) owners.delete(key);
|
|
279
|
+
}
|
|
280
|
+
const installation: StatusLineInstallation = {
|
|
281
|
+
generation: options.generation,
|
|
282
|
+
primaryKey: PRIMARY_WIDGET_KEY,
|
|
283
|
+
secondaryKey: SECONDARY_WIDGET_KEY,
|
|
284
|
+
update(next) {
|
|
285
|
+
if (disposed || options.isCurrent?.() === false) return;
|
|
286
|
+
snapshot = next;
|
|
287
|
+
primaryComponent?.invalidate();
|
|
288
|
+
secondaryComponent?.invalidate();
|
|
289
|
+
},
|
|
290
|
+
configure(next) {
|
|
291
|
+
if (disposed || options.isCurrent?.() === false) return;
|
|
292
|
+
const placementChanged = placementFor(next) !== placementFor(config);
|
|
293
|
+
const enabledChanged = next.enabled !== config.enabled || next.statusLine.enabled !== config.statusLine.enabled;
|
|
294
|
+
config = next;
|
|
295
|
+
if (placementChanged || enabledChanged) {
|
|
296
|
+
clear(PRIMARY_WIDGET_KEY);
|
|
297
|
+
clear(SECONDARY_WIDGET_KEY);
|
|
298
|
+
clearFooter();
|
|
299
|
+
primaryComponent = undefined;
|
|
300
|
+
secondaryComponent = undefined;
|
|
301
|
+
mount();
|
|
302
|
+
} else {
|
|
303
|
+
primaryComponent?.invalidate();
|
|
304
|
+
secondaryComponent?.invalidate();
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
dispose() {
|
|
308
|
+
if (disposed) return;
|
|
309
|
+
disposed = true;
|
|
310
|
+
clear(PRIMARY_WIDGET_KEY);
|
|
311
|
+
clear(SECONDARY_WIDGET_KEY);
|
|
312
|
+
clearFooter();
|
|
313
|
+
installationMap(options.host).delete(options.generation);
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
installationMap(options.host).set(options.generation, installation);
|
|
317
|
+
mount();
|
|
318
|
+
return installation;
|
|
319
|
+
}
|