@quandev104/pi-style 0.2.1 → 0.2.2
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 +10 -0
- package/README.md +1 -1
- package/dist/extensions/pi-style.js +632 -262
- package/dist/extensions/pi-style.js.map +1 -1
- package/extension-src/pi-style/app/runtime.ts +29 -4
- package/extension-src/pi-style/domain/status-renderer.ts +40 -8
- package/extension-src/pi-style/domain/status.ts +15 -5
- package/extension-src/pi-style/domain/theme.ts +32 -1
- package/extension-src/pi-style/features/editor/index.ts +17 -5
- package/extension-src/pi-style/features/messages/index.ts +302 -61
- package/extension-src/pi-style/features/status-line/index.ts +41 -11
- package/extension-src/pi-style/features/tools/boxed/bash.ts +101 -40
- package/extension-src/pi-style/features/tools/boxed/batch.ts +17 -1
- package/extension-src/pi-style/features/tools/boxed/edit.ts +20 -15
- package/extension-src/pi-style/features/tools/boxed/find.ts +9 -4
- package/extension-src/pi-style/features/tools/boxed/git.ts +46 -2
- package/extension-src/pi-style/features/tools/boxed/grep.ts +9 -2
- package/extension-src/pi-style/features/tools/boxed/ls.ts +9 -4
- package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +18 -11
- package/extension-src/pi-style/features/tools/boxed/read.ts +4 -2
- package/extension-src/pi-style/features/tools/boxed/shared.ts +27 -1
- package/extension-src/pi-style/features/tools/boxed/turn-summary.ts +12 -0
- package/extension-src/pi-style/pi/index.ts +2 -0
- package/extension-src/pi-style/shared/ansi.ts +17 -5
- package/extension-src/pi-style/shared/box.ts +70 -4
- package/extension-src/pi-style/shared/split-diff.ts +8 -5
- package/package.json +1 -1
|
@@ -18,6 +18,9 @@ import { CachedGitProvider, InMemoryContextProvider, InMemoryUsageProvider } fro
|
|
|
18
18
|
import { RenderScheduler } from "./render-scheduler.js";
|
|
19
19
|
import { createSnapshot, replaceSnapshot, type UiSnapshot } from "./snapshot.js";
|
|
20
20
|
|
|
21
|
+
/** Debounce window for coalescing git refresh spawns after invalidateGit(). */
|
|
22
|
+
const GIT_INVALIDATE_DEBOUNCE_MS = 250;
|
|
23
|
+
|
|
21
24
|
export interface RuntimeInstallationState {
|
|
22
25
|
readonly status: "installed" | "disabled" | "failed";
|
|
23
26
|
readonly editor: "installed" | "preserved" | "disabled" | "failed";
|
|
@@ -94,6 +97,11 @@ export function createPiStyleRuntime(
|
|
|
94
97
|
const disposables = new DisposableStore();
|
|
95
98
|
const scheduler = new RenderScheduler({ requestRender }, generation, () => !disposed);
|
|
96
99
|
const git = new CachedGitProvider(host.gitRunner);
|
|
100
|
+
// Debounced git refresh (invalidateGit): tool-result bursts fire one
|
|
101
|
+
// invalidateGit per write/edit/bash, each of which would otherwise spawn a
|
|
102
|
+
// `git status` process. Only one refresh may be pending at a time; the
|
|
103
|
+
// handle is unreffed so it can never keep the process alive.
|
|
104
|
+
let pendingGitRefresh: ReturnType<typeof setTimeout> | undefined;
|
|
97
105
|
const contextProvider = new InMemoryContextProvider();
|
|
98
106
|
const usageProvider = new InMemoryUsageProvider();
|
|
99
107
|
const initialContext = contextSnapshot();
|
|
@@ -345,11 +353,24 @@ export function createPiStyleRuntime(
|
|
|
345
353
|
},
|
|
346
354
|
invalidateGit() {
|
|
347
355
|
if (disposed || !host.cwd || !currentConfig.enabled || !currentConfig.statusLine.enabled) return;
|
|
348
|
-
|
|
349
|
-
|
|
356
|
+
const cwd = host.cwd;
|
|
357
|
+
// Mark the cache stale immediately (cheap, no spawn); the actual
|
|
358
|
+
// `git status` process spawn is coalesced behind a short debounce so
|
|
359
|
+
// bursts of tool results trigger ONE refresh, not one per event.
|
|
360
|
+
// CachedGitProvider serializes concurrent gets via entry.promise and
|
|
361
|
+
// honors invalidate-during-flight (needsRefresh re-runs the fetch),
|
|
362
|
+
// so this debounce only reduces spawn count and never loses a signal.
|
|
363
|
+
git.invalidate(cwd);
|
|
364
|
+
if (pendingGitRefresh !== undefined) return;
|
|
365
|
+
pendingGitRefresh = setTimeout(() => {
|
|
366
|
+
pendingGitRefresh = undefined;
|
|
350
367
|
if (disposed) return;
|
|
351
|
-
|
|
352
|
-
|
|
368
|
+
void git.get(cwd).then((value) => {
|
|
369
|
+
if (disposed) return;
|
|
370
|
+
if (updateSnapshot(withSnapshotPatch({ git: value }))) requestRender();
|
|
371
|
+
});
|
|
372
|
+
}, GIT_INVALIDATE_DEBOUNCE_MS);
|
|
373
|
+
pendingGitRefresh.unref?.();
|
|
353
374
|
},
|
|
354
375
|
get disposed() {
|
|
355
376
|
return disposed;
|
|
@@ -357,6 +378,10 @@ export function createPiStyleRuntime(
|
|
|
357
378
|
dispose() {
|
|
358
379
|
if (disposed) return;
|
|
359
380
|
disposed = true;
|
|
381
|
+
if (pendingGitRefresh !== undefined) {
|
|
382
|
+
clearTimeout(pendingGitRefresh);
|
|
383
|
+
pendingGitRefresh = undefined;
|
|
384
|
+
}
|
|
360
385
|
scheduler.cancel();
|
|
361
386
|
// UI surfaces are restored synchronously so teardown is deterministic;
|
|
362
387
|
// the store then disposes the (already disposed) feature instances idempotently.
|
|
@@ -25,6 +25,8 @@ interface Candidate {
|
|
|
25
25
|
readonly segment: StatusSegment;
|
|
26
26
|
readonly result: SegmentRenderResult;
|
|
27
27
|
content: string;
|
|
28
|
+
/** Visible width of `content`; updated when the compact form is swapped in. */
|
|
29
|
+
contentWidth: number;
|
|
28
30
|
compact: boolean;
|
|
29
31
|
moved: boolean;
|
|
30
32
|
}
|
|
@@ -47,10 +49,6 @@ function renderGroup(items: readonly Candidate[], separator: string, padding: st
|
|
|
47
49
|
.join(`${padding}${separator}${padding}`);
|
|
48
50
|
}
|
|
49
51
|
|
|
50
|
-
function widthOf(items: readonly Candidate[], separator: string, padding: string): number {
|
|
51
|
-
return visibleWidth(renderGroup(items, separator, padding));
|
|
52
|
-
}
|
|
53
|
-
|
|
54
52
|
export function renderStatus(
|
|
55
53
|
layout: StatusLayout,
|
|
56
54
|
snapshot: StatusSnapshot,
|
|
@@ -70,7 +68,15 @@ export function renderStatus(
|
|
|
70
68
|
try {
|
|
71
69
|
const result = segment.render(context);
|
|
72
70
|
if (!result.visible || !result.content) continue;
|
|
73
|
-
candidates.set(id, {
|
|
71
|
+
candidates.set(id, {
|
|
72
|
+
id,
|
|
73
|
+
segment,
|
|
74
|
+
result,
|
|
75
|
+
content: result.content,
|
|
76
|
+
contentWidth: visibleWidth(result.content),
|
|
77
|
+
compact: false,
|
|
78
|
+
moved: false,
|
|
79
|
+
});
|
|
74
80
|
} catch {
|
|
75
81
|
// A broken optional segment must not break the status row.
|
|
76
82
|
}
|
|
@@ -88,13 +94,34 @@ export function renderStatus(
|
|
|
88
94
|
.filter((candidate): candidate is Candidate => candidate !== undefined);
|
|
89
95
|
const visible: Candidate[] = [];
|
|
90
96
|
const overflow: Candidate[] = [];
|
|
97
|
+
// Incremental fit tracking: the rendered width of a group is the sum of the
|
|
98
|
+
// member content widths plus one separator gap between consecutive members
|
|
99
|
+
// (every join boundary is broken by the separator string, so visible widths
|
|
100
|
+
// add up). This replaces re-joining and re-measuring the whole group after
|
|
101
|
+
// every push, which made the overflow loop quadratic in segment count.
|
|
102
|
+
const gapWidth = visibleWidth(`${padding}${separator}${padding}`);
|
|
103
|
+
let groupWidth = 0;
|
|
104
|
+
let groupCount = 0;
|
|
105
|
+
const widthAfter = (baseWidth: number, count: number, candidate: Candidate): number =>
|
|
106
|
+
count === 0 ? candidate.contentWidth : baseWidth + gapWidth + candidate.contentWidth;
|
|
91
107
|
for (const candidate of primary) {
|
|
92
108
|
visible.push(candidate);
|
|
93
|
-
|
|
109
|
+
const pushedWidth = widthAfter(groupWidth, groupCount, candidate);
|
|
110
|
+
if (pushedWidth <= width) {
|
|
111
|
+
groupWidth = pushedWidth;
|
|
112
|
+
groupCount++;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
94
115
|
if (candidate.result.compactContent && !candidate.compact) {
|
|
95
116
|
candidate.content = candidate.result.compactContent;
|
|
96
117
|
candidate.compact = true;
|
|
97
|
-
|
|
118
|
+
candidate.contentWidth = visibleWidth(candidate.content);
|
|
119
|
+
const compactedWidth = widthAfter(groupWidth, groupCount, candidate);
|
|
120
|
+
if (compactedWidth <= width) {
|
|
121
|
+
groupWidth = compactedWidth;
|
|
122
|
+
groupCount++;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
98
125
|
}
|
|
99
126
|
visible.pop();
|
|
100
127
|
if (candidate.segment.overflow !== "drop" && candidate.segment.overflow !== "primary") {
|
|
@@ -127,9 +154,14 @@ export function renderStatus(
|
|
|
127
154
|
}
|
|
128
155
|
if (visibleWidth(primaryText) > width) primaryText = truncateAnsi(primaryText, width);
|
|
129
156
|
const secondaryVisible: Candidate[] = [];
|
|
157
|
+
let secondaryWidth = 0;
|
|
158
|
+
let secondaryCount = 0;
|
|
130
159
|
for (const candidate of [...secondary].sort((a, b) => b.segment.defaultPriority - a.segment.defaultPriority)) {
|
|
160
|
+
const pushedWidth = widthAfter(secondaryWidth, secondaryCount, candidate);
|
|
161
|
+
if (pushedWidth > width) continue;
|
|
131
162
|
secondaryVisible.push(candidate);
|
|
132
|
-
|
|
163
|
+
secondaryWidth = pushedWidth;
|
|
164
|
+
secondaryCount++;
|
|
133
165
|
}
|
|
134
166
|
const secondaryText = renderGroup(secondaryVisible, separator, padding);
|
|
135
167
|
const lines = secondaryText ? [primaryText, secondaryText] : primaryText ? [primaryText] : [];
|
|
@@ -308,11 +308,10 @@ export function createBuiltinSegments(): ReadonlyMap<StatusSegmentId, StatusSegm
|
|
|
308
308
|
? ""
|
|
309
309
|
: theme.apply("time", formatElapsed(Date.now() - snapshot.sessionStartedAt)),
|
|
310
310
|
})),
|
|
311
|
-
segment("time", 20, ({ theme }) =>
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
})),
|
|
311
|
+
segment("time", 20, ({ theme }) => {
|
|
312
|
+
const text = theme.apply("time", clockTime());
|
|
313
|
+
return { visible: true, content: text, compactContent: text };
|
|
314
|
+
}),
|
|
316
315
|
segment("hostname", 20, ({ snapshot, theme }) => ({
|
|
317
316
|
visible: Boolean(snapshot.hostname),
|
|
318
317
|
content: theme.apply("muted", snapshot.hostname ?? ""),
|
|
@@ -337,6 +336,17 @@ export function createBuiltinSegments(): ReadonlyMap<StatusSegmentId, StatusSegm
|
|
|
337
336
|
return new Map(segments.map((item) => [item.id, item]));
|
|
338
337
|
}
|
|
339
338
|
|
|
339
|
+
/** Cached clock text: the minute-precision string only changes once per minute. */
|
|
340
|
+
let clockCache: { minuteKey: number; value: string } | undefined;
|
|
341
|
+
function clockTime(): string {
|
|
342
|
+
const now = new Date();
|
|
343
|
+
const minuteKey = now.getHours() * 60 + now.getMinutes();
|
|
344
|
+
if (clockCache?.minuteKey !== minuteKey) {
|
|
345
|
+
clockCache = { minuteKey, value: now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) };
|
|
346
|
+
}
|
|
347
|
+
return clockCache.value;
|
|
348
|
+
}
|
|
349
|
+
|
|
340
350
|
const CONTEXT_BAR_WIDTH = 10;
|
|
341
351
|
|
|
342
352
|
function contextBar(percent: number, width = CONTEXT_BAR_WIDTH): string {
|
|
@@ -200,10 +200,32 @@ function colorPrefixFor(
|
|
|
200
200
|
return "";
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
function envKeyFor(env: Record<string, string | undefined>): string {
|
|
204
|
+
return `${env.PI_STYLE_NERD_FONTS ?? ""}\u0000${env.GHOSTTY_RESOURCES_DIR ? "1" : "0"}\u0000${env.TERM_PROGRAM ?? ""}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const glyphModeCache = new WeakMap<object, Map<string, GlyphMode>>();
|
|
208
|
+
|
|
203
209
|
export function detectGlyphMode(
|
|
204
210
|
config: NormalizedPiStyleConfig,
|
|
205
211
|
env: Record<string, string | undefined> = {},
|
|
206
212
|
): GlyphMode {
|
|
213
|
+
// Memoized per config object; the env-derived key keeps the result correct
|
|
214
|
+
// when the same config is resolved against different environments.
|
|
215
|
+
let byEnv = glyphModeCache.get(config);
|
|
216
|
+
if (!byEnv) {
|
|
217
|
+
byEnv = new Map();
|
|
218
|
+
glyphModeCache.set(config, byEnv);
|
|
219
|
+
}
|
|
220
|
+
const envKey = envKeyFor(env);
|
|
221
|
+
const cached = byEnv.get(envKey);
|
|
222
|
+
if (cached !== undefined) return cached;
|
|
223
|
+
const mode = computeGlyphMode(config, env);
|
|
224
|
+
byEnv.set(envKey, mode);
|
|
225
|
+
return mode;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function computeGlyphMode(config: NormalizedPiStyleConfig, env: Record<string, string | undefined>): GlyphMode {
|
|
207
229
|
if (env.PI_STYLE_NERD_FONTS === "1") return "nerd";
|
|
208
230
|
if (env.PI_STYLE_NERD_FONTS === "0") return "unicode";
|
|
209
231
|
if (config.theme.nerdFonts === "on") return "nerd";
|
|
@@ -224,7 +246,16 @@ export function resolveTheme(
|
|
|
224
246
|
): ResolvedTheme {
|
|
225
247
|
const noColor = Object.hasOwn(env, "NO_COLOR") && env.NO_COLOR !== "" && config.theme.colors.colorOverride !== "on";
|
|
226
248
|
const mode = config.preset === "ascii" ? "ascii" : detectGlyphMode(config, env);
|
|
227
|
-
|
|
249
|
+
// Lazy per-instance prefix memo: repeated apply(token, ...) calls stop
|
|
250
|
+
// re-running colorPrefixFor (and Pi's active.fg()) for the same token.
|
|
251
|
+
const prefixes = new Map<SemanticToken, string>();
|
|
252
|
+
const color = (token: SemanticToken): string => {
|
|
253
|
+
const cached = prefixes.get(token);
|
|
254
|
+
if (cached !== undefined) return cached;
|
|
255
|
+
const prefix = colorPrefixFor(active, config, noColor, token);
|
|
256
|
+
prefixes.set(token, prefix);
|
|
257
|
+
return prefix;
|
|
258
|
+
};
|
|
228
259
|
return {
|
|
229
260
|
mode,
|
|
230
261
|
noColor,
|
|
@@ -56,9 +56,13 @@ const widthOf = visibleWidth;
|
|
|
56
56
|
|
|
57
57
|
function widthSafe(value: string, width: number): string {
|
|
58
58
|
if (width <= 0) return "";
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
const measured = widthOf(value);
|
|
60
|
+
if (measured > width) {
|
|
61
|
+
const fitted = truncateAnsi(value, width, "");
|
|
62
|
+
const current = widthOf(fitted);
|
|
63
|
+
return current < width ? fitted + " ".repeat(width - current) : fitted;
|
|
64
|
+
}
|
|
65
|
+
return measured < width ? value + " ".repeat(width - measured) : value;
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
function isNativeBorderLine(line: string): boolean {
|
|
@@ -142,6 +146,8 @@ export class StyledEditor extends CustomEditor implements EditorComponent {
|
|
|
142
146
|
private readonly fullTheme: EditorOptions["fullTheme"];
|
|
143
147
|
private readonly onSnapshot: (snapshot: StatusSnapshot) => void;
|
|
144
148
|
private semantic: ResolvedTheme;
|
|
149
|
+
/** Set when config changes; invalidate() then rebuilds the (otherwise stable) semantic theme. */
|
|
150
|
+
private semanticDirty = false;
|
|
145
151
|
private disposed = false;
|
|
146
152
|
private renderPlanCache: { key: string; plan: RenderPlan } | undefined;
|
|
147
153
|
|
|
@@ -165,7 +171,7 @@ export class StyledEditor extends CustomEditor implements EditorComponent {
|
|
|
165
171
|
configure(config: NormalizedPiStyleConfig): void {
|
|
166
172
|
if (this.disposed) return;
|
|
167
173
|
this.config = config;
|
|
168
|
-
this.
|
|
174
|
+
this.semanticDirty = true;
|
|
169
175
|
this.invalidate();
|
|
170
176
|
}
|
|
171
177
|
|
|
@@ -177,7 +183,13 @@ export class StyledEditor extends CustomEditor implements EditorComponent {
|
|
|
177
183
|
|
|
178
184
|
override invalidate(): void {
|
|
179
185
|
super.invalidate();
|
|
180
|
-
|
|
186
|
+
// The semantic theme is derived from (piTheme, config); piTheme is fixed per
|
|
187
|
+
// instance and config changes go through configure(), so keystroke-driven
|
|
188
|
+
// invalidations reuse the cached instance instead of rebuilding it.
|
|
189
|
+
if (this.semanticDirty) {
|
|
190
|
+
this.semantic = semanticTheme(this.piTheme, this.config);
|
|
191
|
+
this.semanticDirty = false;
|
|
192
|
+
}
|
|
181
193
|
this.renderPlanCache = undefined;
|
|
182
194
|
this.tui.requestRender();
|
|
183
195
|
}
|