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.
Files changed (44) hide show
  1. package/README.md +21 -5
  2. package/assets/readme/welcome.webp +0 -0
  3. package/extensions/ask-user-question/LICENSE +21 -0
  4. package/extensions/ask-user-question/README.md +63 -0
  5. package/extensions/ask-user-question/ask-user-question.ts +244 -0
  6. package/extensions/ask-user-question/config.ts +75 -0
  7. package/extensions/ask-user-question/events.ts +55 -0
  8. package/extensions/ask-user-question/index.ts +17 -0
  9. package/extensions/ask-user-question/reconcile.ts +47 -0
  10. package/extensions/ask-user-question/rpc-fallback.ts +160 -0
  11. package/extensions/ask-user-question/state/build-questionnaire.ts +291 -0
  12. package/extensions/ask-user-question/state/dialog-height.ts +16 -0
  13. package/extensions/ask-user-question/state/key-router.ts +269 -0
  14. package/extensions/ask-user-question/state/questionnaire-session.ts +196 -0
  15. package/extensions/ask-user-question/state/row-intent.ts +143 -0
  16. package/extensions/ask-user-question/state/selectors/contract.ts +24 -0
  17. package/extensions/ask-user-question/state/selectors/derivations.ts +40 -0
  18. package/extensions/ask-user-question/state/selectors/focus.ts +17 -0
  19. package/extensions/ask-user-question/state/selectors/projections.ts +99 -0
  20. package/extensions/ask-user-question/state/state-reducer.ts +282 -0
  21. package/extensions/ask-user-question/state/state.ts +49 -0
  22. package/extensions/ask-user-question/tool/format-answer.ts +29 -0
  23. package/extensions/ask-user-question/tool/response-envelope.ts +47 -0
  24. package/extensions/ask-user-question/tool/types.ts +145 -0
  25. package/extensions/ask-user-question/tool/validate-questionnaire.ts +56 -0
  26. package/extensions/ask-user-question/view/component-binding.ts +45 -0
  27. package/extensions/ask-user-question/view/components/inline-input.ts +96 -0
  28. package/extensions/ask-user-question/view/components/multi-select-view.ts +191 -0
  29. package/extensions/ask-user-question/view/components/option-list-view.ts +68 -0
  30. package/extensions/ask-user-question/view/components/preview/markdown-content-cache.ts +76 -0
  31. package/extensions/ask-user-question/view/components/preview/preview-block-renderer.ts +108 -0
  32. package/extensions/ask-user-question/view/components/preview/preview-box-renderer.ts +86 -0
  33. package/extensions/ask-user-question/view/components/preview/preview-layout-decider.ts +200 -0
  34. package/extensions/ask-user-question/view/components/preview/preview-pane.ts +226 -0
  35. package/extensions/ask-user-question/view/components/submit-picker.ts +64 -0
  36. package/extensions/ask-user-question/view/components/tab-bar.ts +57 -0
  37. package/extensions/ask-user-question/view/components/wrapping-select.ts +291 -0
  38. package/extensions/ask-user-question/view/dialog-builder.ts +205 -0
  39. package/extensions/ask-user-question/view/props-adapter.ts +123 -0
  40. package/extensions/ask-user-question/view/stateful-view.ts +24 -0
  41. package/extensions/ask-user-question/view/tab-components.ts +16 -0
  42. package/extensions/ask-user-question/view/tab-content-strategy.ts +249 -0
  43. package/package.json +14 -4
  44. package/themes/github-dark-default.json +91 -0
@@ -0,0 +1,160 @@
1
+ /**
2
+ * RPC / dialog-primitive fallback for `ask_user_question`.
3
+ *
4
+ * The canonical TUI path (`ctx.ui.custom()`) renders a tabbed temporary editor that
5
+ * needs a real terminal. RPC-mode hosts (the VSCode pendant, ACP clients such
6
+ * as Zed or Paseo) report `hasUI: true` because pi's dialog sub-protocol
7
+ * (`extension_ui_request`/`extension_ui_response`) works, but `ui.custom()`
8
+ * resolves `undefined` without rendering anything (issue #78). `ui.select()`
9
+ * and `ui.input()` ARE functional there — the host renders them natively — so
10
+ * this module walks the questions sequentially with those primitives and
11
+ * returns the same `QuestionnaireResult` shapes the TUI produces, feeding the
12
+ * shared `buildQuestionnaireResponse` envelope.
13
+ *
14
+ * Parity trade-offs vs the TUI, inherent to the select/input API surface: no
15
+ * side-by-side preview pane (previews are folded into the prompt title), no
16
+ * tabbed multi-question review (one dialog per question), and multi-select is
17
+ * a free-text numbers input instead of checkbox rows. The "Type something."
18
+ * escape is preserved on both variants — multi-select treats any non-index
19
+ * input as a typed custom answer — matching
20
+ * `ROW_INTENT_META.other.autoAppendOnMultiSelect`.
21
+ */
22
+
23
+ import { ROW_INTENT_META } from "./state/row-intent.js";
24
+ import type { QuestionAnswer, QuestionData, QuestionnaireResult, QuestionParams } from "./tool/types.js";
25
+
26
+ const MULTI_SELECT_INSTRUCTIONS =
27
+ 'Enter the numbers of all that apply, comma-separated (e.g. "1,3"), or type a custom answer as plain text.';
28
+ const CUSTOM_ANSWER_TITLE = "Type your answer:";
29
+ const MULTI_SELECT_PLACEHOLDER = "1,3";
30
+
31
+ /** Longest preview slice folded into a select title before truncation. */
32
+ const MAX_PREVIEW_CHARS = 600;
33
+
34
+ /**
35
+ * The dialog-primitive slice of `ExtensionUIContext` this walker needs.
36
+ * Structural on purpose: the pinned pi 0.74 peer types predate `ctx.mode`,
37
+ * and jiti transpiles without type-checking — `hasDialogUI` is the runtime
38
+ * gate that makes the shape trustworthy.
39
+ */
40
+ export type DialogUI = {
41
+ select: (title: string, options: string[]) => Promise<string | undefined>;
42
+ input: (title: string, placeholder?: string) => Promise<string | undefined>;
43
+ };
44
+
45
+ /** True when the host implements the select/input dialog primitives. */
46
+ export function hasDialogUI(ui: unknown): ui is DialogUI {
47
+ const u = ui as Partial<Record<"select" | "input", unknown>> | null | undefined;
48
+ return typeof u?.select === "function" && typeof u?.input === "function";
49
+ }
50
+
51
+ type Option = QuestionData["options"][number];
52
+
53
+ function formatOptionLine(option: Option, index: number): string {
54
+ return `${index + 1}. ${option.label} — ${option.description}`;
55
+ }
56
+
57
+ /**
58
+ * Parse a user-entered (or select-returned) "N…" token to a 0-based option
59
+ * index. `parseInt` reads the leading digits of "2. B — b" as 2; NaN and
60
+ * out-of-range fail the bounds check and return null.
61
+ */
62
+ function parseIndex(token: string, count: number): number | null {
63
+ const i = Number.parseInt(token, 10) - 1;
64
+ return i >= 0 && i < count ? i : null;
65
+ }
66
+
67
+ /** Previews folded into the select title — RPC has no side-by-side pane. */
68
+ function buildPreviewBlock(question: QuestionData): string {
69
+ const blocks = question.options.flatMap((o, i) =>
70
+ o.preview && o.preview.length > 0
71
+ ? [`--- ${i + 1}. ${o.label} preview ---\n${o.preview.slice(0, MAX_PREVIEW_CHARS)}`]
72
+ : [],
73
+ );
74
+ return blocks.length > 0 ? `\n\n${blocks.join("\n\n")}` : "";
75
+ }
76
+
77
+ /**
78
+ * Walk the questionnaire one native dialog at a time. Dismissing any dialog
79
+ * (the primitive resolves `undefined`) cancels the whole questionnaire —
80
+ * mirroring Esc in the TUI — and the shared envelope emits DECLINE. A
81
+ * `QuestionAnswer` is produced per question otherwise, so the envelope is
82
+ * identical to the TUI path's.
83
+ */
84
+ export async function runRpcQuestionnaire(ui: DialogUI, params: QuestionParams): Promise<QuestionnaireResult> {
85
+ const answers: QuestionAnswer[] = [];
86
+ for (let qi = 0; qi < params.questions.length; qi++) {
87
+ const q = params.questions[qi];
88
+ const header = q.header ? `[${q.header}] ` : "";
89
+ const answer = q.multiSelect ? await askMultiSelect(ui, q, qi, header) : await askSingleSelect(ui, q, qi, header);
90
+ if (answer === undefined) return { answers, cancelled: true };
91
+ answers.push(answer);
92
+ }
93
+ return { answers, cancelled: false };
94
+ }
95
+
96
+ /** `undefined` means the user dismissed the dialog (cancel the questionnaire). */
97
+ async function askSingleSelect(
98
+ ui: DialogUI,
99
+ q: QuestionData,
100
+ questionIndex: number,
101
+ header: string,
102
+ ): Promise<QuestionAnswer | undefined> {
103
+ const options = q.options.map(formatOptionLine);
104
+ options.push(`${q.options.length + 1}. ${ROW_INTENT_META.other.label}`);
105
+ const chosen = await ui.select(`${header}${q.question}${buildPreviewBlock(q)}`, options);
106
+ if (chosen == null) return undefined;
107
+ const idx = parseIndex(chosen, options.length);
108
+ // A host returning something outside the offered list is indistinguishable
109
+ // from a dismissal — treat it as one rather than fabricate an answer.
110
+ if (idx == null) return undefined;
111
+ if (idx < q.options.length) {
112
+ const o = q.options[idx];
113
+ return {
114
+ questionIndex,
115
+ question: q.question,
116
+ kind: "option",
117
+ answer: o.label,
118
+ preview: o.preview && o.preview.length > 0 ? o.preview : undefined,
119
+ };
120
+ }
121
+ // "Type something." sentinel → free-text follow-up.
122
+ const typed = await ui.input(`${header}${q.question}\n\n${CUSTOM_ANSWER_TITLE}`, "");
123
+ if (typed == null) return undefined;
124
+ return { questionIndex, question: q.question, kind: "custom", answer: typed };
125
+ }
126
+
127
+ /** `undefined` means the user dismissed the dialog (cancel the questionnaire). */
128
+ async function askMultiSelect(
129
+ ui: DialogUI,
130
+ q: QuestionData,
131
+ questionIndex: number,
132
+ header: string,
133
+ ): Promise<QuestionAnswer | undefined> {
134
+ const list = q.options.map(formatOptionLine).join("\n");
135
+ const value = await ui.input(
136
+ `${header}${q.question}\n\n${list}\n\n${MULTI_SELECT_INSTRUCTIONS}`,
137
+ MULTI_SELECT_PLACEHOLDER,
138
+ );
139
+ if (value == null) return undefined;
140
+ const trimmed = value.trim();
141
+ if (trimmed.length === 0) {
142
+ // Deliberate empty commit — same as pressing "Next" with nothing toggled.
143
+ return { questionIndex, question: q.question, kind: "multi", answer: null, selected: [] };
144
+ }
145
+ const tokens = trimmed.split(/[,\s]+/).filter((tok) => tok.length > 0);
146
+ const indices = tokens.map((tok) => (/^\d+\.?$/.test(tok) ? parseIndex(tok, q.options.length) : null));
147
+ if (indices.every((i): i is number => i != null)) {
148
+ const selected: string[] = [];
149
+ for (const i of indices) {
150
+ const label = q.options[i].label;
151
+ if (!selected.includes(label)) selected.push(label);
152
+ }
153
+ return { questionIndex, question: q.question, kind: "multi", answer: null, selected };
154
+ }
155
+ // Any non-index token (words, or an out-of-range number like "13" for three
156
+ // options) means the user typed an answer, not a selection. Preserve it
157
+ // verbatim as a custom answer instead of silently dropping their input —
158
+ // this is also the multi-select "Type something." escape.
159
+ return { questionIndex, question: q.question, kind: "custom", answer: trimmed };
160
+ }
@@ -0,0 +1,291 @@
1
+ import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Input } from "@earendil-works/pi-tui";
3
+ import type { QuestionData } from "../tool/types.js";
4
+ import {
5
+ type BoundGlobalBinding,
6
+ type BoundPerTabBinding,
7
+ globalBinding,
8
+ perTabBinding,
9
+ } from "../view/component-binding.js";
10
+ import { MultiSelectView } from "../view/components/multi-select-view.js";
11
+ import { OptionListView } from "../view/components/option-list-view.js";
12
+ import { PreviewBlockRenderer } from "../view/components/preview/preview-block-renderer.js";
13
+ import { crossTabLeftWidthWithDonation } from "../view/components/preview/preview-layout-decider.js";
14
+ import type { PreviewPaneProps } from "../view/components/preview/preview-pane.js";
15
+ import { PreviewPane } from "../view/components/preview/preview-pane.js";
16
+ import { SubmitPicker } from "../view/components/submit-picker.js";
17
+ import { TabBar } from "../view/components/tab-bar.js";
18
+ import type { WrappingSelectItem, WrappingSelectTheme } from "../view/components/wrapping-select.js";
19
+ import { DialogView } from "../view/dialog-builder.js";
20
+ import { QuestionnairePropsAdapter } from "../view/props-adapter.js";
21
+ import type { StatefulView } from "../view/stateful-view.js";
22
+ import type { TabBodyHeights, TabComponents } from "../view/tab-components.js";
23
+ import type { PerTabSelector } from "./selectors/contract.js";
24
+ import { selectActivePreviewPaneIndex } from "./selectors/derivations.js";
25
+ import {
26
+ selectDialogProps,
27
+ selectMultiSelectProps,
28
+ selectOptionListProps,
29
+ selectPreviewPaneProps,
30
+ selectSubmitPickerProps,
31
+ selectTabBarProps,
32
+ } from "./selectors/projections.js";
33
+ import type { QuestionnaireState } from "./state.js";
34
+ import { getDialogMaxRows } from "./dialog-height.js";
35
+
36
+ export interface QuestionnaireBuildConfig {
37
+ tui: { terminal: { columns: number; rows: number }; requestRender(): void };
38
+ theme: Theme;
39
+ questions: readonly QuestionData[];
40
+ itemsByTab: ReadonlyArray<readonly WrappingSelectItem[]>;
41
+ isMulti: boolean;
42
+ initialState: QuestionnaireState;
43
+ getCurrentTab: () => number;
44
+ }
45
+
46
+ export interface QuestionnaireBuilt {
47
+ adapter: QuestionnairePropsAdapter;
48
+ notesInput: Input;
49
+ inlineInput: Input;
50
+ render: (width: number) => string[];
51
+ invalidate: () => void;
52
+ }
53
+
54
+ interface HeightComputers {
55
+ current: (width: number) => number;
56
+ }
57
+
58
+ function previewBodyHeights(pane: PreviewPane): (width: number) => TabBodyHeights {
59
+ return (width) => ({
60
+ current: pane.naturalHeight(width),
61
+ max: pane.maxNaturalHeight(width),
62
+ });
63
+ }
64
+
65
+ function multiSelectBodyHeights(view: MultiSelectView): (width: number) => TabBodyHeights {
66
+ return (width) => {
67
+ const h = view.naturalHeight(width);
68
+ return { current: h, max: h };
69
+ };
70
+ }
71
+
72
+ const isActiveTab: PerTabSelector<boolean> = (s, ctx) =>
73
+ ctx.i === selectActivePreviewPaneIndex(s.currentTab, ctx.totalQuestions);
74
+
75
+ /**
76
+ * Pure factory: assembles every TUI component, the props adapter, and a
77
+ * lifecycle handle. Session-state dependencies arrive via `getCurrentTab` and
78
+ * the `inputBuffer` cell. Initial paint is delegated to
79
+ * `adapter.apply(initialState)` (called by the session at construction-end);
80
+ * no selector is invoked here.
81
+ */
82
+ export function buildQuestionnaire(config: QuestionnaireBuildConfig): QuestionnaireBuilt {
83
+ return new QuestionnaireBuilder(config).build();
84
+ }
85
+
86
+ /**
87
+ * Programming-by-intention assembly: each private method names one
88
+ * construction step from `build()`. Read top-down; the class is build-only and
89
+ * discarded once the handle is returned.
90
+ */
91
+ class QuestionnaireBuilder {
92
+ private readonly tui: QuestionnaireBuildConfig["tui"];
93
+ private readonly theme: Theme;
94
+ private readonly questions: readonly QuestionData[];
95
+ private readonly itemsByTab: ReadonlyArray<readonly WrappingSelectItem[]>;
96
+ private readonly isMulti: boolean;
97
+ private readonly initialState: QuestionnaireState;
98
+ private readonly getCurrentTab: () => number;
99
+
100
+ private readonly selectTheme: WrappingSelectTheme;
101
+ private readonly markdownTheme = getMarkdownTheme();
102
+ private readonly notesInput = new Input();
103
+ private readonly inlineInput = new Input();
104
+ private readonly getTerminalWidth = () => this.tui.terminal.columns;
105
+ private readonly getTerminalRows = () => getDialogMaxRows(this.tui.terminal.rows);
106
+
107
+ constructor(config: QuestionnaireBuildConfig) {
108
+ this.tui = config.tui;
109
+ this.theme = config.theme;
110
+ this.questions = config.questions;
111
+ this.itemsByTab = config.itemsByTab;
112
+ this.isMulti = config.isMulti;
113
+ this.initialState = config.initialState;
114
+ this.getCurrentTab = config.getCurrentTab;
115
+
116
+ this.selectTheme = this.makeSelectTheme();
117
+ }
118
+
119
+ build(): QuestionnaireBuilt {
120
+ const tabs = this.buildTabComponents();
121
+ this.injectGlobalLeftWidth(tabs);
122
+ const submitPicker = this.buildSubmitPicker();
123
+ const tabBar = this.buildTabBar();
124
+ const heights = this.buildHeightComputers(tabs);
125
+ const dialog = this.buildDialog(tabs, submitPicker, tabBar, heights);
126
+ const globalBindings = this.buildGlobalBindings(dialog, submitPicker, tabBar);
127
+ const perTabBindings = this.buildPerTabBindings();
128
+ const adapter = this.buildAdapter(tabs, globalBindings, perTabBindings);
129
+ return this.handle(adapter, dialog);
130
+ }
131
+
132
+ private makeSelectTheme(): WrappingSelectTheme {
133
+ const t = this.theme;
134
+ return {
135
+ selectedText: (s) => t.fg("accent", t.bold(s)),
136
+ description: (s) => t.fg("muted", s),
137
+ scrollInfo: (s) => t.fg("dim", s),
138
+ };
139
+ }
140
+
141
+ private buildTabComponents(): ReadonlyArray<TabComponents> {
142
+ return this.questions.map((q, i) => this.buildTabFor(q, i));
143
+ }
144
+
145
+ private buildTabFor(question: QuestionData, index: number): TabComponents {
146
+ const optionList = new OptionListView({
147
+ items: this.itemsByTab[index] ?? [],
148
+ theme: this.selectTheme,
149
+ });
150
+ const previewBlock = new PreviewBlockRenderer({
151
+ question,
152
+ theme: this.theme,
153
+ markdownTheme: this.markdownTheme,
154
+ });
155
+ const preview = new PreviewPane({
156
+ question,
157
+ getTerminalWidth: this.getTerminalWidth,
158
+ optionListView: optionList,
159
+ previewBlock,
160
+ });
161
+ const multiSelect = question.multiSelect ? new MultiSelectView(this.theme, question) : undefined;
162
+ const bodyHeights = this.buildBodyHeights(question, preview, multiSelect);
163
+ return { optionList, preview, multiSelect, bodyHeights };
164
+ }
165
+
166
+ private buildBodyHeights(
167
+ question: QuestionData,
168
+ preview: PreviewPane,
169
+ multiSelect: MultiSelectView | undefined,
170
+ ): (width: number) => TabBodyHeights {
171
+ return question.multiSelect ? multiSelectBodyHeights(multiSelect!) : previewBodyHeights(preview);
172
+ }
173
+
174
+ /**
175
+ * Compute cross-tab max adaptive left width and inject into each PreviewPane.
176
+ * Iterates all tabs and takes the maximum adaptive width.
177
+ * Called after buildTabComponents, before buildDialog (so setGlobalLeftWidth
178
+ * is set before any rendering occurs).
179
+ */
180
+ private injectGlobalLeftWidth(tabs: ReadonlyArray<TabComponents>): void {
181
+ const questions = this.questions;
182
+ const itemsByTab = this.itemsByTab;
183
+ const tabsDescriptor = questions.map((q) => ({ multiSelect: q.multiSelect }));
184
+ const globalLeftWidth = (paneWidth: number): number =>
185
+ crossTabLeftWidthWithDonation(tabsDescriptor, itemsByTab, questions, paneWidth);
186
+ for (const tab of tabs) {
187
+ tab.preview.setGlobalLeftWidth(globalLeftWidth);
188
+ }
189
+ }
190
+
191
+ private buildSubmitPicker(): SubmitPicker | undefined {
192
+ return this.isMulti ? new SubmitPicker(this.theme) : undefined;
193
+ }
194
+
195
+ private buildTabBar(): TabBar | undefined {
196
+ return this.isMulti ? new TabBar(this.theme) : undefined;
197
+ }
198
+
199
+ private buildHeightComputers(tabs: ReadonlyArray<TabComponents>): HeightComputers {
200
+ const current = (width: number): number => {
201
+ const idx = Math.min(this.getCurrentTab(), tabs.length - 1);
202
+ return Math.max(0, tabs[idx]?.bodyHeights(width).current ?? 0);
203
+ };
204
+ return { current };
205
+ }
206
+
207
+ private pickInitialActivePreview(tabs: ReadonlyArray<TabComponents>): StatefulView<PreviewPaneProps> {
208
+ const idx = selectActivePreviewPaneIndex(this.initialState.currentTab, this.questions.length);
209
+ return tabs[idx]?.preview ?? tabs[0]!.preview;
210
+ }
211
+
212
+ private buildDialog(
213
+ tabs: ReadonlyArray<TabComponents>,
214
+ submitPicker: SubmitPicker | undefined,
215
+ tabBar: TabBar | undefined,
216
+ heights: HeightComputers,
217
+ ): DialogView {
218
+ return new DialogView(
219
+ {
220
+ theme: this.theme,
221
+ questions: this.questions,
222
+ tabBar,
223
+ notesInput: this.notesInput,
224
+ isMulti: this.isMulti,
225
+ tabsByIndex: tabs,
226
+ submitPicker,
227
+ getCurrentBodyHeight: heights.current,
228
+ getTerminalRows: this.getTerminalRows,
229
+ },
230
+ { state: this.initialState, activePreviewPane: this.pickInitialActivePreview(tabs) },
231
+ );
232
+ }
233
+
234
+ private buildGlobalBindings(
235
+ dialog: DialogView,
236
+ submitPicker: SubmitPicker | undefined,
237
+ tabBar: TabBar | undefined,
238
+ ): ReadonlyArray<BoundGlobalBinding> {
239
+ return [
240
+ globalBinding({ component: dialog, select: selectDialogProps }),
241
+ ...(submitPicker ? [globalBinding({ component: submitPicker, select: selectSubmitPickerProps })] : []),
242
+ ...(tabBar ? [globalBinding({ component: tabBar, select: selectTabBarProps })] : []),
243
+ ];
244
+ }
245
+
246
+ private buildPerTabBindings(): ReadonlyArray<BoundPerTabBinding> {
247
+ return [
248
+ perTabBinding({
249
+ resolve: (tab) => tab.optionList,
250
+ predicate: isActiveTab,
251
+ select: selectOptionListProps,
252
+ }),
253
+ perTabBinding({
254
+ resolve: (tab) => tab.preview,
255
+ predicate: isActiveTab,
256
+ select: selectPreviewPaneProps,
257
+ }),
258
+ perTabBinding({
259
+ resolve: (tab) => tab.multiSelect,
260
+ select: selectMultiSelectProps,
261
+ }),
262
+ ];
263
+ }
264
+
265
+ private buildAdapter(
266
+ tabs: ReadonlyArray<TabComponents>,
267
+ globalBindings: ReadonlyArray<BoundGlobalBinding>,
268
+ perTabBindings: ReadonlyArray<BoundPerTabBinding>,
269
+ ): QuestionnairePropsAdapter {
270
+ return new QuestionnairePropsAdapter({
271
+ tui: this.tui,
272
+ questions: this.questions,
273
+ itemsByTab: this.itemsByTab,
274
+ tabsByIndex: tabs,
275
+ inlineInput: this.inlineInput,
276
+ globalBindings,
277
+ perTabBindings,
278
+ extraInvalidatables: [this.notesInput],
279
+ });
280
+ }
281
+
282
+ private handle(adapter: QuestionnairePropsAdapter, dialog: DialogView): QuestionnaireBuilt {
283
+ return {
284
+ adapter,
285
+ notesInput: this.notesInput,
286
+ inlineInput: this.inlineInput,
287
+ render: (w) => dialog.render(w),
288
+ invalidate: () => adapter.invalidate(),
289
+ };
290
+ }
291
+ }
@@ -0,0 +1,16 @@
1
+ const FULL_HEIGHT_BREAKPOINT = 16;
2
+ const MIN_DIALOG_ROWS = 10;
3
+ const MIN_HISTORY_ROWS = 6;
4
+ const HISTORY_RATIO = 0.3;
5
+
6
+ /**
7
+ * Keep recent transcript rows visible above the temporary questionnaire editor.
8
+ * Very small terminals fall back to full height so the dialog remains usable.
9
+ */
10
+ export function getDialogMaxRows(terminalRows: number): number {
11
+ const rows = Math.max(1, Math.floor(terminalRows));
12
+ if (rows < FULL_HEIGHT_BREAKPOINT) return rows;
13
+
14
+ const historyRows = Math.max(MIN_HISTORY_ROWS, Math.floor(rows * HISTORY_RATIO));
15
+ return Math.min(rows, Math.max(MIN_DIALOG_ROWS, rows - historyRows));
16
+ }