@thegitai/cli 1.0.0-preview.13 → 1.0.0-preview.15

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 CHANGED
@@ -38,6 +38,22 @@ CLI login tokens use a rolling 24-hour inactivity timeout. If one expires during
38
38
  any server request, the CLI saves the local session, removes the expired
39
39
  credential, and asks you to run `ai login` before resuming.
40
40
 
41
+ ## Structured questions
42
+
43
+ The agent can pause its current turn to ask up to four related questions in one
44
+ form. Use **↑ / ↓**, the displayed option number, or **Enter** to choose, and
45
+ **← / →** to move between questions. **Something else / add details** is the
46
+ numbered final option; focus it and type, paste, or press **Enter** to add a
47
+ single-line note. On single-select questions it is mutually exclusive with the
48
+ listed choices, and **↑ / ↓** leaves its editor when the note is empty.
49
+ Multi-select questions toggle choices. The last question has an explicit
50
+ **Submit answers** row. **Esc** closes an open note first and otherwise cancels
51
+ the form; **Ctrl+C** cancels the whole turn.
52
+
53
+ Answers return to the same turn, and completed question/answer records remain
54
+ readable when the session is resumed. Default, Auto-Accept, and Plan modes all
55
+ support the form; Auto-Accept does not choose answers for you.
56
+
41
57
  ## Visible to-do list
42
58
 
43
59
  For larger multi-step tasks, the agent keeps a compact to-do list on screen so
@@ -2,6 +2,7 @@ import { drainBackgroundJobNotifications } from '../background-jobs.js';
2
2
  import { createPromptCheckpoint, sanitizeSessionSafetyForServer, } from '../session-safety.js';
3
3
  import { applySessionSnapshot, saveSessionState, snapshotFromSession, } from '../session-store.js';
4
4
  import { executeLocalToolCall } from '../tool-executor.js';
5
+ import { isUserInputQuestionArray } from './contracts.js';
5
6
  import { createTraceContext, normalizeServerUrl, readErrorResponse, } from './http.js';
6
7
  import { collectClientEnvironment } from '../client-environment.js';
7
8
  import { collectProjectOrientation } from '../project-orientation.js';
@@ -168,6 +169,8 @@ function publicStatusMessage(data) {
168
169
  return `Running ${toolName}...`;
169
170
  if (event.phase === 'waiting_for_tool')
170
171
  return `Running ${toolName} locally...`;
172
+ if (event.phase === 'waiting_for_user_input')
173
+ return 'Waiting for your input...';
171
174
  return null;
172
175
  }
173
176
  function normalizeShellJobToolCall(call) {
@@ -223,6 +226,28 @@ async function postToolResult({ config, turnId, event, result, session, fetchImp
223
226
  throw await readErrorResponse(response, trace.traceId);
224
227
  }
225
228
  }
229
+ async function postUserInputResult({ config, turnId, requestId, result, fetchImpl, traceId, }) {
230
+ const payload = {
231
+ requestId,
232
+ result,
233
+ };
234
+ const trace = createTraceContext(traceId);
235
+ const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/chat/turn/${encodeURIComponent(turnId)}/user-input-result`, {
236
+ method: 'POST',
237
+ headers: {
238
+ authorization: `Bearer ${config.token}`,
239
+ 'content-type': 'application/json',
240
+ ...trace.headers,
241
+ },
242
+ body: JSON.stringify(payload),
243
+ });
244
+ if (response.status === 410) {
245
+ return;
246
+ }
247
+ if (!response.ok) {
248
+ throw await readErrorResponse(response, trace.traceId);
249
+ }
250
+ }
226
251
  const turnIdOverrides = new WeakMap();
227
252
  function enterServerTurnId(session, serverSessionTurnId) {
228
253
  const active = turnIdOverrides.get(session);
@@ -371,6 +396,33 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
371
396
  }
372
397
  return;
373
398
  }
399
+ if (event.event === 'user-input-request') {
400
+ await drainParallelTools();
401
+ const data = event.data;
402
+ const turnId = String(data?.turnId ?? '').trim();
403
+ const requestId = String(data?.requestId ?? '').trim();
404
+ if (!turnId ||
405
+ !requestId ||
406
+ !isUserInputQuestionArray(data?.questions)) {
407
+ throw new Error('Server emitted an invalid user-input request.');
408
+ }
409
+ if (!session.requestUserInput) {
410
+ throw new Error('Interactive user input is unavailable in this client.');
411
+ }
412
+ const result = await session.requestUserInput({ questions: data.questions }, signal);
413
+ if (signal?.aborted) {
414
+ throw new TurnCancelledError();
415
+ }
416
+ await postUserInputResult({
417
+ config,
418
+ turnId,
419
+ requestId,
420
+ result,
421
+ fetchImpl,
422
+ traceId,
423
+ });
424
+ return;
425
+ }
374
426
  if (event.event === 'result') {
375
427
  await drainParallelTools();
376
428
  finalResult.current = event.data;
@@ -1 +1,55 @@
1
- export {};
1
+ function nonEmptyString(value) {
2
+ return typeof value === 'string' && value.trim().length > 0;
3
+ }
4
+ const USER_INPUT_ID_PATTERN = /^[a-z][a-z0-9_]*$/;
5
+ export function isUserInputQuestionArray(value) {
6
+ if (!Array.isArray(value) || value.length < 1 || value.length > 4) {
7
+ return false;
8
+ }
9
+ const questionIds = new Set();
10
+ return value.every((question) => {
11
+ if (!question || typeof question !== 'object' || Array.isArray(question)) {
12
+ return false;
13
+ }
14
+ const candidate = question;
15
+ if (!nonEmptyString(candidate.id) ||
16
+ !USER_INPUT_ID_PATTERN.test(candidate.id) ||
17
+ questionIds.has(candidate.id) ||
18
+ !nonEmptyString(candidate.header) ||
19
+ Array.from(candidate.header).length > 12 ||
20
+ !nonEmptyString(candidate.question) ||
21
+ typeof candidate.multiSelect !== 'boolean' ||
22
+ !Array.isArray(candidate.options) ||
23
+ candidate.options.length < 2 ||
24
+ candidate.options.length > 4) {
25
+ return false;
26
+ }
27
+ questionIds.add(candidate.id);
28
+ const optionIds = new Set();
29
+ const optionsValid = candidate.options.every((option) => {
30
+ if (!option || typeof option !== 'object' || Array.isArray(option)) {
31
+ return false;
32
+ }
33
+ const item = option;
34
+ if (!nonEmptyString(item.id) ||
35
+ !USER_INPUT_ID_PATTERN.test(item.id) ||
36
+ optionIds.has(item.id) ||
37
+ !nonEmptyString(item.label) ||
38
+ !nonEmptyString(item.description)) {
39
+ return false;
40
+ }
41
+ optionIds.add(item.id);
42
+ return true;
43
+ });
44
+ if (!optionsValid) {
45
+ return false;
46
+ }
47
+ if (candidate.recommendedOptionId !== undefined &&
48
+ (!nonEmptyString(candidate.recommendedOptionId) ||
49
+ candidate.recommendedOptionId !==
50
+ candidate.options[0].id)) {
51
+ return false;
52
+ }
53
+ return true;
54
+ });
55
+ }
@@ -31,7 +31,7 @@ function preserveProviderSelection(serverState) {
31
31
  : null;
32
32
  return providerSelection ? { providerSelection } : {};
33
33
  }
34
- export function createSession({ rootDir, autoYes = false, agentMode, modelId, maxToolSteps = DEFAULT_MAX_TOOL_STEPS, confirmCommand = null, confirmPatch = null, requestSudoPassword = null, onStatus = null, onContextLog = null, onToolEvent = null, env = process.env, sessionId = createSessionId(), sessionName = null, history = [], serverState = null, editJournal = [], stickyFilePaths = [], editCounter = 0, safety = createSessionSafetyState(), }) {
34
+ export function createSession({ rootDir, autoYes = false, agentMode, modelId, maxToolSteps = DEFAULT_MAX_TOOL_STEPS, confirmCommand = null, confirmPatch = null, requestSudoPassword = null, requestUserInput = null, onStatus = null, onContextLog = null, onToolEvent = null, env = process.env, sessionId = createSessionId(), sessionName = null, history = [], serverState = null, editJournal = [], stickyFilePaths = [], editCounter = 0, safety = createSessionSafetyState(), }) {
35
35
  const createdAt = new Date().toISOString();
36
36
  const initialAgentMode = normalizeAgentMode(agentMode ?? (autoYes ? 'auto-accept' : 'default'));
37
37
  return {
@@ -46,6 +46,7 @@ export function createSession({ rootDir, autoYes = false, agentMode, modelId, ma
46
46
  confirmCommand,
47
47
  confirmPatch,
48
48
  requestSudoPassword,
49
+ requestUserInput,
49
50
  history: JSON.parse(JSON.stringify(history)),
50
51
  initialized: true,
51
52
  sessionId,
@@ -1,5 +1,5 @@
1
1
  import { createRatatuiBridge } from './tui/bridge.js';
2
- import { approvalScrollLimit, buildTuiFrame, formatJobElapsed, formatTodoProgress, pickThinkingFallbackPhrase, renderTranscriptEntryLines, THINKING_FALLBACK_PHRASES, } from './tui/build-frame.js';
2
+ import { approvalScrollLimit, buildTuiFrame, formatJobElapsed, formatTodoProgress, pickThinkingFallbackPhrase, renderTranscriptEntryLines, THINKING_FALLBACK_PHRASES, userInputViewportForFrame, } from './tui/build-frame.js';
3
3
  import { createTerminalTitleController } from './tui/terminal-title.js';
4
4
  import { captureTerminalWrites, releaseTerminalWrites, } from './tui/terminal-writes.js';
5
5
  export { getSlashCommandSuggestions } from './tui/build-frame.js';
@@ -13,6 +13,7 @@ import { cancelActiveCommand } from '../executor.js';
13
13
  import { isTurnFailureMarker } from '../turn-failure-marker.js';
14
14
  import { clearCliAuthConfig } from '../api/auth.js';
15
15
  import { authenticationErrorMessage, isAuthenticationError, } from '../api/http.js';
16
+ import { createUserInputPromptState, formatUserInputTranscript, } from './tui/user-input.js';
16
17
  import { setCommandOutputHook, withTuiMode } from '../runtime-mode.js';
17
18
  import { collectBackgroundJobUiKillMutations, collectBackgroundJobUiOutputMutations, } from '../tool-executor.js';
18
19
  import { startNewConversation, } from '../session.js';
@@ -787,10 +788,18 @@ function displayUserTextFromHistoryEntry(entry) {
787
788
  export function buildTranscriptFromSessionHistory(history) {
788
789
  const entries = [];
789
790
  const pendingCalls = new Map();
791
+ const pendingUserInputRequests = new Map();
790
792
  for (const entry of history) {
791
793
  if (!entry?.parts?.length)
792
794
  continue;
793
795
  for (const part of entry.parts) {
796
+ const userInputRequest = part?.userInputRequest;
797
+ if (userInputRequest && typeof userInputRequest === 'object') {
798
+ const requestId = String(userInputRequest.requestId ?? '').trim();
799
+ if (requestId && Array.isArray(userInputRequest.questions)) {
800
+ pendingUserInputRequests.set(requestId, userInputRequest.questions);
801
+ }
802
+ }
794
803
  const call = part?.functionCall;
795
804
  if (!call || typeof call !== 'object')
796
805
  continue;
@@ -800,6 +809,21 @@ export function buildTranscriptFromSessionHistory(history) {
800
809
  pendingCalls.set(callId, call);
801
810
  }
802
811
  for (const part of entry.parts) {
812
+ const userInputResult = part?.userInputResult;
813
+ if (userInputResult && typeof userInputResult === 'object') {
814
+ const requestId = String(userInputResult.requestId ?? '').trim();
815
+ const questions = pendingUserInputRequests.get(requestId);
816
+ const result = userInputResult.result;
817
+ if (questions &&
818
+ (result?.status === 'submitted' || result?.status === 'cancelled')) {
819
+ pendingUserInputRequests.delete(requestId);
820
+ entries.push({
821
+ body: formatUserInputTranscript(questions, result),
822
+ kind: result.status === 'submitted' ? 'user' : 'system',
823
+ title: result.status === 'submitted' ? 'Your answers' : 'Question',
824
+ });
825
+ }
826
+ }
803
827
  const functionResponse = part?.functionResponse;
804
828
  if (!functionResponse || typeof functionResponse !== 'object')
805
829
  continue;
@@ -1008,6 +1032,7 @@ function createInitialShellState(session, serverModels, debugUi) {
1008
1032
  tokenUsage: formatClientTokenUsage(null),
1009
1033
  transcript: [],
1010
1034
  turnCounter: Math.max(0, session.history.filter((entry) => entry.role === 'user').length),
1035
+ userInputPrompt: null,
1011
1036
  pastedChunks: [],
1012
1037
  imageAttachments: [],
1013
1038
  workingTools: [],
@@ -1351,6 +1376,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1351
1376
  let resolveDone = null;
1352
1377
  let resolveApprovalChoice = null;
1353
1378
  let resolveSudoPassword = null;
1379
+ let pendingUserInput = null;
1354
1380
  let cleanupSudoPasswordPrompt = null;
1355
1381
  let sudoPasswordBuffer = '';
1356
1382
  const bridge = createRatatuiBridge();
@@ -1597,12 +1623,28 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1597
1623
  sudoPrompt: null,
1598
1624
  }));
1599
1625
  };
1626
+ const dismissPendingUserInput = () => {
1627
+ const pending = pendingUserInput;
1628
+ if (!pending)
1629
+ return;
1630
+ pendingUserInput = null;
1631
+ pending.signal?.removeEventListener('abort', pending.onAbort);
1632
+ const error = new Error('Turn cancelled.');
1633
+ error.name = 'AbortError';
1634
+ pending.reject(error);
1635
+ store.update((current) => ({
1636
+ ...resumeBusyClock(current, Date.now()),
1637
+ status: current.userInputPrompt?.returnStatus ?? current.status,
1638
+ userInputPrompt: null,
1639
+ }));
1640
+ };
1600
1641
  const cancelActiveTurn = () => {
1601
1642
  if (!store.getState().busy || newConversationInFlight)
1602
1643
  return;
1603
1644
  disarmExitConfirm();
1604
1645
  dismissPendingApproval();
1605
1646
  dismissPendingSudoPassword();
1647
+ dismissPendingUserInput();
1606
1648
  activeTurnGeneration += 1;
1607
1649
  resetThinkingPacer();
1608
1650
  activeTurnAbort?.abort();
@@ -1722,6 +1764,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1722
1764
  }
1723
1765
  dismissPendingApproval();
1724
1766
  dismissPendingSudoPassword();
1767
+ dismissPendingUserInput();
1725
1768
  exiting = true;
1726
1769
  killAllBackgroundJobs();
1727
1770
  store.update((current) => ({
@@ -1841,6 +1884,49 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1841
1884
  : null,
1842
1885
  }));
1843
1886
  };
1887
+ const openUserInputPrompt = (questions, signal) => new Promise((resolve, reject) => {
1888
+ if (exiting || signal?.aborted) {
1889
+ const error = new Error('Turn cancelled.');
1890
+ error.name = 'AbortError';
1891
+ reject(error);
1892
+ return;
1893
+ }
1894
+ const onAbort = () => dismissPendingUserInput();
1895
+ pendingUserInput = {
1896
+ questions,
1897
+ resolve,
1898
+ reject,
1899
+ signal,
1900
+ onAbort,
1901
+ };
1902
+ signal?.addEventListener('abort', onAbort, { once: true });
1903
+ store.update((current) => ({
1904
+ ...pauseBusyClock(current, Date.now()),
1905
+ status: 'Waiting for your input',
1906
+ userInputPrompt: createUserInputPromptState(questions, current.status),
1907
+ }));
1908
+ scheduleLiveFrameRemount();
1909
+ });
1910
+ const handleInlineUserInput = async (result) => {
1911
+ const pending = pendingUserInput;
1912
+ const prompt = store.getState().userInputPrompt;
1913
+ if (!pending || !prompt)
1914
+ return;
1915
+ pendingUserInput = null;
1916
+ pending.signal?.removeEventListener('abort', pending.onAbort);
1917
+ store.update((current) => ({
1918
+ ...resumeBusyClock(current, Date.now()),
1919
+ status: prompt.returnStatus,
1920
+ userInputPrompt: null,
1921
+ }));
1922
+ appendTurnAwareEntry({
1923
+ body: formatUserInputTranscript(pending.questions, result),
1924
+ kind: result.status === 'submitted' ? 'user' : 'system',
1925
+ title: result.status === 'submitted' ? 'Your answers' : 'Question',
1926
+ });
1927
+ pending.resolve(result);
1928
+ scheduleLiveFrameRemount();
1929
+ };
1844
1930
  const openApprovalPrompt = (title, body, options = {}) => new Promise((resolve) => {
1845
1931
  if (exiting) {
1846
1932
  resolve('n');
@@ -2634,6 +2720,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2634
2720
  projectIndex.onStatus = session.onStatus;
2635
2721
  projectIndex.onContextLog = session.onContextLog;
2636
2722
  session.requestSudoPassword = async ({ command, prompt, signal }) => openSudoPasswordPrompt(command, prompt, signal);
2723
+ session.requestUserInput = async (request, signal) => openUserInputPrompt(request.questions, signal);
2637
2724
  session.confirmCommand = async (command) => {
2638
2725
  if (exiting)
2639
2726
  return false;
@@ -2683,6 +2770,11 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2683
2770
  const blocks = store.getState().transcript.map((entry) => renderTranscriptEntryLines(entry, contentWidth));
2684
2771
  return blocks.reduce((total, block, index) => total + block.length + (index > 0 ? 1 : 0), 0);
2685
2772
  },
2773
+ getUserInputViewport: () => {
2774
+ const nowMs = Date.now();
2775
+ const state = store.getState();
2776
+ return userInputViewportForFrame(state, terminalCols, terminalRows, spinnerFrame, busyElapsedSeconds(state, nowMs), nowMs);
2777
+ },
2686
2778
  onCancelTurn: cancelActiveTurn,
2687
2779
  onCycleAgentMode: cycleAgentMode,
2688
2780
  onCtrlC: handleCtrlC,
@@ -2691,6 +2783,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2691
2783
  onLiveFrameShapeChange: scheduleLiveFrameRemount,
2692
2784
  onRequestExit: requestExit,
2693
2785
  onResolveApproval: handleInlineApprovalChoice,
2786
+ onResolveUserInput: handleInlineUserInput,
2694
2787
  onResumeSession: handleInlineResumeSelection,
2695
2788
  onSelectionCopy: handleAppSelectionCopy,
2696
2789
  onSelectModel: handleInlineModelSelection,
@@ -61,6 +61,13 @@ function normalizeChildMessage(raw) {
61
61
  deltaLines: Number(raw.deltaLines ?? raw.delta_lines ?? 0),
62
62
  };
63
63
  }
64
+ if (raw.kind === 'transcriptScrollTo') {
65
+ return {
66
+ op: 'event',
67
+ kind: 'transcriptScrollTo',
68
+ offset: Number(raw.offset ?? 0),
69
+ };
70
+ }
64
71
  if (raw.kind !== 'key') {
65
72
  return null;
66
73
  }
@@ -3,6 +3,7 @@ import { singleLinePreview, truncate } from '../../utils.js';
3
3
  import { formatClientTokenUsage } from '../repl.js';
4
4
  import { renderFormattedBodyLines, renderPreformattedBodyLines, } from './markdown-render.js';
5
5
  import { displayWidth, line, plainLine, sliceToWidth, span, wrapText, } from './text.js';
6
+ import { buildUserInputOverlayLines, } from './user-input.js';
6
7
  const WORKING_CLOCK_ICON = '◷';
7
8
  const BRAILLE_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴'];
8
9
  const TODO_PANEL_MAX_ROWS = 12;
@@ -30,6 +31,7 @@ const OVERLAY_PANEL_MAX_WIDTH = 86;
30
31
  const OVERLAY_PANEL_MARGIN_LINES = 2;
31
32
  const OVERLAY_PANEL_MARGIN_MIN_ROWS = 30;
32
33
  const OVERLAY_BORDER_COLOR = 'yellow';
34
+ const USER_INPUT_BORDER_COLOR = 'cyan';
33
35
  const OVERLAY_WARNING_COLOR = 'ansi256(208)';
34
36
  const MODEL_PICKER_PANEL_MAX_WIDTH = 144;
35
37
  const MODEL_PICKER_PANEL_MARGIN_LINES = 2;
@@ -339,6 +341,26 @@ export function renderTranscriptEntryLines(entry, width) {
339
341
  }
340
342
  return lines;
341
343
  }
344
+ const transcriptEntryLineCache = new WeakMap();
345
+ const MAX_CACHED_TRANSCRIPT_WIDTHS = 2;
346
+ function cachedTranscriptEntryLines(entry, width) {
347
+ let byWidth = transcriptEntryLineCache.get(entry);
348
+ if (!byWidth) {
349
+ byWidth = new Map();
350
+ transcriptEntryLineCache.set(entry, byWidth);
351
+ }
352
+ const cached = byWidth.get(width);
353
+ if (cached)
354
+ return cached;
355
+ const lines = renderTranscriptEntryLines(entry, width);
356
+ if (byWidth.size >= MAX_CACHED_TRANSCRIPT_WIDTHS) {
357
+ const oldest = byWidth.keys().next().value;
358
+ if (oldest !== undefined)
359
+ byWidth.delete(oldest);
360
+ }
361
+ byWidth.set(width, lines);
362
+ return lines;
363
+ }
342
364
  function renderDiffPreviewLines(preview, width, maxDiffLines = TRANSCRIPT_DIFF_PREVIEW_LINES) {
343
365
  return [
344
366
  plainLine(` Added ${preview.added} line${preview.added === 1 ? '' : 's'}, removed ${preview.removed} line${preview.removed === 1 ? '' : 's'}`, { color: 'gray' }),
@@ -454,13 +476,15 @@ function composerFooterLines(state) {
454
476
  : state.input
455
477
  ? 'Enter queues • Esc cancels turn • Ctrl+C clears draft'
456
478
  : 'Enter queues • Esc / Ctrl+C cancel turn';
457
- const helperText = state.busy
458
- ? busyHelperText
459
- : process.platform === 'win32'
460
- ? 'Enter sends • Shift+Tab mode • Alt+V image • Esc cancel turn • Ctrl+C clears / quits'
461
- : process.platform === 'darwin'
462
- ? 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C clears / quits'
463
- : 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C clears / quits';
479
+ const helperText = state.userInputPrompt
480
+ ? 'Answering agent questions • Ctrl+C cancels turn'
481
+ : state.busy
482
+ ? busyHelperText
483
+ : process.platform === 'win32'
484
+ ? 'Enter sends • Shift+Tab mode • Alt+V image • Esc cancel turn • Ctrl+C clears / quits'
485
+ : process.platform === 'darwin'
486
+ ? 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C clears / quits'
487
+ : 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C clears / quits';
464
488
  const agentLabel = agentModeLabel(state.agentMode).padEnd(AGENT_MODE_LABEL_WIDTH);
465
489
  const tokenUsageText = state.tokenUsage || formatClientTokenUsage(null);
466
490
  const footerSpans = [
@@ -661,11 +685,23 @@ function overlayPanelLine(row, width, color) {
661
685
  const padding = Math.max(0, width - lineCharCount(row));
662
686
  return line(span('│ ', { color }), ...row.spans, span(' '.repeat(padding)), span(' │', { color }));
663
687
  }
688
+ function overlayPanelMarginLineCount(height) {
689
+ return height < OVERLAY_PANEL_MARGIN_MIN_ROWS
690
+ ? 0
691
+ : OVERLAY_PANEL_MARGIN_LINES;
692
+ }
693
+ function overlayPanelContentBudget(height) {
694
+ if (!Number.isFinite(height)) {
695
+ return Number.POSITIVE_INFINITY;
696
+ }
697
+ const margins = overlayPanelMarginLineCount(height) * 2;
698
+ return Math.max(1, Math.floor(height) - margins - 2);
699
+ }
664
700
  function buildOverlayPanel(rows, width, color, height = Number.POSITIVE_INFINITY) {
665
701
  const panelWidth = Math.max(24, Math.min(width, OVERLAY_PANEL_MAX_WIDTH));
666
702
  const innerWidth = Math.max(1, panelWidth - 4);
667
703
  const margin = Array.from({
668
- length: height < OVERLAY_PANEL_MARGIN_MIN_ROWS ? 0 : OVERLAY_PANEL_MARGIN_LINES,
704
+ length: overlayPanelMarginLineCount(height),
669
705
  }, () => plainLine(''));
670
706
  return [
671
707
  ...margin,
@@ -990,6 +1026,12 @@ function buildOverlayLines(state, width, height, nowMs) {
990
1026
  lines.push(plainLine('Press Enter to submit, Escape to cancel', { color: 'gray' }));
991
1027
  return buildOverlayPanel(lines, width, OVERLAY_BORDER_COLOR, height);
992
1028
  }
1029
+ if (state.userInputPrompt) {
1030
+ if (height < 3) {
1031
+ return [];
1032
+ }
1033
+ return buildOverlayPanel(buildUserInputOverlayLines(state.userInputPrompt, innerWidth, overlayPanelContentBudget(height)), width, USER_INPUT_BORDER_COLOR, height);
1034
+ }
993
1035
  if (state.approvalPrompt) {
994
1036
  const prompt = state.approvalPrompt;
995
1037
  const padded = approvalPaddingEnabled(height);
@@ -1111,6 +1153,15 @@ function buildOverlayLines(state, width, height, nowMs) {
1111
1153
  function countSectionLines(sections) {
1112
1154
  return sections.reduce((sum, section) => sum + section.lines.length, 0);
1113
1155
  }
1156
+ export function userInputViewportForFrame(state, cols, rows, spinnerFrame, elapsedSeconds, nowMs = 0) {
1157
+ const contentWidth = Math.max(20, Math.floor(cols * 0.95) - 2);
1158
+ const liveRows = buildLiveLines(state, contentWidth, spinnerFrame, elapsedSeconds, nowMs).length;
1159
+ const overlayHeight = Math.max(0, rows - liveRows - composerFooterLines(state).length);
1160
+ return {
1161
+ width: approvalPanelInnerWidth(contentWidth),
1162
+ maxRows: overlayPanelContentBudget(overlayHeight),
1163
+ };
1164
+ }
1114
1165
  function sliceTranscriptLines(lines, maxLines, scrollOffset) {
1115
1166
  if (maxLines <= 0 || lines.length <= maxLines) {
1116
1167
  return lines;
@@ -1123,20 +1174,24 @@ function sliceTranscriptLines(lines, maxLines, scrollOffset) {
1123
1174
  export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, nowMs = 0) {
1124
1175
  const contentWidth = Math.max(20, Math.floor(cols * 0.95) - 2);
1125
1176
  const gutter = Math.max(Math.floor((cols - contentWidth) / 2), 0);
1126
- const transcriptBlocks = state.transcript.map((entry) => renderTranscriptEntryLines(entry, contentWidth));
1127
- const transcriptLines = [];
1128
- transcriptBlocks.forEach((block, index) => {
1129
- if (index > 0) {
1130
- transcriptLines.push(plainLine(''));
1131
- }
1132
- transcriptLines.push(...block);
1133
- });
1177
+ const buildTranscriptLines = (wrapWidth) => {
1178
+ const blocks = state.transcript.map((entry) => cachedTranscriptEntryLines(entry, wrapWidth));
1179
+ const lines = [];
1180
+ blocks.forEach((block, index) => {
1181
+ if (index > 0) {
1182
+ lines.push(plainLine(''));
1183
+ }
1184
+ lines.push(...block);
1185
+ });
1186
+ return lines;
1187
+ };
1188
+ let transcriptLines = buildTranscriptLines(contentWidth);
1134
1189
  const sections = [];
1135
1190
  const liveLines = buildLiveLines(state, contentWidth, spinnerFrame, elapsedSeconds, nowMs);
1136
1191
  if (liveLines.length > 0) {
1137
1192
  sections.push({ kind: 'live', lines: liveLines });
1138
1193
  }
1139
- const overlayActive = Boolean(state.approvalPrompt || state.sudoPrompt);
1194
+ const overlayActive = Boolean(state.approvalPrompt || state.sudoPrompt || state.userInputPrompt);
1140
1195
  if (!state.resumePickerOpen && !state.modelPickerOpen && !state.jobsPickerOpen && !overlayActive) {
1141
1196
  const composerLines = [];
1142
1197
  if (state.busy && state.status === 'Starting a new conversation...') {
@@ -1161,14 +1216,32 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, n
1161
1216
  lines: [...composerLines, plainLine(''), ...composerFooterLines(state)],
1162
1217
  });
1163
1218
  }
1164
- const overlayLines = buildOverlayLines(state, contentWidth, rows, nowMs);
1219
+ if (state.userInputPrompt) {
1220
+ sections.push({
1221
+ kind: 'busyFooter',
1222
+ lines: composerFooterLines(state),
1223
+ });
1224
+ }
1225
+ const overlayHeight = state.userInputPrompt
1226
+ ? Math.max(0, rows - countSectionLines(sections))
1227
+ : rows;
1228
+ const overlayLines = buildOverlayLines(state, contentWidth, overlayHeight, nowMs);
1165
1229
  if (overlayLines.length > 0) {
1166
1230
  sections.push({ kind: 'overlay', lines: overlayLines });
1167
1231
  }
1168
1232
  const reservedLines = countSectionLines(sections.filter((section) => section.kind !== 'transcript'));
1169
- const composerReserve = state.resumePickerOpen || state.approvalPrompt || state.sudoPrompt ? 0 : 4;
1233
+ const composerReserve = state.resumePickerOpen ||
1234
+ state.approvalPrompt ||
1235
+ state.sudoPrompt ||
1236
+ state.userInputPrompt
1237
+ ? 0
1238
+ : 4;
1170
1239
  const transcriptBudget = Math.max(1, rows - reservedLines - composerReserve - 1);
1171
- const transcriptScrollLimit = Math.max(0, transcriptLines.length - transcriptBudget);
1240
+ let transcriptScrollLimit = Math.max(0, transcriptLines.length - transcriptBudget);
1241
+ if (transcriptScrollLimit > 0 && contentWidth > 2) {
1242
+ transcriptLines = buildTranscriptLines(contentWidth - 2);
1243
+ transcriptScrollLimit = Math.max(0, transcriptLines.length - transcriptBudget);
1244
+ }
1172
1245
  const transcriptScrollOffset = Math.min(Math.max(state.transcriptScrollOffset, 0), transcriptScrollLimit);
1173
1246
  sections.unshift({
1174
1247
  kind: 'transcript',
@@ -275,9 +275,9 @@ function wrapInlineToLines(text, width, bodyColor, prefix = '') {
275
275
  };
276
276
  const appendSpan = (part) => {
277
277
  let current = rows[rows.length - 1];
278
- const limit = safeWidth - (rows.length === 1 && indent ? displayWidth(prefix) : 0);
279
278
  let remaining = part.text;
280
279
  while (remaining.length > 0) {
280
+ const limit = safeWidth - (rows.length === 1 && indent ? displayWidth(prefix) : 0);
281
281
  const room = limit - rowWidth;
282
282
  if (room <= 0) {
283
283
  startRow();
@@ -291,8 +291,17 @@ function wrapInlineToLines(text, width, bodyColor, prefix = '') {
291
291
  remaining = '';
292
292
  break;
293
293
  }
294
+ if (rowWidth > 0) {
295
+ if (/^\s+$/.test(remaining)) {
296
+ remaining = '';
297
+ break;
298
+ }
299
+ startRow();
300
+ current = rows[rows.length - 1];
301
+ continue;
302
+ }
294
303
  const head = sliceToWidth(remaining, room);
295
- if (displayWidth(head) > room && current.length > 0) {
304
+ if (!head || (displayWidth(head) > room && current.length > 0)) {
296
305
  startRow();
297
306
  current = rows[rows.length - 1];
298
307
  continue;
@@ -1,6 +1,7 @@
1
1
  import { readClipboardImage, readClipboardText } from '../../core/clipboard.js';
2
2
  import { applySlashCommandSuggestion, buildModelPickerOptions, deleteAtCursor, deleteBeforeCursor, getApprovalChoiceForCursor, getInputCommandToken, getNextApprovalCursor, getNextModelPickerIndex, getSlashCommandSuggestions, insertAtCursor, isExactSlashCommandToken, navigatePromptHistory, resolveApprovalChoiceFromInput, shouldRemountLiveFrameForComposerInputChange, } from '../repl.js';
3
3
  import { buildPastePlaceholder, shouldCollapsePaste, } from '../paste-collapse.js';
4
+ import { handleUserInputPromptEvent, } from './user-input.js';
4
5
  const APPROVAL_PREVIEW_PAGE_ROWS = 3;
5
6
  function isClipboardImagePasteKey(key) {
6
7
  if (process.platform === 'win32') {
@@ -10,6 +11,20 @@ function isClipboardImagePasteKey(key) {
10
11
  }
11
12
  return key.ctrl && key.input === 'v' && !key.shift && !key.meta;
12
13
  }
14
+ function applyUserInputPromptEvent(store, handlers, event) {
15
+ const current = store.getState();
16
+ if (!current.userInputPrompt)
17
+ return false;
18
+ const outcome = handleUserInputPromptEvent(current.userInputPrompt, event, handlers.getUserInputViewport?.());
19
+ store.update((state) => ({
20
+ ...state,
21
+ userInputPrompt: outcome.state,
22
+ }));
23
+ if (outcome.result) {
24
+ void handlers.onResolveUserInput?.(outcome.result);
25
+ }
26
+ return true;
27
+ }
13
28
  function composerIsEmpty(state) {
14
29
  return (!state.input &&
15
30
  state.cursor === 0 &&
@@ -99,6 +114,16 @@ function scrollTranscript(store, handlers, delta) {
99
114
  };
100
115
  });
101
116
  }
117
+ function scrollTranscriptTo(store, handlers, offset) {
118
+ store.update((current) => {
119
+ const limit = handlers.getTranscriptScrollLimit?.() ??
120
+ current.transcript.reduce((total, entry) => total + 2 + (entry.body ? entry.body.split('\n').length : 0), 0);
121
+ return {
122
+ ...current,
123
+ transcriptScrollOffset: Math.max(0, Math.min(Math.trunc(offset), limit)),
124
+ };
125
+ });
126
+ }
102
127
  function scrollApprovalPreview(store, handlers, delta) {
103
128
  if (delta === 0)
104
129
  return;
@@ -125,6 +150,12 @@ function filterResumeSessionsLocal(sessions, filter, serverModels) {
125
150
  }
126
151
  export function handleShellKeyEvent(store, handlers, event) {
127
152
  if (event.kind === 'paste') {
153
+ if (applyUserInputPromptEvent(store, handlers, {
154
+ kind: 'paste',
155
+ text: event.text,
156
+ })) {
157
+ return;
158
+ }
128
159
  insertPastedText(store, handlers, event.text);
129
160
  return;
130
161
  }
@@ -147,6 +178,13 @@ export function handleShellKeyEvent(store, handlers, event) {
147
178
  return;
148
179
  }
149
180
  if (event.kind === 'contextMenu') {
181
+ if (store.getState().userInputPrompt) {
182
+ const text = (handlers.readClipboardText ?? readClipboardText)();
183
+ if (text) {
184
+ applyUserInputPromptEvent(store, handlers, { kind: 'paste', text });
185
+ }
186
+ return;
187
+ }
150
188
  pasteTextFromClipboard(store, handlers);
151
189
  return;
152
190
  }
@@ -154,6 +192,10 @@ export function handleShellKeyEvent(store, handlers, event) {
154
192
  scrollTranscript(store, handlers, Math.trunc(event.deltaLines));
155
193
  return;
156
194
  }
195
+ if (event.kind === 'transcriptScrollTo') {
196
+ scrollTranscriptTo(store, handlers, Number(event.offset ?? 0));
197
+ return;
198
+ }
157
199
  if (event.kind !== 'key')
158
200
  return;
159
201
  const key = event;
@@ -168,6 +210,10 @@ export function handleShellKeyEvent(store, handlers, event) {
168
210
  }
169
211
  };
170
212
  if (key.ctrl && key.input === 'c') {
213
+ if (state.userInputPrompt) {
214
+ handlers.onCtrlC?.();
215
+ return;
216
+ }
171
217
  if (state.sudoPrompt) {
172
218
  handlers.onSudoPasswordInput({ kind: 'cancel' });
173
219
  return;
@@ -200,6 +246,10 @@ export function handleShellKeyEvent(store, handlers, event) {
200
246
  handlers.onRequestExit();
201
247
  return;
202
248
  }
249
+ if (state.userInputPrompt &&
250
+ applyUserInputPromptEvent(store, handlers, key)) {
251
+ return;
252
+ }
203
253
  if (state.sudoPrompt) {
204
254
  if (key.escape) {
205
255
  handlers.onSudoPasswordInput({ kind: 'cancel' });
@@ -0,0 +1,568 @@
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 || event.returnKey) {
143
+ return { ...state, noteOpen: false };
144
+ }
145
+ if (event.tab)
146
+ return state;
147
+ if ((event.upArrow || event.downArrow) && !current.trim()) {
148
+ return moveCursor({
149
+ ...state,
150
+ customText: {
151
+ ...state.customText,
152
+ [question.id]: '',
153
+ },
154
+ noteOpen: false,
155
+ noteCursor: 0,
156
+ }, event.upArrow ? -1 : 1);
157
+ }
158
+ if (event.leftArrow) {
159
+ return {
160
+ ...state,
161
+ noteCursor: previousCharacterBoundary(current, cursor),
162
+ };
163
+ }
164
+ if (event.rightArrow) {
165
+ return {
166
+ ...state,
167
+ noteCursor: nextCharacterBoundary(current, cursor),
168
+ };
169
+ }
170
+ if (event.home) {
171
+ return { ...state, noteCursor: 0 };
172
+ }
173
+ if (event.end) {
174
+ return { ...state, noteCursor: current.length };
175
+ }
176
+ if (event.backspace && cursor > 0) {
177
+ const previous = previousCharacterBoundary(current, cursor);
178
+ return {
179
+ ...state,
180
+ customText: {
181
+ ...state.customText,
182
+ [question.id]: `${current.slice(0, previous)}${current.slice(cursor)}`,
183
+ },
184
+ noteCursor: previous,
185
+ };
186
+ }
187
+ if (event.delete && cursor < current.length) {
188
+ const next = nextCharacterBoundary(current, cursor);
189
+ return {
190
+ ...state,
191
+ customText: {
192
+ ...state.customText,
193
+ [question.id]: `${current.slice(0, cursor)}${current.slice(next)}`,
194
+ },
195
+ };
196
+ }
197
+ if (!event.ctrl && !event.meta && event.input) {
198
+ return insertNote(state, event.input);
199
+ }
200
+ return state;
201
+ }
202
+ function questionAnswered(state, question) {
203
+ return (selectedIds(state, question).length > 0 ||
204
+ Boolean((state.customText[question.id] ?? '').trim()));
205
+ }
206
+ function submitResult(state) {
207
+ const missing = state.questions.find((question) => !questionAnswered(state, question));
208
+ if (missing) {
209
+ const next = setQuestion(state, state.questions.indexOf(missing));
210
+ return {
211
+ state: {
212
+ ...next,
213
+ validationMessage: 'Choose an option or add details before submitting.',
214
+ },
215
+ };
216
+ }
217
+ return {
218
+ state,
219
+ result: {
220
+ status: 'submitted',
221
+ answers: Object.fromEntries(state.questions.map((question) => {
222
+ const customText = (state.customText[question.id] ?? '').trim();
223
+ return [
224
+ question.id,
225
+ {
226
+ selectedOptionIds: selectedIds(state, question),
227
+ ...(customText ? { customText } : {}),
228
+ },
229
+ ];
230
+ })),
231
+ },
232
+ };
233
+ }
234
+ export function createUserInputPromptState(questions, returnStatus) {
235
+ const first = questions[0];
236
+ return {
237
+ questions,
238
+ questionId: first.id,
239
+ cursorByQuestionId: Object.fromEntries(questions.map((question) => [question.id, defaultCursor(question)])),
240
+ selectedOptionIds: Object.fromEntries(questions.map((question) => [question.id, []])),
241
+ customText: Object.fromEntries(questions.map((question) => [question.id, ''])),
242
+ contentScrollAnchor: null,
243
+ contentScrollOffset: 0,
244
+ noteOpen: false,
245
+ noteCursor: 0,
246
+ returnStatus,
247
+ validationMessage: '',
248
+ };
249
+ }
250
+ export function handleUserInputPromptEvent(state, event, viewport) {
251
+ const visibleState = viewport
252
+ ? normalizeUserInputScrollState(state, viewport)
253
+ : state;
254
+ if (event.kind === 'paste') {
255
+ return {
256
+ state: visibleState.noteOpen ||
257
+ cursorForQuestion(visibleState, currentQuestion(visibleState)) ===
258
+ OTHER_CURSOR
259
+ ? insertNote(visibleState, event.text)
260
+ : visibleState,
261
+ };
262
+ }
263
+ if (event.pageUp || event.pageDown) {
264
+ if (!viewport) {
265
+ return {
266
+ state: {
267
+ ...visibleState,
268
+ contentScrollAnchor: null,
269
+ contentScrollOffset: visibleState.contentScrollOffset +
270
+ (event.pageUp ? -SCROLL_PAGE_ROWS : SCROLL_PAGE_ROWS),
271
+ },
272
+ };
273
+ }
274
+ const window = buildUserInputWindow(visibleState, viewport.width, viewport.maxRows);
275
+ return {
276
+ state: {
277
+ ...visibleState,
278
+ contentScrollAnchor: null,
279
+ contentScrollOffset: Math.max(0, Math.min(window.offset +
280
+ (event.pageUp ? -SCROLL_PAGE_ROWS : SCROLL_PAGE_ROWS), window.maxOffset)),
281
+ },
282
+ };
283
+ }
284
+ if (visibleState.noteOpen) {
285
+ return { state: editOpenNote(visibleState, event) };
286
+ }
287
+ if (event.escape) {
288
+ return { state: visibleState, result: { status: 'cancelled' } };
289
+ }
290
+ if (event.upArrow || event.downArrow) {
291
+ return {
292
+ state: moveCursor(visibleState, event.upArrow ? -1 : 1),
293
+ };
294
+ }
295
+ if (event.leftArrow) {
296
+ return {
297
+ state: setQuestion(visibleState, questionIndex(visibleState) - 1),
298
+ };
299
+ }
300
+ if (event.rightArrow) {
301
+ const index = questionIndex(visibleState);
302
+ if (index < visibleState.questions.length - 1) {
303
+ return { state: setQuestion(visibleState, index + 1) };
304
+ }
305
+ return {
306
+ state: setCursor(visibleState, currentQuestion(visibleState), SUBMIT_CURSOR),
307
+ };
308
+ }
309
+ const question = currentQuestion(visibleState);
310
+ const numberedRow = /^[1-5]$/.test(event.input)
311
+ ? Number(event.input)
312
+ : 0;
313
+ if (numberedRow && !event.ctrl && !event.meta) {
314
+ if (numberedRow === question.options.length + 1) {
315
+ return {
316
+ state: openCustomTextEditor(visibleState),
317
+ };
318
+ }
319
+ const numberedOption = question.options[numberedRow - 1];
320
+ if (numberedOption) {
321
+ return { state: selectOption(visibleState, numberedOption.id) };
322
+ }
323
+ }
324
+ if (event.returnKey) {
325
+ const cursor = cursorForQuestion(visibleState, question);
326
+ if (cursor === OTHER_CURSOR) {
327
+ return {
328
+ state: openCustomTextEditor(visibleState),
329
+ };
330
+ }
331
+ if (cursor === SUBMIT_CURSOR) {
332
+ return submitResult(visibleState);
333
+ }
334
+ return { state: selectOption(visibleState, cursor) };
335
+ }
336
+ if (!event.ctrl &&
337
+ !event.meta &&
338
+ event.input &&
339
+ event.input >= ' ' &&
340
+ cursorForQuestion(visibleState, currentQuestion(visibleState)) ===
341
+ OTHER_CURSOR) {
342
+ return { state: insertNote(visibleState, event.input) };
343
+ }
344
+ return { state: visibleState };
345
+ }
346
+ function noteInputLine(state, width) {
347
+ const question = currentQuestion(state);
348
+ const text = state.customText[question.id] ?? '';
349
+ const cursor = Math.max(0, Math.min(state.noteCursor, text.length));
350
+ const characters = Array.from(text);
351
+ const characterCursor = Array.from(text.slice(0, cursor)).length;
352
+ const available = Math.max(8, width - 8);
353
+ const windowStart = Math.max(0, characterCursor - available + 1);
354
+ const visible = characters.slice(windowStart, windowStart + available);
355
+ const visibleCursor = characterCursor - windowStart;
356
+ return line(span(' › ', { color: ACCENT_COLOR }), span(visible.slice(0, visibleCursor).join('')), span(visible[visibleCursor] ?? ' ', { inverse: true }), span(visible.slice(visibleCursor + 1).join('')));
357
+ }
358
+ function sliceWithinWidth(text, width) {
359
+ if (width <= 0)
360
+ return '';
361
+ let result = sliceToWidth(text, width);
362
+ while (result && displayWidth(result) > width) {
363
+ result = Array.from(result).slice(0, -1).join('');
364
+ }
365
+ return result;
366
+ }
367
+ function fitText(text, width) {
368
+ if (width <= 0)
369
+ return '';
370
+ if (displayWidth(text) <= width)
371
+ return text;
372
+ if (width === 1)
373
+ return '…';
374
+ return `${sliceWithinWidth(text, width - 1).trimEnd()}…`;
375
+ }
376
+ function clipLineToWidth(row, width) {
377
+ let remaining = Math.max(0, width);
378
+ const spans = [];
379
+ for (const item of row.spans) {
380
+ if (remaining <= 0)
381
+ break;
382
+ const text = sliceWithinWidth(item.text, remaining);
383
+ if (text)
384
+ spans.push({ ...item, text });
385
+ remaining -= displayWidth(text);
386
+ }
387
+ return { spans };
388
+ }
389
+ function buildUserInputContent(state, width) {
390
+ const question = currentQuestion(state);
391
+ const index = questionIndex(state);
392
+ const cursor = cursorForQuestion(state, question);
393
+ const selected = new Set(selectedIds(state, question));
394
+ let anchorRange = null;
395
+ const progress = `Question ${index + 1} of ${state.questions.length}`;
396
+ const headerPrefix = ' · ';
397
+ const headerBudget = Math.max(0, width - displayWidth(progress) - displayWidth(headerPrefix));
398
+ const lines = [
399
+ line(span(progress, {
400
+ color: ACCENT_COLOR,
401
+ bold: true,
402
+ }), ...(headerBudget > 0
403
+ ? [
404
+ span(`${headerPrefix}${fitText(question.header, headerBudget)}`, { color: 'gray' }),
405
+ ]
406
+ : [])),
407
+ plainLine(''),
408
+ ...wrapText(question.question, width).map((text) => plainLine(text, { bold: true })),
409
+ plainLine(''),
410
+ ];
411
+ question.options.forEach((option, optionIndex) => {
412
+ const focused = cursor === option.id;
413
+ const checked = selected.has(option.id);
414
+ const marker = question.multiSelect
415
+ ? checked
416
+ ? '[x]'
417
+ : '[ ]'
418
+ : checked
419
+ ? '●'
420
+ : '○';
421
+ const blockStart = lines.length;
422
+ const optionPrefix = `${optionIndex + 1}. ${marker} `;
423
+ const recommendation = option.id === question.recommendedOptionId ? ' Recommended' : '';
424
+ const labelBudget = Math.max(1, width -
425
+ displayWidth('› ') -
426
+ displayWidth(optionPrefix) -
427
+ displayWidth(recommendation));
428
+ lines.push(line(span(focused ? '› ' : ' ', {
429
+ color: focused ? ACCENT_COLOR : 'gray',
430
+ }), span(`${optionPrefix}${fitText(option.label, labelBudget)}`, {
431
+ color: focused ? ACCENT_COLOR : undefined,
432
+ bold: focused,
433
+ }), ...(recommendation
434
+ ? [span(recommendation, { color: 'green' })]
435
+ : [])));
436
+ for (const description of wrapText(option.description, Math.max(8, width - 5))) {
437
+ lines.push(plainLine(` ${description}`, { color: 'gray' }));
438
+ }
439
+ if (state.contentScrollAnchor === option.id) {
440
+ anchorRange = { start: blockStart, end: lines.length - 1 };
441
+ }
442
+ });
443
+ const customText = (state.customText[question.id] ?? '').trim();
444
+ const otherFocused = cursor === OTHER_CURSOR;
445
+ const otherAnswered = Boolean(customText);
446
+ const otherMarker = question.multiSelect
447
+ ? otherAnswered
448
+ ? '[x]'
449
+ : '[ ]'
450
+ : otherAnswered
451
+ ? '●'
452
+ : '○';
453
+ const otherNumber = question.options.length + 1;
454
+ const otherStart = lines.length;
455
+ lines.push(plainLine(''));
456
+ lines.push(line(span(otherFocused ? '› ' : ' ', {
457
+ color: otherFocused ? ACCENT_COLOR : 'gray',
458
+ }), span(`${otherNumber}. ${otherMarker} Something else / add details`, {
459
+ color: otherFocused ? ACCENT_COLOR : undefined,
460
+ bold: otherFocused,
461
+ }), ...(customText && !state.noteOpen
462
+ ? [
463
+ span(` · ${Array.from(customText)
464
+ .slice(0, Math.max(8, width - 28))
465
+ .join('')}`, { color: 'gray' }),
466
+ ]
467
+ : otherFocused && !state.noteOpen
468
+ ? [span(' · Type details…', { color: 'gray', dim: true })]
469
+ : [])));
470
+ if (state.noteOpen) {
471
+ lines.push(noteInputLine(state, width));
472
+ }
473
+ lines.push(plainLine(''));
474
+ if (state.contentScrollAnchor === OTHER_CURSOR) {
475
+ anchorRange = { start: otherStart, end: lines.length - 1 };
476
+ }
477
+ if (index === state.questions.length - 1) {
478
+ const submitStart = lines.length - 1;
479
+ lines.push(line(span(cursor === SUBMIT_CURSOR ? '› ' : ' ', {
480
+ color: cursor === SUBMIT_CURSOR ? ACCENT_COLOR : 'gray',
481
+ }), span('Submit answers', {
482
+ color: cursor === SUBMIT_CURSOR ? ACCENT_COLOR : undefined,
483
+ bold: cursor === SUBMIT_CURSOR,
484
+ })));
485
+ lines.push(plainLine(''));
486
+ if (state.contentScrollAnchor === SUBMIT_CURSOR) {
487
+ anchorRange = { start: submitStart, end: lines.length - 1 };
488
+ }
489
+ }
490
+ if (state.validationMessage) {
491
+ lines.push(plainLine(state.validationMessage, { color: 'yellow' }));
492
+ }
493
+ lines.push(plainLine(state.noteOpen
494
+ ? 'Type a single-line note • ↑/↓ moves when empty • Enter saves • Esc closes • PgUp/PgDn scroll'
495
+ : question.multiSelect
496
+ ? `↑/↓ move • 1–${otherNumber} choose • Enter toggles/edits • ←/→ questions • PgUp/PgDn scroll • Esc cancels`
497
+ : `↑/↓ move • 1–${otherNumber} choose • Enter selects/edits • ←/→ questions • PgUp/PgDn scroll • Esc cancels`, { color: 'gray' }));
498
+ return {
499
+ anchorRange,
500
+ lines: lines.map((row) => clipLineToWidth(row, width)),
501
+ };
502
+ }
503
+ function buildUserInputWindow(state, width, maxRows) {
504
+ const content = buildUserInputContent(state, width);
505
+ if (!Number.isFinite(maxRows) || content.lines.length <= maxRows) {
506
+ return { lines: content.lines, maxOffset: 0, offset: 0 };
507
+ }
508
+ const rowBudget = Math.max(1, Math.floor(maxRows));
509
+ const visibleRowBudget = rowBudget === 1 ? 1 : rowBudget - 1;
510
+ const maxOffset = Math.max(0, content.lines.length - visibleRowBudget);
511
+ let offset = Math.max(0, Math.min(state.contentScrollOffset, maxOffset));
512
+ const range = content.anchorRange;
513
+ if (range) {
514
+ const blockRows = range.end - range.start + 1;
515
+ if (blockRows <= visibleRowBudget) {
516
+ if (range.start < offset) {
517
+ offset = range.start;
518
+ }
519
+ else if (range.end >= offset + visibleRowBudget) {
520
+ offset = range.end - visibleRowBudget + 1;
521
+ }
522
+ }
523
+ else if (range.start < offset ||
524
+ range.start >= offset + visibleRowBudget) {
525
+ offset = range.start;
526
+ }
527
+ offset = Math.max(0, Math.min(offset, maxOffset));
528
+ }
529
+ const visible = content.lines.slice(offset, offset + visibleRowBudget);
530
+ if (rowBudget === 1) {
531
+ return { lines: visible, maxOffset, offset };
532
+ }
533
+ return {
534
+ lines: [
535
+ ...visible,
536
+ plainLine(fitText(`Rows ${offset + 1}–${offset + visible.length} of ${content.lines.length} · PgUp/PgDn scroll`, width), { color: 'gray' }),
537
+ ],
538
+ maxOffset,
539
+ offset,
540
+ };
541
+ }
542
+ export function normalizeUserInputScrollState(state, viewport) {
543
+ const window = buildUserInputWindow(state, viewport.width, viewport.maxRows);
544
+ return window.offset === state.contentScrollOffset
545
+ ? state
546
+ : { ...state, contentScrollOffset: window.offset };
547
+ }
548
+ export function buildUserInputOverlayLines(state, width, maxRows = Number.POSITIVE_INFINITY) {
549
+ return buildUserInputWindow(state, width, maxRows).lines;
550
+ }
551
+ export function formatUserInputTranscript(questions, result) {
552
+ if (result.status === 'cancelled') {
553
+ return 'Cancelled without answers.';
554
+ }
555
+ return questions
556
+ .map((question) => {
557
+ const answer = result.answers[question.id];
558
+ const selected = question.options
559
+ .filter((option) => answer?.selectedOptionIds.includes(option.id))
560
+ .map((option) => option.label);
561
+ const values = [
562
+ ...selected,
563
+ ...(answer?.customText ? [answer.customText] : []),
564
+ ];
565
+ return `${question.header} · ${question.question}\n ${values.join(', ')}`;
566
+ })
567
+ .join('\n');
568
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thegitai/cli",
3
- "version": "1.0.0-preview.13",
3
+ "version": "1.0.0-preview.15",
4
4
  "description": "TheGitAI is an AI coding agent for your terminal. It indexes your repository, writes and edits files, runs commands, and builds features with you.",
5
5
  "keywords": [
6
6
  "ai",
@@ -37,10 +37,10 @@
37
37
  "@lydell/node-pty-linux-x64": "1.1.0",
38
38
  "@lydell/node-pty-win32-arm64": "1.1.0",
39
39
  "@lydell/node-pty-win32-x64": "1.1.0",
40
- "@thegitai/tui-darwin-arm64": "1.0.0-preview.13",
41
- "@thegitai/tui-darwin-x64": "1.0.0-preview.13",
42
- "@thegitai/tui-linux-x64": "1.0.0-preview.13",
43
- "@thegitai/tui-win32-x64": "1.0.0-preview.13",
40
+ "@thegitai/tui-darwin-arm64": "1.0.0-preview.15",
41
+ "@thegitai/tui-darwin-x64": "1.0.0-preview.15",
42
+ "@thegitai/tui-linux-x64": "1.0.0-preview.15",
43
+ "@thegitai/tui-win32-x64": "1.0.0-preview.15",
44
44
  "@vscode/ripgrep": "1.18.0"
45
45
  },
46
46
  "publishConfig": {