pi-ui-extension 1.1.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 +43 -0
- package/extensions/ctx/README.md +35 -0
- package/extensions/ctx/activity.ts +226 -0
- package/extensions/ctx/data.test.ts +65 -0
- package/extensions/ctx/data.ts +230 -0
- package/extensions/ctx/panel.test.ts +103 -0
- package/extensions/ctx/panel.ts +185 -0
- package/extensions/ctx/tools.test.ts +64 -0
- package/extensions/fabric-detection.test.ts +15 -0
- package/extensions/fabric-detection.ts +7 -0
- package/extensions/footer/index.ts +62 -0
- package/extensions/index.ts +577 -0
- package/extensions/mcp/integration.test.ts +64 -0
- package/extensions/mcp/resources.test.ts +27 -0
- package/extensions/mcp/resources.ts +31 -0
- package/extensions/resource-dialog.test.ts +45 -0
- package/extensions/session/mutations.test.ts +30 -0
- package/extensions/session/mutations.ts +17 -0
- package/extensions/session/panel.test.ts +238 -0
- package/extensions/session/panel.ts +461 -0
- package/extensions/sidebar/default-panels.test.ts +97 -0
- package/extensions/sidebar/default-panels.ts +155 -0
- package/extensions/sidebar/index.ts +212 -0
- package/extensions/sidebar/layout.test.ts +68 -0
- package/extensions/sidebar/layout.ts +75 -0
- package/extensions/sidebar/panel.ts +49 -0
- package/extensions/sidebar/registry.ts +35 -0
- package/extensions/sidebar/resize.test.ts +71 -0
- package/extensions/sidebar/resize.ts +154 -0
- package/extensions/tool-view/controller.test.ts +57 -0
- package/extensions/tool-view/controller.ts +519 -0
- package/extensions/tool-view/panel.test.ts +73 -0
- package/extensions/tool-view/panel.ts +221 -0
- package/extensions/tool-view/trust.test.ts +41 -0
- package/extensions/tool-view/types.ts +6 -0
- package/extensions/tool-view/usage.test.ts +231 -0
- package/extensions/tool-view/usage.ts +303 -0
- package/extensions/tool-view/wheel.test.ts +32 -0
- package/package.json +53 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { visibleWidth, ScrollView } from "@earendil-works/pi-tui";
|
|
4
|
+
import { renderLayoutFrame } from "@earendil-works/pi-tui/dist/layout.js";
|
|
5
|
+
import { ContextPanel } from "./panel.ts";
|
|
6
|
+
import { estimateContext } from "./data.ts";
|
|
7
|
+
|
|
8
|
+
for (const width of [18, 30, 50]) test(`composition is always visible below a separator at width ${width}`, () => {
|
|
9
|
+
const panel = new ContextPanel({fg:(_c:string,s:string)=>s} as any, {
|
|
10
|
+
getDesiredWidth:()=>width, getUsage:()=>({percent:25,contextWindow:100000} as any),
|
|
11
|
+
getCacheHitRate:()=>42,
|
|
12
|
+
getModel:()=>"model", getThinkingLevel:()=>"off",
|
|
13
|
+
getEstimate:()=>estimateContext([{role:"toolResult",toolName:"read",toolCallId:"1",content:[{type:"text",text:"x".repeat(400000)}]}]),
|
|
14
|
+
});
|
|
15
|
+
const lines=panel.render(width);
|
|
16
|
+
assert.ok(lines.every(line=>visibleWidth(line)<=width));
|
|
17
|
+
assert.ok(lines[2].includes("25%/100K"));
|
|
18
|
+
if (width >= 30) assert.ok(lines[2].includes("Catch:42%"));
|
|
19
|
+
const separator=lines.findIndex(line=>line===`│ ${"─".repeat(width-4)} │`);
|
|
20
|
+
if (width >= 30) assert.ok(separator>0);
|
|
21
|
+
for(const label of ["System", "Tools", "Messages", "Tool Results", "Summary", "Other", "Images"])
|
|
22
|
+
assert.ok(lines.findIndex(line=>line.includes(label))>separator,label);
|
|
23
|
+
assert.ok(lines.some(line=>line.includes("~100K")));
|
|
24
|
+
assert.doesNotMatch(lines.join("\n"), /Composition|▸|▾|Top 3/);
|
|
25
|
+
assert.deepEqual(panel.render(width),lines);
|
|
26
|
+
for(const narrow of [1,2,4,12])assert.ok(panel.render(narrow).every(line=>visibleWidth(line)<=narrow));
|
|
27
|
+
const scroll=new ScrollView(panel,{follow:"none"});
|
|
28
|
+
const frame=renderLayoutFrame(scroll,width,7,()=>{});
|
|
29
|
+
assert.equal(frame.lines.length,7);
|
|
30
|
+
assert.ok(lines.length>frame.lines.length);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("run delta renders on the bar and after the window value", () => {
|
|
34
|
+
const theme={fg:(_c:string,s:string)=>s} as any;
|
|
35
|
+
const build=(delta:number|undefined)=>new ContextPanel(theme,{
|
|
36
|
+
getDesiredWidth:()=>50,getUsage:()=>({percent:25,contextWindow:200000} as any),
|
|
37
|
+
getCacheHitRate:()=>42,getModel:()=>"model",getThinkingLevel:()=>"off",
|
|
38
|
+
getDelta:()=>delta,
|
|
39
|
+
getEstimate:()=>estimateContext([{role:"user",content:"abcd"}]),
|
|
40
|
+
});
|
|
41
|
+
const lines=build(12000).render(50);
|
|
42
|
+
assert.ok(lines[2].includes("25%/200K +12K"));
|
|
43
|
+
assert.ok(lines[2].includes("Catch:42%"));
|
|
44
|
+
// The accent segment is the last slice of the already-filled bar, so the bar length never grows.
|
|
45
|
+
assert.equal(lines[3],`│ ${"█".repeat(12)}${"░".repeat(34)} │`);
|
|
46
|
+
assert.equal(build(undefined).render(50)[3],lines[3]);
|
|
47
|
+
assert.ok(build(-210000).render(50)[2].includes("25%/200K −210K"));
|
|
48
|
+
assert.equal(build(-210000).render(50)[3],lines[3]);
|
|
49
|
+
const colored=new ContextPanel({fg:(c:string,s:string)=>`[${c}]${s}`} as any,{
|
|
50
|
+
getDesiredWidth:()=>50,getUsage:()=>({percent:25,contextWindow:200000} as any),
|
|
51
|
+
getDelta:()=>12000,getModel:()=>"model",getThinkingLevel:()=>"off",
|
|
52
|
+
});
|
|
53
|
+
const coloredLines=colored.render(50);
|
|
54
|
+
assert.ok(coloredLines[3].includes("[accent]███"));
|
|
55
|
+
assert.ok(coloredLines[3].includes("[success]█████████"));
|
|
56
|
+
assert.ok(coloredLines[2].includes("[accent] +12K"));
|
|
57
|
+
assert.ok(!build(undefined).render(50)[2].includes("+"));
|
|
58
|
+
const narrow=build(12000).render(18);
|
|
59
|
+
assert.ok(narrow[2].includes("+12K"));
|
|
60
|
+
assert.ok(narrow.every(line=>visibleWidth(line)<=18));
|
|
61
|
+
});
|
|
62
|
+
test("missing snapshot is visible without interaction",()=>{
|
|
63
|
+
const panel=new ContextPanel({fg:(_c:string,s:string)=>s} as any,{
|
|
64
|
+
getDesiredWidth:()=>50,getUsage:()=>undefined,getModel:()=>"",getThinkingLevel:()=>"",
|
|
65
|
+
});
|
|
66
|
+
assert.match(panel.render(50).join("\n"),/No snapshot available/);
|
|
67
|
+
assert.match(panel.render(50).join("\n"),/not observed/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("activity rows render below the composition and bar", () => {
|
|
71
|
+
const activity={phase:"running",startedAt:1000,averagePerformance:{ttftMs:1000,tokensPerSecond:40},
|
|
72
|
+
turns:[{number:1,startedAt:1000,durationMs:2000,performance:{ttftMs:1200,tokensPerSecond:30},tools:[]},
|
|
73
|
+
{number:2,startedAt:3000,tools:[]}],
|
|
74
|
+
activeTools:[],recentTools:[],completedCount:0,failedCount:0} as any;
|
|
75
|
+
const panel=new ContextPanel({fg:(_c:string,s:string)=>s} as any,{
|
|
76
|
+
getDesiredWidth:()=>50,getUsage:()=>({percent:25,contextWindow:200000} as any),getCacheHitRate:()=>42,
|
|
77
|
+
getModel:()=>"model",getThinkingLevel:()=>"off",getActivity:()=>activity,now:()=>4000,
|
|
78
|
+
getEstimate:()=>estimateContext([{role:"user",content:"abcd"}]),
|
|
79
|
+
});
|
|
80
|
+
const lines=panel.render(50);
|
|
81
|
+
const output=lines.join("\n");
|
|
82
|
+
assert.doesNotMatch(output,/ACTIVITY/);
|
|
83
|
+
assert.match(output,/2 turns · 3s · AVG TTFT 1\.0s · AVG TPS 40\.0/);
|
|
84
|
+
assert.match(output,/TTFT 1\.2s\[fast\]/);
|
|
85
|
+
const activityTop=lines.findIndex(line=>line.includes("2 turns"));
|
|
86
|
+
assert.ok(lines.findIndex(line=>line.includes("Images"))<activityTop);
|
|
87
|
+
assert.ok(activityTop>lines.findIndex(line=>line.includes("25%/200K")));
|
|
88
|
+
assert.ok(lines.every(line=>visibleWidth(line)<=50));
|
|
89
|
+
const settled=new ContextPanel({fg:(_c:string,s:string)=>s} as any,{
|
|
90
|
+
getDesiredWidth:()=>18,getUsage:()=>({percent:25,contextWindow:200000} as any),
|
|
91
|
+
getModel:()=>"model",getThinkingLevel:()=>"off",now:()=>4000,
|
|
92
|
+
getActivity:()=>({...activity,phase:"settled",durationMs:3000} as any),
|
|
93
|
+
});
|
|
94
|
+
const narrow=settled.render(18);
|
|
95
|
+
assert.match(narrow.join("\n"),/done 3s/);
|
|
96
|
+
assert.ok(narrow.every(line=>visibleWidth(line)<=18));
|
|
97
|
+
const idle=new ContextPanel({fg:(_c:string,s:string)=>s} as any,{
|
|
98
|
+
getDesiredWidth:()=>50,getUsage:()=>({percent:25,contextWindow:200000} as any),
|
|
99
|
+
getModel:()=>"model",getThinkingLevel:()=>"off",now:()=>4000,
|
|
100
|
+
getActivity:()=>({...activity,phase:"idle",turns:[],startedAt:undefined} as any),
|
|
101
|
+
});
|
|
102
|
+
assert.match(idle.render(50).join("\n"),/Ready/);
|
|
103
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { ContextUsage, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
|
|
3
|
+
import { panelBottom, panelContent, panelTop, type PanelColor } from "../sidebar/panel.ts";
|
|
4
|
+
|
|
5
|
+
import type { ContextEstimate } from "./data.ts";
|
|
6
|
+
import {
|
|
7
|
+
formatDuration,
|
|
8
|
+
formatTps,
|
|
9
|
+
formatTtft,
|
|
10
|
+
type RunActivitySnapshot,
|
|
11
|
+
} from "./activity.ts";
|
|
12
|
+
|
|
13
|
+
export type ContextPanelOptions = {
|
|
14
|
+
readonly getEstimate?: () => ContextEstimate;
|
|
15
|
+
readonly getDelta?: () => number | undefined;
|
|
16
|
+
readonly getActivity?: () => RunActivitySnapshot | undefined;
|
|
17
|
+
readonly now?: () => number;
|
|
18
|
+
readonly getCacheHitRate?: () => number | undefined;
|
|
19
|
+
readonly getUsage: () => ContextUsage | undefined;
|
|
20
|
+
readonly getModel: () => string;
|
|
21
|
+
readonly getThinkingLevel: () => string;
|
|
22
|
+
readonly getDesiredWidth: () => number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function gradientText(text: string): string {
|
|
26
|
+
if (text.length === 0) return "";
|
|
27
|
+
const start = [80, 200, 255];
|
|
28
|
+
const end = [255, 80, 180];
|
|
29
|
+
const last = Math.max(1, text.length - 1);
|
|
30
|
+
return [...text].map((character, index) => {
|
|
31
|
+
const ratio = index / last;
|
|
32
|
+
const red = Math.round(start[0] + (end[0] - start[0]) * ratio);
|
|
33
|
+
const green = Math.round(start[1] + (end[1] - start[1]) * ratio);
|
|
34
|
+
const blue = Math.round(start[2] + (end[2] - start[2]) * ratio);
|
|
35
|
+
return `\x1b[38;2;${red};${green};${blue}m${character}`;
|
|
36
|
+
}).join("") + "\x1b[39m";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function formatContextWindow(contextWindow: number | undefined): string {
|
|
40
|
+
if (!contextWindow) return "?";
|
|
41
|
+
|
|
42
|
+
const scale = contextWindow >= 1_000_000 ? 1_000_000 : contextWindow >= 1_000 ? 1_000 : 1;
|
|
43
|
+
const suffix = scale === 1_000_000 ? "M" : scale === 1_000 ? "K" : "";
|
|
44
|
+
const value = contextWindow / scale;
|
|
45
|
+
const amount = value.toFixed(value >= 10 ? 0 : 1).replace(/\.0$/, "");
|
|
46
|
+
return `${amount}${suffix}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Activity rows moved into CTX: status/duration, AVERAGE TPS, latest TTFT. */
|
|
50
|
+
function activityRows(
|
|
51
|
+
theme: Theme,
|
|
52
|
+
getActivity: () => RunActivitySnapshot | undefined,
|
|
53
|
+
now: () => number,
|
|
54
|
+
contentWidth: number,
|
|
55
|
+
): string[] {
|
|
56
|
+
const activity = getActivity();
|
|
57
|
+
if (!activity) return [];
|
|
58
|
+
const at = now();
|
|
59
|
+
const duration = activity.phase === "settled"
|
|
60
|
+
? formatDuration(activity.durationMs ?? 0)
|
|
61
|
+
: formatDuration(Math.max(0, at - (activity.startedAt ?? at)));
|
|
62
|
+
const status = activity.phase === "idle"
|
|
63
|
+
? "Ready"
|
|
64
|
+
: `${activity.turns.length} turn${activity.turns.length === 1 ? "" : "s"} · ${activity.phase === "running" ? duration : `done ${duration}`}`;
|
|
65
|
+
const plain = (value: string): string => value.replace(/\[[^\]]+\]/g, "");
|
|
66
|
+
const average = `AVG TTFT ${plain(formatTtft(activity.averagePerformance?.ttftMs))} · AVG TPS ${plain(formatTps(activity.averagePerformance?.tokensPerSecond))}`;
|
|
67
|
+
const compact = `${activity.turns.length}T ${activity.phase === "settled" ? "done " : ""}${duration}`;
|
|
68
|
+
const header = [`${status} · ${average}`, status, compact, average]
|
|
69
|
+
.find((candidate) => visibleWidth(candidate) <= contentWidth) ?? compact;
|
|
70
|
+
const statusColor: PanelColor = activity.phase === "running"
|
|
71
|
+
? "warning"
|
|
72
|
+
: activity.failedCount > 0 ? "error" : "success";
|
|
73
|
+
const performances = activity.turns.map((turn) => turn.durationMs === undefined ? activity.performance : turn.performance);
|
|
74
|
+
let ttft: number | undefined;
|
|
75
|
+
for (let index = performances.length - 1; index >= 0; index--) {
|
|
76
|
+
const value = performances[index]?.ttftMs;
|
|
77
|
+
if (value !== undefined && Number.isFinite(value)) { ttft = value; break; }
|
|
78
|
+
}
|
|
79
|
+
const pending = activity.phase === "running" && activity.turns.length > 0 && ttft === undefined;
|
|
80
|
+
return [
|
|
81
|
+
panelContent(theme, "─".repeat(contentWidth), "dim", contentWidth),
|
|
82
|
+
panelContent(theme, header, statusColor, contentWidth),
|
|
83
|
+
// Latest observed TTFT, not the run average: AVG TTFT stays in the header.
|
|
84
|
+
panelContent(theme, `TTFT ${formatTtft(ttft)}${pending ? " · pending" : ""}`, "accent", contentWidth),
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export class ContextPanel implements Component {
|
|
89
|
+
constructor(
|
|
90
|
+
private readonly theme: Theme,
|
|
91
|
+
private readonly options: ContextPanelOptions,
|
|
92
|
+
) {}
|
|
93
|
+
|
|
94
|
+
render(width: number): string[] {
|
|
95
|
+
const renderWidth = Math.max(1, Math.min(width, this.options.getDesiredWidth()));
|
|
96
|
+
const contentWidth = Math.max(1, renderWidth - 4);
|
|
97
|
+
const usage = this.options.getUsage();
|
|
98
|
+
const percent = usage?.percent;
|
|
99
|
+
const normalizedPercent = percent === null || percent === undefined
|
|
100
|
+
? undefined
|
|
101
|
+
: Math.max(0, Math.min(100, percent));
|
|
102
|
+
const color: PanelColor = normalizedPercent === undefined
|
|
103
|
+
? "dim"
|
|
104
|
+
: normalizedPercent >= 85
|
|
105
|
+
? "error"
|
|
106
|
+
: normalizedPercent >= 60
|
|
107
|
+
? "warning"
|
|
108
|
+
: "success";
|
|
109
|
+
const filledWidth = normalizedPercent === undefined
|
|
110
|
+
? 0
|
|
111
|
+
: Math.round(contentWidth * normalizedPercent / 100);
|
|
112
|
+
const contextWindow = formatContextWindow(usage?.contextWindow);
|
|
113
|
+
const windowTokens = usage?.contextWindow;
|
|
114
|
+
const rawDelta = this.options.getDelta?.();
|
|
115
|
+
const delta = typeof rawDelta === "number" && Number.isFinite(rawDelta) && rawDelta !== 0 ? rawDelta : undefined;
|
|
116
|
+
const baseSummary = `${normalizedPercent === undefined ? "?" : `${Math.round(normalizedPercent)}%`}/${contextWindow}`;
|
|
117
|
+
const deltaSuffix = delta === undefined ? "" : ` ${delta > 0 ? "+" : "−"}${formatContextWindow(Math.abs(delta))}`;
|
|
118
|
+
const deltaColor: PanelColor = delta !== undefined && delta < 0 ? "warning" : "accent";
|
|
119
|
+
const summary = `${baseSummary}${deltaSuffix}`;
|
|
120
|
+
const cacheHitRate = this.options.getCacheHitRate?.();
|
|
121
|
+
const cacheSummary = cacheHitRate === undefined
|
|
122
|
+
? "Catch:?"
|
|
123
|
+
: `Catch:${Math.round(Math.max(0, Math.min(100, cacheHitRate)))}%`;
|
|
124
|
+
const summaryText = `${this.theme.fg(color, baseSummary)}${deltaSuffix ? this.theme.fg(deltaColor, deltaSuffix) : ""}`;
|
|
125
|
+
const summaryRow = visibleWidth(summary) + visibleWidth(cacheSummary) + 1 <= contentWidth
|
|
126
|
+
? `${summaryText}${" ".repeat(contentWidth - visibleWidth(summary) - visibleWidth(cacheSummary))}${this.theme.fg(color, cacheSummary)}`
|
|
127
|
+
: summaryText;
|
|
128
|
+
const deltaWidth = delta === undefined || delta <= 0 || filledWidth === 0 ||
|
|
129
|
+
typeof windowTokens !== "number" || !Number.isFinite(windowTokens) || windowTokens <= 0
|
|
130
|
+
? 0
|
|
131
|
+
: Math.min(filledWidth, Math.max(1, Math.round(contentWidth * delta / windowTokens)));
|
|
132
|
+
const model = this.options.getModel() || "no-model";
|
|
133
|
+
const thinking = this.options.getThinkingLevel() || "off";
|
|
134
|
+
const modelLabel = `${this.theme.fg("accent", model)}${this.theme.fg("dim", "|")}${gradientText(thinking)}`;
|
|
135
|
+
|
|
136
|
+
const rows: string[] = [];
|
|
137
|
+
const row = (text: string): void => { rows.push(panelContent(this.theme, text, "dim", contentWidth)); };
|
|
138
|
+
const estimate = this.options.getEstimate?.();
|
|
139
|
+
const amount = (tokens: number | undefined, percent: number | undefined): string =>
|
|
140
|
+
tokens === undefined ? "unknown" : `~${tokens === 0 ? "0" : formatContextWindow(tokens)} ${percent === undefined ? "?" : percent.toFixed(1)}%`;
|
|
141
|
+
const alignedRow = (label: string, value: string, labelColor: PanelColor = "dim", valueColor: PanelColor = "dim"): void => {
|
|
142
|
+
const gap = contentWidth - visibleWidth(label) - visibleWidth(value) - 1;
|
|
143
|
+
if (gap >= 1) {
|
|
144
|
+
row(`${this.theme.fg(labelColor, label)}${" ".repeat(gap)}${this.theme.fg(valueColor, value)}`);
|
|
145
|
+
} else {
|
|
146
|
+
row(this.theme.fg(labelColor, truncateToWidth(label, contentWidth, "…")));
|
|
147
|
+
row(this.theme.fg(valueColor, value));
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
const estimateRow = (label: string, tokens: number | undefined, percent: number | undefined): void => {
|
|
151
|
+
alignedRow(`${label}:`, amount(tokens, percent));
|
|
152
|
+
};
|
|
153
|
+
const imagesWire = estimate?.imagesWire ?? { images: 0, source: "unavailable" as const };
|
|
154
|
+
const imageLabel = imagesWire.source === "unavailable" ? "⚠ Images" : "Images";
|
|
155
|
+
const imageValue = imagesWire.source === "unavailable"
|
|
156
|
+
? "not observed"
|
|
157
|
+
: `${imagesWire.images} ${imagesWire.images === 1 ? "image" : "images"}`;
|
|
158
|
+
row("─".repeat(contentWidth));
|
|
159
|
+
for (const category of estimate?.categories ?? []) estimateRow(category.name, category.tokens, category.percent);
|
|
160
|
+
if (!estimate) row("No snapshot available");
|
|
161
|
+
alignedRow(imageLabel, imageValue, imagesWire.source === "unavailable" ? "warning" : "dim", "dim");
|
|
162
|
+
return [
|
|
163
|
+
panelTop(this.theme, "CTX", renderWidth),
|
|
164
|
+
panelContent(this.theme, modelLabel, "accent", contentWidth),
|
|
165
|
+
panelContent(this.theme, summaryRow, color, contentWidth),
|
|
166
|
+
panelContent(
|
|
167
|
+
this.theme,
|
|
168
|
+
`${this.theme.fg(color, "█".repeat(filledWidth - deltaWidth))}${deltaWidth > 0 ? this.theme.fg("accent", "█".repeat(deltaWidth)) : ""}${this.theme.fg(color, "░".repeat(contentWidth - filledWidth))}`,
|
|
169
|
+
color,
|
|
170
|
+
contentWidth,
|
|
171
|
+
),
|
|
172
|
+
...rows,
|
|
173
|
+
...activityRows(
|
|
174
|
+
this.theme,
|
|
175
|
+
this.options.getActivity ?? (() => undefined),
|
|
176
|
+
this.options.now ?? Date.now,
|
|
177
|
+
contentWidth,
|
|
178
|
+
),
|
|
179
|
+
panelBottom(this.theme, renderWidth),
|
|
180
|
+
].map((line) => truncateToWidth(line, renderWidth, ""));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
invalidate(): void {}
|
|
184
|
+
|
|
185
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createReadTool } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { estimateContext, estimateProviderToolsTokens, installContextEstimates } from "./data.ts";
|
|
5
|
+
|
|
6
|
+
const tool = { name: "fabric_exec", description: "Execute TypeScript", parameters: createReadTool(process.cwd()).parameters };
|
|
7
|
+
const expected = Math.ceil(JSON.stringify(tool).length / 4);
|
|
8
|
+
const toolsCategory = (snapshot: ReturnType<typeof estimateContext>) => snapshot.categories.find(c => c.name === "Tools")!;
|
|
9
|
+
|
|
10
|
+
test("Tools counts schema JSON only and contributes to totals and percentages", () => {
|
|
11
|
+
const decorated = { ...tool, sourceInfo: {path:"ignored".repeat(1000)}, promptGuidelines:["ignored".repeat(1000)] };
|
|
12
|
+
const snapshot = estimateContext([{role:"user",content:"abcd"}], "abcd", "context hook", [decorated]);
|
|
13
|
+
assert.equal(toolsCategory(snapshot).tokens, expected);
|
|
14
|
+
assert.equal(snapshot.total, expected + 2);
|
|
15
|
+
assert.equal(toolsCategory(snapshot).percent, expected / (expected + 2) * 100);
|
|
16
|
+
assert.ok(Math.abs(snapshot.categories.reduce((sum,c)=>sum+(c.percent??0),0)-100)<1e-9);
|
|
17
|
+
assert.equal(toolsCategory(estimateContext([], "", "active branch", [])).tokens, 0);
|
|
18
|
+
assert.equal(toolsCategory(estimateContext([])).tokens, undefined);
|
|
19
|
+
assert.equal(estimateProviderToolsTokens({ tools: [tool] }), Math.ceil(JSON.stringify([tool]).length / 4));
|
|
20
|
+
assert.equal(estimateProviderToolsTokens({}), 0);
|
|
21
|
+
assert.equal(estimateProviderToolsTokens(undefined), undefined);
|
|
22
|
+
const cyclic: any = {type:"object"}; cyclic.self = cyclic;
|
|
23
|
+
assert.equal(toolsCategory(estimateContext([], "", "active branch", [tool, {...tool,parameters:cyclic}])).tokens, undefined);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("tracker filters active tools, refreshes dynamic schemas, caches reads and resets", () => {
|
|
27
|
+
const handlers = new Map<string, Function>();
|
|
28
|
+
let active = ["fabric_exec", "fabric_exec"];
|
|
29
|
+
let all = [tool, {...tool,name:"captured_read",description:"not exposed".repeat(100)}];
|
|
30
|
+
let reads = 0;
|
|
31
|
+
let fail = false;
|
|
32
|
+
const tracker = installContextEstimates({
|
|
33
|
+
on:(name:string,fn:Function)=>handlers.set(name,fn),
|
|
34
|
+
getActiveTools:()=>{if(fail) throw new Error("unavailable");return active;},
|
|
35
|
+
getAllTools:()=>{reads++;return all;},
|
|
36
|
+
} as any, ()=>{});
|
|
37
|
+
const ctx = {sessionManager:{buildContextEntries:()=>[]},getSystemPrompt:()=>""};
|
|
38
|
+
handlers.get("session_start")!({},ctx);
|
|
39
|
+
assert.equal(toolsCategory(tracker.getSnapshot()).tokens, expected);
|
|
40
|
+
const cached = tracker.getSnapshot(); const priorReads = reads;
|
|
41
|
+
assert.strictEqual(tracker.getSnapshot(),cached); assert.equal(reads,priorReads);
|
|
42
|
+
active = ["captured_read"];
|
|
43
|
+
handlers.get("context")!({messages:[]},ctx);
|
|
44
|
+
assert.ok(toolsCategory(tracker.getSnapshot()).tokens! > expected);
|
|
45
|
+
handlers.get("before_provider_request")!({payload:{tools:[tool]}},ctx);
|
|
46
|
+
assert.equal(toolsCategory(tracker.getSnapshot()).tokens, Math.ceil(JSON.stringify([tool]).length/4));
|
|
47
|
+
all = [{...tool,description:"changed"}]; active = ["fabric_exec"];
|
|
48
|
+
handlers.get("turn_end")!({},ctx);
|
|
49
|
+
// The last provider payload remains authoritative through active-branch refresh.
|
|
50
|
+
assert.equal(toolsCategory(tracker.getSnapshot()).tokens, Math.ceil(JSON.stringify([tool]).length/4));
|
|
51
|
+
active = [];
|
|
52
|
+
handlers.get("agent_settled")!({},ctx);
|
|
53
|
+
// The observed request remains the best provider-level measurement until the next context reset.
|
|
54
|
+
assert.equal(toolsCategory(tracker.getSnapshot()).tokens, Math.ceil(JSON.stringify([tool]).length/4));
|
|
55
|
+
active = ["missing"];
|
|
56
|
+
handlers.get("session_tree")!({},ctx);
|
|
57
|
+
assert.equal(toolsCategory(tracker.getSnapshot()).tokens,undefined);
|
|
58
|
+
fail = true;
|
|
59
|
+
handlers.get("session_compact")!({},ctx);
|
|
60
|
+
assert.equal(toolsCategory(tracker.getSnapshot()).tokens,undefined);
|
|
61
|
+
handlers.get("session_shutdown")!();
|
|
62
|
+
assert.equal(tracker.getSnapshot().source,"unavailable");
|
|
63
|
+
assert.equal(toolsCategory(tracker.getSnapshot()).tokens,undefined);
|
|
64
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { isFabricFullCodeMode } from "./fabric-detection.ts";
|
|
4
|
+
|
|
5
|
+
test("detects Fabric full code mode from the active tool set", () => {
|
|
6
|
+
assert.equal(isFabricFullCodeMode(["fabric_exec", "ask_user_question"]), true);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("does not treat Fabric orchestration-only mode as full code mode", () => {
|
|
10
|
+
assert.equal(isFabricFullCodeMode(["fabric_exec", "read", "bash"]), false);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("does not treat native Pi tools as Fabric full code mode", () => {
|
|
14
|
+
assert.equal(isFabricFullCodeMode(["read", "bash", "edit"]), false);
|
|
15
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
const FABRIC_EXEC_TOOL = "fabric_exec";
|
|
2
|
+
const PI_CORE_TOOLS = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]);
|
|
3
|
+
|
|
4
|
+
export function isFabricFullCodeMode(activeToolNames: readonly string[]): boolean {
|
|
5
|
+
return activeToolNames.includes(FABRIC_EXEC_TOOL) &&
|
|
6
|
+
!activeToolNames.some((name) => PI_CORE_TOOLS.has(name));
|
|
7
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import { resolve, relative, sep, isAbsolute } from "node:path";
|
|
5
|
+
|
|
6
|
+
/** Render cwd with home directory collapsed to `~`, mirroring the built-in footer. */
|
|
7
|
+
function formatCwdForFooter(cwd: string, home: string | undefined): string {
|
|
8
|
+
if (!home) return cwd;
|
|
9
|
+
const resolvedCwd = resolve(cwd);
|
|
10
|
+
const resolvedHome = resolve(home);
|
|
11
|
+
const relativeToHome = relative(resolvedHome, resolvedCwd);
|
|
12
|
+
const isInsideHome =
|
|
13
|
+
relativeToHome === "" ||
|
|
14
|
+
(relativeToHome !== ".." && !relativeToHome.startsWith(`..${sep}`) && !isAbsolute(relativeToHome));
|
|
15
|
+
if (!isInsideHome) return cwd;
|
|
16
|
+
return relativeToHome === "" ? "~" : `~${sep}${relativeToHome}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type FooterController = {
|
|
20
|
+
refresh(): void;
|
|
21
|
+
dispose(): void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function installFooter(
|
|
25
|
+
ctx: ExtensionContext,
|
|
26
|
+
onMount?: (tui: TUI, theme: Theme) => void,
|
|
27
|
+
onUnmount?: () => void,
|
|
28
|
+
): FooterController {
|
|
29
|
+
let tui: TUI | undefined;
|
|
30
|
+
|
|
31
|
+
ctx.ui.setFooter((footerTui, theme, footerData): Component & { dispose(): void } => {
|
|
32
|
+
tui = footerTui;
|
|
33
|
+
onMount?.(footerTui, theme);
|
|
34
|
+
const unsubscribeBranch = footerData.onBranchChange(() => footerTui.requestRender());
|
|
35
|
+
// cwd may change across session reloads; re-render to keep the path fresh.
|
|
36
|
+
const cwd = ctx.cwd;
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
dispose(): void {
|
|
40
|
+
unsubscribeBranch();
|
|
41
|
+
onUnmount?.();
|
|
42
|
+
if (tui === footerTui) tui = undefined;
|
|
43
|
+
},
|
|
44
|
+
invalidate(): void {},
|
|
45
|
+
render(width: number): string[] {
|
|
46
|
+
const branch = footerData.getGitBranch();
|
|
47
|
+
const cwdLabel = formatCwdForFooter(cwd, process.env.HOME ?? process.env.USERPROFILE);
|
|
48
|
+
const cwdSegment = theme.fg("dim", `📁 ${cwdLabel}`);
|
|
49
|
+
const branchSegment = branch ? theme.fg("dim", ` ⎇ ${branch}`) : "";
|
|
50
|
+
return [truncateToWidth(`${cwdSegment}${branchSegment}`, Math.max(0, width), "")];
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
refresh: () => tui?.requestRender(),
|
|
57
|
+
dispose: () => {
|
|
58
|
+
ctx.ui.setFooter(undefined);
|
|
59
|
+
tui = undefined;
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|