@quandev104/pi-style 0.1.7 → 0.2.1

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 (26) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/extensions/pi-style.js +763 -319
  3. package/dist/extensions/pi-style.js.map +1 -1
  4. package/extension-src/pi-style/app/command-service.ts +2 -0
  5. package/extension-src/pi-style/app/index.ts +3 -2
  6. package/extension-src/pi-style/app/runtime.ts +72 -84
  7. package/extension-src/pi-style/app/snapshot.ts +41 -2
  8. package/extension-src/pi-style/domain/config-normalization.ts +3 -0
  9. package/extension-src/pi-style/domain/config-types.ts +4 -0
  10. package/extension-src/pi-style/features/editor/index.ts +80 -61
  11. package/extension-src/pi-style/features/messages/index.ts +194 -56
  12. package/extension-src/pi-style/features/tools/bash-execution.ts +12 -1
  13. package/extension-src/pi-style/features/tools/boxed/bash.ts +99 -31
  14. package/extension-src/pi-style/features/tools/boxed/batch.ts +44 -10
  15. package/extension-src/pi-style/features/tools/boxed/edit.ts +30 -15
  16. package/extension-src/pi-style/features/tools/boxed/index.ts +7 -2
  17. package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +31 -15
  18. package/extension-src/pi-style/features/tools/boxed/session-config.ts +48 -14
  19. package/extension-src/pi-style/features/tools/boxed/shared.ts +25 -0
  20. package/extension-src/pi-style/features/tools/boxed/turn-summary.ts +56 -11
  21. package/extension-src/pi-style/features/tools/index.ts +19 -4
  22. package/extension-src/pi-style/pi/compatibility-probe.ts +1 -1
  23. package/extension-src/pi-style/pi/index.ts +26 -13
  24. package/extension-src/pi-style/pi/session-usage.ts +204 -21
  25. package/extension-src/pi-style/shared/box.ts +43 -8
  26. package/package.json +1 -1
@@ -11,12 +11,11 @@ import {
11
11
  import { requestToolPresentationRender } from "../features/tools/index.js";
12
12
  import { registerPiStyleCommand } from "./commands.js";
13
13
  import { type CompatibilityTestHooks, createPiStyleSessionCoordinator } from "./session-coordinator.js";
14
- import { usageFromSession } from "./session-usage.js";
14
+ import { resetUsageFromSessionCache, usageFromSession } from "./session-usage.js";
15
15
 
16
- /** Usage patch that omits the key when no session usage exists (exact optional types). */
16
+ /** Usage patch clears stale usage when the active branch/session has none. */
17
17
  function usagePatch(ctx: ExtensionContext): StatusSnapshot {
18
- const usage = usageFromSession(ctx.sessionManager);
19
- return usage ? { usage } : {};
18
+ return { usage: usageFromSession(ctx.sessionManager) } as StatusSnapshot;
20
19
  }
21
20
 
22
21
  /**
@@ -65,6 +64,7 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
65
64
  const coordinator = createPiStyleSessionCoordinator(pi, compatibilityTestHooks);
66
65
  registerPiStyleCommand(pi, coordinator.app);
67
66
  pi.on("session_start", async (event, ctx) => {
67
+ resetUsageFromSessionCache(ctx.sessionManager);
68
68
  // Pi only activates read/bash/edit/write by default; grep/find/ls are
69
69
  // registered but inactive (kept out of the model's tool list to keep the
70
70
  // core small). Activate them so the TUI shows them and the model can call
@@ -110,7 +110,9 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
110
110
  ),
111
111
  );
112
112
  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"));
113
+ pi.on("session_info_changed", (event) =>
114
+ coordinator.app.update({ sessionName: event.name }, "coalesced", { refreshExtensionStatuses: true }),
115
+ );
114
116
  pi.on("message_start", () => {
115
117
  // A new message is a batch boundary: quiet-tool (read/ls/find) calls of the
116
118
  // new message start a fresh batch instead of joining the previous one.
@@ -123,11 +125,13 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
123
125
  // Usage (tokens + cost) is aggregated from finalized session entries at
124
126
  // message/turn boundaries, mirroring Pi's native footer; per-chunk updates
125
127
  // stay usage-free to keep streaming cheap.
126
- pi.on("message_end", (_event, ctx) => coordinator.app.update({ ...usagePatch(ctx) }, "coalesced"));
128
+ pi.on("message_end", (_event, ctx) =>
129
+ coordinator.app.update({ ...usagePatch(ctx) }, "coalesced", { refreshContextUsage: true }),
130
+ );
127
131
  pi.on("turn_end", (event, ctx) => {
128
132
  // Append the finalized assistant message's tool batch to the current run.
129
133
  registerTurnFromMessage(event.message, event.toolResults);
130
- coordinator.app.update({ ...usagePatch(ctx) }, "deferred");
134
+ coordinator.app.update({ ...usagePatch(ctx) }, "deferred", { refreshContextUsage: true });
131
135
  });
132
136
  pi.on("agent_end", () => {
133
137
  // The run is complete: collapse its tool blocks into one summary line.
@@ -141,23 +145,32 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
141
145
  requestToolPresentationRender();
142
146
  }
143
147
  });
144
- pi.on("agent_settled", (_event, ctx) => coordinator.app.update({ ...usagePatch(ctx) }, "coalesced"));
148
+ pi.on("agent_settled", (_event, ctx) =>
149
+ coordinator.app.update({ ...usagePatch(ctx) }, "coalesced", { refreshContextUsage: true }),
150
+ );
145
151
  pi.on("session_tree", (_event, ctx) => {
152
+ resetUsageFromSessionCache(ctx.sessionManager);
146
153
  // Rebuild the turn registry from session content so restored/branched
147
154
  // history renders collapsed consistently (no in-process turn_end events).
148
155
  rebuildTurnRegistryFromEntries(ctx.sessionManager.getEntries());
149
- coordinator.app.update({ ...usagePatch(ctx) }, "deferred");
156
+ coordinator.app.update({ ...usagePatch(ctx) }, "deferred", { refreshContextUsage: true });
157
+ });
158
+ pi.on("session_compact", (_event, ctx) => {
159
+ resetUsageFromSessionCache(ctx.sessionManager);
160
+ coordinator.app.update({ ...usagePatch(ctx) }, "deferred", { refreshContextUsage: true });
150
161
  });
151
- pi.on("session_compact", (_event, ctx) => coordinator.app.update({ ...usagePatch(ctx) }, "deferred"));
152
162
  pi.on("tool_result", (event, ctx) => {
153
163
  if (["write", "edit", "bash"].includes(event.toolName)) {
154
164
  coordinator.app.runtime.current?.invalidateGit();
155
- coordinator.app.update({ ...usagePatch(ctx) }, "delayed-retry");
165
+ coordinator.app.update({ ...usagePatch(ctx) }, "delayed-retry", { refreshContextUsage: true });
156
166
  }
157
167
  });
158
168
  pi.on("user_bash", (_event, ctx) => {
159
169
  coordinator.app.runtime.current?.invalidateGit();
160
- coordinator.app.update({ ...usagePatch(ctx) }, "delayed-retry");
170
+ coordinator.app.update({ ...usagePatch(ctx) }, "delayed-retry", { refreshContextUsage: true });
171
+ });
172
+ pi.on("session_shutdown", (_event, ctx) => {
173
+ resetUsageFromSessionCache(ctx.sessionManager);
174
+ coordinator.shutdown();
161
175
  });
162
- pi.on("session_shutdown", () => coordinator.shutdown());
163
176
  }
@@ -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
  }
@@ -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). */
@@ -384,10 +395,10 @@ export function dimLine(text: string): string {
384
395
  return `\x1b[2m${text}\x1b[22m`;
385
396
  }
386
397
 
387
- function boxText(theme: BoxTheme, text: string): string {
398
+ function boxText(_theme: BoxTheme, text: string): string {
388
399
  return dimLine(text);
389
400
  }
390
- function boxFrameText(theme: BoxTheme, text: string): string {
401
+ function boxFrameText(_theme: BoxTheme, text: string): string {
391
402
  return dimLine(text);
392
403
  }
393
404
 
@@ -567,8 +578,20 @@ function renderBoxedOutputLines(
567
578
  let truncated = false;
568
579
 
569
580
  for (; nextInputIndex < outputLines.length; nextInputIndex++) {
570
- const line = boxedTruncatedLine(theme, outputLines[nextInputIndex] ?? "", width);
571
- if (!pushBoundedLines(head, [line], headLimit)) {
581
+ // An output "line" may carry embedded newlines (raw tool error messages,
582
+ // JSON payloads). Split before boxing so every fragment gets its own
583
+ // border and truncation — otherwise the box frame visually breaks on the
584
+ // embedded rows.
585
+ const fragments = (outputLines[nextInputIndex] ?? "").split("\n");
586
+ let headExceeded = false;
587
+ for (const fragment of fragments) {
588
+ const line = boxedTruncatedLine(theme, fragment, width);
589
+ if (!pushBoundedLines(head, [line], headLimit)) {
590
+ headExceeded = true;
591
+ break;
592
+ }
593
+ }
594
+ if (headExceeded) {
572
595
  truncated = true;
573
596
  nextInputIndex++;
574
597
  break;
@@ -580,9 +603,12 @@ function renderBoxedOutputLines(
580
603
  const tail: string[] = [];
581
604
  const tailStart = Math.max(nextInputIndex, outputLines.length - tailLimit);
582
605
  for (let i = tailStart; i < outputLines.length; i++) {
583
- const line = boxedTruncatedLine(theme, outputLines[i] ?? "", width);
584
- tail.push(line);
585
- if (tail.length > tailLimit) tail.splice(0, tail.length - tailLimit);
606
+ const fragments = (outputLines[i] ?? "").split("\n");
607
+ for (const fragment of fragments) {
608
+ const line = boxedTruncatedLine(theme, fragment, width);
609
+ tail.push(line);
610
+ if (tail.length > tailLimit) tail.splice(0, tail.length - tailLimit);
611
+ }
586
612
  }
587
613
 
588
614
  const skippedInputLines = Math.max(0, tailStart - nextInputIndex);
@@ -764,6 +790,10 @@ export function renderBoxedToolResult(
764
790
  bodyLines.length > 0
765
791
  ? [...errorPrefix, ...bodyLines]
766
792
  : [theme.fg("muted", `∅ ${options.emptyText ?? "(no output)"}`)];
793
+ // Split embedded newlines before budgeting so raw multi-line messages
794
+ // (tool validation errors, JSON payloads) render as proper bordered
795
+ // rows instead of one "line" whose embedded rows break the frame.
796
+ const outputFragments = outputLines.flatMap((line) => line.split("\n"));
767
797
  const footerText = (options.footerLines ?? []).join(" · ");
768
798
  const dividerText =
769
799
  typeof options.dividerLabel === "function"
@@ -783,7 +813,12 @@ export function renderBoxedToolResult(
783
813
  ),
784
814
  ]),
785
815
  boxBlankLine(theme, renderedWidth),
786
- ...renderBoxedOutputLines(theme, outputLines, renderedWidth, options.renderLineBudget ?? outputLines.length),
816
+ ...renderBoxedOutputLines(
817
+ theme,
818
+ outputFragments,
819
+ renderedWidth,
820
+ options.renderLineBudget ?? outputFragments.length,
821
+ ),
787
822
  boxBlankLine(theme, renderedWidth),
788
823
  boxLabeledBorder(
789
824
  theme,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quandev104/pi-style",
3
- "version": "0.1.7",
3
+ "version": "0.2.1",
4
4
  "description": "A native-layout, cohesive visual style package for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",