@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.
Files changed (33) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +1 -1
  3. package/dist/extensions/pi-style.js +1256 -491
  4. package/dist/extensions/pi-style.js.map +1 -1
  5. package/extension-src/pi-style/app/index.ts +3 -2
  6. package/extension-src/pi-style/app/runtime.ts +99 -86
  7. package/extension-src/pi-style/app/snapshot.ts +41 -2
  8. package/extension-src/pi-style/domain/status-renderer.ts +40 -8
  9. package/extension-src/pi-style/domain/status.ts +15 -5
  10. package/extension-src/pi-style/domain/theme.ts +32 -1
  11. package/extension-src/pi-style/features/editor/index.ts +97 -66
  12. package/extension-src/pi-style/features/messages/index.ts +469 -90
  13. package/extension-src/pi-style/features/status-line/index.ts +41 -11
  14. package/extension-src/pi-style/features/tools/bash-execution.ts +12 -1
  15. package/extension-src/pi-style/features/tools/boxed/bash.ts +195 -66
  16. package/extension-src/pi-style/features/tools/boxed/batch.ts +60 -10
  17. package/extension-src/pi-style/features/tools/boxed/edit.ts +45 -25
  18. package/extension-src/pi-style/features/tools/boxed/find.ts +9 -4
  19. package/extension-src/pi-style/features/tools/boxed/git.ts +46 -2
  20. package/extension-src/pi-style/features/tools/boxed/grep.ts +9 -2
  21. package/extension-src/pi-style/features/tools/boxed/ls.ts +9 -4
  22. package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +45 -22
  23. package/extension-src/pi-style/features/tools/boxed/read.ts +4 -2
  24. package/extension-src/pi-style/features/tools/boxed/session-config.ts +45 -14
  25. package/extension-src/pi-style/features/tools/boxed/shared.ts +51 -0
  26. package/extension-src/pi-style/features/tools/boxed/turn-summary.ts +12 -0
  27. package/extension-src/pi-style/pi/compatibility-probe.ts +1 -1
  28. package/extension-src/pi-style/pi/index.ts +28 -13
  29. package/extension-src/pi-style/pi/session-usage.ts +204 -21
  30. package/extension-src/pi-style/shared/ansi.ts +17 -5
  31. package/extension-src/pi-style/shared/box.ts +83 -6
  32. package/extension-src/pi-style/shared/split-diff.ts +8 -5
  33. 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): void;
38
- updateStartupResources(resources: StartupResources): void;
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 usage = host.getContextUsage?.();
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
- ...(usage
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 startupSnapshot = (config: NormalizedPiStyleConfig): StartupSnapshot => ({
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
- ...(host.resources ? { resources: host.resources } : {}),
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: startupSnapshot(currentConfig),
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
- currentSnapshot = replaceSnapshot(currentSnapshot, generation, { ...currentSnapshot, git: value });
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: currentSnapshot,
291
+ get snapshot() {
292
+ return currentSnapshot;
293
+ },
268
294
  disposables,
269
295
  scheduler,
270
296
  updateStartupResources(resources) {
271
- if (disposed) return;
272
- startup?.update({
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 liveUsage = host.getContextUsage?.();
288
- const context = liveUsage
289
- ? {
290
- ...(liveUsage.tokens !== null ? { currentTokens: liveUsage.tokens } : {}),
291
- windowTokens: liveUsage.contextWindow,
292
- ...(liveUsage.percent !== null ? { percent: liveUsage.percent } : {}),
293
- }
294
- : undefined;
295
- const statuses = extensionStatuses();
296
- currentSnapshot = replaceSnapshot(currentSnapshot, generation, {
297
- ...currentSnapshot,
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
- git.invalidate(host.cwd);
351
- void git.get(host.cwd).then((value) => {
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
- currentSnapshot = replaceSnapshot(currentSnapshot, generation, { ...currentSnapshot, git: value });
354
- statusLine?.update(currentSnapshot);
355
- editor?.update(currentSnapshot);
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
- requestRender();
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, ...values });
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, { id, segment, result, content: result.content, compact: false, moved: false });
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
- if (widthOf(visible, separator, padding) <= width) continue;
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
- if (widthOf(visible, separator, padding) <= width) continue;
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
- if (widthOf(secondaryVisible, separator, padding) > width) secondaryVisible.pop();
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
- visible: true,
313
- content: theme.apply("time", new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })),
314
- compactContent: theme.apply("time", new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })),
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
- const color = (token: SemanticToken) => colorPrefixFor(active, config, noColor, token);
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,