@thegitai/cli 1.0.0-preview.12 → 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
+ }
@@ -596,7 +596,7 @@ export function commandUsesSudo(command) {
596
596
  }
597
597
  export function sudoPromptFromTail(text) {
598
598
  const tail = text.slice(-1000).replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
599
- const match = tail.match(/(?:\[sudo\][^\r\n]*password[^\r\n]*: ?|sudo[^\r\n]*password[^\r\n]*: ?|password[^\r\n]*: ?)$/i);
599
+ const match = tail.match(/(?:\[sudo\][^\r\n]*password[^\r\n]*: ?|\[?sudo[^\r\n]*password[^\r\n]*: ?|password[^\r\n]*: ?)$/i);
600
600
  return match?.[0] ?? null;
601
601
  }
602
602
  function isSudoPromptLine(text) {
@@ -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 { 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;
@@ -966,9 +990,11 @@ function createInitialShellState(session, serverModels, debugUi) {
966
990
  analyzingImages: 0,
967
991
  approvalCursor: getDefaultApprovalCursor(),
968
992
  approvalPrompt: null,
993
+ approvalScrollOffset: 0,
969
994
  autoYes: session.autoYes,
970
995
  backgroundJobs: [],
971
996
  busy: false,
997
+ busyPausedAt: null,
972
998
  busySince: null,
973
999
  clockNow: Date.now(),
974
1000
  commandCursor: 0,
@@ -1006,6 +1032,7 @@ function createInitialShellState(session, serverModels, debugUi) {
1006
1032
  tokenUsage: formatClientTokenUsage(null),
1007
1033
  transcript: [],
1008
1034
  turnCounter: Math.max(0, session.history.filter((entry) => entry.role === 'user').length),
1035
+ userInputPrompt: null,
1009
1036
  pastedChunks: [],
1010
1037
  imageAttachments: [],
1011
1038
  workingTools: [],
@@ -1265,6 +1292,30 @@ export function resolveApprovalChoiceFromInput(input) {
1265
1292
  return 'n';
1266
1293
  return null;
1267
1294
  }
1295
+ export function pauseBusyClock(state, nowMs) {
1296
+ if (state.busySince === null || state.busyPausedAt !== null)
1297
+ return state;
1298
+ return { ...state, busyPausedAt: nowMs };
1299
+ }
1300
+ export function resumeBusyClock(state, nowMs) {
1301
+ if (state.busyPausedAt === null)
1302
+ return state;
1303
+ return {
1304
+ ...state,
1305
+ busyPausedAt: null,
1306
+ busySince: state.busySince === null
1307
+ ? null
1308
+ : state.busySince + Math.max(0, nowMs - state.busyPausedAt),
1309
+ };
1310
+ }
1311
+ export function busyElapsedMs(state, nowMs) {
1312
+ if (state.busySince === null)
1313
+ return null;
1314
+ return Math.max(0, (state.busyPausedAt ?? nowMs) - state.busySince);
1315
+ }
1316
+ export function busyElapsedSeconds(state, nowMs) {
1317
+ return Math.floor((busyElapsedMs(state, nowMs) ?? 0) / 1000);
1318
+ }
1268
1319
  export function buildModelPickerOptions(currentModelId, serverModels) {
1269
1320
  return serverModels.map((model) => ({
1270
1321
  id: model.id,
@@ -1325,6 +1376,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1325
1376
  let resolveDone = null;
1326
1377
  let resolveApprovalChoice = null;
1327
1378
  let resolveSudoPassword = null;
1379
+ let pendingUserInput = null;
1328
1380
  let cleanupSudoPasswordPrompt = null;
1329
1381
  let sudoPasswordBuffer = '';
1330
1382
  const bridge = createRatatuiBridge();
@@ -1419,9 +1471,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1419
1471
  return;
1420
1472
  const state = store.getState();
1421
1473
  syncTerminalTitle();
1422
- const elapsedSeconds = state.busySince
1423
- ? Math.max(0, Math.floor((Date.now() - state.busySince) / 1000))
1424
- : 0;
1474
+ const elapsedSeconds = busyElapsedSeconds(state, Date.now());
1425
1475
  bridge.render(buildTuiFrame(state, terminalCols, terminalRows, spinnerFrame, elapsedSeconds, Date.now()));
1426
1476
  };
1427
1477
  const remountTui = async () => {
@@ -1553,9 +1603,10 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1553
1603
  resolveApprovalChoice = null;
1554
1604
  pendingResolve('n');
1555
1605
  store.update((current) => ({
1556
- ...current,
1606
+ ...resumeBusyClock(current, Date.now()),
1557
1607
  approvalCursor: getDefaultApprovalCursor(),
1558
1608
  approvalPrompt: null,
1609
+ approvalScrollOffset: 0,
1559
1610
  }));
1560
1611
  };
1561
1612
  const dismissPendingSudoPassword = () => {
@@ -1568,16 +1619,32 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1568
1619
  sudoPasswordBuffer = '';
1569
1620
  pendingResolve(null);
1570
1621
  store.update((current) => ({
1571
- ...current,
1622
+ ...resumeBusyClock(current, Date.now()),
1572
1623
  sudoPrompt: null,
1573
1624
  }));
1574
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
+ };
1575
1641
  const cancelActiveTurn = () => {
1576
1642
  if (!store.getState().busy || newConversationInFlight)
1577
1643
  return;
1578
1644
  disarmExitConfirm();
1579
1645
  dismissPendingApproval();
1580
1646
  dismissPendingSudoPassword();
1647
+ dismissPendingUserInput();
1581
1648
  activeTurnGeneration += 1;
1582
1649
  resetThinkingPacer();
1583
1650
  activeTurnAbort?.abort();
@@ -1597,6 +1664,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1597
1664
  activeTurnInput: '',
1598
1665
  activeTurnInputPreformatted: false,
1599
1666
  busy: false,
1667
+ busyPausedAt: null,
1600
1668
  busySince: null,
1601
1669
  commandLog: [],
1602
1670
  cursor: queued ? queued.body.length : current.cursor,
@@ -1610,7 +1678,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1610
1678
  thinkingTitle: '',
1611
1679
  thinkingNotes: [],
1612
1680
  workingTools: [],
1613
- tokenUsage: formatClientTokenUsage(current.busySince == null ? null : Date.now() - current.busySince, latestUsageSummary),
1681
+ tokenUsage: formatClientTokenUsage(busyElapsedMs(current, Date.now()), latestUsageSummary),
1614
1682
  }));
1615
1683
  appendStaticEntries(cancelledEntries);
1616
1684
  lastTurnStartedAt = null;
@@ -1696,6 +1764,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1696
1764
  }
1697
1765
  dismissPendingApproval();
1698
1766
  dismissPendingSudoPassword();
1767
+ dismissPendingUserInput();
1699
1768
  exiting = true;
1700
1769
  killAllBackgroundJobs();
1701
1770
  store.update((current) => ({
@@ -1761,7 +1830,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1761
1830
  cleanupSudoPasswordPrompt = null;
1762
1831
  }
1763
1832
  store.update((current) => ({
1764
- ...current,
1833
+ ...pauseBusyClock(current, Date.now()),
1765
1834
  sudoPrompt: {
1766
1835
  command,
1767
1836
  passwordLength: 0,
@@ -1781,7 +1850,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1781
1850
  cleanupSudoPasswordPrompt = null;
1782
1851
  sudoPasswordBuffer = '';
1783
1852
  store.update((current) => ({
1784
- ...current,
1853
+ ...resumeBusyClock(current, Date.now()),
1785
1854
  sudoPrompt: null,
1786
1855
  status: current.sudoPrompt?.returnStatus ?? current.status,
1787
1856
  }));
@@ -1795,7 +1864,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1795
1864
  cleanupSudoPasswordPrompt = null;
1796
1865
  sudoPasswordBuffer = '';
1797
1866
  store.update((current) => ({
1798
- ...current,
1867
+ ...resumeBusyClock(current, Date.now()),
1799
1868
  sudoPrompt: null,
1800
1869
  status: current.sudoPrompt?.returnStatus ?? current.status,
1801
1870
  }));
@@ -1815,6 +1884,49 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1815
1884
  : null,
1816
1885
  }));
1817
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
+ };
1818
1930
  const openApprovalPrompt = (title, body, options = {}) => new Promise((resolve) => {
1819
1931
  if (exiting) {
1820
1932
  resolve('n');
@@ -1822,8 +1934,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1822
1934
  }
1823
1935
  resolveApprovalChoice = resolve;
1824
1936
  store.update((current) => ({
1825
- ...current,
1937
+ ...pauseBusyClock(current, Date.now()),
1826
1938
  approvalCursor: getDefaultApprovalCursor(),
1939
+ approvalScrollOffset: 0,
1827
1940
  approvalPrompt: {
1828
1941
  title,
1829
1942
  body,
@@ -1841,9 +1954,10 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1841
1954
  const pendingResolve = resolveApprovalChoice;
1842
1955
  resolveApprovalChoice = null;
1843
1956
  store.update((next) => ({
1844
- ...next,
1957
+ ...resumeBusyClock(next, Date.now()),
1845
1958
  approvalCursor: getDefaultApprovalCursor(),
1846
1959
  approvalPrompt: null,
1960
+ approvalScrollOffset: 0,
1847
1961
  status: current.approvalPrompt?.returnStatus ?? next.status,
1848
1962
  }));
1849
1963
  pendingResolve?.(choice);
@@ -2051,6 +2165,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2051
2165
  store.update((next) => ({
2052
2166
  ...next,
2053
2167
  busy: true,
2168
+ busyPausedAt: null,
2054
2169
  busySince: resumeStartedAt,
2055
2170
  clockNow: resumeStartedAt,
2056
2171
  status: 'Loading session...',
@@ -2067,6 +2182,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2067
2182
  store.update((next) => ({
2068
2183
  ...next,
2069
2184
  busy: false,
2185
+ busyPausedAt: null,
2070
2186
  busySince: null,
2071
2187
  resumePickerFilter: '',
2072
2188
  resumePickerIndex: 0,
@@ -2090,6 +2206,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2090
2206
  store.update((next) => ({
2091
2207
  ...next,
2092
2208
  busy: false,
2209
+ busyPausedAt: null,
2093
2210
  busySince: null,
2094
2211
  resumePickerFilter: '',
2095
2212
  resumePickerIndex: 0,
@@ -2315,6 +2432,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2315
2432
  store.update((current) => ({
2316
2433
  ...current,
2317
2434
  busy: true,
2435
+ busyPausedAt: null,
2318
2436
  busySince: Date.now(),
2319
2437
  imageAttachments: [],
2320
2438
  queuedMessage: null,
@@ -2325,6 +2443,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2325
2443
  store.update((current) => ({
2326
2444
  ...current,
2327
2445
  busy: false,
2446
+ busyPausedAt: null,
2328
2447
  busySince: null,
2329
2448
  status: 'Ready',
2330
2449
  }));
@@ -2352,6 +2471,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2352
2471
  store.update((current) => ({
2353
2472
  ...current,
2354
2473
  busy: false,
2474
+ busyPausedAt: null,
2355
2475
  busySince: null,
2356
2476
  status: 'Ready',
2357
2477
  }));
@@ -2384,6 +2504,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2384
2504
  activeTurnInputPreformatted: preformatted,
2385
2505
  analyzingImages: 0,
2386
2506
  busy: true,
2507
+ busyPausedAt: null,
2387
2508
  busySince: turnStartedAt,
2388
2509
  clockNow: turnStartedAt,
2389
2510
  status: 'Running turn...',
@@ -2445,6 +2566,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2445
2566
  store.update((current) => ({
2446
2567
  ...current,
2447
2568
  busy: false,
2569
+ busyPausedAt: null,
2448
2570
  busySince: null,
2449
2571
  status: result.waitingForApproval ? 'Awaiting approval' : 'Ready',
2450
2572
  tokenUsage: formatClientTokenUsage(Date.now() - turnStartedAt, latestUsageSummary),
@@ -2466,6 +2588,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2466
2588
  activeTurnInput: '',
2467
2589
  activeTurnInputPreformatted: false,
2468
2590
  busy: false,
2591
+ busyPausedAt: null,
2469
2592
  busySince: null,
2470
2593
  commandLog: [],
2471
2594
  imageAttachments: [],
@@ -2517,7 +2640,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2517
2640
  store.update((current) => ({
2518
2641
  ...current,
2519
2642
  status: message,
2520
- tokenUsage: formatClientTokenUsage(current.busySince == null ? null : Date.now() - current.busySince, latestUsageSummary),
2643
+ tokenUsage: formatClientTokenUsage(busyElapsedMs(current, Date.now()), latestUsageSummary),
2521
2644
  }));
2522
2645
  queueThinkingUpdate({
2523
2646
  title: panel ? panel.title : liveStatusPanelTitle(message),
@@ -2528,7 +2651,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2528
2651
  store.update((current) => ({
2529
2652
  ...current,
2530
2653
  contextStatus: message,
2531
- tokenUsage: formatClientTokenUsage(current.busySince == null ? null : Date.now() - current.busySince, latestUsageSummary),
2654
+ tokenUsage: formatClientTokenUsage(busyElapsedMs(current, Date.now()), latestUsageSummary),
2532
2655
  }));
2533
2656
  };
2534
2657
  session.onToolEvent = (event) => {
@@ -2597,6 +2720,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2597
2720
  projectIndex.onStatus = session.onStatus;
2598
2721
  projectIndex.onContextLog = session.onContextLog;
2599
2722
  session.requestSudoPassword = async ({ command, prompt, signal }) => openSudoPasswordPrompt(command, prompt, signal);
2723
+ session.requestUserInput = async (request, signal) => openUserInputPrompt(request.questions, signal);
2600
2724
  session.confirmCommand = async (command) => {
2601
2725
  if (exiting)
2602
2726
  return false;
@@ -2637,11 +2761,20 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2637
2761
  return choice === 'y';
2638
2762
  };
2639
2763
  const shellInputHandlers = {
2764
+ getApprovalScrollLimit: () => {
2765
+ const contentWidth = Math.max(20, Math.floor(terminalCols * 0.95) - 2);
2766
+ return approvalScrollLimit(store.getState().approvalPrompt, contentWidth, terminalRows);
2767
+ },
2640
2768
  getTranscriptScrollLimit: () => {
2641
2769
  const contentWidth = Math.max(20, Math.floor(terminalCols * 0.95) - 2);
2642
2770
  const blocks = store.getState().transcript.map((entry) => renderTranscriptEntryLines(entry, contentWidth));
2643
2771
  return blocks.reduce((total, block, index) => total + block.length + (index > 0 ? 1 : 0), 0);
2644
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
+ },
2645
2778
  onCancelTurn: cancelActiveTurn,
2646
2779
  onCycleAgentMode: cycleAgentMode,
2647
2780
  onCtrlC: handleCtrlC,
@@ -2650,6 +2783,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2650
2783
  onLiveFrameShapeChange: scheduleLiveFrameRemount,
2651
2784
  onRequestExit: requestExit,
2652
2785
  onResolveApproval: handleInlineApprovalChoice,
2786
+ onResolveUserInput: handleInlineUserInput,
2653
2787
  onResumeSession: handleInlineResumeSelection,
2654
2788
  onSelectionCopy: handleAppSelectionCopy,
2655
2789
  onSelectModel: handleInlineModelSelection,