@danypops/papyrus 0.12.0 → 0.13.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/extension/src/artifact-browser.ts +13 -7
- package/extension/src/artifact-status-presentation.ts +53 -0
- package/extension/src/context-budget.ts +173 -0
- package/extension/src/context-view.ts +172 -0
- package/extension/src/docs.ts +6 -5
- package/extension/src/index.ts +34 -1
- package/extension/src/notes.ts +2 -3
- package/extension/src/rules.ts +7 -7
- package/extension/src/skill-catalog-footprint.ts +183 -0
- package/extension/src/skills.ts +2 -3
- package/extension/src/task-widget.ts +13 -1
- package/package.json +1 -1
- package/src/constants.ts +39 -0
- package/src/domain/skill-definition.ts +57 -8
- package/src/domain-services.ts +69 -3
- package/src/skill-execution.ts +169 -75
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { DynamicBorder, rawKeyHint } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Container, Input, Spacer, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
4
4
|
import { SEED_RELATIONS } from "../../src/constants.ts";
|
|
5
5
|
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
6
6
|
import type { OperationName } from "../../src/service.ts";
|
|
7
|
+
import type { StatusPresentation } from "./artifact-status-presentation.ts";
|
|
7
8
|
import { artifactDetailsText } from "./artifact-detail-format.ts";
|
|
8
9
|
import { showArtifactDetailView } from "./artifact-detail-view.ts";
|
|
9
10
|
import { callService } from "./service-client.ts";
|
|
@@ -19,10 +20,10 @@ export interface ArtifactBrowserConfig {
|
|
|
19
20
|
kind: string;
|
|
20
21
|
title: string;
|
|
21
22
|
statusOrder: string[];
|
|
22
|
-
|
|
23
|
+
presentation: Record<string, StatusPresentation>;
|
|
23
24
|
listOperation?: OperationName;
|
|
24
25
|
listInput?: Record<string, unknown>;
|
|
25
|
-
rowMeta(row: Artifact): string;
|
|
26
|
+
rowMeta(row: Artifact, theme: Theme): string;
|
|
26
27
|
actions(row: Artifact): string[];
|
|
27
28
|
handleAction(choice: string, row: Artifact, ctx: ExtensionCommandContext): Promise<void>;
|
|
28
29
|
}
|
|
@@ -156,7 +157,11 @@ function renderPanel(
|
|
|
156
157
|
.join(theme.fg("muted", " · "));
|
|
157
158
|
const spacing = Math.max(1, width - visibleWidth(title) - visibleWidth(hint));
|
|
158
159
|
const summary = statusSummary(rows, config.statusOrder)
|
|
159
|
-
.map(({ status, count }) =>
|
|
160
|
+
.map(({ status, count }) => {
|
|
161
|
+
const presentation = config.presentation[status];
|
|
162
|
+
const glyph = presentation ? theme.fg(presentation.color, presentation.glyph) : status;
|
|
163
|
+
return `${glyph} ${count} ${status}`;
|
|
164
|
+
})
|
|
160
165
|
.join(", ");
|
|
161
166
|
return [
|
|
162
167
|
truncateToWidth(`${title}${" ".repeat(spacing)}${hint}`, width, ""),
|
|
@@ -176,10 +181,11 @@ function renderPanel(
|
|
|
176
181
|
const row = filtered[index]!;
|
|
177
182
|
const selected = index === selectedIndex;
|
|
178
183
|
const cursor = selected ? theme.fg("accent", "❯") : " ";
|
|
179
|
-
const
|
|
184
|
+
const presentation = config.presentation[row.status];
|
|
185
|
+
const glyph = presentation ? theme.fg(presentation.color, presentation.glyph) : "?";
|
|
180
186
|
const title = selected ? theme.bold(row.title) : row.title;
|
|
181
|
-
const meta = config.rowMeta(row);
|
|
182
|
-
lines.push(truncateToWidth(`${cursor} ${glyph} ${title}${meta ? theme.fg("dim",
|
|
187
|
+
const meta = config.rowMeta(row, theme);
|
|
188
|
+
lines.push(truncateToWidth(`${cursor} ${glyph} ${title}${meta ? `${theme.fg("dim", " · ")}${meta}` : ""}`, width, ""));
|
|
183
189
|
}
|
|
184
190
|
lines.push(theme.fg("muted", ` ${selectedIndex + 1}/${filtered.length} ${config.kind}`));
|
|
185
191
|
return lines;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared {label, glyph, color} shape, mirroring task-presentation.ts's TASK_STATUS_PRESENTATION
|
|
5
|
+
* for every other artifact kind's status. Centralizing this closes a real gap: every artifact
|
|
6
|
+
* browser (Rules, Docs, Notes, Skills) previously rendered status as a bare glyph with no color at
|
|
7
|
+
* all, which is exactly why "hard to understand which rules are active" was a real complaint --
|
|
8
|
+
* an active rule's "●" and a deprecated rule's "○" differ only by one filled-vs-hollow pixel shape,
|
|
9
|
+
* easy to miss at a glance across a scrolling list.
|
|
10
|
+
*/
|
|
11
|
+
export interface StatusPresentation {
|
|
12
|
+
label: string;
|
|
13
|
+
glyph: string;
|
|
14
|
+
color: ThemeColor;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const RULE_STATUS_PRESENTATION: Record<string, StatusPresentation> = {
|
|
18
|
+
active: { label: "active", glyph: "●", color: "success" },
|
|
19
|
+
deprecated: { label: "deprecated", glyph: "○", color: "muted" },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const DOC_STATUS_PRESENTATION: Record<string, StatusPresentation> = {
|
|
23
|
+
draft: { label: "draft", glyph: "○", color: "muted" },
|
|
24
|
+
active: { label: "active", glyph: "●", color: "success" },
|
|
25
|
+
archived: { label: "archived", glyph: "■", color: "dim" },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const NOTE_STATUS_PRESENTATION: Record<string, StatusPresentation> = {
|
|
29
|
+
draft: { label: "draft", glyph: "○", color: "muted" },
|
|
30
|
+
active: { label: "active", glyph: "●", color: "success" },
|
|
31
|
+
archived: { label: "archived", glyph: "■", color: "dim" },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const SKILL_STATUS_PRESENTATION: Record<string, StatusPresentation> = {
|
|
35
|
+
active: { label: "active", glyph: "●", color: "success" },
|
|
36
|
+
deprecated: { label: "deprecated", glyph: "○", color: "muted" },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Rule severity gets its own color independent of status -- block is the loudest, info the quietest. */
|
|
40
|
+
export const RULE_SEVERITY_PRESENTATION: Record<string, ThemeColor> = {
|
|
41
|
+
block: "error",
|
|
42
|
+
warn: "warning",
|
|
43
|
+
info: "accent",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function severityColor(severity: string): ThemeColor {
|
|
47
|
+
return RULE_SEVERITY_PRESENTATION[severity.toLowerCase()] ?? "muted";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Plain glyph lookup, for callers that build uncolored text first and colorize it later (e.g. task-graph's colorizeTaskGraphLine pattern). */
|
|
51
|
+
export function glyphOf(presentation: Record<string, StatusPresentation>, status: string): string {
|
|
52
|
+
return presentation[status]?.glyph ?? "?";
|
|
53
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN } from "../../src/constants.ts";
|
|
4
|
+
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
5
|
+
import { discoverSkillDirectories, scanSkillCatalogFootprint, type SkillCatalogFootprint } from "./skill-catalog-footprint.ts";
|
|
6
|
+
import { ruleInjectionPreview } from "./rules.ts";
|
|
7
|
+
|
|
8
|
+
const REPORT_MAX_ROWS = 5;
|
|
9
|
+
|
|
10
|
+
export interface RuleBudgetEntry {
|
|
11
|
+
id: string;
|
|
12
|
+
title: string;
|
|
13
|
+
characters: number;
|
|
14
|
+
estimatedTokens: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ContextBudget {
|
|
18
|
+
rules: {
|
|
19
|
+
entries: RuleBudgetEntry[]; // sorted descending by characters
|
|
20
|
+
totalCharacters: number;
|
|
21
|
+
totalEstimatedTokens: number;
|
|
22
|
+
};
|
|
23
|
+
skills: SkillCatalogFootprint;
|
|
24
|
+
totalEstimatedTokens: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Active Rules are injected into every relevant turn -- the same permanent tax role as a Pi-native skill's catalog entry. */
|
|
28
|
+
export function computeRuleBudget(rules: ReadonlyArray<Pick<Artifact, "id" | "title" | "body" | "extra">>): ContextBudget["rules"] {
|
|
29
|
+
const entries = rules
|
|
30
|
+
.map((rule) => {
|
|
31
|
+
const characters = ruleInjectionPreview(rule).length;
|
|
32
|
+
return { id: rule.id, title: rule.title, characters, estimatedTokens: Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN) };
|
|
33
|
+
})
|
|
34
|
+
.sort((a, b) => b.characters - a.characters);
|
|
35
|
+
return {
|
|
36
|
+
entries,
|
|
37
|
+
totalCharacters: entries.reduce((sum, entry) => sum + entry.characters, 0),
|
|
38
|
+
totalEstimatedTokens: entries.reduce((sum, entry) => sum + entry.estimatedTokens, 0),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Best-effort: a missing, unreadable, or malformed settings.json contributes no extra skill directories rather than failing the whole report. */
|
|
43
|
+
function readSettingsSkillPaths(settingsPath: string): string[] {
|
|
44
|
+
try {
|
|
45
|
+
const raw = JSON.parse(readFileSync(settingsPath, "utf8")) as { skills?: unknown };
|
|
46
|
+
if (!Array.isArray(raw.skills)) return [];
|
|
47
|
+
return raw.skills.filter((entry): entry is string => typeof entry === "string");
|
|
48
|
+
} catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function computeContextBudget(
|
|
54
|
+
rules: ReadonlyArray<Pick<Artifact, "id" | "title" | "body" | "extra">>,
|
|
55
|
+
cwd: string,
|
|
56
|
+
homeDirectory: string = homedir(),
|
|
57
|
+
): ContextBudget {
|
|
58
|
+
const settingsSkills = readSettingsSkillPaths(`${homeDirectory}/.pi/agent/settings.json`);
|
|
59
|
+
const directories = discoverSkillDirectories(homeDirectory, cwd, settingsSkills);
|
|
60
|
+
const skills = scanSkillCatalogFootprint(directories);
|
|
61
|
+
const ruleBudget = computeRuleBudget(rules);
|
|
62
|
+
return { rules: ruleBudget, skills, totalEstimatedTokens: ruleBudget.totalEstimatedTokens + skills.totalEstimatedTokens };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Pi's own documented compaction-reserve default (docs/compaction.md): headroom kept free for the model's response. */
|
|
66
|
+
export const DEFAULT_RESERVE_TOKENS = 16_384;
|
|
67
|
+
|
|
68
|
+
export interface ContextSegmentItem {
|
|
69
|
+
label: string;
|
|
70
|
+
estimatedTokens: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ContextSegment {
|
|
74
|
+
key: "rules" | "tasks" | "skills" | "other";
|
|
75
|
+
label: string;
|
|
76
|
+
estimatedTokens: number;
|
|
77
|
+
/** Drill-down items, when this segment can be broken down further. Absent for "other" -- an opaque remainder, not a real category. */
|
|
78
|
+
items?: ContextSegmentItem[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ContextBreakdown {
|
|
82
|
+
/** Real usage from ctx.getContextUsage() -- ground truth, not estimated. Null only when Pi has no usage yet (e.g. before the first turn). */
|
|
83
|
+
totalTokens: number | null;
|
|
84
|
+
/** From ctx.model.contextWindow. Null when the active model's context window is unknown. */
|
|
85
|
+
contextWindow: number | null;
|
|
86
|
+
/** contextWindow - reserveTokens, mirroring Pi's own compaction-trigger formula. Null when contextWindow is unknown. */
|
|
87
|
+
effectiveBudget: number | null;
|
|
88
|
+
/** rules, tasks, skills, then "other" absorbing whatever real usage the first three don't account for. */
|
|
89
|
+
segments: ContextSegment[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface BuildContextBreakdownInput {
|
|
93
|
+
totalTokens: number | null;
|
|
94
|
+
contextWindow: number | null;
|
|
95
|
+
reserveTokens?: number;
|
|
96
|
+
ruleBudget: ContextBudget["rules"];
|
|
97
|
+
taskEstimatedTokens: number;
|
|
98
|
+
skills: SkillCatalogFootprint;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Composes Papyrus's own estimated segments (rules/tasks/skills) against the real total Pi
|
|
103
|
+
* reports, deriving "everything else" (base system prompt, message history, tool definitions
|
|
104
|
+
* -- none of which Papyrus can see or estimate) as the remainder. The remainder is clamped to
|
|
105
|
+
* zero rather than shown negative: char/4 token estimation is approximate, and a small
|
|
106
|
+
* overshoot in the known segments must not display as a nonsensical negative "other" bucket.
|
|
107
|
+
* When the real total is unavailable, "other" is reported as zero and totalTokens surfaces as
|
|
108
|
+
* null so callers can label the whole breakdown as estimate-only rather than silently treating
|
|
109
|
+
* a partial sum as ground truth.
|
|
110
|
+
*/
|
|
111
|
+
export function buildContextBreakdown(input: BuildContextBreakdownInput): ContextBreakdown {
|
|
112
|
+
const reserveTokens = input.reserveTokens ?? DEFAULT_RESERVE_TOKENS;
|
|
113
|
+
const rules: ContextSegment = {
|
|
114
|
+
key: "rules",
|
|
115
|
+
label: "Papyrus Rules",
|
|
116
|
+
estimatedTokens: input.ruleBudget.totalEstimatedTokens,
|
|
117
|
+
items: input.ruleBudget.entries.map((entry) => ({ label: entry.title, estimatedTokens: entry.estimatedTokens })),
|
|
118
|
+
};
|
|
119
|
+
const tasks: ContextSegment = { key: "tasks", label: "Papyrus Tasks", estimatedTokens: input.taskEstimatedTokens };
|
|
120
|
+
const skills: ContextSegment = {
|
|
121
|
+
key: "skills",
|
|
122
|
+
label: "Pi Skills catalog",
|
|
123
|
+
estimatedTokens: input.skills.totalEstimatedTokens,
|
|
124
|
+
items: input.skills.entries.map((entry) => ({ label: entry.name, estimatedTokens: entry.estimatedTokens })),
|
|
125
|
+
};
|
|
126
|
+
const knownTokens = rules.estimatedTokens + tasks.estimatedTokens + skills.estimatedTokens;
|
|
127
|
+
const other: ContextSegment = {
|
|
128
|
+
key: "other",
|
|
129
|
+
label: "Everything else (base prompt, message history, tool definitions)",
|
|
130
|
+
estimatedTokens: input.totalTokens === null ? 0 : Math.max(0, input.totalTokens - knownTokens),
|
|
131
|
+
};
|
|
132
|
+
return {
|
|
133
|
+
totalTokens: input.totalTokens,
|
|
134
|
+
contextWindow: input.contextWindow,
|
|
135
|
+
effectiveBudget: input.contextWindow === null ? null : Math.max(0, input.contextWindow - reserveTokens),
|
|
136
|
+
segments: [rules, tasks, skills, other],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function truncate(text: string, max: number): string {
|
|
141
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Pure text formatter, independent of live daemon/filesystem state, for direct unit testing. */
|
|
145
|
+
export function formatContextBudgetReport(budget: ContextBudget): string {
|
|
146
|
+
const lines: string[] = ["Papyrus passive context budget", ""];
|
|
147
|
+
|
|
148
|
+
lines.push(`Rules (active, injected every relevant turn): ${budget.rules.entries.length} rules · ${budget.rules.totalCharacters} chars · ~${budget.rules.totalEstimatedTokens} tokens`);
|
|
149
|
+
if (budget.rules.entries.length > 0) {
|
|
150
|
+
lines.push(" Largest:");
|
|
151
|
+
for (const entry of budget.rules.entries.slice(0, REPORT_MAX_ROWS)) {
|
|
152
|
+
lines.push(` ${entry.characters.toString().padStart(5)} chars (~${entry.estimatedTokens} tok) ${truncate(entry.title, 60)}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
lines.push("");
|
|
156
|
+
|
|
157
|
+
lines.push(`Skills (Pi-native catalog, injected at startup): ${budget.skills.entries.length} skills · ${budget.skills.totalCharacters} chars · ~${budget.skills.totalEstimatedTokens} tokens`);
|
|
158
|
+
if (budget.skills.entries.length > 0) {
|
|
159
|
+
lines.push(" Largest:");
|
|
160
|
+
for (const entry of budget.skills.entries.slice(0, REPORT_MAX_ROWS)) {
|
|
161
|
+
lines.push(` ${entry.characters.toString().padStart(5)} chars (~${entry.estimatedTokens} tok) ${truncate(entry.name, 40)}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (budget.skills.scannedDirectories.length > 0) {
|
|
165
|
+
lines.push(` Scanned: ${budget.skills.scannedDirectories.join(", ")}`);
|
|
166
|
+
} else {
|
|
167
|
+
lines.push(" No skill directories found (checked Pi's documented global/project locations and settings.json's skills array).");
|
|
168
|
+
}
|
|
169
|
+
lines.push("");
|
|
170
|
+
|
|
171
|
+
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.`);
|
|
172
|
+
return lines.join("\n");
|
|
173
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { ExtensionCommandContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { matchesKey, truncateToWidth, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { ContextBreakdown, ContextSegment, ContextSegmentItem } from "./context-budget.ts";
|
|
4
|
+
import { formatContextBudgetReport, type ContextBudget } from "./context-budget.ts";
|
|
5
|
+
|
|
6
|
+
const DRILLDOWN_VISIBLE_ROWS = 15;
|
|
7
|
+
|
|
8
|
+
const SEGMENT_COLORS: Record<ContextSegment["key"], ThemeColor> = {
|
|
9
|
+
rules: "accent",
|
|
10
|
+
tasks: "success",
|
|
11
|
+
skills: "mdLink",
|
|
12
|
+
other: "muted",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function formatTokenCount(tokens: number): string {
|
|
16
|
+
return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function percentOf(part: number, whole: number): string {
|
|
20
|
+
return whole > 0 ? `${((part / whole) * 100).toFixed(1)}%` : "—";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Renders the context window as one proportional stacked bar, one colored run of block
|
|
25
|
+
* characters per segment, matching each row's own swatch color below it. A zero-token
|
|
26
|
+
* breakdown (nothing observed yet) renders an empty dim track rather than a divide-by-zero.
|
|
27
|
+
*/
|
|
28
|
+
export function renderContextBar(theme: Theme, segments: ReadonlyArray<ContextSegment>, width: number): string {
|
|
29
|
+
const total = segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
|
|
30
|
+
if (total <= 0 || width <= 0) return theme.fg("dim", "░".repeat(Math.max(0, width)));
|
|
31
|
+
let used = 0;
|
|
32
|
+
let output = "";
|
|
33
|
+
segments.forEach((segment, index) => {
|
|
34
|
+
const isLast = index === segments.length - 1;
|
|
35
|
+
const cells = isLast ? width - used : Math.round((segment.estimatedTokens / total) * width);
|
|
36
|
+
used += cells;
|
|
37
|
+
if (cells > 0) output += theme.fg(SEGMENT_COLORS[segment.key], "█".repeat(cells));
|
|
38
|
+
});
|
|
39
|
+
return output;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
class ContextViewport {
|
|
43
|
+
private selectedIndex = 0;
|
|
44
|
+
private drillDown: ContextSegment | null = null;
|
|
45
|
+
private drillIndex = 0;
|
|
46
|
+
|
|
47
|
+
constructor(
|
|
48
|
+
private readonly tui: TUI,
|
|
49
|
+
private readonly theme: Theme,
|
|
50
|
+
private readonly breakdown: ContextBreakdown,
|
|
51
|
+
private readonly close: () => void,
|
|
52
|
+
) {}
|
|
53
|
+
|
|
54
|
+
invalidate(): void {}
|
|
55
|
+
|
|
56
|
+
render(width: number): string[] {
|
|
57
|
+
const theme = this.theme;
|
|
58
|
+
const contentWidth = Math.max(1, width);
|
|
59
|
+
const border = theme.fg("borderMuted", "─".repeat(contentWidth));
|
|
60
|
+
const lines: string[] = [border, truncateToWidth(theme.fg("accent", theme.bold("Context")), contentWidth, "")];
|
|
61
|
+
|
|
62
|
+
if (this.breakdown.totalTokens !== null && this.breakdown.effectiveBudget !== null) {
|
|
63
|
+
const percent = percentOf(this.breakdown.totalTokens, this.breakdown.effectiveBudget);
|
|
64
|
+
lines.push(truncateToWidth(
|
|
65
|
+
`${formatTokenCount(this.breakdown.totalTokens)} / ${formatTokenCount(this.breakdown.effectiveBudget)} tokens (${percent} of usable budget)`,
|
|
66
|
+
contentWidth,
|
|
67
|
+
"",
|
|
68
|
+
));
|
|
69
|
+
} else if (this.breakdown.totalTokens !== null) {
|
|
70
|
+
lines.push(truncateToWidth(`${formatTokenCount(this.breakdown.totalTokens)} tokens (model context window unknown)`, contentWidth, ""));
|
|
71
|
+
} else {
|
|
72
|
+
lines.push(theme.fg("dim", "No real usage reported yet — segment sizes below are Papyrus's own estimates only"));
|
|
73
|
+
}
|
|
74
|
+
lines.push(renderContextBar(theme, this.breakdown.segments, contentWidth));
|
|
75
|
+
lines.push("");
|
|
76
|
+
|
|
77
|
+
if (this.drillDown) {
|
|
78
|
+
lines.push(...this.renderDrillDown(contentWidth));
|
|
79
|
+
} else {
|
|
80
|
+
lines.push(...this.renderSegments(contentWidth));
|
|
81
|
+
}
|
|
82
|
+
lines.push(border);
|
|
83
|
+
return lines;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private renderSegments(width: number): string[] {
|
|
87
|
+
const theme = this.theme;
|
|
88
|
+
const lines: string[] = [];
|
|
89
|
+
const denominator = this.breakdown.totalTokens ?? this.breakdown.segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
|
|
90
|
+
this.breakdown.segments.forEach((segment, index) => {
|
|
91
|
+
const selected = index === this.selectedIndex;
|
|
92
|
+
const cursor = selected ? theme.fg("accent", "❯") : " ";
|
|
93
|
+
const swatch = theme.fg(SEGMENT_COLORS[segment.key], "██");
|
|
94
|
+
const title = selected ? theme.bold(segment.label) : segment.label;
|
|
95
|
+
const percent = percentOf(segment.estimatedTokens, denominator);
|
|
96
|
+
const drillHint = segment.items && segment.items.length > 0 ? theme.fg("dim", ` (${segment.items.length} items — enter to expand)`) : "";
|
|
97
|
+
lines.push(truncateToWidth(`${cursor} ${swatch} ${segment.estimatedTokens.toString().padStart(6)} tok ${percent.padStart(5)} ${title}${drillHint}`, width, ""));
|
|
98
|
+
});
|
|
99
|
+
lines.push("");
|
|
100
|
+
lines.push(theme.fg("dim", "↑↓ select · enter expand · esc close"));
|
|
101
|
+
return lines;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private renderDrillDown(width: number): string[] {
|
|
105
|
+
const theme = this.theme;
|
|
106
|
+
const segment = this.drillDown!;
|
|
107
|
+
const items: ContextSegmentItem[] = segment.items ?? [];
|
|
108
|
+
const lines: string[] = [truncateToWidth(theme.fg("muted", `${segment.label} — largest first`), width, "")];
|
|
109
|
+
if (items.length === 0) {
|
|
110
|
+
lines.push(theme.fg("dim", " (nothing to break down further)"));
|
|
111
|
+
} else {
|
|
112
|
+
const start = Math.max(0, Math.min(this.drillIndex - Math.floor(DRILLDOWN_VISIBLE_ROWS / 2), items.length - DRILLDOWN_VISIBLE_ROWS));
|
|
113
|
+
const end = Math.min(start + DRILLDOWN_VISIBLE_ROWS, items.length);
|
|
114
|
+
for (let index = start; index < end; index++) {
|
|
115
|
+
const item = items[index]!;
|
|
116
|
+
const selected = index === this.drillIndex;
|
|
117
|
+
const cursor = selected ? theme.fg("accent", "❯") : " ";
|
|
118
|
+
const title = selected ? theme.bold(item.label) : item.label;
|
|
119
|
+
lines.push(truncateToWidth(`${cursor} ${item.estimatedTokens.toString().padStart(6)} tok ${title}`, width, ""));
|
|
120
|
+
}
|
|
121
|
+
lines.push(theme.fg("muted", ` ${this.drillIndex + 1}/${items.length}`));
|
|
122
|
+
}
|
|
123
|
+
lines.push("");
|
|
124
|
+
lines.push(theme.fg("dim", "↑↓ scroll · esc back"));
|
|
125
|
+
return lines;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
handleInput(data: string): void {
|
|
129
|
+
if (this.drillDown) {
|
|
130
|
+
const items = this.drillDown.items ?? [];
|
|
131
|
+
if (matchesKey(data, "escape")) this.drillDown = null;
|
|
132
|
+
else if (matchesKey(data, "up")) this.drillIndex = Math.max(0, this.drillIndex - 1);
|
|
133
|
+
else if (matchesKey(data, "down")) this.drillIndex = Math.min(Math.max(0, items.length - 1), this.drillIndex + 1);
|
|
134
|
+
else return;
|
|
135
|
+
} else {
|
|
136
|
+
if (matchesKey(data, "escape")) { this.close(); return; }
|
|
137
|
+
if (matchesKey(data, "up")) this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
138
|
+
else if (matchesKey(data, "down")) this.selectedIndex = Math.min(this.breakdown.segments.length - 1, this.selectedIndex + 1);
|
|
139
|
+
else if (matchesKey(data, "enter")) {
|
|
140
|
+
const segment = this.breakdown.segments[this.selectedIndex];
|
|
141
|
+
if (segment?.items && segment.items.length > 0) {
|
|
142
|
+
this.drillDown = segment;
|
|
143
|
+
this.drillIndex = 0;
|
|
144
|
+
}
|
|
145
|
+
} else return;
|
|
146
|
+
}
|
|
147
|
+
this.tui.requestRender();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Non-interactive fallback (print mode, RPC, etc.) reuses the existing plain-text report shape. */
|
|
152
|
+
function fallbackReport(breakdown: ContextBreakdown, ruleBudget: ContextBudget["rules"]): string {
|
|
153
|
+
const skillsSegment = breakdown.segments.find((segment) => segment.key === "skills");
|
|
154
|
+
const other = breakdown.segments.find((segment) => segment.key === "other")!;
|
|
155
|
+
const totalLine = breakdown.totalTokens !== null
|
|
156
|
+
? `Real usage: ${breakdown.totalTokens} tokens${breakdown.effectiveBudget !== null ? ` / ${breakdown.effectiveBudget} usable budget (${percentOf(breakdown.totalTokens, breakdown.effectiveBudget)})` : ""}`
|
|
157
|
+
: "Real usage: not yet reported";
|
|
158
|
+
return [
|
|
159
|
+
totalLine,
|
|
160
|
+
`Everything else (base prompt, message history, tool defs): ~${other.estimatedTokens} tokens`,
|
|
161
|
+
"",
|
|
162
|
+
formatContextBudgetReport({ rules: ruleBudget, skills: { entries: [], totalCharacters: 0, totalEstimatedTokens: skillsSegment?.estimatedTokens ?? 0, scannedDirectories: [] }, totalEstimatedTokens: ruleBudget.totalEstimatedTokens }),
|
|
163
|
+
].join("\n");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function showContextView(ctx: ExtensionCommandContext, breakdown: ContextBreakdown, ruleBudget: ContextBudget["rules"]): Promise<void> {
|
|
167
|
+
if (ctx.mode !== "tui") {
|
|
168
|
+
ctx.ui.notify(fallbackReport(breakdown, ruleBudget), "info");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => new ContextViewport(tui, theme, breakdown, done));
|
|
172
|
+
}
|
package/extension/src/docs.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
3
3
|
import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
|
|
4
|
+
import { DOC_STATUS_PRESENTATION } from "./artifact-status-presentation.ts";
|
|
4
5
|
import { callService } from "./service-client.ts";
|
|
5
6
|
|
|
6
|
-
const DOC_GLYPHS: Record<string, string> = { draft: "○", active: "●", archived: "■" };
|
|
7
7
|
const DOC_ACTIONS: Record<string, string[]> = {
|
|
8
8
|
draft: ["Activate", "Archive"],
|
|
9
9
|
active: ["Archive"],
|
|
@@ -11,8 +11,9 @@ const DOC_ACTIONS: Record<string, string[]> = {
|
|
|
11
11
|
};
|
|
12
12
|
const DOC_RELATIONS = ["references", "documents", "supersedes", "relates_to", "contains", "part_of"];
|
|
13
13
|
|
|
14
|
-
export function documentRowMeta(document: Artifact): string {
|
|
15
|
-
|
|
14
|
+
export function documentRowMeta(document: Artifact, theme: Theme): string {
|
|
15
|
+
const subtype = document.subtype ? theme.fg("accent", document.subtype) : "";
|
|
16
|
+
return [subtype, document.labels.join(", ")].filter(Boolean).join(" · ");
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export async function showDocs(ctx: ExtensionCommandContext): Promise<void> {
|
|
@@ -21,7 +22,7 @@ export async function showDocs(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
21
22
|
title: "Documents",
|
|
22
23
|
listOperation: "docs.list",
|
|
23
24
|
statusOrder: ["draft", "active", "archived"],
|
|
24
|
-
|
|
25
|
+
presentation: DOC_STATUS_PRESENTATION,
|
|
25
26
|
rowMeta: documentRowMeta,
|
|
26
27
|
actions: (document) => ["Show details", "Link artifact", ...(DOC_ACTIONS[document.status] ?? [])],
|
|
27
28
|
handleAction: async (choice, document, commandCtx) => {
|
package/extension/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
TASK_DRIVER_MAX_TURNS,
|
|
16
16
|
TASK_DRIVER_MAX_UNCHANGED_TURNS,
|
|
17
17
|
PAPYRUS_CONTEXT_INJECTION_CHANNEL,
|
|
18
|
+
CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN,
|
|
18
19
|
} from "../../src/constants.ts";
|
|
19
20
|
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
20
21
|
import type { GateResult } from "../../src/domain/gate.ts";
|
|
@@ -26,6 +27,8 @@ import { ActiveTaskContinuation, automaticPauseReason, shouldResumeFocusOnHumanI
|
|
|
26
27
|
import { buildTaskWidgetProjection, type TaskWidgetProjection } from "./task-widget.ts";
|
|
27
28
|
import { TASK_STATUS_PRESENTATION, taskTreeConnector } from "./task-presentation.ts";
|
|
28
29
|
import { buildContextInjection } from "./context-injection-telemetry.ts";
|
|
30
|
+
import { buildContextBreakdown, computeContextBudget, computeRuleBudget } from "./context-budget.ts";
|
|
31
|
+
import { showContextView } from "./context-view.ts";
|
|
29
32
|
import { emitTaskFocusEvent, setTaskFocusEventBus } from "./task-focus-events.ts";
|
|
30
33
|
import { renderPapyrusToolCall, renderPapyrusToolResult } from "./tool-rendering/index.ts";
|
|
31
34
|
import {
|
|
@@ -57,7 +60,12 @@ export function renderTaskWidgetLines(theme: Theme, projection: TaskWidgetProjec
|
|
|
57
60
|
const focus = row.active ? theme.fg("accent", row.focusStatus === "paused" ? "Ⅱ" : "▶") : " ";
|
|
58
61
|
const presentation = TASK_STATUS_PRESENTATION[row.task.status as TaskStatus];
|
|
59
62
|
const glyph = presentation ? theme.fg(presentation.color, presentation.glyph) : theme.fg("muted", "?");
|
|
60
|
-
|
|
63
|
+
// Task containment is a DAG: a task with more than one parent is only ever shown once in
|
|
64
|
+
// this bounded tree (under whichever parent this walk reached first). Flag it rather than
|
|
65
|
+
// silently hiding that it also lives elsewhere -- see /tasks graph's composition view for
|
|
66
|
+
// the full multi-parent picture.
|
|
67
|
+
const multiParent = row.parentCount > 1 ? theme.fg("dim", ` ⥂${row.parentCount}`) : "";
|
|
68
|
+
lines.push(truncateToWidth(`${focus} ${hierarchy} ${glyph} ${row.task.title}${multiParent}`, width, "…"));
|
|
61
69
|
}
|
|
62
70
|
return lines;
|
|
63
71
|
}
|
|
@@ -403,6 +411,31 @@ export default async function (pi: ExtensionAPI) {
|
|
|
403
411
|
description: "Browse and invoke Papyrus skills and templates (interactive)",
|
|
404
412
|
handler: async (_args, ctx) => { await skillsModule.showSkills(ctx); },
|
|
405
413
|
});
|
|
414
|
+
pi.registerCommand("context", {
|
|
415
|
+
description: "Structured, per-segment breakdown of the context window: real usage against the model's window, drilling into Papyrus Rules and the Pi-native skill catalog",
|
|
416
|
+
handler: async (_args, ctx) => {
|
|
417
|
+
try {
|
|
418
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
419
|
+
const [rules, taskSummary] = await Promise.all([
|
|
420
|
+
callService<Record<string, unknown>, Array<Pick<Artifact, "id" | "title" | "body" | "extra">>>("rules.injectable", { project_root: ctx.cwd, session_id: sessionId }),
|
|
421
|
+
callService<Record<string, unknown>, string | null>("tasks.context", { project_root: ctx.cwd, session_id: sessionId }),
|
|
422
|
+
]);
|
|
423
|
+
const { skills } = computeContextBudget(rules, ctx.cwd);
|
|
424
|
+
const ruleBudget = computeRuleBudget(rules);
|
|
425
|
+
const usage = ctx.getContextUsage?.();
|
|
426
|
+
const breakdown = buildContextBreakdown({
|
|
427
|
+
totalTokens: usage?.tokens ?? null,
|
|
428
|
+
contextWindow: ctx.model?.contextWindow ?? null,
|
|
429
|
+
ruleBudget,
|
|
430
|
+
taskEstimatedTokens: taskSummary ? Math.ceil(taskSummary.length / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN) : 0,
|
|
431
|
+
skills,
|
|
432
|
+
});
|
|
433
|
+
await showContextView(ctx, breakdown, ruleBudget);
|
|
434
|
+
} catch (error) {
|
|
435
|
+
ctx.ui.notify(`Context breakdown failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
436
|
+
}
|
|
437
|
+
},
|
|
438
|
+
});
|
|
406
439
|
|
|
407
440
|
// ── Task widget (TodoOverlay pattern: factory form, requestRender) ──
|
|
408
441
|
|
package/extension/src/notes.ts
CHANGED
|
@@ -3,10 +3,9 @@ import { NOTE_LIST_MAX_LIMIT } from "../../src/constants.ts";
|
|
|
3
3
|
import { NOTE_DISPOSITIONS } from "../../src/note-service.ts";
|
|
4
4
|
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
5
5
|
import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
|
|
6
|
+
import { NOTE_STATUS_PRESENTATION } from "./artifact-status-presentation.ts";
|
|
6
7
|
import { callService } from "./service-client.ts";
|
|
7
8
|
|
|
8
|
-
const NOTE_GLYPHS: Record<string, string> = { draft: "○", active: "●", archived: "■" };
|
|
9
|
-
|
|
10
9
|
export function noteRowMeta(note: Artifact): string {
|
|
11
10
|
const history = Array.isArray(note.extra["noteHistory"]) ? note.extra["noteHistory"].length : 0;
|
|
12
11
|
return `${history} event${history === 1 ? "" : "s"}`;
|
|
@@ -53,7 +52,7 @@ export async function showNotes(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
53
52
|
listOperation: "notes.list",
|
|
54
53
|
listInput: noteListInput(ctx.cwd),
|
|
55
54
|
statusOrder: ["draft", "active", "archived"],
|
|
56
|
-
|
|
55
|
+
presentation: NOTE_STATUS_PRESENTATION,
|
|
57
56
|
rowMeta: noteRowMeta,
|
|
58
57
|
actions: (note) => [
|
|
59
58
|
"Show details",
|
package/extension/src/rules.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
3
3
|
import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
|
|
4
|
+
import { RULE_STATUS_PRESENTATION, severityColor } from "./artifact-status-presentation.ts";
|
|
4
5
|
import { callService } from "./service-client.ts";
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const severity = typeof rule.extra["severity"] === "string" ? rule.extra["severity"].toUpperCase() : "INFO";
|
|
7
|
+
export function ruleRowMeta(rule: Artifact, theme: Theme): string {
|
|
8
|
+
const severity = typeof rule.extra["severity"] === "string" ? rule.extra["severity"] : "info";
|
|
9
|
+
const severityText = theme.fg(severityColor(severity), severity.toUpperCase());
|
|
10
10
|
const condition = typeof rule.extra["condition"] === "string" ? `when ${rule.extra["condition"]}` : "always";
|
|
11
|
-
return `${
|
|
11
|
+
return `${severityText} · ${condition}`;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export function ruleInjectionPreview(rule: Pick<Artifact, "title" | "body" | "extra">): string {
|
|
@@ -23,7 +23,7 @@ export async function showRules(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
23
23
|
title: "Rules",
|
|
24
24
|
listOperation: "rules.list",
|
|
25
25
|
statusOrder: ["active", "deprecated"],
|
|
26
|
-
|
|
26
|
+
presentation: RULE_STATUS_PRESENTATION,
|
|
27
27
|
rowMeta: ruleRowMeta,
|
|
28
28
|
actions: (rule) => ["Show details", "Preview injection", "Link gated task", rule.status === "active" ? "Disable" : "Enable"],
|
|
29
29
|
handleAction: async (choice, rule, commandCtx) => {
|