@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.
Files changed (27) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +1 -1
  3. package/dist/extensions/pi-style.js +632 -262
  4. package/dist/extensions/pi-style.js.map +1 -1
  5. package/extension-src/pi-style/app/runtime.ts +29 -4
  6. package/extension-src/pi-style/domain/status-renderer.ts +40 -8
  7. package/extension-src/pi-style/domain/status.ts +15 -5
  8. package/extension-src/pi-style/domain/theme.ts +32 -1
  9. package/extension-src/pi-style/features/editor/index.ts +17 -5
  10. package/extension-src/pi-style/features/messages/index.ts +302 -61
  11. package/extension-src/pi-style/features/status-line/index.ts +41 -11
  12. package/extension-src/pi-style/features/tools/boxed/bash.ts +101 -40
  13. package/extension-src/pi-style/features/tools/boxed/batch.ts +17 -1
  14. package/extension-src/pi-style/features/tools/boxed/edit.ts +20 -15
  15. package/extension-src/pi-style/features/tools/boxed/find.ts +9 -4
  16. package/extension-src/pi-style/features/tools/boxed/git.ts +46 -2
  17. package/extension-src/pi-style/features/tools/boxed/grep.ts +9 -2
  18. package/extension-src/pi-style/features/tools/boxed/ls.ts +9 -4
  19. package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +18 -11
  20. package/extension-src/pi-style/features/tools/boxed/read.ts +4 -2
  21. package/extension-src/pi-style/features/tools/boxed/shared.ts +27 -1
  22. package/extension-src/pi-style/features/tools/boxed/turn-summary.ts +12 -0
  23. package/extension-src/pi-style/pi/index.ts +2 -0
  24. package/extension-src/pi-style/shared/ansi.ts +17 -5
  25. package/extension-src/pi-style/shared/box.ts +70 -4
  26. package/extension-src/pi-style/shared/split-diff.ts +8 -5
  27. 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
- git.invalidate(host.cwd);
349
- 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;
350
367
  if (disposed) return;
351
- if (updateSnapshot(withSnapshotPatch({ git: value }))) requestRender();
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, { 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,
@@ -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 fitted = widthOf(value) > width ? truncateAnsi(value, width, "") : value;
60
- const current = widthOf(fitted);
61
- return current < width ? fitted + " ".repeat(width - current) : fitted;
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.semantic = semanticTheme(this.piTheme, config);
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
- this.semantic = semanticTheme(this.piTheme, this.config);
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
  }