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,123 @@
1
+ import { formatAnswerScalar } from "./format-answer.js";
2
+ import type {
3
+ QuestionAnswer,
4
+ QuestionnaireResult,
5
+ QuestionParams,
6
+ UnansweredNote,
7
+ } from "./types.js";
8
+
9
+ export const DECLINE_MESSAGE = "User declined to answer questions";
10
+ export const TIMED_OUT_MESSAGE =
11
+ "Questionnaire timed out — the user did not respond within the configured timeout. The user never saw a decline; do NOT treat this as a rejection. Ask the questions as plain chat text instead or retry.";
12
+ export const ENVELOPE_PREFIX = "User has answered your questions:";
13
+ export const ENVELOPE_SUFFIX = "You can now continue with the user's answers in mind.";
14
+ /** Opens the segment for a note whose question was never answered. */
15
+ export const UNANSWERED_NOTE_PREFIX = "note on";
16
+
17
+ export interface ToolResult {
18
+ content: Array<{ type: "text"; text: string }>;
19
+ details: QuestionnaireResult;
20
+ }
21
+
22
+ /**
23
+ * Turn a questionnaire result into the envelope the model reads. Pure of
24
+ * `(result, params)`.
25
+ *
26
+ * Cancelled and "nothing to report" both collapse to `DECLINE_MESSAGE`, so the
27
+ * model sees one canonical "the user did not answer" signal rather than having
28
+ * to distinguish shades of it. Partial answers and a global note still ride
29
+ * along in `details` for anything replaying the session.
30
+ *
31
+ * "Nothing to report" means no answers AND no global note. The note segment is
32
+ * appended before that check on purpose: submitting a global note and nothing
33
+ * else is a real answer, not a decline.
34
+ */
35
+ export function buildQuestionnaireResponse(
36
+ result: QuestionnaireResult | null | undefined,
37
+ params: QuestionParams,
38
+ ): ToolResult {
39
+ if (result?.error === "timed_out") {
40
+ return buildToolResult(TIMED_OUT_MESSAGE, {
41
+ answers: result.answers,
42
+ cancelled: true,
43
+ error: "timed_out",
44
+ ...(result.globalNote && result.globalNote.length > 0
45
+ ? { globalNote: result.globalNote }
46
+ : {}),
47
+ ...(result.unansweredNotes && result.unansweredNotes.length > 0
48
+ ? { unansweredNotes: result.unansweredNotes }
49
+ : {}),
50
+ });
51
+ }
52
+ if (!result || result.cancelled) {
53
+ // The decline text stays canonical even when a global note rides a
54
+ // cancelled result. The note survives in `details`, like partial answers.
55
+ return buildToolResult(DECLINE_MESSAGE, {
56
+ answers: result?.answers ?? [],
57
+ cancelled: true,
58
+ ...(result?.error ? { error: result.error } : {}),
59
+ ...(result?.globalNote && result.globalNote.length > 0
60
+ ? { globalNote: result.globalNote }
61
+ : {}),
62
+ ...(result?.unansweredNotes && result.unansweredNotes.length > 0
63
+ ? { unansweredNotes: result.unansweredNotes }
64
+ : {}),
65
+ });
66
+ }
67
+
68
+ const segments: string[] = [];
69
+ // Iterate the questions rather than the answers so segments always follow the
70
+ // order the model asked in, whatever order the user filled tabs.
71
+ for (let i = 0; i < params.questions.length; i++) {
72
+ const a = result.answers.find((x) => x.questionIndex === i);
73
+ if (a) {
74
+ segments.push(buildAnswerSegment(a));
75
+ continue;
76
+ }
77
+ // A note with no answer behind it still belongs in ask order, so it is
78
+ // emitted here rather than grouped at the end. Because this loop runs
79
+ // before the "nothing to report" check below, a questionnaire submitted
80
+ // with nothing but such a note counts as answered rather than declined.
81
+ const n = result.unansweredNotes?.find((x) => x.questionIndex === i);
82
+ if (n) segments.push(buildUnansweredNoteSegment(n));
83
+ }
84
+ if (result.globalNote && result.globalNote.length > 0) {
85
+ // Raw multiline echo, no reformatting, trailing period matching the shape
86
+ // of an answer segment.
87
+ segments.push(`global note: ${result.globalNote}.`);
88
+ }
89
+ if (segments.length === 0) {
90
+ return buildToolResult(DECLINE_MESSAGE, { answers: result.answers, cancelled: true });
91
+ }
92
+ return buildToolResult(`${ENVELOPE_PREFIX} ${segments.join(" ")} ${ENVELOPE_SUFFIX}`, result);
93
+ }
94
+
95
+ /**
96
+ * One answer as an envelope segment: `"question"="answer"`, optionally followed
97
+ * by the preview the user was looking at and the note they wrote.
98
+ */
99
+ export function buildAnswerSegment(a: QuestionAnswer): string {
100
+ const parts: string[] = [`"${a.question}"="${formatAnswerScalar(a, "envelope")}"`];
101
+ if (a.preview && a.preview.length > 0) parts.push(`selected preview: ${a.preview}`);
102
+ if (a.notes && a.notes.length > 0) parts.push(`user notes: ${a.notes}`);
103
+ return `${parts.join(". ")}.`;
104
+ }
105
+
106
+ /**
107
+ * A note whose question was never answered.
108
+ *
109
+ * Its own segment shape rather than an answer segment with a placeholder in the
110
+ * answer slot: there is no answer to place, and a second "(no answer)" string
111
+ * sitting one word away from `NO_INPUT_PLACEHOLDER` would be two
112
+ * near-identical placeholders meaning different things.
113
+ */
114
+ export function buildUnansweredNoteSegment(n: UnansweredNote): string {
115
+ return `${UNANSWERED_NOTE_PREFIX} "${n.question}": ${n.note}.`;
116
+ }
117
+
118
+ export function buildToolResult(text: string, details: QuestionnaireResult): ToolResult {
119
+ return {
120
+ content: [{ type: "text" as const, text }],
121
+ details,
122
+ };
123
+ }
@@ -0,0 +1,193 @@
1
+ import { type Static, Type } from "typebox";
2
+ import { LABELS_BY_KIND, ROW_INTENT_META } from "../state/row-intent.js";
3
+
4
+ export const MAX_QUESTIONS = 4;
5
+ export const MIN_OPTIONS = 2;
6
+ export const MAX_OPTIONS = 4;
7
+ export const MAX_HEADER_LENGTH = 16;
8
+ export const MAX_LABEL_LENGTH = 60;
9
+
10
+ /**
11
+ * User-facing labels for the sentinel rows, keyed by row kind. Sourced from the
12
+ * row-intent table so there is one definition. Adding a sentinel there extends
13
+ * this map automatically.
14
+ */
15
+ export const SENTINEL_LABELS = LABELS_BY_KIND;
16
+
17
+ export type { SentinelKind } from "../state/row-intent.js";
18
+ export type SentinelLabel = (typeof SENTINEL_LABELS)[keyof typeof SENTINEL_LABELS];
19
+
20
+ /**
21
+ * Labels an author may not use. Two come from the row-intent table; `"Other"`
22
+ * has no runtime row kind and is reserved anyway, because models are
23
+ * conditioned to reach for it as a free-text escape and the runtime sentinel
24
+ * must stay the only route there.
25
+ *
26
+ * Reserved unconditionally: every question mode rejects these, even where the
27
+ * corresponding sentinel is not appended in that mode.
28
+ *
29
+ * The explicit literal order is load-bearing for consumers that index into the
30
+ * array or compare it whole.
31
+ */
32
+ export const RESERVED_LABELS = [
33
+ "Other",
34
+ ROW_INTENT_META.other.label,
35
+ ROW_INTENT_META.next.label,
36
+ ] as const;
37
+ export type ReservedLabel = (typeof RESERVED_LABELS)[number];
38
+
39
+ export const OptionSchema = Type.Object({
40
+ label: Type.String({
41
+ maxLength: MAX_LABEL_LENGTH,
42
+ description: `MAX ${MAX_LABEL_LENGTH} CHARACTERS — hard limit, requests over the limit are rejected. The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice.`,
43
+ }),
44
+ description: Type.String({
45
+ description:
46
+ "Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications.",
47
+ }),
48
+ preview: Type.Optional(
49
+ Type.String({
50
+ description:
51
+ "Optional preview content rendered when this option is focused. Use for mockups, code snippets, or visual comparisons that help users compare options. See the tool description for the expected content format.",
52
+ }),
53
+ ),
54
+ });
55
+
56
+ export const QuestionSchema = Type.Object({
57
+ question: Type.String({
58
+ description:
59
+ 'The complete question to ask the user. Should be clear, specific, and end with a question mark. Example: "Which library should we use for date formatting?" If multiSelect is true, phrase it accordingly, e.g. "Which features do you want to enable?"',
60
+ }),
61
+ header: Type.String({
62
+ maxLength: MAX_HEADER_LENGTH,
63
+ description: `MAX ${MAX_HEADER_LENGTH} CHARACTERS — hard limit, requests over the limit are rejected. Very short chip/tag shown next to the question. Examples: "Auth method", "Library", "Approach".`,
64
+ }),
65
+ options: Type.Array(OptionSchema, {
66
+ minItems: MIN_OPTIONS,
67
+ maxItems: MAX_OPTIONS,
68
+ description:
69
+ "The available choices for this question. Must have 2-4 options. Each option should be a distinct, mutually exclusive choice (unless multiSelect is enabled). The 'Type something.' row is appended automatically — do NOT author it.",
70
+ }),
71
+ multiSelect: Type.Optional(
72
+ Type.Boolean({
73
+ default: false,
74
+ description:
75
+ "Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive.",
76
+ }),
77
+ ),
78
+ });
79
+
80
+ export const QuestionsSchema = Type.Array(QuestionSchema, {
81
+ minItems: 1,
82
+ maxItems: MAX_QUESTIONS,
83
+ description: "Questions to ask the user (1-4 questions)",
84
+ });
85
+
86
+ export const QuestionParamsSchema = Type.Object({
87
+ questions: QuestionsSchema,
88
+ timeout: Type.Optional(
89
+ Type.Integer({
90
+ minimum: 1000,
91
+ description:
92
+ 'Optional timeout in milliseconds after which the questionnaire auto-dismisses with a live countdown. When it expires the tool returns cancelled:true with error "timed_out" — not a decline. The user never saw a timeout as a rejection; retry or fall back to asking in chat instead.',
93
+ }),
94
+ ),
95
+ });
96
+
97
+ export type OptionData = Static<typeof OptionSchema>;
98
+ export type QuestionData = Static<typeof QuestionSchema>;
99
+ export type QuestionParams = Static<typeof QuestionParamsSchema>;
100
+
101
+ /**
102
+ * Answer-intent union. `kind` is the only discriminator; parallel boolean flags
103
+ * are banned and a test enforces that.
104
+ *
105
+ * - `option` — the user picked an authored option. `answer` is its label.
106
+ * - `custom` — the user typed free text in the "Type something." row.
107
+ * `answer` is the text, or null when they committed nothing.
108
+ * - `multi` — the user committed multi-select choices. `selected` carries the
109
+ * chosen labels and `answer` is null.
110
+ */
111
+ export interface QuestionAnswer {
112
+ questionIndex: number;
113
+ question: string;
114
+ kind: "option" | "custom" | "multi";
115
+ answer: string | null;
116
+ selected?: string[];
117
+ notes?: string;
118
+ /**
119
+ * Markdown copied from the chosen option's `preview`, populated only when a
120
+ * single-select answer landed on a preview-bearing option. The envelope
121
+ * echoes it back so the model knows which artifact the user actually saw.
122
+ * Undefined for multi-select and free-text answers.
123
+ */
124
+ preview?: string;
125
+ }
126
+
127
+ /**
128
+ * A note the user wrote on a question they never answered.
129
+ *
130
+ * These cannot ride in `answers`. `notesByTab` is decoupled from `answers` so
131
+ * that writing a note does not mark a question answered, and folding a
132
+ * note-only entry into `answers` would flip its tab to answered and silence the
133
+ * Submit tab's missing-question warning. They get their own field instead,
134
+ * exactly as `globalNote` does.
135
+ *
136
+ * `question` is copied alongside the index for the same reason `QuestionAnswer`
137
+ * copies it: a replayed session reads standalone, with no access to the params
138
+ * the questionnaire was built from.
139
+ */
140
+ export interface UnansweredNote {
141
+ questionIndex: number;
142
+ question: string;
143
+ note: string;
144
+ }
145
+
146
+ export type QuestionnaireError =
147
+ | "no_ui"
148
+ | "no_custom_ui"
149
+ | "no_questions"
150
+ | "empty_options"
151
+ | "too_many_questions"
152
+ | "duplicate_question"
153
+ | "duplicate_option_label"
154
+ | "reserved_label"
155
+ | "session_load_failed"
156
+ | "stale_module_cache"
157
+ | "timed_out";
158
+
159
+ export interface QuestionnaireResult {
160
+ answers: QuestionAnswer[];
161
+ cancelled: boolean;
162
+ /**
163
+ * A note covering the whole questionnaire rather than one question, authored
164
+ * on the Submit tab. Attached on cancel as well as submit, mirroring
165
+ * per-question notes.
166
+ *
167
+ * Conditional-spread contract: the key appears only via conditional spread of
168
+ * a non-empty string. Never assigned `undefined`, never kept for a
169
+ * whitespace-only draft, so a note-free result stays byte-identical and
170
+ * `!("globalNote" in result)` holds.
171
+ */
172
+ globalNote?: string;
173
+ /**
174
+ * Notes on questions that were never answered, in the order the questions
175
+ * were asked. Without this field they were discarded silently: the note lived
176
+ * in `notesByTab`, and both the envelope and the Submit review skipped any
177
+ * tab with no answer.
178
+ *
179
+ * Same conditional-spread contract as `globalNote`: the key appears only via
180
+ * conditional spread of a non-empty array, is never assigned `undefined`, and
181
+ * so a result with no such notes stays byte-identical and
182
+ * `!("unansweredNotes" in result)` holds. Attached on cancel as well as
183
+ * submit, mirroring per-question notes.
184
+ */
185
+ unansweredNotes?: UnansweredNote[];
186
+ error?: QuestionnaireError;
187
+ }
188
+
189
+ export function isQuestionnaireResult(value: unknown): value is QuestionnaireResult {
190
+ if (!value || typeof value !== "object") return false;
191
+ const v = value as Record<string, unknown>;
192
+ return Array.isArray(v.answers) && typeof v.cancelled === "boolean";
193
+ }
@@ -0,0 +1,74 @@
1
+ import {
2
+ MAX_QUESTIONS,
3
+ MIN_OPTIONS,
4
+ type QuestionnaireError,
5
+ type QuestionParams,
6
+ RESERVED_LABELS,
7
+ } from "./types.js";
8
+
9
+ export const ERROR_NO_QUESTIONS = "Error: At least one question is required";
10
+ export const ERROR_TOO_MANY_QUESTIONS = `Error: At most ${MAX_QUESTIONS} questions are allowed per invocation`;
11
+ export const ERROR_DUPLICATE_QUESTION = "Error: Question text must be unique within an invocation";
12
+ export const ERROR_TOO_FEW_OPTIONS = `Error: Each question requires at least ${MIN_OPTIONS} options`;
13
+ export const ERROR_RESERVED_LABEL = `Error: Option label is reserved (${RESERVED_LABELS.join(", ")})`;
14
+ export const ERROR_DUPLICATE_OPTION_LABEL = "Error: Option labels must be unique within a question";
15
+
16
+ const RESERVED_LABEL_SET: ReadonlySet<string> = new Set<string>(RESERVED_LABELS);
17
+
18
+ export type ValidationResult =
19
+ | { ok: true }
20
+ | { ok: false; error: QuestionnaireError; message: string };
21
+
22
+ /**
23
+ * Runtime validator for tool parameters. Pure. Covers every guard except
24
+ * `no_ui`, which depends on host state and stays at the call site.
25
+ *
26
+ * Upstream documents the reserved-label check as needing to precede the
27
+ * duplicate-label check. It reads well but is unobservable: a reserved label
28
+ * trips on its FIRST occurrence, when `seenLabels` cannot yet contain it, so no
29
+ * option is ever both reserved and a duplicate. Two `"Other"` options return
30
+ * `reserved_label` at index 0 under either ordering, and index 1 is
31
+ * unreachable. Swapping these two blocks changes no output for any input.
32
+ *
33
+ * The order is kept because it reads in order of severity, not because
34
+ * behavior depends on it. Do not add a test claiming to pin the precedence:
35
+ * it would pass under both orderings and assert nothing.
36
+ */
37
+ export function validateQuestionnaire(typed: QuestionParams): ValidationResult {
38
+ if (typed.questions.length === 0) {
39
+ return { ok: false, error: "no_questions", message: ERROR_NO_QUESTIONS };
40
+ }
41
+ if (typed.questions.length > MAX_QUESTIONS) {
42
+ return { ok: false, error: "too_many_questions", message: ERROR_TOO_MANY_QUESTIONS };
43
+ }
44
+
45
+ const seenQuestions = new Set<string>();
46
+ for (const q of typed.questions) {
47
+ if (seenQuestions.has(q.question)) {
48
+ return { ok: false, error: "duplicate_question", message: ERROR_DUPLICATE_QUESTION };
49
+ }
50
+ seenQuestions.add(q.question);
51
+ }
52
+
53
+ for (const q of typed.questions) {
54
+ if (q.options.length < MIN_OPTIONS) {
55
+ return { ok: false, error: "empty_options", message: ERROR_TOO_FEW_OPTIONS };
56
+ }
57
+ const seenLabels = new Set<string>();
58
+ for (const o of q.options) {
59
+ if (RESERVED_LABEL_SET.has(o.label)) {
60
+ return { ok: false, error: "reserved_label", message: ERROR_RESERVED_LABEL };
61
+ }
62
+ if (seenLabels.has(o.label)) {
63
+ return {
64
+ ok: false,
65
+ error: "duplicate_option_label",
66
+ message: ERROR_DUPLICATE_OPTION_LABEL,
67
+ };
68
+ }
69
+ seenLabels.add(o.label);
70
+ }
71
+ }
72
+
73
+ return { ok: true };
74
+ }
@@ -0,0 +1,51 @@
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
+ /** A cross-tab component and the selector that computes its props. */
12
+ export interface ComponentBinding<P> {
13
+ readonly component: StatefulView<P>;
14
+ readonly select: GlobalSelector<P>;
15
+ }
16
+
17
+ /**
18
+ * A per-tab component kind. `resolve` picks the instance out of a tab, and may
19
+ * return undefined for kinds a tab does not have (only multiSelect questions
20
+ * carry a `MultiSelectView`). `predicate` skips the write entirely.
21
+ */
22
+ export interface PerTabBinding<P> {
23
+ readonly resolve: (tab: TabComponents) => StatefulView<P> | undefined;
24
+ readonly select: PerTabSelector<P>;
25
+ readonly predicate?: PerTabSelector<boolean>;
26
+ }
27
+
28
+ export interface BoundGlobalBinding {
29
+ apply(state: QuestionnaireState, ctx: BindingContext): void;
30
+ invalidate(): void;
31
+ }
32
+
33
+ export interface BoundPerTabBinding {
34
+ apply(state: QuestionnaireState, ctx: PerTabBindingContext): void;
35
+ }
36
+
37
+ export function globalBinding<P>(spec: ComponentBinding<P>): BoundGlobalBinding {
38
+ return {
39
+ apply: (state, ctx) => spec.component.setProps(spec.select(state, ctx)),
40
+ invalidate: () => spec.component.invalidate(),
41
+ };
42
+ }
43
+
44
+ export function perTabBinding<P>(spec: PerTabBinding<P>): BoundPerTabBinding {
45
+ return {
46
+ apply: (state, ctx) => {
47
+ if (spec.predicate && !spec.predicate(state, ctx)) return;
48
+ spec.resolve(ctx.tab)?.setProps(spec.select(state, ctx));
49
+ },
50
+ };
51
+ }
@@ -0,0 +1,66 @@
1
+ import { CURSOR_MARKER, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
+
3
+ // Grapheme-aware extraction at the cursor: pi-tui's Editor reports UTF-16
4
+ // line/column positions, so the cursor can land between code units of one cluster
5
+ // (emoji, ZWJ, combining marks). Single-code-unit slicing would split the cluster
6
+ // across the SGR 7/27 boundary. Both single- and multi-select views share this
7
+ // cursor-building core.
8
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
9
+
10
+ export interface RenderInlineInputOptions {
11
+ /** Live inline-input buffer. */
12
+ buffer: string;
13
+ /** Cursor offset; `undefined` / out-of-range → end-of-buffer fallback. */
14
+ cursorOffset: number | undefined;
15
+ /** Prefix for the first emitted line (e.g. `❯ 4. `). */
16
+ rowPrefix: string;
17
+ /** Prefix for continuation lines (whitespace of equal visible width). */
18
+ continuationPrefix: string;
19
+ /** Visible columns available for the buffer content (width − prefix width). */
20
+ contentWidth: number;
21
+ /** Per-line styling (single-select: `theme.selectedText`; multi-select: accent+bold). */
22
+ selectedText: (text: string) => string;
23
+ }
24
+
25
+ /**
26
+ * Resolve the cursor offset, falling back to end-of-buffer for `undefined`/out-of-range.
27
+ * Mirrors the original wrapping-select.resolveOffset exactly.
28
+ */
29
+ function resolveCursorOffset(buffer: string, requested: number | undefined): number {
30
+ if (requested !== undefined && requested >= 0 && requested <= buffer.length) return requested;
31
+ return buffer.length;
32
+ }
33
+
34
+ /**
35
+ * Build the cursor-marked raw string for the whole buffer: `before | CURSOR_MARKER |
36
+ * SGR-7 reverse-video cell | SGR-27 | after`. The cell UNDER the cursor is the single
37
+ * grapheme at the offset (or U+00A0 NBSP at end-of-buffer / on a literal space — NBSP is
38
+ * wrap-safe where a literal space would tokenize as a wrap break). Zero characters shift;
39
+ * the column under the cursor inverts. `CURSOR_MARKER` is zero-width so wrap/truncate math
40
+ * is preserved.
41
+ */
42
+ function buildCursorRaw(buffer: string, offset: number): string {
43
+ const before = buffer.slice(0, offset);
44
+ const [firstGrapheme] = graphemeSegmenter.segment(buffer.slice(offset));
45
+ const rawAt = firstGrapheme ? firstGrapheme.segment : "";
46
+ // A logical newline has no visible cell. Draw the cursor on an NBSP immediately
47
+ // before it and leave the newline unconsumed so the next logical line still renders.
48
+ const cursorAtLineEnd = rawAt === "\n";
49
+ const atCursor = rawAt === "" || rawAt === " " || cursorAtLineEnd ? "\xa0" : rawAt;
50
+ const after = buffer.slice(offset + (cursorAtLineEnd ? 0 : rawAt.length));
51
+ return `${before}${CURSOR_MARKER}\x1b[7m${atCursor}\x1b[27m${after}`;
52
+ }
53
+
54
+ /**
55
+ * Render the inline editor across logical and visually wrapped lines.
56
+ * Cursor visualization follows Pi's editor pattern: reverse-video on the cell at
57
+ * the cursor, with a non-breaking-space cell at end-of-line/end-of-buffer.
58
+ */
59
+ export function renderInlineInputRow(opts: RenderInlineInputOptions): string[] {
60
+ const { buffer, cursorOffset, rowPrefix, continuationPrefix, contentWidth, selectedText } = opts;
61
+ const raw = buildCursorRaw(buffer, resolveCursorOffset(buffer, cursorOffset));
62
+ return wrapTextWithAnsi(raw, contentWidth).map((segment, index) => {
63
+ const prefix = index === 0 ? rowPrefix : continuationPrefix;
64
+ return selectedText(`${prefix}${segment}`);
65
+ });
66
+ }