@danypops/papyrus 0.34.2 → 0.35.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.
Files changed (47) hide show
  1. package/README.md +5 -189
  2. package/package.json +8 -16
  3. package/src/artifact-relationship-view.ts +23 -0
  4. package/src/cli.ts +0 -0
  5. package/src/index.ts +32 -0
  6. package/src/task-relationship-view.ts +2 -1
  7. package/extension/src/active-task-continuation.ts +0 -131
  8. package/extension/src/artifact-browser.ts +0 -229
  9. package/extension/src/artifact-detail-format.ts +0 -31
  10. package/extension/src/artifact-detail-view.ts +0 -112
  11. package/extension/src/artifact-format.ts +0 -84
  12. package/extension/src/artifact-status-presentation.ts +0 -71
  13. package/extension/src/base-prompt-breakdown.ts +0 -55
  14. package/extension/src/beautiful-mermaid-renderer.ts +0 -68
  15. package/extension/src/bounded-poll.ts +0 -20
  16. package/extension/src/context-budget.ts +0 -503
  17. package/extension/src/context-injection-telemetry.ts +0 -88
  18. package/extension/src/context-view.ts +0 -222
  19. package/extension/src/discuss-ask-layout.ts +0 -193
  20. package/extension/src/discuss-ask-view.ts +0 -1301
  21. package/extension/src/discuss.ts +0 -134
  22. package/extension/src/discussion-detail-view.ts +0 -136
  23. package/extension/src/docs.ts +0 -58
  24. package/extension/src/domain-tools.ts +0 -886
  25. package/extension/src/index.ts +0 -776
  26. package/extension/src/markdown.ts +0 -60
  27. package/extension/src/note-widget.ts +0 -8
  28. package/extension/src/notes.ts +0 -102
  29. package/extension/src/playbook-bridge.ts +0 -91
  30. package/extension/src/playbooks.ts +0 -97
  31. package/extension/src/rules.ts +0 -51
  32. package/extension/src/service-client.ts +0 -29
  33. package/extension/src/session-identity.ts +0 -22
  34. package/extension/src/skill-catalog-footprint.ts +0 -183
  35. package/extension/src/skills.ts +0 -127
  36. package/extension/src/task-context.ts +0 -1
  37. package/extension/src/task-detail-format.ts +0 -110
  38. package/extension/src/task-detail-view.ts +0 -139
  39. package/extension/src/task-focus-events.ts +0 -57
  40. package/extension/src/task-graph.ts +0 -116
  41. package/extension/src/task-presentation.ts +0 -26
  42. package/extension/src/task-widget.ts +0 -70
  43. package/extension/src/tasks.ts +0 -418
  44. package/extension/src/tool-rendering/artifact-card.ts +0 -117
  45. package/extension/src/tool-rendering/artifact-list.ts +0 -179
  46. package/extension/src/tool-rendering/index.ts +0 -109
  47. package/extension/src/tool-rendering/render-model.ts +0 -410
@@ -1,222 +0,0 @@
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
-
5
- const VISIBLE_ROWS = 24;
6
-
7
- const SEGMENT_COLORS: Record<ContextSegment["key"], ThemeColor> = {
8
- rules: "accent",
9
- tasks: "success",
10
- skills: "mdLink",
11
- basePrompt: "warning",
12
- messageHistory: "syntaxFunction",
13
- toolDefinitions: "syntaxKeyword",
14
- other: "muted",
15
- };
16
-
17
- /**
18
- * One row in the unified scrollable view. Every segment that has any real (nonzero) content
19
- * is fully expanded inline -- there is no separate "select a segment, then drill in" step.
20
- * `key` drives this row's color; `isHeader` distinguishes a segment's own summary line from
21
- * its item rows underneath it.
22
- */
23
- export interface ContextRow {
24
- key: ContextSegment["key"];
25
- isHeader: boolean;
26
- text: string;
27
- /** Nesting depth for indentation -- 0 for a segment header or a top-level item, deeper for real tree children (message history branches, Task containment). */
28
- depth: number;
29
- }
30
-
31
- function formatTokenCount(tokens: number): string {
32
- return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
33
- }
34
-
35
- function percentOf(part: number, whole: number): string {
36
- return whole > 0 ? `${((part / whole) * 100).toFixed(1)}%` : "—";
37
- }
38
-
39
- /**
40
- * Flattens every segment with real content into one linear row list, filtering out anything
41
- * that is genuinely zero rather than displaying a misleading "0 tok 0.0%" row -- a segment or
42
- * item with literally nothing in it carries no information and is pure noise in a scrollable
43
- * view meant to show where tokens actually go. A segment whose OWN total is zero but whose
44
- * items are also all zero is dropped entirely; a segment with a nonzero total is always kept
45
- * even if all its items individually round to zero (the total itself is real signal).
46
- */
47
- /** Recursively flattens one item and its real tree children (message history branches, Task containment) into indented rows, sorted biggest-first at each level -- a parent always immediately precedes its own children, never scrambled by a global sort. */
48
- function flattenItem(item: ContextSegmentItem, key: ContextSegment["key"], depth: number, rows: ContextRow[]): void {
49
- rows.push({ key, isHeader: false, depth, text: `${item.estimatedTokens.toString().padStart(6)} tok ${item.label}` });
50
- const children = (item.children ?? []).filter((child) => child.estimatedTokens > 0).sort((a, b) => b.estimatedTokens - a.estimatedTokens);
51
- for (const child of children) flattenItem(child, key, depth + 1, rows);
52
- }
53
-
54
- export function buildContextRows(breakdown: ContextBreakdown): ContextRow[] {
55
- const rows: ContextRow[] = [];
56
- const denominator = breakdown.totalTokens ?? breakdown.segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
57
- for (const segment of breakdown.segments) {
58
- const items = (segment.items ?? []).filter((item) => item.estimatedTokens > 0).sort((a, b) => b.estimatedTokens - a.estimatedTokens);
59
- // A genuinely-unknown segment (basePrompt before the first observed turn) must stay
60
- // visible even when its placeholder value is zero -- hiding it would misrepresent
61
- // "not measured yet" as "measured and empty", the same honesty problem overshootTokens
62
- // exists to prevent for the unaccounted bucket.
63
- if (segment.estimatedTokens <= 0 && items.length === 0 && !segment.unknown) continue;
64
- rows.push({
65
- key: segment.key,
66
- isHeader: true,
67
- depth: 0,
68
- text: `${segment.label} — ${segment.estimatedTokens} tok (${percentOf(segment.estimatedTokens, denominator)})`,
69
- });
70
- for (const item of items) flattenItem(item, segment.key, 1, rows);
71
- }
72
- return rows;
73
- }
74
-
75
- class ContextViewport {
76
- private offsetY = 0;
77
- private readonly rows: ContextRow[];
78
-
79
- constructor(
80
- private readonly tui: TUI,
81
- private readonly theme: Theme,
82
- private readonly breakdown: ContextBreakdown,
83
- private readonly close: () => void,
84
- ) {
85
- this.rows = buildContextRows(breakdown);
86
- }
87
-
88
- invalidate(): void {}
89
-
90
- render(width: number): string[] {
91
- const theme = this.theme;
92
- const contentWidth = Math.max(1, width);
93
- const border = theme.fg("borderMuted", "─".repeat(contentWidth));
94
- const lines: string[] = [border, truncateToWidth(theme.fg("accent", theme.bold("Context")), contentWidth, "")];
95
-
96
- if (this.breakdown.totalTokens !== null && this.breakdown.effectiveBudget !== null) {
97
- const percent = percentOf(this.breakdown.totalTokens, this.breakdown.effectiveBudget);
98
- lines.push(truncateToWidth(
99
- `${formatTokenCount(this.breakdown.totalTokens)} / ${formatTokenCount(this.breakdown.effectiveBudget)} tokens (${percent} of usable budget)`,
100
- contentWidth,
101
- "",
102
- ));
103
- } else if (this.breakdown.totalTokens !== null) {
104
- lines.push(truncateToWidth(`${formatTokenCount(this.breakdown.totalTokens)} tokens (model context window unknown)`, contentWidth, ""));
105
- } else {
106
- lines.push(theme.fg("dim", "No real usage reported yet — sizes below are Papyrus's own estimates only"));
107
- }
108
- lines.push(renderContextBar(theme, this.breakdown.segments, contentWidth, this.breakdown.effectiveBudget ?? undefined, this.breakdown.totalTokens ?? undefined));
109
- if (this.breakdown.overshootTokens > 0) {
110
- lines.push(truncateToWidth(theme.fg("warning", `Estimates exceed real total by ~${this.breakdown.overshootTokens} tok — sizes below are approximate, not exact`), contentWidth, ""));
111
- }
112
- lines.push("");
113
-
114
- this.visibleWindow().forEach(({ row, index }) => {
115
- const gutter = theme.fg(SEGMENT_COLORS[row.key], "▌");
116
- const indent = " ".repeat(row.depth);
117
- const text = row.isHeader ? theme.bold(row.text) : `${indent}${row.text}`;
118
- lines.push(truncateToWidth(`${gutter} ${text}`, contentWidth, ""));
119
- void index;
120
- });
121
- if (this.rows.length === 0) lines.push(theme.fg("dim", " (nothing observed yet)"));
122
- else lines.push(theme.fg("muted", ` ${Math.min(this.offsetY + VISIBLE_ROWS, this.rows.length)}/${this.rows.length}`));
123
-
124
- lines.push("");
125
- lines.push(theme.fg("dim", "↑↓ scroll · esc close"));
126
- lines.push(border);
127
- return lines;
128
- }
129
-
130
- private visibleWindow(): Array<{ row: ContextRow; index: number }> {
131
- const end = Math.min(this.offsetY + VISIBLE_ROWS, this.rows.length);
132
- const result: Array<{ row: ContextRow; index: number }> = [];
133
- for (let index = this.offsetY; index < end; index++) result.push({ row: this.rows[index]!, index });
134
- return result;
135
- }
136
-
137
- handleInput(data: string): void {
138
- if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.close(); return; }
139
- if (matchesKey(data, "up")) this.offsetY = Math.max(0, this.offsetY - 1);
140
- else if (matchesKey(data, "down")) this.offsetY = Math.min(Math.max(0, this.rows.length - VISIBLE_ROWS), this.offsetY + 1);
141
- else return;
142
- this.tui.requestRender();
143
- }
144
- }
145
-
146
- /**
147
- * Distributes `totalCells` proportionally across `weights` (parallel arrays), guaranteeing
148
- * every genuinely-positive weight gets at least one cell when there is room for all of them
149
- * to (totalCells >= weights.length) -- a real, nonzero segment must stay visible even when
150
- * dwarfed by a much larger one, not round away to nothing. The largest resulting cell count
151
- * absorbs whatever rounding leaves over or short, so the sum always equals totalCells exactly.
152
- */
153
- function distributeCells(weights: readonly number[], totalCells: number): number[] {
154
- const sum = weights.reduce((a, b) => a + b, 0);
155
- if (sum <= 0 || totalCells <= 0 || weights.length === 0) return weights.map(() => 0);
156
- let cells = weights.map((weight) => Math.round((weight / sum) * totalCells));
157
- if (totalCells >= weights.length) cells = cells.map((count) => (count === 0 ? 1 : count));
158
- const diff = totalCells - cells.reduce((a, b) => a + b, 0);
159
- if (diff !== 0) {
160
- const maxIndex = cells.indexOf(Math.max(...cells));
161
- cells[maxIndex] = (cells[maxIndex] ?? 0) + diff;
162
- }
163
- return cells;
164
- }
165
-
166
- /**
167
- * Renders the context window as one horizontal stacked bar: one colored run of block
168
- * characters per USED segment, followed by a gray/dim run of "░" cells for the remaining,
169
- * genuinely EMPTY context window -- this is the "total used vs. unused" graph. A zero-token
170
- * breakdown (nothing observed yet) renders an entirely gray/dim track rather than a
171
- * divide-by-zero, since 0 used really does mean the whole window is empty right now.
172
- *
173
- * `capacity` is the real denominator (Papyrus's own effectiveBudget, matching the percentage
174
- * already shown in the text line above this bar). `usedTokens` is the real, ground-truth used
175
- * amount (breakdown.totalTokens) the used-vs-unused split is measured against -- NOT the sum of
176
- * `segments`' own estimates. That distinction is load-bearing: a live-reported bug showed a
177
- * fully solid bar with zero gray even though the header read "55.9% of usable budget", because
178
- * the old code compared `capacity` against the SUM of estimated segments, which independently
179
- * overshot both the real total and the capacity itself (a session whose message-history
180
- * estimate alone summed to over 1.5M tokens against a real ~550k total) -- the exact estimate-
181
- * overshoot dishonesty `overshootTokens` exists to surface elsewhere was silently defeating the
182
- * bar's own gray/used split. `usedTokens` defaults to the segment sum only when omitted, for
183
- * callers with no real total available. Segments still split the USED portion proportionally to
184
- * their own estimated share of each other (via distributeCells, which also guarantees a tiny
185
- * nonzero segment stays visible rather than rounding to nothing next to a much larger one).
186
- */
187
- export function renderContextBar(theme: Theme, segments: ReadonlyArray<ContextSegment>, width: number, capacity?: number, usedTokens?: number): string {
188
- const estimatedSum = segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
189
- if (estimatedSum <= 0 || width <= 0) return theme.fg("dim", "░".repeat(Math.max(0, width)));
190
- const realUsed = usedTokens ?? estimatedSum;
191
- const usedWidth = capacity !== undefined ? Math.max(0, Math.min(width, Math.round((realUsed / capacity) * width))) : width;
192
-
193
- const nonZero = segments.filter((segment) => segment.estimatedTokens > 0);
194
- const cellCounts = distributeCells(nonZero.map((segment) => segment.estimatedTokens), usedWidth);
195
- let output = "";
196
- nonZero.forEach((segment, index) => {
197
- const cells = cellCounts[index] ?? 0;
198
- if (cells > 0) output += theme.fg(SEGMENT_COLORS[segment.key], "█".repeat(cells));
199
- });
200
- const emptyWidth = width - usedWidth;
201
- if (emptyWidth > 0) output += theme.fg("dim", "░".repeat(emptyWidth));
202
- return output;
203
- }
204
-
205
- /** Non-interactive fallback (print mode, RPC, etc.): the same unified row list, as plain text lines. */
206
- function fallbackReport(breakdown: ContextBreakdown): string {
207
- const totalLine = breakdown.totalTokens !== null
208
- ? `Real usage: ${breakdown.totalTokens} tokens${breakdown.effectiveBudget !== null ? ` / ${breakdown.effectiveBudget} usable budget (${percentOf(breakdown.totalTokens, breakdown.effectiveBudget)})` : ""}`
209
- : "Real usage: not yet reported";
210
- const overshootLine = breakdown.overshootTokens > 0 ? [`Estimates exceed real total by ~${breakdown.overshootTokens} tok -- sizes below are approximate, not exact`] : [];
211
- const rows = buildContextRows(breakdown);
212
- const rowLines = rows.length > 0 ? rows.map((row) => (row.isHeader ? row.text : `${" ".repeat(row.depth)}${row.text}`)) : ["(nothing observed yet)"];
213
- return [totalLine, ...overshootLine, "", ...rowLines].join("\n");
214
- }
215
-
216
- export async function showContextView(ctx: ExtensionCommandContext, breakdown: ContextBreakdown): Promise<void> {
217
- if (ctx.mode !== "tui") {
218
- ctx.ui.notify(fallbackReport(breakdown), "info");
219
- return;
220
- }
221
- await ctx.ui.custom<void>((tui, theme, _keybindings, done) => new ContextViewport(tui, theme, breakdown, done));
222
- }
@@ -1,193 +0,0 @@
1
- /**
2
- * discuss-ask-layout.ts — row layout for the searchable single-select list used by Discuss's
3
- * live:true ask UI (discuss-ask-view.ts). Wraps long option titles/descriptions to a target
4
- * width and returns flat annotated rows, windowed around the current selection when the full
5
- * list would overflow the available rows.
6
- *
7
- * Adapted from pi-ask-user's single-select-layout.ts (MIT, Copyright (c) 2026 Enzo Lucchesi --
8
- * see THIRD_PARTY_LICENSES.md) so Discuss owns this UI directly instead of depending on that
9
- * package at runtime.
10
- */
11
-
12
- export interface AskOption {
13
- title: string;
14
- description?: string;
15
- }
16
-
17
- export interface AnnotatedRow {
18
- line: string;
19
- selected: boolean;
20
- }
21
-
22
- export interface RenderSingleSelectRowsParams {
23
- options: AskOption[];
24
- selectedIndex: number;
25
- width: number;
26
- allowFreeform: boolean;
27
- allowComment?: boolean;
28
- commentEnabled?: boolean;
29
- maxRows?: number;
30
- hideDescriptions?: boolean;
31
- }
32
-
33
- function wrapText(text: string, width: number): string[] {
34
- const normalized = text.replace(/\s+/g, " ").trim();
35
- if (!normalized) return [""];
36
- if (width <= 1) return normalized.split("");
37
-
38
- const words = normalized.split(" ");
39
- const lines: string[] = [];
40
- let current = "";
41
-
42
- for (const word of words) {
43
- if (!current) {
44
- if (word.length <= width) {
45
- current = word;
46
- } else {
47
- for (let i = 0; i < word.length; i += width) {
48
- lines.push(word.slice(i, i + width));
49
- }
50
- }
51
- continue;
52
- }
53
-
54
- const candidate = `${current} ${word}`;
55
- if (candidate.length <= width) {
56
- current = candidate;
57
- continue;
58
- }
59
-
60
- lines.push(current);
61
- if (word.length <= width) {
62
- current = word;
63
- } else {
64
- current = "";
65
- for (let i = 0; i < word.length; i += width) {
66
- const chunk = word.slice(i, i + width);
67
- if (chunk.length === width || i + width < word.length) lines.push(chunk);
68
- else current = chunk;
69
- }
70
- }
71
- }
72
-
73
- if (current) lines.push(current);
74
- return lines;
75
- }
76
-
77
- function padLine(prefix: string, content: string): string {
78
- return `${prefix}${content}`.trimEnd();
79
- }
80
-
81
- interface ItemBlock {
82
- itemIndex: number;
83
- lines: string[];
84
- }
85
-
86
- type ListItem =
87
- | { type: "option"; option: AskOption }
88
- | { type: "comment-toggle"; option: AskOption }
89
- | { type: "freeform"; option: AskOption };
90
-
91
- function buildItemBlocks(
92
- options: AskOption[],
93
- width: number,
94
- allowFreeform: boolean,
95
- allowComment: boolean,
96
- commentEnabled: boolean,
97
- selectedIndex: number,
98
- hideDescriptions = false,
99
- ): ItemBlock[] {
100
- const normalizedWidth = Math.max(12, width);
101
- const freeformLabel = "Type something. — Enter a custom response";
102
- const commentToggleLabel = `${commentEnabled ? "[✓]" : "[ ]"} Add extra context after selection`;
103
- const allItems: ListItem[] = options.map((option) => ({ type: "option", option }));
104
- if (allowComment) allItems.push({ type: "comment-toggle", option: { title: commentToggleLabel } });
105
- if (allowFreeform) allItems.push({ type: "freeform", option: { title: freeformLabel } });
106
-
107
- return allItems.map((item, itemIndex) => {
108
- const pointer = itemIndex === selectedIndex ? "→" : " ";
109
- const lines: string[] = [];
110
-
111
- if (item.type === "comment-toggle" || item.type === "freeform") {
112
- const prefix = `${pointer} `;
113
- const wrapped = wrapText(item.option.title, Math.max(8, normalizedWidth - prefix.length));
114
- wrapped.forEach((line, lineIndex) => {
115
- lines.push(padLine(lineIndex === 0 ? prefix : " ".repeat(prefix.length), line));
116
- });
117
- return { itemIndex, lines };
118
- }
119
-
120
- const numberPrefix = `${pointer} ${itemIndex + 1}. `;
121
- const continuationPrefix = " ".repeat(numberPrefix.length);
122
- const titleLines = wrapText(item.option.title, Math.max(8, normalizedWidth - numberPrefix.length));
123
- titleLines.forEach((line, lineIndex) => {
124
- lines.push(padLine(lineIndex === 0 ? numberPrefix : continuationPrefix, line));
125
- });
126
-
127
- if (item.option.description && !hideDescriptions) {
128
- const descriptionPrefix = " ";
129
- const descriptionLines = wrapText(item.option.description, Math.max(8, normalizedWidth - descriptionPrefix.length));
130
- descriptionLines.forEach((line) => lines.push(padLine(descriptionPrefix, line)));
131
- }
132
-
133
- return { itemIndex, lines };
134
- });
135
- }
136
-
137
- function flatten(blocks: ItemBlock[], selectedIndex: number): AnnotatedRow[] {
138
- return blocks.flatMap((block) => block.lines.map((line) => ({ line, selected: block.itemIndex === selectedIndex })));
139
- }
140
-
141
- export function renderSingleSelectRows({
142
- options,
143
- selectedIndex,
144
- width,
145
- allowFreeform,
146
- allowComment = false,
147
- commentEnabled = false,
148
- maxRows,
149
- hideDescriptions,
150
- }: RenderSingleSelectRowsParams): AnnotatedRow[] {
151
- const itemCount = options.length + (allowComment ? 1 : 0) + (allowFreeform ? 1 : 0);
152
- const blocks = buildItemBlocks(options, width, allowFreeform, allowComment, commentEnabled, selectedIndex, hideDescriptions);
153
- const allRows = flatten(blocks, selectedIndex);
154
-
155
- if (!Number.isFinite(maxRows) || !maxRows || maxRows <= 0 || allRows.length <= maxRows) return allRows;
156
-
157
- const safeMaxRows = Math.max(1, Math.floor(maxRows));
158
- const selectedBlock = blocks[selectedIndex] ?? blocks[0];
159
- if (!selectedBlock) return [];
160
-
161
- const indicator = ` (${selectedIndex + 1}/${itemCount})`;
162
- const availableRows = safeMaxRows > 1 ? safeMaxRows - 1 : 1;
163
-
164
- if (selectedBlock.lines.length >= availableRows) {
165
- const visible = selectedBlock.lines.slice(0, availableRows).map((line) => ({ line, selected: true }));
166
- if (safeMaxRows > 1) visible.push({ line: indicator, selected: false });
167
- return visible.slice(0, safeMaxRows);
168
- }
169
-
170
- let start = selectedIndex;
171
- let end = selectedIndex + 1;
172
- let usedRows = selectedBlock.lines.length;
173
-
174
- while (true) {
175
- const nextCanFit = end < blocks.length && usedRows + blocks[end]!.lines.length <= availableRows;
176
- if (nextCanFit) {
177
- usedRows += blocks[end]!.lines.length;
178
- end += 1;
179
- continue;
180
- }
181
- const prevCanFit = start > 0 && usedRows + blocks[start - 1]!.lines.length <= availableRows;
182
- if (prevCanFit) {
183
- start -= 1;
184
- usedRows += blocks[start]!.lines.length;
185
- continue;
186
- }
187
- break;
188
- }
189
-
190
- const visible = flatten(blocks.slice(start, end), selectedIndex);
191
- visible.push({ line: indicator, selected: false });
192
- return visible.slice(0, safeMaxRows);
193
- }