@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.
- package/README.md +5 -189
- package/package.json +8 -16
- package/src/artifact-relationship-view.ts +23 -0
- package/src/cli.ts +0 -0
- package/src/index.ts +32 -0
- package/src/task-relationship-view.ts +2 -1
- package/extension/src/active-task-continuation.ts +0 -131
- package/extension/src/artifact-browser.ts +0 -229
- package/extension/src/artifact-detail-format.ts +0 -31
- package/extension/src/artifact-detail-view.ts +0 -112
- package/extension/src/artifact-format.ts +0 -84
- package/extension/src/artifact-status-presentation.ts +0 -71
- package/extension/src/base-prompt-breakdown.ts +0 -55
- package/extension/src/beautiful-mermaid-renderer.ts +0 -68
- package/extension/src/bounded-poll.ts +0 -20
- package/extension/src/context-budget.ts +0 -503
- package/extension/src/context-injection-telemetry.ts +0 -88
- package/extension/src/context-view.ts +0 -222
- package/extension/src/discuss-ask-layout.ts +0 -193
- package/extension/src/discuss-ask-view.ts +0 -1301
- package/extension/src/discuss.ts +0 -134
- package/extension/src/discussion-detail-view.ts +0 -136
- package/extension/src/docs.ts +0 -58
- package/extension/src/domain-tools.ts +0 -886
- package/extension/src/index.ts +0 -776
- package/extension/src/markdown.ts +0 -60
- package/extension/src/note-widget.ts +0 -8
- package/extension/src/notes.ts +0 -102
- package/extension/src/playbook-bridge.ts +0 -91
- package/extension/src/playbooks.ts +0 -97
- package/extension/src/rules.ts +0 -51
- package/extension/src/service-client.ts +0 -29
- package/extension/src/session-identity.ts +0 -22
- package/extension/src/skill-catalog-footprint.ts +0 -183
- package/extension/src/skills.ts +0 -127
- package/extension/src/task-context.ts +0 -1
- package/extension/src/task-detail-format.ts +0 -110
- package/extension/src/task-detail-view.ts +0 -139
- package/extension/src/task-focus-events.ts +0 -57
- package/extension/src/task-graph.ts +0 -116
- package/extension/src/task-presentation.ts +0 -26
- package/extension/src/task-widget.ts +0 -70
- package/extension/src/tasks.ts +0 -418
- package/extension/src/tool-rendering/artifact-card.ts +0 -117
- package/extension/src/tool-rendering/artifact-list.ts +0 -179
- package/extension/src/tool-rendering/index.ts +0 -109
- 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
|
-
}
|