@dieulc/workflow 0.7.0 → 0.8.1

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