@danypops/papyrus 0.13.1 → 0.13.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/extension/src/context-budget.ts +39 -44
- package/extension/src/context-view.ts +98 -100
- package/extension/src/index.ts +10 -4
- package/package.json +1 -1
|
@@ -5,8 +5,6 @@ import type { Artifact } from "../../src/domain/artifact.ts";
|
|
|
5
5
|
import { discoverSkillDirectories, scanSkillCatalogFootprint, type SkillCatalogFootprint } from "./skill-catalog-footprint.ts";
|
|
6
6
|
import { ruleInjectionPreview } from "./rules.ts";
|
|
7
7
|
|
|
8
|
-
const REPORT_MAX_ROWS = 5;
|
|
9
|
-
|
|
10
8
|
export interface RuleBudgetEntry {
|
|
11
9
|
id: string;
|
|
12
10
|
title: string;
|
|
@@ -76,6 +74,14 @@ export interface ContextSegment {
|
|
|
76
74
|
estimatedTokens: number;
|
|
77
75
|
/** Drill-down items, when this segment can be broken down further. Absent for "other" -- an opaque remainder, not a real category. */
|
|
78
76
|
items?: ContextSegmentItem[];
|
|
77
|
+
/**
|
|
78
|
+
* True when this segment's size is genuinely unmeasured (not yet observed), as opposed to
|
|
79
|
+
* measured-and-actually-zero. A display layer that hides zero-token rows to cut noise must
|
|
80
|
+
* NOT hide an unknown segment just because its placeholder value happens to be zero --
|
|
81
|
+
* that would silently misrepresent "we don't know" as "there is nothing here", the same
|
|
82
|
+
* category of honesty problem overshootTokens exists to prevent for the unaccounted bucket.
|
|
83
|
+
*/
|
|
84
|
+
unknown?: boolean;
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
/**
|
|
@@ -139,7 +145,16 @@ export interface ContextBreakdown {
|
|
|
139
145
|
contextWindow: number | null;
|
|
140
146
|
/** contextWindow - reserveTokens, mirroring Pi's own compaction-trigger formula. Null when contextWindow is unknown. */
|
|
141
147
|
effectiveBudget: number | null;
|
|
142
|
-
/**
|
|
148
|
+
/**
|
|
149
|
+
* How much the known/estimated segments (rules+tasks+skills+basePrompt+messageHistory)
|
|
150
|
+
* exceed the real total, when they do. Zero means no overshoot. This must stay visible
|
|
151
|
+
* rather than only being absorbed into "unaccounted" clamping to zero -- a clamped-to-zero
|
|
152
|
+
* unaccounted segment does NOT mean tool definitions and framework overhead are actually
|
|
153
|
+
* free; it means this estimate's other segments already consumed the entire real budget on
|
|
154
|
+
* paper. Hiding that distinction would make a genuinely nonzero cost look like zero.
|
|
155
|
+
*/
|
|
156
|
+
overshootTokens: number;
|
|
157
|
+
/** rules, tasks, skills, basePrompt, messageHistory, then "other" absorbing whatever real usage the rest don't account for. */
|
|
143
158
|
segments: ContextSegment[];
|
|
144
159
|
}
|
|
145
160
|
|
|
@@ -148,7 +163,8 @@ export interface BuildContextBreakdownInput {
|
|
|
148
163
|
contextWindow: number | null;
|
|
149
164
|
reserveTokens?: number;
|
|
150
165
|
ruleBudget: ContextBudget["rules"];
|
|
151
|
-
|
|
166
|
+
/** Open tasks contributing to the injected task-context summary, each sized individually so the Tasks segment can be drilled into like Rules and Skills. */
|
|
167
|
+
taskItems: ContextSegmentItem[];
|
|
152
168
|
skills: SkillCatalogFootprint;
|
|
153
169
|
/** Pi's own base system prompt size, cached from the last observed before_agent_start turn. Null before any turn has run yet. */
|
|
154
170
|
basePromptEstimatedTokens: number | null;
|
|
@@ -161,11 +177,13 @@ export interface BuildContextBreakdownInput {
|
|
|
161
177
|
* catalog, cached base-prompt size, and the live session's own message history) against the
|
|
162
178
|
* real total Pi reports, deriving "unaccounted" (tool definitions and framework overhead --
|
|
163
179
|
* genuinely invisible to any extension) as the remainder. The remainder is clamped to zero
|
|
164
|
-
* rather than shown negative
|
|
165
|
-
* in the known segments must not display as a nonsensical negative bucket
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
180
|
+
* rather than shown negative -- char/4 token estimation is approximate, and a small overshoot
|
|
181
|
+
* in the known segments must not display as a nonsensical negative bucket -- but the clamp
|
|
182
|
+
* amount itself is preserved as overshootTokens rather than silently discarded, so a
|
|
183
|
+
* consumer can tell "genuinely zero" apart from "our other estimates already exceeded the
|
|
184
|
+
* real total". When the real total is unavailable, unaccounted is reported as zero and
|
|
185
|
+
* totalTokens surfaces as null so callers can label the whole breakdown as estimate-only
|
|
186
|
+
* rather than silently treating a partial sum as ground truth.
|
|
169
187
|
*/
|
|
170
188
|
export function buildContextBreakdown(input: BuildContextBreakdownInput): ContextBreakdown {
|
|
171
189
|
const reserveTokens = input.reserveTokens ?? DEFAULT_RESERVE_TOKENS;
|
|
@@ -175,7 +193,12 @@ export function buildContextBreakdown(input: BuildContextBreakdownInput): Contex
|
|
|
175
193
|
estimatedTokens: input.ruleBudget.totalEstimatedTokens,
|
|
176
194
|
items: input.ruleBudget.entries.map((entry) => ({ label: entry.title, estimatedTokens: entry.estimatedTokens })),
|
|
177
195
|
};
|
|
178
|
-
const tasks: ContextSegment = {
|
|
196
|
+
const tasks: ContextSegment = {
|
|
197
|
+
key: "tasks",
|
|
198
|
+
label: "Papyrus Tasks",
|
|
199
|
+
estimatedTokens: input.taskItems.reduce((sum, item) => sum + item.estimatedTokens, 0),
|
|
200
|
+
items: input.taskItems,
|
|
201
|
+
};
|
|
179
202
|
const skills: ContextSegment = {
|
|
180
203
|
key: "skills",
|
|
181
204
|
label: "Pi Skills catalog",
|
|
@@ -186,6 +209,7 @@ export function buildContextBreakdown(input: BuildContextBreakdownInput): Contex
|
|
|
186
209
|
key: "basePrompt",
|
|
187
210
|
label: input.basePromptEstimatedTokens === null ? "Base system prompt (not observed yet)" : "Base system prompt (Pi + host instructions)",
|
|
188
211
|
estimatedTokens: input.basePromptEstimatedTokens ?? 0,
|
|
212
|
+
...(input.basePromptEstimatedTokens === null ? { unknown: true } : {}),
|
|
189
213
|
};
|
|
190
214
|
const messageHistory: ContextSegment = {
|
|
191
215
|
key: "messageHistory",
|
|
@@ -193,50 +217,21 @@ export function buildContextBreakdown(input: BuildContextBreakdownInput): Contex
|
|
|
193
217
|
estimatedTokens: input.messageHistoryEstimatedTokens,
|
|
194
218
|
};
|
|
195
219
|
const knownTokens = rules.estimatedTokens + tasks.estimatedTokens + skills.estimatedTokens + basePrompt.estimatedTokens + messageHistory.estimatedTokens;
|
|
220
|
+
const overshootTokens = input.totalTokens === null ? 0 : Math.max(0, knownTokens - input.totalTokens);
|
|
196
221
|
const other: ContextSegment = {
|
|
197
222
|
key: "other",
|
|
198
|
-
label:
|
|
223
|
+
label: overshootTokens > 0
|
|
224
|
+
? `Unaccounted (tool definitions, framework overhead) -- estimate overshoot: other segments' estimates already exceed the real total by ~${overshootTokens} tokens, so this is a floor, not a real zero`
|
|
225
|
+
: "Unaccounted (tool definitions, framework overhead)",
|
|
199
226
|
estimatedTokens: input.totalTokens === null ? 0 : Math.max(0, input.totalTokens - knownTokens),
|
|
200
227
|
};
|
|
201
228
|
return {
|
|
202
229
|
totalTokens: input.totalTokens,
|
|
203
230
|
contextWindow: input.contextWindow,
|
|
204
231
|
effectiveBudget: input.contextWindow === null ? null : Math.max(0, input.contextWindow - reserveTokens),
|
|
232
|
+
overshootTokens,
|
|
205
233
|
segments: [rules, tasks, skills, basePrompt, messageHistory, other],
|
|
206
234
|
};
|
|
207
235
|
}
|
|
208
236
|
|
|
209
|
-
function truncate(text: string, max: number): string {
|
|
210
|
-
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
211
|
-
}
|
|
212
237
|
|
|
213
|
-
/** Pure text formatter, independent of live daemon/filesystem state, for direct unit testing. */
|
|
214
|
-
export function formatContextBudgetReport(budget: ContextBudget): string {
|
|
215
|
-
const lines: string[] = ["Papyrus passive context budget", ""];
|
|
216
|
-
|
|
217
|
-
lines.push(`Rules (active, injected every relevant turn): ${budget.rules.entries.length} rules · ${budget.rules.totalCharacters} chars · ~${budget.rules.totalEstimatedTokens} tokens`);
|
|
218
|
-
if (budget.rules.entries.length > 0) {
|
|
219
|
-
lines.push(" Largest:");
|
|
220
|
-
for (const entry of budget.rules.entries.slice(0, REPORT_MAX_ROWS)) {
|
|
221
|
-
lines.push(` ${entry.characters.toString().padStart(5)} chars (~${entry.estimatedTokens} tok) ${truncate(entry.title, 60)}`);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
lines.push("");
|
|
225
|
-
|
|
226
|
-
lines.push(`Skills (Pi-native catalog, injected at startup): ${budget.skills.entries.length} skills · ${budget.skills.totalCharacters} chars · ~${budget.skills.totalEstimatedTokens} tokens`);
|
|
227
|
-
if (budget.skills.entries.length > 0) {
|
|
228
|
-
lines.push(" Largest:");
|
|
229
|
-
for (const entry of budget.skills.entries.slice(0, REPORT_MAX_ROWS)) {
|
|
230
|
-
lines.push(` ${entry.characters.toString().padStart(5)} chars (~${entry.estimatedTokens} tok) ${truncate(entry.name, 40)}`);
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
if (budget.skills.scannedDirectories.length > 0) {
|
|
234
|
-
lines.push(` Scanned: ${budget.skills.scannedDirectories.join(", ")}`);
|
|
235
|
-
} else {
|
|
236
|
-
lines.push(" No skill directories found (checked Pi's documented global/project locations and settings.json's skills array).");
|
|
237
|
-
}
|
|
238
|
-
lines.push("");
|
|
239
|
-
|
|
240
|
-
lines.push(`Total passive tax: ~${budget.totalEstimatedTokens} tokens across ${budget.rules.entries.length + budget.skills.entries.length} items, before a single user message or tool call.`);
|
|
241
|
-
return lines.join("\n");
|
|
242
|
-
}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import type { ExtensionCommandContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { matchesKey, truncateToWidth, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
-
import type { ContextBreakdown, ContextSegment
|
|
4
|
-
import { formatContextBudgetReport, type ContextBudget } from "./context-budget.ts";
|
|
3
|
+
import type { ContextBreakdown, ContextSegment } from "./context-budget.ts";
|
|
5
4
|
|
|
6
|
-
const
|
|
5
|
+
const VISIBLE_ROWS = 24;
|
|
7
6
|
|
|
8
7
|
const SEGMENT_COLORS: Record<ContextSegment["key"], ThemeColor> = {
|
|
9
8
|
rules: "accent",
|
|
@@ -14,6 +13,18 @@ const SEGMENT_COLORS: Record<ContextSegment["key"], ThemeColor> = {
|
|
|
14
13
|
other: "muted",
|
|
15
14
|
};
|
|
16
15
|
|
|
16
|
+
/**
|
|
17
|
+
* One row in the unified scrollable view. Every segment that has any real (nonzero) content
|
|
18
|
+
* is fully expanded inline -- there is no separate "select a segment, then drill in" step.
|
|
19
|
+
* `key` drives this row's color; `isHeader` distinguishes a segment's own summary line from
|
|
20
|
+
* its item rows underneath it.
|
|
21
|
+
*/
|
|
22
|
+
export interface ContextRow {
|
|
23
|
+
key: ContextSegment["key"];
|
|
24
|
+
isHeader: boolean;
|
|
25
|
+
text: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
17
28
|
function formatTokenCount(tokens: number): string {
|
|
18
29
|
return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
|
|
19
30
|
}
|
|
@@ -23,35 +34,47 @@ function percentOf(part: number, whole: number): string {
|
|
|
23
34
|
}
|
|
24
35
|
|
|
25
36
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
37
|
+
* Flattens every segment with real content into one linear row list, filtering out anything
|
|
38
|
+
* that is genuinely zero rather than displaying a misleading "0 tok 0.0%" row -- a segment or
|
|
39
|
+
* item with literally nothing in it carries no information and is pure noise in a scrollable
|
|
40
|
+
* view meant to show where tokens actually go. A segment whose OWN total is zero but whose
|
|
41
|
+
* items are also all zero is dropped entirely; a segment with a nonzero total is always kept
|
|
42
|
+
* even if all its items individually round to zero (the total itself is real signal).
|
|
29
43
|
*/
|
|
30
|
-
export function
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
|
|
44
|
+
export function buildContextRows(breakdown: ContextBreakdown): ContextRow[] {
|
|
45
|
+
const rows: ContextRow[] = [];
|
|
46
|
+
const denominator = breakdown.totalTokens ?? breakdown.segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
|
|
47
|
+
for (const segment of breakdown.segments) {
|
|
48
|
+
const items = (segment.items ?? []).filter((item) => item.estimatedTokens > 0).sort((a, b) => b.estimatedTokens - a.estimatedTokens);
|
|
49
|
+
// A genuinely-unknown segment (basePrompt before the first observed turn) must stay
|
|
50
|
+
// visible even when its placeholder value is zero -- hiding it would misrepresent
|
|
51
|
+
// "not measured yet" as "measured and empty", the same honesty problem overshootTokens
|
|
52
|
+
// exists to prevent for the unaccounted bucket.
|
|
53
|
+
if (segment.estimatedTokens <= 0 && items.length === 0 && !segment.unknown) continue;
|
|
54
|
+
rows.push({
|
|
55
|
+
key: segment.key,
|
|
56
|
+
isHeader: true,
|
|
57
|
+
text: `${segment.label} — ${segment.estimatedTokens} tok (${percentOf(segment.estimatedTokens, denominator)})`,
|
|
58
|
+
});
|
|
59
|
+
for (const item of items) {
|
|
60
|
+
rows.push({ key: segment.key, isHeader: false, text: ` ${item.estimatedTokens.toString().padStart(6)} tok ${item.label}` });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return rows;
|
|
42
64
|
}
|
|
43
65
|
|
|
44
66
|
class ContextViewport {
|
|
45
|
-
private
|
|
46
|
-
private
|
|
47
|
-
private drillIndex = 0;
|
|
67
|
+
private offsetY = 0;
|
|
68
|
+
private readonly rows: ContextRow[];
|
|
48
69
|
|
|
49
70
|
constructor(
|
|
50
71
|
private readonly tui: TUI,
|
|
51
72
|
private readonly theme: Theme,
|
|
52
73
|
private readonly breakdown: ContextBreakdown,
|
|
53
74
|
private readonly close: () => void,
|
|
54
|
-
) {
|
|
75
|
+
) {
|
|
76
|
+
this.rows = buildContextRows(breakdown);
|
|
77
|
+
}
|
|
55
78
|
|
|
56
79
|
invalidate(): void {}
|
|
57
80
|
|
|
@@ -71,106 +94,81 @@ class ContextViewport {
|
|
|
71
94
|
} else if (this.breakdown.totalTokens !== null) {
|
|
72
95
|
lines.push(truncateToWidth(`${formatTokenCount(this.breakdown.totalTokens)} tokens (model context window unknown)`, contentWidth, ""));
|
|
73
96
|
} else {
|
|
74
|
-
lines.push(theme.fg("dim", "No real usage reported yet —
|
|
97
|
+
lines.push(theme.fg("dim", "No real usage reported yet — sizes below are Papyrus's own estimates only"));
|
|
75
98
|
}
|
|
76
99
|
lines.push(renderContextBar(theme, this.breakdown.segments, contentWidth));
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (this.drillDown) {
|
|
80
|
-
lines.push(...this.renderDrillDown(contentWidth));
|
|
81
|
-
} else {
|
|
82
|
-
lines.push(...this.renderSegments(contentWidth));
|
|
100
|
+
if (this.breakdown.overshootTokens > 0) {
|
|
101
|
+
lines.push(truncateToWidth(theme.fg("warning", `Estimates exceed real total by ~${this.breakdown.overshootTokens} tok — sizes below are approximate, not exact`), contentWidth, ""));
|
|
83
102
|
}
|
|
84
|
-
lines.push(
|
|
85
|
-
return lines;
|
|
86
|
-
}
|
|
103
|
+
lines.push("");
|
|
87
104
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const selected = index === this.selectedIndex;
|
|
94
|
-
const cursor = selected ? theme.fg("accent", "❯") : " ";
|
|
95
|
-
const swatch = theme.fg(SEGMENT_COLORS[segment.key], "██");
|
|
96
|
-
const title = selected ? theme.bold(segment.label) : segment.label;
|
|
97
|
-
const percent = percentOf(segment.estimatedTokens, denominator);
|
|
98
|
-
const drillHint = segment.items && segment.items.length > 0 ? theme.fg("dim", ` (${segment.items.length} items — enter to expand)`) : "";
|
|
99
|
-
lines.push(truncateToWidth(`${cursor} ${swatch} ${segment.estimatedTokens.toString().padStart(6)} tok ${percent.padStart(5)} ${title}${drillHint}`, width, ""));
|
|
105
|
+
this.visibleWindow().forEach(({ row, index }) => {
|
|
106
|
+
const gutter = theme.fg(SEGMENT_COLORS[row.key], "▌");
|
|
107
|
+
const text = row.isHeader ? theme.bold(row.text) : row.text;
|
|
108
|
+
lines.push(truncateToWidth(`${gutter} ${text}`, contentWidth, ""));
|
|
109
|
+
void index;
|
|
100
110
|
});
|
|
111
|
+
if (this.rows.length === 0) lines.push(theme.fg("dim", " (nothing observed yet)"));
|
|
112
|
+
else lines.push(theme.fg("muted", ` ${Math.min(this.offsetY + VISIBLE_ROWS, this.rows.length)}/${this.rows.length}`));
|
|
113
|
+
|
|
101
114
|
lines.push("");
|
|
102
|
-
lines.push(theme.fg("dim", "↑↓
|
|
115
|
+
lines.push(theme.fg("dim", "↑↓ scroll · esc close"));
|
|
116
|
+
lines.push(border);
|
|
103
117
|
return lines;
|
|
104
118
|
}
|
|
105
119
|
|
|
106
|
-
private
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (items.length === 0) {
|
|
112
|
-
lines.push(theme.fg("dim", " (nothing to break down further)"));
|
|
113
|
-
} else {
|
|
114
|
-
const start = Math.max(0, Math.min(this.drillIndex - Math.floor(DRILLDOWN_VISIBLE_ROWS / 2), items.length - DRILLDOWN_VISIBLE_ROWS));
|
|
115
|
-
const end = Math.min(start + DRILLDOWN_VISIBLE_ROWS, items.length);
|
|
116
|
-
for (let index = start; index < end; index++) {
|
|
117
|
-
const item = items[index]!;
|
|
118
|
-
const selected = index === this.drillIndex;
|
|
119
|
-
const cursor = selected ? theme.fg("accent", "❯") : " ";
|
|
120
|
-
const title = selected ? theme.bold(item.label) : item.label;
|
|
121
|
-
lines.push(truncateToWidth(`${cursor} ${item.estimatedTokens.toString().padStart(6)} tok ${title}`, width, ""));
|
|
122
|
-
}
|
|
123
|
-
lines.push(theme.fg("muted", ` ${this.drillIndex + 1}/${items.length}`));
|
|
124
|
-
}
|
|
125
|
-
lines.push("");
|
|
126
|
-
lines.push(theme.fg("dim", "↑↓ scroll · esc back"));
|
|
127
|
-
return lines;
|
|
120
|
+
private visibleWindow(): Array<{ row: ContextRow; index: number }> {
|
|
121
|
+
const end = Math.min(this.offsetY + VISIBLE_ROWS, this.rows.length);
|
|
122
|
+
const result: Array<{ row: ContextRow; index: number }> = [];
|
|
123
|
+
for (let index = this.offsetY; index < end; index++) result.push({ row: this.rows[index]!, index });
|
|
124
|
+
return result;
|
|
128
125
|
}
|
|
129
126
|
|
|
130
127
|
handleInput(data: string): void {
|
|
131
|
-
if (this.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
else if (matchesKey(data, "down")) this.drillIndex = Math.min(Math.max(0, items.length - 1), this.drillIndex + 1);
|
|
136
|
-
else return;
|
|
137
|
-
} else {
|
|
138
|
-
if (matchesKey(data, "escape")) { this.close(); return; }
|
|
139
|
-
if (matchesKey(data, "up")) this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
140
|
-
else if (matchesKey(data, "down")) this.selectedIndex = Math.min(this.breakdown.segments.length - 1, this.selectedIndex + 1);
|
|
141
|
-
else if (matchesKey(data, "enter")) {
|
|
142
|
-
const segment = this.breakdown.segments[this.selectedIndex];
|
|
143
|
-
if (segment?.items && segment.items.length > 0) {
|
|
144
|
-
this.drillDown = segment;
|
|
145
|
-
this.drillIndex = 0;
|
|
146
|
-
}
|
|
147
|
-
} else return;
|
|
148
|
-
}
|
|
128
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.close(); return; }
|
|
129
|
+
if (matchesKey(data, "up")) this.offsetY = Math.max(0, this.offsetY - 1);
|
|
130
|
+
else if (matchesKey(data, "down")) this.offsetY = Math.min(Math.max(0, this.rows.length - VISIBLE_ROWS), this.offsetY + 1);
|
|
131
|
+
else return;
|
|
149
132
|
this.tui.requestRender();
|
|
150
133
|
}
|
|
151
134
|
}
|
|
152
135
|
|
|
153
|
-
/**
|
|
154
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Renders the context window as one proportional stacked bar, one colored run of block
|
|
138
|
+
* characters per segment, matching each row's own gutter color above/below it. A zero-token
|
|
139
|
+
* breakdown (nothing observed yet) renders an empty dim track rather than a divide-by-zero.
|
|
140
|
+
* Zero-token segments contribute no cells and are effectively invisible in the bar, matching
|
|
141
|
+
* their exclusion from the row list below it.
|
|
142
|
+
*/
|
|
143
|
+
export function renderContextBar(theme: Theme, segments: ReadonlyArray<ContextSegment>, width: number): string {
|
|
144
|
+
const total = segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
|
|
145
|
+
if (total <= 0 || width <= 0) return theme.fg("dim", "░".repeat(Math.max(0, width)));
|
|
146
|
+
const nonZero = segments.filter((segment) => segment.estimatedTokens > 0);
|
|
147
|
+
let used = 0;
|
|
148
|
+
let output = "";
|
|
149
|
+
nonZero.forEach((segment, index) => {
|
|
150
|
+
const isLast = index === nonZero.length - 1;
|
|
151
|
+
const cells = isLast ? width - used : Math.round((segment.estimatedTokens / total) * width);
|
|
152
|
+
used += cells;
|
|
153
|
+
if (cells > 0) output += theme.fg(SEGMENT_COLORS[segment.key], "█".repeat(cells));
|
|
154
|
+
});
|
|
155
|
+
return output;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Non-interactive fallback (print mode, RPC, etc.): the same unified row list, as plain text lines. */
|
|
159
|
+
function fallbackReport(breakdown: ContextBreakdown): string {
|
|
155
160
|
const totalLine = breakdown.totalTokens !== null
|
|
156
161
|
? `Real usage: ${breakdown.totalTokens} tokens${breakdown.effectiveBudget !== null ? ` / ${breakdown.effectiveBudget} usable budget (${percentOf(breakdown.totalTokens, breakdown.effectiveBudget)})` : ""}`
|
|
157
162
|
: "Real usage: not yet reported";
|
|
158
|
-
const
|
|
159
|
-
const
|
|
160
|
-
const
|
|
161
|
-
return [
|
|
162
|
-
totalLine,
|
|
163
|
-
"",
|
|
164
|
-
"Segments:",
|
|
165
|
-
...segmentLines,
|
|
166
|
-
"",
|
|
167
|
-
formatContextBudgetReport({ rules: ruleBudget, skills: { entries: [], totalCharacters: 0, totalEstimatedTokens: skillsSegment?.estimatedTokens ?? 0, scannedDirectories: [] }, totalEstimatedTokens: ruleBudget.totalEstimatedTokens }),
|
|
168
|
-
].join("\n");
|
|
163
|
+
const overshootLine = breakdown.overshootTokens > 0 ? [`Estimates exceed real total by ~${breakdown.overshootTokens} tok -- sizes below are approximate, not exact`] : [];
|
|
164
|
+
const rows = buildContextRows(breakdown);
|
|
165
|
+
const rowLines = rows.length > 0 ? rows.map((row) => row.text) : ["(nothing observed yet)"];
|
|
166
|
+
return [totalLine, ...overshootLine, "", ...rowLines].join("\n");
|
|
169
167
|
}
|
|
170
168
|
|
|
171
|
-
export async function showContextView(ctx: ExtensionCommandContext, breakdown: ContextBreakdown
|
|
169
|
+
export async function showContextView(ctx: ExtensionCommandContext, breakdown: ContextBreakdown): Promise<void> {
|
|
172
170
|
if (ctx.mode !== "tui") {
|
|
173
|
-
ctx.ui.notify(fallbackReport(breakdown
|
|
171
|
+
ctx.ui.notify(fallbackReport(breakdown), "info");
|
|
174
172
|
return;
|
|
175
173
|
}
|
|
176
174
|
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => new ContextViewport(tui, theme, breakdown, done));
|
package/extension/src/index.ts
CHANGED
|
@@ -421,24 +421,30 @@ export default async function (pi: ExtensionAPI) {
|
|
|
421
421
|
handler: async (_args, ctx) => {
|
|
422
422
|
try {
|
|
423
423
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
424
|
-
const [rules,
|
|
424
|
+
const [rules, openTasks] = await Promise.all([
|
|
425
425
|
callService<Record<string, unknown>, Array<Pick<Artifact, "id" | "title" | "body" | "extra">>>("rules.injectable", { project_root: ctx.cwd, session_id: sessionId }),
|
|
426
|
-
callService<Record<string, unknown>,
|
|
426
|
+
callService<Record<string, unknown>, Artifact[]>("tasks.list", { project_root: ctx.cwd, session_id: sessionId, limit: 200 }),
|
|
427
427
|
]);
|
|
428
428
|
const { skills } = computeContextBudget(rules, ctx.cwd);
|
|
429
429
|
const ruleBudget = computeRuleBudget(rules);
|
|
430
430
|
const usage = ctx.getContextUsage?.();
|
|
431
431
|
const branch = ctx.sessionManager.getBranch() as unknown as SessionBranchEntryLike[];
|
|
432
|
+
// Sized individually (title+body) so the Tasks segment can be drilled into like Rules
|
|
433
|
+
// and Skills; this is a per-task approximation, not a byte-identical reproduction of
|
|
434
|
+
// tasks.context's own current/next/rejected selection and rendering.
|
|
435
|
+
const taskItems = openTasks
|
|
436
|
+
.filter((task) => task.status !== "done" && task.status !== "canceled")
|
|
437
|
+
.map((task) => ({ label: task.title, estimatedTokens: Math.ceil((task.title.length + task.body.length) / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN) }));
|
|
432
438
|
const breakdown = buildContextBreakdown({
|
|
433
439
|
totalTokens: usage?.tokens ?? null,
|
|
434
440
|
contextWindow: ctx.model?.contextWindow ?? null,
|
|
435
441
|
ruleBudget,
|
|
436
|
-
|
|
442
|
+
taskItems,
|
|
437
443
|
skills,
|
|
438
444
|
basePromptEstimatedTokens: lastObservedBasePromptTokens,
|
|
439
445
|
messageHistoryEstimatedTokens: estimateMessageHistoryTokens(branch),
|
|
440
446
|
});
|
|
441
|
-
await showContextView(ctx, breakdown
|
|
447
|
+
await showContextView(ctx, breakdown);
|
|
442
448
|
} catch (error) {
|
|
443
449
|
ctx.ui.notify(`Context breakdown failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
444
450
|
}
|
package/package.json
CHANGED