@thegitai/cli 1.0.0-preview.5 → 1.0.0-preview.50

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.
Files changed (75) hide show
  1. package/README.md +93 -4
  2. package/dist/bin/ai.js +142 -112
  3. package/dist/bin/browser-host.js +265 -0
  4. package/dist/src/agent-mode.js +32 -7
  5. package/dist/src/api/auth.js +10 -4
  6. package/dist/src/api/browser-login.js +149 -37
  7. package/dist/src/api/chat.js +680 -166
  8. package/dist/src/api/contracts.js +55 -1
  9. package/dist/src/api/default-host.js +1 -0
  10. package/dist/src/api/http.js +151 -7
  11. package/dist/src/api/models.js +29 -10
  12. package/dist/src/api/startup-handshake.js +36 -0
  13. package/dist/src/api/turn-recovery.js +189 -0
  14. package/dist/src/background-jobs.js +4 -2
  15. package/dist/src/browser/bridge.js +262 -0
  16. package/dist/src/browser/framing.js +42 -0
  17. package/dist/src/browser/native-host.js +219 -0
  18. package/dist/src/browser/protocol.js +46 -0
  19. package/dist/src/browser/session-bridge.js +130 -0
  20. package/dist/src/cli-args.js +19 -5
  21. package/dist/src/client-environment.js +2 -0
  22. package/dist/src/core/clipboard.js +7 -13
  23. package/dist/src/core/image-limits.js +56 -0
  24. package/dist/src/core/image-path-extractor.js +70 -3
  25. package/dist/src/core/session-image-store.js +246 -0
  26. package/dist/src/edit-journal.js +33 -2
  27. package/dist/src/executor.js +25 -3
  28. package/dist/src/help-text.js +112 -21
  29. package/dist/src/patcher.js +43 -4
  30. package/dist/src/permissions.js +288 -0
  31. package/dist/src/preferences.js +39 -0
  32. package/dist/src/quota-notice.js +35 -0
  33. package/dist/src/session-safety.js +0 -12
  34. package/dist/src/session-store.js +121 -20
  35. package/dist/src/session.js +19 -4
  36. package/dist/src/signin.js +58 -0
  37. package/dist/src/tool-executor.js +13 -46
  38. package/dist/src/tools/browser.js +628 -0
  39. package/dist/src/tools/delete-file.js +20 -4
  40. package/dist/src/tools/index.js +32 -10
  41. package/dist/src/tools/patch-file.js +12 -26
  42. package/dist/src/tools/read-image-file.js +85 -0
  43. package/dist/src/tools/replace-document-text.js +28 -18
  44. package/dist/src/tools/restore-checkpoint.js +0 -1
  45. package/dist/src/tools/run-command.js +14 -71
  46. package/dist/src/tools/run-node-script.js +12 -81
  47. package/dist/src/tools/save-generated-image.js +120 -0
  48. package/dist/src/tools/str-replace.js +12 -26
  49. package/dist/src/tools/undo-edit.js +1 -6
  50. package/dist/src/tools/write-file.js +67 -11
  51. package/dist/src/ui/prompt-history-store.js +1 -1
  52. package/dist/src/ui/repl.js +949 -279
  53. package/dist/src/ui/slash-commands.js +121 -0
  54. package/dist/src/ui/tui/attention.js +25 -0
  55. package/dist/src/ui/tui/bridge.js +38 -5
  56. package/dist/src/ui/tui/build-frame.js +686 -223
  57. package/dist/src/ui/tui/composer-layout.js +76 -0
  58. package/dist/src/ui/tui/desktop-notification.js +118 -0
  59. package/dist/src/ui/tui/markdown-render.js +11 -2
  60. package/dist/src/ui/tui/shell-input.js +256 -73
  61. package/dist/src/ui/tui/shortcuts.js +61 -0
  62. package/dist/src/ui/tui/terminal-theme.js +28 -0
  63. package/dist/src/ui/tui/user-input.js +593 -0
  64. package/dist/src/update-check.js +205 -0
  65. package/dist/src/usage.js +5 -2
  66. package/dist/src/utils.js +13 -0
  67. package/dist/src/version.js +52 -2
  68. package/package.json +22 -9
  69. package/dist/src/project-index.js +0 -233
  70. package/dist/src/tools/code-intel.js +0 -472
  71. package/dist/src/tools/find-symbol.js +0 -70
  72. package/dist/src/tools/hover-symbol.js +0 -95
  73. package/dist/src/tools/list-symbols.js +0 -55
  74. package/dist/src/tools/search-code.js +0 -37
  75. package/dist/src/tools/signature-help.js +0 -118
@@ -0,0 +1,593 @@
1
+ import { displayWidth, line, plainLine, sliceToWidth, span, wrapText, } from './text.js';
2
+ const OTHER_CURSOR = '$other';
3
+ const SUBMIT_CURSOR = '$submit';
4
+ const ACCENT_COLOR = 'cyan';
5
+ const SCROLL_PAGE_ROWS = 4;
6
+ function currentQuestion(state) {
7
+ return (state.questions.find((question) => question.id === state.questionId) ??
8
+ state.questions[0]);
9
+ }
10
+ function questionIndex(state) {
11
+ return Math.max(0, state.questions.findIndex((question) => question.id === state.questionId));
12
+ }
13
+ function defaultCursor(question) {
14
+ return question.options[0]?.id ?? OTHER_CURSOR;
15
+ }
16
+ function cursorForQuestion(state, question) {
17
+ return state.cursorByQuestionId[question.id] ?? defaultCursor(question);
18
+ }
19
+ function cursorRows(state, question) {
20
+ const rows = [...question.options.map((option) => option.id), OTHER_CURSOR];
21
+ if (questionIndex(state) === state.questions.length - 1) {
22
+ rows.push(SUBMIT_CURSOR);
23
+ }
24
+ return rows;
25
+ }
26
+ function setQuestion(state, index) {
27
+ const question = state.questions[index];
28
+ if (!question)
29
+ return state;
30
+ return {
31
+ ...state,
32
+ questionId: question.id,
33
+ contentScrollAnchor: null,
34
+ contentScrollOffset: 0,
35
+ noteOpen: false,
36
+ noteCursor: (state.customText[question.id] ?? '').length,
37
+ validationMessage: '',
38
+ };
39
+ }
40
+ function setCursor(state, question, cursor) {
41
+ return {
42
+ ...state,
43
+ contentScrollAnchor: cursor,
44
+ cursorByQuestionId: {
45
+ ...state.cursorByQuestionId,
46
+ [question.id]: cursor,
47
+ },
48
+ validationMessage: '',
49
+ };
50
+ }
51
+ function moveCursor(state, delta) {
52
+ const question = currentQuestion(state);
53
+ const rows = cursorRows(state, question);
54
+ const current = cursorForQuestion(state, question);
55
+ const index = Math.max(0, rows.indexOf(current));
56
+ const next = Math.max(0, Math.min(index + delta, rows.length - 1));
57
+ return setCursor(state, question, rows[next]);
58
+ }
59
+ function selectedIds(state, question) {
60
+ return state.selectedOptionIds[question.id] ?? [];
61
+ }
62
+ function selectOption(state, optionId) {
63
+ const question = currentQuestion(state);
64
+ const current = selectedIds(state, question);
65
+ const nextSelected = question.multiSelect
66
+ ? current.includes(optionId)
67
+ ? current.filter((id) => id !== optionId)
68
+ : question.options
69
+ .map((option) => option.id)
70
+ .filter((id) => id === optionId || current.includes(id))
71
+ : [optionId];
72
+ let next = {
73
+ ...setCursor(state, question, optionId),
74
+ selectedOptionIds: {
75
+ ...state.selectedOptionIds,
76
+ [question.id]: nextSelected,
77
+ },
78
+ customText: question.multiSelect
79
+ ? state.customText
80
+ : {
81
+ ...state.customText,
82
+ [question.id]: '',
83
+ },
84
+ };
85
+ if (!question.multiSelect) {
86
+ const index = questionIndex(state);
87
+ next =
88
+ index < state.questions.length - 1
89
+ ? setQuestion(next, index + 1)
90
+ : setCursor(next, question, SUBMIT_CURSOR);
91
+ }
92
+ return next;
93
+ }
94
+ function normalizeNoteText(value) {
95
+ return value.replace(/[\r\n\t]+/g, ' ').replace(/ {2,}/g, ' ');
96
+ }
97
+ function previousCharacterBoundary(value, cursor) {
98
+ const previous = Array.from(value.slice(0, cursor)).at(-1);
99
+ return previous ? cursor - previous.length : 0;
100
+ }
101
+ function nextCharacterBoundary(value, cursor) {
102
+ const next = Array.from(value.slice(cursor))[0];
103
+ return next ? cursor + next.length : value.length;
104
+ }
105
+ function openCustomTextEditor(state) {
106
+ const question = currentQuestion(state);
107
+ return {
108
+ ...setCursor(state, question, OTHER_CURSOR),
109
+ selectedOptionIds: question.multiSelect
110
+ ? state.selectedOptionIds
111
+ : {
112
+ ...state.selectedOptionIds,
113
+ [question.id]: [],
114
+ },
115
+ noteOpen: true,
116
+ noteCursor: (state.customText[question.id] ?? '').length,
117
+ };
118
+ }
119
+ function insertNote(state, insertedText) {
120
+ const nextState = openCustomTextEditor(state);
121
+ const question = currentQuestion(nextState);
122
+ const current = nextState.customText[question.id] ?? '';
123
+ const inserted = normalizeNoteText(insertedText);
124
+ if (!inserted) {
125
+ return nextState;
126
+ }
127
+ const noteCursor = Math.max(0, Math.min(nextState.noteCursor, current.length));
128
+ const nextText = `${current.slice(0, noteCursor)}${inserted}${current.slice(noteCursor)}`;
129
+ return {
130
+ ...nextState,
131
+ customText: {
132
+ ...nextState.customText,
133
+ [question.id]: nextText,
134
+ },
135
+ noteCursor: noteCursor + inserted.length,
136
+ };
137
+ }
138
+ function editOpenNote(state, event) {
139
+ const question = currentQuestion(state);
140
+ const current = state.customText[question.id] ?? '';
141
+ const cursor = Math.max(0, Math.min(state.noteCursor, current.length));
142
+ if (event.escape) {
143
+ return { ...state, noteOpen: false };
144
+ }
145
+ if (event.returnKey) {
146
+ const closed = { ...state, noteOpen: false };
147
+ if (question.multiSelect || !current.trim()) {
148
+ return closed;
149
+ }
150
+ const index = questionIndex(closed);
151
+ return index < closed.questions.length - 1
152
+ ? setQuestion(closed, index + 1)
153
+ : setCursor(closed, question, SUBMIT_CURSOR);
154
+ }
155
+ if (event.tab)
156
+ return state;
157
+ if ((event.upArrow || event.downArrow) && !current.trim()) {
158
+ return moveCursor({
159
+ ...state,
160
+ customText: {
161
+ ...state.customText,
162
+ [question.id]: '',
163
+ },
164
+ noteOpen: false,
165
+ noteCursor: 0,
166
+ }, event.upArrow ? -1 : 1);
167
+ }
168
+ if (event.leftArrow) {
169
+ return {
170
+ ...state,
171
+ noteCursor: previousCharacterBoundary(current, cursor),
172
+ };
173
+ }
174
+ if (event.rightArrow) {
175
+ return {
176
+ ...state,
177
+ noteCursor: nextCharacterBoundary(current, cursor),
178
+ };
179
+ }
180
+ if (event.home) {
181
+ return { ...state, noteCursor: 0 };
182
+ }
183
+ if (event.end) {
184
+ return { ...state, noteCursor: current.length };
185
+ }
186
+ if (event.backspace && cursor > 0) {
187
+ const previous = previousCharacterBoundary(current, cursor);
188
+ return {
189
+ ...state,
190
+ customText: {
191
+ ...state.customText,
192
+ [question.id]: `${current.slice(0, previous)}${current.slice(cursor)}`,
193
+ },
194
+ noteCursor: previous,
195
+ };
196
+ }
197
+ if (event.delete && cursor < current.length) {
198
+ const next = nextCharacterBoundary(current, cursor);
199
+ return {
200
+ ...state,
201
+ customText: {
202
+ ...state.customText,
203
+ [question.id]: `${current.slice(0, cursor)}${current.slice(next)}`,
204
+ },
205
+ };
206
+ }
207
+ if (!event.ctrl && !event.meta && event.input) {
208
+ return insertNote(state, event.input);
209
+ }
210
+ return state;
211
+ }
212
+ function questionAnswered(state, question) {
213
+ return (selectedIds(state, question).length > 0 ||
214
+ Boolean((state.customText[question.id] ?? '').trim()));
215
+ }
216
+ function submitResult(state) {
217
+ const missing = state.questions.find((question) => !questionAnswered(state, question));
218
+ if (missing) {
219
+ const next = setQuestion(state, state.questions.indexOf(missing));
220
+ return {
221
+ state: {
222
+ ...next,
223
+ validationMessage: 'Choose an option or add details before submitting.',
224
+ },
225
+ };
226
+ }
227
+ return {
228
+ state,
229
+ result: {
230
+ status: 'submitted',
231
+ answers: Object.fromEntries(state.questions.map((question) => {
232
+ const customText = (state.customText[question.id] ?? '').trim();
233
+ return [
234
+ question.id,
235
+ {
236
+ selectedOptionIds: selectedIds(state, question),
237
+ ...(customText ? { customText } : {}),
238
+ },
239
+ ];
240
+ })),
241
+ },
242
+ };
243
+ }
244
+ export function createUserInputPromptState(questions, returnStatus) {
245
+ const first = questions[0];
246
+ return {
247
+ questions,
248
+ questionId: first.id,
249
+ cursorByQuestionId: Object.fromEntries(questions.map((question) => [question.id, defaultCursor(question)])),
250
+ selectedOptionIds: Object.fromEntries(questions.map((question) => [question.id, []])),
251
+ customText: Object.fromEntries(questions.map((question) => [question.id, ''])),
252
+ contentScrollAnchor: null,
253
+ contentScrollOffset: 0,
254
+ noteOpen: false,
255
+ noteCursor: 0,
256
+ returnStatus,
257
+ validationMessage: '',
258
+ };
259
+ }
260
+ export function handleUserInputPromptEvent(state, event, viewport) {
261
+ const visibleState = viewport
262
+ ? normalizeUserInputScrollState(state, viewport)
263
+ : state;
264
+ if (event.kind === 'paste') {
265
+ return {
266
+ state: visibleState.noteOpen ||
267
+ cursorForQuestion(visibleState, currentQuestion(visibleState)) ===
268
+ OTHER_CURSOR
269
+ ? insertNote(visibleState, event.text)
270
+ : visibleState,
271
+ };
272
+ }
273
+ if (event.pageUp || event.pageDown) {
274
+ if (!viewport) {
275
+ return {
276
+ state: {
277
+ ...visibleState,
278
+ contentScrollAnchor: null,
279
+ contentScrollOffset: visibleState.contentScrollOffset +
280
+ (event.pageUp ? -SCROLL_PAGE_ROWS : SCROLL_PAGE_ROWS),
281
+ },
282
+ };
283
+ }
284
+ const window = buildUserInputWindow(visibleState, viewport.width, viewport.maxRows);
285
+ return {
286
+ state: {
287
+ ...visibleState,
288
+ contentScrollAnchor: null,
289
+ contentScrollOffset: Math.max(0, Math.min(window.offset +
290
+ (event.pageUp ? -SCROLL_PAGE_ROWS : SCROLL_PAGE_ROWS), window.maxOffset)),
291
+ },
292
+ };
293
+ }
294
+ if (visibleState.noteOpen) {
295
+ return { state: editOpenNote(visibleState, event) };
296
+ }
297
+ if (event.escape) {
298
+ return { state: visibleState, result: { status: 'cancelled' } };
299
+ }
300
+ if (event.upArrow || event.downArrow) {
301
+ return {
302
+ state: moveCursor(visibleState, event.upArrow ? -1 : 1),
303
+ };
304
+ }
305
+ if (event.leftArrow) {
306
+ return {
307
+ state: setQuestion(visibleState, questionIndex(visibleState) - 1),
308
+ };
309
+ }
310
+ if (event.rightArrow) {
311
+ const index = questionIndex(visibleState);
312
+ if (index < visibleState.questions.length - 1) {
313
+ return { state: setQuestion(visibleState, index + 1) };
314
+ }
315
+ return {
316
+ state: setCursor(visibleState, currentQuestion(visibleState), SUBMIT_CURSOR),
317
+ };
318
+ }
319
+ const question = currentQuestion(visibleState);
320
+ const numberedRow = /^[1-5]$/.test(event.input)
321
+ ? Number(event.input)
322
+ : 0;
323
+ if (numberedRow && !event.ctrl && !event.meta) {
324
+ if (numberedRow === question.options.length + 1) {
325
+ return {
326
+ state: openCustomTextEditor(visibleState),
327
+ };
328
+ }
329
+ const numberedOption = question.options[numberedRow - 1];
330
+ if (numberedOption) {
331
+ return { state: selectOption(visibleState, numberedOption.id) };
332
+ }
333
+ }
334
+ if (event.returnKey) {
335
+ const cursor = cursorForQuestion(visibleState, question);
336
+ if (cursor === OTHER_CURSOR) {
337
+ return {
338
+ state: openCustomTextEditor(visibleState),
339
+ };
340
+ }
341
+ if (cursor === SUBMIT_CURSOR) {
342
+ return submitResult(visibleState);
343
+ }
344
+ return { state: selectOption(visibleState, cursor) };
345
+ }
346
+ if (!event.ctrl &&
347
+ !event.meta &&
348
+ event.input &&
349
+ event.input >= ' ' &&
350
+ cursorForQuestion(visibleState, currentQuestion(visibleState)) ===
351
+ OTHER_CURSOR) {
352
+ return { state: insertNote(visibleState, event.input) };
353
+ }
354
+ return { state: visibleState };
355
+ }
356
+ function noteInputLine(state, width) {
357
+ const question = currentQuestion(state);
358
+ const text = state.customText[question.id] ?? '';
359
+ const cursor = Math.max(0, Math.min(state.noteCursor, text.length));
360
+ const characters = Array.from(text);
361
+ const characterCursor = Array.from(text.slice(0, cursor)).length;
362
+ const available = Math.max(8, width - 8);
363
+ const windowStart = Math.max(0, characterCursor - available + 1);
364
+ const visible = characters.slice(windowStart, windowStart + available);
365
+ const visibleCursor = characterCursor - windowStart;
366
+ return line(span(' › ', { color: ACCENT_COLOR }), span(visible.slice(0, visibleCursor).join('')), span(visible[visibleCursor] ?? ' ', { inverse: true }), span(visible.slice(visibleCursor + 1).join('')));
367
+ }
368
+ function sliceWithinWidth(text, width) {
369
+ if (width <= 0)
370
+ return '';
371
+ let result = sliceToWidth(text, width);
372
+ while (result && displayWidth(result) > width) {
373
+ result = Array.from(result).slice(0, -1).join('');
374
+ }
375
+ return result;
376
+ }
377
+ function fitText(text, width) {
378
+ if (width <= 0)
379
+ return '';
380
+ if (displayWidth(text) <= width)
381
+ return text;
382
+ if (width === 1)
383
+ return '…';
384
+ return `${sliceWithinWidth(text, width - 1).trimEnd()}…`;
385
+ }
386
+ function clipLineToWidth(row, width) {
387
+ let remaining = Math.max(0, width);
388
+ const spans = [];
389
+ for (const item of row.spans) {
390
+ if (remaining <= 0)
391
+ break;
392
+ const text = sliceWithinWidth(item.text, remaining);
393
+ if (text)
394
+ spans.push({ ...item, text });
395
+ remaining -= displayWidth(text);
396
+ }
397
+ return { spans };
398
+ }
399
+ function buildUserInputContent(state, width) {
400
+ const question = currentQuestion(state);
401
+ const index = questionIndex(state);
402
+ const cursor = cursorForQuestion(state, question);
403
+ const selected = new Set(selectedIds(state, question));
404
+ let anchorRange = null;
405
+ const progress = `Question ${index + 1} of ${state.questions.length}`;
406
+ const headerPrefix = ' · ';
407
+ const headerBudget = Math.max(0, width - displayWidth(progress) - displayWidth(headerPrefix));
408
+ const lines = [
409
+ line(span(progress, {
410
+ color: ACCENT_COLOR,
411
+ bold: true,
412
+ }), ...(headerBudget > 0
413
+ ? [
414
+ span(`${headerPrefix}${fitText(question.header, headerBudget)}`, { color: 'gray' }),
415
+ ]
416
+ : [])),
417
+ plainLine(''),
418
+ ...wrapText(question.question, width).map((text) => plainLine(text, { bold: true })),
419
+ plainLine(''),
420
+ ];
421
+ question.options.forEach((option, optionIndex) => {
422
+ const focused = cursor === option.id;
423
+ const checked = selected.has(option.id);
424
+ const marker = question.multiSelect
425
+ ? checked
426
+ ? '[x]'
427
+ : '[ ]'
428
+ : checked
429
+ ? '●'
430
+ : '○';
431
+ const blockStart = lines.length;
432
+ const optionPrefix = `${optionIndex + 1}. ${marker} `;
433
+ const recommendation = option.id === question.recommendedOptionId ? ' Recommended' : '';
434
+ const labelBudget = Math.max(1, width -
435
+ displayWidth('› ') -
436
+ displayWidth(optionPrefix) -
437
+ displayWidth(recommendation));
438
+ lines.push(line(span(focused ? '› ' : ' ', {
439
+ color: focused ? ACCENT_COLOR : 'gray',
440
+ }), span(`${optionPrefix}${fitText(option.label, labelBudget)}`, {
441
+ color: focused ? ACCENT_COLOR : undefined,
442
+ bold: focused,
443
+ }), ...(recommendation
444
+ ? [span(recommendation, { color: 'green' })]
445
+ : [])));
446
+ if (focused) {
447
+ for (const description of wrapText(option.description, Math.max(8, width - 5))) {
448
+ lines.push(plainLine(` ${description}`, { color: 'gray' }));
449
+ }
450
+ }
451
+ if (state.contentScrollAnchor === option.id) {
452
+ anchorRange = { start: blockStart, end: lines.length - 1 };
453
+ }
454
+ });
455
+ const customText = (state.customText[question.id] ?? '').trim();
456
+ const otherFocused = cursor === OTHER_CURSOR;
457
+ const otherAnswered = Boolean(customText);
458
+ const otherMarker = question.multiSelect
459
+ ? otherAnswered
460
+ ? '[x]'
461
+ : '[ ]'
462
+ : otherAnswered
463
+ ? '●'
464
+ : '○';
465
+ const otherNumber = question.options.length + 1;
466
+ const otherStart = lines.length;
467
+ lines.push(plainLine(''));
468
+ lines.push(line(span(otherFocused ? '› ' : ' ', {
469
+ color: otherFocused ? ACCENT_COLOR : 'gray',
470
+ }), span(`${otherNumber}. ${otherMarker} Something else / add details`, {
471
+ color: otherFocused ? ACCENT_COLOR : undefined,
472
+ bold: otherFocused,
473
+ }), ...(customText && !state.noteOpen
474
+ ? [
475
+ span(` · ${Array.from(customText)
476
+ .slice(0, Math.max(8, width - 28))
477
+ .join('')}`, { color: 'gray' }),
478
+ ]
479
+ : otherFocused && !state.noteOpen
480
+ ? [span(' · Type details…', { color: 'gray', dim: true })]
481
+ : [])));
482
+ if (state.noteOpen) {
483
+ lines.push(noteInputLine(state, width));
484
+ }
485
+ lines.push(plainLine(''));
486
+ if (state.contentScrollAnchor === OTHER_CURSOR) {
487
+ anchorRange = { start: otherStart, end: lines.length - 1 };
488
+ }
489
+ if (index === state.questions.length - 1) {
490
+ const submitStart = lines.length - 1;
491
+ lines.push(line(span(cursor === SUBMIT_CURSOR ? '› ' : ' ', {
492
+ color: cursor === SUBMIT_CURSOR ? ACCENT_COLOR : 'gray',
493
+ }), span('Submit answers', {
494
+ color: cursor === SUBMIT_CURSOR ? ACCENT_COLOR : undefined,
495
+ bold: cursor === SUBMIT_CURSOR,
496
+ })));
497
+ lines.push(plainLine(''));
498
+ if (state.contentScrollAnchor === SUBMIT_CURSOR) {
499
+ anchorRange = { start: submitStart, end: lines.length - 1 };
500
+ }
501
+ }
502
+ if (state.validationMessage) {
503
+ lines.push(plainLine(state.validationMessage, { color: 'yellow' }));
504
+ }
505
+ const footer = plainLine(state.noteOpen
506
+ ? 'Type a single-line note • ↑/↓ moves when empty • Enter saves • Esc closes'
507
+ : question.multiSelect
508
+ ? `↑/↓ move • 1–${otherNumber} choose • Enter toggles/edits • ←/→ questions • Esc cancels`
509
+ : `↑/↓ move • 1–${otherNumber} choose • Enter selects/edits • ←/→ questions • Esc cancels`, { color: 'gray' });
510
+ return {
511
+ anchorRange,
512
+ footer: clipLineToWidth(footer, width),
513
+ lines: lines.map((row) => clipLineToWidth(row, width)),
514
+ };
515
+ }
516
+ function buildUserInputWindow(state, width, maxRows) {
517
+ const content = buildUserInputContent(state, width);
518
+ if (!Number.isFinite(maxRows)) {
519
+ return {
520
+ lines: [...content.lines, content.footer],
521
+ maxOffset: 0,
522
+ offset: 0,
523
+ };
524
+ }
525
+ const rowBudget = Math.max(1, Math.floor(maxRows));
526
+ if (rowBudget === 1) {
527
+ return { lines: [content.footer], maxOffset: 0, offset: 0 };
528
+ }
529
+ if (content.lines.length <= rowBudget - 1) {
530
+ return {
531
+ lines: [...content.lines, content.footer],
532
+ maxOffset: 0,
533
+ offset: 0,
534
+ };
535
+ }
536
+ const visibleRowBudget = Math.max(1, rowBudget - 2);
537
+ const maxOffset = Math.max(0, content.lines.length - visibleRowBudget);
538
+ let offset = Math.max(0, Math.min(state.contentScrollOffset, maxOffset));
539
+ const range = content.anchorRange;
540
+ if (range) {
541
+ const blockRows = range.end - range.start + 1;
542
+ if (blockRows <= visibleRowBudget) {
543
+ if (range.start < offset) {
544
+ offset = range.start;
545
+ }
546
+ else if (range.end >= offset + visibleRowBudget) {
547
+ offset = range.end - visibleRowBudget + 1;
548
+ }
549
+ }
550
+ else if (range.start < offset ||
551
+ range.start >= offset + visibleRowBudget) {
552
+ offset = range.start;
553
+ }
554
+ offset = Math.max(0, Math.min(offset, maxOffset));
555
+ }
556
+ const visible = content.lines.slice(offset, offset + visibleRowBudget);
557
+ return {
558
+ lines: [
559
+ ...visible,
560
+ plainLine(fitText(`Rows ${offset + 1}–${offset + visible.length} of ${content.lines.length} · PgUp/PgDn scroll`, width), { color: 'gray' }),
561
+ content.footer,
562
+ ],
563
+ maxOffset,
564
+ offset,
565
+ };
566
+ }
567
+ export function normalizeUserInputScrollState(state, viewport) {
568
+ const window = buildUserInputWindow(state, viewport.width, viewport.maxRows);
569
+ return window.offset === state.contentScrollOffset
570
+ ? state
571
+ : { ...state, contentScrollOffset: window.offset };
572
+ }
573
+ export function buildUserInputOverlayLines(state, width, maxRows = Number.POSITIVE_INFINITY) {
574
+ return buildUserInputWindow(state, width, maxRows).lines;
575
+ }
576
+ export function formatUserInputTranscript(questions, result) {
577
+ if (result.status === 'cancelled') {
578
+ return 'Cancelled without answers.';
579
+ }
580
+ return questions
581
+ .map((question) => {
582
+ const answer = result.answers[question.id];
583
+ const selected = question.options
584
+ .filter((option) => answer?.selectedOptionIds.includes(option.id))
585
+ .map((option) => option.label);
586
+ const values = [
587
+ ...selected,
588
+ ...(answer?.customText ? [answer.customText] : []),
589
+ ];
590
+ return `${question.header} · ${question.question}\n ${values.join(', ')}`;
591
+ })
592
+ .join('\n');
593
+ }