@danypops/papyrus 0.13.4 → 0.13.5

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.
@@ -13,6 +13,19 @@ const SEGMENT_COLORS: Record<ContextSegment["key"], ThemeColor> = {
13
13
  other: "muted",
14
14
  };
15
15
 
16
+ /** Short, fixed-width column labels for the vertical deep-dive graph -- must match VERTICAL_BAR_WIDTH exactly so each label sits centered under its own bar. */
17
+ const SEGMENT_SHORT_LABELS: Record<ContextSegment["key"], string> = {
18
+ rules: "Rul",
19
+ tasks: "Tsk",
20
+ skills: "Skl",
21
+ basePrompt: "Bse",
22
+ messageHistory: "Msg",
23
+ other: "Oth",
24
+ };
25
+
26
+ const VERTICAL_BAR_HEIGHT = 6;
27
+ const VERTICAL_BAR_WIDTH = 3;
28
+
16
29
  /**
17
30
  * One row in the unified scrollable view. Every segment that has any real (nonzero) content
18
31
  * is fully expanded inline -- there is no separate "select a segment, then drill in" step.
@@ -104,10 +117,16 @@ class ContextViewport {
104
117
  } else {
105
118
  lines.push(theme.fg("dim", "No real usage reported yet — sizes below are Papyrus's own estimates only"));
106
119
  }
107
- lines.push(renderContextBar(theme, this.breakdown.segments, contentWidth));
120
+ lines.push(renderContextBar(theme, this.breakdown.segments, contentWidth, this.breakdown.effectiveBudget ?? undefined));
108
121
  if (this.breakdown.overshootTokens > 0) {
109
122
  lines.push(truncateToWidth(theme.fg("warning", `Estimates exceed real total by ~${this.breakdown.overshootTokens} tok — sizes below are approximate, not exact`), contentWidth, ""));
110
123
  }
124
+ const verticalBars = renderContextVerticalBars(theme, this.breakdown.segments);
125
+ if (verticalBars.length > 0) {
126
+ lines.push("");
127
+ lines.push(theme.fg("dim", "Composition of used tokens:"));
128
+ for (const barLine of verticalBars) lines.push(truncateToWidth(barLine, contentWidth, ""));
129
+ }
111
130
  lines.push("");
112
131
 
113
132
  this.visibleWindow().forEach(({ row, index }) => {
@@ -143,27 +162,67 @@ class ContextViewport {
143
162
  }
144
163
 
145
164
  /**
146
- * Renders the context window as one proportional stacked bar, one colored run of block
147
- * characters per segment, matching each row's own gutter color above/below it. A zero-token
148
- * breakdown (nothing observed yet) renders an empty dim track rather than a divide-by-zero.
149
- * Zero-token segments contribute no cells and are effectively invisible in the bar, matching
150
- * their exclusion from the row list below it.
165
+ * Renders the context window as one horizontal stacked bar: one colored run of block
166
+ * characters per USED segment, followed by a gray/dim run of "░" cells for the remaining,
167
+ * genuinely EMPTY context window -- this is the "total used vs. unused" graph. A zero-token
168
+ * breakdown (nothing observed yet) renders an entirely gray/dim track rather than a
169
+ * divide-by-zero, since 0 used really does mean the whole window is empty right now.
170
+ *
171
+ * `capacity` is the real denominator (Papyrus's own effectiveBudget, matching the percentage
172
+ * already shown in the text line above this bar) that used-vs-unused is measured against. When
173
+ * omitted, or when usage has already exceeded it (overshoot / near-compaction), the bar falls
174
+ * back to filling 100% of its width proportionally among segments -- there is no "unused" left
175
+ * to show gray for once real usage has met or passed the real budget.
151
176
  */
152
- export function renderContextBar(theme: Theme, segments: ReadonlyArray<ContextSegment>, width: number): string {
177
+ export function renderContextBar(theme: Theme, segments: ReadonlyArray<ContextSegment>, width: number, capacity?: number): string {
153
178
  const total = segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
154
179
  if (total <= 0 || width <= 0) return theme.fg("dim", "░".repeat(Math.max(0, width)));
155
180
  const nonZero = segments.filter((segment) => segment.estimatedTokens > 0);
181
+ const usedWidth = capacity !== undefined && capacity > total ? Math.min(width, Math.round((total / capacity) * width)) : width;
182
+
156
183
  let used = 0;
157
184
  let output = "";
158
185
  nonZero.forEach((segment, index) => {
159
186
  const isLast = index === nonZero.length - 1;
160
- const cells = isLast ? width - used : Math.round((segment.estimatedTokens / total) * width);
187
+ const cells = isLast ? usedWidth - used : Math.round((segment.estimatedTokens / total) * usedWidth);
161
188
  used += cells;
162
189
  if (cells > 0) output += theme.fg(SEGMENT_COLORS[segment.key], "█".repeat(cells));
163
190
  });
191
+ const emptyWidth = width - usedWidth;
192
+ if (emptyWidth > 0) output += theme.fg("dim", "░".repeat(emptyWidth));
164
193
  return output;
165
194
  }
166
195
 
196
+ /**
197
+ * Renders the "used" portion's own composition as a small vertical bar chart, one column per
198
+ * segment with real content, scaled so the largest segment fills the full height -- the
199
+ * "deep dive" graph, complementing the horizontal used-vs-unused bar above it. Any segment
200
+ * with real (nonzero) tokens gets at least one filled row so it stays visible even next to a
201
+ * much larger segment. Returns an empty array (nothing to render) when no segment has any
202
+ * tokens yet, matching the same zero-noise principle as the row list below it.
203
+ */
204
+ export function renderContextVerticalBars(theme: Theme, segments: ReadonlyArray<ContextSegment>): string[] {
205
+ const visible = segments.filter((segment) => segment.estimatedTokens > 0);
206
+ if (visible.length === 0) return [];
207
+ const max = Math.max(...visible.map((segment) => segment.estimatedTokens));
208
+ const filledRows = new Map(visible.map((segment) => [segment.key, Math.max(1, Math.round((segment.estimatedTokens / max) * VERTICAL_BAR_HEIGHT))]));
209
+
210
+ const lines: string[] = [];
211
+ for (let row = 0; row < VERTICAL_BAR_HEIGHT; row++) {
212
+ const rowsFromBottom = VERTICAL_BAR_HEIGHT - row;
213
+ let line = "";
214
+ for (const segment of visible) {
215
+ const filled = (filledRows.get(segment.key) ?? 0) >= rowsFromBottom;
216
+ line += `${filled ? theme.fg(SEGMENT_COLORS[segment.key], "█".repeat(VERTICAL_BAR_WIDTH)) : " ".repeat(VERTICAL_BAR_WIDTH)} `;
217
+ }
218
+ lines.push(line);
219
+ }
220
+ let legend = "";
221
+ for (const segment of visible) legend += `${theme.fg(SEGMENT_COLORS[segment.key], SEGMENT_SHORT_LABELS[segment.key])} `;
222
+ lines.push(legend);
223
+ return lines;
224
+ }
225
+
167
226
  /** Non-interactive fallback (print mode, RPC, etc.): the same unified row list, as plain text lines. */
168
227
  function fallbackReport(breakdown: ContextBreakdown): string {
169
228
  const totalLine = breakdown.totalTokens !== null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.13.4",
3
+ "version": "0.13.5",
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"],