pi-ask-popup 0.1.1 → 0.2.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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/ask-user-question.ts +10 -3
- package/src/config.ts +52 -1
- package/src/rpc-fallback.ts +63 -21
- package/src/state/build-questionnaire.ts +42 -7
- package/src/state/external-editor.ts +18 -10
- package/src/state/key-router.ts +23 -0
- package/src/state/questionnaire-session.ts +44 -1
- package/src/state/selectors/projections.ts +6 -0
- package/src/state/state-reducer.ts +14 -8
- package/src/tool/response-envelope.ts +39 -2
- package/src/tool/types.ts +15 -2
- package/src/view/components/multi-select-view.ts +17 -2
- package/src/view/components/option-list-view.ts +5 -0
- package/src/view/components/preview/markdown-content-cache.ts +61 -24
- package/src/view/components/preview/preview-layout-decider.ts +32 -0
- package/src/view/components/preview/preview-pane.ts +24 -16
- package/src/view/components/tab-bar.ts +26 -8
- package/src/view/components/wrapping-select.ts +100 -37
- package/src/view/dialog-builder.ts +20 -2
- package/src/view/props-adapter.ts +8 -0
- package/src/view/tab-content-strategy.ts +10 -2
|
@@ -9,6 +9,8 @@ import type {
|
|
|
9
9
|
export const DECLINE_MESSAGE = "User declined to answer questions";
|
|
10
10
|
export const TIMED_OUT_MESSAGE =
|
|
11
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 HOST_ERROR_MESSAGE =
|
|
13
|
+
"The host replied with a value that was never offered, so the questionnaire could not be completed. Nobody declined and nobody answered — do NOT treat this as a rejection. Ask the questions as plain chat text instead.";
|
|
12
14
|
export const ENVELOPE_PREFIX = "User has answered your questions:";
|
|
13
15
|
export const ENVELOPE_SUFFIX = "You can now continue with the user's answers in mind.";
|
|
14
16
|
/** Opens the segment for a note whose question was never answered. */
|
|
@@ -51,6 +53,23 @@ export function buildQuestionnaireResponse(
|
|
|
51
53
|
}
|
|
52
54
|
return buildToolResult(TIMED_OUT_MESSAGE, details);
|
|
53
55
|
}
|
|
56
|
+
if (result?.error === "host_error") {
|
|
57
|
+
// A broken host is not a decision. Sharing the decline text would tell the
|
|
58
|
+
// model the user said no, when the user was never shown a working dialog.
|
|
59
|
+
const details: QuestionnaireResult = {
|
|
60
|
+
answers: result.answers,
|
|
61
|
+
cancelled: true,
|
|
62
|
+
error: "host_error",
|
|
63
|
+
};
|
|
64
|
+
if (result.hostErrorDetail && result.hostErrorDetail.length > 0) {
|
|
65
|
+
(details as { hostErrorDetail: string }).hostErrorDetail = result.hostErrorDetail;
|
|
66
|
+
return buildToolResult(
|
|
67
|
+
`${HOST_ERROR_MESSAGE} (host sent: ${result.hostErrorDetail})`,
|
|
68
|
+
details,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return buildToolResult(HOST_ERROR_MESSAGE, details);
|
|
72
|
+
}
|
|
54
73
|
if (!result || result.cancelled) {
|
|
55
74
|
// The decline text stays canonical even when a global note rides a
|
|
56
75
|
// cancelled result. The note survives in `details`, like partial answers.
|
|
@@ -71,11 +90,18 @@ export function buildQuestionnaireResponse(
|
|
|
71
90
|
return buildToolResult(DECLINE_MESSAGE, details);
|
|
72
91
|
}
|
|
73
92
|
|
|
93
|
+
// Indexed once rather than scanned per question. Both sides are keyed by
|
|
94
|
+
// `questionIndex`, which is what the loop below asks for, and it is the same
|
|
95
|
+
// shape `orderedAnswers` uses in the reducer. A first entry wins, so a
|
|
96
|
+
// duplicated index reads as the earlier `find` did.
|
|
97
|
+
const answerByIndex = byQuestionIndex(result.answers);
|
|
98
|
+
const noteByIndex = byQuestionIndex(result.unansweredNotes ?? []);
|
|
99
|
+
|
|
74
100
|
const segments: string[] = [];
|
|
75
101
|
// Iterate the questions rather than the answers so segments always follow the
|
|
76
102
|
// order the model asked in, whatever order the user filled tabs.
|
|
77
103
|
for (let i = 0; i < params.questions.length; i++) {
|
|
78
|
-
const a =
|
|
104
|
+
const a = answerByIndex.get(i);
|
|
79
105
|
if (a) {
|
|
80
106
|
segments.push(buildAnswerSegment(a));
|
|
81
107
|
continue;
|
|
@@ -84,7 +110,7 @@ export function buildQuestionnaireResponse(
|
|
|
84
110
|
// emitted here rather than grouped at the end. Because this loop runs
|
|
85
111
|
// before the "nothing to report" check below, a questionnaire submitted
|
|
86
112
|
// with nothing but such a note counts as answered rather than declined.
|
|
87
|
-
const n =
|
|
113
|
+
const n = noteByIndex.get(i);
|
|
88
114
|
if (n) {
|
|
89
115
|
segments.push(buildUnansweredNoteSegment(n));
|
|
90
116
|
}
|
|
@@ -100,6 +126,17 @@ export function buildQuestionnaireResponse(
|
|
|
100
126
|
return buildToolResult(`${ENVELOPE_PREFIX} ${segments.join(" ")} ${ENVELOPE_SUFFIX}`, result);
|
|
101
127
|
}
|
|
102
128
|
|
|
129
|
+
/** First entry per `questionIndex` wins, which is what `Array.find` did. */
|
|
130
|
+
function byQuestionIndex<T extends { questionIndex: number }>(items: readonly T[]): Map<number, T> {
|
|
131
|
+
const out = new Map<number, T>();
|
|
132
|
+
for (const item of items) {
|
|
133
|
+
if (!out.has(item.questionIndex)) {
|
|
134
|
+
out.set(item.questionIndex, item);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
103
140
|
/**
|
|
104
141
|
* One answer as an envelope segment: `"question"="answer"`, optionally followed
|
|
105
142
|
* by the preview the user was looking at and the note they wrote.
|
package/src/tool/types.ts
CHANGED
|
@@ -114,7 +114,10 @@ export type QuestionParams = Static<typeof QuestionParamsSchema>;
|
|
|
114
114
|
* - `custom` — the user typed free text in the "Type something." row.
|
|
115
115
|
* `answer` is the text, or null when they committed nothing.
|
|
116
116
|
* - `multi` — the user committed multi-select choices. `selected` carries the
|
|
117
|
-
* chosen labels and `answer` is null.
|
|
117
|
+
* chosen labels and `answer` is null. Text typed on the "Type something." row
|
|
118
|
+
* appears in `selected` too, as its own trimmed entry: on a multi-select
|
|
119
|
+
* question that row counts as chosen whenever it holds text, so an answer can
|
|
120
|
+
* be several authored labels plus one thing the user wrote.
|
|
118
121
|
*/
|
|
119
122
|
export interface QuestionAnswer {
|
|
120
123
|
questionIndex: number;
|
|
@@ -162,7 +165,8 @@ export type QuestionnaireError =
|
|
|
162
165
|
| "reserved_label"
|
|
163
166
|
| "session_load_failed"
|
|
164
167
|
| "stale_module_cache"
|
|
165
|
-
| "timed_out"
|
|
168
|
+
| "timed_out"
|
|
169
|
+
| "host_error";
|
|
166
170
|
|
|
167
171
|
export interface QuestionnaireResult {
|
|
168
172
|
answers: QuestionAnswer[];
|
|
@@ -192,6 +196,15 @@ export interface QuestionnaireResult {
|
|
|
192
196
|
*/
|
|
193
197
|
unansweredNotes?: UnansweredNote[];
|
|
194
198
|
error?: QuestionnaireError;
|
|
199
|
+
/**
|
|
200
|
+
* What the host actually sent, on a `host_error` result. Quoted into the
|
|
201
|
+
* envelope so whoever reads the transcript can see which value was rejected
|
|
202
|
+
* rather than guessing at the malfunction.
|
|
203
|
+
*
|
|
204
|
+
* Same conditional-spread contract as `globalNote`: present only alongside
|
|
205
|
+
* `error: "host_error"`, never assigned `undefined`.
|
|
206
|
+
*/
|
|
207
|
+
hostErrorDetail?: string;
|
|
195
208
|
}
|
|
196
209
|
|
|
197
210
|
export function isQuestionnaireResult(value: unknown): value is QuestionnaireResult {
|
|
@@ -20,6 +20,13 @@ export const MULTI_SUBMIT_LABEL = "Submit";
|
|
|
20
20
|
export interface MultiSelectOtherRowProps {
|
|
21
21
|
/** The "Type something." row is the focused row (optionIndex === options.length). */
|
|
22
22
|
active: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* The typed text counts as a selection, which on this row means only that
|
|
25
|
+
* there is some. Drawn with the same ✔ as an option: the row is as chosen as
|
|
26
|
+
* a ticked box, and the box used to be painted permanently empty while the
|
|
27
|
+
* text under it was on its way to the model.
|
|
28
|
+
*/
|
|
29
|
+
checked: boolean;
|
|
23
30
|
/** `state.inputMode` — true once the row has focus and keystrokes append to the buffer. */
|
|
24
31
|
inputMode: boolean;
|
|
25
32
|
/** Live inline-input buffer (read from `runtime.inputBuffer` / `ctx.inputBuffer`). */
|
|
@@ -66,7 +73,13 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
|
|
|
66
73
|
) {
|
|
67
74
|
this.props = {
|
|
68
75
|
rows: [],
|
|
69
|
-
other: {
|
|
76
|
+
other: {
|
|
77
|
+
active: false,
|
|
78
|
+
checked: false,
|
|
79
|
+
inputMode: false,
|
|
80
|
+
inputBuffer: "",
|
|
81
|
+
inputCursorOffset: undefined,
|
|
82
|
+
},
|
|
70
83
|
nextActive: false,
|
|
71
84
|
nextLabel: ROW_INTENT_META.next.label,
|
|
72
85
|
};
|
|
@@ -175,7 +188,9 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
|
|
|
175
188
|
private renderOtherRow(contentWidth: number, numberWidth: number): string[] {
|
|
176
189
|
const other = this.props.other;
|
|
177
190
|
const pointer = other.active ? this.theme.fg("accent", ACTIVE_POINTER) : INACTIVE_POINTER;
|
|
178
|
-
const box =
|
|
191
|
+
const box = other.checked
|
|
192
|
+
? this.theme.fg("accent", CHECKED)
|
|
193
|
+
: this.theme.fg("muted", UNCHECKED);
|
|
179
194
|
const number = String(this.question.options.length + 1).padStart(numberWidth, " ");
|
|
180
195
|
const rowPrefix = `${pointer}${number}${NUMBER_SEPARATOR}${box}${BOX_LABEL_GAP}`;
|
|
181
196
|
const continuationPrefix = " ".repeat(visibleWidth(rowPrefix));
|
|
@@ -71,6 +71,11 @@ export class OptionListView implements StatefulView<OptionListViewProps> {
|
|
|
71
71
|
return this.select.render(width);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/** Row count at `width`, equal to `render(width).length`, without building the rows. */
|
|
75
|
+
measureHeight(width: number): number {
|
|
76
|
+
return this.select.measureHeight(width);
|
|
77
|
+
}
|
|
78
|
+
|
|
74
79
|
focusedItemRowRange(width: number): [number, number] {
|
|
75
80
|
return this.select.focusedItemRowRange(width);
|
|
76
81
|
}
|
|
@@ -10,22 +10,39 @@ export const MAX_PREVIEW_HEIGHT_SIDE_BY_SIDE = 20;
|
|
|
10
10
|
/** Preserves narrow-terminal protection in stacked layout. */
|
|
11
11
|
export const MAX_PREVIEW_HEIGHT_STACKED = 15;
|
|
12
12
|
export const NO_PREVIEW_TEXT = "No preview available";
|
|
13
|
+
/** Fallback body line when a preview's markdown fails to render (untrusted input boundary). */
|
|
14
|
+
export const PREVIEW_RENDER_FAILED_TEXT = "Preview failed to render";
|
|
13
15
|
/** 1 blank separator + 1 affordance text row reserved when `hasAnyPreview` (height stability of the affordance row's offset relative to the box). */
|
|
14
16
|
export const NOTES_AFFORDANCE_OVERHEAD = 2;
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* `
|
|
19
|
+
* One entry per option that carries a preview. `md` is built once and kept for
|
|
20
|
+
* the life of the cache; `lines` are the stripped rows it last produced, and
|
|
21
|
+
* `width` is the inner width they were produced at.
|
|
22
|
+
*/
|
|
23
|
+
interface PreviewEntry {
|
|
24
|
+
md: ReturnType<MarkdownFactory>;
|
|
25
|
+
width: number | undefined;
|
|
26
|
+
lines: string[] | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Per-question cache for rendered markdown previews, keyed per option.
|
|
31
|
+
*
|
|
32
|
+
* Width used to be tracked for the cache as a whole: any change invalidated
|
|
33
|
+
* every `Markdown` it held, so a resize drag re-wrapped all four options one by
|
|
34
|
+
* one through the `maxNaturalHeight` loop even though the frame only measured
|
|
35
|
+
* some of them. Each option now remembers the width it was rendered at, so a
|
|
36
|
+
* width flip costs a re-render of the options that frame actually asks for and
|
|
37
|
+
* nothing else. It also holds the stripped rows, which spares the second strip
|
|
38
|
+
* when `blockHeight` and `renderBlock` ask for the same option in one frame.
|
|
20
39
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* first request, never re-constructed — count semantics frozen by tests.
|
|
40
|
+
* One Markdown per option, lazy on first request, never re-constructed — count
|
|
41
|
+
* semantics frozen by tests.
|
|
24
42
|
*/
|
|
25
43
|
export class MarkdownContentCache {
|
|
26
44
|
private readonly previewTexts: Map<number, string>;
|
|
27
|
-
private readonly markdownCache: Map<number,
|
|
28
|
-
private cachedWidth: number | undefined;
|
|
45
|
+
private readonly markdownCache: Map<number, PreviewEntry>;
|
|
29
46
|
private readonly theme: Theme;
|
|
30
47
|
private readonly markdownTheme: MarkdownTheme;
|
|
31
48
|
private readonly markdownFactory: MarkdownFactory;
|
|
@@ -58,34 +75,54 @@ export class MarkdownContentCache {
|
|
|
58
75
|
}
|
|
59
76
|
|
|
60
77
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
78
|
+
* Body lines for one option at one inner width. A repeat request at the width
|
|
79
|
+
* the option was last rendered at returns the stored rows.
|
|
80
|
+
*
|
|
81
|
+
* Untrusted boundary: `preview` is model-authored markdown and this is the only
|
|
82
|
+
* place it reaches the render path. A `Markdown` that throws (malformed input,
|
|
83
|
+
* width edge cases) must not take the overlay down with it, so the render is
|
|
84
|
+
* wrapped and a single dim fallback line is returned instead. There is no
|
|
85
|
+
* notify channel from the view layer — the fallback line IS the diagnostic.
|
|
86
|
+
* A failed render stores nothing, so the next frame tries again.
|
|
63
87
|
*/
|
|
64
88
|
bodyFor(optionIndex: number, innerWidth: number): string[] {
|
|
65
|
-
if (this.cachedWidth !== innerWidth) {
|
|
66
|
-
for (const md of this.markdownCache.values()) {
|
|
67
|
-
md.invalidate();
|
|
68
|
-
}
|
|
69
|
-
this.cachedWidth = innerWidth;
|
|
70
|
-
}
|
|
71
89
|
const text = this.previewTexts.get(optionIndex);
|
|
72
90
|
if (!text) {
|
|
73
91
|
const placeholder = this.theme.fg("dim", NO_PREVIEW_TEXT);
|
|
74
92
|
const pad = Math.max(0, innerWidth - visibleWidth(placeholder));
|
|
75
93
|
return [placeholder + " ".repeat(pad)];
|
|
76
94
|
}
|
|
77
|
-
let
|
|
78
|
-
if (!
|
|
79
|
-
|
|
80
|
-
|
|
95
|
+
let entry = this.markdownCache.get(optionIndex);
|
|
96
|
+
if (!entry) {
|
|
97
|
+
entry = {
|
|
98
|
+
md: this.markdownFactory(text, this.markdownTheme),
|
|
99
|
+
width: undefined,
|
|
100
|
+
lines: undefined,
|
|
101
|
+
};
|
|
102
|
+
this.markdownCache.set(optionIndex, entry);
|
|
103
|
+
}
|
|
104
|
+
if (entry.lines !== undefined && entry.width === innerWidth) {
|
|
105
|
+
// A copy: the rows travel into the box renderer and out to the pane, and
|
|
106
|
+
// the cache is the only thing that may hold the originals.
|
|
107
|
+
return [...entry.lines];
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const lines = stripFenceMarkers(entry.md.render(innerWidth));
|
|
111
|
+
entry.width = innerWidth;
|
|
112
|
+
entry.lines = lines;
|
|
113
|
+
return [...lines];
|
|
114
|
+
} catch {
|
|
115
|
+
entry.width = undefined;
|
|
116
|
+
entry.lines = undefined;
|
|
117
|
+
return [this.theme.fg("dim", PREVIEW_RENDER_FAILED_TEXT)];
|
|
81
118
|
}
|
|
82
|
-
return stripFenceMarkers(md.render(innerWidth));
|
|
83
119
|
}
|
|
84
120
|
|
|
85
121
|
invalidate(): void {
|
|
86
|
-
for (const
|
|
87
|
-
md.invalidate();
|
|
122
|
+
for (const entry of this.markdownCache.values()) {
|
|
123
|
+
entry.md.invalidate();
|
|
124
|
+
entry.width = undefined;
|
|
125
|
+
entry.lines = undefined;
|
|
88
126
|
}
|
|
89
|
-
this.cachedWidth = undefined;
|
|
90
127
|
}
|
|
91
128
|
}
|
|
@@ -197,6 +197,38 @@ export function crossTabLeftWidthWithDonation(
|
|
|
197
197
|
return Math.min(Math.max(labelDriven, slackDonation), Math.max(1, ceiling));
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Widths kept before the memo below is dropped wholesale. A frame asks for one;
|
|
202
|
+
* the rest of the entries are old terminal sizes from a resize drag.
|
|
203
|
+
*/
|
|
204
|
+
const PANE_WIDTH_MEMO_MAX_ENTRIES = 16;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Memoize a pane-width computation. For `crossTabLeftWidthWithDonation` and
|
|
208
|
+
* `crossTabPreviewBudget`, whose other inputs are fixed for the life of a
|
|
209
|
+
* questionnaire: both scan every tab, every option and every preview source
|
|
210
|
+
* line, and the pane width only changes on resize, yet the callers ask four to
|
|
211
|
+
* five times a frame (`focusedItemRowRange`, `naturalHeight`,
|
|
212
|
+
* `maxNaturalHeight`, `renderSideBySide`).
|
|
213
|
+
*/
|
|
214
|
+
export function memoizeByPaneWidth(
|
|
215
|
+
compute: (paneWidth: number) => number,
|
|
216
|
+
): (paneWidth: number) => number {
|
|
217
|
+
const cache = new Map<number, number>();
|
|
218
|
+
return (paneWidth: number): number => {
|
|
219
|
+
const hit = cache.get(paneWidth);
|
|
220
|
+
if (hit !== undefined) {
|
|
221
|
+
return hit;
|
|
222
|
+
}
|
|
223
|
+
const value = compute(paneWidth);
|
|
224
|
+
if (cache.size >= PANE_WIDTH_MEMO_MAX_ENTRIES) {
|
|
225
|
+
cache.clear();
|
|
226
|
+
}
|
|
227
|
+
cache.set(paneWidth, value);
|
|
228
|
+
return value;
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
200
232
|
/**
|
|
201
233
|
* Width allocation for side-by-side mode.
|
|
202
234
|
* `adaptiveLeft` is the pre-computed left column width (from `adaptiveLeftWidth`,
|
|
@@ -59,7 +59,13 @@ export interface PreviewPaneProps {
|
|
|
59
59
|
|
|
60
60
|
export interface PreviewPaneConfig {
|
|
61
61
|
question: QuestionData;
|
|
62
|
-
|
|
62
|
+
/**
|
|
63
|
+
* The frame's terminal width, snapshotted by `DialogView.render` before
|
|
64
|
+
* anything paints. Not a live read of `tui.terminal.columns`: two components
|
|
65
|
+
* deciding layout from two different readings of one frame is the bug this
|
|
66
|
+
* closes.
|
|
67
|
+
*/
|
|
68
|
+
getFrameTerminalWidth: () => number;
|
|
63
69
|
optionListView: OptionListView;
|
|
64
70
|
previewBlock: PreviewBlockRenderer;
|
|
65
71
|
}
|
|
@@ -74,11 +80,13 @@ export interface PreviewPaneConfig {
|
|
|
74
80
|
*
|
|
75
81
|
* `naturalHeight` and `maxNaturalHeight` query both children's heights; `render`
|
|
76
82
|
* combines them via `decideLayout` (mode threaded into both calls — never
|
|
77
|
-
* re-derived).
|
|
83
|
+
* re-derived). The probes ask `OptionListView.measureHeight`, not `render`, so
|
|
84
|
+
* one frame's three passes over the option list cost one wrap at each distinct
|
|
85
|
+
* width and no discarded rows.
|
|
78
86
|
*/
|
|
79
87
|
export class PreviewPane implements StatefulView<PreviewPaneProps>, Component {
|
|
80
88
|
private readonly question: QuestionData;
|
|
81
|
-
private readonly
|
|
89
|
+
private readonly getFrameTerminalWidth: () => number;
|
|
82
90
|
private readonly optionListView: OptionListView;
|
|
83
91
|
private readonly previewBlock: PreviewBlockRenderer;
|
|
84
92
|
private props: PreviewPaneProps;
|
|
@@ -93,7 +101,7 @@ export class PreviewPane implements StatefulView<PreviewPaneProps>, Component {
|
|
|
93
101
|
|
|
94
102
|
constructor(config: PreviewPaneConfig) {
|
|
95
103
|
this.question = config.question;
|
|
96
|
-
this.
|
|
104
|
+
this.getFrameTerminalWidth = config.getFrameTerminalWidth;
|
|
97
105
|
this.optionListView = config.optionListView;
|
|
98
106
|
this.previewBlock = config.previewBlock;
|
|
99
107
|
this.props = { notesVisible: false, selectedIndex: 0, focused: false, inputMode: false };
|
|
@@ -134,7 +142,7 @@ export class PreviewPane implements StatefulView<PreviewPaneProps>, Component {
|
|
|
134
142
|
return this.optionListView.render(width);
|
|
135
143
|
}
|
|
136
144
|
|
|
137
|
-
const mode = decideLayout(this.
|
|
145
|
+
const mode = decideLayout(this.getFrameTerminalWidth(), width);
|
|
138
146
|
if (mode === "side-by-side") {
|
|
139
147
|
return this.renderSideBySide(width, mode);
|
|
140
148
|
}
|
|
@@ -165,7 +173,7 @@ export class PreviewPane implements StatefulView<PreviewPaneProps>, Component {
|
|
|
165
173
|
if (this.props.inputMode) {
|
|
166
174
|
return this.optionListView.focusedItemRowRange(width);
|
|
167
175
|
}
|
|
168
|
-
const mode = decideLayout(this.
|
|
176
|
+
const mode = decideLayout(this.getFrameTerminalWidth(), width);
|
|
169
177
|
if (mode === "stacked") {
|
|
170
178
|
return this.optionListView.focusedItemRowRange(width);
|
|
171
179
|
}
|
|
@@ -176,20 +184,20 @@ export class PreviewPane implements StatefulView<PreviewPaneProps>, Component {
|
|
|
176
184
|
|
|
177
185
|
naturalHeight(width: number): number {
|
|
178
186
|
if (this.question.multiSelect === true) {
|
|
179
|
-
return this.optionListView.
|
|
187
|
+
return this.optionListView.measureHeight(width);
|
|
180
188
|
}
|
|
181
189
|
if (!this.previewBlock.hasAnyPreview()) {
|
|
182
|
-
return this.optionListView.
|
|
190
|
+
return this.optionListView.measureHeight(width);
|
|
183
191
|
}
|
|
184
192
|
// `inputMode`: height is the full-width option list only (no preview block) — preserves
|
|
185
193
|
// the `naturalHeight === render.length` parity invariant.
|
|
186
194
|
if (this.props.inputMode) {
|
|
187
|
-
return this.optionListView.
|
|
195
|
+
return this.optionListView.measureHeight(width);
|
|
188
196
|
}
|
|
189
|
-
const mode = decideLayout(this.
|
|
197
|
+
const mode = decideLayout(this.getFrameTerminalWidth(), width);
|
|
190
198
|
const adaptiveLeft = this.getAdaptiveLeft(width);
|
|
191
199
|
const { optionsWidth, previewWidth } = bodyWidths(width, mode, adaptiveLeft);
|
|
192
|
-
const optionsHeight = this.optionListView.
|
|
200
|
+
const optionsHeight = this.optionListView.measureHeight(optionsWidth);
|
|
193
201
|
const previewBlockHeight = this.previewBlock.blockHeight(
|
|
194
202
|
previewWidth,
|
|
195
203
|
this.props.selectedIndex,
|
|
@@ -203,20 +211,20 @@ export class PreviewPane implements StatefulView<PreviewPaneProps>, Component {
|
|
|
203
211
|
|
|
204
212
|
maxNaturalHeight(width: number): number {
|
|
205
213
|
if (this.question.multiSelect === true) {
|
|
206
|
-
return this.optionListView.
|
|
214
|
+
return this.optionListView.measureHeight(width);
|
|
207
215
|
}
|
|
208
216
|
if (!this.previewBlock.hasAnyPreview()) {
|
|
209
|
-
return this.optionListView.
|
|
217
|
+
return this.optionListView.measureHeight(width);
|
|
210
218
|
}
|
|
211
219
|
// `inputMode`: like naturalHeight — full-width option list only, so the
|
|
212
220
|
// `maxNaturalHeight >= naturalHeight` parity invariant holds (both equal the list height).
|
|
213
221
|
if (this.props.inputMode) {
|
|
214
|
-
return this.optionListView.
|
|
222
|
+
return this.optionListView.measureHeight(width);
|
|
215
223
|
}
|
|
216
|
-
const mode = decideLayout(this.
|
|
224
|
+
const mode = decideLayout(this.getFrameTerminalWidth(), width);
|
|
217
225
|
const adaptiveLeft = this.getAdaptiveLeft(width);
|
|
218
226
|
const { optionsWidth, previewWidth } = bodyWidths(width, mode, adaptiveLeft);
|
|
219
|
-
const optionsHeight = this.optionListView.
|
|
227
|
+
const optionsHeight = this.optionListView.measureHeight(optionsWidth);
|
|
220
228
|
let maxPreviewBlock = 0;
|
|
221
229
|
for (let i = 0; i < this.question.options.length; i++) {
|
|
222
230
|
const h = this.previewBlock.blockHeight(previewWidth, i, mode);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
3
|
import type { StatefulView } from "../stateful-view.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -16,12 +16,13 @@ import type { StatefulView } from "../stateful-view.js";
|
|
|
16
16
|
* than being appended, so a segment is the same width noted or not. Appending
|
|
17
17
|
* it would add one cell per tab, and four tabs at the schema's 16-character
|
|
18
18
|
* header limit already put this bar at 99 columns: four suffixes would push it
|
|
19
|
-
* to 103, past the 100 columns previews require.
|
|
20
|
-
* called with an empty ellipsis and drops the tail, so what would go missing is
|
|
21
|
-
* the Submit tab, silently.
|
|
19
|
+
* to 103, past the 100 columns previews require.
|
|
22
20
|
*/
|
|
23
21
|
export const NOTED_MARKER = "*";
|
|
24
22
|
|
|
23
|
+
/** Shown in place of the question tabs that did not fit. */
|
|
24
|
+
export const TAB_OVERFLOW_ELLIPSIS = "…";
|
|
25
|
+
|
|
25
26
|
export interface TabBarProps {
|
|
26
27
|
/** One per author-defined question, in order. */
|
|
27
28
|
tabs: ReadonlyArray<{ label: string; answered: boolean; active: boolean; noted: boolean }>;
|
|
@@ -44,6 +45,19 @@ export class TabBar implements StatefulView<TabBarProps> {
|
|
|
44
45
|
|
|
45
46
|
invalidate(): void {}
|
|
46
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Question tabs absorb the truncation; the Submit pill does not.
|
|
50
|
+
*
|
|
51
|
+
* Four 16-character headers put this bar past 99 columns, so a narrower
|
|
52
|
+
* terminal always drops something. Trimming the joined line from the right
|
|
53
|
+
* dropped Submit first, and a user who cannot see Submit cannot finish the
|
|
54
|
+
* questionnaire. The tail is reserved, the question tabs are trimmed to what
|
|
55
|
+
* is left, and `TAB_OVERFLOW_ELLIPSIS` marks the tabs that went missing.
|
|
56
|
+
*
|
|
57
|
+
* Below the tail's own width there is nothing left to reserve — the whole
|
|
58
|
+
* line is clipped, as before, so the caller's `visibleWidth <= width`
|
|
59
|
+
* invariant survives every terminal size.
|
|
60
|
+
*/
|
|
47
61
|
render(width: number): string[] {
|
|
48
62
|
const pieces: string[] = [" ← "];
|
|
49
63
|
|
|
@@ -61,10 +75,14 @@ export class TabBar implements StatefulView<TabBarProps> {
|
|
|
61
75
|
const submitStyled = this.props.submit.active
|
|
62
76
|
? this.theme.bg("selectedBg", this.theme.fg("text", submitText))
|
|
63
77
|
: this.theme.fg(this.props.submit.allAnswered ? "success" : "dim", submitText);
|
|
64
|
-
|
|
65
|
-
|
|
78
|
+
const tail = `${submitStyled} →`;
|
|
79
|
+
const tailWidth = visibleWidth(tail);
|
|
80
|
+
const head = pieces.join("");
|
|
66
81
|
|
|
67
|
-
|
|
68
|
-
|
|
82
|
+
if (width <= tailWidth) {
|
|
83
|
+
return [truncateToWidth(`${head}${tail}`, width, ""), ""];
|
|
84
|
+
}
|
|
85
|
+
const headLine = truncateToWidth(head, width - tailWidth, TAB_OVERFLOW_ELLIPSIS);
|
|
86
|
+
return [`${headLine}${tail}`, ""];
|
|
69
87
|
}
|
|
70
88
|
}
|