@danypops/papyrus 0.13.0 → 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.
@@ -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;
@@ -71,11 +69,73 @@ export interface ContextSegmentItem {
71
69
  }
72
70
 
73
71
  export interface ContextSegment {
74
- key: "rules" | "tasks" | "skills" | "other";
72
+ key: "rules" | "tasks" | "skills" | "basePrompt" | "messageHistory" | "other";
75
73
  label: string;
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;
85
+ }
86
+
87
+ /**
88
+ * Session branch entries as SessionManager exposes them (docs/session-format.md): a subset
89
+ * covering only the fields this estimate reads, so this stays testable with plain object
90
+ * literals instead of importing pi's own session types.
91
+ */
92
+ export interface SessionBranchEntryLike {
93
+ type: string;
94
+ message?: unknown;
95
+ summary?: string;
96
+ }
97
+
98
+ function messageContentCharacters(message: unknown): number {
99
+ if (typeof message !== "object" || message === null) return 0;
100
+ const record = message as Record<string, unknown>;
101
+ if (record["role"] === "bashExecution") {
102
+ // Pi's own context builder excludes "!!"-prefixed bash output from context; match that.
103
+ if (record["excludeFromContext"] === true) return 0;
104
+ return String(record["command"] ?? "").length + String(record["output"] ?? "").length;
105
+ }
106
+ const content = record["content"];
107
+ if (typeof content === "string") return content.length;
108
+ if (!Array.isArray(content)) return 0;
109
+ let characters = 0;
110
+ for (const block of content) {
111
+ if (typeof block !== "object" || block === null) continue;
112
+ const b = block as Record<string, unknown>;
113
+ if (b["type"] === "text") characters += String(b["text"] ?? "").length;
114
+ else if (b["type"] === "thinking") characters += String(b["thinking"] ?? "").length;
115
+ else if (b["type"] === "toolCall") characters += JSON.stringify(b["arguments"] ?? {}).length;
116
+ // "image" blocks are deliberately not counted here -- image tokens follow a different,
117
+ // non-character-based cost model this char/4 estimate cannot represent; this is a real,
118
+ // documented undercount for image-heavy sessions, not a silent approximation.
119
+ }
120
+ return characters;
121
+ }
122
+
123
+ /**
124
+ * Estimates the conversation transcript's own context contribution by walking the actual
125
+ * session branch (docs/session-format.md's buildSessionContext(): message/compaction/
126
+ * branch_summary entries participate in context, plain "custom" entries do not). This is
127
+ * character-count estimation like every other Papyrus segment here, not exact -- but it is
128
+ * real session content, not a guess, and in a long-running session this is very likely the
129
+ * dominant contributor to "the base prompt, message history, and tool definitions" bucket
130
+ * that would otherwise stay fully opaque.
131
+ */
132
+ export function estimateMessageHistoryTokens(branch: ReadonlyArray<SessionBranchEntryLike>): number {
133
+ let characters = 0;
134
+ for (const entry of branch) {
135
+ if (entry.type === "message") characters += messageContentCharacters(entry.message);
136
+ else if (entry.type === "compaction" || entry.type === "branch_summary") characters += (entry.summary ?? "").length;
137
+ }
138
+ return Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
79
139
  }
80
140
 
81
141
  export interface ContextBreakdown {
@@ -85,7 +145,16 @@ export interface ContextBreakdown {
85
145
  contextWindow: number | null;
86
146
  /** contextWindow - reserveTokens, mirroring Pi's own compaction-trigger formula. Null when contextWindow is unknown. */
87
147
  effectiveBudget: number | null;
88
- /** rules, tasks, skills, then "other" absorbing whatever real usage the first three don't account for. */
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. */
89
158
  segments: ContextSegment[];
90
159
  }
91
160
 
@@ -94,19 +163,27 @@ export interface BuildContextBreakdownInput {
94
163
  contextWindow: number | null;
95
164
  reserveTokens?: number;
96
165
  ruleBudget: ContextBudget["rules"];
97
- taskEstimatedTokens: number;
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[];
98
168
  skills: SkillCatalogFootprint;
169
+ /** Pi's own base system prompt size, cached from the last observed before_agent_start turn. Null before any turn has run yet. */
170
+ basePromptEstimatedTokens: number | null;
171
+ /** From estimateMessageHistoryTokens() against the live session branch. */
172
+ messageHistoryEstimatedTokens: number;
99
173
  }
100
174
 
101
175
  /**
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.
176
+ * Composes every segment Papyrus can actually measure or estimate (rules, tasks, skills
177
+ * catalog, cached base-prompt size, and the live session's own message history) against the
178
+ * real total Pi reports, deriving "unaccounted" (tool definitions and framework overhead --
179
+ * genuinely invisible to any extension) as the remainder. The remainder is clamped to zero
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.
110
187
  */
111
188
  export function buildContextBreakdown(input: BuildContextBreakdownInput): ContextBreakdown {
112
189
  const reserveTokens = input.reserveTokens ?? DEFAULT_RESERVE_TOKENS;
@@ -116,58 +193,45 @@ export function buildContextBreakdown(input: BuildContextBreakdownInput): Contex
116
193
  estimatedTokens: input.ruleBudget.totalEstimatedTokens,
117
194
  items: input.ruleBudget.entries.map((entry) => ({ label: entry.title, estimatedTokens: entry.estimatedTokens })),
118
195
  };
119
- const tasks: ContextSegment = { key: "tasks", label: "Papyrus Tasks", estimatedTokens: input.taskEstimatedTokens };
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
+ };
120
202
  const skills: ContextSegment = {
121
203
  key: "skills",
122
204
  label: "Pi Skills catalog",
123
205
  estimatedTokens: input.skills.totalEstimatedTokens,
124
206
  items: input.skills.entries.map((entry) => ({ label: entry.name, estimatedTokens: entry.estimatedTokens })),
125
207
  };
126
- const knownTokens = rules.estimatedTokens + tasks.estimatedTokens + skills.estimatedTokens;
208
+ const basePrompt: ContextSegment = {
209
+ key: "basePrompt",
210
+ label: input.basePromptEstimatedTokens === null ? "Base system prompt (not observed yet)" : "Base system prompt (Pi + host instructions)",
211
+ estimatedTokens: input.basePromptEstimatedTokens ?? 0,
212
+ ...(input.basePromptEstimatedTokens === null ? { unknown: true } : {}),
213
+ };
214
+ const messageHistory: ContextSegment = {
215
+ key: "messageHistory",
216
+ label: "Conversation message history",
217
+ estimatedTokens: input.messageHistoryEstimatedTokens,
218
+ };
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);
127
221
  const other: ContextSegment = {
128
222
  key: "other",
129
- label: "Everything else (base prompt, message history, tool definitions)",
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)",
130
226
  estimatedTokens: input.totalTokens === null ? 0 : Math.max(0, input.totalTokens - knownTokens),
131
227
  };
132
228
  return {
133
229
  totalTokens: input.totalTokens,
134
230
  contextWindow: input.contextWindow,
135
231
  effectiveBudget: input.contextWindow === null ? null : Math.max(0, input.contextWindow - reserveTokens),
136
- segments: [rules, tasks, skills, other],
232
+ overshootTokens,
233
+ segments: [rules, tasks, skills, basePrompt, messageHistory, other],
137
234
  };
138
235
  }
139
236
 
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
237
 
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
- }
@@ -1,17 +1,30 @@
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, ContextSegmentItem } from "./context-budget.ts";
4
- import { formatContextBudgetReport, type ContextBudget } from "./context-budget.ts";
3
+ import type { ContextBreakdown, ContextSegment } from "./context-budget.ts";
5
4
 
6
- const DRILLDOWN_VISIBLE_ROWS = 15;
5
+ const VISIBLE_ROWS = 24;
7
6
 
8
7
  const SEGMENT_COLORS: Record<ContextSegment["key"], ThemeColor> = {
9
8
  rules: "accent",
10
9
  tasks: "success",
11
10
  skills: "mdLink",
11
+ basePrompt: "warning",
12
+ messageHistory: "syntaxFunction",
12
13
  other: "muted",
13
14
  };
14
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
+
15
28
  function formatTokenCount(tokens: number): string {
16
29
  return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
17
30
  }
@@ -21,35 +34,47 @@ function percentOf(part: number, whole: number): string {
21
34
  }
22
35
 
23
36
  /**
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.
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).
27
43
  */
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;
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;
40
64
  }
41
65
 
42
66
  class ContextViewport {
43
- private selectedIndex = 0;
44
- private drillDown: ContextSegment | null = null;
45
- private drillIndex = 0;
67
+ private offsetY = 0;
68
+ private readonly rows: ContextRow[];
46
69
 
47
70
  constructor(
48
71
  private readonly tui: TUI,
49
72
  private readonly theme: Theme,
50
73
  private readonly breakdown: ContextBreakdown,
51
74
  private readonly close: () => void,
52
- ) {}
75
+ ) {
76
+ this.rows = buildContextRows(breakdown);
77
+ }
53
78
 
54
79
  invalidate(): void {}
55
80
 
@@ -69,103 +94,81 @@ class ContextViewport {
69
94
  } else if (this.breakdown.totalTokens !== null) {
70
95
  lines.push(truncateToWidth(`${formatTokenCount(this.breakdown.totalTokens)} tokens (model context window unknown)`, contentWidth, ""));
71
96
  } else {
72
- lines.push(theme.fg("dim", "No real usage reported yet — segment sizes below are Papyrus's own estimates only"));
97
+ lines.push(theme.fg("dim", "No real usage reported yet — sizes below are Papyrus's own estimates only"));
73
98
  }
74
99
  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));
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, ""));
81
102
  }
82
- lines.push(border);
83
- return lines;
84
- }
103
+ lines.push("");
85
104
 
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, ""));
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;
98
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
+
99
114
  lines.push("");
100
- lines.push(theme.fg("dim", "↑↓ select · enter expand · esc close"));
115
+ lines.push(theme.fg("dim", "↑↓ scroll · esc close"));
116
+ lines.push(border);
101
117
  return lines;
102
118
  }
103
119
 
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;
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;
126
125
  }
127
126
 
128
127
  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
- }
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;
147
132
  this.tui.requestRender();
148
133
  }
149
134
  }
150
135
 
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")!;
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
- 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");
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");
164
167
  }
165
168
 
166
- export async function showContextView(ctx: ExtensionCommandContext, breakdown: ContextBreakdown, ruleBudget: ContextBudget["rules"]): Promise<void> {
169
+ export async function showContextView(ctx: ExtensionCommandContext, breakdown: ContextBreakdown): Promise<void> {
167
170
  if (ctx.mode !== "tui") {
168
- ctx.ui.notify(fallbackReport(breakdown, ruleBudget), "info");
171
+ ctx.ui.notify(fallbackReport(breakdown), "info");
169
172
  return;
170
173
  }
171
174
  await ctx.ui.custom<void>((tui, theme, _keybindings, done) => new ContextViewport(tui, theme, breakdown, done));
@@ -27,7 +27,7 @@ import { ActiveTaskContinuation, automaticPauseReason, shouldResumeFocusOnHumanI
27
27
  import { buildTaskWidgetProjection, type TaskWidgetProjection } from "./task-widget.ts";
28
28
  import { TASK_STATUS_PRESENTATION, taskTreeConnector } from "./task-presentation.ts";
29
29
  import { buildContextInjection } from "./context-injection-telemetry.ts";
30
- import { buildContextBreakdown, computeContextBudget, computeRuleBudget } from "./context-budget.ts";
30
+ import { buildContextBreakdown, computeContextBudget, computeRuleBudget, estimateMessageHistoryTokens, type SessionBranchEntryLike } from "./context-budget.ts";
31
31
  import { showContextView } from "./context-view.ts";
32
32
  import { emitTaskFocusEvent, setTaskFocusEventBus } from "./task-focus-events.ts";
33
33
  import { renderPapyrusToolCall, renderPapyrusToolResult } from "./tool-rendering/index.ts";
@@ -160,6 +160,11 @@ export default async function (pi: ExtensionAPI) {
160
160
  let contextInjectionSequence = 0;
161
161
  const contextInjectionProducerId = randomUUID();
162
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;
163
168
  const taskContinuation = new ActiveTaskContinuation({
164
169
  maxTurns: TASK_DRIVER_MAX_TURNS,
165
170
  maxUnchangedTurns: TASK_DRIVER_MAX_UNCHANGED_TURNS,
@@ -416,21 +421,30 @@ export default async function (pi: ExtensionAPI) {
416
421
  handler: async (_args, ctx) => {
417
422
  try {
418
423
  const sessionId = ctx.sessionManager.getSessionId();
419
- const [rules, taskSummary] = await Promise.all([
424
+ const [rules, openTasks] = await Promise.all([
420
425
  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 }),
426
+ callService<Record<string, unknown>, Artifact[]>("tasks.list", { project_root: ctx.cwd, session_id: sessionId, limit: 200 }),
422
427
  ]);
423
428
  const { skills } = computeContextBudget(rules, ctx.cwd);
424
429
  const ruleBudget = computeRuleBudget(rules);
425
430
  const usage = ctx.getContextUsage?.();
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) }));
426
438
  const breakdown = buildContextBreakdown({
427
439
  totalTokens: usage?.tokens ?? null,
428
440
  contextWindow: ctx.model?.contextWindow ?? null,
429
441
  ruleBudget,
430
- taskEstimatedTokens: taskSummary ? Math.ceil(taskSummary.length / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN) : 0,
442
+ taskItems,
431
443
  skills,
444
+ basePromptEstimatedTokens: lastObservedBasePromptTokens,
445
+ messageHistoryEstimatedTokens: estimateMessageHistoryTokens(branch),
432
446
  });
433
- await showContextView(ctx, breakdown, ruleBudget);
447
+ await showContextView(ctx, breakdown);
434
448
  } catch (error) {
435
449
  ctx.ui.notify(`Context breakdown failed: ${error instanceof Error ? error.message : error}`, "error");
436
450
  }
@@ -502,6 +516,7 @@ export default async function (pi: ExtensionAPI) {
502
516
  previousFingerprint: previousContextInjectionFingerprint,
503
517
  });
504
518
  previousContextInjectionFingerprint = injection.observation.fingerprint;
519
+ lastObservedBasePromptTokens = Math.ceil(injection.observation.before.characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
505
520
  pi.events.emit(PAPYRUS_CONTEXT_INJECTION_CHANNEL, injection.observation);
506
521
  if (injection.prompt !== (event.systemPrompt ?? "")) return { systemPrompt: injection.prompt };
507
522
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],