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,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
|
+
}
|