@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
@@ -306,6 +306,18 @@ export function invalidateTurnMembers(turn: TurnState): void {
306
306
  }
307
307
  }
308
308
 
309
+ /**
310
+ * Drop the turn's captured invalidate callbacks (`agent_end`, after the
311
+ * collapse re-render). The closures pin component state for the rest of the
312
+ * session otherwise; `memberByCallId` entries stay so scrollback keeps
313
+ * resolving the turn. Later expand toggles re-render via Pi's updateDisplay
314
+ * selectors and re-capture fresh callbacks; a missing callback is already
315
+ * skipped gracefully by invalidateTurnMembers.
316
+ */
317
+ export function releaseTurnInvalidators(turn: TurnState): void {
318
+ for (const member of turn.members) invalidateByCallId.delete(member.toolCallId);
319
+ }
320
+
309
321
  /**
310
322
  * Freeze a member's wall-clock elapsed into the registry (idempotent; the
311
323
  * value is frozen by the renderer state once the terminal result rendered).
@@ -145,7 +145,7 @@ export const KNOWN_NATIVE_IDENTITIES: Readonly<Record<string, readonly KnownNati
145
145
  /** Primary (first-recorded) fingerprint per surface, for diagnostics and back-compat. */
146
146
  export const TRUSTED_NATIVE_FINGERPRINTS: Readonly<Record<string, string>> = Object.freeze(
147
147
  Object.fromEntries(
148
- Object.entries(KNOWN_NATIVE_IDENTITIES).map(([key, identities]) => [key, identities[0]!.fingerprint]),
148
+ Object.entries(KNOWN_NATIVE_IDENTITIES).map(([key, identities]) => [key, identities[0]?.fingerprint ?? ""]),
149
149
  ),
150
150
  );
151
151
 
@@ -7,16 +7,16 @@ import {
7
7
  invalidateTurnMembers,
8
8
  rebuildTurnRegistryFromEntries,
9
9
  registerTurnFromMessage,
10
+ releaseTurnInvalidators,
10
11
  } from "../features/tools/boxed/turn-summary.js";
11
12
  import { requestToolPresentationRender } from "../features/tools/index.js";
12
13
  import { registerPiStyleCommand } from "./commands.js";
13
14
  import { type CompatibilityTestHooks, createPiStyleSessionCoordinator } from "./session-coordinator.js";
14
- import { usageFromSession } from "./session-usage.js";
15
+ import { resetUsageFromSessionCache, usageFromSession } from "./session-usage.js";
15
16
 
16
- /** Usage patch that omits the key when no session usage exists (exact optional types). */
17
+ /** Usage patch clears stale usage when the active branch/session has none. */
17
18
  function usagePatch(ctx: ExtensionContext): StatusSnapshot {
18
- const usage = usageFromSession(ctx.sessionManager);
19
- return usage ? { usage } : {};
19
+ return { usage: usageFromSession(ctx.sessionManager) } as StatusSnapshot;
20
20
  }
21
21
 
22
22
  /**
@@ -65,6 +65,7 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
65
65
  const coordinator = createPiStyleSessionCoordinator(pi, compatibilityTestHooks);
66
66
  registerPiStyleCommand(pi, coordinator.app);
67
67
  pi.on("session_start", async (event, ctx) => {
68
+ resetUsageFromSessionCache(ctx.sessionManager);
68
69
  // Pi only activates read/bash/edit/write by default; grep/find/ls are
69
70
  // registered but inactive (kept out of the model's tool list to keep the
70
71
  // core small). Activate them so the TUI shows them and the model can call
@@ -110,7 +111,9 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
110
111
  ),
111
112
  );
112
113
  pi.on("thinking_level_select", (event) => coordinator.app.update({ thinkingLevel: event.level }, "immediate"));
113
- pi.on("session_info_changed", (event) => coordinator.app.update({ sessionName: event.name }, "coalesced"));
114
+ pi.on("session_info_changed", (event) =>
115
+ coordinator.app.update({ sessionName: event.name }, "coalesced", { refreshExtensionStatuses: true }),
116
+ );
114
117
  pi.on("message_start", () => {
115
118
  // A new message is a batch boundary: quiet-tool (read/ls/find) calls of the
116
119
  // new message start a fresh batch instead of joining the previous one.
@@ -123,11 +126,13 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
123
126
  // Usage (tokens + cost) is aggregated from finalized session entries at
124
127
  // message/turn boundaries, mirroring Pi's native footer; per-chunk updates
125
128
  // stay usage-free to keep streaming cheap.
126
- pi.on("message_end", (_event, ctx) => coordinator.app.update({ ...usagePatch(ctx) }, "coalesced"));
129
+ pi.on("message_end", (_event, ctx) =>
130
+ coordinator.app.update({ ...usagePatch(ctx) }, "coalesced", { refreshContextUsage: true }),
131
+ );
127
132
  pi.on("turn_end", (event, ctx) => {
128
133
  // Append the finalized assistant message's tool batch to the current run.
129
134
  registerTurnFromMessage(event.message, event.toolResults);
130
- coordinator.app.update({ ...usagePatch(ctx) }, "deferred");
135
+ coordinator.app.update({ ...usagePatch(ctx) }, "deferred", { refreshContextUsage: true });
131
136
  });
132
137
  pi.on("agent_end", () => {
133
138
  // The run is complete: collapse its tool blocks into one summary line.
@@ -138,26 +143,36 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
138
143
  const run = finishAgentRun();
139
144
  if (run) {
140
145
  invalidateTurnMembers(run);
146
+ releaseTurnInvalidators(run);
141
147
  requestToolPresentationRender();
142
148
  }
143
149
  });
144
- pi.on("agent_settled", (_event, ctx) => coordinator.app.update({ ...usagePatch(ctx) }, "coalesced"));
150
+ pi.on("agent_settled", (_event, ctx) =>
151
+ coordinator.app.update({ ...usagePatch(ctx) }, "coalesced", { refreshContextUsage: true }),
152
+ );
145
153
  pi.on("session_tree", (_event, ctx) => {
154
+ resetUsageFromSessionCache(ctx.sessionManager);
146
155
  // Rebuild the turn registry from session content so restored/branched
147
156
  // history renders collapsed consistently (no in-process turn_end events).
148
157
  rebuildTurnRegistryFromEntries(ctx.sessionManager.getEntries());
149
- coordinator.app.update({ ...usagePatch(ctx) }, "deferred");
158
+ coordinator.app.update({ ...usagePatch(ctx) }, "deferred", { refreshContextUsage: true });
159
+ });
160
+ pi.on("session_compact", (_event, ctx) => {
161
+ resetUsageFromSessionCache(ctx.sessionManager);
162
+ coordinator.app.update({ ...usagePatch(ctx) }, "deferred", { refreshContextUsage: true });
150
163
  });
151
- pi.on("session_compact", (_event, ctx) => coordinator.app.update({ ...usagePatch(ctx) }, "deferred"));
152
164
  pi.on("tool_result", (event, ctx) => {
153
165
  if (["write", "edit", "bash"].includes(event.toolName)) {
154
166
  coordinator.app.runtime.current?.invalidateGit();
155
- coordinator.app.update({ ...usagePatch(ctx) }, "delayed-retry");
167
+ coordinator.app.update({ ...usagePatch(ctx) }, "delayed-retry", { refreshContextUsage: true });
156
168
  }
157
169
  });
158
170
  pi.on("user_bash", (_event, ctx) => {
159
171
  coordinator.app.runtime.current?.invalidateGit();
160
- coordinator.app.update({ ...usagePatch(ctx) }, "delayed-retry");
172
+ coordinator.app.update({ ...usagePatch(ctx) }, "delayed-retry", { refreshContextUsage: true });
173
+ });
174
+ pi.on("session_shutdown", (_event, ctx) => {
175
+ resetUsageFromSessionCache(ctx.sessionManager);
176
+ coordinator.shutdown();
161
177
  });
162
- pi.on("session_shutdown", () => coordinator.shutdown());
163
178
  }
@@ -18,27 +18,197 @@ interface UsageTotals {
18
18
  cost: number;
19
19
  }
20
20
 
21
+ interface UsageContribution extends UsageTotals {
22
+ sawUsage: boolean;
23
+ }
24
+
25
+ interface SessionUsageCacheState {
26
+ processedLength: number;
27
+ lastEntryId: string | undefined;
28
+ lastEntryRef: SessionEntry | undefined;
29
+ lastContribution: UsageContribution;
30
+ totals: UsageTotals;
31
+ cached: UsageSnapshot | undefined;
32
+ scannedEntries: number;
33
+ cacheHits: number;
34
+ rebuilds: number;
35
+ tailRefreshes: number;
36
+ }
37
+
38
+ export interface SessionUsageCacheStats {
39
+ processedLength: number;
40
+ scannedEntries: number;
41
+ cacheHits: number;
42
+ rebuilds: number;
43
+ tailRefreshes: number;
44
+ }
45
+
46
+ let usageCache = new WeakMap<SessionUsageSource, SessionUsageCacheState>();
47
+
21
48
  /**
22
- * Aggregate cumulative token/cost usage from all session entries, mirroring
23
- * Pi's native footer: assistant messages always carry usage, tool results and
24
- * compaction/branch summaries carry it when available.
49
+ * Aggregate cumulative token/cost usage from finalized session entries,
50
+ * mirroring Pi's native footer. The result is cached incrementally so repeated
51
+ * reads only scan appended entries and refresh the finalized tail entry when it
52
+ * changes in place.
25
53
  */
26
54
  export function usageFromSession(session: SessionUsageSource): UsageSnapshot | undefined {
27
- const totals: UsageTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
28
- let sawUsage = false;
29
- for (const entry of session.getEntries()) {
30
- if (entry.type === "message") {
31
- if (entry.message.role !== "assistant" && entry.message.role !== "toolResult") continue;
32
- const usage = "usage" in entry.message ? entry.message.usage : undefined;
33
- if (!usage) continue;
34
- addUsage(totals, usage);
35
- sawUsage = true;
36
- } else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) {
37
- addUsage(totals, entry.usage);
38
- sawUsage = true;
55
+ const entries = session.getEntries();
56
+ const state = usageCache.get(session) ?? createState();
57
+ usageCache.set(session, state);
58
+
59
+ if (entries.length === 0) {
60
+ state.cacheHits++;
61
+ state.processedLength = 0;
62
+ state.lastEntryId = undefined;
63
+ state.lastEntryRef = undefined;
64
+ state.lastContribution = emptyContribution();
65
+ state.totals = emptyTotals();
66
+ state.cached = undefined;
67
+ return undefined;
68
+ }
69
+
70
+ if (state.processedLength === 0) {
71
+ rebuildFrom(entries, state);
72
+ return state.cached;
73
+ }
74
+
75
+ if (entries.length < state.processedLength) {
76
+ rebuildFrom(entries, state);
77
+ return state.cached;
78
+ }
79
+
80
+ const currentLast = entries.at(-1);
81
+ if (!currentLast) {
82
+ state.cacheHits++;
83
+ state.cached = undefined;
84
+ return undefined;
85
+ }
86
+
87
+ if (entries.length === state.processedLength) {
88
+ if (currentLast.id !== state.lastEntryId) {
89
+ rebuildFrom(entries, state);
90
+ return state.cached;
39
91
  }
92
+ if (currentLast !== state.lastEntryRef) refreshTailEntry(currentLast, state);
93
+ else state.cacheHits++;
94
+ return state.cached;
95
+ }
96
+
97
+ const previousLast = entries[state.processedLength - 1];
98
+ if (!previousLast || previousLast.id !== state.lastEntryId) {
99
+ rebuildFrom(entries, state);
100
+ return state.cached;
40
101
  }
41
- if (!sawUsage) return undefined;
102
+ if (previousLast !== state.lastEntryRef) refreshTailEntry(previousLast, state);
103
+ for (const entry of entries.slice(state.processedLength)) appendEntry(entry, state);
104
+ return state.cached;
105
+ }
106
+
107
+ export function resetUsageFromSessionCache(session?: SessionUsageSource): void {
108
+ if (session) {
109
+ usageCache.delete(session);
110
+ return;
111
+ }
112
+ usageCache = new WeakMap<SessionUsageSource, SessionUsageCacheState>();
113
+ }
114
+
115
+ export function getUsageFromSessionCacheStats(session: SessionUsageSource): SessionUsageCacheStats {
116
+ const state = usageCache.get(session) ?? createState();
117
+ if (!usageCache.has(session)) usageCache.set(session, state);
118
+ return {
119
+ processedLength: state.processedLength,
120
+ scannedEntries: state.scannedEntries,
121
+ cacheHits: state.cacheHits,
122
+ rebuilds: state.rebuilds,
123
+ tailRefreshes: state.tailRefreshes,
124
+ };
125
+ }
126
+
127
+ function createState(): SessionUsageCacheState {
128
+ return {
129
+ processedLength: 0,
130
+ lastEntryId: undefined,
131
+ lastEntryRef: undefined,
132
+ lastContribution: emptyContribution(),
133
+ totals: emptyTotals(),
134
+ cached: undefined,
135
+ scannedEntries: 0,
136
+ cacheHits: 0,
137
+ rebuilds: 0,
138
+ tailRefreshes: 0,
139
+ };
140
+ }
141
+
142
+ function rebuildFrom(entries: readonly SessionEntry[], state: SessionUsageCacheState): void {
143
+ state.rebuilds++;
144
+ state.processedLength = 0;
145
+ state.lastEntryId = undefined;
146
+ state.lastEntryRef = undefined;
147
+ state.lastContribution = emptyContribution();
148
+ state.totals = emptyTotals();
149
+ state.cached = undefined;
150
+ for (const entry of entries) appendEntry(entry, state);
151
+ }
152
+
153
+ function refreshTailEntry(entry: SessionEntry, state: SessionUsageCacheState): void {
154
+ state.tailRefreshes++;
155
+ subtractContribution(state.totals, state.lastContribution);
156
+ const contribution = usageContribution(entry);
157
+ addContribution(state.totals, contribution);
158
+ state.lastEntryId = entry.id;
159
+ state.lastEntryRef = entry;
160
+ state.lastContribution = contribution;
161
+ state.cached = snapshotFromTotals(state.totals);
162
+ state.scannedEntries++;
163
+ }
164
+
165
+ function appendEntry(entry: SessionEntry, state: SessionUsageCacheState): void {
166
+ const contribution = usageContribution(entry);
167
+ addContribution(state.totals, contribution);
168
+ state.processedLength++;
169
+ state.lastEntryId = entry.id;
170
+ state.lastEntryRef = entry;
171
+ state.lastContribution = contribution;
172
+ state.cached = snapshotFromTotals(state.totals);
173
+ state.scannedEntries++;
174
+ }
175
+
176
+ function usageContribution(entry: SessionEntry): UsageContribution {
177
+ if (entry.type === "message") {
178
+ if (entry.message.role !== "assistant" && entry.message.role !== "toolResult") return emptyContribution();
179
+ const usage = "usage" in entry.message ? entry.message.usage : undefined;
180
+ if (!usage) return emptyContribution();
181
+ return {
182
+ input: usage.input,
183
+ output: usage.output,
184
+ cacheRead: usage.cacheRead,
185
+ cacheWrite: usage.cacheWrite,
186
+ cost: usage.cost.total,
187
+ sawUsage: true,
188
+ };
189
+ }
190
+ if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) {
191
+ return {
192
+ input: entry.usage.input,
193
+ output: entry.usage.output,
194
+ cacheRead: entry.usage.cacheRead,
195
+ cacheWrite: entry.usage.cacheWrite,
196
+ cost: entry.usage.cost.total,
197
+ sawUsage: true,
198
+ };
199
+ }
200
+ return emptyContribution();
201
+ }
202
+
203
+ function snapshotFromTotals(totals: UsageTotals): UsageSnapshot | undefined {
204
+ if (
205
+ totals.input === 0 &&
206
+ totals.output === 0 &&
207
+ totals.cacheRead === 0 &&
208
+ totals.cacheWrite === 0 &&
209
+ totals.cost === 0
210
+ )
211
+ return undefined;
42
212
  return {
43
213
  inputTokens: totals.input,
44
214
  outputTokens: totals.output,
@@ -50,13 +220,26 @@ export function usageFromSession(session: SessionUsageSource): UsageSnapshot | u
50
220
  };
51
221
  }
52
222
 
53
- function addUsage(
54
- totals: UsageTotals,
55
- usage: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: { total: number } },
56
- ): void {
223
+ function addContribution(totals: UsageTotals, usage: UsageContribution): void {
57
224
  totals.input += usage.input;
58
225
  totals.output += usage.output;
59
226
  totals.cacheRead += usage.cacheRead;
60
227
  totals.cacheWrite += usage.cacheWrite;
61
- totals.cost += usage.cost.total;
228
+ totals.cost += usage.cost;
229
+ }
230
+
231
+ function subtractContribution(totals: UsageTotals, usage: UsageContribution): void {
232
+ totals.input -= usage.input;
233
+ totals.output -= usage.output;
234
+ totals.cacheRead -= usage.cacheRead;
235
+ totals.cacheWrite -= usage.cacheWrite;
236
+ totals.cost -= usage.cost;
237
+ }
238
+
239
+ function emptyTotals(): UsageTotals {
240
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
241
+ }
242
+
243
+ function emptyContribution(): UsageContribution {
244
+ return { ...emptyTotals(), sawUsage: false };
62
245
  }
@@ -1,3 +1,5 @@
1
+ import { visibleWidth as tuiVisibleWidth } from "@earendil-works/pi-tui";
2
+
1
3
  function isFinal(byte: string): boolean {
2
4
  return byte >= "@" && byte <= "~";
3
5
  }
@@ -91,8 +93,12 @@ export function stripAnsi(value: string): string {
91
93
  }
92
94
  return output;
93
95
  }
96
+ /**
97
+ * Terminal-correct visible width: delegates to pi-tui (ANSI-stripping,
98
+ * ASCII fast path, per-string cache, wide chars = 2 columns, tabs = 3).
99
+ */
94
100
  export function visibleWidth(value: string): number {
95
- return [...stripAnsi(value)].length;
101
+ return tuiVisibleWidth(value);
96
102
  }
97
103
  export function resetAnsi(value: string): string {
98
104
  return `${value}\x1b[0m`;
@@ -105,9 +111,10 @@ export function fitAnsiWidth(value: string, width: number, ellipsis = "…"): st
105
111
  export function truncateAnsi(value: string, width: number, ellipsis = "…"): string {
106
112
  if (width <= 0) return "";
107
113
  if (visibleWidth(value) <= width) return resetAnsi(value);
114
+ const ellipsisWidth = visibleWidth(ellipsis);
108
115
  let output = "";
109
116
  let visible = 0;
110
- for (let i = 0; i < value.length && visible < width - visibleWidth(ellipsis); i++) {
117
+ for (let i = 0; i < value.length && visible < width - ellipsisWidth; i++) {
111
118
  if (value.charCodeAt(i) === 27) {
112
119
  const start = i;
113
120
  i++;
@@ -125,12 +132,17 @@ export function wrapAnsi(value: string, width: number): string[] {
125
132
  if (width <= 0) return [""];
126
133
  const lines: string[] = [];
127
134
  let line = "";
135
+ let lineWidth = 0;
128
136
  for (const word of value.split(/\s+/)) {
129
- const next = line ? `${line} ${word}` : word;
130
- if (visibleWidth(next) <= width) line = next;
131
- else {
137
+ const wordWidth = visibleWidth(word);
138
+ const nextWidth = line ? lineWidth + 1 + wordWidth : wordWidth;
139
+ if (nextWidth <= width) {
140
+ line = line ? `${line} ${word}` : word;
141
+ lineWidth = nextWidth;
142
+ } else {
132
143
  if (line) lines.push(resetAnsi(line));
133
144
  line = truncateAnsi(word, width);
145
+ lineWidth = visibleWidth(line);
134
146
  }
135
147
  }
136
148
  if (line || lines.length === 0) lines.push(resetAnsi(line));
@@ -36,6 +36,17 @@ export interface BoxTheme {
36
36
  getColorMode?(): string;
37
37
  }
38
38
 
39
+ const THEME_CACHE_KEYS = new WeakMap<object, number>();
40
+ let nextThemeCacheKey = 1;
41
+
42
+ export function themeCacheKey(theme: object): number {
43
+ const cached = THEME_CACHE_KEYS.get(theme);
44
+ if (cached !== undefined) return cached;
45
+ const created = nextThemeCacheKey++;
46
+ THEME_CACHE_KEYS.set(theme, created);
47
+ return created;
48
+ }
49
+
39
50
  export interface BoxedRenderOptions {
40
51
  widthKey?: string;
41
52
  /** Detail embedded in the top-border title after the tool name (e.g. the path). */
@@ -138,12 +149,63 @@ export function countLines(text: string): number {
138
149
  return normalized.split("\n").length;
139
150
  }
140
151
 
152
+ // Word-character membership table powering countWords. ASCII and the UTF-16
153
+ // surrogate range are initialized eagerly; every other BMP code point is
154
+ // resolved through the Unicode letter/digit class on first sight and memoized,
155
+ // so repeat scans are pure table lookups. The per-code-point regex dispatch
156
+ // this replaces measured ~1.9ms per 90KB output on every boxed footer render;
157
+ // note that String.match with a \p{L}\p{N} class is no faster on V8 — the
158
+ // memoized scan is the only variant that hit the <0.2ms budget.
159
+ const WORD_CP_UNKNOWN = 255;
160
+ const WORD_CP_SURROGATE = 254;
161
+ const WORD_CP_CLASS = new Uint8Array(0x10000).fill(WORD_CP_UNKNOWN);
162
+ for (let code = 0x30; code <= 0x39; code++) WORD_CP_CLASS[code] = 1; // 0-9
163
+ for (let code = 0x41; code <= 0x5a; code++) WORD_CP_CLASS[code] = 1; // A-Z
164
+ for (let code = 0x61; code <= 0x7a; code++) WORD_CP_CLASS[code] = 1; // a-z
165
+ WORD_CP_CLASS[0x27] = 1; // '
166
+ WORD_CP_CLASS[0x2d] = 1; // -
167
+ WORD_CP_CLASS[0x5f] = 1; // _
168
+ WORD_CP_CLASS.fill(WORD_CP_SURROGATE, 0xd800, 0xe000);
169
+ const NON_ASCII_WORD_RE = /[\p{L}\p{N}]/u;
170
+ const ASTRAL_WORD_CP = new Map<number, 0 | 1>();
171
+
172
+ function astralWordMembership(codePoint: number): 0 | 1 {
173
+ const cached = ASTRAL_WORD_CP.get(codePoint);
174
+ if (cached !== undefined) return cached;
175
+ const membership: 0 | 1 = NON_ASCII_WORD_RE.test(String.fromCodePoint(codePoint)) ? 1 : 0;
176
+ ASTRAL_WORD_CP.set(codePoint, membership);
177
+ return membership;
178
+ }
179
+
180
+ /** Counts words as maximal runs of word characters (letters, digits,
181
+ * underscore, apostrophe, hyphen) — identical counts to a per-code-point
182
+ * `\p{L}\p{N}_'-` class test, in one table-driven pass with no allocations. */
141
183
  export function countWords(text: string): number {
184
+ const len = text.length;
142
185
  let count = 0;
143
- let inWord = false;
144
- for (const char of text) {
145
- const isWord = /[\p{L}\p{N}_'-]/u.test(char);
146
- if (isWord && !inWord) count++;
186
+ let inWord: number = 0;
187
+ for (let i = 0; i < len; i++) {
188
+ const code = text.charCodeAt(i);
189
+ const membership = WORD_CP_CLASS[code] ?? 0;
190
+ let isWord: number;
191
+ if (membership <= 1) {
192
+ isWord = membership;
193
+ } else if (membership === WORD_CP_SURROGATE) {
194
+ const next = i + 1 < len ? text.charCodeAt(i + 1) : 0;
195
+ if (code <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) {
196
+ // Astral word characters (e.g. mathematical alphanumerics) count as
197
+ // one code point, exactly like the previous code-point iteration.
198
+ isWord = astralWordMembership(0x10000 + ((code - 0xd800) << 10) + (next - 0xdc00));
199
+ i++; // consumed the pair's low surrogate half
200
+ } else {
201
+ isWord = 0; // lone surrogate half: never a word character
202
+ }
203
+ } else {
204
+ // First sight of this non-ASCII BMP code point: resolve once, memoize.
205
+ isWord = NON_ASCII_WORD_RE.test(String.fromCharCode(code)) ? 1 : 0;
206
+ WORD_CP_CLASS[code] = isWord;
207
+ }
208
+ count += isWord & (inWord ^ 1);
147
209
  inWord = isWord;
148
210
  }
149
211
  return count;
@@ -199,6 +261,9 @@ const BOX_DIVIDER_RIGHT = "┤";
199
261
  /** Dash run before the right corner when a right-side border label is present. */
200
262
  const BOX_LABELED_RIGHT_DASH_MIN = 3;
201
263
  const BOX_WIDTH_CACHE = new Map<string, number>();
264
+ /** Hard cap for BOX_WIDTH_CACHE; the oldest entry is evicted beyond this. Keys
265
+ * embed full bash commands, so the cache must stay bounded across a session. */
266
+ const BOX_WIDTH_CACHE_MAX_ENTRIES = 512;
202
267
 
203
268
  export function boxWidth(width: number): number {
204
269
  return Math.max(BOX_MIN_WIDTH, width);
@@ -225,6 +290,13 @@ function _tightBoxWidth(
225
290
  if (!widthKey) return measuredWidth;
226
291
  const cachedWidth = BOX_WIDTH_CACHE.get(widthKey) ?? 0;
227
292
  const nextWidth = Math.min(boxWidth(availableWidth), Math.max(cachedWidth, measuredWidth));
293
+ // Bounded LRU: Map preserves insertion order, so delete+set refreshes the
294
+ // key's recency and the first key is the oldest evict candidate.
295
+ BOX_WIDTH_CACHE.delete(widthKey);
296
+ if (BOX_WIDTH_CACHE.size >= BOX_WIDTH_CACHE_MAX_ENTRIES) {
297
+ const oldestKey = BOX_WIDTH_CACHE.keys().next().value;
298
+ if (oldestKey !== undefined) BOX_WIDTH_CACHE.delete(oldestKey);
299
+ }
228
300
  BOX_WIDTH_CACHE.set(widthKey, nextWidth);
229
301
  return nextWidth;
230
302
  }
@@ -233,6 +305,11 @@ export function boxedToolWidthKey(toolName: string, detail: string): string {
233
305
  return `${toolName}:${detail}`;
234
306
  }
235
307
 
308
+ /** Test-only debug view of the bounded box width cache. */
309
+ export function __getBoxWidthCacheDebugState(): { size: number } {
310
+ return { size: BOX_WIDTH_CACHE.size };
311
+ }
312
+
236
313
  export function formatToolName(toolName: string): string {
237
314
  const spaced = toolName
238
315
  .replace(/[_-]+/g, " ")
@@ -384,10 +461,10 @@ export function dimLine(text: string): string {
384
461
  return `\x1b[2m${text}\x1b[22m`;
385
462
  }
386
463
 
387
- function boxText(theme: BoxTheme, text: string): string {
464
+ function boxText(_theme: BoxTheme, text: string): string {
388
465
  return dimLine(text);
389
466
  }
390
- function boxFrameText(theme: BoxTheme, text: string): string {
467
+ function boxFrameText(_theme: BoxTheme, text: string): string {
391
468
  return dimLine(text);
392
469
  }
393
470
 
@@ -56,7 +56,13 @@ type DiffEntry =
56
56
 
57
57
  const ESC = "\x1b";
58
58
  const BG_ANSI_PATTERN = new RegExp(`${ESC}\\[(?:4\\d|10\\d|48;5;\\d{1,3}|48;2;\\d{1,3};\\d{1,3};\\d{1,3}|49)m`, "g");
59
+ /** Any SGR escape (capture: parameter bytes). Shared with String.replace only —
60
+ * replace resets lastIndex, so the global flag is safe here. */
61
+ const ANSI_SGR_PATTERN = new RegExp(`${ESC}\\[([0-9;]*)m`, "g");
59
62
  const CONTROL_CHARS = "\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F";
63
+ /** Control characters (minus \r/\n, handled separately) stripped from diff
64
+ * text. Replace-only usage, so the global flag is safe (see above). */
65
+ const CONTROL_CHARS_PATTERN = new RegExp(`[${CONTROL_CHARS}]`, "g");
60
66
 
61
67
  const ADD_ROW_BACKGROUND_MIX_RATIO = 0.24;
62
68
  const REMOVE_ROW_BACKGROUND_MIX_RATIO = 0.12;
@@ -173,7 +179,7 @@ function resolveDiffPalette(theme: SplitDiffTheme): DiffPalette {
173
179
  function keepBackgroundAcrossResets(text: string, rowBgAnsi: string): string {
174
180
  if (!text) return text;
175
181
 
176
- return text.replace(new RegExp(`${ESC}\\[([0-9;]*)m`, "g"), (sequence, rawCodes) => {
182
+ return text.replace(ANSI_SGR_PATTERN, (sequence, rawCodes) => {
177
183
  const split = String(rawCodes ?? "")
178
184
  .split(";")
179
185
  .filter(Boolean);
@@ -233,10 +239,7 @@ function applyBackgroundToVisibleRange(
233
239
  // ── Text utilities ─────────────────────────────────────────────────
234
240
 
235
241
  function sanitizeSingleLineText(value: string): string {
236
- return value
237
- .replace(/\r/g, "")
238
- .replace(/\n/g, "")
239
- .replace(new RegExp(`[${CONTROL_CHARS}]`, "g"), "");
242
+ return value.replace(/\r/g, "").replace(/\n/g, "").replace(CONTROL_CHARS_PATTERN, "");
240
243
  }
241
244
 
242
245
  function stripInlineBreaksPreserveAnsi(value: string): string {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quandev104/pi-style",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "A native-layout, cohesive visual style package for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",