@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 +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/build-frame.js +56 -11
- package/dist/src/ui/tui/shell-input.js +36 -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,
|
|
@@ -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.
|
|
458
|
-
?
|
|
459
|
-
:
|
|
460
|
-
?
|
|
461
|
-
: process.platform === '
|
|
462
|
-
? 'Enter sends • Shift+Tab mode •
|
|
463
|
-
:
|
|
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
|
|
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
|
-
|
|
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 ||
|
|
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.
|
|
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.
|
|
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.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": {
|