@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 +16 -0
- package/dist/src/api/chat.js +52 -0
- package/dist/src/api/contracts.js +55 -1
- package/dist/src/session.js +2 -1
- package/dist/src/ui/repl.js +94 -1
- package/dist/src/ui/tui/bridge.js +7 -0
- package/dist/src/ui/tui/build-frame.js +93 -20
- package/dist/src/ui/tui/markdown-render.js +11 -2
- package/dist/src/ui/tui/shell-input.js +50 -0
- package/dist/src/ui/tui/user-input.js +568 -0
- package/package.json +5 -5
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
|
package/dist/src/api/chat.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/src/session.js
CHANGED
|
@@ -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,
|
package/dist/src/ui/repl.js
CHANGED
|
@@ -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.
|
|
458
|
-
?
|
|
459
|
-
:
|
|
460
|
-
?
|
|
461
|
-
: process.platform === '
|
|
462
|
-
? 'Enter sends • Shift+Tab mode •
|
|
463
|
-
:
|
|
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
|
|
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
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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.
|
|
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.
|
|
41
|
-
"@thegitai/tui-darwin-x64": "1.0.0-preview.
|
|
42
|
-
"@thegitai/tui-linux-x64": "1.0.0-preview.
|
|
43
|
-
"@thegitai/tui-win32-x64": "1.0.0-preview.
|
|
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": {
|