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

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,
@@ -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;
@@ -454,13 +456,15 @@ function composerFooterLines(state) {
454
456
  : state.input
455
457
  ? 'Enter queues • Esc cancels turn • Ctrl+C clears draft'
456
458
  : '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';
459
+ const helperText = state.userInputPrompt
460
+ ? 'Answering agent questions • Ctrl+C cancels turn'
461
+ : state.busy
462
+ ? busyHelperText
463
+ : process.platform === 'win32'
464
+ ? 'Enter sends • Shift+Tab mode • Alt+V image • Esc cancel turn • Ctrl+C clears / quits'
465
+ : process.platform === 'darwin'
466
+ ? 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C clears / quits'
467
+ : 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C clears / quits';
464
468
  const agentLabel = agentModeLabel(state.agentMode).padEnd(AGENT_MODE_LABEL_WIDTH);
465
469
  const tokenUsageText = state.tokenUsage || formatClientTokenUsage(null);
466
470
  const footerSpans = [
@@ -661,11 +665,23 @@ function overlayPanelLine(row, width, color) {
661
665
  const padding = Math.max(0, width - lineCharCount(row));
662
666
  return line(span('│ ', { color }), ...row.spans, span(' '.repeat(padding)), span(' │', { color }));
663
667
  }
668
+ function overlayPanelMarginLineCount(height) {
669
+ return height < OVERLAY_PANEL_MARGIN_MIN_ROWS
670
+ ? 0
671
+ : OVERLAY_PANEL_MARGIN_LINES;
672
+ }
673
+ function overlayPanelContentBudget(height) {
674
+ if (!Number.isFinite(height)) {
675
+ return Number.POSITIVE_INFINITY;
676
+ }
677
+ const margins = overlayPanelMarginLineCount(height) * 2;
678
+ return Math.max(1, Math.floor(height) - margins - 2);
679
+ }
664
680
  function buildOverlayPanel(rows, width, color, height = Number.POSITIVE_INFINITY) {
665
681
  const panelWidth = Math.max(24, Math.min(width, OVERLAY_PANEL_MAX_WIDTH));
666
682
  const innerWidth = Math.max(1, panelWidth - 4);
667
683
  const margin = Array.from({
668
- length: height < OVERLAY_PANEL_MARGIN_MIN_ROWS ? 0 : OVERLAY_PANEL_MARGIN_LINES,
684
+ length: overlayPanelMarginLineCount(height),
669
685
  }, () => plainLine(''));
670
686
  return [
671
687
  ...margin,
@@ -990,6 +1006,12 @@ function buildOverlayLines(state, width, height, nowMs) {
990
1006
  lines.push(plainLine('Press Enter to submit, Escape to cancel', { color: 'gray' }));
991
1007
  return buildOverlayPanel(lines, width, OVERLAY_BORDER_COLOR, height);
992
1008
  }
1009
+ if (state.userInputPrompt) {
1010
+ if (height < 3) {
1011
+ return [];
1012
+ }
1013
+ return buildOverlayPanel(buildUserInputOverlayLines(state.userInputPrompt, innerWidth, overlayPanelContentBudget(height)), width, USER_INPUT_BORDER_COLOR, height);
1014
+ }
993
1015
  if (state.approvalPrompt) {
994
1016
  const prompt = state.approvalPrompt;
995
1017
  const padded = approvalPaddingEnabled(height);
@@ -1111,6 +1133,15 @@ function buildOverlayLines(state, width, height, nowMs) {
1111
1133
  function countSectionLines(sections) {
1112
1134
  return sections.reduce((sum, section) => sum + section.lines.length, 0);
1113
1135
  }
1136
+ export function userInputViewportForFrame(state, cols, rows, spinnerFrame, elapsedSeconds, nowMs = 0) {
1137
+ const contentWidth = Math.max(20, Math.floor(cols * 0.95) - 2);
1138
+ const liveRows = buildLiveLines(state, contentWidth, spinnerFrame, elapsedSeconds, nowMs).length;
1139
+ const overlayHeight = Math.max(0, rows - liveRows - composerFooterLines(state).length);
1140
+ return {
1141
+ width: approvalPanelInnerWidth(contentWidth),
1142
+ maxRows: overlayPanelContentBudget(overlayHeight),
1143
+ };
1144
+ }
1114
1145
  function sliceTranscriptLines(lines, maxLines, scrollOffset) {
1115
1146
  if (maxLines <= 0 || lines.length <= maxLines) {
1116
1147
  return lines;
@@ -1136,7 +1167,7 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, n
1136
1167
  if (liveLines.length > 0) {
1137
1168
  sections.push({ kind: 'live', lines: liveLines });
1138
1169
  }
1139
- const overlayActive = Boolean(state.approvalPrompt || state.sudoPrompt);
1170
+ const overlayActive = Boolean(state.approvalPrompt || state.sudoPrompt || state.userInputPrompt);
1140
1171
  if (!state.resumePickerOpen && !state.modelPickerOpen && !state.jobsPickerOpen && !overlayActive) {
1141
1172
  const composerLines = [];
1142
1173
  if (state.busy && state.status === 'Starting a new conversation...') {
@@ -1161,12 +1192,26 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, n
1161
1192
  lines: [...composerLines, plainLine(''), ...composerFooterLines(state)],
1162
1193
  });
1163
1194
  }
1164
- const overlayLines = buildOverlayLines(state, contentWidth, rows, nowMs);
1195
+ if (state.userInputPrompt) {
1196
+ sections.push({
1197
+ kind: 'busyFooter',
1198
+ lines: composerFooterLines(state),
1199
+ });
1200
+ }
1201
+ const overlayHeight = state.userInputPrompt
1202
+ ? Math.max(0, rows - countSectionLines(sections))
1203
+ : rows;
1204
+ const overlayLines = buildOverlayLines(state, contentWidth, overlayHeight, nowMs);
1165
1205
  if (overlayLines.length > 0) {
1166
1206
  sections.push({ kind: 'overlay', lines: overlayLines });
1167
1207
  }
1168
1208
  const reservedLines = countSectionLines(sections.filter((section) => section.kind !== 'transcript'));
1169
- const composerReserve = state.resumePickerOpen || state.approvalPrompt || state.sudoPrompt ? 0 : 4;
1209
+ const composerReserve = state.resumePickerOpen ||
1210
+ state.approvalPrompt ||
1211
+ state.sudoPrompt ||
1212
+ state.userInputPrompt
1213
+ ? 0
1214
+ : 4;
1170
1215
  const transcriptBudget = Math.max(1, rows - reservedLines - composerReserve - 1);
1171
1216
  const transcriptScrollLimit = Math.max(0, transcriptLines.length - transcriptBudget);
1172
1217
  const transcriptScrollOffset = Math.min(Math.max(state.transcriptScrollOffset, 0), transcriptScrollLimit);
@@ -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 &&
@@ -125,6 +140,12 @@ function filterResumeSessionsLocal(sessions, filter, serverModels) {
125
140
  }
126
141
  export function handleShellKeyEvent(store, handlers, event) {
127
142
  if (event.kind === 'paste') {
143
+ if (applyUserInputPromptEvent(store, handlers, {
144
+ kind: 'paste',
145
+ text: event.text,
146
+ })) {
147
+ return;
148
+ }
128
149
  insertPastedText(store, handlers, event.text);
129
150
  return;
130
151
  }
@@ -147,6 +168,13 @@ export function handleShellKeyEvent(store, handlers, event) {
147
168
  return;
148
169
  }
149
170
  if (event.kind === 'contextMenu') {
171
+ if (store.getState().userInputPrompt) {
172
+ const text = (handlers.readClipboardText ?? readClipboardText)();
173
+ if (text) {
174
+ applyUserInputPromptEvent(store, handlers, { kind: 'paste', text });
175
+ }
176
+ return;
177
+ }
150
178
  pasteTextFromClipboard(store, handlers);
151
179
  return;
152
180
  }
@@ -168,6 +196,10 @@ export function handleShellKeyEvent(store, handlers, event) {
168
196
  }
169
197
  };
170
198
  if (key.ctrl && key.input === 'c') {
199
+ if (state.userInputPrompt) {
200
+ handlers.onCtrlC?.();
201
+ return;
202
+ }
171
203
  if (state.sudoPrompt) {
172
204
  handlers.onSudoPasswordInput({ kind: 'cancel' });
173
205
  return;
@@ -200,6 +232,10 @@ export function handleShellKeyEvent(store, handlers, event) {
200
232
  handlers.onRequestExit();
201
233
  return;
202
234
  }
235
+ if (state.userInputPrompt &&
236
+ applyUserInputPromptEvent(store, handlers, key)) {
237
+ return;
238
+ }
203
239
  if (state.sudoPrompt) {
204
240
  if (key.escape) {
205
241
  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.14",
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.14",
41
+ "@thegitai/tui-darwin-x64": "1.0.0-preview.14",
42
+ "@thegitai/tui-linux-x64": "1.0.0-preview.14",
43
+ "@thegitai/tui-win32-x64": "1.0.0-preview.14",
44
44
  "@vscode/ripgrep": "1.18.0"
45
45
  },
46
46
  "publishConfig": {