@quandev104/pi-style 0.2.0 → 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 +16 -0
- package/README.md +1 -1
- package/dist/extensions/pi-style.js +1256 -491
- package/dist/extensions/pi-style.js.map +1 -1
- package/extension-src/pi-style/app/index.ts +3 -2
- package/extension-src/pi-style/app/runtime.ts +99 -86
- package/extension-src/pi-style/app/snapshot.ts +41 -2
- 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 +97 -66
- package/extension-src/pi-style/features/messages/index.ts +469 -90
- package/extension-src/pi-style/features/status-line/index.ts +41 -11
- package/extension-src/pi-style/features/tools/bash-execution.ts +12 -1
- package/extension-src/pi-style/features/tools/boxed/bash.ts +195 -66
- package/extension-src/pi-style/features/tools/boxed/batch.ts +60 -10
- package/extension-src/pi-style/features/tools/boxed/edit.ts +45 -25
- 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 +45 -22
- package/extension-src/pi-style/features/tools/boxed/read.ts +4 -2
- package/extension-src/pi-style/features/tools/boxed/session-config.ts +45 -14
- package/extension-src/pi-style/features/tools/boxed/shared.ts +51 -0
- package/extension-src/pi-style/features/tools/boxed/turn-summary.ts +12 -0
- package/extension-src/pi-style/pi/compatibility-probe.ts +1 -1
- package/extension-src/pi-style/pi/index.ts +28 -13
- package/extension-src/pi-style/pi/session-usage.ts +204 -21
- package/extension-src/pi-style/shared/ansi.ts +17 -5
- package/extension-src/pi-style/shared/box.ts +83 -6
- package/extension-src/pi-style/shared/split-diff.ts +8 -5
- package/package.json +1 -1
|
@@ -96,6 +96,7 @@ export interface PiStyleApp {
|
|
|
96
96
|
update(
|
|
97
97
|
values: import("../domain/status.js").StatusSnapshot,
|
|
98
98
|
kind?: import("./render-scheduler.js").UpdateClass,
|
|
99
|
+
options?: import("./runtime.js").RuntimeUpdateOptions,
|
|
99
100
|
): void;
|
|
100
101
|
}
|
|
101
102
|
|
|
@@ -268,10 +269,10 @@ export function createPiStyleApp(
|
|
|
268
269
|
sessionShutdown() {
|
|
269
270
|
runtime.stop();
|
|
270
271
|
},
|
|
271
|
-
update(values, kind = "coalesced") {
|
|
272
|
+
update(values, kind = "coalesced", options) {
|
|
272
273
|
const active = runtime.current;
|
|
273
274
|
if (!active) return;
|
|
274
|
-
active.update(values);
|
|
275
|
+
if (!active.update(values, options)) return;
|
|
275
276
|
active.scheduler.schedule(kind);
|
|
276
277
|
},
|
|
277
278
|
reload() {
|
|
@@ -2,7 +2,7 @@ import type { ExtensionContext, ExtensionUIContext } from "@earendil-works/pi-co
|
|
|
2
2
|
import { diffConfig } from "../domain/config-diff.js";
|
|
3
3
|
import type { NormalizedPiStyleConfig } from "../domain/config-types.js";
|
|
4
4
|
import type { GitCommandRunner } from "../domain/providers.js";
|
|
5
|
-
import type { StatusSnapshot } from "../domain/status.js";
|
|
5
|
+
import type { ContextSnapshot, StatusSnapshot } from "../domain/status.js";
|
|
6
6
|
import { normalizeThinkingLevel } from "../domain/status.js";
|
|
7
7
|
import { installEditor } from "../features/editor/index.js";
|
|
8
8
|
import {
|
|
@@ -18,12 +18,20 @@ 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";
|
|
24
27
|
readonly startup: "installed" | "disabled" | "failed";
|
|
25
28
|
}
|
|
26
29
|
|
|
30
|
+
export interface RuntimeUpdateOptions {
|
|
31
|
+
readonly refreshContextUsage?: boolean;
|
|
32
|
+
readonly refreshExtensionStatuses?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
export interface PiStyleRuntime {
|
|
28
36
|
generation: number;
|
|
29
37
|
readonly providerIdentity: { git: object; context: object; usage: object };
|
|
@@ -34,8 +42,8 @@ export interface PiStyleRuntime {
|
|
|
34
42
|
snapshot: UiSnapshot;
|
|
35
43
|
disposables: DisposableStore;
|
|
36
44
|
scheduler: RenderScheduler;
|
|
37
|
-
update(values: StatusSnapshot):
|
|
38
|
-
updateStartupResources(resources: StartupResources):
|
|
45
|
+
update(values: StatusSnapshot, options?: RuntimeUpdateOptions): boolean;
|
|
46
|
+
updateStartupResources(resources: StartupResources): boolean;
|
|
39
47
|
dismissStartup(): void;
|
|
40
48
|
invalidateGit(): void;
|
|
41
49
|
configure(config: NormalizedPiStyleConfig): void;
|
|
@@ -64,7 +72,7 @@ export interface RuntimeHost {
|
|
|
64
72
|
export function createPiStyleRuntime(
|
|
65
73
|
host: RuntimeHost,
|
|
66
74
|
generation: number,
|
|
67
|
-
requestRender: () => void = () => {},
|
|
75
|
+
requestRender: () => void = host.requestRender ?? (() => {}),
|
|
68
76
|
): PiStyleRuntime {
|
|
69
77
|
let disposed = false;
|
|
70
78
|
const extensionStatuses = (): readonly import("../domain/status.js").ExtensionStatus[] | undefined => {
|
|
@@ -76,13 +84,27 @@ export function createPiStyleRuntime(
|
|
|
76
84
|
return [];
|
|
77
85
|
}
|
|
78
86
|
};
|
|
87
|
+
const contextSnapshot = (): ContextSnapshot | undefined => {
|
|
88
|
+
const usage = host.getContextUsage?.();
|
|
89
|
+
if (!usage) return undefined;
|
|
90
|
+
return {
|
|
91
|
+
...(usage.tokens !== null ? { currentTokens: usage.tokens } : {}),
|
|
92
|
+
windowTokens: usage.contextWindow,
|
|
93
|
+
...(usage.percent !== null ? { percent: usage.percent } : {}),
|
|
94
|
+
};
|
|
95
|
+
};
|
|
79
96
|
let currentConfig = host.config;
|
|
80
97
|
const disposables = new DisposableStore();
|
|
81
98
|
const scheduler = new RenderScheduler({ requestRender }, generation, () => !disposed);
|
|
82
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;
|
|
83
105
|
const contextProvider = new InMemoryContextProvider();
|
|
84
106
|
const usageProvider = new InMemoryUsageProvider();
|
|
85
|
-
const
|
|
107
|
+
const initialContext = contextSnapshot();
|
|
86
108
|
const initialStatuses = extensionStatuses();
|
|
87
109
|
const initialValues = {
|
|
88
110
|
...(initialStatuses ? { extensionStatuses: initialStatuses } : {}),
|
|
@@ -91,15 +113,7 @@ export function createPiStyleRuntime(
|
|
|
91
113
|
...(host.model?.reasoning !== undefined ? { reasoning: host.model.reasoning } : {}),
|
|
92
114
|
...(host.thinkingLevel ? { thinkingLevel: normalizeThinkingLevel(host.thinkingLevel) } : {}),
|
|
93
115
|
...(host.cwd ? { cwd: host.cwd } : {}),
|
|
94
|
-
...(
|
|
95
|
-
? {
|
|
96
|
-
context: {
|
|
97
|
-
...(usage.tokens !== null ? { currentTokens: usage.tokens } : {}),
|
|
98
|
-
windowTokens: usage.contextWindow,
|
|
99
|
-
...(usage.percent !== null ? { percent: usage.percent } : {}),
|
|
100
|
-
},
|
|
101
|
-
}
|
|
102
|
-
: {}),
|
|
116
|
+
...(initialContext ? { context: initialContext } : {}),
|
|
103
117
|
};
|
|
104
118
|
let currentSnapshot = createSnapshot(generation, 0, initialValues);
|
|
105
119
|
let statusLine: ReturnType<typeof installStatusLine> | undefined;
|
|
@@ -110,14 +124,41 @@ export function createPiStyleRuntime(
|
|
|
110
124
|
editor: "disabled",
|
|
111
125
|
startup: "disabled",
|
|
112
126
|
};
|
|
113
|
-
const
|
|
127
|
+
const snapshotValues = (snapshot: UiSnapshot): StatusSnapshot => {
|
|
128
|
+
const { generation: _generation, revision: _revision, ...values } = snapshot;
|
|
129
|
+
return values;
|
|
130
|
+
};
|
|
131
|
+
const withSnapshotPatch = (
|
|
132
|
+
patch: Partial<Record<keyof StatusSnapshot, StatusSnapshot[keyof StatusSnapshot] | undefined>>,
|
|
133
|
+
): StatusSnapshot => {
|
|
134
|
+
const next = { ...snapshotValues(currentSnapshot) } as Record<string, unknown>;
|
|
135
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
136
|
+
if (value === undefined) delete next[key];
|
|
137
|
+
else next[key] = value;
|
|
138
|
+
}
|
|
139
|
+
return next as StatusSnapshot;
|
|
140
|
+
};
|
|
141
|
+
const createStartupSnapshot = (
|
|
142
|
+
config: NormalizedPiStyleConfig,
|
|
143
|
+
resources: StartupResources | undefined = host.resources,
|
|
144
|
+
): StartupSnapshot => ({
|
|
114
145
|
...currentSnapshot,
|
|
115
146
|
reason: host.startupReason ?? "startup",
|
|
116
147
|
...(host.provider ? { startupProvider: host.provider } : {}),
|
|
117
148
|
...(host.cwd ? { project: host.cwd.split(/[\\/]/).filter(Boolean).at(-1) } : {}),
|
|
118
149
|
preset: config.preset,
|
|
119
|
-
...(
|
|
150
|
+
...(resources ? { resources } : {}),
|
|
120
151
|
});
|
|
152
|
+
const applySnapshot = (nextSnapshot: UiSnapshot): boolean => {
|
|
153
|
+
if (nextSnapshot === currentSnapshot) return false;
|
|
154
|
+
currentSnapshot = nextSnapshot;
|
|
155
|
+
statusLine?.update(currentSnapshot);
|
|
156
|
+
editor?.update(currentSnapshot);
|
|
157
|
+
startup?.update(createStartupSnapshot(currentConfig));
|
|
158
|
+
return true;
|
|
159
|
+
};
|
|
160
|
+
const updateSnapshot = (values: StatusSnapshot): boolean =>
|
|
161
|
+
applySnapshot(replaceSnapshot(currentSnapshot, generation, values));
|
|
121
162
|
const installStatus = () => {
|
|
122
163
|
if (!host.hasUI || host.mode !== "tui" || !host.ui || !currentConfig.enabled || !currentConfig.statusLine.enabled) {
|
|
123
164
|
installationState = { ...installationState, status: "disabled" };
|
|
@@ -186,7 +227,7 @@ export function createPiStyleRuntime(
|
|
|
186
227
|
startup = installStartup({
|
|
187
228
|
host: { ...(host.ui as unknown as StartupHost), mode: host.mode, hasUI: host.hasUI },
|
|
188
229
|
config: currentConfig,
|
|
189
|
-
snapshot:
|
|
230
|
+
snapshot: createStartupSnapshot(currentConfig),
|
|
190
231
|
generation,
|
|
191
232
|
requestRender,
|
|
192
233
|
timeoutMs: 3000,
|
|
@@ -235,27 +276,10 @@ export function createPiStyleRuntime(
|
|
|
235
276
|
if (host.cwd && currentConfig.enabled && currentConfig.statusLine.enabled) {
|
|
236
277
|
void git.get(host.cwd).then((value) => {
|
|
237
278
|
if (disposed) return;
|
|
238
|
-
|
|
239
|
-
statusLine?.update(currentSnapshot);
|
|
240
|
-
editor?.update(currentSnapshot);
|
|
241
|
-
startup?.update({
|
|
242
|
-
...currentSnapshot,
|
|
243
|
-
reason: host.startupReason ?? "startup",
|
|
244
|
-
...(host.provider ? { startupProvider: host.provider } : {}),
|
|
245
|
-
...(host.cwd ? { project: host.cwd.split(/[\\/]/).filter(Boolean).at(-1) } : {}),
|
|
246
|
-
preset: currentConfig.preset,
|
|
247
|
-
...(host.resources ? { resources: host.resources } : {}),
|
|
248
|
-
});
|
|
249
|
-
requestRender();
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
if (usage) {
|
|
253
|
-
contextProvider.set("active", {
|
|
254
|
-
...(usage.tokens !== null ? { currentTokens: usage.tokens } : {}),
|
|
255
|
-
windowTokens: usage.contextWindow,
|
|
256
|
-
...(usage.percent !== null ? { percent: usage.percent } : {}),
|
|
279
|
+
if (updateSnapshot(withSnapshotPatch({ git: value }))) requestRender();
|
|
257
280
|
});
|
|
258
281
|
}
|
|
282
|
+
if (initialContext) contextProvider.set("active", initialContext);
|
|
259
283
|
return {
|
|
260
284
|
generation,
|
|
261
285
|
providerIdentity: { git, context: contextProvider, usage: usageProvider },
|
|
@@ -264,51 +288,33 @@ export function createPiStyleRuntime(
|
|
|
264
288
|
},
|
|
265
289
|
mode: host.mode,
|
|
266
290
|
hasUI: host.hasUI,
|
|
267
|
-
snapshot
|
|
291
|
+
get snapshot() {
|
|
292
|
+
return currentSnapshot;
|
|
293
|
+
},
|
|
268
294
|
disposables,
|
|
269
295
|
scheduler,
|
|
270
296
|
updateStartupResources(resources) {
|
|
271
|
-
if (disposed) return;
|
|
272
|
-
startup
|
|
273
|
-
...currentSnapshot,
|
|
274
|
-
reason: host.startupReason ?? "startup",
|
|
275
|
-
...(host.provider ? { startupProvider: host.provider } : {}),
|
|
276
|
-
...(host.cwd ? { project: host.cwd.split(/[\\/]/).filter(Boolean).at(-1) } : {}),
|
|
277
|
-
preset: currentConfig.preset,
|
|
278
|
-
resources,
|
|
279
|
-
});
|
|
297
|
+
if (disposed || !startup) return false;
|
|
298
|
+
startup.update(createStartupSnapshot(currentConfig, resources));
|
|
280
299
|
requestRender();
|
|
300
|
+
return true;
|
|
281
301
|
},
|
|
282
302
|
dismissStartup() {
|
|
283
303
|
startup?.dismiss();
|
|
284
304
|
},
|
|
285
|
-
update(values) {
|
|
286
|
-
if (disposed) return;
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
...values,
|
|
299
|
-
...(context ? { context } : {}),
|
|
300
|
-
...(statuses ? { extensionStatuses: statuses } : {}),
|
|
301
|
-
});
|
|
302
|
-
statusLine?.update(currentSnapshot);
|
|
303
|
-
editor?.update(currentSnapshot);
|
|
304
|
-
startup?.update({
|
|
305
|
-
...currentSnapshot,
|
|
306
|
-
reason: host.startupReason ?? "startup",
|
|
307
|
-
...(host.provider ? { startupProvider: host.provider } : {}),
|
|
308
|
-
...(host.cwd ? { project: host.cwd.split(/[\\/]/).filter(Boolean).at(-1) } : {}),
|
|
309
|
-
preset: currentConfig.preset,
|
|
310
|
-
...(host.resources ? { resources: host.resources } : {}),
|
|
311
|
-
});
|
|
305
|
+
update(values, options = {}) {
|
|
306
|
+
if (disposed) return false;
|
|
307
|
+
const patch: Partial<Record<keyof StatusSnapshot, StatusSnapshot[keyof StatusSnapshot] | undefined>> = {
|
|
308
|
+
...(values as Partial<Record<keyof StatusSnapshot, StatusSnapshot[keyof StatusSnapshot] | undefined>>),
|
|
309
|
+
};
|
|
310
|
+
if (options.refreshContextUsage) {
|
|
311
|
+
const context = contextSnapshot();
|
|
312
|
+
patch.context = context;
|
|
313
|
+
if (context) contextProvider.set("active", context);
|
|
314
|
+
else contextProvider.clear();
|
|
315
|
+
}
|
|
316
|
+
if (options.refreshExtensionStatuses) patch.extensionStatuses = extensionStatuses();
|
|
317
|
+
return updateSnapshot(withSnapshotPatch(patch));
|
|
312
318
|
},
|
|
313
319
|
configure(nextConfig) {
|
|
314
320
|
if (disposed) return;
|
|
@@ -347,21 +353,24 @@ export function createPiStyleRuntime(
|
|
|
347
353
|
},
|
|
348
354
|
invalidateGit() {
|
|
349
355
|
if (disposed || !host.cwd || !currentConfig.enabled || !currentConfig.statusLine.enabled) return;
|
|
350
|
-
|
|
351
|
-
|
|
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;
|
|
352
367
|
if (disposed) return;
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
startup?.update({
|
|
357
|
-
...currentSnapshot,
|
|
358
|
-
reason: host.startupReason ?? "startup",
|
|
359
|
-
...(host.provider ? { startupProvider: host.provider } : {}),
|
|
360
|
-
...(host.cwd ? { project: host.cwd.split(/[\\/]/).filter(Boolean).at(-1) } : {}),
|
|
361
|
-
preset: currentConfig.preset,
|
|
368
|
+
void git.get(cwd).then((value) => {
|
|
369
|
+
if (disposed) return;
|
|
370
|
+
if (updateSnapshot(withSnapshotPatch({ git: value }))) requestRender();
|
|
362
371
|
});
|
|
363
|
-
|
|
364
|
-
|
|
372
|
+
}, GIT_INVALIDATE_DEBOUNCE_MS);
|
|
373
|
+
pendingGitRefresh.unref?.();
|
|
365
374
|
},
|
|
366
375
|
get disposed() {
|
|
367
376
|
return disposed;
|
|
@@ -369,6 +378,10 @@ export function createPiStyleRuntime(
|
|
|
369
378
|
dispose() {
|
|
370
379
|
if (disposed) return;
|
|
371
380
|
disposed = true;
|
|
381
|
+
if (pendingGitRefresh !== undefined) {
|
|
382
|
+
clearTimeout(pendingGitRefresh);
|
|
383
|
+
pendingGitRefresh = undefined;
|
|
384
|
+
}
|
|
372
385
|
scheduler.cancel();
|
|
373
386
|
// UI surfaces are restored synchronously so teardown is deterministic;
|
|
374
387
|
// the store then disposes the (already disposed) feature instances idempotently.
|
|
@@ -6,10 +6,49 @@ export interface UiSnapshot extends StatusSnapshot {
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
export function createSnapshot(generation: number, revision = 0, values: StatusSnapshot = {}): UiSnapshot {
|
|
9
|
-
return Object.freeze({ generation, revision
|
|
9
|
+
return Object.freeze({ ...values, generation, revision });
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export function replaceSnapshot(current: UiSnapshot, generation: number, values: StatusSnapshot): UiSnapshot {
|
|
13
13
|
if (current.generation !== generation) return current;
|
|
14
|
-
return createSnapshot(generation, current.revision + 1, values);
|
|
14
|
+
return equalStatusSnapshot(current, values) ? current : createSnapshot(generation, current.revision + 1, values);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function equalStatusSnapshot(current: StatusSnapshot, next: StatusSnapshot): boolean {
|
|
18
|
+
const currentEntries = Object.entries(current).filter(([key]) => key !== "generation" && key !== "revision");
|
|
19
|
+
const nextEntries = Object.entries(next).filter(([key]) => key !== "generation" && key !== "revision");
|
|
20
|
+
if (currentEntries.length !== nextEntries.length) return false;
|
|
21
|
+
for (const [key, value] of nextEntries) {
|
|
22
|
+
if (!hasOwn(current, key)) return false;
|
|
23
|
+
if (!equalValue((current as Record<string, unknown>)[key], value)) return false;
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function equalValue(left: unknown, right: unknown): boolean {
|
|
29
|
+
if (Object.is(left, right)) return true;
|
|
30
|
+
if (Array.isArray(left) && Array.isArray(right)) {
|
|
31
|
+
if (left.length !== right.length) return false;
|
|
32
|
+
for (let index = 0; index < left.length; index++) {
|
|
33
|
+
if (!equalValue(left[index], right[index])) return false;
|
|
34
|
+
}
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
if (!isPlainRecord(left) || !isPlainRecord(right)) return false;
|
|
38
|
+
const leftKeys = Object.keys(left);
|
|
39
|
+
const rightKeys = Object.keys(right);
|
|
40
|
+
if (leftKeys.length !== rightKeys.length) return false;
|
|
41
|
+
for (const key of rightKeys) {
|
|
42
|
+
if (!hasOwn(left, key)) return false;
|
|
43
|
+
if (!equalValue(left[key], right[key])) return false;
|
|
44
|
+
}
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function hasOwn(value: object, key: string): boolean {
|
|
49
|
+
return Object.hasOwn(value, key);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
53
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
15
54
|
}
|
|
@@ -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,
|