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

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.
@@ -248,6 +248,26 @@ async function postUserInputResult({ config, turnId, requestId, result, fetchImp
248
248
  throw await readErrorResponse(response, trace.traceId);
249
249
  }
250
250
  }
251
+ export async function postInterjection({ config, turnId, text, messageId, fetchImpl = globalThis.fetch, traceId, }) {
252
+ const payload = { text, messageId };
253
+ const trace = createTraceContext(traceId);
254
+ const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/chat/turn/${encodeURIComponent(turnId)}/interject`, {
255
+ method: 'POST',
256
+ headers: {
257
+ authorization: `Bearer ${config.token}`,
258
+ 'content-type': 'application/json',
259
+ ...trace.headers,
260
+ },
261
+ body: JSON.stringify(payload),
262
+ });
263
+ if (response.status === 410) {
264
+ return 'stale';
265
+ }
266
+ if (!response.ok) {
267
+ throw await readErrorResponse(response, trace.traceId);
268
+ }
269
+ return 'delivered';
270
+ }
251
271
  const turnIdOverrides = new WeakMap();
252
272
  function enterServerTurnId(session, serverSessionTurnId) {
253
273
  const active = turnIdOverrides.get(session);
@@ -309,7 +329,7 @@ async function executeAndPostToolResult({ config, projectIndex, session, event,
309
329
  }
310
330
  }
311
331
  }
312
- async function consumeTurnStream({ response, config, projectIndex, session, input, fetchImpl, signal, traceId, }) {
332
+ async function consumeTurnStream({ response, config, projectIndex, session, input, fetchImpl, signal, traceId, onTurnStart, onInterjectionDelivered, }) {
313
333
  if (!response.body) {
314
334
  throw new Error('Server returned an empty chat stream.');
315
335
  }
@@ -345,6 +365,16 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
345
365
  }
346
366
  }
347
367
  async function handleEvent(event) {
368
+ if (event.event === 'turn-start') {
369
+ const turnId = String(event.data?.turnId ?? '').trim();
370
+ if (turnId)
371
+ onTurnStart?.(turnId);
372
+ return;
373
+ }
374
+ if (event.event === 'interjection-delivered') {
375
+ onInterjectionDelivered?.(event.data);
376
+ return;
377
+ }
348
378
  if (event.event === 'status') {
349
379
  const data = event.data;
350
380
  if (data?.phase === 'analyzing_image') {
@@ -478,7 +508,7 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
478
508
  }
479
509
  return finalResult.current;
480
510
  }
481
- export async function sendServerUserMessage({ config, projectIndex, session, input, imageAttachments = [], fetchImpl = globalThis.fetch, signal, }) {
511
+ export async function sendServerUserMessage({ config, projectIndex, session, input, imageAttachments = [], fetchImpl = globalThis.fetch, signal, onTurnStart, onInterjectionDelivered, }) {
482
512
  const autoAttach = autoAttachImages(input, session.rootDir, imageAttachments);
483
513
  const requestImageAttachments = autoAttach.attachments.length > 0
484
514
  ? [...imageAttachments, ...autoAttach.attachments]
@@ -535,6 +565,8 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
535
565
  fetchImpl,
536
566
  signal,
537
567
  traceId: trace.traceId,
568
+ onTurnStart,
569
+ onInterjectionDelivered,
538
570
  });
539
571
  applySessionSnapshot(session, result.snapshot, { preserveAgentMode: true });
540
572
  return {
@@ -59,6 +59,13 @@ const HELP_MARKDOWN = [
59
59
  '- **Enter** sends • **Shift+Tab** cycles modes • **Esc** cancels the turn •',
60
60
  ' **Ctrl+C** clears the composer or the queued message, and quits once there',
61
61
  ' is nothing left to clear. These are the same on macOS, Linux, and Windows.',
62
+ '- **While the agent is working**, Enter queues your message and locks the',
63
+ ' composer. Press **Enter** again to send it into the running turn: the agent',
64
+ ' picks it up at its next step instead of waiting for the turn to finish, and',
65
+ ' the Your messages panel tracks it from queued to delivered. **↑** brings it',
66
+ ' back for editing and **Esc**/**Ctrl+C** discards it; both unlock the',
67
+ ' composer for the next message. Messages with images cannot be sent mid-turn',
68
+ ' and are sent with the next prompt instead.',
62
69
  `- **Paste** into the composer with your terminal's paste shortcut (\`${PASTE_SHORTCUT}\``,
63
70
  ' on this system) or by right-clicking the composer.',
64
71
  '- **Copy** from the transcript by dragging to select; double-click copies a',
@@ -85,8 +92,11 @@ const HELP_MARKDOWN = [
85
92
  '## Safety & approvals',
86
93
  '',
87
94
  '- TheGitAI asks before running shell commands or applying file edits.',
88
- '- At each prompt: **y** approves once, **a** approves the rest of the',
89
- ' session, **n** denies.',
95
+ '- At each prompt: **↑/↓** moves between choices, **Enter** confirms the',
96
+ ' highlighted one, and **Esc** denies. Deny is selected by default, and the',
97
+ ' choices are Approve once, Approve all remaining actions, and Deny.',
98
+ ' Single-letter shortcuts were removed on purpose: a prompt can appear',
99
+ ' while you are typing, and a stray letter must never approve anything.',
90
100
  '- If an approved `sudo` command needs a password, the TUI shows the exact',
91
101
  ' command and keeps the password masked and local.',
92
102
  '- `-y` / `--yes` at startup auto-approves every shell command and file',
@@ -852,6 +852,13 @@ export function buildTranscriptFromSessionHistory(history) {
852
852
  }
853
853
  continue;
854
854
  }
855
+ if (entry.role === 'user' && entry.kind === 'userInterjection') {
856
+ const text = String(entry.userInput ?? '').trim();
857
+ if (text) {
858
+ entries.push({ body: text, kind: 'user', title: 'You · sent mid-turn' });
859
+ }
860
+ continue;
861
+ }
855
862
  const text = textFromHistoryEntry(entry);
856
863
  if ((entry.role === 'model' || entry.role === 'assistant') && text) {
857
864
  if (isTurnFailureMarker(text)) {
@@ -1022,6 +1029,7 @@ function createInitialShellState(session, serverModels, debugUi) {
1022
1029
  resumePickerSessions: [],
1023
1030
  transcriptScrollOffset: 0,
1024
1031
  queuedMessage: null,
1032
+ turnMessages: [],
1025
1033
  serverModels: serverModels.models,
1026
1034
  sudoPrompt: null,
1027
1035
  status: 'Ready',
@@ -1282,16 +1290,6 @@ export function getApprovalChoiceForCursor(cursor) {
1282
1290
  return (APPROVAL_OPTIONS[Math.min(Math.max(cursor, 0), APPROVAL_OPTIONS.length - 1)]
1283
1291
  ?.value ?? 'n');
1284
1292
  }
1285
- export function resolveApprovalChoiceFromInput(input) {
1286
- const normalizedInput = String(input ?? '').trim().toLowerCase();
1287
- if (normalizedInput === 'y')
1288
- return 'y';
1289
- if (normalizedInput === 'a')
1290
- return 'a';
1291
- if (normalizedInput === 'n')
1292
- return 'n';
1293
- return null;
1294
- }
1295
1293
  export function pauseBusyClock(state, nowMs) {
1296
1294
  if (state.busySince === null || state.busyPausedAt !== null)
1297
1295
  return state;
@@ -1393,6 +1391,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1393
1391
  let latestUsageSummary = null;
1394
1392
  let pendingTurnEntries = [];
1395
1393
  let activeTurnAbort = null;
1394
+ let activeServerTurnId = null;
1395
+ let unacknowledged = new Map();
1396
+ const blockedRows = new WeakMap();
1396
1397
  let newConversationInFlight = false;
1397
1398
  let todosTouchedThisTurn = false;
1398
1399
  const syncTodosState = () => {
@@ -1677,6 +1678,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1677
1678
  status: 'Ready',
1678
1679
  thinkingTitle: '',
1679
1680
  thinkingNotes: [],
1681
+ turnMessages: [],
1680
1682
  workingTools: [],
1681
1683
  tokenUsage: formatClientTokenUsage(busyElapsedMs(current, Date.now()), latestUsageSummary),
1682
1684
  }));
@@ -1936,6 +1938,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1936
1938
  store.update((current) => ({
1937
1939
  ...pauseBusyClock(current, Date.now()),
1938
1940
  approvalCursor: getDefaultApprovalCursor(),
1941
+ approvalOpenedAt: Date.now(),
1939
1942
  approvalScrollOffset: 0,
1940
1943
  approvalPrompt: {
1941
1944
  title,
@@ -2230,6 +2233,129 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2230
2233
  }));
2231
2234
  await handleSubmit(queued.body);
2232
2235
  };
2236
+ let turnMessageCounter = 0;
2237
+ const upsertTurnMessage = (id, patch) => {
2238
+ store.update((current) => ({
2239
+ ...current,
2240
+ turnMessages: current.turnMessages.map((message) => message.id === id ? { ...message, ...patch } : message),
2241
+ }));
2242
+ };
2243
+ const recoverUnacknowledgedMessages = (autoSubmits = true) => {
2244
+ if (unacknowledged.size === 0)
2245
+ return;
2246
+ const stranded = [...unacknowledged.values()];
2247
+ unacknowledged = new Map();
2248
+ for (const { row } of stranded) {
2249
+ upsertTurnMessage(row, {
2250
+ state: 'queued',
2251
+ note: autoSubmits
2252
+ ? 'not read — sending as the next prompt'
2253
+ : 'not read — left in the composer',
2254
+ });
2255
+ }
2256
+ const existing = store.getState().queuedMessage;
2257
+ const combined = [
2258
+ ...stranded.map((entry) => entry.queued),
2259
+ ...(existing ? [existing] : []),
2260
+ ];
2261
+ const body = combined
2262
+ .map((entry) => entry.body)
2263
+ .filter((entry) => entry.trim())
2264
+ .join('\n\n');
2265
+ if (!body.trim())
2266
+ return;
2267
+ scheduleLiveFrameRemount();
2268
+ store.update((current) => ({
2269
+ ...current,
2270
+ queuedMessage: {
2271
+ body,
2272
+ imageAttachments: combined.flatMap((entry) => entry.imageAttachments),
2273
+ pastedChunks: combined.flatMap((entry) => entry.pastedChunks),
2274
+ },
2275
+ }));
2276
+ };
2277
+ const fireQueuedMessage = async () => {
2278
+ const state = store.getState();
2279
+ const queued = state.queuedMessage;
2280
+ if (!queued || !state.busy || exiting)
2281
+ return;
2282
+ const text = (queued.pastedChunks.length
2283
+ ? expandPastedChunks(queued.body, queued.pastedChunks)
2284
+ : queued.body).trim();
2285
+ if (!text)
2286
+ return;
2287
+ const turnId = activeServerTurnId;
2288
+ const blockedReason = queued.imageAttachments.length > 0
2289
+ ? 'waiting — images go with the next prompt'
2290
+ : !turnId
2291
+ ? 'waiting for the turn to end'
2292
+ : null;
2293
+ const existingRow = blockedRows.get(queued);
2294
+ if (existingRow !== undefined) {
2295
+ upsertTurnMessage(existingRow, {
2296
+ state: 'queued',
2297
+ ...(blockedReason ? { note: blockedReason } : {}),
2298
+ });
2299
+ if (blockedReason || !turnId)
2300
+ return;
2301
+ }
2302
+ const id = existingRow ?? ++turnMessageCounter;
2303
+ const messageId = `m${id}_${Date.now().toString(36)}`;
2304
+ if (existingRow === undefined) {
2305
+ scheduleLiveFrameRemount();
2306
+ store.update((current) => ({
2307
+ ...current,
2308
+ turnMessages: [
2309
+ ...current.turnMessages,
2310
+ {
2311
+ id,
2312
+ text,
2313
+ state: blockedReason ? 'queued' : 'sending',
2314
+ ...(blockedReason ? { note: blockedReason } : {}),
2315
+ },
2316
+ ],
2317
+ }));
2318
+ }
2319
+ if (blockedReason || !turnId) {
2320
+ blockedRows.set(queued, id);
2321
+ return;
2322
+ }
2323
+ blockedRows.delete(queued);
2324
+ unacknowledged.set(messageId, { row: id, queued });
2325
+ scheduleLiveFrameRemount();
2326
+ store.update((current) => current.queuedMessage === queued
2327
+ ? { ...current, queuedMessage: null }
2328
+ : current);
2329
+ queueTurnEntry({
2330
+ body: text,
2331
+ kind: 'user',
2332
+ preformatted: queued.pastedChunks.length > 0,
2333
+ title: 'You · sent mid-turn',
2334
+ });
2335
+ const rollback = (note) => {
2336
+ if (!unacknowledged.delete(messageId))
2337
+ return;
2338
+ upsertTurnMessage(id, { state: 'queued', note });
2339
+ scheduleLiveFrameRemount();
2340
+ store.update((current) => current.queuedMessage ? current : { ...current, queuedMessage: queued });
2341
+ };
2342
+ let outcome;
2343
+ try {
2344
+ outcome = await chat.postInterjection({
2345
+ config: authConfig,
2346
+ turnId,
2347
+ text,
2348
+ messageId,
2349
+ });
2350
+ }
2351
+ catch (error) {
2352
+ rollback(`not sent — ${error?.message ?? 'request failed'}`);
2353
+ return;
2354
+ }
2355
+ if (outcome === 'stale') {
2356
+ rollback('turn ended — sending as the next prompt');
2357
+ }
2358
+ };
2233
2359
  const handleSubmit = async (rawInput) => {
2234
2360
  const chunks = store.getState().pastedChunks;
2235
2361
  const expanded = chunks.length
@@ -2481,6 +2607,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2481
2607
  }
2482
2608
  latestUsageSummary = null;
2483
2609
  disarmExitConfirm();
2610
+ unacknowledged = new Map();
2484
2611
  const turnStartedAt = Date.now();
2485
2612
  const turnGeneration = ++activeTurnGeneration;
2486
2613
  const turnAbort = new AbortController();
@@ -2513,6 +2640,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2513
2640
  transcriptScrollOffset: 0,
2514
2641
  tokenUsage: formatClientTokenUsage(0, latestUsageSummary),
2515
2642
  turnCounter: current.turnCounter + 1,
2643
+ turnMessages: [],
2516
2644
  workingTools: [],
2517
2645
  }));
2518
2646
  try {
@@ -2523,6 +2651,25 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2523
2651
  input,
2524
2652
  imageAttachments,
2525
2653
  signal: turnAbort.signal,
2654
+ onTurnStart: (serverTurnId) => {
2655
+ if (turnGeneration !== activeTurnGeneration)
2656
+ return;
2657
+ activeServerTurnId = serverTurnId;
2658
+ },
2659
+ onInterjectionDelivered: (event) => {
2660
+ if (turnGeneration !== activeTurnGeneration)
2661
+ return;
2662
+ for (const messageId of event.messageIds ?? []) {
2663
+ const pending = unacknowledged.get(messageId);
2664
+ if (!pending)
2665
+ continue;
2666
+ unacknowledged.delete(messageId);
2667
+ upsertTurnMessage(pending.row, {
2668
+ state: 'delivered',
2669
+ note: undefined,
2670
+ });
2671
+ }
2672
+ },
2526
2673
  });
2527
2674
  if (turnGeneration !== activeTurnGeneration)
2528
2675
  return;
@@ -2571,6 +2718,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2571
2718
  status: result.waitingForApproval ? 'Awaiting approval' : 'Ready',
2572
2719
  tokenUsage: formatClientTokenUsage(Date.now() - turnStartedAt, latestUsageSummary),
2573
2720
  }));
2721
+ recoverUnacknowledgedMessages();
2574
2722
  await flushQueuedMessage();
2575
2723
  }
2576
2724
  catch (error) {
@@ -2616,6 +2764,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2616
2764
  appendError(error.message);
2617
2765
  }
2618
2766
  await remountTui();
2767
+ recoverUnacknowledgedMessages(!cancelled);
2619
2768
  if (!cancelled && turnGeneration === activeTurnGeneration) {
2620
2769
  await flushQueuedMessage();
2621
2770
  }
@@ -2626,7 +2775,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2626
2775
  }
2627
2776
  if (turnGeneration === activeTurnGeneration) {
2628
2777
  lastTurnStartedAt = null;
2778
+ activeServerTurnId = null;
2629
2779
  }
2780
+ recoverUnacknowledgedMessages();
2630
2781
  }
2631
2782
  };
2632
2783
  session.onImageAnalysis = (activeImageCount) => {
@@ -2791,6 +2942,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2791
2942
  onJobsPickerKill: handleJobsPickerKill,
2792
2943
  onSudoPasswordInput: handleSudoPasswordInput,
2793
2944
  onSubmit: handleSubmit,
2945
+ onFireQueuedMessage: fireQueuedMessage,
2794
2946
  };
2795
2947
  const unsubscribe = store.subscribe(() => {
2796
2948
  renderCurrentFrame();
@@ -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
  }
@@ -2,7 +2,7 @@ import { agentModeLabel } from '../../agent-mode.js';
2
2
  import { singleLinePreview, truncate } from '../../utils.js';
3
3
  import { formatClientTokenUsage } from '../repl.js';
4
4
  import { renderFormattedBodyLines, renderPreformattedBodyLines, } from './markdown-render.js';
5
- import { displayWidth, line, plainLine, sliceToWidth, span, wrapText, } from './text.js';
5
+ import { displayWidth, line, padToWidth, plainLine, sliceToWidth, span, wrapText, } from './text.js';
6
6
  import { buildUserInputOverlayLines, } from './user-input.js';
7
7
  const WORKING_CLOCK_ICON = '◷';
8
8
  const BRAILLE_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴'];
@@ -286,6 +286,46 @@ function todoItemLine(item, width, progressTick = null) {
286
286
  }
287
287
  return line(span(' ○ ', { color: 'gray' }), span(text));
288
288
  }
289
+ const TURN_MESSAGE_PANEL_MAX_ROWS = 6;
290
+ const TURN_MESSAGE_LABEL = {
291
+ queued: 'queued',
292
+ sending: 'Processing . . .',
293
+ delivered: 'delivered',
294
+ };
295
+ function turnMessageLine(message, width, progressTick) {
296
+ const DECORATION = 6;
297
+ const budget = Math.max(1, width - DECORATION);
298
+ const preferredLabel = Math.max(...Object.values(TURN_MESSAGE_LABEL).map((value) => displayWidth(value)));
299
+ const labelWidth = Math.min(Math.max(1, Math.floor(budget / 2)), Math.max(preferredLabel, displayWidth(message.note ?? '')));
300
+ const label = fitLine(message.note ?? TURN_MESSAGE_LABEL[message.state], labelWidth);
301
+ const textWidth = Math.max(1, budget - labelWidth);
302
+ const text = padToWidth(fitLine(`"${message.text}"`, textWidth), textWidth);
303
+ if (message.state === 'delivered') {
304
+ return line(span(' ● ', { color: 'green' }), span(text, { color: 'gray', dim: true }), span(` ${label}`, { color: 'green' }));
305
+ }
306
+ if (message.state === 'sending') {
307
+ return line(span(` ${todoInProgressGlyph(progressTick)} `, {
308
+ color: TODO_IN_PROGRESS_COLOR,
309
+ }), span(text, { color: TODO_IN_PROGRESS_COLOR, bold: true }), span(` ${label}`, { color: TODO_IN_PROGRESS_COLOR }));
310
+ }
311
+ return line(span(' ○ ', { color: 'cyan' }), span(text, { color: 'cyan' }), span(` ${label}`, { color: 'gray' }));
312
+ }
313
+ export function buildTurnMessageLines(messages, width, progressTick = null) {
314
+ if (messages.length === 0)
315
+ return [];
316
+ const lines = [
317
+ line(span('Your messages', { color: 'cyan', bold: true })),
318
+ ];
319
+ const visible = messages.slice(-(TURN_MESSAGE_PANEL_MAX_ROWS - 1));
320
+ const hidden = messages.length - visible.length;
321
+ if (hidden > 0) {
322
+ lines.push(line(span(` ● ${hidden} earlier`, { color: 'gray', dim: true })));
323
+ }
324
+ for (const message of visible) {
325
+ lines.push(turnMessageLine(message, width, progressTick));
326
+ }
327
+ return lines;
328
+ }
289
329
  export function formatTodoProgress(items) {
290
330
  const done = items.filter((item) => item.status === 'completed').length;
291
331
  return `${done}/${items.length} done`;
@@ -341,6 +381,26 @@ export function renderTranscriptEntryLines(entry, width) {
341
381
  }
342
382
  return lines;
343
383
  }
384
+ const transcriptEntryLineCache = new WeakMap();
385
+ const MAX_CACHED_TRANSCRIPT_WIDTHS = 2;
386
+ function cachedTranscriptEntryLines(entry, width) {
387
+ let byWidth = transcriptEntryLineCache.get(entry);
388
+ if (!byWidth) {
389
+ byWidth = new Map();
390
+ transcriptEntryLineCache.set(entry, byWidth);
391
+ }
392
+ const cached = byWidth.get(width);
393
+ if (cached)
394
+ return cached;
395
+ const lines = renderTranscriptEntryLines(entry, width);
396
+ if (byWidth.size >= MAX_CACHED_TRANSCRIPT_WIDTHS) {
397
+ const oldest = byWidth.keys().next().value;
398
+ if (oldest !== undefined)
399
+ byWidth.delete(oldest);
400
+ }
401
+ byWidth.set(width, lines);
402
+ return lines;
403
+ }
344
404
  function renderDiffPreviewLines(preview, width, maxDiffLines = TRANSCRIPT_DIFF_PREVIEW_LINES) {
345
405
  return [
346
406
  plainLine(` Added ${preview.added} line${preview.added === 1 ? '' : 's'}, removed ${preview.removed} line${preview.removed === 1 ? '' : 's'}`, { color: 'gray' }),
@@ -452,7 +512,7 @@ function composerFooterLines(state) {
452
512
  return lines;
453
513
  }
454
514
  const busyHelperText = state.queuedMessage
455
- ? 'Enter re-queues • ↑ edit queued • Esc / Ctrl+C clear queued'
515
+ ? 'Enter sends it to the agent • ↑ edit • Esc / Ctrl+C discard'
456
516
  : state.input
457
517
  ? 'Enter queues • Esc cancels turn • Ctrl+C clears draft'
458
518
  : 'Enter queues • Esc / Ctrl+C cancel turn';
@@ -629,6 +689,11 @@ function buildLiveLines(state, width, spinnerFrame, elapsedSeconds, nowMs) {
629
689
  lines.push(plainLine(''));
630
690
  }
631
691
  lines.push(plainLine(buildWorkingClockLine(state, elapsedSeconds), { color: 'yellow' }));
692
+ const turnMessages = state.turnMessages ?? [];
693
+ if (turnMessages.length > 0) {
694
+ lines.push(plainLine(''));
695
+ lines.push(...buildTurnMessageLines(turnMessages, width, todoProgressTick(nowMs)));
696
+ }
632
697
  const todos = state.todos ?? [];
633
698
  if (todos.length > 0) {
634
699
  lines.push(plainLine(''));
@@ -1035,25 +1100,19 @@ function buildOverlayLines(state, width, height, nowMs) {
1035
1100
  if (padded)
1036
1101
  lines.push(plainLine(''));
1037
1102
  }
1038
- const options = [
1039
- { value: 'y', label: 'Approve once' },
1040
- { value: 'a', label: 'Approve all remaining actions' },
1041
- { value: 'n', label: 'Deny' },
1042
- ];
1043
- options.forEach((option, index) => {
1103
+ const options = ['Approve once', 'Approve all remaining actions', 'Deny'];
1104
+ options.forEach((label, index) => {
1044
1105
  const selected = index === state.approvalCursor;
1045
1106
  lines.push(line(span(selected ? '› ' : ' ', {
1046
1107
  color: selected ? APPROVAL_ACCENT_COLOR : 'gray',
1047
- }), span(option.value, {
1108
+ }), span(label, {
1048
1109
  color: selected ? APPROVAL_ACCENT_COLOR : undefined,
1049
1110
  bold: selected,
1050
- }), span(` ${option.label}`, {
1051
- color: selected ? APPROVAL_ACCENT_COLOR : undefined,
1052
1111
  })));
1053
1112
  });
1054
1113
  if (padded)
1055
1114
  lines.push(plainLine(''));
1056
- lines.push(plainLine('Press y, a, or n ↑/↓ movesEnter confirms', {
1115
+ lines.push(plainLine('↑/↓ movesEnter confirmsEsc denies', {
1057
1116
  color: 'gray',
1058
1117
  }));
1059
1118
  return buildOverlayPanel(lines, width, OVERLAY_BORDER_COLOR, height);
@@ -1154,14 +1213,18 @@ function sliceTranscriptLines(lines, maxLines, scrollOffset) {
1154
1213
  export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, nowMs = 0) {
1155
1214
  const contentWidth = Math.max(20, Math.floor(cols * 0.95) - 2);
1156
1215
  const gutter = Math.max(Math.floor((cols - contentWidth) / 2), 0);
1157
- const transcriptBlocks = state.transcript.map((entry) => renderTranscriptEntryLines(entry, contentWidth));
1158
- const transcriptLines = [];
1159
- transcriptBlocks.forEach((block, index) => {
1160
- if (index > 0) {
1161
- transcriptLines.push(plainLine(''));
1162
- }
1163
- transcriptLines.push(...block);
1164
- });
1216
+ const buildTranscriptLines = (wrapWidth) => {
1217
+ const blocks = state.transcript.map((entry) => cachedTranscriptEntryLines(entry, wrapWidth));
1218
+ const lines = [];
1219
+ blocks.forEach((block, index) => {
1220
+ if (index > 0) {
1221
+ lines.push(plainLine(''));
1222
+ }
1223
+ lines.push(...block);
1224
+ });
1225
+ return lines;
1226
+ };
1227
+ let transcriptLines = buildTranscriptLines(contentWidth);
1165
1228
  const sections = [];
1166
1229
  const liveLines = buildLiveLines(state, contentWidth, spinnerFrame, elapsedSeconds, nowMs);
1167
1230
  if (liveLines.length > 0) {
@@ -1174,11 +1237,10 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, n
1174
1237
  composerLines.push(line(span('Starting a new conversation…', { color: 'gray', dim: true })));
1175
1238
  }
1176
1239
  else if (state.queuedMessage) {
1177
- const preview = truncate(state.queuedMessage.body.trim().replace(/\s+/g, ' '), 60);
1178
1240
  const imageCount = state.queuedMessage.imageAttachments.length;
1179
- composerLines.push(line(span(`↳ Queued · "${preview}"`, { color: 'gray', dim: true }), ...(imageCount > 0
1180
- ? [span(` +${imageCount} img`, { color: 'gray', dim: true })]
1181
- : []), span(' edit · esc cancel', { color: 'gray', dim: true })));
1241
+ composerLines.push(line(span('Queued', { color: 'cyan', bold: true }), span(' Enter', { color: 'cyan', bold: true }), span(imageCount > 0
1242
+ ? ' sends it with the next prompt'
1243
+ : ' sends it to the agent now', { color: 'gray' }), span(' · ', { color: 'cyan', bold: true }), span(' edit', { color: 'gray' }), span(' · Esc', { color: 'cyan', bold: true }), span(' discard', { color: 'gray' })));
1182
1244
  }
1183
1245
  else {
1184
1246
  const promptLabel = state.busy ? 'queue> ' : '❯ ';
@@ -1213,7 +1275,11 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, n
1213
1275
  ? 0
1214
1276
  : 4;
1215
1277
  const transcriptBudget = Math.max(1, rows - reservedLines - composerReserve - 1);
1216
- const transcriptScrollLimit = Math.max(0, transcriptLines.length - transcriptBudget);
1278
+ let transcriptScrollLimit = Math.max(0, transcriptLines.length - transcriptBudget);
1279
+ if (transcriptScrollLimit > 0 && contentWidth > 2) {
1280
+ transcriptLines = buildTranscriptLines(contentWidth - 2);
1281
+ transcriptScrollLimit = Math.max(0, transcriptLines.length - transcriptBudget);
1282
+ }
1217
1283
  const transcriptScrollOffset = Math.min(Math.max(state.transcriptScrollOffset, 0), transcriptScrollLimit);
1218
1284
  sections.unshift({
1219
1285
  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,5 +1,5 @@
1
1
  import { readClipboardImage, readClipboardText } from '../../core/clipboard.js';
2
- import { applySlashCommandSuggestion, buildModelPickerOptions, deleteAtCursor, deleteBeforeCursor, getApprovalChoiceForCursor, getInputCommandToken, getNextApprovalCursor, getNextModelPickerIndex, getSlashCommandSuggestions, insertAtCursor, isExactSlashCommandToken, navigatePromptHistory, resolveApprovalChoiceFromInput, shouldRemountLiveFrameForComposerInputChange, } from '../repl.js';
2
+ import { applySlashCommandSuggestion, buildModelPickerOptions, deleteAtCursor, deleteBeforeCursor, getApprovalChoiceForCursor, getInputCommandToken, getNextApprovalCursor, getNextModelPickerIndex, getSlashCommandSuggestions, insertAtCursor, isExactSlashCommandToken, navigatePromptHistory, shouldRemountLiveFrameForComposerInputChange, } from '../repl.js';
3
3
  import { buildPastePlaceholder, shouldCollapsePaste, } from '../paste-collapse.js';
4
4
  import { handleUserInputPromptEvent, } from './user-input.js';
5
5
  const APPROVAL_PREVIEW_PAGE_ROWS = 3;
@@ -11,6 +11,13 @@ function isClipboardImagePasteKey(key) {
11
11
  }
12
12
  return key.ctrl && key.input === 'v' && !key.shift && !key.meta;
13
13
  }
14
+ export const APPROVAL_INPUT_GUARD_MS = 500;
15
+ function approvalIsGuarded(state) {
16
+ const openedAt = state.approvalOpenedAt;
17
+ if (typeof openedAt !== 'number')
18
+ return false;
19
+ return Date.now() - openedAt < APPROVAL_INPUT_GUARD_MS;
20
+ }
14
21
  function applyUserInputPromptEvent(store, handlers, event) {
15
22
  const current = store.getState();
16
23
  if (!current.userInputPrompt)
@@ -114,6 +121,16 @@ function scrollTranscript(store, handlers, delta) {
114
121
  };
115
122
  });
116
123
  }
124
+ function scrollTranscriptTo(store, handlers, offset) {
125
+ store.update((current) => {
126
+ const limit = handlers.getTranscriptScrollLimit?.() ??
127
+ current.transcript.reduce((total, entry) => total + 2 + (entry.body ? entry.body.split('\n').length : 0), 0);
128
+ return {
129
+ ...current,
130
+ transcriptScrollOffset: Math.max(0, Math.min(Math.trunc(offset), limit)),
131
+ };
132
+ });
133
+ }
117
134
  function scrollApprovalPreview(store, handlers, delta) {
118
135
  if (delta === 0)
119
136
  return;
@@ -182,6 +199,10 @@ export function handleShellKeyEvent(store, handlers, event) {
182
199
  scrollTranscript(store, handlers, Math.trunc(event.deltaLines));
183
200
  return;
184
201
  }
202
+ if (event.kind === 'transcriptScrollTo') {
203
+ scrollTranscriptTo(store, handlers, Number(event.offset ?? 0));
204
+ return;
205
+ }
185
206
  if (event.kind !== 'key')
186
207
  return;
187
208
  const key = event;
@@ -269,15 +290,6 @@ export function handleShellKeyEvent(store, handlers, event) {
269
290
  }
270
291
  return;
271
292
  }
272
- const directChoice = key.ctrl || key.meta ? null : resolveApprovalChoiceFromInput(key.input);
273
- if (directChoice) {
274
- void handlers.onResolveApproval(directChoice);
275
- return;
276
- }
277
- if (key.escape) {
278
- void handlers.onResolveApproval('n');
279
- return;
280
- }
281
293
  if (key.upArrow || key.downArrow) {
282
294
  store.update((current) => ({
283
295
  ...current,
@@ -285,6 +297,13 @@ export function handleShellKeyEvent(store, handlers, event) {
285
297
  }));
286
298
  return;
287
299
  }
300
+ if (approvalIsGuarded(state)) {
301
+ return;
302
+ }
303
+ if (key.escape) {
304
+ void handlers.onResolveApproval('n');
305
+ return;
306
+ }
288
307
  if (key.returnKey) {
289
308
  void handlers.onResolveApproval(getApprovalChoiceForCursor(state.approvalCursor));
290
309
  }
@@ -422,6 +441,15 @@ export function handleShellKeyEvent(store, handlers, event) {
422
441
  handlers.onCycleAgentMode();
423
442
  return;
424
443
  }
444
+ if (state.busy && state.queuedMessage) {
445
+ if (key.returnKey) {
446
+ void handlers.onFireQueuedMessage?.();
447
+ return;
448
+ }
449
+ if (!key.upArrow) {
450
+ return;
451
+ }
452
+ }
425
453
  if (commandPaletteActive && (key.upArrow || key.downArrow)) {
426
454
  store.update((current) => ({
427
455
  ...current,
@@ -430,7 +458,7 @@ export function handleShellKeyEvent(store, handlers, event) {
430
458
  return;
431
459
  }
432
460
  if (key.upArrow) {
433
- if (state.busy && state.input.trim() === '' && state.queuedMessage) {
461
+ if (state.busy && state.queuedMessage) {
434
462
  handlers.onLiveFrameShapeChange();
435
463
  store.update((current) => {
436
464
  const queued = current.queuedMessage;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thegitai/cli",
3
- "version": "1.0.0-preview.14",
3
+ "version": "1.0.0-preview.16",
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.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",
40
+ "@thegitai/tui-darwin-arm64": "1.0.0-preview.16",
41
+ "@thegitai/tui-darwin-x64": "1.0.0-preview.16",
42
+ "@thegitai/tui-linux-x64": "1.0.0-preview.16",
43
+ "@thegitai/tui-win32-x64": "1.0.0-preview.16",
44
44
  "@vscode/ripgrep": "1.18.0"
45
45
  },
46
46
  "publishConfig": {