pi-ask-popup 0.1.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 (42) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +55 -0
  3. package/docs/adr/0001-fork-rpiv-ask-user-question-as-zero-dep-pi-ask-popup.md +90 -0
  4. package/package.json +48 -0
  5. package/src/ask-user-question.ts +474 -0
  6. package/src/config.ts +250 -0
  7. package/src/events.ts +107 -0
  8. package/src/index.ts +25 -0
  9. package/src/reconcile.ts +31 -0
  10. package/src/rpc-fallback.ts +198 -0
  11. package/src/state/build-questionnaire.ts +346 -0
  12. package/src/state/external-editor.ts +94 -0
  13. package/src/state/key-router.ts +378 -0
  14. package/src/state/questionnaire-session.ts +382 -0
  15. package/src/state/row-intent.ts +156 -0
  16. package/src/state/selectors/contract.ts +40 -0
  17. package/src/state/selectors/derivations.ts +40 -0
  18. package/src/state/selectors/focus.ts +17 -0
  19. package/src/state/selectors/projections.ts +111 -0
  20. package/src/state/state-reducer.ts +421 -0
  21. package/src/state/state.ts +110 -0
  22. package/src/tool/format-answer.ts +28 -0
  23. package/src/tool/response-envelope.ts +123 -0
  24. package/src/tool/types.ts +193 -0
  25. package/src/tool/validate-questionnaire.ts +74 -0
  26. package/src/view/component-binding.ts +51 -0
  27. package/src/view/components/inline-input.ts +66 -0
  28. package/src/view/components/multi-select-view.ts +208 -0
  29. package/src/view/components/option-list-view.ts +77 -0
  30. package/src/view/components/preview/markdown-content-cache.ts +76 -0
  31. package/src/view/components/preview/preview-block-renderer.ts +116 -0
  32. package/src/view/components/preview/preview-box-renderer.ts +88 -0
  33. package/src/view/components/preview/preview-layout-decider.ts +219 -0
  34. package/src/view/components/preview/preview-pane.ts +240 -0
  35. package/src/view/components/submit-picker.ts +66 -0
  36. package/src/view/components/tab-bar.ts +70 -0
  37. package/src/view/components/wrapping-select.ts +313 -0
  38. package/src/view/dialog-builder.ts +325 -0
  39. package/src/view/props-adapter.ts +124 -0
  40. package/src/view/stateful-view.ts +20 -0
  41. package/src/view/tab-components.ts +16 -0
  42. package/src/view/tab-content-strategy.ts +447 -0
@@ -0,0 +1,208 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
+ import { ROW_INTENT_META } from "../../state/row-intent.js";
4
+ import type { QuestionData } from "../../tool/types.js";
5
+ import type { StatefulView } from "../stateful-view.js";
6
+ import { renderInlineInputRow } from "./inline-input.js";
7
+
8
+ const ACTIVE_POINTER = "❯ ";
9
+ const INACTIVE_POINTER = " ";
10
+ const CHECKED = "[✔]";
11
+ const UNCHECKED = "[ ]";
12
+ const NUMBER_SEPARATOR = ". ";
13
+ const BOX_LABEL_GAP = " ";
14
+ // CC parity: description continuation indents to col 2 (past the pointer slot), NOT to the
15
+ // full prefix column. Wrap width still uses prefixVisibleWidth so naturalHeight matches render.
16
+ const CONTINUATION_INDENT = " ";
17
+
18
+ export const MULTI_SUBMIT_LABEL = "Submit";
19
+
20
+ export interface MultiSelectOtherRowProps {
21
+ /** The "Type something." row is the focused row (optionIndex === options.length). */
22
+ active: boolean;
23
+ /** `state.inputMode` — true once the row has focus and keystrokes append to the buffer. */
24
+ inputMode: boolean;
25
+ /** Live inline-input buffer (read from `runtime.inputBuffer` / `ctx.inputBuffer`). */
26
+ inputBuffer: string;
27
+ inputCursorOffset: number | undefined;
28
+ }
29
+
30
+ export interface MultiSelectViewProps {
31
+ rows: ReadonlyArray<{ checked: boolean; active: boolean }>;
32
+ other: MultiSelectOtherRowProps;
33
+ nextActive: boolean;
34
+ nextLabel: string;
35
+ }
36
+
37
+ /**
38
+ * Renders the multi-select option list (one row per option — pointer + checkbox + label —
39
+ * plus zero or more wrapped continuation lines per description).
40
+ *
41
+ * `naturalHeight(width)` is the rendered height for the current props. It grows when
42
+ * the custom-answer editor contains logical or visually wrapped lines, allowing the
43
+ * dialog to reserve exactly the space the active draft needs.
44
+ *
45
+ * One width-keyed layout supplies rendering, height, and focused-row measurement;
46
+ * `setProps` and `invalidate` discard that derived cache.
47
+ */
48
+ interface MultiSelectLayout {
49
+ lines: string[];
50
+ focusedRange: [number, number];
51
+ }
52
+
53
+ /** Mutable row accumulator threaded through the append helpers during a layout miss. */
54
+ interface MultiSelectBuild {
55
+ lines: string[];
56
+ focusedRange: [number, number];
57
+ }
58
+
59
+ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
60
+ private props: MultiSelectViewProps;
61
+ private cachedLayout: { width: number; value: MultiSelectLayout } | undefined;
62
+
63
+ constructor(
64
+ private readonly theme: Theme,
65
+ private readonly question: QuestionData,
66
+ ) {
67
+ this.props = {
68
+ rows: [],
69
+ other: { active: false, inputMode: false, inputBuffer: "", inputCursorOffset: undefined },
70
+ nextActive: false,
71
+ nextLabel: ROW_INTENT_META.next.label,
72
+ };
73
+ }
74
+
75
+ setProps(props: MultiSelectViewProps): void {
76
+ this.props = props;
77
+ this.cachedLayout = undefined;
78
+ }
79
+
80
+ handleInput(_data: string): void {}
81
+
82
+ invalidate(): void {
83
+ this.cachedLayout = undefined;
84
+ }
85
+
86
+ render(width: number): string[] {
87
+ return this.layout(width).lines;
88
+ }
89
+
90
+ focusedItemRowRange(width: number): [number, number] {
91
+ return this.layout(width).focusedRange;
92
+ }
93
+
94
+ naturalHeight(width: number): number {
95
+ return this.layout(width).lines.length;
96
+ }
97
+
98
+ private layout(width: number): MultiSelectLayout {
99
+ if (this.cachedLayout?.width === width) return this.cachedLayout.value;
100
+
101
+ const build: MultiSelectBuild = { lines: [], focusedRange: [0, 0] };
102
+ const contentWidth = Math.max(1, width - this.prefixVisibleWidth());
103
+ const numberWidth = String(Math.max(1, this.question.options.length + 1)).length;
104
+
105
+ this.appendOptionRows(build, width, contentWidth, numberWidth);
106
+
107
+ const otherStart = build.lines.length;
108
+ build.lines.push(...this.renderOtherRow(contentWidth, numberWidth));
109
+ if (this.props.other.active) build.focusedRange = [otherStart, build.lines.length];
110
+
111
+ this.appendNextRow(build, width);
112
+
113
+ const value = { lines: build.lines, focusedRange: build.focusedRange };
114
+ this.cachedLayout = { width, value };
115
+ return value;
116
+ }
117
+
118
+ private appendOptionRows(
119
+ build: MultiSelectBuild,
120
+ width: number,
121
+ contentWidth: number,
122
+ numberWidth: number,
123
+ ): void {
124
+ for (let i = 0; i < this.question.options.length; i++) {
125
+ const opt = this.question.options[i];
126
+ const row = this.props.rows[i];
127
+ if (!opt || !row) continue;
128
+ const start = build.lines.length;
129
+ const pointer = row.active ? this.theme.fg("accent", ACTIVE_POINTER) : INACTIVE_POINTER;
130
+ // Checked and active rows share the accent hue, matching the dialog's selection rhythm.
131
+ const box = row.checked
132
+ ? this.theme.fg("accent", CHECKED)
133
+ : this.theme.fg("muted", UNCHECKED);
134
+ const label = truncateToWidth(opt.label, contentWidth, "…");
135
+ const styledLabel = row.active ? this.theme.fg("accent", this.theme.bold(label)) : label;
136
+ const number = String(i + 1).padStart(numberWidth, " ");
137
+ build.lines.push(
138
+ truncateToWidth(
139
+ `${pointer}${number}${NUMBER_SEPARATOR}${box}${BOX_LABEL_GAP}${styledLabel}`,
140
+ width,
141
+ "",
142
+ ),
143
+ );
144
+ if (opt.description) {
145
+ for (const segment of wrapTextWithAnsi(opt.description, contentWidth)) {
146
+ build.lines.push(CONTINUATION_INDENT + this.theme.fg("muted", segment));
147
+ }
148
+ }
149
+ if (row.active) build.focusedRange = [start, build.lines.length];
150
+ }
151
+ }
152
+
153
+ private appendNextRow(build: MultiSelectBuild, width: number): void {
154
+ const nextStart = build.lines.length;
155
+ const nextPointer = this.props.nextActive
156
+ ? this.theme.fg("accent", ACTIVE_POINTER)
157
+ : INACTIVE_POINTER;
158
+ const nextLabel = this.props.nextActive
159
+ ? this.theme.fg("accent", this.theme.bold(this.props.nextLabel))
160
+ : this.props.nextLabel;
161
+ build.lines.push(truncateToWidth(`${nextPointer}${nextLabel}`, width, ""));
162
+ if (this.props.nextActive) build.focusedRange = [nextStart, build.lines.length];
163
+ }
164
+
165
+ private renderOtherRow(contentWidth: number, numberWidth: number): string[] {
166
+ const other = this.props.other;
167
+ const pointer = other.active ? this.theme.fg("accent", ACTIVE_POINTER) : INACTIVE_POINTER;
168
+ const box = this.theme.fg("muted", UNCHECKED);
169
+ const number = String(this.question.options.length + 1).padStart(numberWidth, " ");
170
+ const rowPrefix = `${pointer}${number}${NUMBER_SEPARATOR}${box}${BOX_LABEL_GAP}`;
171
+ const continuationPrefix = " ".repeat(visibleWidth(rowPrefix));
172
+ const selectedText = (text: string) => this.theme.fg("accent", this.theme.bold(text));
173
+
174
+ if (other.active && other.inputMode) {
175
+ return renderInlineInputRow({
176
+ buffer: other.inputBuffer,
177
+ cursorOffset: other.inputCursorOffset,
178
+ rowPrefix,
179
+ continuationPrefix,
180
+ contentWidth,
181
+ selectedText,
182
+ });
183
+ }
184
+
185
+ return wrapTextWithAnsi(other.inputBuffer || ROW_INTENT_META.other.label, contentWidth).map(
186
+ (segment, index) => {
187
+ const line = `${index === 0 ? rowPrefix : continuationPrefix}${segment}`;
188
+ return other.active ? selectedText(line) : line;
189
+ },
190
+ );
191
+ }
192
+
193
+ private prefixVisibleWidth(): number {
194
+ // Canonical prefix for OPTION rows: INACTIVE_POINTER + numberWidth digits + NUMBER_SEPARATOR
195
+ // + UNCHECKED + BOX_LABEL_GAP. State-independent because ACTIVE/INACTIVE pointer share
196
+ // visibleWidth, CHECKED/UNCHECKED share visibleWidth, and numberWidth is constant per question.
197
+ // The number column fits `options.length + 1` so the "Type something." row's N+1 number
198
+ // is never clipped. The Next sentinel uses a bare `pointer + "Next"` shape — its width
199
+ // never exceeds this prefix at any reasonable terminal width, so it's safe to leave it
200
+ // out of the canonical computation.
201
+ const numberWidth = String(Math.max(1, this.question.options.length + 1)).length;
202
+ return (
203
+ visibleWidth(INACTIVE_POINTER) +
204
+ numberWidth +
205
+ visibleWidth(`${NUMBER_SEPARATOR}${UNCHECKED}${BOX_LABEL_GAP}`)
206
+ );
207
+ }
208
+ }
@@ -0,0 +1,77 @@
1
+ import type { StatefulView } from "../stateful-view.js";
2
+ import {
3
+ WrappingSelect,
4
+ type WrappingSelectItem,
5
+ type WrappingSelectTheme,
6
+ } from "./wrapping-select.js";
7
+
8
+ /**
9
+ * Maximum number of option rows visible in the WrappingSelect window. Lifted here from
10
+ * `preview-pane.ts` so the cap travels with the option-list owner.
11
+ */
12
+ export const MAX_VISIBLE_OPTIONS = 10;
13
+
14
+ export interface OptionListViewConfig {
15
+ items: readonly WrappingSelectItem[];
16
+ theme: WrappingSelectTheme;
17
+ }
18
+
19
+ /**
20
+ * Per-tick projection of OptionListView state. After Phase 11b, `inputBuffer`
21
+ * is part of the props bag — the session-owned `inlineInput` (a headless
22
+ * `pi-tui` Editor instance) supplies its current `getText()` here per tick.
23
+ * `OptionListView` is purely props-driven; the imperative buffer surface and
24
+ * read-back getters are gone.
25
+ */
26
+ export interface OptionListViewProps {
27
+ selectedIndex: number;
28
+ focused: boolean;
29
+ inputBuffer: string;
30
+ inputCursorOffset?: number;
31
+ /** Optional previously-confirmed indicator. Omit when no marker should be drawn. */
32
+ confirmed?: { index: number; labelOverride?: string };
33
+ }
34
+
35
+ /**
36
+ * Sole owner of the option list's interactive state. Wraps a single
37
+ * `WrappingSelect`. Implements `StatefulView<OptionListViewProps>`:
38
+ * `setProps` is the only mutator; render output reflects the last props
39
+ * received.
40
+ */
41
+ export class OptionListView implements StatefulView<OptionListViewProps> {
42
+ private readonly select: WrappingSelect;
43
+
44
+ constructor(config: OptionListViewConfig) {
45
+ this.select = new WrappingSelect(
46
+ config.items,
47
+ Math.min(config.items.length, MAX_VISIBLE_OPTIONS),
48
+ config.theme,
49
+ {
50
+ numberStartOffset: 0,
51
+ totalItemsForNumbering: config.items.length,
52
+ },
53
+ );
54
+ }
55
+
56
+ setProps(props: OptionListViewProps): void {
57
+ this.select.setSelectedIndex(props.selectedIndex);
58
+ this.select.setFocused(props.focused);
59
+ this.select.setConfirmedIndex(props.confirmed?.index, props.confirmed?.labelOverride);
60
+ this.select.setInputBuffer(props.inputBuffer);
61
+ this.select.setInputCursorOffset(props.inputCursorOffset);
62
+ }
63
+
64
+ handleInput(_data: string): void {}
65
+
66
+ invalidate(): void {
67
+ this.select.invalidate();
68
+ }
69
+
70
+ render(width: number): string[] {
71
+ return this.select.render(width);
72
+ }
73
+
74
+ focusedItemRowRange(width: number): [number, number] {
75
+ return this.select.focusedItemRowRange(width);
76
+ }
77
+ }
@@ -0,0 +1,76 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Markdown, type MarkdownTheme, visibleWidth } from "@earendil-works/pi-tui";
3
+ import type { QuestionData } from "../../../tool/types.js";
4
+ import { stripFenceMarkers } from "./preview-box-renderer.js";
5
+
6
+ /** CC parity in side-by-side layout. */
7
+ export const MAX_PREVIEW_HEIGHT_SIDE_BY_SIDE = 20;
8
+ /** Preserves narrow-terminal protection in stacked layout. */
9
+ export const MAX_PREVIEW_HEIGHT_STACKED = 15;
10
+ export const NO_PREVIEW_TEXT = "No preview available";
11
+ /** 1 blank separator + 1 affordance text row reserved when `hasAnyPreview` (height stability of the affordance row's offset relative to the box). */
12
+ export const NOTES_AFFORDANCE_OVERHEAD = 2;
13
+
14
+ /**
15
+ * Per-question cache for rendered markdown previews. Width-keyed: switching the
16
+ * inner width invalidates every cached `Markdown`'s render output (pi-tui's
17
+ * `Markdown.render(width)` re-wraps when width changes).
18
+ *
19
+ * Replaces the inline `previewTexts`, `markdownCache`, `cachedWidth` triple from
20
+ * the previous monolithic `preview-pane.ts`. One Markdown per option, lazy on
21
+ * first request, never re-constructed — count semantics frozen by tests.
22
+ */
23
+ export class MarkdownContentCache {
24
+ private readonly previewTexts: Map<number, string>;
25
+ private readonly markdownCache: Map<number, Markdown>;
26
+ private cachedWidth: number | undefined;
27
+ private readonly theme: Theme;
28
+ private readonly markdownTheme: MarkdownTheme;
29
+
30
+ constructor(question: QuestionData, theme: Theme, markdownTheme: MarkdownTheme) {
31
+ this.theme = theme;
32
+ this.markdownTheme = markdownTheme;
33
+ this.previewTexts = new Map();
34
+ for (let i = 0; i < question.options.length; i++) {
35
+ const raw = question.options[i]?.preview;
36
+ if (raw && raw.length > 0) this.previewTexts.set(i, raw);
37
+ }
38
+ this.markdownCache = new Map();
39
+ }
40
+
41
+ hasAnyPreview(): boolean {
42
+ return this.previewTexts.size > 0;
43
+ }
44
+
45
+ has(optionIndex: number): boolean {
46
+ return this.previewTexts.has(optionIndex);
47
+ }
48
+
49
+ /**
50
+ * Compute the body lines for a given option at a given inner width. Width changes
51
+ * invalidate the per-Markdown render cache.
52
+ */
53
+ bodyFor(optionIndex: number, innerWidth: number): string[] {
54
+ if (this.cachedWidth !== innerWidth) {
55
+ for (const md of this.markdownCache.values()) md.invalidate();
56
+ this.cachedWidth = innerWidth;
57
+ }
58
+ const text = this.previewTexts.get(optionIndex);
59
+ if (!text) {
60
+ const placeholder = this.theme.fg("dim", NO_PREVIEW_TEXT);
61
+ const pad = Math.max(0, innerWidth - visibleWidth(placeholder));
62
+ return [placeholder + " ".repeat(pad)];
63
+ }
64
+ let md = this.markdownCache.get(optionIndex);
65
+ if (!md) {
66
+ md = new Markdown(text, 0, 0, this.markdownTheme);
67
+ this.markdownCache.set(optionIndex, md);
68
+ }
69
+ return stripFenceMarkers(md.render(innerWidth));
70
+ }
71
+
72
+ invalidate(): void {
73
+ for (const md of this.markdownCache.values()) md.invalidate();
74
+ this.cachedWidth = undefined;
75
+ }
76
+ }
@@ -0,0 +1,116 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { MarkdownTheme } from "@earendil-works/pi-tui";
3
+ import type { QuestionData } from "../../../tool/types.js";
4
+ import {
5
+ MAX_PREVIEW_HEIGHT_SIDE_BY_SIDE,
6
+ MAX_PREVIEW_HEIGHT_STACKED,
7
+ MarkdownContentCache,
8
+ NOTES_AFFORDANCE_OVERHEAD,
9
+ } from "./markdown-content-cache.js";
10
+ import {
11
+ BORDER_HORIZONTAL_OVERHEAD,
12
+ BORDER_INNER_PADDING_HORIZONTAL,
13
+ BORDER_VERTICAL_OVERHEAD,
14
+ computeBoxDimensions,
15
+ renderBorderedBox,
16
+ } from "./preview-box-renderer.js";
17
+ import type { PreviewLayoutMode } from "./preview-layout-decider.js";
18
+
19
+ /**
20
+ * Affordance text shown below the bordered preview when focused on a preview-bearing option.
21
+ * Re-exported by `preview-pane.ts` for the existing test surface.
22
+ */
23
+ export const NOTES_AFFORDANCE_TEXT = "Notes: press n to add notes";
24
+
25
+ /** Content row budget for a layout mode: preview cap minus border + affordance overhead. */
26
+ function contentBudgetFor(mode: PreviewLayoutMode): number {
27
+ const cap =
28
+ mode === "side-by-side" ? MAX_PREVIEW_HEIGHT_SIDE_BY_SIDE : MAX_PREVIEW_HEIGHT_STACKED;
29
+ return Math.max(1, cap - BORDER_VERTICAL_OVERHEAD - NOTES_AFFORDANCE_OVERHEAD);
30
+ }
31
+
32
+ /** Inner (padding-aware) content width for a total block width. */
33
+ function innerWidthFor(width: number): number {
34
+ return Math.max(1, width - BORDER_HORIZONTAL_OVERHEAD - 2 * BORDER_INNER_PADDING_HORIZONTAL);
35
+ }
36
+
37
+ export interface PreviewBlockRendererConfig {
38
+ question: QuestionData;
39
+ theme: Theme;
40
+ markdownTheme: MarkdownTheme;
41
+ }
42
+
43
+ /**
44
+ * Renders the bordered markdown preview block for a single question (one block per render call,
45
+ * for the option at `optionIndex`). Owns a per-question `MarkdownContentCache`.
46
+ *
47
+ * NOT a `Component` — pure render-and-measure helper consumed by `PreviewPane`. The layout mode
48
+ * is threaded as an explicit param (never re-derived from column width post-split).
49
+ *
50
+ * The affordance row is always emitted (visually empty when gated) so the preview block's row
51
+ * count is height-stable across affordance-state transitions.
52
+ */
53
+ export class PreviewBlockRenderer {
54
+ private readonly theme: Theme;
55
+ private readonly cache: MarkdownContentCache;
56
+
57
+ constructor(config: PreviewBlockRendererConfig) {
58
+ this.theme = config.theme;
59
+ this.cache = new MarkdownContentCache(config.question, config.theme, config.markdownTheme);
60
+ }
61
+
62
+ hasAnyPreview(): boolean {
63
+ return this.cache.hasAnyPreview();
64
+ }
65
+
66
+ has(optionIndex: number): boolean {
67
+ return this.cache.has(optionIndex);
68
+ }
69
+
70
+ invalidate(): void {
71
+ this.cache.invalidate();
72
+ }
73
+
74
+ /**
75
+ * Height contribution of the preview block: `BORDER_VERTICAL_OVERHEAD + contentRows +
76
+ * NOTES_AFFORDANCE_OVERHEAD`. Always returns the same value as `renderBlock(...).length`
77
+ * — the affordance overhead is constant, not gated by `focused`/`notesVisible`.
78
+ */
79
+ blockHeight(width: number, optionIndex: number, mode: PreviewLayoutMode): number {
80
+ const contentBudget = contentBudgetFor(mode);
81
+ const innerWidth = innerWidthFor(width);
82
+ const rawRows = this.cache.bodyFor(optionIndex, innerWidth).length;
83
+ const contentRows = Math.min(rawRows, contentBudget);
84
+ return BORDER_VERTICAL_OVERHEAD + contentRows + NOTES_AFFORDANCE_OVERHEAD;
85
+ }
86
+
87
+ /**
88
+ * Render the full preview block at `width`: bordered box + blank separator + affordance row.
89
+ * `focused` and `notesVisible` together gate the affordance text (visible only when the
90
+ * focused option carries a preview AND notes mode is inactive). The affordance row is ALWAYS
91
+ * emitted (as an empty string when gated) so the row count is invariant.
92
+ */
93
+ renderBlock(
94
+ width: number,
95
+ optionIndex: number,
96
+ mode: PreviewLayoutMode,
97
+ focused: boolean,
98
+ notesVisible: boolean,
99
+ ): string[] {
100
+ const contentBudget = contentBudgetFor(mode);
101
+ const maxInnerWidth = innerWidthFor(width);
102
+
103
+ const raw = this.cache.bodyFor(optionIndex, maxInnerWidth);
104
+ const truncated = raw.length > contentBudget;
105
+ const hidden = truncated ? raw.length - contentBudget : 0;
106
+ const contentLines = truncated ? raw.slice(0, contentBudget) : raw;
107
+
108
+ const { boxWidth } = computeBoxDimensions(contentLines, maxInnerWidth);
109
+ const colorFn = (s: string) => this.theme.fg("accent", s);
110
+ const boxedLines = renderBorderedBox(contentLines, boxWidth, colorFn, hidden);
111
+
112
+ const showAffordance = focused && !notesVisible && this.cache.has(optionIndex);
113
+ const affordance = showAffordance ? this.theme.fg("muted", NOTES_AFFORDANCE_TEXT) : "";
114
+ return [...boxedLines, "", affordance];
115
+ }
116
+ }
@@ -0,0 +1,88 @@
1
+ import { stripVTControlCharacters } from "node:util";
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+
4
+ const FENCE_MARKER_RE = /^`{3}/;
5
+
6
+ /** Top + bottom border rows consumed by `renderBorderedBox`. */
7
+ export const BORDER_VERTICAL_OVERHEAD = 2;
8
+ /** Left + right vertical bar columns (`│ ... │`) consumed by `renderBorderedBox`. */
9
+ export const BORDER_HORIZONTAL_OVERHEAD = 2;
10
+ /** Inner horizontal padding (1 col) between each border bar and content area. */
11
+ export const BORDER_INNER_PADDING_HORIZONTAL = 1;
12
+ /** Floor for the preview box's inner content width — CC parity (`PreviewBox.minWidth`). */
13
+ export const BOX_MIN_CONTENT_WIDTH = 40;
14
+
15
+ /**
16
+ * Drops fenced-code-block marker lines (` ``` ` opener/closer) from rendered markdown.
17
+ * pi-tui's Markdown emits literal opening ` ```lang ` and closing ` ``` ` lines around
18
+ * code blocks; this strip leaves only the highlighted code body. Inline code
19
+ * (`codespan`) is unaffected — pi-tui already renders it without backticks.
20
+ *
21
+ * Escapes are stripped with the Node builtin rather than the two hand-rolled
22
+ * regexes this replaced. Those matched SGR colour codes and OSC-8 hyperlinks
23
+ * only, so any other escape a highlighter emitted would sit in front of the
24
+ * backticks and hide a fence marker from the test below. The builtin covers the
25
+ * whole VT vocabulary, including both OSC-8 terminators (BEL and ST).
26
+ */
27
+ export function stripFenceMarkers(lines: readonly string[]): string[] {
28
+ return lines.filter((line) => !FENCE_MARKER_RE.test(stripVTControlCharacters(line)));
29
+ }
30
+
31
+ /**
32
+ * Wraps `lines` in a 4-sided ASCII border with 1 col of inner horizontal padding.
33
+ * Layout per content row: `│` + ` ` + content padded to `contentInner` + ` ` + `│`,
34
+ * where `contentInner = width - BORDER_HORIZONTAL_OVERHEAD - 2 * BORDER_INNER_PADDING_HORIZONTAL`.
35
+ * Top/bottom dash runs span corner-to-corner (`width - BORDER_HORIZONTAL_OVERHEAD`). When
36
+ * `hidden > 0`, the bottom-row dash run is replaced with ` ✂ ── N lines hidden ── ` (corners stay).
37
+ */
38
+ export function renderBorderedBox(
39
+ lines: readonly string[],
40
+ width: number,
41
+ colorFn: (s: string) => string,
42
+ hidden = 0,
43
+ ): string[] {
44
+ const dashSpan = Math.max(1, width - BORDER_HORIZONTAL_OVERHEAD);
45
+ const contentInner = Math.max(1, dashSpan - 2 * BORDER_INNER_PADDING_HORIZONTAL);
46
+ const pad = " ".repeat(BORDER_INNER_PADDING_HORIZONTAL);
47
+ const top = colorFn(`┌${"─".repeat(dashSpan)}┐`);
48
+ const out: string[] = [top];
49
+ for (const line of lines) {
50
+ const padded = truncateToWidth(line, contentInner, "", true);
51
+ out.push(`${colorFn("│")}${pad}${padded}${pad}${colorFn("│")}`);
52
+ }
53
+ if (hidden > 0) {
54
+ const indicator = ` ✂ ── ${hidden} lines hidden ── `;
55
+ const space = dashSpan - indicator.length;
56
+ const leftFill = "─".repeat(Math.max(0, Math.floor(space / 2)));
57
+ const rightFill = "─".repeat(Math.max(0, dashSpan - leftFill.length - indicator.length));
58
+ out.push(colorFn(`└${leftFill}${indicator}${rightFill}┘`));
59
+ } else {
60
+ out.push(colorFn(`└${"─".repeat(dashSpan)}┘`));
61
+ }
62
+ return out;
63
+ }
64
+
65
+ /**
66
+ * Compute box dimensions from content lines. Pure of args.
67
+ *
68
+ * CC parity:
69
+ * contentWidth = max(minWidth, widestRenderedLine)
70
+ * boxWidth = min(contentWidth + 4, effectiveMaxWidth)
71
+ *
72
+ * Trailing whitespace is stripped before measuring because pi-tui's
73
+ * `Markdown.render(width)` pads every line to `width`, which would otherwise force
74
+ * the box to fill the whole column allocation.
75
+ */
76
+ export function computeBoxDimensions(
77
+ contentLines: readonly string[],
78
+ maxInnerWidth: number,
79
+ ): { innerWidth: number; boxWidth: number } {
80
+ let widest = Math.min(BOX_MIN_CONTENT_WIDTH, maxInnerWidth);
81
+ for (const line of contentLines) {
82
+ const w = visibleWidth(line.replace(/\s+$/, ""));
83
+ if (w > widest) widest = w;
84
+ }
85
+ const innerWidth = Math.min(widest, maxInnerWidth);
86
+ const boxWidth = innerWidth + BORDER_HORIZONTAL_OVERHEAD + 2 * BORDER_INNER_PADDING_HORIZONTAL;
87
+ return { innerWidth, boxWidth };
88
+ }