@dieulc/workflow 0.7.0 → 0.8.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 +110 -28
- package/commit-policy.ts +43 -0
- package/index.ts +2725 -1100
- package/model-default.ts +535 -0
- package/package.json +8 -2
- package/picker.ts +246 -0
- package/profiles.ts +73 -0
- package/questionnaire-layout.ts +340 -0
- package/questionnaire-view.ts +909 -0
- package/review-runtime.ts +260 -74
- package/review-ui.ts +27 -4
- package/review.ts +516 -16
- package/tab-editor.ts +147 -0
- package/tab-policy.ts +42 -0
- package/utils.ts +372 -69
package/picker.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filterable picker primitives shared by the `/profile` and `/role` overlays.
|
|
3
|
+
*
|
|
4
|
+
* pi-tui's `SelectList` deliberately has no text composer: `handleInput()` only
|
|
5
|
+
* understands Up/Down/Enter/Escape and silently drops printable keys, while
|
|
6
|
+
* `setFilter()` is a prefix match on `item.value`. pi's own `/model` selector
|
|
7
|
+
* solves this with an `Input` + `fuzzyFilter`; this module exposes the same
|
|
8
|
+
* shape as a reusable component because pi's selector lives in pi's internal
|
|
9
|
+
* `dist/` and is not a public export.
|
|
10
|
+
*
|
|
11
|
+
* Contract for `FilterableList.handleInput(data)`:
|
|
12
|
+
* - returns `true` when the key was consumed by the composer or list;
|
|
13
|
+
* - returns `false` for Enter (`tui.select.confirm`) and for the cancel key
|
|
14
|
+
* when the filter is already empty, so the caller can apply its own
|
|
15
|
+
* confirm / back / cancel semantics (Enter on a no-match list must stay
|
|
16
|
+
* inert instead of closing the overlay).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
Input,
|
|
21
|
+
Key,
|
|
22
|
+
SelectList,
|
|
23
|
+
fuzzyFilter,
|
|
24
|
+
getKeybindings,
|
|
25
|
+
matchesKey,
|
|
26
|
+
type SelectItem,
|
|
27
|
+
type SelectListLayoutOptions,
|
|
28
|
+
type SelectListTheme,
|
|
29
|
+
} from "@earendil-works/pi-tui";
|
|
30
|
+
|
|
31
|
+
/** A selectable row plus hidden text that the filter may match but never renders. */
|
|
32
|
+
export interface FilterableItem extends SelectItem {
|
|
33
|
+
/**
|
|
34
|
+
* Extra search-only text (e.g. a model's display name). `SelectList` ignores
|
|
35
|
+
* unknown properties, so this never affects the rendered columns.
|
|
36
|
+
*/
|
|
37
|
+
searchText?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The text a row is matched against: label, value, description and hidden text. */
|
|
41
|
+
export function pickerItemText(item: FilterableItem): string {
|
|
42
|
+
const parts: string[] = [];
|
|
43
|
+
const seen = new Set<string>();
|
|
44
|
+
for (const part of [
|
|
45
|
+
item.label,
|
|
46
|
+
item.value,
|
|
47
|
+
item.description,
|
|
48
|
+
item.searchText,
|
|
49
|
+
]) {
|
|
50
|
+
if (!part) continue;
|
|
51
|
+
const text = part.trim();
|
|
52
|
+
// Model rows use the ref as both label and value; dedupe so the fuzzy
|
|
53
|
+
// scorer does not weight the same string twice (and can still award the
|
|
54
|
+
// exact-match bonus).
|
|
55
|
+
if (!text || seen.has(text)) continue;
|
|
56
|
+
seen.add(text);
|
|
57
|
+
parts.push(text);
|
|
58
|
+
}
|
|
59
|
+
return parts.join(" ");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Fuzzy-filter `items` (best match first). A blank query returns every item.
|
|
64
|
+
* Matching uses `fuzzyFilter`, so whitespace- and slash-separated tokens all
|
|
65
|
+
* have to match; it is case-insensitive.
|
|
66
|
+
*/
|
|
67
|
+
export function filterPickerItems<T extends FilterableItem>(
|
|
68
|
+
items: readonly T[],
|
|
69
|
+
query: string,
|
|
70
|
+
getText: (item: T) => string = pickerItemText,
|
|
71
|
+
): T[] {
|
|
72
|
+
const all = [...items];
|
|
73
|
+
if (!query.trim()) return all;
|
|
74
|
+
return fuzzyFilter(all, query, getText);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface FilterableListOptions {
|
|
78
|
+
items: readonly FilterableItem[];
|
|
79
|
+
maxVisible: number;
|
|
80
|
+
theme: SelectListTheme;
|
|
81
|
+
layout?: SelectListLayoutOptions;
|
|
82
|
+
/** Seed the composer (e.g. a filter remembered across a re-opened picker). */
|
|
83
|
+
initialQuery?: string;
|
|
84
|
+
/** Initial cursor when the query is empty. */
|
|
85
|
+
initialIndex?: number;
|
|
86
|
+
/** Filter-text builder; defaults to `pickerItemText`. */
|
|
87
|
+
searchText?: (item: FilterableItem) => string;
|
|
88
|
+
/** Message shown when nothing matches (without the two-space indent). */
|
|
89
|
+
noMatchText?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A `SelectList` paired with an `Input` composer and fuzzy filtering.
|
|
94
|
+
*
|
|
95
|
+
* `SelectList` has no item setter, so the inner list is rebuilt whenever the
|
|
96
|
+
* filtered set changes. The wrapper itself is stable, which is what callers
|
|
97
|
+
* keep per tab so filter text and cursor survive tab switches.
|
|
98
|
+
*/
|
|
99
|
+
export class FilterableList {
|
|
100
|
+
private items: FilterableItem[];
|
|
101
|
+
private filtered: FilterableItem[];
|
|
102
|
+
private query: string;
|
|
103
|
+
private readonly maxVisible: number;
|
|
104
|
+
private readonly theme: SelectListTheme;
|
|
105
|
+
private readonly layout: SelectListLayoutOptions | undefined;
|
|
106
|
+
private readonly searchText: (item: FilterableItem) => string;
|
|
107
|
+
private readonly noMatchText: string;
|
|
108
|
+
private readonly input: Input;
|
|
109
|
+
private inner: SelectList;
|
|
110
|
+
|
|
111
|
+
constructor(opts: FilterableListOptions) {
|
|
112
|
+
this.items = [...opts.items];
|
|
113
|
+
this.maxVisible = opts.maxVisible;
|
|
114
|
+
this.theme = opts.theme;
|
|
115
|
+
this.layout = opts.layout;
|
|
116
|
+
this.searchText = opts.searchText ?? pickerItemText;
|
|
117
|
+
this.noMatchText = opts.noMatchText ?? "No matching entries";
|
|
118
|
+
this.input = new Input();
|
|
119
|
+
// Input.setValue() clamps the cursor instead of moving it to the end, so a
|
|
120
|
+
// remembered query is seeded by simulating typing. Otherwise the next key
|
|
121
|
+
// would be inserted at position 0.
|
|
122
|
+
if (opts.initialQuery) this.input.handleInput(opts.initialQuery);
|
|
123
|
+
this.query = this.input.getValue();
|
|
124
|
+
this.filtered = filterPickerItems(this.items, this.query, this.searchText);
|
|
125
|
+
this.inner = new SelectList(
|
|
126
|
+
this.filtered,
|
|
127
|
+
this.maxVisible,
|
|
128
|
+
this.theme,
|
|
129
|
+
this.layout,
|
|
130
|
+
);
|
|
131
|
+
if (!this.query && opts.initialIndex && opts.initialIndex > 0) {
|
|
132
|
+
this.inner.setSelectedIndex(opts.initialIndex);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Current filter text ("" when unfiltered). */
|
|
137
|
+
getQuery(): string {
|
|
138
|
+
return this.query;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** All rows, unfiltered. */
|
|
142
|
+
getItems(): readonly FilterableItem[] {
|
|
143
|
+
return this.items;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Rows matching the current query (the list the user is navigating). */
|
|
147
|
+
getFilteredItems(): readonly FilterableItem[] {
|
|
148
|
+
return this.filtered;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The highlighted row, or null when nothing matches. */
|
|
152
|
+
getSelectedItem(): SelectItem | null {
|
|
153
|
+
return this.inner.getSelectedItem();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Move the cursor (only meaningful while the query is empty). */
|
|
157
|
+
setSelectedIndex(index: number): void {
|
|
158
|
+
this.inner.setSelectedIndex(index);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Reset the filter and restore the full list. */
|
|
162
|
+
clearFilter(): void {
|
|
163
|
+
if (!this.query) return;
|
|
164
|
+
this.applyQuery("");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Replace the rows (e.g. Ctrl+O scope toggle). The query and the selected
|
|
169
|
+
* value are preserved when they still exist in the new set.
|
|
170
|
+
*/
|
|
171
|
+
setItems(items: readonly FilterableItem[]): void {
|
|
172
|
+
const previous = this.inner.getSelectedItem()?.value;
|
|
173
|
+
this.items = [...items];
|
|
174
|
+
this.refilter(previous);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** One composer line, sized to `width`. */
|
|
178
|
+
renderQuery(width: number): string[] {
|
|
179
|
+
return this.input.render(Math.max(1, width));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The filtered list, or a themed no-match line, sized to `width`. */
|
|
183
|
+
renderList(width: number): string[] {
|
|
184
|
+
if (this.filtered.length === 0) {
|
|
185
|
+
return [this.theme.noMatch(` ${this.noMatchText}`)];
|
|
186
|
+
}
|
|
187
|
+
return this.inner.render(Math.max(1, width));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Navigation + filter editing. Returns false for keys the caller owns:
|
|
192
|
+
* Enter, and Escape/Ctrl+C when the filter is empty.
|
|
193
|
+
*/
|
|
194
|
+
handleInput(data: string): boolean {
|
|
195
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
196
|
+
if (this.query) {
|
|
197
|
+
this.applyQuery("");
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
const kb = getKeybindings();
|
|
203
|
+
if (
|
|
204
|
+
kb.matches(data, "tui.select.up") ||
|
|
205
|
+
kb.matches(data, "tui.select.down")
|
|
206
|
+
) {
|
|
207
|
+
if (this.filtered.length > 0) this.inner.handleInput(data);
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
if (kb.matches(data, "tui.select.confirm")) return false;
|
|
211
|
+
this.input.handleInput(data);
|
|
212
|
+
const next = this.input.getValue();
|
|
213
|
+
if (next !== this.query) this.applyQuery(next);
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Apply a new query, resetting to the top match or restoring the previous row. */
|
|
218
|
+
private applyQuery(next: string): void {
|
|
219
|
+
const previous = this.inner.getSelectedItem()?.value;
|
|
220
|
+
this.query = next;
|
|
221
|
+
if (this.input.getValue() !== next) this.input.setValue(next);
|
|
222
|
+
this.refilter(previous);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private refilter(previous: string | undefined): void {
|
|
226
|
+
this.filtered = filterPickerItems(
|
|
227
|
+
this.items,
|
|
228
|
+
this.query,
|
|
229
|
+
this.searchText,
|
|
230
|
+
);
|
|
231
|
+
this.inner = new SelectList(
|
|
232
|
+
this.filtered,
|
|
233
|
+
this.maxVisible,
|
|
234
|
+
this.theme,
|
|
235
|
+
this.layout,
|
|
236
|
+
);
|
|
237
|
+
if (this.query) {
|
|
238
|
+
this.inner.setSelectedIndex(0);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const index = previous
|
|
242
|
+
? this.filtered.findIndex((item) => item.value === previous)
|
|
243
|
+
: -1;
|
|
244
|
+
this.inner.setSelectedIndex(Math.max(0, index));
|
|
245
|
+
}
|
|
246
|
+
}
|
package/profiles.ts
CHANGED
|
@@ -419,6 +419,42 @@ export function applyProfileOverlay(
|
|
|
419
419
|
|
|
420
420
|
// ── Display ──────────────────────────────────────────────────────────
|
|
421
421
|
|
|
422
|
+
/** One selectable model row for pi-tui's `SelectList`. */
|
|
423
|
+
export interface ModelPickerItem {
|
|
424
|
+
value: string;
|
|
425
|
+
label: string;
|
|
426
|
+
/**
|
|
427
|
+
* Hidden search text (the model's display name). Never rendered: `SelectList`
|
|
428
|
+
* only reads value/label/description, so this cannot reserve a column or
|
|
429
|
+
* truncate the ref.
|
|
430
|
+
*/
|
|
431
|
+
searchText?: string;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* The rows of a model picker: the **full** `provider/id` in the label, never a
|
|
436
|
+
* `description`.
|
|
437
|
+
*
|
|
438
|
+
* pi-tui reserves a fixed 32-cell primary column whenever a row has a
|
|
439
|
+
* description (and truncates the label to 30), which is what used to cut long
|
|
440
|
+
* refs such as `synthetic/hf:deepseek-ai/DeepSeek-V4.1-Flash-1017` even on a wide
|
|
441
|
+
* pane. Selection and scope state belong in the picker header, not in a column.
|
|
442
|
+
* The optional display name travels as `searchText`, so typing what `/model`
|
|
443
|
+
* shows (e.g. "DeepSeek V4.1 Flash") still matches without touching the row's
|
|
444
|
+
* visible shape.
|
|
445
|
+
*/
|
|
446
|
+
export function modelPickerItems(
|
|
447
|
+
entries: readonly { provider: string; id: string; name?: string }[],
|
|
448
|
+
): ModelPickerItem[] {
|
|
449
|
+
return entries.map((entry) => {
|
|
450
|
+
const ref = `${entry.provider}/${entry.id}`;
|
|
451
|
+
const name = entry.name?.trim();
|
|
452
|
+
return name
|
|
453
|
+
? { value: ref, label: ref, searchText: name }
|
|
454
|
+
: { value: ref, label: ref };
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
422
458
|
/** One-line summary: `⚡ cheap — 🧠 a/b (high) · 🔍 …` (for picker rows). */
|
|
423
459
|
export function formatProfileOneLine(p: Profile): string {
|
|
424
460
|
const parts = PROFILE_ROLES.map((r) => {
|
|
@@ -454,6 +490,24 @@ export function describeProfile(p: Profile): string {
|
|
|
454
490
|
return lines.join("\n");
|
|
455
491
|
}
|
|
456
492
|
|
|
493
|
+
// ── Picker flow ──────────────────────────────────────────────────────
|
|
494
|
+
|
|
495
|
+
/** Result of a `/profile` sub-flow (editor, default picker, action). */
|
|
496
|
+
export type ProfileFlowOutcome = "completed" | "cancelled" | "failed";
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Whether a finished `/profile` sub-flow closes the picker overlay.
|
|
500
|
+
*
|
|
501
|
+
* Only a completed action closes it. Cancelled or failed sub-flows stay on the
|
|
502
|
+
* profile list so the user can retry or choose another action, and the action
|
|
503
|
+
* menu's "back" entry is navigation, never a close.
|
|
504
|
+
*/
|
|
505
|
+
export function profileActionClosesPicker(
|
|
506
|
+
outcome: ProfileFlowOutcome | "back",
|
|
507
|
+
): boolean {
|
|
508
|
+
return outcome === "completed";
|
|
509
|
+
}
|
|
510
|
+
|
|
457
511
|
export function formatProfilesForDisplay(
|
|
458
512
|
config: ProfileConfig,
|
|
459
513
|
activeName?: string | null,
|
|
@@ -533,6 +587,25 @@ export function defaultProfileSettingPaths(
|
|
|
533
587
|
};
|
|
534
588
|
}
|
|
535
589
|
|
|
590
|
+
/**
|
|
591
|
+
* Which scope a `workflow.defaultProfile` write should target: the project file
|
|
592
|
+
* when it already defines the key (so an existing project value stays
|
|
593
|
+
* effective), otherwise global.
|
|
594
|
+
*
|
|
595
|
+
* Mirrors `modelDefaultWriteScope` in model-default.ts. Without this, an
|
|
596
|
+
* activation would write global while a project value kept shadowing it, and
|
|
597
|
+
* the write would silently do nothing.
|
|
598
|
+
*/
|
|
599
|
+
export function defaultProfileWriteScope(
|
|
600
|
+
cwd: string,
|
|
601
|
+
agentDir: string,
|
|
602
|
+
): "global" | "project" {
|
|
603
|
+
const paths = defaultProfileSettingPaths(cwd, agentDir);
|
|
604
|
+
return extractDefaultProfile(readSettingsSafe(paths.project)) !== undefined
|
|
605
|
+
? "project"
|
|
606
|
+
: "global";
|
|
607
|
+
}
|
|
608
|
+
|
|
536
609
|
/**
|
|
537
610
|
* Extract `workflow.defaultProfile` (a profile name string) from a raw
|
|
538
611
|
* settings object. Anything else (absent, non-string, blank) → undefined.
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Questionnaire viewport geometry and scroll state.
|
|
3
|
+
*
|
|
4
|
+
* This module is deliberately pure: no Pi runtime imports and no pi-tui
|
|
5
|
+
* imports. The questionnaire view is rendered as a flat component (the host's
|
|
6
|
+
* input dock treats the editor container as a leaf, so an inner layout node
|
|
7
|
+
* would be ignored), which means the view must bound its own height and slice
|
|
8
|
+
* its panes by hand. Keeping that math here makes it deterministic and easy to
|
|
9
|
+
* unit-test.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Hard cap for the questionnaire's rendered height. */
|
|
13
|
+
export const QUESTIONNAIRE_MAX_TOTAL_ROWS = 32;
|
|
14
|
+
|
|
15
|
+
/** Share of the terminal height the bottom-sheet overlay is allowed to use. */
|
|
16
|
+
export const QUESTIONNAIRE_MAX_FRACTION = 0.8;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The `overlayOptions.maxHeight` string used by the questionnaire host.
|
|
20
|
+
* Keep in sync with `QUESTIONNAIRE_MAX_FRACTION` (the view caps at
|
|
21
|
+
* `floor(rows * fraction) - 1`, so it is always inside the overlay).
|
|
22
|
+
*/
|
|
23
|
+
export const QUESTIONNAIRE_OVERLAY_MAX_HEIGHT = "80%";
|
|
24
|
+
|
|
25
|
+
/** A question longer than this is not pinned above the scroll body. */
|
|
26
|
+
export const QUESTIONNAIRE_MAX_PINNED_QUESTION_LINES = 3;
|
|
27
|
+
|
|
28
|
+
/** Maximum logical answer lines shown under the freeform row. */
|
|
29
|
+
export const QUESTIONNAIRE_MAX_ANSWER_PREVIEW_LINES = 2;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Maximum number of lines the questionnaire may render for a terminal with
|
|
33
|
+
* `termRows` rows. Always at least 1 and always at most the overlay's
|
|
34
|
+
* `floor(termRows * 0.8)` (minus a one-line safety margin), so the overlay's
|
|
35
|
+
* own `maxHeight` slice can never cut the pinned choices.
|
|
36
|
+
*/
|
|
37
|
+
export function questionnaireMaxRows(termRows: number): number {
|
|
38
|
+
// A zero/undefined/negative terminal height is not meaningful; fall back to
|
|
39
|
+
// a typical 24-row terminal instead of collapsing the UI to one line.
|
|
40
|
+
const raw = Number.isFinite(termRows) && termRows > 0 ? termRows : 24;
|
|
41
|
+
const rows = Math.max(1, Math.floor(raw));
|
|
42
|
+
const overlayMax = Math.max(
|
|
43
|
+
1,
|
|
44
|
+
Math.floor(rows * QUESTIONNAIRE_MAX_FRACTION),
|
|
45
|
+
);
|
|
46
|
+
return Math.max(
|
|
47
|
+
1,
|
|
48
|
+
Math.min(QUESTIONNAIRE_MAX_TOTAL_ROWS, overlayMax - 1),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface QuestionnaireBudgetInput {
|
|
53
|
+
termRows: number;
|
|
54
|
+
/** Height of the top border / tab row. */
|
|
55
|
+
topHeight: number;
|
|
56
|
+
/** Height of a pinned short question (including its trailing blank). */
|
|
57
|
+
pinnedHeight: number;
|
|
58
|
+
/** Height of the help / status / bottom border block. */
|
|
59
|
+
bottomHeight: number;
|
|
60
|
+
/** Full height of the question/context (or answers summary) body. */
|
|
61
|
+
questionContentHeight: number;
|
|
62
|
+
/** Full height of the option list (or editor pane). */
|
|
63
|
+
optionsContentHeight: number;
|
|
64
|
+
/** Input mode: give the editor pane every row it can use. */
|
|
65
|
+
editorPriority?: boolean;
|
|
66
|
+
minQuestionRows?: number;
|
|
67
|
+
minOptionRows?: number;
|
|
68
|
+
/** Optional lower cap (never raises the host cap). */
|
|
69
|
+
maxTotalRows?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface QuestionnaireBudget {
|
|
73
|
+
/** top + pinned + question + options + bottom. */
|
|
74
|
+
total: number;
|
|
75
|
+
/** Viewport height for the question/context body. */
|
|
76
|
+
question: number;
|
|
77
|
+
/** Viewport height for the option list / editor pane. */
|
|
78
|
+
options: number;
|
|
79
|
+
questionOverflow: boolean;
|
|
80
|
+
optionsOverflow: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function toSize(value: number | undefined, fallback = 0): number {
|
|
84
|
+
if (!Number.isFinite(value as number)) return fallback;
|
|
85
|
+
return Math.max(0, Math.floor(value as number));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function clamp(value: number, min: number, max: number): number {
|
|
89
|
+
return Math.max(min, Math.min(max, value));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Allocate the available rows between the scrolling question body and the
|
|
94
|
+
* pinned option list.
|
|
95
|
+
*
|
|
96
|
+
* The options are the controls, so they are kept intact whenever they fit
|
|
97
|
+
* alongside the minimum question height. When both bodies overflow, the
|
|
98
|
+
* question gets roughly 40% and the options the rest; the selected option is
|
|
99
|
+
* kept visible by `keepRangeVisible`, and the option list scrolls when even
|
|
100
|
+
* that is not enough. The returned `total` is the exact rendered height
|
|
101
|
+
* assuming the caller has already trimmed its chrome below `questionnaireMaxRows`.
|
|
102
|
+
*/
|
|
103
|
+
export function computeQuestionnaireBudget(
|
|
104
|
+
input: QuestionnaireBudgetInput,
|
|
105
|
+
): QuestionnaireBudget {
|
|
106
|
+
const hostCap = questionnaireMaxRows(input.termRows);
|
|
107
|
+
const cap = Math.min(
|
|
108
|
+
hostCap,
|
|
109
|
+
toSize(input.maxTotalRows, QUESTIONNAIRE_MAX_TOTAL_ROWS),
|
|
110
|
+
);
|
|
111
|
+
const top = toSize(input.topHeight);
|
|
112
|
+
const pinned = toSize(input.pinnedHeight);
|
|
113
|
+
const bottom = toSize(input.bottomHeight);
|
|
114
|
+
const fixed = top + pinned + bottom;
|
|
115
|
+
const avail = Math.max(0, cap - fixed);
|
|
116
|
+
const questionContent = toSize(input.questionContentHeight);
|
|
117
|
+
const optionsContent = toSize(input.optionsContentHeight);
|
|
118
|
+
|
|
119
|
+
if (avail === 0) {
|
|
120
|
+
return {
|
|
121
|
+
total: fixed,
|
|
122
|
+
question: 0,
|
|
123
|
+
options: 0,
|
|
124
|
+
questionOverflow: questionContent > 0,
|
|
125
|
+
optionsOverflow: optionsContent > 0,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const minQuestion = Math.min(
|
|
130
|
+
avail,
|
|
131
|
+
toSize(input.minQuestionRows, 2),
|
|
132
|
+
);
|
|
133
|
+
const minOptions = Math.min(avail, toSize(input.minOptionRows, 2));
|
|
134
|
+
|
|
135
|
+
let question: number;
|
|
136
|
+
let options: number;
|
|
137
|
+
|
|
138
|
+
if (input.editorPriority) {
|
|
139
|
+
// The editor is the thing the user is typing into: show all of it that
|
|
140
|
+
// fits, then give any remainder to the question/context.
|
|
141
|
+
options = Math.min(optionsContent, avail);
|
|
142
|
+
question = Math.min(questionContent, Math.max(0, avail - options));
|
|
143
|
+
} else if (optionsContent <= Math.max(0, avail - minQuestion)) {
|
|
144
|
+
// The choices fit while reserving the minimum question area.
|
|
145
|
+
options = optionsContent;
|
|
146
|
+
question = Math.min(questionContent, Math.max(0, avail - options));
|
|
147
|
+
} else {
|
|
148
|
+
// Both need to scroll. The choices keep a larger share (they are the
|
|
149
|
+
// controls); the selected row is auto-scrolled by the caller.
|
|
150
|
+
question = Math.min(
|
|
151
|
+
questionContent,
|
|
152
|
+
Math.max(minQuestion, Math.floor(avail * 0.4)),
|
|
153
|
+
);
|
|
154
|
+
options = Math.min(optionsContent, Math.max(0, avail - question));
|
|
155
|
+
// If the question still has slack, honor the minimum option height so
|
|
156
|
+
// the pinned choice block does not collapse to a single clipped row.
|
|
157
|
+
const targetOptions = Math.min(minOptions, optionsContent);
|
|
158
|
+
if (options < targetOptions && question > minQuestion) {
|
|
159
|
+
const shift = Math.min(question - minQuestion, targetOptions - options);
|
|
160
|
+
question -= shift;
|
|
161
|
+
options += shift;
|
|
162
|
+
}
|
|
163
|
+
if (options === 0 && optionsContent > 0 && avail > 0) {
|
|
164
|
+
options = Math.min(1, avail);
|
|
165
|
+
question = Math.max(0, avail - options);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Defensive clamps: never exceed the available content/space and never
|
|
170
|
+
// return a fractional or negative height.
|
|
171
|
+
question = clamp(question, 0, Math.min(questionContent, avail));
|
|
172
|
+
options = clamp(options, 0, Math.min(optionsContent, avail - question));
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
total: fixed + question + options,
|
|
176
|
+
question,
|
|
177
|
+
options,
|
|
178
|
+
questionOverflow: question < questionContent,
|
|
179
|
+
optionsOverflow: options < optionsContent,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** A manually-sliced viewport: which line range of a body is visible. */
|
|
184
|
+
export interface ScrollPane {
|
|
185
|
+
scrollTop: number;
|
|
186
|
+
contentHeight: number;
|
|
187
|
+
viewportHeight: number;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function createScrollPane(): ScrollPane {
|
|
191
|
+
return { scrollTop: 0, contentHeight: 0, viewportHeight: 0 };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Largest valid `scrollTop` for the pane's current content/viewport. */
|
|
195
|
+
export function maxScrollTop(pane: ScrollPane): number {
|
|
196
|
+
const content = toSize(pane.contentHeight);
|
|
197
|
+
const viewport = toSize(pane.viewportHeight);
|
|
198
|
+
return Math.max(0, content - viewport);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Recompute the pane's dimensions and clamp `scrollTop` into range. */
|
|
202
|
+
export function updateScrollPane(
|
|
203
|
+
pane: ScrollPane,
|
|
204
|
+
contentHeight: number,
|
|
205
|
+
viewportHeight: number,
|
|
206
|
+
): void {
|
|
207
|
+
pane.contentHeight = toSize(contentHeight);
|
|
208
|
+
pane.viewportHeight = toSize(viewportHeight);
|
|
209
|
+
const top = Number.isFinite(pane.scrollTop) ? pane.scrollTop : 0;
|
|
210
|
+
pane.scrollTop = clamp(Math.floor(top), 0, maxScrollTop(pane));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Set the pane's scroll position, clamped to its current limits. */
|
|
214
|
+
export function scrollPaneTo(pane: ScrollPane, scrollTop: number): void {
|
|
215
|
+
const next = Number.isFinite(scrollTop) ? Math.floor(scrollTop) : 0;
|
|
216
|
+
pane.scrollTop = clamp(next, 0, maxScrollTop(pane));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Scroll the pane by `delta` lines (positive = down). */
|
|
220
|
+
export function scrollPaneBy(pane: ScrollPane, delta: number): void {
|
|
221
|
+
const step = Number.isFinite(delta) ? Math.trunc(delta) : 0;
|
|
222
|
+
scrollPaneTo(pane, pane.scrollTop + step);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Inclusive-exclusive line range in a body. */
|
|
226
|
+
export interface LineRange {
|
|
227
|
+
start: number;
|
|
228
|
+
end: number;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Return the scrollTop that keeps `range` visible.
|
|
233
|
+
*
|
|
234
|
+
* - A range that fits is fully shown.
|
|
235
|
+
* - A range taller than the viewport keeps the current scrollTop when it is
|
|
236
|
+
* already inside the range, so a long selected description can be read
|
|
237
|
+
* instead of snapping back to its first line.
|
|
238
|
+
* - Out-of-range values are clamped to the pane.
|
|
239
|
+
*/
|
|
240
|
+
export function keepRangeVisible(pane: ScrollPane, range: LineRange): number {
|
|
241
|
+
const content = toSize(pane.contentHeight);
|
|
242
|
+
const viewport = toSize(pane.viewportHeight);
|
|
243
|
+
const maxTop = Math.max(0, content - viewport);
|
|
244
|
+
const current = clamp(toSize(pane.scrollTop), 0, maxTop);
|
|
245
|
+
|
|
246
|
+
if (viewport <= 0 || content <= 0) return 0;
|
|
247
|
+
|
|
248
|
+
const rawStart = Math.min(range.start, range.end);
|
|
249
|
+
const rawEnd = Math.max(range.start, range.end);
|
|
250
|
+
const start = clamp(toSize(rawStart), 0, content);
|
|
251
|
+
const end = clamp(toSize(rawEnd), start, content);
|
|
252
|
+
|
|
253
|
+
// Already fully visible.
|
|
254
|
+
if (start >= current && end <= current + viewport) return current;
|
|
255
|
+
|
|
256
|
+
// Taller than the viewport: stay inside the range if possible, otherwise
|
|
257
|
+
// show its first line.
|
|
258
|
+
if (end - start > viewport) {
|
|
259
|
+
if (current >= start && current <= end - viewport) return current;
|
|
260
|
+
return clamp(start, 0, maxTop);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Above the viewport.
|
|
264
|
+
if (start < current) return clamp(start, 0, maxTop);
|
|
265
|
+
|
|
266
|
+
// Below the viewport; scrolling to `end - viewport` shows the whole range.
|
|
267
|
+
if (end > current + viewport) return clamp(end - viewport, 0, maxTop);
|
|
268
|
+
|
|
269
|
+
return current;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Parse a wheel event into a line delta (+3 = down, -3 = up, 0 = not a wheel).
|
|
274
|
+
* Supports SGR (`\x1b[<64;x;yM`) and X10 (`\x1b[M` + 3 bytes) sequences.
|
|
275
|
+
* Horizontal wheel events (66/67) are ignored.
|
|
276
|
+
*/
|
|
277
|
+
export function parseWheelScroll(data: string): number {
|
|
278
|
+
if (!data) return 0;
|
|
279
|
+
|
|
280
|
+
const sgr = /\x1b\[<(\d+);\d+;\d+[Mm]/.exec(data);
|
|
281
|
+
if (sgr) {
|
|
282
|
+
const button = Number(sgr[1]);
|
|
283
|
+
if (button === 64) return -3;
|
|
284
|
+
if (button === 65) return 3;
|
|
285
|
+
return 0;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const x10 = data.indexOf("\x1b[M");
|
|
289
|
+
if (x10 >= 0 && data.length >= x10 + 6) {
|
|
290
|
+
const button = data.charCodeAt(x10 + 3) - 32;
|
|
291
|
+
if (button === 64) return -3;
|
|
292
|
+
if (button === 65) return 3;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Human-readable position, e.g. `1–12/42` (empty content → `0/0`). */
|
|
299
|
+
export function formatScrollPosition(pane: ScrollPane): string {
|
|
300
|
+
const content = toSize(pane.contentHeight);
|
|
301
|
+
if (content <= 0) return "0/0";
|
|
302
|
+
const viewport = toSize(pane.viewportHeight);
|
|
303
|
+
const maxTop = Math.max(0, content - viewport);
|
|
304
|
+
const top = clamp(toSize(pane.scrollTop), 0, maxTop);
|
|
305
|
+
const first = Math.min(content, top + 1);
|
|
306
|
+
const last = Math.min(content, top + Math.max(1, viewport));
|
|
307
|
+
return `${first}–${last}/${content}`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Split text into at most `maxLines` display lines for the inline
|
|
312
|
+
* "↳ Your answer" preview, with a rough character wrap. The caller should
|
|
313
|
+
* still truncate/format each line to the real terminal width; this helper only
|
|
314
|
+
* guarantees the preview's line cap and reports how many lines were hidden.
|
|
315
|
+
*/
|
|
316
|
+
export function previewTextLines(
|
|
317
|
+
text: string,
|
|
318
|
+
width: number,
|
|
319
|
+
maxLines: number,
|
|
320
|
+
): { lines: string[]; hidden: number } {
|
|
321
|
+
const safeWidth = Math.max(1, toSize(width, 1));
|
|
322
|
+
const max = Math.max(0, toSize(maxLines, 0));
|
|
323
|
+
const wrapped: string[] = [];
|
|
324
|
+
|
|
325
|
+
for (const raw of String(text ?? "").split(/\r?\n/)) {
|
|
326
|
+
if (raw.length === 0) {
|
|
327
|
+
wrapped.push("");
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
let rest = raw;
|
|
331
|
+
while (rest.length > safeWidth) {
|
|
332
|
+
wrapped.push(rest.slice(0, safeWidth));
|
|
333
|
+
rest = rest.slice(safeWidth);
|
|
334
|
+
}
|
|
335
|
+
wrapped.push(rest);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const lines = wrapped.slice(0, max);
|
|
339
|
+
return { lines, hidden: Math.max(0, wrapped.length - lines.length) };
|
|
340
|
+
}
|