pi-cc-extensions 0.8.8 → 0.8.10

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 (60) hide show
  1. package/README.md +24 -6
  2. package/extensions/agent-autocomplete.ts +11 -16
  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 +266 -0
  6. package/extensions/ask-user-question/config.ts +79 -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 +172 -0
  11. package/extensions/ask-user-question/state/build-questionnaire.ts +302 -0
  12. package/extensions/ask-user-question/state/dialog-height.ts +16 -0
  13. package/extensions/ask-user-question/state/key-router.ts +291 -0
  14. package/extensions/ask-user-question/state/questionnaire-session.ts +193 -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 +102 -0
  20. package/extensions/ask-user-question/state/state-reducer.ts +299 -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 +50 -0
  24. package/extensions/ask-user-question/tool/types.ts +149 -0
  25. package/extensions/ask-user-question/tool/validate-questionnaire.ts +64 -0
  26. package/extensions/ask-user-question/view/component-binding.ts +45 -0
  27. package/extensions/ask-user-question/view/components/inline-input.ts +109 -0
  28. package/extensions/ask-user-question/view/components/multi-select-view.ts +198 -0
  29. package/extensions/ask-user-question/view/components/option-list-view.ts +77 -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 +114 -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 +212 -0
  34. package/extensions/ask-user-question/view/components/preview/preview-pane.ts +230 -0
  35. package/extensions/ask-user-question/view/components/submit-picker.ts +66 -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 +307 -0
  38. package/extensions/ask-user-question/view/dialog-builder.ts +225 -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 +264 -0
  43. package/extensions/claude-code-style.ts +1658 -1485
  44. package/extensions/compact-style.ts +120 -43
  45. package/extensions/context.ts +502 -394
  46. package/extensions/session-reference-core.ts +8 -3
  47. package/extensions/session-reference.ts +475 -448
  48. package/extensions/tool-diff/ATTRIBUTION.md +28 -0
  49. package/extensions/tool-diff/ansi-utils.ts +113 -0
  50. package/extensions/tool-diff/diff-presentation.ts +70 -0
  51. package/extensions/tool-diff/diff-renderer.ts +2827 -0
  52. package/extensions/tool-diff/index.ts +106 -0
  53. package/extensions/tool-diff/line-width-safety.ts +91 -0
  54. package/extensions/tool-diff/render-utils.ts +5 -0
  55. package/extensions/tool-diff/types.ts +20 -0
  56. package/extensions/tool-diff/write-display-utils.ts +29 -0
  57. package/extensions/tool-diff/write-execution.ts +113 -0
  58. package/extensions/tool-result-sanitize.ts +4 -4
  59. package/package.json +64 -55
  60. package/themes/github-dark-default.json +91 -91
@@ -0,0 +1,47 @@
1
+ /**
2
+ * reconcile — mid-session lifecycle reconciliation for ask_user_question.
3
+ *
4
+ * Strips or re-adds the tool to the active set so it is invisible to the LLM
5
+ * in non-interactive runs (no UI) and present in interactive ones. Mirrors the
6
+ * advisor's reconcileAdvisorTool / registerAdvisorBeforeAgentStart pattern
7
+ * (packages/rpiv-advisor/advisor/handlers.ts), simplified: ask_user_question's
8
+ * only gating signal is ctx.hasUI (no model or executor blocklist), so it
9
+ * reads the flag directly and omits the notify.
10
+ *
11
+ * RPC hosts (ctx.mode === "rpc": VSCode pendant, Zed, Paseo) are deliberately
12
+ * NOT stripped: since PR #100 the tool renders there via the select/input
13
+ * dialog walker (rpc-fallback.ts), so hasUI is the honest signal again. The
14
+ * brief period where RPC was stripped (872ef1c) predates that fallback.
15
+ */
16
+
17
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
+ import { ASK_USER_QUESTION_TOOL_NAME } from "./ask-user-question.js";
19
+
20
+ /**
21
+ * Strip-or-restore `ask_user_question` to match `ctx.hasUI`. Reads the active
22
+ * tool list itself. Idempotent: when the tool is already in the right state it
23
+ * leaves the active set (and sibling tools) untouched.
24
+ */
25
+ export function reconcileAskUserQuestionTool(pi: ExtensionAPI, ctx: ExtensionContext): void {
26
+ const active = pi.getActiveTools();
27
+ const hasTool = active.includes(ASK_USER_QUESTION_TOOL_NAME);
28
+ // !hasUI → strip so the tool never reaches the LLM's tool list in
29
+ // non-interactive runs; hasUI → restore. The in-handler guards in
30
+ // ask-user-question.ts (!hasUI, and the custom()-undefined → dialog-walker /
31
+ // no_custom_ui backstop) remain as one-turn backstops if a future Pi change
32
+ // reorders the tool-list snapshot ahead of before_agent_start, or a host
33
+ // reports hasUI without any usable rendering primitive.
34
+ if (!ctx.hasUI && hasTool) {
35
+ pi.setActiveTools(active.filter((n) => n !== ASK_USER_QUESTION_TOOL_NAME));
36
+ } else if (ctx.hasUI && !hasTool) {
37
+ pi.setActiveTools([...active, ASK_USER_QUESTION_TOOL_NAME]);
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Attach the reconciler to `before_agent_start` so the active set is fixed up
43
+ * before each turn's tool-list snapshot is read. Safe to call once at load.
44
+ */
45
+ export function registerAskUserQuestionReconciler(pi: ExtensionAPI): void {
46
+ pi.on("before_agent_start", (_event, ctx) => reconcileAskUserQuestionTool(pi, ctx));
47
+ }
@@ -0,0 +1,172 @@
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 {
25
+ QuestionAnswer,
26
+ QuestionData,
27
+ QuestionnaireResult,
28
+ QuestionParams,
29
+ } from "./tool/types.js";
30
+
31
+ const MULTI_SELECT_INSTRUCTIONS =
32
+ 'Enter the numbers of all that apply, comma-separated (e.g. "1,3"), or type a custom answer as plain text.';
33
+ const CUSTOM_ANSWER_TITLE = "Type your answer:";
34
+ const MULTI_SELECT_PLACEHOLDER = "1,3";
35
+
36
+ /** Longest preview slice folded into a select title before truncation. */
37
+ const MAX_PREVIEW_CHARS = 600;
38
+
39
+ /**
40
+ * The dialog-primitive slice of `ExtensionUIContext` this walker needs.
41
+ * Structural on purpose: the pinned pi 0.74 peer types predate `ctx.mode`,
42
+ * and jiti transpiles without type-checking — `hasDialogUI` is the runtime
43
+ * gate that makes the shape trustworthy.
44
+ */
45
+ export type DialogUI = {
46
+ select: (title: string, options: string[]) => Promise<string | undefined>;
47
+ input: (title: string, placeholder?: string) => Promise<string | undefined>;
48
+ };
49
+
50
+ /** True when the host implements the select/input dialog primitives. */
51
+ export function hasDialogUI(ui: unknown): ui is DialogUI {
52
+ const u = ui as Partial<Record<"select" | "input", unknown>> | null | undefined;
53
+ return typeof u?.select === "function" && typeof u?.input === "function";
54
+ }
55
+
56
+ type Option = QuestionData["options"][number];
57
+
58
+ function formatOptionLine(option: Option, index: number): string {
59
+ return `${index + 1}. ${option.label} — ${option.description}`;
60
+ }
61
+
62
+ /**
63
+ * Parse a user-entered (or select-returned) "N…" token to a 0-based option
64
+ * index. `parseInt` reads the leading digits of "2. B — b" as 2; NaN and
65
+ * out-of-range fail the bounds check and return null.
66
+ */
67
+ function parseIndex(token: string, count: number): number | null {
68
+ const i = Number.parseInt(token, 10) - 1;
69
+ return i >= 0 && i < count ? i : null;
70
+ }
71
+
72
+ /** Previews folded into the select title — RPC has no side-by-side pane. */
73
+ function buildPreviewBlock(question: QuestionData): string {
74
+ const blocks = question.options.flatMap((o, i) =>
75
+ o.preview && o.preview.length > 0
76
+ ? [`--- ${i + 1}. ${o.label} preview ---\n${o.preview.slice(0, MAX_PREVIEW_CHARS)}`]
77
+ : [],
78
+ );
79
+ return blocks.length > 0 ? `\n\n${blocks.join("\n\n")}` : "";
80
+ }
81
+
82
+ /**
83
+ * Walk the questionnaire one native dialog at a time. Dismissing any dialog
84
+ * (the primitive resolves `undefined`) cancels the whole questionnaire —
85
+ * mirroring Esc in the TUI — and the shared envelope emits DECLINE. A
86
+ * `QuestionAnswer` is produced per question otherwise, so the envelope is
87
+ * identical to the TUI path's.
88
+ */
89
+ export async function runRpcQuestionnaire(
90
+ ui: DialogUI,
91
+ params: QuestionParams,
92
+ ): Promise<QuestionnaireResult> {
93
+ const answers: QuestionAnswer[] = [];
94
+ for (let qi = 0; qi < params.questions.length; qi++) {
95
+ const q = params.questions[qi];
96
+ const header = q.header ? `[${q.header}] ` : "";
97
+ const answer = q.multiSelect
98
+ ? await askMultiSelect(ui, q, qi, header)
99
+ : await askSingleSelect(ui, q, qi, header);
100
+ if (answer === undefined) return { answers, cancelled: true };
101
+ answers.push(answer);
102
+ }
103
+ return { answers, cancelled: false };
104
+ }
105
+
106
+ /** `undefined` means the user dismissed the dialog (cancel the questionnaire). */
107
+ async function askSingleSelect(
108
+ ui: DialogUI,
109
+ q: QuestionData,
110
+ questionIndex: number,
111
+ header: string,
112
+ ): Promise<QuestionAnswer | undefined> {
113
+ const options = q.options.map(formatOptionLine);
114
+ options.push(`${q.options.length + 1}. ${ROW_INTENT_META.other.label}`);
115
+ const chosen = await ui.select(`${header}${q.question}${buildPreviewBlock(q)}`, options);
116
+ if (chosen == null) return undefined;
117
+ const idx = parseIndex(chosen, options.length);
118
+ // A host returning something outside the offered list is indistinguishable
119
+ // from a dismissal — treat it as one rather than fabricate an answer.
120
+ if (idx == null) return undefined;
121
+ if (idx < q.options.length) {
122
+ const o = q.options[idx];
123
+ return {
124
+ questionIndex,
125
+ question: q.question,
126
+ kind: "option",
127
+ answer: o.label,
128
+ preview: o.preview && o.preview.length > 0 ? o.preview : undefined,
129
+ };
130
+ }
131
+ // "Type something." sentinel → free-text follow-up.
132
+ const typed = await ui.input(`${header}${q.question}\n\n${CUSTOM_ANSWER_TITLE}`, "");
133
+ if (typed == null) return undefined;
134
+ return { questionIndex, question: q.question, kind: "custom", answer: typed };
135
+ }
136
+
137
+ /** `undefined` means the user dismissed the dialog (cancel the questionnaire). */
138
+ async function askMultiSelect(
139
+ ui: DialogUI,
140
+ q: QuestionData,
141
+ questionIndex: number,
142
+ header: string,
143
+ ): Promise<QuestionAnswer | undefined> {
144
+ const list = q.options.map(formatOptionLine).join("\n");
145
+ const value = await ui.input(
146
+ `${header}${q.question}\n\n${list}\n\n${MULTI_SELECT_INSTRUCTIONS}`,
147
+ MULTI_SELECT_PLACEHOLDER,
148
+ );
149
+ if (value == null) return undefined;
150
+ const trimmed = value.trim();
151
+ if (trimmed.length === 0) {
152
+ // Deliberate empty commit — same as pressing "Next" with nothing toggled.
153
+ return { questionIndex, question: q.question, kind: "multi", answer: null, selected: [] };
154
+ }
155
+ const tokens = trimmed.split(/[,\s]+/).filter((tok) => tok.length > 0);
156
+ const indices = tokens.map((tok) =>
157
+ /^\d+\.?$/.test(tok) ? parseIndex(tok, q.options.length) : null,
158
+ );
159
+ if (indices.every((i): i is number => i != null)) {
160
+ const selected: string[] = [];
161
+ for (const i of indices) {
162
+ const label = q.options[i].label;
163
+ if (!selected.includes(label)) selected.push(label);
164
+ }
165
+ return { questionIndex, question: q.question, kind: "multi", answer: null, selected };
166
+ }
167
+ // Any non-index token (words, or an out-of-range number like "13" for three
168
+ // options) means the user typed an answer, not a selection. Preserve it
169
+ // verbatim as a custom answer instead of silently dropping their input —
170
+ // this is also the multi-select "Type something." escape.
171
+ return { questionIndex, question: q.question, kind: "custom", answer: trimmed };
172
+ }
@@ -0,0 +1,302 @@
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 {
19
+ WrappingSelectItem,
20
+ WrappingSelectTheme,
21
+ } from "../view/components/wrapping-select.js";
22
+ import { DialogView } from "../view/dialog-builder.js";
23
+ import { QuestionnairePropsAdapter } from "../view/props-adapter.js";
24
+ import type { StatefulView } from "../view/stateful-view.js";
25
+ import type { TabBodyHeights, TabComponents } from "../view/tab-components.js";
26
+ import type { PerTabSelector } from "./selectors/contract.js";
27
+ import { selectActivePreviewPaneIndex } from "./selectors/derivations.js";
28
+ import {
29
+ selectDialogProps,
30
+ selectMultiSelectProps,
31
+ selectOptionListProps,
32
+ selectPreviewPaneProps,
33
+ selectSubmitPickerProps,
34
+ selectTabBarProps,
35
+ } from "./selectors/projections.js";
36
+ import type { QuestionnaireState } from "./state.js";
37
+ import { getDialogMaxRows } from "./dialog-height.js";
38
+
39
+ export interface QuestionnaireBuildConfig {
40
+ tui: { terminal: { columns: number; rows: number }; requestRender(): void };
41
+ theme: Theme;
42
+ questions: readonly QuestionData[];
43
+ itemsByTab: ReadonlyArray<readonly WrappingSelectItem[]>;
44
+ isMulti: boolean;
45
+ initialState: QuestionnaireState;
46
+ getCurrentTab: () => number;
47
+ }
48
+
49
+ export interface QuestionnaireBuilt {
50
+ adapter: QuestionnairePropsAdapter;
51
+ notesInput: Input;
52
+ inlineInput: Input;
53
+ render: (width: number) => string[];
54
+ invalidate: () => void;
55
+ }
56
+
57
+ interface HeightComputers {
58
+ current: (width: number) => number;
59
+ }
60
+
61
+ function previewBodyHeights(pane: PreviewPane): (width: number) => TabBodyHeights {
62
+ return (width) => ({
63
+ current: pane.naturalHeight(width),
64
+ max: pane.maxNaturalHeight(width),
65
+ });
66
+ }
67
+
68
+ function multiSelectBodyHeights(view: MultiSelectView): (width: number) => TabBodyHeights {
69
+ return (width) => {
70
+ const h = view.naturalHeight(width);
71
+ return { current: h, max: h };
72
+ };
73
+ }
74
+
75
+ const isActiveTab: PerTabSelector<boolean> = (s, ctx) =>
76
+ ctx.i === selectActivePreviewPaneIndex(s.currentTab, ctx.totalQuestions);
77
+
78
+ /**
79
+ * Pure factory: assembles every TUI component, the props adapter, and a
80
+ * lifecycle handle. Session-state dependencies arrive via `getCurrentTab` and
81
+ * the `inputBuffer` cell. Initial paint is delegated to
82
+ * `adapter.apply(initialState)` (called by the session at construction-end);
83
+ * no selector is invoked here.
84
+ */
85
+ export function buildQuestionnaire(config: QuestionnaireBuildConfig): QuestionnaireBuilt {
86
+ return new QuestionnaireBuilder(config).build();
87
+ }
88
+
89
+ /**
90
+ * Programming-by-intention assembly: each private method names one
91
+ * construction step from `build()`. Read top-down; the class is build-only and
92
+ * discarded once the handle is returned.
93
+ */
94
+ class QuestionnaireBuilder {
95
+ private readonly tui: QuestionnaireBuildConfig["tui"];
96
+ private readonly theme: Theme;
97
+ private readonly questions: readonly QuestionData[];
98
+ private readonly itemsByTab: ReadonlyArray<readonly WrappingSelectItem[]>;
99
+ private readonly isMulti: boolean;
100
+ private readonly initialState: QuestionnaireState;
101
+ private readonly getCurrentTab: () => number;
102
+
103
+ private readonly selectTheme: WrappingSelectTheme;
104
+ private readonly markdownTheme = getMarkdownTheme();
105
+ private readonly notesInput = new Input();
106
+ private readonly inlineInput = new Input();
107
+ private readonly getTerminalWidth = () => this.tui.terminal.columns;
108
+ private readonly getTerminalRows = () => getDialogMaxRows(this.tui.terminal.rows);
109
+
110
+ constructor(config: QuestionnaireBuildConfig) {
111
+ this.tui = config.tui;
112
+ this.theme = config.theme;
113
+ this.questions = config.questions;
114
+ this.itemsByTab = config.itemsByTab;
115
+ this.isMulti = config.isMulti;
116
+ this.initialState = config.initialState;
117
+ this.getCurrentTab = config.getCurrentTab;
118
+
119
+ this.selectTheme = this.makeSelectTheme();
120
+ }
121
+
122
+ build(): QuestionnaireBuilt {
123
+ const tabs = this.buildTabComponents();
124
+ this.injectGlobalLeftWidth(tabs);
125
+ const submitPicker = this.buildSubmitPicker();
126
+ const tabBar = this.buildTabBar();
127
+ const heights = this.buildHeightComputers(tabs);
128
+ const dialog = this.buildDialog(tabs, submitPicker, tabBar, heights);
129
+ const globalBindings = this.buildGlobalBindings(dialog, submitPicker, tabBar);
130
+ const perTabBindings = this.buildPerTabBindings();
131
+ const adapter = this.buildAdapter(tabs, globalBindings, perTabBindings);
132
+ return this.handle(adapter, dialog);
133
+ }
134
+
135
+ private makeSelectTheme(): WrappingSelectTheme {
136
+ const t = this.theme;
137
+ return {
138
+ selectedText: (s) => t.fg("accent", t.bold(s)),
139
+ description: (s) => t.fg("muted", s),
140
+ scrollInfo: (s) => t.fg("dim", s),
141
+ };
142
+ }
143
+
144
+ private buildTabComponents(): ReadonlyArray<TabComponents> {
145
+ return this.questions.map((q, i) => this.buildTabFor(q, i));
146
+ }
147
+
148
+ private buildTabFor(question: QuestionData, index: number): TabComponents {
149
+ const optionList = new OptionListView({
150
+ items: this.itemsByTab[index] ?? [],
151
+ theme: this.selectTheme,
152
+ });
153
+ const previewBlock = new PreviewBlockRenderer({
154
+ question,
155
+ theme: this.theme,
156
+ markdownTheme: this.markdownTheme,
157
+ });
158
+ const preview = new PreviewPane({
159
+ question,
160
+ getTerminalWidth: this.getTerminalWidth,
161
+ optionListView: optionList,
162
+ previewBlock,
163
+ });
164
+ const multiSelect = question.multiSelect
165
+ ? new MultiSelectView(this.theme, question)
166
+ : undefined;
167
+ const bodyHeights = this.buildBodyHeights(question, preview, multiSelect);
168
+ return { optionList, preview, multiSelect, bodyHeights };
169
+ }
170
+
171
+ private buildBodyHeights(
172
+ question: QuestionData,
173
+ preview: PreviewPane,
174
+ multiSelect: MultiSelectView | undefined,
175
+ ): (width: number) => TabBodyHeights {
176
+ return question.multiSelect
177
+ ? multiSelectBodyHeights(multiSelect!)
178
+ : previewBodyHeights(preview);
179
+ }
180
+
181
+ /**
182
+ * Compute cross-tab max adaptive left width and inject into each PreviewPane.
183
+ * Iterates all tabs and takes the maximum adaptive width.
184
+ * Called after buildTabComponents, before buildDialog (so setGlobalLeftWidth
185
+ * is set before any rendering occurs).
186
+ */
187
+ private injectGlobalLeftWidth(tabs: ReadonlyArray<TabComponents>): void {
188
+ const questions = this.questions;
189
+ const itemsByTab = this.itemsByTab;
190
+ const tabsDescriptor = questions.map((q) => ({ multiSelect: q.multiSelect }));
191
+ const globalLeftWidth = (paneWidth: number): number =>
192
+ crossTabLeftWidthWithDonation(tabsDescriptor, itemsByTab, questions, paneWidth);
193
+ for (const tab of tabs) {
194
+ tab.preview.setGlobalLeftWidth(globalLeftWidth);
195
+ }
196
+ }
197
+
198
+ private buildSubmitPicker(): SubmitPicker | undefined {
199
+ return this.isMulti ? new SubmitPicker(this.theme) : undefined;
200
+ }
201
+
202
+ private buildTabBar(): TabBar | undefined {
203
+ return this.isMulti ? new TabBar(this.theme) : undefined;
204
+ }
205
+
206
+ private buildHeightComputers(tabs: ReadonlyArray<TabComponents>): HeightComputers {
207
+ const current = (width: number): number => {
208
+ const idx = Math.min(this.getCurrentTab(), tabs.length - 1);
209
+ return Math.max(0, tabs[idx]?.bodyHeights(width).current ?? 0);
210
+ };
211
+ return { current };
212
+ }
213
+
214
+ private pickInitialActivePreview(
215
+ tabs: ReadonlyArray<TabComponents>,
216
+ ): StatefulView<PreviewPaneProps> {
217
+ const idx = selectActivePreviewPaneIndex(this.initialState.currentTab, this.questions.length);
218
+ return tabs[idx]?.preview ?? tabs[0]!.preview;
219
+ }
220
+
221
+ private buildDialog(
222
+ tabs: ReadonlyArray<TabComponents>,
223
+ submitPicker: SubmitPicker | undefined,
224
+ tabBar: TabBar | undefined,
225
+ heights: HeightComputers,
226
+ ): DialogView {
227
+ return new DialogView(
228
+ {
229
+ theme: this.theme,
230
+ questions: this.questions,
231
+ tabBar,
232
+ notesInput: this.notesInput,
233
+ isMulti: this.isMulti,
234
+ tabsByIndex: tabs,
235
+ submitPicker,
236
+ getCurrentBodyHeight: heights.current,
237
+ getTerminalRows: this.getTerminalRows,
238
+ },
239
+ { state: this.initialState, activePreviewPane: this.pickInitialActivePreview(tabs) },
240
+ );
241
+ }
242
+
243
+ private buildGlobalBindings(
244
+ dialog: DialogView,
245
+ submitPicker: SubmitPicker | undefined,
246
+ tabBar: TabBar | undefined,
247
+ ): ReadonlyArray<BoundGlobalBinding> {
248
+ return [
249
+ globalBinding({ component: dialog, select: selectDialogProps }),
250
+ ...(submitPicker
251
+ ? [globalBinding({ component: submitPicker, select: selectSubmitPickerProps })]
252
+ : []),
253
+ ...(tabBar ? [globalBinding({ component: tabBar, select: selectTabBarProps })] : []),
254
+ ];
255
+ }
256
+
257
+ private buildPerTabBindings(): ReadonlyArray<BoundPerTabBinding> {
258
+ return [
259
+ perTabBinding({
260
+ resolve: (tab) => tab.optionList,
261
+ predicate: isActiveTab,
262
+ select: selectOptionListProps,
263
+ }),
264
+ perTabBinding({
265
+ resolve: (tab) => tab.preview,
266
+ predicate: isActiveTab,
267
+ select: selectPreviewPaneProps,
268
+ }),
269
+ perTabBinding({
270
+ resolve: (tab) => tab.multiSelect,
271
+ select: selectMultiSelectProps,
272
+ }),
273
+ ];
274
+ }
275
+
276
+ private buildAdapter(
277
+ tabs: ReadonlyArray<TabComponents>,
278
+ globalBindings: ReadonlyArray<BoundGlobalBinding>,
279
+ perTabBindings: ReadonlyArray<BoundPerTabBinding>,
280
+ ): QuestionnairePropsAdapter {
281
+ return new QuestionnairePropsAdapter({
282
+ tui: this.tui,
283
+ questions: this.questions,
284
+ itemsByTab: this.itemsByTab,
285
+ tabsByIndex: tabs,
286
+ inlineInput: this.inlineInput,
287
+ globalBindings,
288
+ perTabBindings,
289
+ extraInvalidatables: [this.notesInput],
290
+ });
291
+ }
292
+
293
+ private handle(adapter: QuestionnairePropsAdapter, dialog: DialogView): QuestionnaireBuilt {
294
+ return {
295
+ adapter,
296
+ notesInput: this.notesInput,
297
+ inlineInput: this.inlineInput,
298
+ render: (w) => dialog.render(w),
299
+ invalidate: () => adapter.invalidate(),
300
+ };
301
+ }
302
+ }
@@ -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
+ }