@dieulc/workflow 0.6.1 → 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 +126 -40
- package/commit-policy.ts +43 -0
- package/edit-policy.ts +193 -14
- package/index.ts +3090 -1148
- package/model-default.ts +535 -0
- package/package.json +10 -3
- package/picker.ts +246 -0
- package/profiles.ts +98 -1
- 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
|
@@ -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
|
+
}
|