@danypops/papyrus 0.12.0 → 0.13.1

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.
@@ -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
- glyphs: Record<string, string>;
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 }) => `${config.glyphs[status] ?? status} ${count} ${status}`)
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 glyph = config.glyphs[row.status] ?? "?";
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", ` · ${meta}`) : ""}`, width, ""));
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,242 @@
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" | "basePrompt" | "messageHistory" | "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
+ /**
82
+ * Session branch entries as SessionManager exposes them (docs/session-format.md): a subset
83
+ * covering only the fields this estimate reads, so this stays testable with plain object
84
+ * literals instead of importing pi's own session types.
85
+ */
86
+ export interface SessionBranchEntryLike {
87
+ type: string;
88
+ message?: unknown;
89
+ summary?: string;
90
+ }
91
+
92
+ function messageContentCharacters(message: unknown): number {
93
+ if (typeof message !== "object" || message === null) return 0;
94
+ const record = message as Record<string, unknown>;
95
+ if (record["role"] === "bashExecution") {
96
+ // Pi's own context builder excludes "!!"-prefixed bash output from context; match that.
97
+ if (record["excludeFromContext"] === true) return 0;
98
+ return String(record["command"] ?? "").length + String(record["output"] ?? "").length;
99
+ }
100
+ const content = record["content"];
101
+ if (typeof content === "string") return content.length;
102
+ if (!Array.isArray(content)) return 0;
103
+ let characters = 0;
104
+ for (const block of content) {
105
+ if (typeof block !== "object" || block === null) continue;
106
+ const b = block as Record<string, unknown>;
107
+ if (b["type"] === "text") characters += String(b["text"] ?? "").length;
108
+ else if (b["type"] === "thinking") characters += String(b["thinking"] ?? "").length;
109
+ else if (b["type"] === "toolCall") characters += JSON.stringify(b["arguments"] ?? {}).length;
110
+ // "image" blocks are deliberately not counted here -- image tokens follow a different,
111
+ // non-character-based cost model this char/4 estimate cannot represent; this is a real,
112
+ // documented undercount for image-heavy sessions, not a silent approximation.
113
+ }
114
+ return characters;
115
+ }
116
+
117
+ /**
118
+ * Estimates the conversation transcript's own context contribution by walking the actual
119
+ * session branch (docs/session-format.md's buildSessionContext(): message/compaction/
120
+ * branch_summary entries participate in context, plain "custom" entries do not). This is
121
+ * character-count estimation like every other Papyrus segment here, not exact -- but it is
122
+ * real session content, not a guess, and in a long-running session this is very likely the
123
+ * dominant contributor to "the base prompt, message history, and tool definitions" bucket
124
+ * that would otherwise stay fully opaque.
125
+ */
126
+ export function estimateMessageHistoryTokens(branch: ReadonlyArray<SessionBranchEntryLike>): number {
127
+ let characters = 0;
128
+ for (const entry of branch) {
129
+ if (entry.type === "message") characters += messageContentCharacters(entry.message);
130
+ else if (entry.type === "compaction" || entry.type === "branch_summary") characters += (entry.summary ?? "").length;
131
+ }
132
+ return Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
133
+ }
134
+
135
+ export interface ContextBreakdown {
136
+ /** Real usage from ctx.getContextUsage() -- ground truth, not estimated. Null only when Pi has no usage yet (e.g. before the first turn). */
137
+ totalTokens: number | null;
138
+ /** From ctx.model.contextWindow. Null when the active model's context window is unknown. */
139
+ contextWindow: number | null;
140
+ /** contextWindow - reserveTokens, mirroring Pi's own compaction-trigger formula. Null when contextWindow is unknown. */
141
+ effectiveBudget: number | null;
142
+ /** rules, tasks, skills, then "other" absorbing whatever real usage the first three don't account for. */
143
+ segments: ContextSegment[];
144
+ }
145
+
146
+ export interface BuildContextBreakdownInput {
147
+ totalTokens: number | null;
148
+ contextWindow: number | null;
149
+ reserveTokens?: number;
150
+ ruleBudget: ContextBudget["rules"];
151
+ taskEstimatedTokens: number;
152
+ skills: SkillCatalogFootprint;
153
+ /** Pi's own base system prompt size, cached from the last observed before_agent_start turn. Null before any turn has run yet. */
154
+ basePromptEstimatedTokens: number | null;
155
+ /** From estimateMessageHistoryTokens() against the live session branch. */
156
+ messageHistoryEstimatedTokens: number;
157
+ }
158
+
159
+ /**
160
+ * Composes every segment Papyrus can actually measure or estimate (rules, tasks, skills
161
+ * catalog, cached base-prompt size, and the live session's own message history) against the
162
+ * real total Pi reports, deriving "unaccounted" (tool definitions and framework overhead --
163
+ * genuinely invisible to any extension) as the remainder. The remainder is clamped to zero
164
+ * rather than shown negative: char/4 token estimation is approximate, and a small overshoot
165
+ * in the known segments must not display as a nonsensical negative bucket. When the real
166
+ * total is unavailable, unaccounted is reported as zero and totalTokens surfaces as null so
167
+ * callers can label the whole breakdown as estimate-only rather than silently treating a
168
+ * partial sum as ground truth.
169
+ */
170
+ export function buildContextBreakdown(input: BuildContextBreakdownInput): ContextBreakdown {
171
+ const reserveTokens = input.reserveTokens ?? DEFAULT_RESERVE_TOKENS;
172
+ const rules: ContextSegment = {
173
+ key: "rules",
174
+ label: "Papyrus Rules",
175
+ estimatedTokens: input.ruleBudget.totalEstimatedTokens,
176
+ items: input.ruleBudget.entries.map((entry) => ({ label: entry.title, estimatedTokens: entry.estimatedTokens })),
177
+ };
178
+ const tasks: ContextSegment = { key: "tasks", label: "Papyrus Tasks", estimatedTokens: input.taskEstimatedTokens };
179
+ const skills: ContextSegment = {
180
+ key: "skills",
181
+ label: "Pi Skills catalog",
182
+ estimatedTokens: input.skills.totalEstimatedTokens,
183
+ items: input.skills.entries.map((entry) => ({ label: entry.name, estimatedTokens: entry.estimatedTokens })),
184
+ };
185
+ const basePrompt: ContextSegment = {
186
+ key: "basePrompt",
187
+ label: input.basePromptEstimatedTokens === null ? "Base system prompt (not observed yet)" : "Base system prompt (Pi + host instructions)",
188
+ estimatedTokens: input.basePromptEstimatedTokens ?? 0,
189
+ };
190
+ const messageHistory: ContextSegment = {
191
+ key: "messageHistory",
192
+ label: "Conversation message history",
193
+ estimatedTokens: input.messageHistoryEstimatedTokens,
194
+ };
195
+ const knownTokens = rules.estimatedTokens + tasks.estimatedTokens + skills.estimatedTokens + basePrompt.estimatedTokens + messageHistory.estimatedTokens;
196
+ const other: ContextSegment = {
197
+ key: "other",
198
+ label: "Unaccounted (tool definitions, framework overhead)",
199
+ estimatedTokens: input.totalTokens === null ? 0 : Math.max(0, input.totalTokens - knownTokens),
200
+ };
201
+ return {
202
+ totalTokens: input.totalTokens,
203
+ contextWindow: input.contextWindow,
204
+ effectiveBudget: input.contextWindow === null ? null : Math.max(0, input.contextWindow - reserveTokens),
205
+ segments: [rules, tasks, skills, basePrompt, messageHistory, other],
206
+ };
207
+ }
208
+
209
+ function truncate(text: string, max: number): string {
210
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
211
+ }
212
+
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
+ }
@@ -0,0 +1,177 @@
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
+ basePrompt: "warning",
13
+ messageHistory: "syntaxFunction",
14
+ other: "muted",
15
+ };
16
+
17
+ function formatTokenCount(tokens: number): string {
18
+ return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
19
+ }
20
+
21
+ function percentOf(part: number, whole: number): string {
22
+ return whole > 0 ? `${((part / whole) * 100).toFixed(1)}%` : "—";
23
+ }
24
+
25
+ /**
26
+ * Renders the context window as one proportional stacked bar, one colored run of block
27
+ * characters per segment, matching each row's own swatch color below it. A zero-token
28
+ * breakdown (nothing observed yet) renders an empty dim track rather than a divide-by-zero.
29
+ */
30
+ export function renderContextBar(theme: Theme, segments: ReadonlyArray<ContextSegment>, width: number): string {
31
+ const total = segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
32
+ if (total <= 0 || width <= 0) return theme.fg("dim", "░".repeat(Math.max(0, width)));
33
+ let used = 0;
34
+ let output = "";
35
+ segments.forEach((segment, index) => {
36
+ const isLast = index === segments.length - 1;
37
+ const cells = isLast ? width - used : Math.round((segment.estimatedTokens / total) * width);
38
+ used += cells;
39
+ if (cells > 0) output += theme.fg(SEGMENT_COLORS[segment.key], "█".repeat(cells));
40
+ });
41
+ return output;
42
+ }
43
+
44
+ class ContextViewport {
45
+ private selectedIndex = 0;
46
+ private drillDown: ContextSegment | null = null;
47
+ private drillIndex = 0;
48
+
49
+ constructor(
50
+ private readonly tui: TUI,
51
+ private readonly theme: Theme,
52
+ private readonly breakdown: ContextBreakdown,
53
+ private readonly close: () => void,
54
+ ) {}
55
+
56
+ invalidate(): void {}
57
+
58
+ render(width: number): string[] {
59
+ const theme = this.theme;
60
+ const contentWidth = Math.max(1, width);
61
+ const border = theme.fg("borderMuted", "─".repeat(contentWidth));
62
+ const lines: string[] = [border, truncateToWidth(theme.fg("accent", theme.bold("Context")), contentWidth, "")];
63
+
64
+ if (this.breakdown.totalTokens !== null && this.breakdown.effectiveBudget !== null) {
65
+ const percent = percentOf(this.breakdown.totalTokens, this.breakdown.effectiveBudget);
66
+ lines.push(truncateToWidth(
67
+ `${formatTokenCount(this.breakdown.totalTokens)} / ${formatTokenCount(this.breakdown.effectiveBudget)} tokens (${percent} of usable budget)`,
68
+ contentWidth,
69
+ "",
70
+ ));
71
+ } else if (this.breakdown.totalTokens !== null) {
72
+ lines.push(truncateToWidth(`${formatTokenCount(this.breakdown.totalTokens)} tokens (model context window unknown)`, contentWidth, ""));
73
+ } else {
74
+ lines.push(theme.fg("dim", "No real usage reported yet — segment sizes below are Papyrus's own estimates only"));
75
+ }
76
+ lines.push(renderContextBar(theme, this.breakdown.segments, contentWidth));
77
+ lines.push("");
78
+
79
+ if (this.drillDown) {
80
+ lines.push(...this.renderDrillDown(contentWidth));
81
+ } else {
82
+ lines.push(...this.renderSegments(contentWidth));
83
+ }
84
+ lines.push(border);
85
+ return lines;
86
+ }
87
+
88
+ private renderSegments(width: number): string[] {
89
+ const theme = this.theme;
90
+ const lines: string[] = [];
91
+ const denominator = this.breakdown.totalTokens ?? this.breakdown.segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
92
+ this.breakdown.segments.forEach((segment, index) => {
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, ""));
100
+ });
101
+ lines.push("");
102
+ lines.push(theme.fg("dim", "↑↓ select · enter expand · esc close"));
103
+ return lines;
104
+ }
105
+
106
+ private renderDrillDown(width: number): string[] {
107
+ const theme = this.theme;
108
+ const segment = this.drillDown!;
109
+ const items: ContextSegmentItem[] = segment.items ?? [];
110
+ const lines: string[] = [truncateToWidth(theme.fg("muted", `${segment.label} — largest first`), width, "")];
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;
128
+ }
129
+
130
+ handleInput(data: string): void {
131
+ if (this.drillDown) {
132
+ const items = this.drillDown.items ?? [];
133
+ if (matchesKey(data, "escape")) this.drillDown = null;
134
+ else if (matchesKey(data, "up")) this.drillIndex = Math.max(0, this.drillIndex - 1);
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
+ }
149
+ this.tui.requestRender();
150
+ }
151
+ }
152
+
153
+ /** Non-interactive fallback (print mode, RPC, etc.): every segment listed plainly, plus the existing per-rule/per-skill breakdown for the two segments that support drill-down. */
154
+ function fallbackReport(breakdown: ContextBreakdown, ruleBudget: ContextBudget["rules"]): string {
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
+ const denominator = breakdown.totalTokens ?? breakdown.segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
159
+ const segmentLines = breakdown.segments.map((segment) => ` ${segment.estimatedTokens.toString().padStart(7)} tok ${percentOf(segment.estimatedTokens, denominator).padStart(5)} ${segment.label}`);
160
+ const skillsSegment = breakdown.segments.find((segment) => segment.key === "skills");
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");
169
+ }
170
+
171
+ export async function showContextView(ctx: ExtensionCommandContext, breakdown: ContextBreakdown, ruleBudget: ContextBudget["rules"]): Promise<void> {
172
+ if (ctx.mode !== "tui") {
173
+ ctx.ui.notify(fallbackReport(breakdown, ruleBudget), "info");
174
+ return;
175
+ }
176
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) => new ContextViewport(tui, theme, breakdown, done));
177
+ }
@@ -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
- return [document.subtype, document.labels.join(", ")].filter(Boolean).join(" · ");
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
- glyphs: DOC_GLYPHS,
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) => {
@@ -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, estimateMessageHistoryTokens, type SessionBranchEntryLike } 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
- lines.push(truncateToWidth(`${focus} ${hierarchy} ${glyph} ${row.task.title}`, width, "…"));
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
  }
@@ -152,6 +160,11 @@ export default async function (pi: ExtensionAPI) {
152
160
  let contextInjectionSequence = 0;
153
161
  const contextInjectionProducerId = randomUUID();
154
162
  let previousContextInjectionFingerprint: string | undefined;
163
+ // Cached from the most recent before_agent_start observation: Pi's own base system prompt
164
+ // is only ever visible transiently inside that hook's event.systemPrompt, so /context
165
+ // reuses the size buildContextInjection already computes every turn rather than going
166
+ // without it entirely.
167
+ let lastObservedBasePromptTokens: number | null = null;
155
168
  const taskContinuation = new ActiveTaskContinuation({
156
169
  maxTurns: TASK_DRIVER_MAX_TURNS,
157
170
  maxUnchangedTurns: TASK_DRIVER_MAX_UNCHANGED_TURNS,
@@ -403,6 +416,34 @@ export default async function (pi: ExtensionAPI) {
403
416
  description: "Browse and invoke Papyrus skills and templates (interactive)",
404
417
  handler: async (_args, ctx) => { await skillsModule.showSkills(ctx); },
405
418
  });
419
+ pi.registerCommand("context", {
420
+ 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",
421
+ handler: async (_args, ctx) => {
422
+ try {
423
+ const sessionId = ctx.sessionManager.getSessionId();
424
+ const [rules, taskSummary] = await Promise.all([
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>, string | null>("tasks.context", { project_root: ctx.cwd, session_id: sessionId }),
427
+ ]);
428
+ const { skills } = computeContextBudget(rules, ctx.cwd);
429
+ const ruleBudget = computeRuleBudget(rules);
430
+ const usage = ctx.getContextUsage?.();
431
+ const branch = ctx.sessionManager.getBranch() as unknown as SessionBranchEntryLike[];
432
+ const breakdown = buildContextBreakdown({
433
+ totalTokens: usage?.tokens ?? null,
434
+ contextWindow: ctx.model?.contextWindow ?? null,
435
+ ruleBudget,
436
+ taskEstimatedTokens: taskSummary ? Math.ceil(taskSummary.length / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN) : 0,
437
+ skills,
438
+ basePromptEstimatedTokens: lastObservedBasePromptTokens,
439
+ messageHistoryEstimatedTokens: estimateMessageHistoryTokens(branch),
440
+ });
441
+ await showContextView(ctx, breakdown, ruleBudget);
442
+ } catch (error) {
443
+ ctx.ui.notify(`Context breakdown failed: ${error instanceof Error ? error.message : error}`, "error");
444
+ }
445
+ },
446
+ });
406
447
 
407
448
  // ── Task widget (TodoOverlay pattern: factory form, requestRender) ──
408
449
 
@@ -469,6 +510,7 @@ export default async function (pi: ExtensionAPI) {
469
510
  previousFingerprint: previousContextInjectionFingerprint,
470
511
  });
471
512
  previousContextInjectionFingerprint = injection.observation.fingerprint;
513
+ lastObservedBasePromptTokens = Math.ceil(injection.observation.before.characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
472
514
  pi.events.emit(PAPYRUS_CONTEXT_INJECTION_CHANNEL, injection.observation);
473
515
  if (injection.prompt !== (event.systemPrompt ?? "")) return { systemPrompt: injection.prompt };
474
516
  } catch {
@@ -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
- glyphs: NOTE_GLYPHS,
55
+ presentation: NOTE_STATUS_PRESENTATION,
57
56
  rowMeta: noteRowMeta,
58
57
  actions: (note) => [
59
58
  "Show details",