@thegitai/cli 1.0.0-preview.5 → 1.0.0-preview.51

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 (76) hide show
  1. package/README.md +93 -4
  2. package/dist/bin/ai.js +142 -112
  3. package/dist/bin/browser-host.js +265 -0
  4. package/dist/src/agent-mode.js +32 -7
  5. package/dist/src/api/auth.js +10 -4
  6. package/dist/src/api/browser-login.js +149 -37
  7. package/dist/src/api/chat.js +680 -166
  8. package/dist/src/api/contracts.js +55 -1
  9. package/dist/src/api/default-host.js +1 -0
  10. package/dist/src/api/http.js +151 -7
  11. package/dist/src/api/models.js +29 -10
  12. package/dist/src/api/startup-handshake.js +36 -0
  13. package/dist/src/api/turn-recovery.js +189 -0
  14. package/dist/src/background-jobs.js +4 -2
  15. package/dist/src/browser/bridge.js +262 -0
  16. package/dist/src/browser/framing.js +42 -0
  17. package/dist/src/browser/native-host.js +219 -0
  18. package/dist/src/browser/protocol.js +46 -0
  19. package/dist/src/browser/session-bridge.js +130 -0
  20. package/dist/src/cli-args.js +19 -5
  21. package/dist/src/client-environment.js +2 -0
  22. package/dist/src/core/clipboard.js +7 -13
  23. package/dist/src/core/image-limits.js +56 -0
  24. package/dist/src/core/image-path-extractor.js +70 -3
  25. package/dist/src/core/session-image-store.js +246 -0
  26. package/dist/src/edit-journal.js +33 -2
  27. package/dist/src/executor.js +25 -3
  28. package/dist/src/help-text.js +112 -21
  29. package/dist/src/patcher.js +43 -4
  30. package/dist/src/permissions.js +288 -0
  31. package/dist/src/preferences.js +39 -0
  32. package/dist/src/quota-notice.js +35 -0
  33. package/dist/src/session-safety.js +0 -12
  34. package/dist/src/session-store.js +121 -20
  35. package/dist/src/session.js +19 -4
  36. package/dist/src/signin.js +58 -0
  37. package/dist/src/tool-executor.js +13 -46
  38. package/dist/src/tools/browser.js +641 -0
  39. package/dist/src/tools/delete-file.js +20 -4
  40. package/dist/src/tools/index.js +32 -10
  41. package/dist/src/tools/patch-file.js +12 -26
  42. package/dist/src/tools/read-file.js +5 -0
  43. package/dist/src/tools/read-image-file.js +85 -0
  44. package/dist/src/tools/replace-document-text.js +28 -18
  45. package/dist/src/tools/restore-checkpoint.js +0 -1
  46. package/dist/src/tools/run-command.js +14 -71
  47. package/dist/src/tools/run-node-script.js +12 -81
  48. package/dist/src/tools/save-generated-image.js +120 -0
  49. package/dist/src/tools/str-replace.js +12 -26
  50. package/dist/src/tools/undo-edit.js +1 -6
  51. package/dist/src/tools/write-file.js +67 -11
  52. package/dist/src/ui/prompt-history-store.js +1 -1
  53. package/dist/src/ui/repl.js +949 -279
  54. package/dist/src/ui/slash-commands.js +121 -0
  55. package/dist/src/ui/tui/attention.js +25 -0
  56. package/dist/src/ui/tui/bridge.js +38 -5
  57. package/dist/src/ui/tui/build-frame.js +686 -223
  58. package/dist/src/ui/tui/composer-layout.js +76 -0
  59. package/dist/src/ui/tui/desktop-notification.js +118 -0
  60. package/dist/src/ui/tui/markdown-render.js +11 -2
  61. package/dist/src/ui/tui/shell-input.js +256 -73
  62. package/dist/src/ui/tui/shortcuts.js +61 -0
  63. package/dist/src/ui/tui/terminal-theme.js +28 -0
  64. package/dist/src/ui/tui/user-input.js +593 -0
  65. package/dist/src/update-check.js +205 -0
  66. package/dist/src/usage.js +5 -2
  67. package/dist/src/utils.js +13 -0
  68. package/dist/src/version.js +52 -2
  69. package/package.json +22 -9
  70. package/dist/src/project-index.js +0 -233
  71. package/dist/src/tools/code-intel.js +0 -472
  72. package/dist/src/tools/find-symbol.js +0 -70
  73. package/dist/src/tools/hover-symbol.js +0 -95
  74. package/dist/src/tools/list-symbols.js +0 -55
  75. package/dist/src/tools/search-code.js +0 -37
  76. package/dist/src/tools/signature-help.js +0 -118
@@ -1,6 +1,12 @@
1
+ import { shortcutsPanelAvailable } from './shortcuts.js';
2
+ import { moveComposerCursorVertically } from './composer-layout.js';
1
3
  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';
4
+ import { MAX_IMAGES_PER_MESSAGE, MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE, approximateBase64DecodedBytes, totalAttachmentBytes, } from '../../core/image-limits.js';
5
+ import { tryCacheAttachmentBytes } from '../../core/session-image-store.js';
6
+ import { applySlashCommandSuggestion, buildModelPickerOptions, deleteAtCursor, deleteBeforeCursor, getInputCommandToken, getNextApprovalCursor, getNextModelPickerIndex, getSlashCommandSuggestions, insertAtCursor, navigatePromptHistory, shouldRemountLiveFrameForComposerInputChange, slashCommandMatchesToken, } from '../repl.js';
3
7
  import { buildPastePlaceholder, shouldCollapsePaste, } from '../paste-collapse.js';
8
+ import { handleUserInputPromptEvent, } from './user-input.js';
9
+ const APPROVAL_PREVIEW_PAGE_ROWS = 3;
4
10
  function isClipboardImagePasteKey(key) {
5
11
  if (process.platform === 'win32') {
6
12
  return ((key.ctrl || key.meta) &&
@@ -9,6 +15,38 @@ function isClipboardImagePasteKey(key) {
9
15
  }
10
16
  return key.ctrl && key.input === 'v' && !key.shift && !key.meta;
11
17
  }
18
+ export const APPROVAL_INPUT_GUARD_MS = 500;
19
+ function approvalIsGuarded(state) {
20
+ const openedAt = state.approvalOpenedAt;
21
+ if (typeof openedAt !== 'number')
22
+ return false;
23
+ return Date.now() - openedAt < APPROVAL_INPUT_GUARD_MS;
24
+ }
25
+ function applyUserInputPromptEvent(store, handlers, event) {
26
+ const current = store.getState();
27
+ if (!current.userInputPrompt)
28
+ return false;
29
+ const outcome = handleUserInputPromptEvent(current.userInputPrompt, event, handlers.getUserInputViewport?.());
30
+ store.update((state) => ({
31
+ ...state,
32
+ userInputPrompt: outcome.state,
33
+ }));
34
+ if (outcome.result) {
35
+ void handlers.onResolveUserInput?.(outcome.result);
36
+ }
37
+ return true;
38
+ }
39
+ function composerIsEmpty(state) {
40
+ return (!state.input &&
41
+ state.cursor === 0 &&
42
+ state.pastedChunks.length === 0 &&
43
+ state.promptHistoryCursor === null);
44
+ }
45
+ function composerHasDiscardableDraft(state) {
46
+ if (!composerIsEmpty(state))
47
+ return true;
48
+ return !state.busy && state.imageAttachments.length > 0;
49
+ }
12
50
  function shouldShowCommandPalette(state) {
13
51
  if (state.busy ||
14
52
  state.exiting ||
@@ -18,7 +56,9 @@ function shouldShowCommandPalette(state) {
18
56
  return false;
19
57
  }
20
58
  const token = getInputCommandToken(state.input);
21
- return token.startsWith('/') && !token.includes(' ');
59
+ if (!token.startsWith('/') || state.input.includes(' '))
60
+ return false;
61
+ return getSlashCommandSuggestions(state.input).length > 0;
22
62
  }
23
63
  function insertPastedText(store, handlers, text) {
24
64
  const current = store.getState();
@@ -68,12 +108,47 @@ function pasteTextFromClipboard(store, handlers) {
68
108
  if (!text) {
69
109
  store.update((state) => ({
70
110
  ...state,
71
- status: 'Clipboard has no text to paste',
111
+ transientStatus: 'Clipboard has no text to paste',
72
112
  }));
73
113
  return;
74
114
  }
75
115
  insertPastedText(store, handlers, text);
76
116
  }
117
+ function scrollTranscript(store, handlers, delta) {
118
+ if (delta === 0)
119
+ return;
120
+ store.update((current) => {
121
+ const limit = handlers.getTranscriptScrollLimit?.() ??
122
+ current.transcript.reduce((total, entry) => total + 2 + (entry.body ? entry.body.split('\n').length : 0), 0);
123
+ const next = current.transcriptScrollOffset + delta;
124
+ return {
125
+ ...current,
126
+ transcriptScrollOffset: Math.max(0, Math.min(next, limit)),
127
+ };
128
+ });
129
+ }
130
+ function scrollTranscriptTo(store, handlers, offset) {
131
+ store.update((current) => {
132
+ const limit = handlers.getTranscriptScrollLimit?.() ??
133
+ current.transcript.reduce((total, entry) => total + 2 + (entry.body ? entry.body.split('\n').length : 0), 0);
134
+ return {
135
+ ...current,
136
+ transcriptScrollOffset: Math.max(0, Math.min(Math.trunc(offset), limit)),
137
+ };
138
+ });
139
+ }
140
+ function scrollApprovalPreview(store, handlers, delta) {
141
+ if (delta === 0)
142
+ return;
143
+ store.update((current) => {
144
+ const limit = handlers.getApprovalScrollLimit?.() ?? 0;
145
+ const next = (current.approvalScrollOffset ?? 0) + delta;
146
+ return {
147
+ ...current,
148
+ approvalScrollOffset: Math.max(0, Math.min(next, limit)),
149
+ };
150
+ });
151
+ }
77
152
  function filterResumeSessionsLocal(sessions, filter, serverModels) {
78
153
  const q = filter.trim().toLowerCase();
79
154
  if (!q)
@@ -88,6 +163,12 @@ function filterResumeSessionsLocal(sessions, filter, serverModels) {
88
163
  }
89
164
  export function handleShellKeyEvent(store, handlers, event) {
90
165
  if (event.kind === 'paste') {
166
+ if (applyUserInputPromptEvent(store, handlers, {
167
+ kind: 'paste',
168
+ text: event.text,
169
+ })) {
170
+ return;
171
+ }
91
172
  insertPastedText(store, handlers, event.text);
92
173
  return;
93
174
  }
@@ -110,33 +191,67 @@ export function handleShellKeyEvent(store, handlers, event) {
110
191
  return;
111
192
  }
112
193
  if (event.kind === 'contextMenu') {
194
+ if (store.getState().userInputPrompt) {
195
+ const text = (handlers.readClipboardText ?? readClipboardText)();
196
+ if (text) {
197
+ applyUserInputPromptEvent(store, handlers, { kind: 'paste', text });
198
+ }
199
+ return;
200
+ }
113
201
  pasteTextFromClipboard(store, handlers);
114
202
  return;
115
203
  }
116
204
  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
- }
205
+ scrollTranscript(store, handlers, Math.trunc(event.deltaLines));
206
+ return;
207
+ }
208
+ if (event.kind === 'transcriptScrollTo') {
209
+ scrollTranscriptTo(store, handlers, Number(event.offset ?? 0));
129
210
  return;
130
211
  }
131
212
  if (event.kind !== 'key')
132
213
  return;
133
214
  const key = event;
134
215
  const state = store.getState();
216
+ const commandPaletteActive = shouldShowCommandPalette(state);
217
+ const commandSuggestions = commandPaletteActive
218
+ ? getSlashCommandSuggestions(state.input)
219
+ : [];
220
+ const prepareForComposerInputChange = (current, nextInput) => {
221
+ if (shouldRemountLiveFrameForComposerInputChange(current, nextInput)) {
222
+ handlers.onLiveFrameShapeChange();
223
+ }
224
+ };
135
225
  if (key.ctrl && key.input === 'c') {
226
+ if (state.userInputPrompt) {
227
+ handlers.onCtrlC?.();
228
+ return;
229
+ }
136
230
  if (state.sudoPrompt) {
137
231
  handlers.onSudoPasswordInput({ kind: 'cancel' });
138
232
  return;
139
233
  }
234
+ if (state.queuedMessage) {
235
+ handlers.onLiveFrameShapeChange();
236
+ store.update((current) => ({ ...current, queuedMessage: null }));
237
+ return;
238
+ }
239
+ if (composerHasDiscardableDraft(state)) {
240
+ store.update((current) => {
241
+ prepareForComposerInputChange(current, '');
242
+ return {
243
+ ...current,
244
+ commandCursor: 0,
245
+ cursor: 0,
246
+ imageAttachments: current.busy ? current.imageAttachments : [],
247
+ input: '',
248
+ pastedChunks: [],
249
+ promptHistoryCursor: null,
250
+ promptHistoryDraft: '',
251
+ };
252
+ });
253
+ return;
254
+ }
140
255
  if (handlers.onCtrlC) {
141
256
  handlers.onCtrlC();
142
257
  return;
@@ -144,15 +259,10 @@ export function handleShellKeyEvent(store, handlers, event) {
144
259
  handlers.onRequestExit();
145
260
  return;
146
261
  }
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
- };
262
+ if (state.userInputPrompt &&
263
+ applyUserInputPromptEvent(store, handlers, key)) {
264
+ return;
265
+ }
156
266
  if (state.sudoPrompt) {
157
267
  if (key.escape) {
158
268
  handlers.onSudoPasswordInput({ kind: 'cancel' });
@@ -176,24 +286,32 @@ export function handleShellKeyEvent(store, handlers, event) {
176
286
  return;
177
287
  }
178
288
  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');
289
+ if (key.pageUp || key.pageDown) {
290
+ const delta = key.pageUp ? -APPROVAL_PREVIEW_PAGE_ROWS : APPROVAL_PREVIEW_PAGE_ROWS;
291
+ if (key.shift) {
292
+ scrollTranscript(store, handlers, key.pageUp ? 8 : -8);
293
+ }
294
+ else {
295
+ scrollApprovalPreview(store, handlers, delta);
296
+ }
186
297
  return;
187
298
  }
188
299
  if (key.upArrow || key.downArrow) {
189
300
  store.update((current) => ({
190
301
  ...current,
191
- approvalCursor: getNextApprovalCursor(current.approvalCursor, key.upArrow ? -1 : 1),
302
+ approvalCursor: getNextApprovalCursor(current.approvalCursor, key.upArrow ? -1 : 1, current.approvalPrompt?.options?.length ?? 0),
192
303
  }));
193
304
  return;
194
305
  }
306
+ if (approvalIsGuarded(state)) {
307
+ return;
308
+ }
309
+ if (key.escape) {
310
+ void handlers.onResolveApproval(-1);
311
+ return;
312
+ }
195
313
  if (key.returnKey) {
196
- void handlers.onResolveApproval(getApprovalChoiceForCursor(state.approvalCursor));
314
+ void handlers.onResolveApproval(state.approvalCursor);
197
315
  }
198
316
  return;
199
317
  }
@@ -201,24 +319,18 @@ export function handleShellKeyEvent(store, handlers, event) {
201
319
  return;
202
320
  }
203
321
  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
- });
322
+ scrollTranscript(store, handlers, key.pageUp ? 8 : -8);
213
323
  return;
214
324
  }
215
325
  if (state.jobsPickerOpen) {
216
- if (key.escape) {
326
+ if (key.escape || (key.ctrl && key.input === 'b' && !key.meta && !key.shift)) {
327
+ handlers.onLiveFrameShapeChange();
217
328
  store.update((current) => ({
218
329
  ...current,
219
330
  jobsPickerExpandedId: null,
220
331
  jobsPickerOpen: false,
221
- status: 'Ready',
332
+ jobsPickerReturnStatus: null,
333
+ status: current.jobsPickerReturnStatus ?? 'Ready',
222
334
  }));
223
335
  return;
224
336
  }
@@ -306,6 +418,28 @@ export function handleShellKeyEvent(store, handlers, event) {
306
418
  }
307
419
  return;
308
420
  }
421
+ if (state.shortcutsOpen) {
422
+ if (key.escape || key.returnKey || key.input === '?' || key.input === 'q') {
423
+ handlers.onLiveFrameShapeChange();
424
+ store.update((current) => ({ ...current, shortcutsOpen: false }));
425
+ }
426
+ return;
427
+ }
428
+ if (key.ctrl && key.input === 'b' && !key.meta && !key.shift) {
429
+ if ((state.backgroundJobs ?? []).length > 0) {
430
+ handlers.onOpenJobsPicker();
431
+ }
432
+ return;
433
+ }
434
+ if (key.ctrl && key.input === 'g' && !key.meta && !key.shift) {
435
+ handlers.onToggleNotifications?.();
436
+ return;
437
+ }
438
+ if (key.input === '?' && !key.ctrl && !key.meta && shortcutsPanelAvailable(state)) {
439
+ handlers.onLiveFrameShapeChange();
440
+ store.update((current) => ({ ...current, shortcutsOpen: true }));
441
+ return;
442
+ }
309
443
  if (key.escape) {
310
444
  if (state.busy) {
311
445
  if (state.queuedMessage) {
@@ -317,10 +451,7 @@ export function handleShellKeyEvent(store, handlers, event) {
317
451
  return;
318
452
  }
319
453
  store.update((current) => {
320
- if (!current.input &&
321
- current.cursor === 0 &&
322
- current.pastedChunks.length === 0 &&
323
- current.promptHistoryCursor === null) {
454
+ if (composerIsEmpty(current)) {
324
455
  return current;
325
456
  }
326
457
  prepareForComposerInputChange(current, '');
@@ -337,9 +468,22 @@ export function handleShellKeyEvent(store, handlers, event) {
337
468
  return;
338
469
  }
339
470
  if (key.tab && key.shift) {
471
+ if (state.busy) {
472
+ handlers.onAgentModeChangeBlocked?.();
473
+ return;
474
+ }
340
475
  handlers.onCycleAgentMode();
341
476
  return;
342
477
  }
478
+ if (state.busy && state.queuedMessage) {
479
+ if (key.returnKey) {
480
+ void handlers.onFireQueuedMessage?.();
481
+ return;
482
+ }
483
+ if (!key.upArrow && !isClipboardImagePasteKey(key)) {
484
+ return;
485
+ }
486
+ }
343
487
  if (commandPaletteActive && (key.upArrow || key.downArrow)) {
344
488
  store.update((current) => ({
345
489
  ...current,
@@ -348,7 +492,7 @@ export function handleShellKeyEvent(store, handlers, event) {
348
492
  return;
349
493
  }
350
494
  if (key.upArrow) {
351
- if (state.busy && state.input.trim() === '' && state.queuedMessage) {
495
+ if (state.busy && state.queuedMessage) {
352
496
  handlers.onLiveFrameShapeChange();
353
497
  store.update((current) => {
354
498
  const queued = current.queuedMessage;
@@ -367,9 +511,13 @@ export function handleShellKeyEvent(store, handlers, event) {
367
511
  });
368
512
  return;
369
513
  }
370
- if (state.busy)
514
+ const nextCursor = moveComposerCursorVertically(state.input, state.cursor, handlers.getComposerInputWidth?.() ?? Number.POSITIVE_INFINITY, -1);
515
+ if (nextCursor === null && state.busy)
371
516
  return;
372
517
  store.update((current) => {
518
+ if (nextCursor !== null) {
519
+ return { ...current, commandCursor: 0, cursor: nextCursor };
520
+ }
373
521
  const next = navigatePromptHistory(current, 'previous');
374
522
  prepareForComposerInputChange(current, next.input);
375
523
  return next;
@@ -377,9 +525,13 @@ export function handleShellKeyEvent(store, handlers, event) {
377
525
  return;
378
526
  }
379
527
  if (key.downArrow) {
380
- if (state.busy)
528
+ const nextCursor = moveComposerCursorVertically(state.input, state.cursor, handlers.getComposerInputWidth?.() ?? Number.POSITIVE_INFINITY, 1);
529
+ if (nextCursor === null && state.busy)
381
530
  return;
382
531
  store.update((current) => {
532
+ if (nextCursor !== null) {
533
+ return { ...current, commandCursor: 0, cursor: nextCursor };
534
+ }
383
535
  const next = navigatePromptHistory(current, 'next');
384
536
  prepareForComposerInputChange(current, next.input);
385
537
  return next;
@@ -399,10 +551,10 @@ export function handleShellKeyEvent(store, handlers, event) {
399
551
  }
400
552
  if (key.returnKey) {
401
553
  const token = getInputCommandToken(state.input);
402
- if (commandPaletteActive && !isExactSlashCommandToken(token)) {
403
- const suggestion = commandSuggestions[Math.min(state.commandCursor, commandSuggestions.length - 1)];
404
- if (!suggestion)
405
- return;
554
+ const suggestion = commandSuggestions[Math.min(state.commandCursor, commandSuggestions.length - 1)];
555
+ if (commandPaletteActive &&
556
+ suggestion &&
557
+ !slashCommandMatchesToken(suggestion, token)) {
406
558
  store.update((current) => {
407
559
  const nextInput = applySlashCommandSuggestion(current.input, suggestion);
408
560
  prepareForComposerInputChange(current, nextInput.input);
@@ -467,12 +619,16 @@ export function handleShellKeyEvent(store, handlers, event) {
467
619
  }
468
620
  if (isClipboardImagePasteKey(key)) {
469
621
  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) {
622
+ const target = current.queuedMessage
623
+ ? {
624
+ body: current.queuedMessage.body,
625
+ attachments: current.queuedMessage.imageAttachments,
626
+ }
627
+ : { body: current.input, attachments: current.imageAttachments };
628
+ const liveAttachments = target.attachments.filter((attachment) => target.body.includes(`[Image #${attachment.index}]`));
629
+ if (liveAttachments.length >= MAX_IMAGES_PER_MESSAGE) {
474
630
  store.appendEntry({
475
- body: 'Maximum of 2 images per message. Send the current message first.',
631
+ body: `Maximum of ${MAX_IMAGES_PER_MESSAGE} images per message. Send the current message first.`,
476
632
  kind: 'error',
477
633
  title: 'Image',
478
634
  });
@@ -480,22 +636,49 @@ export function handleShellKeyEvent(store, handlers, event) {
480
636
  }
481
637
  try {
482
638
  const clipResult = readClipboardImage();
639
+ if (totalAttachmentBytes(liveAttachments) +
640
+ approximateBase64DecodedBytes(clipResult.base64Data) >
641
+ MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE) {
642
+ store.appendEntry({
643
+ 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.`,
644
+ kind: 'error',
645
+ title: 'Image',
646
+ });
647
+ return;
648
+ }
483
649
  const idx = liveAttachments.length === 0
484
650
  ? 1
485
651
  : 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
- }));
652
+ const cached = tryCacheAttachmentBytes({
653
+ base64Data: clipResult.base64Data,
654
+ mimeType: clipResult.mimeType,
655
+ });
656
+ const attachment = {
657
+ index: cached?.index ?? idx,
658
+ mimeType: clipResult.mimeType,
659
+ base64Data: clipResult.base64Data,
660
+ source: 'clipboard',
661
+ ...(cached ? { cachePath: cached.cachePath } : {}),
662
+ };
663
+ const marker = attachment.index;
664
+ store.update((shellState) => {
665
+ const nextAttachments = [...liveAttachments, attachment];
666
+ if (shellState.queuedMessage) {
667
+ return {
668
+ ...shellState,
669
+ queuedMessage: {
670
+ ...shellState.queuedMessage,
671
+ body: `${shellState.queuedMessage.body} [Image #${marker}]`.trim(),
672
+ imageAttachments: nextAttachments,
673
+ },
674
+ };
675
+ }
676
+ return {
677
+ ...shellState,
678
+ imageAttachments: nextAttachments,
679
+ ...insertAtCursor(shellState, `[Image #${marker}] `),
680
+ };
681
+ });
499
682
  }
500
683
  catch (error) {
501
684
  if (error?.code === 'NO_IMAGE') {
@@ -0,0 +1,61 @@
1
+ import { agentModeCycleSequence } from '../../agent-mode.js';
2
+ export function shortcutsPanelAvailable(state) {
3
+ return (!state.exiting &&
4
+ !state.approvalPrompt &&
5
+ !state.sudoPrompt &&
6
+ !state.userInputPrompt &&
7
+ !state.queuedMessage &&
8
+ !state.input &&
9
+ (state.cursor ?? 0) === 0 &&
10
+ (state.pastedChunks?.length ?? 0) === 0 &&
11
+ state.promptHistoryCursor == null);
12
+ }
13
+ export function buildShortcutGroups(platform = process.platform) {
14
+ const imagePaste = platform === 'win32' ? 'Alt+V' : 'Ctrl+V';
15
+ const textPaste = platform === 'darwin' ? 'Cmd+V' : 'Ctrl+Shift+V';
16
+ return [
17
+ {
18
+ title: 'Composer',
19
+ rows: [
20
+ { keys: 'Enter', description: 'Send your message' },
21
+ {
22
+ keys: 'Shift+Tab',
23
+ description: `Cycle ${agentModeCycleSequence()} (between turns)`,
24
+ },
25
+ { keys: '/', description: 'Slash commands — /help lists them all' },
26
+ { keys: '↑ / ↓', description: 'Earlier prompts' },
27
+ { keys: 'Esc', description: 'Clear the composer' },
28
+ { keys: 'Ctrl+C', description: 'Clear the composer, or quit when empty' },
29
+ { keys: 'Ctrl+G', description: 'Desktop notifications — toggle on or off' },
30
+ ],
31
+ },
32
+ {
33
+ title: 'While the agent is working',
34
+ rows: [
35
+ { keys: 'Enter', description: 'Queue a message; Enter again sends it into the turn' },
36
+ { keys: '↑', description: 'Bring a queued message back to edit it' },
37
+ { keys: 'Esc', description: 'Discard the queued message, or cancel the turn' },
38
+ { keys: 'Ctrl+C', description: 'Cancel the turn' },
39
+ { keys: 'Ctrl+B', description: 'Background jobs' },
40
+ ],
41
+ },
42
+ {
43
+ title: 'Clipboard & images',
44
+ rows: [
45
+ { keys: imagePaste, description: 'Paste a screenshot into the message' },
46
+ { keys: textPaste, description: 'Paste text (your terminal’s own shortcut)' },
47
+ { keys: 'Right-click', description: 'Paste, or copy the link under the cursor' },
48
+ ],
49
+ },
50
+ {
51
+ title: 'Transcript',
52
+ rows: [
53
+ { keys: 'PageUp / PageDown', description: 'Scroll — the mouse wheel works too' },
54
+ { keys: 'Drag', description: 'Select and copy' },
55
+ { keys: 'Double-click', description: 'Copy a word' },
56
+ { keys: 'Triple-click', description: 'Copy a whole row' },
57
+ { keys: 'Left-click', description: 'Open a link' },
58
+ ],
59
+ },
60
+ ];
61
+ }
@@ -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
+ }