@thegitai/cli 1.0.0-preview.2 → 1.0.0-preview.21

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.
Files changed (44) hide show
  1. package/README.md +32 -4
  2. package/dist/bin/ai.js +57 -291
  3. package/dist/src/agent-mode.js +1 -1
  4. package/dist/src/api/auth.js +2 -2
  5. package/dist/src/api/browser-login.js +72 -3
  6. package/dist/src/api/chat.js +236 -33
  7. package/dist/src/api/contracts.js +55 -1
  8. package/dist/src/api/http.js +16 -3
  9. package/dist/src/api/models.js +9 -4
  10. package/dist/src/core/clipboard.js +7 -13
  11. package/dist/src/core/image-limits.js +56 -0
  12. package/dist/src/core/image-path-extractor.js +70 -3
  13. package/dist/src/core/session-image-store.js +199 -0
  14. package/dist/src/executor.js +1 -1
  15. package/dist/src/help-text.js +51 -11
  16. package/dist/src/permissions.js +243 -0
  17. package/dist/src/project-index.js +13 -1
  18. package/dist/src/session-store.js +119 -20
  19. package/dist/src/session.js +14 -3
  20. package/dist/src/tool-executor.js +2 -2
  21. package/dist/src/tools/delete-file.js +14 -0
  22. package/dist/src/tools/index.js +2 -0
  23. package/dist/src/tools/patch-file.js +12 -16
  24. package/dist/src/tools/read-image-file.js +85 -0
  25. package/dist/src/tools/replace-document-text.js +28 -18
  26. package/dist/src/tools/run-command.js +13 -27
  27. package/dist/src/tools/run-node-script.js +11 -26
  28. package/dist/src/tools/str-replace.js +12 -16
  29. package/dist/src/tools/write-file.js +66 -0
  30. package/dist/src/turn-failure-marker.js +11 -0
  31. package/dist/src/ui/prompt-history-store.js +1 -1
  32. package/dist/src/ui/repl.js +579 -154
  33. package/dist/src/ui/tui/bridge.js +10 -0
  34. package/dist/src/ui/tui/build-frame.js +535 -159
  35. package/dist/src/ui/tui/markdown-render.js +81 -73
  36. package/dist/src/ui/tui/shell-input.js +206 -63
  37. package/dist/src/ui/tui/terminal-theme.js +28 -0
  38. package/dist/src/ui/tui/terminal-title.js +3 -0
  39. package/dist/src/ui/tui/terminal-writes.js +48 -0
  40. package/dist/src/ui/tui/text.js +158 -4
  41. package/dist/src/ui/tui/user-input.js +568 -0
  42. package/dist/src/utils.js +9 -0
  43. package/package.json +18 -6
  44. package/dist/src/markdown-renderer.js +0 -112
@@ -1,6 +1,8 @@
1
- import { line, plainLine, span, wrapText } from './text.js';
2
- const TABLE_BORDER_WIDTH = 2;
3
- const TABLE_CELL_OVERHEAD_WIDTH = 3;
1
+ import { displayWidth, line, padToWidth, plainLine, sliceToWidth, span, wrapText, wrapToWidth, } from './text.js';
2
+ const MIN_TABLE_COLUMN_WIDTH = 3;
3
+ function tableRowOverhead(columnCount) {
4
+ return 3 * columnCount + 1;
5
+ }
4
6
  function parseInlineSegments(text) {
5
7
  const source = String(text ?? '');
6
8
  const segments = [];
@@ -110,45 +112,37 @@ function stripInlineFormattingForWidth(text) {
110
112
  .replace(/\*\*([^*]+)\*\*/g, '$1');
111
113
  }
112
114
  function fitTableColumnWidths(columnWidths, maxWidth) {
113
- const widths = columnWidths.map((width) => Math.max(3, Math.floor(width)));
114
- if (widths.length === 0)
115
- return widths;
116
- const overhead = TABLE_BORDER_WIDTH + widths.length * TABLE_CELL_OVERHEAD_WIDTH;
117
- const budget = Math.max(widths.length * 3, maxWidth - overhead);
118
- const total = widths.reduce((sum, width) => sum + width, 0);
119
- if (total <= budget)
120
- return widths;
121
- let remaining = budget;
122
- const scaled = widths.map((width) => {
123
- const next = Math.max(3, Math.floor((width / total) * budget));
124
- remaining -= next;
125
- return next;
126
- });
127
- while (remaining > 0) {
128
- let targetIndex = 0;
129
- for (let index = 1; index < widths.length; index++) {
130
- if (widths[index] - scaled[index] > widths[targetIndex] - scaled[targetIndex]) {
131
- targetIndex = index;
132
- }
133
- }
134
- scaled[targetIndex]++;
135
- remaining--;
115
+ const columnCount = columnWidths.length;
116
+ if (columnCount === 0)
117
+ return [];
118
+ const natural = columnWidths.map((width) => Math.max(1, Math.floor(width)));
119
+ const budget = Math.floor(maxWidth) - tableRowOverhead(columnCount);
120
+ const total = natural.reduce((sum, width) => sum + width, 0);
121
+ if (budget >= total)
122
+ return natural;
123
+ const floorWidth = Math.max(1, Math.min(MIN_TABLE_COLUMN_WIDTH, Math.floor(budget / columnCount)));
124
+ const widths = natural.map((width) => Math.min(width, floorWidth));
125
+ let used = widths.reduce((sum, width) => sum + width, 0);
126
+ if (used > budget) {
127
+ const share = Math.max(1, Math.floor(budget / columnCount));
128
+ for (let index = 0; index < columnCount; index++)
129
+ widths[index] = share;
130
+ used = share * columnCount;
136
131
  }
137
- while (remaining < 0) {
138
- let targetIndex = -1;
139
- for (let index = 0; index < scaled.length; index++) {
140
- if (scaled[index] <= 3)
132
+ let remaining = budget - used;
133
+ while (remaining > 0) {
134
+ let grew = false;
135
+ for (let index = 0; index < columnCount && remaining > 0; index++) {
136
+ if (widths[index] >= natural[index])
141
137
  continue;
142
- if (targetIndex === -1 || scaled[index] > scaled[targetIndex]) {
143
- targetIndex = index;
144
- }
138
+ widths[index]++;
139
+ remaining--;
140
+ grew = true;
145
141
  }
146
- if (targetIndex === -1)
142
+ if (!grew)
147
143
  break;
148
- scaled[targetIndex]--;
149
- remaining++;
150
144
  }
151
- return scaled;
145
+ return widths;
152
146
  }
153
147
  function normalizeMarkdownTableCells(cells, columnCount) {
154
148
  return Array.from({ length: columnCount }, (_, index) => cells[index] ?? '');
@@ -176,7 +170,7 @@ function parseMarkdownTableBlock(lines, startIndex) {
176
170
  const normalizedHeaders = normalizeMarkdownTableCells(headers, columnCount);
177
171
  const columnWidths = normalizedHeaders.map((header, columnIndex) => {
178
172
  const values = [header, ...rows.map((row) => row[columnIndex] ?? '')];
179
- return Math.max(3, ...values.map((value) => stripInlineFormattingForWidth(value).length));
173
+ return Math.max(MIN_TABLE_COLUMN_WIDTH, ...values.map((value) => displayWidth(stripInlineFormattingForWidth(value))));
180
174
  });
181
175
  return {
182
176
  nextIndex,
@@ -280,24 +274,42 @@ function wrapInlineToLines(text, width, bodyColor, prefix = '') {
280
274
  rowWidth = 0;
281
275
  };
282
276
  const appendSpan = (part) => {
283
- const current = rows[rows.length - 1];
284
- const limit = safeWidth - (indent ? prefix.length : 0);
277
+ let current = rows[rows.length - 1];
285
278
  let remaining = part.text;
286
279
  while (remaining.length > 0) {
280
+ const limit = safeWidth - (rows.length === 1 && indent ? displayWidth(prefix) : 0);
287
281
  const room = limit - rowWidth;
288
282
  if (room <= 0) {
289
283
  startRow();
284
+ current = rows[rows.length - 1];
290
285
  continue;
291
286
  }
292
- if (remaining.length <= room) {
287
+ const width = displayWidth(remaining);
288
+ if (width <= room) {
293
289
  current.push(span(remaining, styleFromSpan(part)));
294
- rowWidth += remaining.length;
290
+ rowWidth += width;
295
291
  remaining = '';
296
292
  break;
297
293
  }
298
- current.push(span(remaining.slice(0, room), styleFromSpan(part)));
299
- remaining = remaining.slice(room);
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
+ }
303
+ const head = sliceToWidth(remaining, room);
304
+ if (!head || (displayWidth(head) > room && current.length > 0)) {
305
+ startRow();
306
+ current = rows[rows.length - 1];
307
+ continue;
308
+ }
309
+ current.push(span(head, styleFromSpan(part)));
310
+ remaining = remaining.slice(head.length);
300
311
  startRow();
312
+ current = rows[rows.length - 1];
301
313
  }
302
314
  };
303
315
  for (const segment of segments) {
@@ -316,36 +328,35 @@ function wrapInlineToLines(text, width, bodyColor, prefix = '') {
316
328
  return line(...bodySpans);
317
329
  });
318
330
  }
319
- function padCell(text, width) {
320
- const plain = stripInlineFormattingForWidth(text);
321
- if (plain.length >= width)
322
- return plain.slice(0, width);
323
- return plain + ' '.repeat(width - plain.length);
324
- }
325
331
  function renderTableLines(table, width) {
326
332
  const columnWidths = fitTableColumnWidths(table.columnWidths, width);
333
+ const border = (left, joint, right) => line(span(left +
334
+ columnWidths.map((colWidth) => '─'.repeat(colWidth + 2)).join(joint) +
335
+ right, { color: 'cyan', dim: true }));
327
336
  const renderRow = (cells, bold) => {
328
- const parts = columnWidths.map((colWidth, index) => span(` ${padCell(cells[index] ?? '', colWidth)} `, {
329
- color: 'cyan',
330
- bold,
331
- }));
332
- return line(span('│', { color: 'cyan' }), ...parts, span('│', { color: 'cyan' }));
337
+ const wrapped = columnWidths.map((colWidth, index) => wrapToWidth(stripInlineFormattingForWidth(cells[index] ?? ''), colWidth));
338
+ const height = Math.max(1, ...wrapped.map((cellLines) => cellLines.length));
339
+ const rows = [];
340
+ for (let row = 0; row < height; row++) {
341
+ const spans = [span('│', { color: 'cyan' })];
342
+ for (let column = 0; column < columnWidths.length; column++) {
343
+ const text = wrapped[column]?.[row] ?? '';
344
+ spans.push(span(` ${padToWidth(text, columnWidths[column])} `, {
345
+ color: 'cyan',
346
+ bold,
347
+ }));
348
+ spans.push(span('│', { color: 'cyan' }));
349
+ }
350
+ rows.push(line(...spans));
351
+ }
352
+ return rows;
333
353
  };
334
- const separator = line(span('├' +
335
- columnWidths.map((colWidth) => '─'.repeat(colWidth + 2)).join('┼') +
336
- '┤', { color: 'cyan', dim: true }));
337
- const top = line(span('┌' +
338
- columnWidths.map((colWidth) => '─'.repeat(colWidth + 2)).join('┬') +
339
- '┐', { color: 'cyan', dim: true }));
340
- const bottom = line(span('└' +
341
- columnWidths.map((colWidth) => '─'.repeat(colWidth + 2)).join('┴') +
342
- '┘', { color: 'cyan', dim: true }));
343
354
  return [
344
- top,
345
- renderRow(table.headers, true),
346
- separator,
347
- ...table.rows.map((row) => renderRow(row, false)),
348
- bottom,
355
+ border('┌', '┬', '┐'),
356
+ ...renderRow(table.headers, true),
357
+ border('├', '┼', '┤'),
358
+ ...table.rows.flatMap((row) => renderRow(row, false)),
359
+ border('└', '┴', '┘'),
349
360
  ];
350
361
  }
351
362
  function getEntryColor(kind) {
@@ -410,10 +421,7 @@ export function renderFormattedBodyLines(body, width, kind) {
410
421
  }
411
422
  if (formattedLine.kind === 'table' && formattedLine.table) {
412
423
  output.push(...renderTableLines(formattedLine.table, bodyWidth).map((tableLine) => {
413
- const spans = tableLine.spans.map((part) => ({
414
- ...part,
415
- text: ` ${part.text}`,
416
- }));
424
+ const spans = tableLine.spans.map((part, index) => index === 0 ? { ...part, text: ` ${part.text}` } : part);
417
425
  return { spans };
418
426
  }));
419
427
  continue;
@@ -1,6 +1,10 @@
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 { MAX_IMAGES_PER_MESSAGE, MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE, approximateBase64DecodedBytes, totalAttachmentBytes, } from '../../core/image-limits.js';
3
+ import { tryCacheAttachmentBytes } from '../../core/session-image-store.js';
4
+ import { applySlashCommandSuggestion, buildModelPickerOptions, deleteAtCursor, deleteBeforeCursor, getInputCommandToken, getNextApprovalCursor, getNextModelPickerIndex, getSlashCommandSuggestions, insertAtCursor, isExactSlashCommandToken, navigatePromptHistory, shouldRemountLiveFrameForComposerInputChange, } from '../repl.js';
3
5
  import { buildPastePlaceholder, shouldCollapsePaste, } from '../paste-collapse.js';
6
+ import { handleUserInputPromptEvent, } from './user-input.js';
7
+ const APPROVAL_PREVIEW_PAGE_ROWS = 3;
4
8
  function isClipboardImagePasteKey(key) {
5
9
  if (process.platform === 'win32') {
6
10
  return ((key.ctrl || key.meta) &&
@@ -9,6 +13,38 @@ function isClipboardImagePasteKey(key) {
9
13
  }
10
14
  return key.ctrl && key.input === 'v' && !key.shift && !key.meta;
11
15
  }
16
+ export const APPROVAL_INPUT_GUARD_MS = 500;
17
+ function approvalIsGuarded(state) {
18
+ const openedAt = state.approvalOpenedAt;
19
+ if (typeof openedAt !== 'number')
20
+ return false;
21
+ return Date.now() - openedAt < APPROVAL_INPUT_GUARD_MS;
22
+ }
23
+ function applyUserInputPromptEvent(store, handlers, event) {
24
+ const current = store.getState();
25
+ if (!current.userInputPrompt)
26
+ return false;
27
+ const outcome = handleUserInputPromptEvent(current.userInputPrompt, event, handlers.getUserInputViewport?.());
28
+ store.update((state) => ({
29
+ ...state,
30
+ userInputPrompt: outcome.state,
31
+ }));
32
+ if (outcome.result) {
33
+ void handlers.onResolveUserInput?.(outcome.result);
34
+ }
35
+ return true;
36
+ }
37
+ function composerIsEmpty(state) {
38
+ return (!state.input &&
39
+ state.cursor === 0 &&
40
+ state.pastedChunks.length === 0 &&
41
+ state.promptHistoryCursor === null);
42
+ }
43
+ function composerHasDiscardableDraft(state) {
44
+ if (!composerIsEmpty(state))
45
+ return true;
46
+ return !state.busy && state.imageAttachments.length > 0;
47
+ }
12
48
  function shouldShowCommandPalette(state) {
13
49
  if (state.busy ||
14
50
  state.exiting ||
@@ -74,6 +110,41 @@ function pasteTextFromClipboard(store, handlers) {
74
110
  }
75
111
  insertPastedText(store, handlers, text);
76
112
  }
113
+ function scrollTranscript(store, handlers, delta) {
114
+ if (delta === 0)
115
+ return;
116
+ store.update((current) => {
117
+ const limit = handlers.getTranscriptScrollLimit?.() ??
118
+ current.transcript.reduce((total, entry) => total + 2 + (entry.body ? entry.body.split('\n').length : 0), 0);
119
+ const next = current.transcriptScrollOffset + delta;
120
+ return {
121
+ ...current,
122
+ transcriptScrollOffset: Math.max(0, Math.min(next, limit)),
123
+ };
124
+ });
125
+ }
126
+ function scrollTranscriptTo(store, handlers, offset) {
127
+ store.update((current) => {
128
+ const limit = handlers.getTranscriptScrollLimit?.() ??
129
+ current.transcript.reduce((total, entry) => total + 2 + (entry.body ? entry.body.split('\n').length : 0), 0);
130
+ return {
131
+ ...current,
132
+ transcriptScrollOffset: Math.max(0, Math.min(Math.trunc(offset), limit)),
133
+ };
134
+ });
135
+ }
136
+ function scrollApprovalPreview(store, handlers, delta) {
137
+ if (delta === 0)
138
+ return;
139
+ store.update((current) => {
140
+ const limit = handlers.getApprovalScrollLimit?.() ?? 0;
141
+ const next = (current.approvalScrollOffset ?? 0) + delta;
142
+ return {
143
+ ...current,
144
+ approvalScrollOffset: Math.max(0, Math.min(next, limit)),
145
+ };
146
+ });
147
+ }
77
148
  function filterResumeSessionsLocal(sessions, filter, serverModels) {
78
149
  const q = filter.trim().toLowerCase();
79
150
  if (!q)
@@ -88,6 +159,12 @@ function filterResumeSessionsLocal(sessions, filter, serverModels) {
88
159
  }
89
160
  export function handleShellKeyEvent(store, handlers, event) {
90
161
  if (event.kind === 'paste') {
162
+ if (applyUserInputPromptEvent(store, handlers, {
163
+ kind: 'paste',
164
+ text: event.text,
165
+ })) {
166
+ return;
167
+ }
91
168
  insertPastedText(store, handlers, event.text);
92
169
  return;
93
170
  }
@@ -110,33 +187,67 @@ export function handleShellKeyEvent(store, handlers, event) {
110
187
  return;
111
188
  }
112
189
  if (event.kind === 'contextMenu') {
190
+ if (store.getState().userInputPrompt) {
191
+ const text = (handlers.readClipboardText ?? readClipboardText)();
192
+ if (text) {
193
+ applyUserInputPromptEvent(store, handlers, { kind: 'paste', text });
194
+ }
195
+ return;
196
+ }
113
197
  pasteTextFromClipboard(store, handlers);
114
198
  return;
115
199
  }
116
200
  if (event.kind === 'transcriptScroll') {
117
- const delta = Math.trunc(event.deltaLines);
118
- if (delta !== 0) {
119
- store.update((current) => {
120
- const limit = handlers.getTranscriptScrollLimit?.() ??
121
- current.transcript.reduce((total, entry) => total + 2 + (entry.body ? entry.body.split('\n').length : 0), 0);
122
- const next = current.transcriptScrollOffset + delta;
123
- return {
124
- ...current,
125
- transcriptScrollOffset: Math.max(0, Math.min(next, limit)),
126
- };
127
- });
128
- }
201
+ scrollTranscript(store, handlers, Math.trunc(event.deltaLines));
202
+ return;
203
+ }
204
+ if (event.kind === 'transcriptScrollTo') {
205
+ scrollTranscriptTo(store, handlers, Number(event.offset ?? 0));
129
206
  return;
130
207
  }
131
208
  if (event.kind !== 'key')
132
209
  return;
133
210
  const key = event;
134
211
  const state = store.getState();
212
+ const commandPaletteActive = shouldShowCommandPalette(state);
213
+ const commandSuggestions = commandPaletteActive
214
+ ? getSlashCommandSuggestions(state.input)
215
+ : [];
216
+ const prepareForComposerInputChange = (current, nextInput) => {
217
+ if (shouldRemountLiveFrameForComposerInputChange(current, nextInput)) {
218
+ handlers.onLiveFrameShapeChange();
219
+ }
220
+ };
135
221
  if (key.ctrl && key.input === 'c') {
222
+ if (state.userInputPrompt) {
223
+ handlers.onCtrlC?.();
224
+ return;
225
+ }
136
226
  if (state.sudoPrompt) {
137
227
  handlers.onSudoPasswordInput({ kind: 'cancel' });
138
228
  return;
139
229
  }
230
+ if (state.queuedMessage) {
231
+ handlers.onLiveFrameShapeChange();
232
+ store.update((current) => ({ ...current, queuedMessage: null }));
233
+ return;
234
+ }
235
+ if (composerHasDiscardableDraft(state)) {
236
+ store.update((current) => {
237
+ prepareForComposerInputChange(current, '');
238
+ return {
239
+ ...current,
240
+ commandCursor: 0,
241
+ cursor: 0,
242
+ imageAttachments: current.busy ? current.imageAttachments : [],
243
+ input: '',
244
+ pastedChunks: [],
245
+ promptHistoryCursor: null,
246
+ promptHistoryDraft: '',
247
+ };
248
+ });
249
+ return;
250
+ }
140
251
  if (handlers.onCtrlC) {
141
252
  handlers.onCtrlC();
142
253
  return;
@@ -144,15 +255,10 @@ export function handleShellKeyEvent(store, handlers, event) {
144
255
  handlers.onRequestExit();
145
256
  return;
146
257
  }
147
- const commandPaletteActive = shouldShowCommandPalette(state);
148
- const commandSuggestions = commandPaletteActive
149
- ? getSlashCommandSuggestions(state.input)
150
- : [];
151
- const prepareForComposerInputChange = (current, nextInput) => {
152
- if (shouldRemountLiveFrameForComposerInputChange(current, nextInput)) {
153
- handlers.onLiveFrameShapeChange();
154
- }
155
- };
258
+ if (state.userInputPrompt &&
259
+ applyUserInputPromptEvent(store, handlers, key)) {
260
+ return;
261
+ }
156
262
  if (state.sudoPrompt) {
157
263
  if (key.escape) {
158
264
  handlers.onSudoPasswordInput({ kind: 'cancel' });
@@ -176,24 +282,32 @@ export function handleShellKeyEvent(store, handlers, event) {
176
282
  return;
177
283
  }
178
284
  if (state.approvalPrompt) {
179
- const directChoice = resolveApprovalChoiceFromInput(key.input);
180
- if (directChoice) {
181
- void handlers.onResolveApproval(directChoice);
182
- return;
183
- }
184
- if (key.escape) {
185
- void handlers.onResolveApproval('n');
285
+ if (key.pageUp || key.pageDown) {
286
+ const delta = key.pageUp ? -APPROVAL_PREVIEW_PAGE_ROWS : APPROVAL_PREVIEW_PAGE_ROWS;
287
+ if (key.shift) {
288
+ scrollTranscript(store, handlers, key.pageUp ? 8 : -8);
289
+ }
290
+ else {
291
+ scrollApprovalPreview(store, handlers, delta);
292
+ }
186
293
  return;
187
294
  }
188
295
  if (key.upArrow || key.downArrow) {
189
296
  store.update((current) => ({
190
297
  ...current,
191
- approvalCursor: getNextApprovalCursor(current.approvalCursor, key.upArrow ? -1 : 1),
298
+ approvalCursor: getNextApprovalCursor(current.approvalCursor, key.upArrow ? -1 : 1, current.approvalPrompt?.options?.length ?? 0),
192
299
  }));
193
300
  return;
194
301
  }
302
+ if (approvalIsGuarded(state)) {
303
+ return;
304
+ }
305
+ if (key.escape) {
306
+ void handlers.onResolveApproval(-1);
307
+ return;
308
+ }
195
309
  if (key.returnKey) {
196
- void handlers.onResolveApproval(getApprovalChoiceForCursor(state.approvalCursor));
310
+ void handlers.onResolveApproval(state.approvalCursor);
197
311
  }
198
312
  return;
199
313
  }
@@ -201,15 +315,7 @@ export function handleShellKeyEvent(store, handlers, event) {
201
315
  return;
202
316
  }
203
317
  if (key.pageUp || key.pageDown) {
204
- store.update((current) => {
205
- const transcriptLines = handlers.getTranscriptScrollLimit?.() ??
206
- current.transcript.reduce((total, entry) => total + 2 + (entry.body ? entry.body.split('\n').length : 0), 0);
207
- const next = current.transcriptScrollOffset + (key.pageUp ? 8 : -8);
208
- return {
209
- ...current,
210
- transcriptScrollOffset: Math.max(0, Math.min(next, transcriptLines)),
211
- };
212
- });
318
+ scrollTranscript(store, handlers, key.pageUp ? 8 : -8);
213
319
  return;
214
320
  }
215
321
  if (state.jobsPickerOpen) {
@@ -317,10 +423,7 @@ export function handleShellKeyEvent(store, handlers, event) {
317
423
  return;
318
424
  }
319
425
  store.update((current) => {
320
- if (!current.input &&
321
- current.cursor === 0 &&
322
- current.pastedChunks.length === 0 &&
323
- current.promptHistoryCursor === null) {
426
+ if (composerIsEmpty(current)) {
324
427
  return current;
325
428
  }
326
429
  prepareForComposerInputChange(current, '');
@@ -340,6 +443,15 @@ export function handleShellKeyEvent(store, handlers, event) {
340
443
  handlers.onCycleAgentMode();
341
444
  return;
342
445
  }
446
+ if (state.busy && state.queuedMessage) {
447
+ if (key.returnKey) {
448
+ void handlers.onFireQueuedMessage?.();
449
+ return;
450
+ }
451
+ if (!key.upArrow && !isClipboardImagePasteKey(key)) {
452
+ return;
453
+ }
454
+ }
343
455
  if (commandPaletteActive && (key.upArrow || key.downArrow)) {
344
456
  store.update((current) => ({
345
457
  ...current,
@@ -348,7 +460,7 @@ export function handleShellKeyEvent(store, handlers, event) {
348
460
  return;
349
461
  }
350
462
  if (key.upArrow) {
351
- if (state.busy && state.input.trim() === '' && state.queuedMessage) {
463
+ if (state.busy && state.queuedMessage) {
352
464
  handlers.onLiveFrameShapeChange();
353
465
  store.update((current) => {
354
466
  const queued = current.queuedMessage;
@@ -467,12 +579,16 @@ export function handleShellKeyEvent(store, handlers, event) {
467
579
  }
468
580
  if (isClipboardImagePasteKey(key)) {
469
581
  const current = store.getState();
470
- if (current.busy)
471
- return;
472
- const liveAttachments = current.imageAttachments.filter((attachment) => current.input.includes(`[Image #${attachment.index}]`));
473
- if (liveAttachments.length >= 2) {
582
+ const target = current.queuedMessage
583
+ ? {
584
+ body: current.queuedMessage.body,
585
+ attachments: current.queuedMessage.imageAttachments,
586
+ }
587
+ : { body: current.input, attachments: current.imageAttachments };
588
+ const liveAttachments = target.attachments.filter((attachment) => target.body.includes(`[Image #${attachment.index}]`));
589
+ if (liveAttachments.length >= MAX_IMAGES_PER_MESSAGE) {
474
590
  store.appendEntry({
475
- body: 'Maximum of 2 images per message. Send the current message first.',
591
+ body: `Maximum of ${MAX_IMAGES_PER_MESSAGE} images per message. Send the current message first.`,
476
592
  kind: 'error',
477
593
  title: 'Image',
478
594
  });
@@ -480,22 +596,49 @@ export function handleShellKeyEvent(store, handlers, event) {
480
596
  }
481
597
  try {
482
598
  const clipResult = readClipboardImage();
599
+ if (totalAttachmentBytes(liveAttachments) +
600
+ approximateBase64DecodedBytes(clipResult.base64Data) >
601
+ MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE) {
602
+ store.appendEntry({
603
+ body: `This image would put the message over the ${Math.round(MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE / 1024 / 1024)}MB combined image limit. Send the current message first.`,
604
+ kind: 'error',
605
+ title: 'Image',
606
+ });
607
+ return;
608
+ }
483
609
  const idx = liveAttachments.length === 0
484
610
  ? 1
485
611
  : Math.max(...liveAttachments.map((attachment) => attachment.index)) + 1;
486
- store.update((shellState) => ({
487
- ...shellState,
488
- imageAttachments: [
489
- ...liveAttachments,
490
- {
491
- index: idx,
492
- mimeType: clipResult.mimeType,
493
- base64Data: clipResult.base64Data,
494
- source: 'clipboard',
495
- },
496
- ],
497
- ...insertAtCursor(shellState, `[Image #${idx}] `),
498
- }));
612
+ const cached = tryCacheAttachmentBytes({
613
+ base64Data: clipResult.base64Data,
614
+ mimeType: clipResult.mimeType,
615
+ });
616
+ const attachment = {
617
+ index: cached?.index ?? idx,
618
+ mimeType: clipResult.mimeType,
619
+ base64Data: clipResult.base64Data,
620
+ source: 'clipboard',
621
+ ...(cached ? { cachePath: cached.cachePath } : {}),
622
+ };
623
+ const marker = attachment.index;
624
+ store.update((shellState) => {
625
+ const nextAttachments = [...liveAttachments, attachment];
626
+ if (shellState.queuedMessage) {
627
+ return {
628
+ ...shellState,
629
+ queuedMessage: {
630
+ ...shellState.queuedMessage,
631
+ body: `${shellState.queuedMessage.body} [Image #${marker}]`.trim(),
632
+ imageAttachments: nextAttachments,
633
+ },
634
+ };
635
+ }
636
+ return {
637
+ ...shellState,
638
+ imageAttachments: nextAttachments,
639
+ ...insertAtCursor(shellState, `[Image #${marker}] `),
640
+ };
641
+ });
499
642
  }
500
643
  catch (error) {
501
644
  if (error?.code === 'NO_IMAGE') {
@@ -0,0 +1,28 @@
1
+ export function isDarkTerminalBackground(env = process.env) {
2
+ const raw = String(env.COLORFGBG ?? '').trim();
3
+ if (!raw)
4
+ return false;
5
+ const parts = raw
6
+ .split(/[;:]/)
7
+ .map((part) => part.trim())
8
+ .filter(Boolean);
9
+ const bgToken = parts[parts.length - 1];
10
+ if (!bgToken)
11
+ return false;
12
+ const bg = Number(bgToken);
13
+ if (!Number.isFinite(bg))
14
+ return false;
15
+ return bg >= 0 && bg < 7;
16
+ }
17
+ export function mutedStyle(env = process.env) {
18
+ if (isDarkTerminalBackground(env)) {
19
+ return { color: 'ansi256(247)' };
20
+ }
21
+ return { color: 'gray' };
22
+ }
23
+ export function mutedColor(env = process.env) {
24
+ if (isDarkTerminalBackground(env)) {
25
+ return 'ansi256(247)';
26
+ }
27
+ return 'gray';
28
+ }
@@ -1,3 +1,4 @@
1
+ import { isTuiMode } from '../../runtime-mode.js';
1
2
  const BRAILLE_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴'];
2
3
  const TITLE_BRAND = 'TheGitAI';
3
4
  const TITLE_MARK_PREFIX = '❯_';
@@ -19,6 +20,8 @@ export function formatTerminalTitle(state, spinnerFrame = 0) {
19
20
  return `${TITLE_MARK_PREFIX}● ${TITLE_BRAND}`;
20
21
  }
21
22
  export function writeTerminalTitle(title, stream = process.stdout) {
23
+ if (isTuiMode())
24
+ return;
22
25
  if (!('isTTY' in stream) || !stream.isTTY)
23
26
  return;
24
27
  stream.write(`\x1b]0;${title}\x07`);