zerg-ztc 0.1.3 → 0.1.4

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 (72) hide show
  1. package/dist/App.d.ts.map +1 -1
  2. package/dist/App.js +71 -13
  3. package/dist/App.js.map +1 -1
  4. package/dist/agent/agent.d.ts.map +1 -1
  5. package/dist/agent/agent.js +3 -1
  6. package/dist/agent/agent.js.map +1 -1
  7. package/dist/agent/commands/index.d.ts.map +1 -1
  8. package/dist/agent/commands/index.js +3 -1
  9. package/dist/agent/commands/index.js.map +1 -1
  10. package/dist/agent/commands/input_mode.d.ts +3 -0
  11. package/dist/agent/commands/input_mode.d.ts.map +1 -0
  12. package/dist/agent/commands/input_mode.js +21 -0
  13. package/dist/agent/commands/input_mode.js.map +1 -0
  14. package/dist/agent/commands/keybindings.d.ts +3 -0
  15. package/dist/agent/commands/keybindings.d.ts.map +1 -0
  16. package/dist/agent/commands/keybindings.js +38 -0
  17. package/dist/agent/commands/keybindings.js.map +1 -0
  18. package/dist/agent/commands/types.d.ts +2 -0
  19. package/dist/agent/commands/types.d.ts.map +1 -1
  20. package/dist/cli.js +38 -1
  21. package/dist/cli.js.map +1 -1
  22. package/dist/components/FullScreen.d.ts.map +1 -1
  23. package/dist/components/FullScreen.js +29 -29
  24. package/dist/components/FullScreen.js.map +1 -1
  25. package/dist/components/InputArea.d.ts.map +1 -1
  26. package/dist/components/InputArea.js +476 -19
  27. package/dist/components/InputArea.js.map +1 -1
  28. package/dist/components/StatusBar.d.ts +1 -0
  29. package/dist/components/StatusBar.d.ts.map +1 -1
  30. package/dist/components/StatusBar.js +2 -1
  31. package/dist/components/StatusBar.js.map +1 -1
  32. package/dist/types.d.ts +1 -0
  33. package/dist/types.d.ts.map +1 -1
  34. package/dist/ui/core/input_segments.d.ts +1 -0
  35. package/dist/ui/core/input_segments.d.ts.map +1 -1
  36. package/dist/ui/core/input_segments.js +46 -14
  37. package/dist/ui/core/input_segments.js.map +1 -1
  38. package/dist/ui/core/types.d.ts +1 -0
  39. package/dist/ui/core/types.d.ts.map +1 -1
  40. package/dist/ui/ink/render.d.ts +3 -1
  41. package/dist/ui/ink/render.d.ts.map +1 -1
  42. package/dist/ui/ink/render.js +7 -5
  43. package/dist/ui/ink/render.js.map +1 -1
  44. package/dist/ui/views/app.d.ts +2 -1
  45. package/dist/ui/views/app.d.ts.map +1 -1
  46. package/dist/ui/views/app.js +2 -1
  47. package/dist/ui/views/app.js.map +1 -1
  48. package/dist/ui/views/header.d.ts.map +1 -1
  49. package/dist/ui/views/header.js +8 -5
  50. package/dist/ui/views/header.js.map +1 -1
  51. package/dist/ui/views/status_bar.d.ts +2 -1
  52. package/dist/ui/views/status_bar.d.ts.map +1 -1
  53. package/dist/ui/views/status_bar.js +5 -1
  54. package/dist/ui/views/status_bar.js.map +1 -1
  55. package/package.json +1 -1
  56. package/src/App.tsx +71 -13
  57. package/src/agent/agent.ts +3 -1
  58. package/src/agent/commands/index.ts +4 -0
  59. package/src/agent/commands/input_mode.ts +22 -0
  60. package/src/agent/commands/keybindings.ts +40 -0
  61. package/src/agent/commands/types.ts +2 -0
  62. package/src/cli.tsx +43 -1
  63. package/src/components/FullScreen.tsx +39 -34
  64. package/src/components/InputArea.tsx +489 -19
  65. package/src/components/StatusBar.tsx +3 -0
  66. package/src/types.ts +1 -0
  67. package/src/ui/core/input_segments.ts +49 -14
  68. package/src/ui/core/types.ts +1 -0
  69. package/src/ui/ink/render.tsx +16 -5
  70. package/src/ui/views/app.ts +3 -0
  71. package/src/ui/views/header.ts +8 -5
  72. package/src/ui/views/status_bar.ts +6 -0
@@ -1,5 +1,5 @@
1
1
  import React, { useReducer, useCallback } from 'react';
2
- import { useInput, useStdout } from 'ink';
2
+ import { useInput, useStdout, useApp } from 'ink';
3
3
  import { InkNode } from '../ui/ink/index.js';
4
4
  import { buildInputAreaView, wrapInputSegments } from '../ui/views/input_area.js';
5
5
  import { InputBus, InputKey } from '../ui/core/input.js';
@@ -116,6 +116,14 @@ export const InputArea: React.FC<InputAreaProps> = ({
116
116
  const [state, dispatch] = useReducer(reducer, initialState);
117
117
  const stateRef = React.useRef(state);
118
118
  const [badgePreview, setBadgePreview] = React.useState<string[] | null>(null);
119
+ const killRingRef = React.useRef<string[]>([]);
120
+ const killIndexRef = React.useRef<number>(-1);
121
+
122
+ // Bracketed paste mode support - buffer paste content between \x1b[200~ and \x1b[201~
123
+ const pasteBufferRef = React.useRef<string>('');
124
+ const isPastingRef = React.useRef<boolean>(false);
125
+ // Ref for handleClipboardImagePaste to avoid circular dependency
126
+ const handleClipboardImagePasteRef = React.useRef<((target: 'state' | 'overlay') => Promise<void>) | null>(null);
119
127
  React.useEffect(() => {
120
128
  stateRef.current = state;
121
129
  onStateChange?.(state);
@@ -151,6 +159,126 @@ export const InputArea: React.FC<InputAreaProps> = ({
151
159
  }
152
160
  }, [state.cursor.index, state.segments]);
153
161
 
162
+ const pushKill = useCallback((text: string) => {
163
+ if (!text) return;
164
+ const ring = killRingRef.current;
165
+ ring.unshift(text);
166
+ if (ring.length > 20) ring.pop();
167
+ killIndexRef.current = 0;
168
+ }, []);
169
+
170
+ const yank = useCallback((current: InputState) => {
171
+ const ring = killRingRef.current;
172
+ if (ring.length === 0) return current;
173
+ const next = insertText(current, ring[0]);
174
+ return { ...next, historyIdx: -1 };
175
+ }, []);
176
+
177
+ const yankPop = useCallback((current: InputState) => {
178
+ const ring = killRingRef.current;
179
+ if (ring.length === 0) return current;
180
+ const idx = killIndexRef.current === -1 ? 0 : (killIndexRef.current + 1) % ring.length;
181
+ killIndexRef.current = idx;
182
+ const next = insertText(current, ring[idx]);
183
+ return { ...next, historyIdx: -1 };
184
+ }, []);
185
+
186
+ const killToStart = useCallback((current: InputState): { next: InputState; killed: string } => {
187
+ const segments = [...current.segments];
188
+ const cursor = current.cursor;
189
+ const seg = segments[cursor.index];
190
+ if (!seg || seg.type !== 'text') return { next: current, killed: '' };
191
+ const killed = seg.text.slice(0, cursor.offset);
192
+ seg.text = seg.text.slice(cursor.offset);
193
+ return {
194
+ next: { ...current, segments, cursor: { index: cursor.index, offset: 0 }, historyIdx: -1 },
195
+ killed
196
+ };
197
+ }, []);
198
+
199
+ const killToEnd = useCallback((current: InputState): { next: InputState; killed: string } => {
200
+ const segments = [...current.segments];
201
+ const cursor = current.cursor;
202
+ const seg = segments[cursor.index];
203
+ if (!seg || seg.type !== 'text') return { next: current, killed: '' };
204
+ const killed = seg.text.slice(cursor.offset);
205
+ seg.text = seg.text.slice(0, cursor.offset);
206
+ return {
207
+ next: { ...current, segments, cursor: { index: cursor.index, offset: seg.text.length }, historyIdx: -1 },
208
+ killed
209
+ };
210
+ }, []);
211
+
212
+ const killWordBackward = useCallback((current: InputState): { next: InputState; killed: string } => {
213
+ const segments = [...current.segments];
214
+ const cursor = current.cursor;
215
+ const seg = segments[cursor.index];
216
+ if (!seg || seg.type !== 'text') return { next: current, killed: '' };
217
+ const before = seg.text.slice(0, cursor.offset);
218
+ const after = seg.text.slice(cursor.offset);
219
+ const trimmed = before.replace(/\s+$/, '');
220
+ const match = trimmed.match(/(\S+)\s*$/);
221
+ const killStart = match ? trimmed.length - match[1].length : before.length;
222
+ const killed = before.slice(killStart);
223
+ seg.text = before.slice(0, killStart) + after;
224
+ return {
225
+ next: { ...current, segments, cursor: { index: cursor.index, offset: killStart }, historyIdx: -1 },
226
+ killed
227
+ };
228
+ }, []);
229
+
230
+ const killWordForward = useCallback((current: InputState): { next: InputState; killed: string } => {
231
+ const segments = [...current.segments];
232
+ const cursor = current.cursor;
233
+ const seg = segments[cursor.index];
234
+ if (!seg || seg.type !== 'text') return { next: current, killed: '' };
235
+ const before = seg.text.slice(0, cursor.offset);
236
+ const after = seg.text.slice(cursor.offset);
237
+ const match = after.match(/^(\s*\S+)/);
238
+ const killed = match ? match[1] : '';
239
+ seg.text = before + after.slice(killed.length);
240
+ return {
241
+ next: { ...current, segments, cursor, historyIdx: -1 },
242
+ killed
243
+ };
244
+ }, []);
245
+
246
+ const transposeChars = useCallback((current: InputState): InputState => {
247
+ const segments = [...current.segments];
248
+ const cursor = current.cursor;
249
+ const seg = segments[cursor.index];
250
+ if (!seg || seg.type !== 'text') return current;
251
+ const text = seg.text;
252
+ if (text.length < 2) return current;
253
+ const idx = cursor.offset === 0 ? 0 : cursor.offset === text.length ? text.length - 2 : cursor.offset - 1;
254
+ const chars = text.split('');
255
+ const a = chars[idx];
256
+ chars[idx] = chars[idx + 1];
257
+ chars[idx + 1] = a;
258
+ seg.text = chars.join('');
259
+ return { ...current, segments, cursor: { index: cursor.index, offset: Math.min(text.length, cursor.offset + 1) }, historyIdx: -1 };
260
+ }, []);
261
+
262
+ const transposeWords = useCallback((current: InputState): InputState => {
263
+ const segments = [...current.segments];
264
+ const cursor = current.cursor;
265
+ const seg = segments[cursor.index];
266
+ if (!seg || seg.type !== 'text') return current;
267
+ const text = seg.text;
268
+ const left = text.slice(0, cursor.offset);
269
+ const right = text.slice(cursor.offset);
270
+ const leftMatch = left.match(/(\S+)\s*$/);
271
+ const rightMatch = right.match(/^\s*(\S+)/);
272
+ if (!leftMatch || !rightMatch) return current;
273
+ const leftWord = leftMatch[1];
274
+ const rightWord = rightMatch[1];
275
+ const leftStart = left.length - leftMatch[0].length;
276
+ const rightEnd = rightMatch[0].length;
277
+ const replacedLeft = left.slice(0, leftStart) + rightWord;
278
+ seg.text = replacedLeft + right.slice(rightEnd);
279
+ return { ...current, segments, cursor: { index: cursor.index, offset: replacedLeft.length }, historyIdx: -1 };
280
+ }, []);
281
+
154
282
  const inputRenderCount = React.useRef(0);
155
283
  React.useEffect(() => {
156
284
  inputRenderCount.current += 1;
@@ -168,8 +296,18 @@ export const InputArea: React.FC<InputAreaProps> = ({
168
296
  const isCommand = state.segments.length === 1 && state.segments[0].type === 'text' && plainText.startsWith('/');
169
297
  if (isCommand) {
170
298
  const parts = plainText.slice(1).split(/\s+/);
171
- const cmd = parts[0];
299
+ const cmd = parts[0].toLowerCase();
172
300
  const args = parts.slice(1);
301
+
302
+ // Handle /pi (paste image) command directly - intercept before passing to onCommand
303
+ if (cmd === 'pi' || cmd === 'paste-image') {
304
+ dispatch({ type: 'submit', historyEnabled, historyEntry: serialized });
305
+ if (handleClipboardImagePasteRef.current) {
306
+ void handleClipboardImagePasteRef.current('state');
307
+ }
308
+ return;
309
+ }
310
+
173
311
  onCommand?.(cmd, args);
174
312
  } else {
175
313
  onSubmit(serialized);
@@ -183,7 +321,8 @@ export const InputArea: React.FC<InputAreaProps> = ({
183
321
  }, [historyEnabled]);
184
322
 
185
323
  const { stdout } = useStdout();
186
- const overlayEnabled = process.env.ZTC_INPUT_OVERLAY !== '0' && process.env.ZTC_WEB_MIRROR !== '1';
324
+ const { exit } = useApp();
325
+ const overlayEnabled = process.env.ZTC_INPUT_OVERLAY === '1' && process.env.ZTC_WEB_MIRROR !== '1';
187
326
  const overlayStateRef = React.useRef<InputState>(initialState);
188
327
  const overlayBusyRef = React.useRef(false);
189
328
  const pasteBusyRef = React.useRef(false);
@@ -295,13 +434,146 @@ export const InputArea: React.FC<InputAreaProps> = ({
295
434
  }
296
435
  }, [insertTextIntoOverlay, insertTextIntoState, onToast, renderOverlay]);
297
436
 
437
+ // Update ref so handleSubmit can access it
438
+ handleClipboardImagePasteRef.current = handleClipboardImagePaste;
439
+
298
440
  const handleInput = useCallback((input: string, key: InputKey) => {
441
+ // Detect Kitty keyboard protocol CSI u sequences
442
+ // Format: ESC [ <keycode> ; <modifiers> u
443
+ // Modifiers: 2=shift, 3=alt, 5=ctrl, 9=super/cmd
444
+ // Check for sequences with or without ESC (Ink may strip it)
445
+
446
+ // Ctrl+V or Cmd+V for image paste: [118;5u or [118;9u
447
+ const kittyPasteV = /\x1b?\[118;[59]u/.test(input);
448
+ if (kittyPasteV) {
449
+ void handleClipboardImagePaste('state');
450
+ return;
451
+ }
452
+
453
+ // Consume any other Kitty sequences to prevent them from being displayed
454
+ // Match pattern: ESC? [ number ; number u
455
+ if (/\x1b?\[\d+;\d+u/.test(input)) {
456
+ // This is a Kitty keyboard sequence - don't display it as text
457
+ // Extract what key it is and handle accordingly
458
+ const match = input.match(/\x1b?\[(\d+);(\d+)u/);
459
+ if (match) {
460
+ const keycode = parseInt(match[1], 10);
461
+ const modifier = parseInt(match[2], 10);
462
+ // Ctrl+C (99;5) or Cmd+C (99;9) - exit the app
463
+ if (keycode === 99 && (modifier === 5 || modifier === 9)) {
464
+ exit();
465
+ return;
466
+ }
467
+ // Ctrl+L (108;5) - could add clear screen here if needed
468
+ }
469
+ return; // Consume other Kitty sequences
470
+ }
471
+
299
472
  if (disabled) return;
300
- const backspaceFallback = input === '\b' || input === '\x7f';
301
- if (backspaceFallback && !key.backspace && !key.delete) {
302
- key.backspace = true;
473
+
474
+ // Handle bracketed paste mode markers
475
+ // Note: ESC might be stripped or sent separately by Ink, so check both with and without ESC
476
+ const PASTE_START_FULL = '\x1b[200~';
477
+ const PASTE_START_SHORT = '[200~';
478
+ const PASTE_END_FULL = '\x1b[201~';
479
+ const PASTE_END_SHORT = '[201~';
480
+
481
+ const hasPasteStart = input.includes(PASTE_START_FULL) || input.includes(PASTE_START_SHORT);
482
+ const hasPasteEnd = input.includes(PASTE_END_FULL) || input.includes(PASTE_END_SHORT);
483
+
484
+ // Check for paste start marker
485
+ if (hasPasteStart) {
486
+ isPastingRef.current = true;
487
+ // Remove the paste start marker (try both variants)
488
+ let content = input.replace(PASTE_START_FULL, '').replace(PASTE_START_SHORT, '');
489
+
490
+ // Check if paste end is also in this chunk
491
+ const hasEndInChunk = content.includes(PASTE_END_FULL) || content.includes(PASTE_END_SHORT);
492
+ if (hasEndInChunk) {
493
+ let pasteContent = content.replace(PASTE_END_FULL, '').replace(PASTE_END_SHORT, '');
494
+ // Normalize line endings: \r\n -> \n, \r -> \n
495
+ pasteContent = pasteContent.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
496
+ isPastingRef.current = false;
497
+ pasteBufferRef.current = '';
498
+ // Process the complete paste
499
+ if (pasteContent.length > 0) {
500
+ const lineCount = pasteContent.split('\n').length;
501
+ if (lineCount >= PASTE_BADGE_THRESHOLD) {
502
+ const next = insertBadge(state, { type: 'paste', text: pasteContent });
503
+ dispatch({ type: 'apply', state: { ...next, historyIdx: -1 } });
504
+ } else {
505
+ const next = insertText(state, pasteContent);
506
+ dispatch({ type: 'apply', state: { ...next, historyIdx: -1 } });
507
+ }
508
+ } else {
509
+ // Empty paste - user may have tried to paste an image
510
+ // Try to extract image from system clipboard
511
+ void handleClipboardImagePaste('state');
512
+ }
513
+ } else {
514
+ pasteBufferRef.current = content;
515
+ }
516
+ return;
517
+ }
518
+
519
+ // Check for paste end marker
520
+ if (hasPasteEnd) {
521
+ // Remove the paste end marker (try both variants)
522
+ const contentBeforeEnd = input.replace(PASTE_END_FULL, '').replace(PASTE_END_SHORT, '');
523
+ pasteBufferRef.current += contentBeforeEnd;
524
+ // Normalize line endings: \r\n -> \n, \r -> \n
525
+ const pasteContent = pasteBufferRef.current.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
526
+ isPastingRef.current = false;
527
+ pasteBufferRef.current = '';
528
+ const lineCount = pasteContent.split('\n').length;
529
+ // Process the complete paste
530
+ if (pasteContent.length > 0) {
531
+ if (lineCount >= PASTE_BADGE_THRESHOLD) {
532
+ const next = insertBadge(state, { type: 'paste', text: pasteContent });
533
+ dispatch({ type: 'apply', state: { ...next, historyIdx: -1 } });
534
+ } else {
535
+ const next = insertText(state, pasteContent);
536
+ dispatch({ type: 'apply', state: { ...next, historyIdx: -1 } });
537
+ }
538
+ } else {
539
+ // Empty paste - user may have tried to paste an image
540
+ // Try to extract image from system clipboard
541
+ void handleClipboardImagePaste('state');
542
+ }
543
+ return;
544
+ }
545
+
546
+ // If we're in the middle of a paste, buffer the content
547
+ if (isPastingRef.current) {
548
+ pasteBufferRef.current += input;
549
+ return;
550
+ }
551
+
552
+ // Detect backspace via explicit key flag or known control codes
553
+ const backspaceFallback = input === '\b' || input === '\x7f' || input === '\x08';
554
+ const isBackspace = key.backspace || key.delete || backspaceFallback;
555
+
556
+ // Ignore completely empty input events that aren't backspace
557
+ // (some terminals send spurious empty events)
558
+ const safeKey = key as InputKey & { tab?: boolean; escape?: boolean };
559
+ if (
560
+ input === '' &&
561
+ !isBackspace &&
562
+ !safeKey.leftArrow &&
563
+ !safeKey.rightArrow &&
564
+ !safeKey.upArrow &&
565
+ !safeKey.downArrow &&
566
+ !safeKey.return &&
567
+ !safeKey.tab &&
568
+ !safeKey.escape &&
569
+ !safeKey.ctrl &&
570
+ !safeKey.meta
571
+ ) {
572
+ return;
303
573
  }
304
574
 
575
+ // Try to detect Cmd+V / Ctrl+V for image paste
576
+ // Note: Most terminals intercept Cmd+V, so this may not trigger. Use /pi command instead.
305
577
  if ((key.ctrl || key.meta) && (input === 'v' || input === '\u0016')) {
306
578
  void handleClipboardImagePaste('state');
307
579
  return;
@@ -352,23 +624,89 @@ export const InputArea: React.FC<InputAreaProps> = ({
352
624
  dispatch({ type: 'apply', state: { cursor: { index: state.segments.length, offset: 0 } } });
353
625
  return;
354
626
  }
627
+ if (key.ctrl && input === 'b') {
628
+ dispatch({ type: 'apply', state: moveLeft(state) });
629
+ return;
630
+ }
631
+ if (key.ctrl && input === 'f') {
632
+ dispatch({ type: 'apply', state: moveRight(state) });
633
+ return;
634
+ }
635
+ if (key.ctrl && input === 'p') {
636
+ navigateHistory('up');
637
+ return;
638
+ }
639
+ if (key.ctrl && input === 'n') {
640
+ navigateHistory('down');
641
+ return;
642
+ }
355
643
 
356
- if (key.backspace || key.delete) {
357
- const next = key.backspace ? backspace(state) : deleteForward(state);
644
+ if (isBackspace) {
645
+ // On macOS, backspace key reports as key.delete=true, so always use backspace() for backward deletion
646
+ const next = backspace(state);
358
647
  dispatch({ type: 'apply', state: next });
359
648
  return;
360
649
  }
361
650
 
362
651
  if (key.ctrl && input === 'u') {
363
- dispatch({ type: 'apply', state: { segments: [], cursor: { index: 0, offset: 0 } } });
652
+ const { next, killed } = killToStart(state);
653
+ pushKill(killed);
654
+ dispatch({ type: 'apply', state: next });
655
+ return;
656
+ }
657
+ if (key.ctrl && input === 'k') {
658
+ const { next, killed } = killToEnd(state);
659
+ pushKill(killed);
660
+ dispatch({ type: 'apply', state: next });
661
+ return;
662
+ }
663
+ if (key.ctrl && input === 'y') {
664
+ dispatch({ type: 'apply', state: yank(state) });
665
+ return;
666
+ }
667
+ if (key.meta && input === 'y') {
668
+ dispatch({ type: 'apply', state: yankPop(state) });
669
+ return;
670
+ }
671
+ if (key.ctrl && input === 't') {
672
+ dispatch({ type: 'apply', state: transposeChars(state) });
673
+ return;
674
+ }
675
+ if (key.meta && input === 't') {
676
+ dispatch({ type: 'apply', state: transposeWords(state) });
677
+ return;
678
+ }
679
+ if (key.ctrl && input === 'd') {
680
+ dispatch({ type: 'apply', state: deleteForward(state) });
364
681
  return;
365
682
  }
366
683
 
367
684
  if (key.ctrl && input === 'w') {
368
- const next = backspace(moveWordLeft(state));
685
+ const { next, killed } = killWordBackward(state);
686
+ pushKill(killed);
687
+ dispatch({ type: 'apply', state: next });
688
+ return;
689
+ }
690
+ if (key.meta && (input === '\b' || input === '\x7f')) {
691
+ const { next, killed } = killWordBackward(state);
692
+ pushKill(killed);
693
+ dispatch({ type: 'apply', state: next });
694
+ return;
695
+ }
696
+ if (key.meta && input === 'd') {
697
+ const { next, killed } = killWordForward(state);
698
+ pushKill(killed);
369
699
  dispatch({ type: 'apply', state: next });
370
700
  return;
371
701
  }
702
+ if (key.meta && input === 'b') {
703
+ dispatch({ type: 'apply', state: moveWordLeft(state) });
704
+ return;
705
+ }
706
+ if (key.meta && input === 'f') {
707
+ dispatch({ type: 'apply', state: moveWordRight(state) });
708
+ return;
709
+ }
372
710
 
373
711
  if (!key.ctrl && !key.meta && input) {
374
712
  if (input.includes('\n')) {
@@ -382,14 +720,32 @@ export const InputArea: React.FC<InputAreaProps> = ({
382
720
  const next = insertText(state, input);
383
721
  dispatch({ type: 'apply', state: { ...next, historyIdx: -1 } });
384
722
  }
385
- }, [disabled, handleSubmit, navigateHistory, state]);
723
+ }, [disabled, exit, handleClipboardImagePaste, handleSubmit, navigateHistory, state]);
386
724
 
387
725
  const handleOverlayInput = useCallback((input: string, key: InputKey) => {
388
726
  if (disabled) return;
389
- const backspaceFallback = input === '\b' || input === '\x7f';
390
- if (backspaceFallback && !key.backspace && !key.delete) {
391
- key.backspace = true;
727
+ // Detect backspace via explicit key flag or known control codes
728
+ const backspaceFallback = input === '\b' || input === '\x7f' || input === '\x08';
729
+ const isBackspace = key.backspace || key.delete || backspaceFallback;
730
+
731
+ // Ignore completely empty input events that aren't backspace
732
+ const safeKey = key as InputKey & { tab?: boolean; escape?: boolean };
733
+ if (
734
+ input === '' &&
735
+ !isBackspace &&
736
+ !safeKey.leftArrow &&
737
+ !safeKey.rightArrow &&
738
+ !safeKey.upArrow &&
739
+ !safeKey.downArrow &&
740
+ !safeKey.return &&
741
+ !safeKey.tab &&
742
+ !safeKey.escape &&
743
+ !safeKey.ctrl &&
744
+ !safeKey.meta
745
+ ) {
746
+ return;
392
747
  }
748
+
393
749
  const current = overlayStateRef.current;
394
750
 
395
751
  if ((key.ctrl || key.meta) && (input === 'v' || input === '\u0016')) {
@@ -404,8 +760,17 @@ export const InputArea: React.FC<InputAreaProps> = ({
404
760
  const isCommand = current.segments.length === 1 && current.segments[0].type === 'text' && plainText.startsWith('/');
405
761
  if (isCommand) {
406
762
  const parts = plainText.slice(1).split(/\s+/);
407
- const cmd = parts[0];
763
+ const cmd = parts[0].toLowerCase();
408
764
  const args = parts.slice(1);
765
+
766
+ // Handle /pi (paste image) command directly
767
+ if (cmd === 'pi' || cmd === 'paste-image') {
768
+ overlayStateRef.current = { ...createEmptyState(), history: current.history, historyIdx: -1 };
769
+ renderOverlay(overlayStateRef.current);
770
+ void handleClipboardImagePaste('overlay');
771
+ return;
772
+ }
773
+
409
774
  onCommand?.(cmd, args);
410
775
  } else {
411
776
  onSubmit(serialized);
@@ -482,21 +847,126 @@ export const InputArea: React.FC<InputAreaProps> = ({
482
847
  renderOverlay(overlayStateRef.current);
483
848
  return;
484
849
  }
850
+ if (key.ctrl && input === 'b') {
851
+ overlayStateRef.current = moveLeft(current);
852
+ renderOverlay(overlayStateRef.current);
853
+ return;
854
+ }
855
+ if (key.ctrl && input === 'f') {
856
+ overlayStateRef.current = moveRight(current);
857
+ renderOverlay(overlayStateRef.current);
858
+ return;
859
+ }
860
+ if (key.ctrl && input === 'p') {
861
+ const direction = 'up';
862
+ if (historyEnabled && current.history.length > 0) {
863
+ let newIdx = current.historyIdx === -1 ? current.history.length - 1 : Math.max(0, current.historyIdx - 1);
864
+ const historyValue = current.history[newIdx] || '';
865
+ overlayStateRef.current = {
866
+ ...current,
867
+ historyIdx: newIdx,
868
+ segments: historyValue.length > 0 ? [{ type: 'text', text: historyValue }] : [],
869
+ cursor: { index: historyValue.length > 0 ? 0 : 0, offset: historyValue.length }
870
+ };
871
+ renderOverlay(overlayStateRef.current);
872
+ }
873
+ return;
874
+ }
875
+ if (key.ctrl && input === 'n') {
876
+ if (historyEnabled && current.history.length > 0) {
877
+ let newIdx = current.historyIdx === -1 ? -1 : current.historyIdx + 1;
878
+ if (newIdx >= current.history.length) newIdx = -1;
879
+ if (newIdx === -1) {
880
+ overlayStateRef.current = { ...createEmptyState(), history: current.history, historyIdx: -1 };
881
+ } else {
882
+ const historyValue = current.history[newIdx];
883
+ overlayStateRef.current = {
884
+ ...current,
885
+ historyIdx: newIdx,
886
+ segments: historyValue.length > 0 ? [{ type: 'text', text: historyValue }] : [],
887
+ cursor: { index: historyValue.length > 0 ? 0 : 0, offset: historyValue.length }
888
+ };
889
+ }
890
+ renderOverlay(overlayStateRef.current);
891
+ }
892
+ return;
893
+ }
485
894
 
486
- if (key.backspace || key.delete) {
487
- overlayStateRef.current = key.backspace ? backspace(current) : deleteForward(current);
895
+ if (isBackspace) {
896
+ // On macOS, backspace key reports as key.delete=true, so always use backspace() for backward deletion
897
+ overlayStateRef.current = backspace(current);
488
898
  renderOverlay(overlayStateRef.current);
489
899
  return;
490
900
  }
491
901
 
492
902
  if (key.ctrl && input === 'u') {
493
- overlayStateRef.current = { ...createEmptyState(), history: current.history, historyIdx: -1 };
903
+ const { next, killed } = killToStart(current);
904
+ pushKill(killed);
905
+ overlayStateRef.current = { ...next, history: current.history, historyIdx: -1 };
906
+ renderOverlay(overlayStateRef.current);
907
+ return;
908
+ }
909
+ if (key.ctrl && input === 'k') {
910
+ const { next, killed } = killToEnd(current);
911
+ pushKill(killed);
912
+ overlayStateRef.current = next;
913
+ renderOverlay(overlayStateRef.current);
914
+ return;
915
+ }
916
+ if (key.ctrl && input === 'y') {
917
+ overlayStateRef.current = yank(current);
918
+ renderOverlay(overlayStateRef.current);
919
+ return;
920
+ }
921
+ if (key.meta && input === 'y') {
922
+ overlayStateRef.current = yankPop(current);
923
+ renderOverlay(overlayStateRef.current);
924
+ return;
925
+ }
926
+ if (key.ctrl && input === 't') {
927
+ overlayStateRef.current = transposeChars(current);
928
+ renderOverlay(overlayStateRef.current);
929
+ return;
930
+ }
931
+ if (key.meta && input === 't') {
932
+ overlayStateRef.current = transposeWords(current);
933
+ renderOverlay(overlayStateRef.current);
934
+ return;
935
+ }
936
+ if (key.ctrl && input === 'd') {
937
+ overlayStateRef.current = deleteForward(current);
494
938
  renderOverlay(overlayStateRef.current);
495
939
  return;
496
940
  }
497
941
 
498
942
  if (key.ctrl && input === 'w') {
499
- overlayStateRef.current = backspace(moveWordLeft(current));
943
+ const { next, killed } = killWordBackward(current);
944
+ pushKill(killed);
945
+ overlayStateRef.current = next;
946
+ renderOverlay(overlayStateRef.current);
947
+ return;
948
+ }
949
+ if (key.meta && (input === '\b' || input === '\x7f')) {
950
+ const { next, killed } = killWordBackward(current);
951
+ pushKill(killed);
952
+ overlayStateRef.current = next;
953
+ renderOverlay(overlayStateRef.current);
954
+ return;
955
+ }
956
+ if (key.meta && input === 'd') {
957
+ const { next, killed } = killWordForward(current);
958
+ pushKill(killed);
959
+ overlayStateRef.current = next;
960
+ renderOverlay(overlayStateRef.current);
961
+ return;
962
+ }
963
+ if (key.meta && input === 'b') {
964
+ overlayStateRef.current = moveWordLeft(current);
965
+ renderOverlay(overlayStateRef.current);
966
+ return;
967
+ }
968
+ if (key.meta && input === 'f') {
969
+ overlayStateRef.current = moveWordRight(current);
500
970
  renderOverlay(overlayStateRef.current);
501
971
  return;
502
972
  }
@@ -13,6 +13,7 @@ interface StatusBarProps {
13
13
  provider?: string;
14
14
  model?: string;
15
15
  emulationId?: string;
16
+ inputMode?: 'queue' | 'interrupt';
16
17
  toast?: string | null;
17
18
  debug?: boolean;
18
19
  }
@@ -27,6 +28,7 @@ export const StatusBar: React.FC<StatusBarProps> = ({
27
28
  provider,
28
29
  model,
29
30
  emulationId,
31
+ inputMode,
30
32
  toast,
31
33
  debug = false
32
34
  }) => {
@@ -40,6 +42,7 @@ export const StatusBar: React.FC<StatusBarProps> = ({
40
42
  provider,
41
43
  model,
42
44
  emulationId,
45
+ inputMode,
43
46
  toast,
44
47
  debug
45
48
  });
package/src/types.ts CHANGED
@@ -7,6 +7,7 @@ export type MessageRole = 'user' | 'assistant' | 'tool' | 'system';
7
7
  export type ToolStatus = 'pending' | 'running' | 'complete' | 'error';
8
8
 
9
9
  export type AgentStatus = 'idle' | 'thinking' | 'tool_use' | 'streaming' | 'error';
10
+ export type InputMode = 'queue' | 'interrupt';
10
11
 
11
12
  export interface ToolCall {
12
13
  id: string;