@quandev104/pi-style 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +1 -1
- package/dist/extensions/pi-style.js +1256 -491
- package/dist/extensions/pi-style.js.map +1 -1
- package/extension-src/pi-style/app/index.ts +3 -2
- package/extension-src/pi-style/app/runtime.ts +99 -86
- package/extension-src/pi-style/app/snapshot.ts +41 -2
- package/extension-src/pi-style/domain/status-renderer.ts +40 -8
- package/extension-src/pi-style/domain/status.ts +15 -5
- package/extension-src/pi-style/domain/theme.ts +32 -1
- package/extension-src/pi-style/features/editor/index.ts +97 -66
- package/extension-src/pi-style/features/messages/index.ts +469 -90
- package/extension-src/pi-style/features/status-line/index.ts +41 -11
- package/extension-src/pi-style/features/tools/bash-execution.ts +12 -1
- package/extension-src/pi-style/features/tools/boxed/bash.ts +195 -66
- package/extension-src/pi-style/features/tools/boxed/batch.ts +60 -10
- package/extension-src/pi-style/features/tools/boxed/edit.ts +45 -25
- package/extension-src/pi-style/features/tools/boxed/find.ts +9 -4
- package/extension-src/pi-style/features/tools/boxed/git.ts +46 -2
- package/extension-src/pi-style/features/tools/boxed/grep.ts +9 -2
- package/extension-src/pi-style/features/tools/boxed/ls.ts +9 -4
- package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +45 -22
- package/extension-src/pi-style/features/tools/boxed/read.ts +4 -2
- package/extension-src/pi-style/features/tools/boxed/session-config.ts +45 -14
- package/extension-src/pi-style/features/tools/boxed/shared.ts +51 -0
- package/extension-src/pi-style/features/tools/boxed/turn-summary.ts +12 -0
- package/extension-src/pi-style/pi/compatibility-probe.ts +1 -1
- package/extension-src/pi-style/pi/index.ts +28 -13
- package/extension-src/pi-style/pi/session-usage.ts +204 -21
- package/extension-src/pi-style/shared/ansi.ts +17 -5
- package/extension-src/pi-style/shared/box.ts +83 -6
- package/extension-src/pi-style/shared/split-diff.ts +8 -5
- package/package.json +1 -1
|
@@ -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]
|
|
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
|
|
17
|
+
/** Usage patch clears stale usage when the active branch/session has none. */
|
|
17
18
|
function usagePatch(ctx: ExtensionContext): StatusSnapshot {
|
|
18
|
-
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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
|
|
23
|
-
* Pi's native footer
|
|
24
|
-
*
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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 -
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
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 =
|
|
144
|
-
for (
|
|
145
|
-
const
|
|
146
|
-
|
|
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(
|
|
464
|
+
function boxText(_theme: BoxTheme, text: string): string {
|
|
388
465
|
return dimLine(text);
|
|
389
466
|
}
|
|
390
|
-
function boxFrameText(
|
|
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(
|
|
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 {
|