pi-jev-lens 0.2.1 → 0.4.0
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/README.md +44 -14
- package/index.ts +102 -83
- package/package.json +2 -2
- package/src/commands.ts +21 -0
- package/src/health.ts +38 -0
- package/src/secret-input.ts +124 -0
- package/src/ui.ts +55 -76
package/README.md
CHANGED
|
@@ -64,6 +64,8 @@ Three results shaped the design:
|
|
|
64
64
|
|
|
65
65
|
## Install
|
|
66
66
|
|
|
67
|
+
Use pi 0.84.3 or newer. The extension uses pi's built-in tool renderers for results that it does not compress.
|
|
68
|
+
|
|
67
69
|
```sh
|
|
68
70
|
pi install npm:pi-jev-lens # from npm
|
|
69
71
|
pi install git:github.com/dizk/pi-jev-lens # or from GitHub
|
|
@@ -73,12 +75,22 @@ jev needs a TypeSafe API key. You can get one at [console.typesafe.ai](https://c
|
|
|
73
75
|
for the key in this order:
|
|
74
76
|
|
|
75
77
|
1. `TYPESAFE_API_KEY` in the environment.
|
|
76
|
-
2.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
2. A `.env` file next to the installed package. This is for development and supplies environment values that are not already set.
|
|
79
|
+
3. The key that you stored with `/jev-lens key` inside pi. In terminal mode, the command opens a masked input field.
|
|
80
|
+
The key is stored in `~/.pi/agent/jev-lens.json`. New files are readable only by you.
|
|
81
|
+
|
|
82
|
+
Use `/jev-lens key` without an argument to keep the key out of command history. The field displays only `*` characters.
|
|
83
|
+
Type or paste the key, then press Enter to save. Press Esc to cancel without changing the stored key.
|
|
84
|
+
In RPC or noninteractive mode, set `TYPESAFE_API_KEY`. These modes do not fall back to a visible input field.
|
|
85
|
+
The key file still stores the key as plain text. Masking protects the terminal display, not the file.
|
|
86
|
+
|
|
87
|
+
You can still use `/jev-lens key ts_...`, but that exposes the key in the editor and can retain it in command history.
|
|
88
|
+
A new key takes effect without a restart, but the command does not validate it.
|
|
89
|
+
If `TYPESAFE_API_KEY` is set, that value takes priority again after reload.
|
|
90
|
+
If mock mode is forced or compression is disabled, storing a key does not change those settings.
|
|
80
91
|
|
|
81
|
-
If no key is found, the extension shows a warning at startup and
|
|
92
|
+
If no key is found, the extension shows a warning at startup and uses a deterministic mock classifier.
|
|
93
|
+
The mock can compress results without API calls. It is not the jev model.
|
|
82
94
|
|
|
83
95
|
For development, clone the repository and load it directly:
|
|
84
96
|
|
|
@@ -143,20 +155,38 @@ The footer shows the share of the session's input tokens that jev kept out of th
|
|
|
143
155
|
jev-lens −38% of input (presend −12.3k · 5/8 · 1 recalls)
|
|
144
156
|
```
|
|
145
157
|
|
|
146
|
-
The share is cut divided by sent plus cut. Sent
|
|
147
|
-
|
|
158
|
+
The share is cut divided by sent plus cut. Sent counts input and cache-read tokens reported by the provider since the last load.
|
|
159
|
+
Cut estimates what compressed results saved on those calls, including results restored from the session.
|
|
160
|
+
The percentage counts repeated savings when the same result appears in later prompts. The `presend` total counts each result once.
|
|
148
161
|
|
|
149
|
-
|
|
150
|
-
|
|
162
|
+
After `/reload` or resume, the footer includes saved tokens from restored compressed results.
|
|
163
|
+
For example, `0/3 new · 5 restored` means no new compressions among three candidates, plus five restored compressed results.
|
|
164
|
+
New-result counts and recall counts start at zero after loading. `/jev-lens stats` shows new and restored savings separately.
|
|
151
165
|
|
|
152
|
-
|
|
166
|
+
In the transcript, a compressed result shows a header like `⌁ jev-lens outline · 179 of 1524 tokens (−88 %)`. When
|
|
167
|
+
you press ctrl+o to expand, you see full output on the left and compressed output on the right.
|
|
168
|
+
Retained lines align with their originals. A `−` marks each omitted line in the full output.
|
|
169
|
+
Long lines wrap. On narrow terminals, the two versions appear one below the other.
|
|
170
|
+
The collapsed result ends with a hint such as `… (48 lines pruned, 50 original, ctrl+o for diff)`.
|
|
171
|
+
The compressed version includes omission markers but excludes the recall footer. Press ctrl+o again to collapse.
|
|
172
|
+
This uses pi's tool expansion keybinding, including any custom binding.
|
|
173
|
+
|
|
174
|
+
Type `/jev-lens ` and press Tab to complete subcommands.
|
|
175
|
+
Use `/reload` after installing the package in a running pi session.
|
|
176
|
+
|
|
177
|
+
- `/jev-lens` or `/jev-lens stats` shows the statistics, active configuration, and key source.
|
|
178
|
+
- `/jev-lens help` shows command usage.
|
|
179
|
+
- `/jev-lens decisions` shows post-send pruning decisions. Post-send pruning is off by default.
|
|
153
180
|
- `/jev-lens list` lists the latest 200 compressed results with the tokens before and after.
|
|
154
|
-
- `/jev-lens diff [n]` opens an overlay for the n-th latest result. It shows the original with the lines that the
|
|
155
|
-
model did not get marked with `−`. Press `t` to see what was sent, and `Esc` to close.
|
|
156
181
|
- `/jev-lens key` stores the API key.
|
|
157
182
|
|
|
158
|
-
jev-lens
|
|
159
|
-
|
|
183
|
+
If compression fails, jev-lens keeps the full output and shows a warning. A failed post-send classification leaves that result unchanged.
|
|
184
|
+
Warnings appear at most once per stage per session. The footer shows `degraded` until a later attempt in that stage succeeds.
|
|
185
|
+
Use `/jev-lens stats` to see failure counts and recovery status. Cancellation does not count as a failure.
|
|
186
|
+
|
|
187
|
+
Uncompressed results, errors, and streaming updates use pi's built-in tool renderers.
|
|
188
|
+
jev-lens logs every decision to `<project>/.pi/jev-lens.log` as JSON lines. Set `JEV_LENS_UI=0` to disable the
|
|
189
|
+
custom savings headers and tool overrides.
|
|
160
190
|
|
|
161
191
|
### Configuration (environment)
|
|
162
192
|
|
package/index.ts
CHANGED
|
@@ -11,18 +11,21 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
13
13
|
import { join } from "node:path";
|
|
14
|
-
import type { ExtensionAPI, ExtensionContext,
|
|
14
|
+
import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
15
15
|
import type { AgentMessage } from "./src/pi-types.ts";
|
|
16
|
-
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { CONFIG_DIR_NAME, keyText } from "@earendil-works/pi-coding-agent";
|
|
17
17
|
import { Type } from "typebox";
|
|
18
|
-
import {
|
|
18
|
+
import { createBashToolDefinition, createFindToolDefinition, createGrepToolDefinition, createLsToolDefinition, createReadToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
19
19
|
import { Text } from "@earendil-works/pi-tui";
|
|
20
|
-
import {
|
|
20
|
+
import { ComparisonResult, comparisonHint, listLines, savingsLine, type CompressedRecord } from "./src/ui.ts";
|
|
21
21
|
import { TypeSafeClient } from "@typesafe-ai/sdk";
|
|
22
22
|
import { buildItemState, JevClassifier, MockClassifier, type Classifier } from "./src/classifier.ts";
|
|
23
23
|
import { buildPresendState, decideView, DEFAULT_PROMPTS, expandRelevantBlocks, JevPresend, MockPresend, type PresendClassifier, type PromptVariant } from "./src/presend.ts";
|
|
24
24
|
import { buildCandidatesAsync, extractTerms, footer } from "./src/views.ts";
|
|
25
25
|
import { keyFilePath, loadConfigWithVariant, storeKey, type Config } from "./src/config.ts";
|
|
26
|
+
import { Health } from "./src/health.ts";
|
|
27
|
+
import { SecretInput } from "./src/secret-input.ts";
|
|
28
|
+
import { commandCompletions, commandHelp } from "./src/commands.ts";
|
|
26
29
|
import { ENTRY_TYPE, rebuildLedger } from "./src/ledger.ts";
|
|
27
30
|
import { applyLedger, decideBucket, pendingPrunable, shouldApplyPending } from "./src/policy.ts";
|
|
28
31
|
import { contentText, describeToolCall, estimateTokensOfText, toolCallsOf, truncate } from "./src/text.ts";
|
|
@@ -37,6 +40,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
37
40
|
const { cfg, variant } = loadConfigWithVariant();
|
|
38
41
|
const prompts: PromptVariant = { ...DEFAULT_PROMPTS, ...((variant.prompts ?? {}) as Partial<PromptVariant>), viewDescriptions: { ...DEFAULT_PROMPTS.viewDescriptions, ...(((variant.prompts ?? {}) as Partial<PromptVariant>).viewDescriptions ?? {}) } };
|
|
39
42
|
const viewParams = variant.views ?? {};
|
|
43
|
+
let keySource = process.env.TYPESAFE_API_KEY === cfg.apiKey && cfg.apiKey ? "env" : cfg.apiKey ? keyFilePath() : "none";
|
|
40
44
|
let usingMock = cfg.forceMock || !cfg.apiKey;
|
|
41
45
|
let classifier: Classifier = usingMock ? new MockClassifier() : new JevClassifier(cfg);
|
|
42
46
|
let presend: PresendClassifier = usingMock ? new MockPresend() : new JevPresend(new TypeSafeClient({ apiKey: cfg.apiKey }), cfg.model, prompts);
|
|
@@ -54,7 +58,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
54
58
|
const recordById = new Map<string, CompressedRecord>();
|
|
55
59
|
const remember = (r: CompressedRecord) => { records.push(r); recordById.set(r.id, r); if (records.length > 200) { const old = records.shift(); if (old) recordById.delete(old.id); } };
|
|
56
60
|
let lastAssistantText = "";
|
|
61
|
+
let health = new Health();
|
|
57
62
|
let presendTotals = { considered: 0, compressed: 0, tokensSaved: 0, recalls: 0 };
|
|
63
|
+
let restored = { compressed: 0, tokensSaved: 0 };
|
|
58
64
|
|
|
59
65
|
let ledger = new Map<string, Decision>();
|
|
60
66
|
/** Classifications launched but not yet resolved, keyed by toolCallId. */
|
|
@@ -96,14 +102,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
96
102
|
return sent > 0 ? Math.round((100 * kept) / (sent + kept)) : undefined;
|
|
97
103
|
};
|
|
98
104
|
const statusText = () => {
|
|
99
|
-
const tag = usingMock ? "jev-lens(mock)" : "jev-lens";
|
|
105
|
+
const tag = !cfg.enabled ? "jev-lens(disabled)" : usingMock ? "jev-lens(mock)" : "jev-lens";
|
|
100
106
|
const pct = cutShare();
|
|
101
|
-
const
|
|
107
|
+
const label = health.failing ? `${tag}(degraded)` : tag;
|
|
108
|
+
const lead = pct === undefined ? label : `${label} −${pct}% of input`;
|
|
102
109
|
const pruned = cfg.mode === "off" ? "" : `, pruned −${(totals.pruned / 1000).toFixed(1)}k · ${totals.applied}`;
|
|
103
|
-
|
|
110
|
+
const saved = presendTotals.tokensSaved + restored.tokensSaved;
|
|
111
|
+
const counts = `${presendTotals.compressed}/${presendTotals.considered}${restored.compressed ? ` new · ${restored.compressed} restored` : ""}`;
|
|
112
|
+
return `${lead} (presend −${(saved / 1000).toFixed(1)}k · ${counts} · ${presendTotals.recalls} recalls${pruned})`;
|
|
104
113
|
};
|
|
105
114
|
const status = (ctx: ExtensionContext) => {
|
|
106
115
|
if (!ctx.hasUI) return;
|
|
116
|
+
for (const warning of health.warnings()) ctx.ui.notify(warning, "warning");
|
|
107
117
|
ctx.ui.setStatus("jev-lens", statusText());
|
|
108
118
|
};
|
|
109
119
|
|
|
@@ -116,6 +126,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
116
126
|
sessionAbort.abort();
|
|
117
127
|
sessionAbort = new AbortController();
|
|
118
128
|
lastAssistantText = "";
|
|
129
|
+
health = new Health();
|
|
119
130
|
ledger = rebuildLedger(ctx.sessionManager.getEntries());
|
|
120
131
|
buffer = [];
|
|
121
132
|
inflight.clear();
|
|
@@ -130,6 +141,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
130
141
|
records.length = 0;
|
|
131
142
|
recordById.clear();
|
|
132
143
|
presendTotals = { considered: 0, compressed: 0, tokensSaved: 0, recalls: 0 };
|
|
144
|
+
restored = { compressed: 0, tokensSaved: 0 };
|
|
133
145
|
try {
|
|
134
146
|
mkdirSync(join(ctx.cwd, CONFIG_DIR_NAME), { recursive: true });
|
|
135
147
|
logPath = join(ctx.cwd, CONFIG_DIR_NAME, "jev-lens.log");
|
|
@@ -147,6 +159,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
147
159
|
if (d?.full) {
|
|
148
160
|
fullOutputs.set(entry.message.toolCallId, { text: d.full, toolName: entry.message.toolName, args: d.args, view: d.view ?? "?" });
|
|
149
161
|
const sent = contentText(entry.message.content).replace(/\n\n\[jev-lens:[\s\S]*$/, "");
|
|
162
|
+
restored.compressed++;
|
|
163
|
+
restored.tokensSaved += Math.max(0, estimateTokensOfText(d.full) - estimateTokensOfText(sent));
|
|
150
164
|
remember({ id: entry.message.toolCallId, toolName: entry.message.toolName, args: d.args, kind: d.kind ?? "?", view: d.view ?? "?", tokensBefore: estimateTokensOfText(d.full), tokensAfter: estimateTokensOfText(sent), full: d.full, sent, included: d.included ?? [], needsFull: d.needsFull, pFull: d.p?.full, recalls: 0, at: entry.message.timestamp });
|
|
151
165
|
}
|
|
152
166
|
}
|
|
@@ -206,20 +220,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
206
220
|
}
|
|
207
221
|
});
|
|
208
222
|
|
|
209
|
-
pi.on("agent_end", async () => {
|
|
210
|
-
const epoch = generation;
|
|
223
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
211
224
|
// No further assistant reaction is coming for the last results; classify with what we have.
|
|
212
225
|
const toClassify = buffer;
|
|
213
226
|
buffer = [];
|
|
214
|
-
for (const item of toClassify) launchClassification(item, "", [],
|
|
227
|
+
for (const item of toClassify) launchClassification(item, "", [], ctx);
|
|
215
228
|
await waitForWork([...inflight.values()]);
|
|
216
|
-
void epoch;
|
|
217
229
|
});
|
|
218
230
|
|
|
219
231
|
function launchClassification(item: PendingResult, afterText: string, afterCalls: { name: string; arguments: unknown }[], ctx?: ExtensionContext) {
|
|
220
232
|
const m = item.message;
|
|
221
233
|
if (cfg.mode === "off" || sessionAbort.signal.aborted || m.content.some((c) => c.type !== "text")) return;
|
|
222
234
|
const epoch = generation;
|
|
235
|
+
const signal = workSignal(ctx?.signal);
|
|
223
236
|
if (ledger.has(m.toolCallId) || inflight.has(m.toolCallId)) return;
|
|
224
237
|
const output = contentText(m.content);
|
|
225
238
|
const tokens = estimateTokensOfText(output);
|
|
@@ -238,9 +251,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
238
251
|
const summary = describeToolCall(m.toolName, item.args, output.length, lines);
|
|
239
252
|
const started = Date.now();
|
|
240
253
|
const p = classifier
|
|
241
|
-
.classifyToolResult(state,
|
|
254
|
+
.classifyToolResult(state, signal)
|
|
242
255
|
.then((probs) => {
|
|
243
|
-
if (epoch !== generation) return;
|
|
256
|
+
if (epoch !== generation || signal.aborted) return;
|
|
257
|
+
health.success("postsend");
|
|
258
|
+
if (ctx) status(ctx);
|
|
244
259
|
const decision: Decision = {
|
|
245
260
|
id: m.toolCallId,
|
|
246
261
|
toolName: m.toolName,
|
|
@@ -256,7 +271,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
256
271
|
log({ event: "decision", id: decision.id, tool: m.toolName, bucket: decision.bucket, p: probs, tokens, ms: Date.now() - started, summary });
|
|
257
272
|
})
|
|
258
273
|
.catch((err) => {
|
|
259
|
-
if (epoch
|
|
274
|
+
if (epoch !== generation || signal.aborted) return;
|
|
275
|
+
health.failure("postsend", err);
|
|
276
|
+
log({ event: "classify_error", id: m.toolCallId, error: health.lines()[1] });
|
|
277
|
+
if (ctx) status(ctx);
|
|
260
278
|
})
|
|
261
279
|
.finally(() => { if (epoch === generation) inflight.delete(m.toolCallId); });
|
|
262
280
|
inflight.set(m.toolCallId, p);
|
|
@@ -348,16 +366,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
348
366
|
if (event.content.some((c) => c.type === "image")) return;
|
|
349
367
|
presendTotals.considered++;
|
|
350
368
|
const started = Date.now();
|
|
351
|
-
const terms = extractTerms(latestUser, lastAssistantText, JSON.stringify(event.input ?? {}));
|
|
352
|
-
const cands = await buildCandidatesAsync(event.toolName, event.input, text, terms, viewParams);
|
|
353
|
-
if (epoch !== generation || signal.aborted) return;
|
|
354
|
-
if (cands.views.length < 2) {
|
|
355
|
-
log({ event: "presend", id: event.toolCallId, tool: event.toolName, tokens, view: "full", reason: "no-candidates" });
|
|
356
|
-
return;
|
|
357
|
-
}
|
|
358
|
-
const totalLines = text.split("\n").length;
|
|
359
|
-
const state = buildPresendState(cfg, { firstUser, latestUser, agentText: lastAssistantText, toolName: event.toolName, args: event.input, isError: event.isError, cands, totalLines, totalChars: text.length });
|
|
360
369
|
try {
|
|
370
|
+
const terms = extractTerms(latestUser, lastAssistantText, JSON.stringify(event.input ?? {}));
|
|
371
|
+
const cands = await buildCandidatesAsync(event.toolName, event.input, text, terms, viewParams);
|
|
372
|
+
if (epoch !== generation || signal.aborted) return;
|
|
373
|
+
if (cands.views.length < 2) {
|
|
374
|
+
log({ event: "presend", id: event.toolCallId, tool: event.toolName, tokens, view: "full", reason: "no-candidates" });
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const totalLines = text.split("\n").length;
|
|
378
|
+
const state = buildPresendState(cfg, { firstUser, latestUser, agentText: lastAssistantText, toolName: event.toolName, args: event.input, isError: event.isError, cands, totalLines, totalChars: text.length });
|
|
361
379
|
const answer = await presend.choose(state, cands.views.map((v) => v.kind), signal);
|
|
362
380
|
if (epoch !== generation || signal.aborted) return;
|
|
363
381
|
let view = decideView(answer, cands, cfg);
|
|
@@ -368,6 +386,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
368
386
|
if (epoch !== generation || signal.aborted) return;
|
|
369
387
|
if (ex) { view = ex.view; expanded = ex.probs.map((p, i) => (p > above ? i : -1)).filter((i) => i >= 0); }
|
|
370
388
|
}
|
|
389
|
+
health.success("presend");
|
|
390
|
+
status(ctx);
|
|
371
391
|
log({ event: "presend", id: event.toolCallId, tool: event.toolName, kind: cands.kind, tokens, view: view.kind, viewTokens: estimateTokensOfText(view.text), chosen: answer.choice, needsFull: answer.needsFull, p: answer.probabilities, confidence: answer.confidence, expanded, candidates: cands.views.map((v) => `${v.kind}:${v.chars}`), ms: Date.now() - started });
|
|
372
392
|
if (view.kind === "full") return;
|
|
373
393
|
presendTotals.compressed++;
|
|
@@ -378,7 +398,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
378
398
|
status(ctx);
|
|
379
399
|
return { content: [{ type: "text", text: view.text + footer(view, event.toolCallId, totalLines) }], details };
|
|
380
400
|
} catch (err) {
|
|
381
|
-
if (epoch
|
|
401
|
+
if (epoch !== generation || signal.aborted) return;
|
|
402
|
+
health.failure("presend", err);
|
|
403
|
+
log({ event: "presend_error", id: event.toolCallId, error: health.lines()[0] });
|
|
404
|
+
status(ctx);
|
|
382
405
|
return;
|
|
383
406
|
}
|
|
384
407
|
});
|
|
@@ -427,96 +450,92 @@ export default function (pi: ExtensionAPI) {
|
|
|
427
450
|
|
|
428
451
|
if (process.env.JEV_LENS_UI !== "0") {
|
|
429
452
|
const cwd = process.cwd();
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
};
|
|
437
|
-
for (const [name, original] of Object.entries(originals)) {
|
|
438
|
-
const o = original as unknown as { description: string; parameters: never; execute: (...a: unknown[]) => Promise<unknown>; renderCall?: (...a: unknown[]) => unknown; renderResult?: (...a: unknown[]) => unknown; promptSnippet?: string; promptGuidelines?: string[] };
|
|
453
|
+
// Tool definitions include pi's renderers; create*Tool() strips them.
|
|
454
|
+
const originals: ToolDefinition<any, any>[] = [
|
|
455
|
+
createReadToolDefinition(cwd), createBashToolDefinition(cwd),
|
|
456
|
+
createGrepToolDefinition(cwd), createFindToolDefinition(cwd), createLsToolDefinition(cwd),
|
|
457
|
+
];
|
|
458
|
+
for (const original of originals) {
|
|
439
459
|
pi.registerTool({
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
const a = args as Record<string, unknown>;
|
|
452
|
-
const what = typeof a.path === "string" ? a.path : typeof a.command === "string" ? a.command : typeof a.pattern === "string" ? a.pattern : "";
|
|
453
|
-
return new Text(theme.fg("toolTitle", theme.bold(`${name} `)) + theme.fg("accent", String(what)), 0, 0);
|
|
454
|
-
},
|
|
455
|
-
renderResult(result: { content: unknown }, options: { expanded: boolean }, theme: Theme, context: { toolCallId: string }) {
|
|
456
|
-
const rec = recordById.get(context.toolCallId);
|
|
457
|
-
if (!rec) {
|
|
458
|
-
if (o.renderResult) return (o.renderResult as (r: unknown, op: unknown, t: unknown, c: unknown) => never)(result, options, theme, context);
|
|
459
|
-
const text = contentText(result.content);
|
|
460
|
-
const lines = text.split("\n");
|
|
461
|
-
let out = theme.fg("success", `${lines.length} lines`);
|
|
462
|
-
if (options.expanded) out += "\n" + lines.slice(0, 200).join("\n");
|
|
463
|
-
else out += theme.fg("dim", " " + lines[0]?.slice(0, 80));
|
|
464
|
-
return new Text(out, 0, 0);
|
|
460
|
+
...original,
|
|
461
|
+
renderResult(result, options, theme, context) {
|
|
462
|
+
let rec = recordById.get(context.toolCallId);
|
|
463
|
+
// Older rows can leave the recent-results index, but retain their comparison.
|
|
464
|
+
const d = result.details?.jevLens;
|
|
465
|
+
if (!rec && typeof d?.full === "string") {
|
|
466
|
+
const sent = contentText(result.content).replace(/\n\n\[jev-lens:[\s\S]*$/, "");
|
|
467
|
+
rec = { id: context.toolCallId, toolName: original.name, args: d.args,
|
|
468
|
+
kind: d.kind ?? "?", view: d.view ?? "?", full: d.full, sent,
|
|
469
|
+
tokensBefore: estimateTokensOfText(d.full), tokensAfter: estimateTokensOfText(sent),
|
|
470
|
+
included: d.included ?? [], recalls: 0, at: 0 };
|
|
465
471
|
}
|
|
472
|
+
if (!rec || options.isPartial || context.isError) {
|
|
473
|
+
return original.renderResult!(result, options, theme, context);
|
|
474
|
+
}
|
|
475
|
+
if (options.expanded) return new ComparisonResult(rec, theme, `${keyText("app.tools.expand")} to collapse`);
|
|
466
476
|
let out = savingsLine(rec, theme);
|
|
467
|
-
|
|
468
|
-
|
|
477
|
+
out += "\n" + theme.fg("dim", rec.sent.split("\n").slice(0, 3).join("\n"));
|
|
478
|
+
out += "\n" + theme.fg("dim", comparisonHint(rec, keyText("app.tools.expand")));
|
|
469
479
|
return new Text(out, 0, 0);
|
|
470
480
|
},
|
|
471
|
-
}
|
|
481
|
+
});
|
|
472
482
|
}
|
|
473
483
|
}
|
|
474
484
|
|
|
475
485
|
// ---- commands ----------------------------------------------------------------------
|
|
476
486
|
|
|
477
487
|
pi.registerCommand("jev-lens", {
|
|
478
|
-
description: "
|
|
488
|
+
description: "Inspect compression and setup: stats | list | decisions | key | help",
|
|
489
|
+
getArgumentCompletions: (prefix) => commandCompletions(prefix),
|
|
479
490
|
handler: async (args, ctx) => {
|
|
480
491
|
const sub = (args ?? "").trim();
|
|
481
|
-
if (sub === "
|
|
492
|
+
if (sub === "help" || sub === "--help" || sub === "-h") {
|
|
493
|
+
ctx.ui.notify(commandHelp, "info");
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (/^key(?:\s|$)/.test(sub)) {
|
|
482
497
|
let key = sub.slice(3).trim();
|
|
483
|
-
if (!key
|
|
484
|
-
if (!key)
|
|
485
|
-
|
|
498
|
+
if (!key && (!ctx.hasUI || ctx.mode !== "tui")) { ctx.ui.notify("Masked key input requires terminal mode. Set TYPESAFE_API_KEY or run /jev-lens key in interactive pi.", "warning"); return; }
|
|
499
|
+
if (!key) key = ((await ctx.ui.custom<string | undefined>((tui, theme, keys, done) =>
|
|
500
|
+
new SecretInput(theme, keys, done, () => tui.requestRender()),
|
|
501
|
+
)) ?? "").trim();
|
|
502
|
+
if (!key) { ctx.ui.notify("Key setup cancelled. The current key is unchanged.", "info"); return; }
|
|
503
|
+
let where: string;
|
|
504
|
+
try { where = storeKey(key); }
|
|
505
|
+
catch {
|
|
506
|
+
ctx.ui.notify(`Could not store the key in ${keyFilePath()}. Check directory permissions or set TYPESAFE_API_KEY.`, "error");
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
486
509
|
useKey(key);
|
|
487
|
-
|
|
510
|
+
keySource = where;
|
|
511
|
+
const next = cfg.forceMock ? "Mock mode remains active. Unset JEV_LENS_CLASSIFIER and reload pi to use jev." : !cfg.enabled || !cfg.presend ? "Pre-send compression is disabled. See /jev-lens stats." : "jev will use this key from the next tool result. The key has not been validated.";
|
|
512
|
+
ctx.ui.notify(`jev-lens: key stored in ${where}. ${next}${process.env.TYPESAFE_API_KEY ? " TYPESAFE_API_KEY takes priority again after reload." : ""}`, "info");
|
|
488
513
|
status(ctx);
|
|
489
514
|
return;
|
|
490
515
|
}
|
|
491
|
-
if (sub.startsWith("diff")) {
|
|
492
|
-
const n = Number(sub.slice(4).trim() || "1");
|
|
493
|
-
const rec = records[records.length - (Number.isFinite(n) && n >= 1 ? n : 1)];
|
|
494
|
-
if (!rec) { ctx.ui.notify("no compressed tool result to show yet", "info"); return; }
|
|
495
|
-
if (!ctx.hasUI || ctx.mode !== "tui") { ctx.ui.notify(listLines([rec], { fg: (_c, t) => t, bold: (t) => t }).join("\n"), "info"); return; }
|
|
496
|
-
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
|
497
|
-
const height = Math.max(12, Math.floor(((tui as { terminalHeight?: number }).terminalHeight ?? process.stdout.rows ?? 40) * 0.85));
|
|
498
|
-
const overlay = new DiffOverlay(rec, theme, height, () => done(), () => tui.requestRender());
|
|
499
|
-
return { render: (w) => overlay.render(w), handleInput: (d) => overlay.handleInput(d), invalidate: () => overlay.invalidate() };
|
|
500
|
-
}, { overlay: true, overlayOptions: { width: "92%", maxHeight: "90%", anchor: "center" } });
|
|
501
|
-
return;
|
|
502
|
-
}
|
|
503
516
|
if (sub === "list") {
|
|
504
517
|
ctx.ui.notify(listLines(records, { fg: (_c, t) => t, bold: (t) => t }).join("\n"), "info");
|
|
505
518
|
return;
|
|
506
519
|
}
|
|
507
520
|
if (sub === "decisions") {
|
|
508
521
|
const rows = [...ledger.values()].map((d) => `${d.status === "applied" ? "●" : "○"} ${d.bucket.padEnd(6)} n=${d.p.needed.toFixed(2)} o=${d.p.outcomeOnly.toFixed(2)} ${d.tokensBefore}t ${d.summary}`);
|
|
509
|
-
ctx.ui.notify(rows.join("\n") || "(
|
|
522
|
+
ctx.ui.notify(rows.join("\n") || (cfg.mode === "off" ? "Post-send pruning is off (the default). Pre-send compression is separate: see /jev-lens stats." : "No post-send decisions yet."), "info");
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
if (sub && sub !== "stats") {
|
|
526
|
+
ctx.ui.notify("Unknown subcommand or extra arguments. Run /jev-lens help for usage.", "warning");
|
|
510
527
|
return;
|
|
511
528
|
}
|
|
512
529
|
const hit = totals.input + totals.cacheRead > 0 ? Math.round((100 * totals.cacheRead) / (totals.input + totals.cacheRead)) : 0;
|
|
513
530
|
ctx.ui.notify(
|
|
514
531
|
[
|
|
515
|
-
`mode=${cfg.mode} enabled=${cfg.enabled} classifier=${usingMock ? "mock (no key: /jev-lens key)" : cfg.model} key=${
|
|
516
|
-
`presend: ${presendTotals.compressed}/${presendTotals.considered} large results compressed, ≈${presendTotals.tokensSaved} tokens saved, ${presendTotals.recalls} recalls`,
|
|
532
|
+
`mode=${cfg.mode} enabled=${cfg.enabled} presend=${cfg.presend} classifier=${usingMock ? cfg.forceMock ? "mock (forced by JEV_LENS_CLASSIFIER)" : "mock (no key: /jev-lens key)" : cfg.model} key=${keySource}`,
|
|
533
|
+
`presend since load: ${presendTotals.compressed}/${presendTotals.considered} large results compressed, ≈${presendTotals.tokensSaved} tokens saved, ${presendTotals.recalls} recalls`,
|
|
534
|
+
`restored from session: ${restored.compressed} compressed results, ≈${restored.tokensSaved} tokens saved (included in footer savings)`,
|
|
517
535
|
`post-send: calls=${totals.calls} decisions=${ledger.size} applied=${totals.applied} pruned≈${totals.pruned} tokens`,
|
|
536
|
+
...health.lines(),
|
|
518
537
|
`cache: read=${totals.cacheRead} uncached=${totals.input} hit=${hit}%`,
|
|
519
|
-
`input cut: ${cutShare() ?? 0}% of
|
|
538
|
+
`input cut: ${cutShare() ?? 0}% of input tokens counted since load (≈${cut.presend + cut.pruned} of ${totals.input + totals.cacheRead + cut.presend + cut.pruned}: presend ${cut.presend}, pruned ${cut.pruned}, summed over ${totals.calls} calls)`,
|
|
520
539
|
].join("\n"),
|
|
521
540
|
"info",
|
|
522
541
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-jev-lens",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "pi extension that compresses large tool results before they reach the model: jev picks the view (outline, relevant blocks, sections, signals, testlog), full text stays recallable",
|
|
5
5
|
"author": "Didrik Rognstad",
|
|
6
6
|
"license": "MIT",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"web-tree-sitter": "^0.27.0"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
|
-
"@earendil-works/pi-coding-agent": "
|
|
54
|
+
"@earendil-works/pi-coding-agent": ">=0.84.3",
|
|
55
55
|
"@earendil-works/pi-tui": "*",
|
|
56
56
|
"typebox": "*"
|
|
57
57
|
},
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
const commands = [
|
|
4
|
+
{ value: "stats", label: "stats", description: "Show statistics and active configuration" },
|
|
5
|
+
{ value: "list", label: "list", description: "List recent compressed results" },
|
|
6
|
+
{ value: "decisions", label: "decisions", description: "Show post-send pruning decisions" },
|
|
7
|
+
{ value: "key", label: "key", description: "Store a TypeSafe API key" },
|
|
8
|
+
{ value: "help", label: "help", description: "Show commands and usage" },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export const commandHelp = [
|
|
12
|
+
"/jev-lens [stats] — Show statistics and active configuration.",
|
|
13
|
+
...commands.slice(1).map((c) => `/jev-lens ${c.value} — ${c.description}.`),
|
|
14
|
+
"ctrl+o to expand tool output and compare full and compressed output (default keybinding).",
|
|
15
|
+
"Press Tab after /jev-lens to complete a subcommand.",
|
|
16
|
+
].join("\n");
|
|
17
|
+
|
|
18
|
+
export function commandCompletions(prefix: string): AutocompleteItem[] | null {
|
|
19
|
+
const items = commands.filter((c) => c.value.startsWith(prefix.trimStart()));
|
|
20
|
+
return items.length ? items : null;
|
|
21
|
+
}
|
package/src/health.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export type Stage = "presend" | "postsend";
|
|
2
|
+
|
|
3
|
+
type StageHealth = { failures: number; failing: boolean; reason: string; notified: boolean };
|
|
4
|
+
|
|
5
|
+
/** Session-local failure counters. Never expose provider error messages or credentials. */
|
|
6
|
+
export class Health {
|
|
7
|
+
private stages: Record<Stage, StageHealth> = {
|
|
8
|
+
presend: { failures: 0, failing: false, reason: "", notified: false },
|
|
9
|
+
postsend: { failures: 0, failing: false, reason: "", notified: false },
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
failure(stage: Stage, error: unknown): void {
|
|
13
|
+
const status = error && typeof error === "object" && "status" in error ? error.status : undefined;
|
|
14
|
+
const reason = status === 401 || status === 403 ? "Check your TypeSafe API key."
|
|
15
|
+
: status === 429 ? "TypeSafe rejected the request because of a usage limit."
|
|
16
|
+
: "Check your connection and TypeSafe service availability.";
|
|
17
|
+
Object.assign(this.stages[stage], { failures: this.stages[stage].failures + 1, failing: true, reason });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
success(stage: Stage): void { this.stages[stage].failing = false; }
|
|
21
|
+
|
|
22
|
+
get failing(): boolean { return Object.values(this.stages).some((s) => s.failing); }
|
|
23
|
+
|
|
24
|
+
/** At most one warning per stage per session, including work completed without a UI context. */
|
|
25
|
+
warnings(): string[] {
|
|
26
|
+
return (Object.entries(this.stages) as [Stage, StageHealth][]).flatMap(([stage, state]) => {
|
|
27
|
+
if (!state.failures || state.notified) return [];
|
|
28
|
+
state.notified = true;
|
|
29
|
+
const effect = stage === "presend" ? "Full output was kept." : "The affected result was not pruned.";
|
|
30
|
+
return [`jev-lens: ${stage === "presend" ? "Pre-send compression" : "Post-send classification"} failed. ${effect} ${state.reason} See /jev-lens stats. Further failures appear there without repeated warnings.`];
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
lines(): string[] {
|
|
35
|
+
return (Object.entries(this.stages) as [Stage, StageHealth][]).map(([stage, state]) =>
|
|
36
|
+
`${stage} failures: ${state.failures}${state.failures ? state.failing ? ` (last attempt failed). ${state.reason}` : " (a later attempt succeeded)" : ""}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { CURSOR_MARKER, decodeKittyPrintable, truncateToWidth, type Component, type Focusable, type KeybindingsManager } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { ThemeLike } from "./ui.ts";
|
|
3
|
+
|
|
4
|
+
const PASTE_START = "\x1b[200~", PASTE_END = "\x1b[201~";
|
|
5
|
+
const MAX_LENGTH = 4096;
|
|
6
|
+
|
|
7
|
+
/** A secret-only editor: no plaintext rendering, history, clipboard, undo, or reveal action. */
|
|
8
|
+
export class SecretInput implements Component, Focusable {
|
|
9
|
+
focused = false;
|
|
10
|
+
private value: string[] = [];
|
|
11
|
+
private cursor = 0;
|
|
12
|
+
private paste: string | undefined;
|
|
13
|
+
private pasteTooLong = false;
|
|
14
|
+
private error = "";
|
|
15
|
+
private closed = false;
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
private theme: ThemeLike,
|
|
19
|
+
private keys: Pick<KeybindingsManager, "matches" | "getKeys">,
|
|
20
|
+
private done: (value: string | undefined) => void,
|
|
21
|
+
private requestRender: () => void,
|
|
22
|
+
) {}
|
|
23
|
+
|
|
24
|
+
private insert(text: string): void {
|
|
25
|
+
const chars = Array.from(text);
|
|
26
|
+
if (/[\x00-\x1f\x7f-\x9f]/.test(text)) {
|
|
27
|
+
this.error = "Paste a single-line API key. Control characters are not allowed.";
|
|
28
|
+
} else if (this.value.length + chars.length > MAX_LENGTH) {
|
|
29
|
+
this.error = `The key is too long. Maximum: ${MAX_LENGTH} characters.`;
|
|
30
|
+
} else {
|
|
31
|
+
this.value.splice(this.cursor, 0, ...chars);
|
|
32
|
+
this.cursor += chars.length;
|
|
33
|
+
this.error = "";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private finish(value?: string): void {
|
|
38
|
+
this.dispose();
|
|
39
|
+
this.done(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Drop references on submit, cancel, or external teardown. JS cannot guarantee memory erasure. */
|
|
43
|
+
dispose(): void {
|
|
44
|
+
this.value.fill("");
|
|
45
|
+
this.value = [];
|
|
46
|
+
this.cursor = 0;
|
|
47
|
+
this.paste = undefined;
|
|
48
|
+
this.error = "";
|
|
49
|
+
this.closed = true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
handleInput(data: string): void {
|
|
53
|
+
if (this.closed) return;
|
|
54
|
+
if (this.paste === undefined && data.startsWith(PASTE_START)) {
|
|
55
|
+
this.paste = "";
|
|
56
|
+
this.pasteTooLong = false;
|
|
57
|
+
data = data.slice(PASTE_START.length);
|
|
58
|
+
}
|
|
59
|
+
if (this.paste !== undefined) {
|
|
60
|
+
this.paste += data;
|
|
61
|
+
const end = this.paste.indexOf(PASTE_END);
|
|
62
|
+
if (end >= 0) {
|
|
63
|
+
const text = this.paste.slice(0, end);
|
|
64
|
+
const remaining = this.paste.slice(end + PASTE_END.length);
|
|
65
|
+
this.paste = undefined;
|
|
66
|
+
if (this.pasteTooLong) this.error = "The pasted key is too long. Paste only the API key.";
|
|
67
|
+
else this.insert(text.trim());
|
|
68
|
+
if (remaining) this.handleInput(remaining);
|
|
69
|
+
} else if (this.paste.length > MAX_LENGTH + PASTE_END.length) {
|
|
70
|
+
this.pasteTooLong = true;
|
|
71
|
+
this.paste = this.paste.slice(-PASTE_END.length); // retain a possible split end marker
|
|
72
|
+
}
|
|
73
|
+
} else if (this.keys.matches(data, "tui.select.cancel")) {
|
|
74
|
+
this.finish();
|
|
75
|
+
} else if (this.keys.matches(data, "tui.input.submit")) {
|
|
76
|
+
if (!this.error) this.finish(this.value.join("").trim() || undefined);
|
|
77
|
+
} else if (this.keys.matches(data, "tui.editor.cursorLeft")) {
|
|
78
|
+
this.cursor = Math.max(0, this.cursor - 1);
|
|
79
|
+
} else if (this.keys.matches(data, "tui.editor.cursorRight")) {
|
|
80
|
+
this.cursor = Math.min(this.value.length, this.cursor + 1);
|
|
81
|
+
} else if (this.keys.matches(data, "tui.editor.cursorLineStart")) {
|
|
82
|
+
this.cursor = 0;
|
|
83
|
+
} else if (this.keys.matches(data, "tui.editor.cursorLineEnd")) {
|
|
84
|
+
this.cursor = this.value.length;
|
|
85
|
+
} else if (this.keys.matches(data, "tui.editor.deleteCharBackward")) {
|
|
86
|
+
if (this.cursor) this.value.splice(--this.cursor, 1);
|
|
87
|
+
this.error = "";
|
|
88
|
+
} else if (this.keys.matches(data, "tui.editor.deleteCharForward")) {
|
|
89
|
+
this.value.splice(this.cursor, 1);
|
|
90
|
+
this.error = "";
|
|
91
|
+
} else if (this.keys.matches(data, "tui.editor.deleteToLineStart")) {
|
|
92
|
+
this.value.splice(0, this.cursor);
|
|
93
|
+
this.cursor = 0;
|
|
94
|
+
this.error = "";
|
|
95
|
+
} else if (this.keys.matches(data, "tui.editor.deleteToLineEnd")) {
|
|
96
|
+
this.value.splice(this.cursor);
|
|
97
|
+
this.error = "";
|
|
98
|
+
} else {
|
|
99
|
+
const text = decodeKittyPrintable(data) ?? data;
|
|
100
|
+
if (!/[\x00-\x1f\x7f-\x9f]/.test(text)) this.insert(text);
|
|
101
|
+
}
|
|
102
|
+
this.requestRender();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
invalidate(): void {}
|
|
106
|
+
|
|
107
|
+
render(width: number): string[] {
|
|
108
|
+
if (width < 1) return [""];
|
|
109
|
+
const room = Math.max(1, width - 2);
|
|
110
|
+
const start = Math.max(0, this.cursor - room + 1);
|
|
111
|
+
const before = "*".repeat(this.cursor - start);
|
|
112
|
+
const after = "*".repeat(Math.min(this.value.length - this.cursor, room - before.length - 1));
|
|
113
|
+
const cursor = this.cursor < this.value.length ? "*" : " ";
|
|
114
|
+
const marker = this.focused ? CURSOR_MARKER : "";
|
|
115
|
+
const field = `${width > 2 ? "> " : ""}${before}${marker}${this.focused ? `\x1b[7m${cursor}\x1b[27m` : cursor}${after}`;
|
|
116
|
+
return [
|
|
117
|
+
this.theme.fg("accent", "TypeSafe API key (masked)"),
|
|
118
|
+
this.theme.fg("dim", "Get a key at console.typesafe.ai. Type or paste it below."),
|
|
119
|
+
field,
|
|
120
|
+
this.theme.fg("dim", `${this.keys.getKeys("tui.input.submit").join("/")}: save · ${this.keys.getKeys("tui.select.cancel").join("/")}: cancel`),
|
|
121
|
+
...(this.error ? [this.theme.fg("warning", this.error)] : []),
|
|
122
|
+
].map((line) => truncateToWidth(line, width));
|
|
123
|
+
}
|
|
124
|
+
}
|
package/src/ui.ts
CHANGED
|
@@ -2,11 +2,9 @@
|
|
|
2
2
|
* TUI integration: what the model got versus what the tool really returned.
|
|
3
3
|
*
|
|
4
4
|
* - Built-in tools (read, bash, grep, find, ls) are re-registered with renderers that show, for
|
|
5
|
-
* a compressed result, a
|
|
6
|
-
* - `/jev-lens diff [n]` opens an overlay with the original output, omitted lines marked, and
|
|
7
|
-
* `t` toggles to the sent view.
|
|
5
|
+
* a compressed result, a savings header and an inline comparison when expanded.
|
|
8
6
|
*/
|
|
9
|
-
import {
|
|
7
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
10
8
|
|
|
11
9
|
/** Everything needed to show one compressed result. Stored per session and in the tool result details. */
|
|
12
10
|
export interface CompressedRecord {
|
|
@@ -39,11 +37,16 @@ export function savingsLine(r: CompressedRecord, theme: ThemeLike): string {
|
|
|
39
37
|
theme.fg("accent", "⌁ jev-lens ") +
|
|
40
38
|
theme.fg("toolTitle", theme.bold(r.view)) +
|
|
41
39
|
theme.fg("muted", ` · ${r.tokensAfter} of ${r.tokensBefore} tokens (−${pct} %)`) +
|
|
42
|
-
(r.recalls ? theme.fg("warning", ` · recalled ${r.recalls}×`) : "")
|
|
43
|
-
theme.fg("dim", " · /jev-lens diff")
|
|
40
|
+
(r.recalls ? theme.fg("warning", ` · recalled ${r.recalls}×`) : "")
|
|
44
41
|
);
|
|
45
42
|
}
|
|
46
43
|
|
|
44
|
+
export function comparisonHint(r: CompressedRecord, key: string): string {
|
|
45
|
+
const original = r.full.split("\n").length;
|
|
46
|
+
const pruned = original - new Set(r.included).size;
|
|
47
|
+
return `… (${pruned} ${pruned === 1 ? "line" : "lines"} pruned, ${original} original, ${key} for diff)`;
|
|
48
|
+
}
|
|
49
|
+
|
|
47
50
|
function describeArgs(toolName: string, args: unknown): string {
|
|
48
51
|
const a = (args ?? {}) as Record<string, unknown>;
|
|
49
52
|
if (typeof a.path === "string") return a.path;
|
|
@@ -52,83 +55,59 @@ function describeArgs(toolName: string, args: unknown): string {
|
|
|
52
55
|
return JSON.stringify(a).slice(0, 80);
|
|
53
56
|
}
|
|
54
57
|
|
|
55
|
-
/**
|
|
56
|
-
|
|
57
|
-
* what was sent (mode "sent"). Keys: ↑/↓ scroll, PgUp/PgDn, Home/End, t toggle, Esc/q close.
|
|
58
|
-
*/
|
|
59
|
-
export class DiffOverlay {
|
|
60
|
-
private offset = 0;
|
|
61
|
-
private mode: "annotated" | "sent" = "annotated";
|
|
58
|
+
/** Inline comparison. Pi owns expansion and transcript scrolling; no extra key handler. */
|
|
59
|
+
export class ComparisonResult {
|
|
62
60
|
private cache?: { width: number; lines: string[] };
|
|
63
|
-
constructor(
|
|
64
|
-
private record: CompressedRecord,
|
|
65
|
-
private theme: ThemeLike,
|
|
66
|
-
private height: number,
|
|
67
|
-
private onClose: () => void,
|
|
68
|
-
private onChange?: () => void,
|
|
69
|
-
) {}
|
|
61
|
+
constructor(private record: CompressedRecord, private theme: ThemeLike, private hint: string) {}
|
|
70
62
|
|
|
71
|
-
|
|
63
|
+
render(width: number): string[] {
|
|
64
|
+
if (width < 1) return [];
|
|
65
|
+
if (this.cache?.width === width) return this.cache.lines;
|
|
72
66
|
const r = this.record;
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
67
|
+
const full = r.full.split("\n");
|
|
68
|
+
const included = new Set(r.included);
|
|
69
|
+
const digits = String(full.length).length;
|
|
70
|
+
const original = (i: number) => this.theme.fg(included.has(i + 1) ? "text" : "toolDiffRemoved",
|
|
71
|
+
`${String(i + 1).padStart(digits)} ${included.has(i + 1) ? "│" : "−"} ${full[i]}`);
|
|
72
|
+
const wrap = (text: string, columns: number) => wrapTextWithAnsi(text.replace(/\t/g, " "), columns)
|
|
73
|
+
.map((line) => truncateToWidth(line, columns));
|
|
74
|
+
const out = wrap(savingsLine(r, this.theme), width);
|
|
75
|
+
out.push(...wrap(this.theme.fg("dim", this.hint), width));
|
|
76
|
+
out.push(...wrap(this.theme.fg("dim", "− omitted from model input · compressed view excludes the recall footer"), width));
|
|
77
|
+
if (width < 100) {
|
|
78
|
+
out.push(...wrap(this.theme.bold("Full output"), width));
|
|
79
|
+
for (let i = 0; i < full.length; i++) out.push(...wrap(original(i), width));
|
|
80
|
+
out.push("", ...wrap(this.theme.bold("Compressed output"), width));
|
|
81
|
+
for (const line of r.sent.split("\n")) out.push(...wrap(line, width));
|
|
82
|
+
} else {
|
|
83
|
+
const leftWidth = Math.floor((width - 3) / 2);
|
|
84
|
+
const rightWidth = width - leftWidth - 3;
|
|
85
|
+
const row = (left: string, right: string) => {
|
|
86
|
+
const a = wrap(left, leftWidth), b = wrap(right, rightWidth);
|
|
87
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
88
|
+
const l = a[i] ?? "";
|
|
89
|
+
out.push(l + " ".repeat(Math.max(0, leftWidth - visibleWidth(l))) + this.theme.fg("dim", " │ ") + (b[i] ?? ""));
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
row(this.theme.bold("Full output"), this.theme.bold("Compressed output"));
|
|
93
|
+
let cursor = 0;
|
|
94
|
+
// Align numbered view lines with their originals. Keep markers and any other
|
|
95
|
+
// generated view text verbatim on separate rows, rather than reconstructing it.
|
|
96
|
+
for (const line of r.sent.split("\n")) {
|
|
97
|
+
const match = /^\s*(\d+)│ /.exec(line);
|
|
98
|
+
const n = match ? Number(match[1]) : 0;
|
|
99
|
+
if (n > cursor && n <= full.length && included.has(n)) {
|
|
100
|
+
while (cursor < n - 1) row(original(cursor++), "");
|
|
101
|
+
row(original(cursor++), line);
|
|
102
|
+
} else row("", line);
|
|
103
|
+
}
|
|
104
|
+
while (cursor < full.length) row(original(cursor++), "");
|
|
85
105
|
}
|
|
106
|
+
this.cache = { width, lines: out };
|
|
86
107
|
return out;
|
|
87
108
|
}
|
|
88
109
|
|
|
89
|
-
|
|
90
|
-
const r = this.record;
|
|
91
|
-
const pct = r.tokensBefore ? Math.round((100 * (r.tokensBefore - r.tokensAfter)) / r.tokensBefore) : 0;
|
|
92
|
-
const omitted = r.full.split("\n").length - r.included.length;
|
|
93
|
-
return [
|
|
94
|
-
truncateToWidth(this.theme.fg("accent", this.theme.bold(`jev-lens · ${r.toolName} ${describeArgs(r.toolName, r.args)}`)), width),
|
|
95
|
-
truncateToWidth(this.theme.fg("muted", `${r.kind} → ${r.view} · ${r.tokensAfter} of ${r.tokensBefore} tokens (−${pct} %) · ${omitted} of ${r.full.split("\n").length} lines omitted` + (r.needsFull !== undefined ? ` · P(needs full)=${r.needsFull.toFixed(2)} P(full)=${(r.pFull ?? 0).toFixed(2)}` : "") + (r.recalls ? ` · recalled ${r.recalls}×` : "")), width),
|
|
96
|
-
truncateToWidth(this.theme.fg("dim", this.mode === "annotated" ? "original output; − marks lines the model did not get · t: show what was sent · ↑↓ PgUp PgDn · Esc" : "exactly what the model got · t: show original with omissions · ↑↓ PgUp PgDn · Esc"), width),
|
|
97
|
-
"",
|
|
98
|
-
];
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
render(width: number): string[] {
|
|
102
|
-
if (this.cache && this.cache.width === width) return this.cache.lines;
|
|
103
|
-
const head = this.header(width);
|
|
104
|
-
const body = this.bodyLines(width);
|
|
105
|
-
const room = Math.max(3, this.height - head.length - 1);
|
|
106
|
-
const maxOffset = Math.max(0, body.length - room);
|
|
107
|
-
if (this.offset > maxOffset) this.offset = maxOffset;
|
|
108
|
-
const slice = body.slice(this.offset, this.offset + room);
|
|
109
|
-
const footer = truncateToWidth(this.theme.fg("dim", `lines ${body.length ? this.offset + 1 : 0}-${Math.min(body.length, this.offset + room)} of ${body.length}`), width);
|
|
110
|
-
this.cache = { width, lines: [...head, ...slice, footer] };
|
|
111
|
-
return this.cache.lines;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
handleInput(data: string): void {
|
|
115
|
-
const page = Math.max(1, this.height - 6);
|
|
116
|
-
if (matchesKey(data, "escape") || data === "q") { this.onClose(); return; }
|
|
117
|
-
else if (matchesKey(data, "up")) this.offset = Math.max(0, this.offset - 1);
|
|
118
|
-
else if (matchesKey(data, "down")) this.offset += 1;
|
|
119
|
-
else if (matchesKey(data, "pageUp")) this.offset = Math.max(0, this.offset - page);
|
|
120
|
-
else if (matchesKey(data, "pageDown")) this.offset += page;
|
|
121
|
-
else if (matchesKey(data, "home")) this.offset = 0;
|
|
122
|
-
else if (matchesKey(data, "end")) this.offset = Number.MAX_SAFE_INTEGER;
|
|
123
|
-
else if (data === "t") { this.mode = this.mode === "annotated" ? "sent" : "annotated"; this.offset = 0; }
|
|
124
|
-
else return;
|
|
125
|
-
this.invalidate();
|
|
126
|
-
this.onChange?.();
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
invalidate(): void {
|
|
130
|
-
this.cache = undefined;
|
|
131
|
-
}
|
|
110
|
+
invalidate(): void { this.cache = undefined; }
|
|
132
111
|
}
|
|
133
112
|
|
|
134
113
|
/** One row per compressed result, newest last. */
|