pi-cc-extensions 0.8.7 → 0.8.9
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 +21 -5
- package/assets/readme/welcome.webp +0 -0
- package/extensions/ask-user-question/LICENSE +21 -0
- package/extensions/ask-user-question/README.md +63 -0
- package/extensions/ask-user-question/ask-user-question.ts +244 -0
- package/extensions/ask-user-question/config.ts +75 -0
- package/extensions/ask-user-question/events.ts +55 -0
- package/extensions/ask-user-question/index.ts +17 -0
- package/extensions/ask-user-question/reconcile.ts +47 -0
- package/extensions/ask-user-question/rpc-fallback.ts +160 -0
- package/extensions/ask-user-question/state/build-questionnaire.ts +291 -0
- package/extensions/ask-user-question/state/dialog-height.ts +16 -0
- package/extensions/ask-user-question/state/key-router.ts +269 -0
- package/extensions/ask-user-question/state/questionnaire-session.ts +196 -0
- package/extensions/ask-user-question/state/row-intent.ts +143 -0
- package/extensions/ask-user-question/state/selectors/contract.ts +24 -0
- package/extensions/ask-user-question/state/selectors/derivations.ts +40 -0
- package/extensions/ask-user-question/state/selectors/focus.ts +17 -0
- package/extensions/ask-user-question/state/selectors/projections.ts +99 -0
- package/extensions/ask-user-question/state/state-reducer.ts +282 -0
- package/extensions/ask-user-question/state/state.ts +49 -0
- package/extensions/ask-user-question/tool/format-answer.ts +29 -0
- package/extensions/ask-user-question/tool/response-envelope.ts +47 -0
- package/extensions/ask-user-question/tool/types.ts +145 -0
- package/extensions/ask-user-question/tool/validate-questionnaire.ts +56 -0
- package/extensions/ask-user-question/view/component-binding.ts +45 -0
- package/extensions/ask-user-question/view/components/inline-input.ts +96 -0
- package/extensions/ask-user-question/view/components/multi-select-view.ts +191 -0
- package/extensions/ask-user-question/view/components/option-list-view.ts +68 -0
- package/extensions/ask-user-question/view/components/preview/markdown-content-cache.ts +76 -0
- package/extensions/ask-user-question/view/components/preview/preview-block-renderer.ts +108 -0
- package/extensions/ask-user-question/view/components/preview/preview-box-renderer.ts +86 -0
- package/extensions/ask-user-question/view/components/preview/preview-layout-decider.ts +200 -0
- package/extensions/ask-user-question/view/components/preview/preview-pane.ts +226 -0
- package/extensions/ask-user-question/view/components/submit-picker.ts +64 -0
- package/extensions/ask-user-question/view/components/tab-bar.ts +57 -0
- package/extensions/ask-user-question/view/components/wrapping-select.ts +291 -0
- package/extensions/ask-user-question/view/dialog-builder.ts +205 -0
- package/extensions/ask-user-question/view/props-adapter.ts +123 -0
- package/extensions/ask-user-question/view/stateful-view.ts +24 -0
- package/extensions/ask-user-question/view/tab-components.ts +16 -0
- package/extensions/ask-user-question/view/tab-content-strategy.ts +249 -0
- package/package.json +14 -4
- package/themes/github-dark-default.json +91 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { MAX_QUESTIONS, MIN_OPTIONS, type QuestionnaireError, type QuestionParams, RESERVED_LABELS } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export const ERROR_NO_QUESTIONS = "Error: At least one question is required";
|
|
4
|
+
export const ERROR_TOO_MANY_QUESTIONS = `Error: At most ${MAX_QUESTIONS} questions are allowed per invocation`;
|
|
5
|
+
export const ERROR_DUPLICATE_QUESTION = "Error: Question text must be unique within an invocation";
|
|
6
|
+
export const ERROR_TOO_FEW_OPTIONS = `Error: Each question requires at least ${MIN_OPTIONS} options`;
|
|
7
|
+
export const ERROR_RESERVED_LABEL = `Error: Option label is reserved (${RESERVED_LABELS.join(", ")})`;
|
|
8
|
+
export const ERROR_DUPLICATE_OPTION_LABEL = "Error: Option labels must be unique within a question";
|
|
9
|
+
|
|
10
|
+
const RESERVED_LABEL_SET: ReadonlySet<string> = new Set(RESERVED_LABELS);
|
|
11
|
+
|
|
12
|
+
export type ValidationResult = { ok: true } | { ok: false; error: QuestionnaireError; message: string };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Pure runtime validator for `QuestionParams`. Covers every guard except
|
|
16
|
+
* `no_ui` (which depends on `ctx.hasUI` and stays inline at the call site).
|
|
17
|
+
* `reserved_label` MUST short-circuit before `duplicate_option_label`.
|
|
18
|
+
*/
|
|
19
|
+
export function validateQuestionnaire(typed: QuestionParams): ValidationResult {
|
|
20
|
+
if (typed.questions.length === 0) {
|
|
21
|
+
return { ok: false, error: "no_questions", message: ERROR_NO_QUESTIONS };
|
|
22
|
+
}
|
|
23
|
+
if (typed.questions.length > MAX_QUESTIONS) {
|
|
24
|
+
return { ok: false, error: "too_many_questions", message: ERROR_TOO_MANY_QUESTIONS };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const seenQuestions = new Set<string>();
|
|
28
|
+
for (const q of typed.questions) {
|
|
29
|
+
if (seenQuestions.has(q.question)) {
|
|
30
|
+
return { ok: false, error: "duplicate_question", message: ERROR_DUPLICATE_QUESTION };
|
|
31
|
+
}
|
|
32
|
+
seenQuestions.add(q.question);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const q of typed.questions) {
|
|
36
|
+
if (q.options.length < MIN_OPTIONS) {
|
|
37
|
+
return { ok: false, error: "empty_options", message: ERROR_TOO_FEW_OPTIONS };
|
|
38
|
+
}
|
|
39
|
+
const seenLabels = new Set<string>();
|
|
40
|
+
for (const o of q.options) {
|
|
41
|
+
if (RESERVED_LABEL_SET.has(o.label)) {
|
|
42
|
+
return { ok: false, error: "reserved_label", message: ERROR_RESERVED_LABEL };
|
|
43
|
+
}
|
|
44
|
+
if (seenLabels.has(o.label)) {
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
error: "duplicate_option_label",
|
|
48
|
+
message: ERROR_DUPLICATE_OPTION_LABEL,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
seenLabels.add(o.label);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { ok: true };
|
|
56
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BindingContext,
|
|
3
|
+
GlobalSelector,
|
|
4
|
+
PerTabBindingContext,
|
|
5
|
+
PerTabSelector,
|
|
6
|
+
} from "../state/selectors/contract.js";
|
|
7
|
+
import type { QuestionnaireState } from "../state/state.js";
|
|
8
|
+
import type { StatefulView } from "./stateful-view.js";
|
|
9
|
+
import type { TabComponents } from "./tab-components.js";
|
|
10
|
+
|
|
11
|
+
export interface ComponentBinding<P> {
|
|
12
|
+
readonly component: StatefulView<P>;
|
|
13
|
+
readonly select: GlobalSelector<P>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PerTabBinding<P> {
|
|
17
|
+
readonly resolve: (tab: TabComponents) => StatefulView<P> | undefined;
|
|
18
|
+
readonly select: PerTabSelector<P>;
|
|
19
|
+
readonly predicate?: PerTabSelector<boolean>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BoundGlobalBinding {
|
|
23
|
+
apply(state: QuestionnaireState, ctx: BindingContext): void;
|
|
24
|
+
invalidate(): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface BoundPerTabBinding {
|
|
28
|
+
apply(state: QuestionnaireState, ctx: PerTabBindingContext): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function globalBinding<P>(spec: ComponentBinding<P>): BoundGlobalBinding {
|
|
32
|
+
return {
|
|
33
|
+
apply: (state, ctx) => spec.component.setProps(spec.select(state, ctx)),
|
|
34
|
+
invalidate: () => spec.component.invalidate(),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function perTabBinding<P>(spec: PerTabBinding<P>): BoundPerTabBinding {
|
|
39
|
+
return {
|
|
40
|
+
apply: (state, ctx) => {
|
|
41
|
+
if (spec.predicate && !spec.predicate(state, ctx)) return;
|
|
42
|
+
spec.resolve(ctx.tab)?.setProps(spec.select(state, ctx));
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { CURSOR_MARKER, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
// Grapheme-aware extraction at the cursor: pi-tui's Input advances `cursor` by
|
|
4
|
+
// grapheme-cluster code-unit length, so the cursor can land between code units of
|
|
5
|
+
// one cluster (emoji, ZWJ, combining marks). Single-code-unit slicing would split
|
|
6
|
+
// the cluster across the SGR 7/27 boundary. Moved here from wrapping-select.ts so
|
|
7
|
+
// both the single-select (wrap) and multi-select (truncate-to-one-line) inline
|
|
8
|
+
// inputs share one cursor-building core.
|
|
9
|
+
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
10
|
+
|
|
11
|
+
export interface RenderInlineInputOptions {
|
|
12
|
+
/** Live inline-input buffer. */
|
|
13
|
+
buffer: string;
|
|
14
|
+
/** Cursor offset; `undefined` / out-of-range → end-of-buffer fallback. */
|
|
15
|
+
cursorOffset: number | undefined;
|
|
16
|
+
/** Prefix for the first emitted line (e.g. `❯ 4. `). */
|
|
17
|
+
rowPrefix: string;
|
|
18
|
+
/** Prefix for continuation lines (whitespace of equal visible width). */
|
|
19
|
+
continuationPrefix: string;
|
|
20
|
+
/** Visible columns available for the buffer content (width − prefix width). */
|
|
21
|
+
contentWidth: number;
|
|
22
|
+
/** Per-line styling (single-select: `theme.selectedText`; multi-select: accent+bold). */
|
|
23
|
+
selectedText: (text: string) => string;
|
|
24
|
+
/**
|
|
25
|
+
* `true` (single-select): wrap across lines at `contentWidth` (byte-identical to the
|
|
26
|
+
* pre-extraction wrapping-select behavior).
|
|
27
|
+
* `false` (multi-select): collapse to ONE line — truncate the plain buffer to
|
|
28
|
+
* `contentWidth` with `…` when the cursor-marked content would overflow, keeping the
|
|
29
|
+
* dialog height state-independent. The cursor is clipped past the visible window
|
|
30
|
+
* (accepted trade): when the cursor-marked content fits it is rendered in full.
|
|
31
|
+
*/
|
|
32
|
+
multiline: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the cursor offset, falling back to end-of-buffer for `undefined`/out-of-range.
|
|
37
|
+
* Mirrors the original wrapping-select.resolveOffset exactly.
|
|
38
|
+
*/
|
|
39
|
+
function resolveCursorOffset(buffer: string, requested: number | undefined): number {
|
|
40
|
+
if (requested !== undefined && requested >= 0 && requested <= buffer.length) return requested;
|
|
41
|
+
return buffer.length;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build the cursor-marked raw string for the whole buffer: `before | CURSOR_MARKER |
|
|
46
|
+
* SGR-7 reverse-video cell | SGR-27 | after`. The cell UNDER the cursor is the single
|
|
47
|
+
* grapheme at the offset (or U+00A0 NBSP at end-of-buffer / on a literal space — NBSP is
|
|
48
|
+
* wrap-safe where a literal space would tokenize as a wrap break). Zero characters shift;
|
|
49
|
+
* the column under the cursor inverts. `CURSOR_MARKER` is zero-width so wrap/truncate math
|
|
50
|
+
* is preserved.
|
|
51
|
+
*/
|
|
52
|
+
function buildCursorRaw(buffer: string, offset: number): string {
|
|
53
|
+
const before = buffer.slice(0, offset);
|
|
54
|
+
const [firstGrapheme] = graphemeSegmenter.segment(buffer.slice(offset));
|
|
55
|
+
const rawAt = firstGrapheme ? firstGrapheme.segment : "";
|
|
56
|
+
// NBSP (U+00A0) fallback: visually identical to a space, wrap-safe.
|
|
57
|
+
const atCursor = rawAt === "" || rawAt === " " ? "\xa0" : rawAt;
|
|
58
|
+
const after = buffer.slice(offset + rawAt.length);
|
|
59
|
+
return `${before}${CURSOR_MARKER}\x1b[7m${atCursor}\x1b[27m${after}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Render the inline-input row. `multiline: true` wraps (single-select, byte-identical to
|
|
64
|
+
* the pre-extraction output); `multiline: false` collapses to a single line.
|
|
65
|
+
*
|
|
66
|
+
* Cursor visualization follows the standard TUI input-widget pattern (ECMA-48 SGR 7
|
|
67
|
+
* reverse-video on the cell AT the cursor, not an inserted glyph) — same approach used by
|
|
68
|
+
* pi-tui Input.render, ink-text-input, terkelg/prompts, ratatui's user-input example.
|
|
69
|
+
*/
|
|
70
|
+
export function renderInlineInputRow(opts: RenderInlineInputOptions): string[] {
|
|
71
|
+
const { buffer, cursorOffset, rowPrefix, continuationPrefix, contentWidth, selectedText, multiline } = opts;
|
|
72
|
+
const offset = resolveCursorOffset(buffer, cursorOffset);
|
|
73
|
+
|
|
74
|
+
if (multiline) {
|
|
75
|
+
// Single-select: wrap at contentWidth. Byte-identical to the original
|
|
76
|
+
// wrapping-select.renderInlineInputRow output (same raw, same wrapTextWithAnsi,
|
|
77
|
+
// same prefix/selectedText per line).
|
|
78
|
+
const raw = buildCursorRaw(buffer, offset);
|
|
79
|
+
const wrapped = wrapTextWithAnsi(raw, contentWidth);
|
|
80
|
+
return wrapped.map((segment, index) => {
|
|
81
|
+
const prefix = index === 0 ? rowPrefix : continuationPrefix;
|
|
82
|
+
return selectedText(`${prefix}${segment}`);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Multi-select: single line. Build the cursor markup on the full buffer; if it fits
|
|
87
|
+
// contentWidth, emit it as one line. If it overflows, fall back to truncating the PLAIN
|
|
88
|
+
// buffer (ANSI-safe — never cut a CURSOR_MARKER/SGR sequence) with `…` and clip the
|
|
89
|
+
// cursor. This keeps the row at exactly one line so naturalHeight stays state-independent.
|
|
90
|
+
const cursorRaw = buildCursorRaw(buffer, offset);
|
|
91
|
+
if (visibleWidth(cursorRaw) <= contentWidth) {
|
|
92
|
+
return [selectedText(`${rowPrefix}${cursorRaw}`)];
|
|
93
|
+
}
|
|
94
|
+
const clipped = truncateToWidth(buffer, contentWidth, "…");
|
|
95
|
+
return [selectedText(`${rowPrefix}${clipped}`)];
|
|
96
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
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 state-INDEPENDENT (depends only on theme glyph widths,
|
|
42
|
+
* question.options, and width) so the host can compute a stable globalContentHeight
|
|
43
|
+
* without rendering. `naturalHeight(w) === render(w).length` for every props.
|
|
44
|
+
*
|
|
45
|
+
* `setProps(props)` is a pure field reassignment — no render, no invalidate side effects.
|
|
46
|
+
*/
|
|
47
|
+
export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
|
|
48
|
+
private props: MultiSelectViewProps;
|
|
49
|
+
|
|
50
|
+
constructor(
|
|
51
|
+
private readonly theme: Theme,
|
|
52
|
+
private readonly question: QuestionData,
|
|
53
|
+
) {
|
|
54
|
+
this.props = {
|
|
55
|
+
rows: [],
|
|
56
|
+
other: { active: false, inputMode: false, inputBuffer: "", inputCursorOffset: undefined },
|
|
57
|
+
nextActive: false,
|
|
58
|
+
nextLabel: ROW_INTENT_META.next.label,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
setProps(props: MultiSelectViewProps): void {
|
|
63
|
+
this.props = props;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
handleInput(_data: string): void {}
|
|
67
|
+
|
|
68
|
+
invalidate(): void {}
|
|
69
|
+
|
|
70
|
+
render(width: number): string[] {
|
|
71
|
+
const lines: string[] = [];
|
|
72
|
+
const prefixWidth = this.prefixVisibleWidth();
|
|
73
|
+
const contentWidth = Math.max(1, width - prefixWidth);
|
|
74
|
+
const numberWidth = String(Math.max(1, this.question.options.length + 1)).length;
|
|
75
|
+
for (let i = 0; i < this.question.options.length; i++) {
|
|
76
|
+
const opt = this.question.options[i];
|
|
77
|
+
const row = this.props.rows[i];
|
|
78
|
+
if (!opt || !row) continue;
|
|
79
|
+
const pointer = row.active ? this.theme.fg("accent", ACTIVE_POINTER) : INACTIVE_POINTER;
|
|
80
|
+
// Checked uses the same `accent` hue as the active-row label so checked rows read
|
|
81
|
+
// as "selected" rather than "success" — matches the visual rhythm of the rest of
|
|
82
|
+
// the dialog (active pointer, label, picker rows are all accent).
|
|
83
|
+
const box = row.checked ? this.theme.fg("accent", CHECKED) : this.theme.fg("muted", UNCHECKED);
|
|
84
|
+
const label = truncateToWidth(opt.label, contentWidth, "…");
|
|
85
|
+
const styledLabel = row.active ? this.theme.fg("accent", this.theme.bold(label)) : label;
|
|
86
|
+
const num = String(i + 1).padStart(numberWidth, " ");
|
|
87
|
+
const line = `${pointer}${num}${NUMBER_SEPARATOR}${box}${BOX_LABEL_GAP}${styledLabel}`;
|
|
88
|
+
lines.push(truncateToWidth(line, width, ""));
|
|
89
|
+
if (opt.description) {
|
|
90
|
+
const wrapped = wrapTextWithAnsi(opt.description, contentWidth);
|
|
91
|
+
for (const segment of wrapped) {
|
|
92
|
+
lines.push(CONTINUATION_INDENT + this.theme.fg("muted", segment));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// "Type something." row — numbered N+1, box always [ ] muted UNCHECKED (never checkable).
|
|
98
|
+
// When focused + inputMode, render the label slot via the shared inline-input helper
|
|
99
|
+
// (single-line); otherwise the static label is truncated to contentWidth with `…`.
|
|
100
|
+
const other = this.props.other;
|
|
101
|
+
const otherPointer = other.active ? this.theme.fg("accent", ACTIVE_POINTER) : INACTIVE_POINTER;
|
|
102
|
+
const otherBox = this.theme.fg("muted", UNCHECKED);
|
|
103
|
+
const otherNum = String(this.question.options.length + 1).padStart(numberWidth, " ");
|
|
104
|
+
let otherLabel: string;
|
|
105
|
+
if (other.active && other.inputMode) {
|
|
106
|
+
const rendered = renderInlineInputRow({
|
|
107
|
+
buffer: other.inputBuffer,
|
|
108
|
+
cursorOffset: other.inputCursorOffset,
|
|
109
|
+
rowPrefix: "",
|
|
110
|
+
continuationPrefix: "",
|
|
111
|
+
contentWidth,
|
|
112
|
+
selectedText: (t) => this.theme.fg("accent", this.theme.bold(t)),
|
|
113
|
+
multiline: false,
|
|
114
|
+
});
|
|
115
|
+
otherLabel = rendered[0] ?? "";
|
|
116
|
+
} else {
|
|
117
|
+
const label = truncateToWidth(ROW_INTENT_META.other.label, contentWidth, "…");
|
|
118
|
+
otherLabel = other.active ? this.theme.fg("accent", this.theme.bold(label)) : label;
|
|
119
|
+
}
|
|
120
|
+
lines.push(
|
|
121
|
+
truncateToWidth(
|
|
122
|
+
`${otherPointer}${otherNum}${NUMBER_SEPARATOR}${otherBox}${BOX_LABEL_GAP}${otherLabel}`,
|
|
123
|
+
width,
|
|
124
|
+
"",
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const nextPointer = this.props.nextActive ? this.theme.fg("accent", ACTIVE_POINTER) : INACTIVE_POINTER;
|
|
129
|
+
const nextLabel = this.props.nextActive
|
|
130
|
+
? this.theme.fg("accent", this.theme.bold(this.props.nextLabel))
|
|
131
|
+
: this.props.nextLabel;
|
|
132
|
+
lines.push(truncateToWidth(`${nextPointer}${nextLabel}`, width, ""));
|
|
133
|
+
return lines;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Returns the [startRow, endRow) range of the active (focused) row within
|
|
138
|
+
* `render(width)`. Labels are always 1 row (truncated); descriptions wrap.
|
|
139
|
+
*/
|
|
140
|
+
focusedItemRowRange(width: number): [number, number] {
|
|
141
|
+
const prefixWidth = this.prefixVisibleWidth();
|
|
142
|
+
const contentWidth = Math.max(1, width - prefixWidth);
|
|
143
|
+
let row = 0;
|
|
144
|
+
for (let i = 0; i < this.question.options.length; i++) {
|
|
145
|
+
const opt = this.question.options[i];
|
|
146
|
+
const r = this.props.rows[i];
|
|
147
|
+
if (!opt || !r) continue;
|
|
148
|
+
const itemHeight = 1 + (opt.description ? wrapTextWithAnsi(opt.description, contentWidth).length : 0);
|
|
149
|
+
if (r.active) {
|
|
150
|
+
return [row, row + itemHeight];
|
|
151
|
+
}
|
|
152
|
+
row += itemHeight;
|
|
153
|
+
}
|
|
154
|
+
// "Type something." row is always exactly 1 line (truncated, no description).
|
|
155
|
+
if (this.props.other.active) {
|
|
156
|
+
return [row, row + 1];
|
|
157
|
+
}
|
|
158
|
+
row += 1;
|
|
159
|
+
if (this.props.nextActive) {
|
|
160
|
+
return [row, row + 1];
|
|
161
|
+
}
|
|
162
|
+
return [0, 0];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
naturalHeight(width: number): number {
|
|
166
|
+
const contentWidth = Math.max(1, width - this.prefixVisibleWidth());
|
|
167
|
+
let total = 0;
|
|
168
|
+
for (const opt of this.question.options) {
|
|
169
|
+
if (!opt) continue;
|
|
170
|
+
total += 1; // row line
|
|
171
|
+
if (opt.description) {
|
|
172
|
+
total += wrapTextWithAnsi(opt.description, contentWidth).length;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return total + 2; // "Type something." row + Next sentinel row (neither wraps).
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private prefixVisibleWidth(): number {
|
|
179
|
+
// Canonical prefix for OPTION rows: INACTIVE_POINTER + numberWidth digits + NUMBER_SEPARATOR
|
|
180
|
+
// + UNCHECKED + BOX_LABEL_GAP. State-independent because ACTIVE/INACTIVE pointer share
|
|
181
|
+
// visibleWidth, CHECKED/UNCHECKED share visibleWidth, and numberWidth is constant per question.
|
|
182
|
+
// The number column fits `options.length + 1` so the "Type something." row's N+1 number
|
|
183
|
+
// is never clipped. The Next sentinel uses a bare `pointer + "Next"` shape — its width
|
|
184
|
+
// never exceeds this prefix at any reasonable terminal width, so it's safe to leave it
|
|
185
|
+
// out of the canonical computation.
|
|
186
|
+
const numberWidth = String(Math.max(1, this.question.options.length + 1)).length;
|
|
187
|
+
return (
|
|
188
|
+
visibleWidth(INACTIVE_POINTER) + numberWidth + visibleWidth(`${NUMBER_SEPARATOR}${UNCHECKED}${BOX_LABEL_GAP}`)
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { StatefulView } from "../stateful-view.js";
|
|
2
|
+
import { WrappingSelect, type WrappingSelectItem, type WrappingSelectTheme } from "./wrapping-select.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Maximum number of option rows visible in the WrappingSelect window. Lifted here from
|
|
6
|
+
* `preview-pane.ts` so the cap travels with the option-list owner.
|
|
7
|
+
*/
|
|
8
|
+
export const MAX_VISIBLE_OPTIONS = 10;
|
|
9
|
+
|
|
10
|
+
export interface OptionListViewConfig {
|
|
11
|
+
items: readonly WrappingSelectItem[];
|
|
12
|
+
theme: WrappingSelectTheme;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Per-tick projection of OptionListView state. After Phase 11b, `inputBuffer`
|
|
17
|
+
* is part of the props bag — the session-owned `inlineInput` (a headless
|
|
18
|
+
* `pi-tui` Input instance) supplies its current `getValue()` here per tick.
|
|
19
|
+
* `OptionListView` is purely props-driven; the imperative buffer surface and
|
|
20
|
+
* read-back getters are gone.
|
|
21
|
+
*/
|
|
22
|
+
export interface OptionListViewProps {
|
|
23
|
+
selectedIndex: number;
|
|
24
|
+
focused: boolean;
|
|
25
|
+
inputBuffer: string;
|
|
26
|
+
inputCursorOffset?: number;
|
|
27
|
+
/** Optional previously-confirmed indicator. Omit when no marker should be drawn. */
|
|
28
|
+
confirmed?: { index: number; labelOverride?: string };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Sole owner of the option list's interactive state. Wraps a single
|
|
33
|
+
* `WrappingSelect`. Implements `StatefulView<OptionListViewProps>`:
|
|
34
|
+
* `setProps` is the only mutator; render output reflects the last props
|
|
35
|
+
* received.
|
|
36
|
+
*/
|
|
37
|
+
export class OptionListView implements StatefulView<OptionListViewProps> {
|
|
38
|
+
private readonly select: WrappingSelect;
|
|
39
|
+
|
|
40
|
+
constructor(config: OptionListViewConfig) {
|
|
41
|
+
this.select = new WrappingSelect(config.items, Math.min(config.items.length, MAX_VISIBLE_OPTIONS), config.theme, {
|
|
42
|
+
numberStartOffset: 0,
|
|
43
|
+
totalItemsForNumbering: config.items.length,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
setProps(props: OptionListViewProps): void {
|
|
48
|
+
this.select.setSelectedIndex(props.selectedIndex);
|
|
49
|
+
this.select.setFocused(props.focused);
|
|
50
|
+
this.select.setConfirmedIndex(props.confirmed?.index, props.confirmed?.labelOverride);
|
|
51
|
+
this.select.setInputBuffer(props.inputBuffer);
|
|
52
|
+
this.select.setInputCursorOffset(props.inputCursorOffset);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
handleInput(_data: string): void {}
|
|
56
|
+
|
|
57
|
+
invalidate(): void {
|
|
58
|
+
this.select.invalidate();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
render(width: number): string[] {
|
|
62
|
+
return this.select.render(width);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
focusedItemRowRange(width: number): [number, number] {
|
|
66
|
+
return this.select.focusedItemRowRange(width);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -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,108 @@
|
|
|
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
|
+
export interface PreviewBlockRendererConfig {
|
|
26
|
+
question: QuestionData;
|
|
27
|
+
theme: Theme;
|
|
28
|
+
markdownTheme: MarkdownTheme;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Renders the bordered markdown preview block for a single question (one block per render call,
|
|
33
|
+
* for the option at `optionIndex`). Owns a per-question `MarkdownContentCache`.
|
|
34
|
+
*
|
|
35
|
+
* NOT a `Component` — pure render-and-measure helper consumed by `PreviewPane`. The layout mode
|
|
36
|
+
* is threaded as an explicit param (never re-derived from column width post-split).
|
|
37
|
+
*
|
|
38
|
+
* The affordance row is always emitted (visually empty when gated) so the preview block's row
|
|
39
|
+
* count is height-stable across affordance-state transitions.
|
|
40
|
+
*/
|
|
41
|
+
export class PreviewBlockRenderer {
|
|
42
|
+
private readonly theme: Theme;
|
|
43
|
+
private readonly cache: MarkdownContentCache;
|
|
44
|
+
|
|
45
|
+
constructor(config: PreviewBlockRendererConfig) {
|
|
46
|
+
this.theme = config.theme;
|
|
47
|
+
this.cache = new MarkdownContentCache(config.question, config.theme, config.markdownTheme);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
hasAnyPreview(): boolean {
|
|
51
|
+
return this.cache.hasAnyPreview();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
has(optionIndex: number): boolean {
|
|
55
|
+
return this.cache.has(optionIndex);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
invalidate(): void {
|
|
59
|
+
this.cache.invalidate();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Height contribution of the preview block: `BORDER_VERTICAL_OVERHEAD + contentRows +
|
|
64
|
+
* NOTES_AFFORDANCE_OVERHEAD`. Always returns the same value as `renderBlock(...).length`
|
|
65
|
+
* — the affordance overhead is constant, not gated by `focused`/`notesVisible`.
|
|
66
|
+
*/
|
|
67
|
+
blockHeight(width: number, optionIndex: number, mode: PreviewLayoutMode): number {
|
|
68
|
+
const cap = mode === "side-by-side" ? MAX_PREVIEW_HEIGHT_SIDE_BY_SIDE : MAX_PREVIEW_HEIGHT_STACKED;
|
|
69
|
+
const contentBudget = Math.max(1, cap - BORDER_VERTICAL_OVERHEAD - NOTES_AFFORDANCE_OVERHEAD);
|
|
70
|
+
const innerWidth = Math.max(1, width - BORDER_HORIZONTAL_OVERHEAD - 2 * BORDER_INNER_PADDING_HORIZONTAL);
|
|
71
|
+
const rawRows = this.cache.bodyFor(optionIndex, innerWidth).length;
|
|
72
|
+
const contentRows = Math.min(rawRows, contentBudget);
|
|
73
|
+
return BORDER_VERTICAL_OVERHEAD + contentRows + NOTES_AFFORDANCE_OVERHEAD;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Render the full preview block at `width`: bordered box + blank separator + affordance row.
|
|
78
|
+
* `focused` and `notesVisible` together gate the affordance text (visible only when the
|
|
79
|
+
* focused option carries a preview AND notes mode is inactive). The affordance row is ALWAYS
|
|
80
|
+
* emitted (as an empty string when gated) so the row count is invariant.
|
|
81
|
+
*/
|
|
82
|
+
renderBlock(
|
|
83
|
+
width: number,
|
|
84
|
+
optionIndex: number,
|
|
85
|
+
mode: PreviewLayoutMode,
|
|
86
|
+
focused: boolean,
|
|
87
|
+
notesVisible: boolean,
|
|
88
|
+
): string[] {
|
|
89
|
+
const cap = mode === "side-by-side" ? MAX_PREVIEW_HEIGHT_SIDE_BY_SIDE : MAX_PREVIEW_HEIGHT_STACKED;
|
|
90
|
+
const contentBudget = Math.max(1, cap - BORDER_VERTICAL_OVERHEAD - NOTES_AFFORDANCE_OVERHEAD);
|
|
91
|
+
const maxInnerWidth = Math.max(1, width - BORDER_HORIZONTAL_OVERHEAD - 2 * BORDER_INNER_PADDING_HORIZONTAL);
|
|
92
|
+
|
|
93
|
+
const raw = this.cache.bodyFor(optionIndex, maxInnerWidth);
|
|
94
|
+
const truncated = raw.length > contentBudget;
|
|
95
|
+
const hidden = truncated ? raw.length - contentBudget : 0;
|
|
96
|
+
const contentLines = truncated ? raw.slice(0, contentBudget) : raw;
|
|
97
|
+
|
|
98
|
+
const { boxWidth } = computeBoxDimensions(contentLines, maxInnerWidth);
|
|
99
|
+
const colorFn = (s: string) => this.theme.fg("accent", s);
|
|
100
|
+
const boxedLines = renderBorderedBox(contentLines, boxWidth, colorFn, hidden);
|
|
101
|
+
|
|
102
|
+
const showAffordance = focused && !notesVisible && this.cache.has(optionIndex);
|
|
103
|
+
const affordance = showAffordance
|
|
104
|
+
? this.theme.fg("muted", NOTES_AFFORDANCE_TEXT)
|
|
105
|
+
: "";
|
|
106
|
+
return [...boxedLines, "", affordance];
|
|
107
|
+
}
|
|
108
|
+
}
|