zerg-ztc 0.1.3 → 0.1.5

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 (147) hide show
  1. package/dist/App.d.ts.map +1 -1
  2. package/dist/App.js +183 -19
  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/config.d.ts.map +1 -1
  8. package/dist/agent/commands/config.js +68 -2
  9. package/dist/agent/commands/config.js.map +1 -1
  10. package/dist/agent/commands/index.d.ts.map +1 -1
  11. package/dist/agent/commands/index.js +4 -1
  12. package/dist/agent/commands/index.js.map +1 -1
  13. package/dist/agent/commands/input_mode.d.ts +3 -0
  14. package/dist/agent/commands/input_mode.d.ts.map +1 -0
  15. package/dist/agent/commands/input_mode.js +21 -0
  16. package/dist/agent/commands/input_mode.js.map +1 -0
  17. package/dist/agent/commands/keybindings.d.ts +3 -0
  18. package/dist/agent/commands/keybindings.d.ts.map +1 -0
  19. package/dist/agent/commands/keybindings.js +38 -0
  20. package/dist/agent/commands/keybindings.js.map +1 -0
  21. package/dist/agent/commands/types.d.ts +2 -0
  22. package/dist/agent/commands/types.d.ts.map +1 -1
  23. package/dist/agent/commands/update.d.ts +3 -0
  24. package/dist/agent/commands/update.d.ts.map +1 -0
  25. package/dist/agent/commands/update.js +33 -0
  26. package/dist/agent/commands/update.js.map +1 -0
  27. package/dist/cli.js +68 -16
  28. package/dist/cli.js.map +1 -1
  29. package/dist/components/ActivityLine.d.ts +11 -0
  30. package/dist/components/ActivityLine.d.ts.map +1 -0
  31. package/dist/components/ActivityLine.js +9 -0
  32. package/dist/components/ActivityLine.js.map +1 -0
  33. package/dist/components/FullScreen.d.ts +1 -0
  34. package/dist/components/FullScreen.d.ts.map +1 -1
  35. package/dist/components/FullScreen.js +30 -30
  36. package/dist/components/FullScreen.js.map +1 -1
  37. package/dist/components/InputArea.d.ts.map +1 -1
  38. package/dist/components/InputArea.js +476 -19
  39. package/dist/components/InputArea.js.map +1 -1
  40. package/dist/components/MessageList.d.ts +2 -1
  41. package/dist/components/MessageList.d.ts.map +1 -1
  42. package/dist/components/MessageList.js +41 -2
  43. package/dist/components/MessageList.js.map +1 -1
  44. package/dist/components/SingleMessage.d.ts +9 -0
  45. package/dist/components/SingleMessage.d.ts.map +1 -0
  46. package/dist/components/SingleMessage.js +27 -0
  47. package/dist/components/SingleMessage.js.map +1 -0
  48. package/dist/components/StatusBar.d.ts +2 -0
  49. package/dist/components/StatusBar.d.ts.map +1 -1
  50. package/dist/components/StatusBar.js +3 -1
  51. package/dist/components/StatusBar.js.map +1 -1
  52. package/dist/components/index.d.ts +2 -0
  53. package/dist/components/index.d.ts.map +1 -1
  54. package/dist/components/index.js +2 -0
  55. package/dist/components/index.js.map +1 -1
  56. package/dist/config/types.d.ts +1 -0
  57. package/dist/config/types.d.ts.map +1 -1
  58. package/dist/config.d.ts.map +1 -1
  59. package/dist/config.js +8 -0
  60. package/dist/config.js.map +1 -1
  61. package/dist/types.d.ts +1 -0
  62. package/dist/types.d.ts.map +1 -1
  63. package/dist/ui/core/input_segments.d.ts +1 -0
  64. package/dist/ui/core/input_segments.d.ts.map +1 -1
  65. package/dist/ui/core/input_segments.js +46 -14
  66. package/dist/ui/core/input_segments.js.map +1 -1
  67. package/dist/ui/core/types.d.ts +1 -0
  68. package/dist/ui/core/types.d.ts.map +1 -1
  69. package/dist/ui/ink/render.d.ts +3 -1
  70. package/dist/ui/ink/render.d.ts.map +1 -1
  71. package/dist/ui/ink/render.js +7 -5
  72. package/dist/ui/ink/render.js.map +1 -1
  73. package/dist/ui/views/activity_line.d.ts +11 -0
  74. package/dist/ui/views/activity_line.d.ts.map +1 -0
  75. package/dist/ui/views/activity_line.js +20 -0
  76. package/dist/ui/views/activity_line.js.map +1 -0
  77. package/dist/ui/views/app.d.ts +5 -1
  78. package/dist/ui/views/app.d.ts.map +1 -1
  79. package/dist/ui/views/app.js +18 -14
  80. package/dist/ui/views/app.js.map +1 -1
  81. package/dist/ui/views/header.d.ts.map +1 -1
  82. package/dist/ui/views/header.js +7 -5
  83. package/dist/ui/views/header.js.map +1 -1
  84. package/dist/ui/views/input_area.d.ts.map +1 -1
  85. package/dist/ui/views/input_area.js +25 -12
  86. package/dist/ui/views/input_area.js.map +1 -1
  87. package/dist/ui/views/message_list.d.ts +3 -2
  88. package/dist/ui/views/message_list.d.ts.map +1 -1
  89. package/dist/ui/views/message_list.js +33 -19
  90. package/dist/ui/views/message_list.js.map +1 -1
  91. package/dist/ui/views/status_bar.d.ts +3 -1
  92. package/dist/ui/views/status_bar.d.ts.map +1 -1
  93. package/dist/ui/views/status_bar.js +8 -2
  94. package/dist/ui/views/status_bar.js.map +1 -1
  95. package/dist/utils/spinner_frames.d.ts +2 -0
  96. package/dist/utils/spinner_frames.d.ts.map +1 -0
  97. package/dist/utils/spinner_frames.js +2 -0
  98. package/dist/utils/spinner_frames.js.map +1 -0
  99. package/dist/utils/spinner_verbs.d.ts +4 -0
  100. package/dist/utils/spinner_verbs.d.ts.map +1 -0
  101. package/dist/utils/spinner_verbs.js +22 -0
  102. package/dist/utils/spinner_verbs.js.map +1 -0
  103. package/dist/utils/tool_trace.d.ts.map +1 -1
  104. package/dist/utils/tool_trace.js +12 -2
  105. package/dist/utils/tool_trace.js.map +1 -1
  106. package/dist/utils/update.d.ts +9 -0
  107. package/dist/utils/update.d.ts.map +1 -0
  108. package/dist/utils/update.js +37 -0
  109. package/dist/utils/update.js.map +1 -0
  110. package/dist/utils/version.d.ts +2 -0
  111. package/dist/utils/version.d.ts.map +1 -0
  112. package/dist/utils/version.js +16 -0
  113. package/dist/utils/version.js.map +1 -0
  114. package/package.json +1 -1
  115. package/src/App.tsx +226 -32
  116. package/src/agent/agent.ts +3 -1
  117. package/src/agent/commands/config.ts +76 -2
  118. package/src/agent/commands/index.ts +6 -0
  119. package/src/agent/commands/input_mode.ts +22 -0
  120. package/src/agent/commands/keybindings.ts +40 -0
  121. package/src/agent/commands/types.ts +2 -0
  122. package/src/agent/commands/update.ts +32 -0
  123. package/src/cli.tsx +77 -15
  124. package/src/components/ActivityLine.tsx +23 -0
  125. package/src/components/FullScreen.tsx +41 -35
  126. package/src/components/InputArea.tsx +489 -19
  127. package/src/components/MessageList.tsx +52 -6
  128. package/src/components/SingleMessage.tsx +59 -0
  129. package/src/components/StatusBar.tsx +6 -0
  130. package/src/components/index.tsx +3 -1
  131. package/src/config/types.ts +1 -0
  132. package/src/config.ts +8 -0
  133. package/src/types.ts +1 -0
  134. package/src/ui/core/input_segments.ts +49 -14
  135. package/src/ui/core/types.ts +1 -0
  136. package/src/ui/ink/render.tsx +16 -5
  137. package/src/ui/views/activity_line.ts +33 -0
  138. package/src/ui/views/app.ts +25 -13
  139. package/src/ui/views/header.ts +7 -5
  140. package/src/ui/views/input_area.ts +28 -17
  141. package/src/ui/views/message_list.ts +36 -20
  142. package/src/ui/views/status_bar.ts +11 -1
  143. package/src/utils/spinner_frames.ts +1 -0
  144. package/src/utils/spinner_verbs.ts +23 -0
  145. package/src/utils/tool_trace.ts +12 -2
  146. package/src/utils/update.ts +44 -0
  147. package/src/utils/version.ts +15 -0
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import React, { useReducer, useCallback } from 'react';
3
- import { useInput, useStdout } from 'ink';
3
+ import { useInput, useStdout, useApp } from 'ink';
4
4
  import { InkNode } from '../ui/ink/index.js';
5
5
  import { buildInputAreaView, wrapInputSegments } from '../ui/views/input_area.js';
6
6
  import { debugLog } from '../debug/logger.js';
@@ -61,6 +61,13 @@ export const InputArea = ({ onSubmit, onCommand, commands = [], onStateChange, o
61
61
  const [state, dispatch] = useReducer(reducer, initialState);
62
62
  const stateRef = React.useRef(state);
63
63
  const [badgePreview, setBadgePreview] = React.useState(null);
64
+ const killRingRef = React.useRef([]);
65
+ const killIndexRef = React.useRef(-1);
66
+ // Bracketed paste mode support - buffer paste content between \x1b[200~ and \x1b[201~
67
+ const pasteBufferRef = React.useRef('');
68
+ const isPastingRef = React.useRef(false);
69
+ // Ref for handleClipboardImagePaste to avoid circular dependency
70
+ const handleClipboardImagePasteRef = React.useRef(null);
64
71
  React.useEffect(() => {
65
72
  stateRef.current = state;
66
73
  onStateChange?.(state);
@@ -96,6 +103,129 @@ export const InputArea = ({ onSubmit, onCommand, commands = [], onStateChange, o
96
103
  });
97
104
  }
98
105
  }, [state.cursor.index, state.segments]);
106
+ const pushKill = useCallback((text) => {
107
+ if (!text)
108
+ return;
109
+ const ring = killRingRef.current;
110
+ ring.unshift(text);
111
+ if (ring.length > 20)
112
+ ring.pop();
113
+ killIndexRef.current = 0;
114
+ }, []);
115
+ const yank = useCallback((current) => {
116
+ const ring = killRingRef.current;
117
+ if (ring.length === 0)
118
+ return current;
119
+ const next = insertText(current, ring[0]);
120
+ return { ...next, historyIdx: -1 };
121
+ }, []);
122
+ const yankPop = useCallback((current) => {
123
+ const ring = killRingRef.current;
124
+ if (ring.length === 0)
125
+ return current;
126
+ const idx = killIndexRef.current === -1 ? 0 : (killIndexRef.current + 1) % ring.length;
127
+ killIndexRef.current = idx;
128
+ const next = insertText(current, ring[idx]);
129
+ return { ...next, historyIdx: -1 };
130
+ }, []);
131
+ const killToStart = useCallback((current) => {
132
+ const segments = [...current.segments];
133
+ const cursor = current.cursor;
134
+ const seg = segments[cursor.index];
135
+ if (!seg || seg.type !== 'text')
136
+ return { next: current, killed: '' };
137
+ const killed = seg.text.slice(0, cursor.offset);
138
+ seg.text = seg.text.slice(cursor.offset);
139
+ return {
140
+ next: { ...current, segments, cursor: { index: cursor.index, offset: 0 }, historyIdx: -1 },
141
+ killed
142
+ };
143
+ }, []);
144
+ const killToEnd = useCallback((current) => {
145
+ const segments = [...current.segments];
146
+ const cursor = current.cursor;
147
+ const seg = segments[cursor.index];
148
+ if (!seg || seg.type !== 'text')
149
+ return { next: current, killed: '' };
150
+ const killed = seg.text.slice(cursor.offset);
151
+ seg.text = seg.text.slice(0, cursor.offset);
152
+ return {
153
+ next: { ...current, segments, cursor: { index: cursor.index, offset: seg.text.length }, historyIdx: -1 },
154
+ killed
155
+ };
156
+ }, []);
157
+ const killWordBackward = useCallback((current) => {
158
+ const segments = [...current.segments];
159
+ const cursor = current.cursor;
160
+ const seg = segments[cursor.index];
161
+ if (!seg || seg.type !== 'text')
162
+ return { next: current, killed: '' };
163
+ const before = seg.text.slice(0, cursor.offset);
164
+ const after = seg.text.slice(cursor.offset);
165
+ const trimmed = before.replace(/\s+$/, '');
166
+ const match = trimmed.match(/(\S+)\s*$/);
167
+ const killStart = match ? trimmed.length - match[1].length : before.length;
168
+ const killed = before.slice(killStart);
169
+ seg.text = before.slice(0, killStart) + after;
170
+ return {
171
+ next: { ...current, segments, cursor: { index: cursor.index, offset: killStart }, historyIdx: -1 },
172
+ killed
173
+ };
174
+ }, []);
175
+ const killWordForward = useCallback((current) => {
176
+ const segments = [...current.segments];
177
+ const cursor = current.cursor;
178
+ const seg = segments[cursor.index];
179
+ if (!seg || seg.type !== 'text')
180
+ return { next: current, killed: '' };
181
+ const before = seg.text.slice(0, cursor.offset);
182
+ const after = seg.text.slice(cursor.offset);
183
+ const match = after.match(/^(\s*\S+)/);
184
+ const killed = match ? match[1] : '';
185
+ seg.text = before + after.slice(killed.length);
186
+ return {
187
+ next: { ...current, segments, cursor, historyIdx: -1 },
188
+ killed
189
+ };
190
+ }, []);
191
+ const transposeChars = useCallback((current) => {
192
+ const segments = [...current.segments];
193
+ const cursor = current.cursor;
194
+ const seg = segments[cursor.index];
195
+ if (!seg || seg.type !== 'text')
196
+ return current;
197
+ const text = seg.text;
198
+ if (text.length < 2)
199
+ return current;
200
+ const idx = cursor.offset === 0 ? 0 : cursor.offset === text.length ? text.length - 2 : cursor.offset - 1;
201
+ const chars = text.split('');
202
+ const a = chars[idx];
203
+ chars[idx] = chars[idx + 1];
204
+ chars[idx + 1] = a;
205
+ seg.text = chars.join('');
206
+ return { ...current, segments, cursor: { index: cursor.index, offset: Math.min(text.length, cursor.offset + 1) }, historyIdx: -1 };
207
+ }, []);
208
+ const transposeWords = useCallback((current) => {
209
+ const segments = [...current.segments];
210
+ const cursor = current.cursor;
211
+ const seg = segments[cursor.index];
212
+ if (!seg || seg.type !== 'text')
213
+ return current;
214
+ const text = seg.text;
215
+ const left = text.slice(0, cursor.offset);
216
+ const right = text.slice(cursor.offset);
217
+ const leftMatch = left.match(/(\S+)\s*$/);
218
+ const rightMatch = right.match(/^\s*(\S+)/);
219
+ if (!leftMatch || !rightMatch)
220
+ return current;
221
+ const leftWord = leftMatch[1];
222
+ const rightWord = rightMatch[1];
223
+ const leftStart = left.length - leftMatch[0].length;
224
+ const rightEnd = rightMatch[0].length;
225
+ const replacedLeft = left.slice(0, leftStart) + rightWord;
226
+ seg.text = replacedLeft + right.slice(rightEnd);
227
+ return { ...current, segments, cursor: { index: cursor.index, offset: replacedLeft.length }, historyIdx: -1 };
228
+ }, []);
99
229
  const inputRenderCount = React.useRef(0);
100
230
  React.useEffect(() => {
101
231
  inputRenderCount.current += 1;
@@ -110,8 +240,16 @@ export const InputArea = ({ onSubmit, onCommand, commands = [], onStateChange, o
110
240
  const isCommand = state.segments.length === 1 && state.segments[0].type === 'text' && plainText.startsWith('/');
111
241
  if (isCommand) {
112
242
  const parts = plainText.slice(1).split(/\s+/);
113
- const cmd = parts[0];
243
+ const cmd = parts[0].toLowerCase();
114
244
  const args = parts.slice(1);
245
+ // Handle /pi (paste image) command directly - intercept before passing to onCommand
246
+ if (cmd === 'pi' || cmd === 'paste-image') {
247
+ dispatch({ type: 'submit', historyEnabled, historyEntry: serialized });
248
+ if (handleClipboardImagePasteRef.current) {
249
+ void handleClipboardImagePasteRef.current('state');
250
+ }
251
+ return;
252
+ }
115
253
  onCommand?.(cmd, args);
116
254
  }
117
255
  else {
@@ -123,7 +261,8 @@ export const InputArea = ({ onSubmit, onCommand, commands = [], onStateChange, o
123
261
  dispatch({ type: 'history_nav', direction, historyEnabled });
124
262
  }, [historyEnabled]);
125
263
  const { stdout } = useStdout();
126
- const overlayEnabled = process.env.ZTC_INPUT_OVERLAY !== '0' && process.env.ZTC_WEB_MIRROR !== '1';
264
+ const { exit } = useApp();
265
+ const overlayEnabled = process.env.ZTC_INPUT_OVERLAY === '1' && process.env.ZTC_WEB_MIRROR !== '1';
127
266
  const overlayStateRef = React.useRef(initialState);
128
267
  const overlayBusyRef = React.useRef(false);
129
268
  const pasteBusyRef = React.useRef(false);
@@ -234,13 +373,137 @@ export const InputArea = ({ onSubmit, onCommand, commands = [], onStateChange, o
234
373
  pasteBusyRef.current = false;
235
374
  }
236
375
  }, [insertTextIntoOverlay, insertTextIntoState, onToast, renderOverlay]);
376
+ // Update ref so handleSubmit can access it
377
+ handleClipboardImagePasteRef.current = handleClipboardImagePaste;
237
378
  const handleInput = useCallback((input, key) => {
379
+ // Detect Kitty keyboard protocol CSI u sequences
380
+ // Format: ESC [ <keycode> ; <modifiers> u
381
+ // Modifiers: 2=shift, 3=alt, 5=ctrl, 9=super/cmd
382
+ // Check for sequences with or without ESC (Ink may strip it)
383
+ // Ctrl+V or Cmd+V for image paste: [118;5u or [118;9u
384
+ const kittyPasteV = /\x1b?\[118;[59]u/.test(input);
385
+ if (kittyPasteV) {
386
+ void handleClipboardImagePaste('state');
387
+ return;
388
+ }
389
+ // Consume any other Kitty sequences to prevent them from being displayed
390
+ // Match pattern: ESC? [ number ; number u
391
+ if (/\x1b?\[\d+;\d+u/.test(input)) {
392
+ // This is a Kitty keyboard sequence - don't display it as text
393
+ // Extract what key it is and handle accordingly
394
+ const match = input.match(/\x1b?\[(\d+);(\d+)u/);
395
+ if (match) {
396
+ const keycode = parseInt(match[1], 10);
397
+ const modifier = parseInt(match[2], 10);
398
+ // Ctrl+C (99;5) or Cmd+C (99;9) - exit the app
399
+ if (keycode === 99 && (modifier === 5 || modifier === 9)) {
400
+ exit();
401
+ return;
402
+ }
403
+ // Ctrl+L (108;5) - could add clear screen here if needed
404
+ }
405
+ return; // Consume other Kitty sequences
406
+ }
238
407
  if (disabled)
239
408
  return;
240
- const backspaceFallback = input === '\b' || input === '\x7f';
241
- if (backspaceFallback && !key.backspace && !key.delete) {
242
- key.backspace = true;
409
+ // Handle bracketed paste mode markers
410
+ // Note: ESC might be stripped or sent separately by Ink, so check both with and without ESC
411
+ const PASTE_START_FULL = '\x1b[200~';
412
+ const PASTE_START_SHORT = '[200~';
413
+ const PASTE_END_FULL = '\x1b[201~';
414
+ const PASTE_END_SHORT = '[201~';
415
+ const hasPasteStart = input.includes(PASTE_START_FULL) || input.includes(PASTE_START_SHORT);
416
+ const hasPasteEnd = input.includes(PASTE_END_FULL) || input.includes(PASTE_END_SHORT);
417
+ // Check for paste start marker
418
+ if (hasPasteStart) {
419
+ isPastingRef.current = true;
420
+ // Remove the paste start marker (try both variants)
421
+ let content = input.replace(PASTE_START_FULL, '').replace(PASTE_START_SHORT, '');
422
+ // Check if paste end is also in this chunk
423
+ const hasEndInChunk = content.includes(PASTE_END_FULL) || content.includes(PASTE_END_SHORT);
424
+ if (hasEndInChunk) {
425
+ let pasteContent = content.replace(PASTE_END_FULL, '').replace(PASTE_END_SHORT, '');
426
+ // Normalize line endings: \r\n -> \n, \r -> \n
427
+ pasteContent = pasteContent.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
428
+ isPastingRef.current = false;
429
+ pasteBufferRef.current = '';
430
+ // Process the complete paste
431
+ if (pasteContent.length > 0) {
432
+ const lineCount = pasteContent.split('\n').length;
433
+ if (lineCount >= PASTE_BADGE_THRESHOLD) {
434
+ const next = insertBadge(state, { type: 'paste', text: pasteContent });
435
+ dispatch({ type: 'apply', state: { ...next, historyIdx: -1 } });
436
+ }
437
+ else {
438
+ const next = insertText(state, pasteContent);
439
+ dispatch({ type: 'apply', state: { ...next, historyIdx: -1 } });
440
+ }
441
+ }
442
+ else {
443
+ // Empty paste - user may have tried to paste an image
444
+ // Try to extract image from system clipboard
445
+ void handleClipboardImagePaste('state');
446
+ }
447
+ }
448
+ else {
449
+ pasteBufferRef.current = content;
450
+ }
451
+ return;
452
+ }
453
+ // Check for paste end marker
454
+ if (hasPasteEnd) {
455
+ // Remove the paste end marker (try both variants)
456
+ const contentBeforeEnd = input.replace(PASTE_END_FULL, '').replace(PASTE_END_SHORT, '');
457
+ pasteBufferRef.current += contentBeforeEnd;
458
+ // Normalize line endings: \r\n -> \n, \r -> \n
459
+ const pasteContent = pasteBufferRef.current.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
460
+ isPastingRef.current = false;
461
+ pasteBufferRef.current = '';
462
+ const lineCount = pasteContent.split('\n').length;
463
+ // Process the complete paste
464
+ if (pasteContent.length > 0) {
465
+ if (lineCount >= PASTE_BADGE_THRESHOLD) {
466
+ const next = insertBadge(state, { type: 'paste', text: pasteContent });
467
+ dispatch({ type: 'apply', state: { ...next, historyIdx: -1 } });
468
+ }
469
+ else {
470
+ const next = insertText(state, pasteContent);
471
+ dispatch({ type: 'apply', state: { ...next, historyIdx: -1 } });
472
+ }
473
+ }
474
+ else {
475
+ // Empty paste - user may have tried to paste an image
476
+ // Try to extract image from system clipboard
477
+ void handleClipboardImagePaste('state');
478
+ }
479
+ return;
243
480
  }
481
+ // If we're in the middle of a paste, buffer the content
482
+ if (isPastingRef.current) {
483
+ pasteBufferRef.current += input;
484
+ return;
485
+ }
486
+ // Detect backspace via explicit key flag or known control codes
487
+ const backspaceFallback = input === '\b' || input === '\x7f' || input === '\x08';
488
+ const isBackspace = key.backspace || key.delete || backspaceFallback;
489
+ // Ignore completely empty input events that aren't backspace
490
+ // (some terminals send spurious empty events)
491
+ const safeKey = key;
492
+ if (input === '' &&
493
+ !isBackspace &&
494
+ !safeKey.leftArrow &&
495
+ !safeKey.rightArrow &&
496
+ !safeKey.upArrow &&
497
+ !safeKey.downArrow &&
498
+ !safeKey.return &&
499
+ !safeKey.tab &&
500
+ !safeKey.escape &&
501
+ !safeKey.ctrl &&
502
+ !safeKey.meta) {
503
+ return;
504
+ }
505
+ // Try to detect Cmd+V / Ctrl+V for image paste
506
+ // Note: Most terminals intercept Cmd+V, so this may not trigger. Use /pi command instead.
244
507
  if ((key.ctrl || key.meta) && (input === 'v' || input === '\u0016')) {
245
508
  void handleClipboardImagePaste('state');
246
509
  return;
@@ -288,20 +551,86 @@ export const InputArea = ({ onSubmit, onCommand, commands = [], onStateChange, o
288
551
  dispatch({ type: 'apply', state: { cursor: { index: state.segments.length, offset: 0 } } });
289
552
  return;
290
553
  }
291
- if (key.backspace || key.delete) {
292
- const next = key.backspace ? backspace(state) : deleteForward(state);
554
+ if (key.ctrl && input === 'b') {
555
+ dispatch({ type: 'apply', state: moveLeft(state) });
556
+ return;
557
+ }
558
+ if (key.ctrl && input === 'f') {
559
+ dispatch({ type: 'apply', state: moveRight(state) });
560
+ return;
561
+ }
562
+ if (key.ctrl && input === 'p') {
563
+ navigateHistory('up');
564
+ return;
565
+ }
566
+ if (key.ctrl && input === 'n') {
567
+ navigateHistory('down');
568
+ return;
569
+ }
570
+ if (isBackspace) {
571
+ // On macOS, backspace key reports as key.delete=true, so always use backspace() for backward deletion
572
+ const next = backspace(state);
293
573
  dispatch({ type: 'apply', state: next });
294
574
  return;
295
575
  }
296
576
  if (key.ctrl && input === 'u') {
297
- dispatch({ type: 'apply', state: { segments: [], cursor: { index: 0, offset: 0 } } });
577
+ const { next, killed } = killToStart(state);
578
+ pushKill(killed);
579
+ dispatch({ type: 'apply', state: next });
580
+ return;
581
+ }
582
+ if (key.ctrl && input === 'k') {
583
+ const { next, killed } = killToEnd(state);
584
+ pushKill(killed);
585
+ dispatch({ type: 'apply', state: next });
586
+ return;
587
+ }
588
+ if (key.ctrl && input === 'y') {
589
+ dispatch({ type: 'apply', state: yank(state) });
590
+ return;
591
+ }
592
+ if (key.meta && input === 'y') {
593
+ dispatch({ type: 'apply', state: yankPop(state) });
594
+ return;
595
+ }
596
+ if (key.ctrl && input === 't') {
597
+ dispatch({ type: 'apply', state: transposeChars(state) });
598
+ return;
599
+ }
600
+ if (key.meta && input === 't') {
601
+ dispatch({ type: 'apply', state: transposeWords(state) });
602
+ return;
603
+ }
604
+ if (key.ctrl && input === 'd') {
605
+ dispatch({ type: 'apply', state: deleteForward(state) });
298
606
  return;
299
607
  }
300
608
  if (key.ctrl && input === 'w') {
301
- const next = backspace(moveWordLeft(state));
609
+ const { next, killed } = killWordBackward(state);
610
+ pushKill(killed);
611
+ dispatch({ type: 'apply', state: next });
612
+ return;
613
+ }
614
+ if (key.meta && (input === '\b' || input === '\x7f')) {
615
+ const { next, killed } = killWordBackward(state);
616
+ pushKill(killed);
617
+ dispatch({ type: 'apply', state: next });
618
+ return;
619
+ }
620
+ if (key.meta && input === 'd') {
621
+ const { next, killed } = killWordForward(state);
622
+ pushKill(killed);
302
623
  dispatch({ type: 'apply', state: next });
303
624
  return;
304
625
  }
626
+ if (key.meta && input === 'b') {
627
+ dispatch({ type: 'apply', state: moveWordLeft(state) });
628
+ return;
629
+ }
630
+ if (key.meta && input === 'f') {
631
+ dispatch({ type: 'apply', state: moveWordRight(state) });
632
+ return;
633
+ }
305
634
  if (!key.ctrl && !key.meta && input) {
306
635
  if (input.includes('\n')) {
307
636
  const lineCount = input.split('\n').length;
@@ -314,13 +643,27 @@ export const InputArea = ({ onSubmit, onCommand, commands = [], onStateChange, o
314
643
  const next = insertText(state, input);
315
644
  dispatch({ type: 'apply', state: { ...next, historyIdx: -1 } });
316
645
  }
317
- }, [disabled, handleSubmit, navigateHistory, state]);
646
+ }, [disabled, exit, handleClipboardImagePaste, handleSubmit, navigateHistory, state]);
318
647
  const handleOverlayInput = useCallback((input, key) => {
319
648
  if (disabled)
320
649
  return;
321
- const backspaceFallback = input === '\b' || input === '\x7f';
322
- if (backspaceFallback && !key.backspace && !key.delete) {
323
- key.backspace = true;
650
+ // Detect backspace via explicit key flag or known control codes
651
+ const backspaceFallback = input === '\b' || input === '\x7f' || input === '\x08';
652
+ const isBackspace = key.backspace || key.delete || backspaceFallback;
653
+ // Ignore completely empty input events that aren't backspace
654
+ const safeKey = key;
655
+ if (input === '' &&
656
+ !isBackspace &&
657
+ !safeKey.leftArrow &&
658
+ !safeKey.rightArrow &&
659
+ !safeKey.upArrow &&
660
+ !safeKey.downArrow &&
661
+ !safeKey.return &&
662
+ !safeKey.tab &&
663
+ !safeKey.escape &&
664
+ !safeKey.ctrl &&
665
+ !safeKey.meta) {
666
+ return;
324
667
  }
325
668
  const current = overlayStateRef.current;
326
669
  if ((key.ctrl || key.meta) && (input === 'v' || input === '\u0016')) {
@@ -334,8 +677,15 @@ export const InputArea = ({ onSubmit, onCommand, commands = [], onStateChange, o
334
677
  const isCommand = current.segments.length === 1 && current.segments[0].type === 'text' && plainText.startsWith('/');
335
678
  if (isCommand) {
336
679
  const parts = plainText.slice(1).split(/\s+/);
337
- const cmd = parts[0];
680
+ const cmd = parts[0].toLowerCase();
338
681
  const args = parts.slice(1);
682
+ // Handle /pi (paste image) command directly
683
+ if (cmd === 'pi' || cmd === 'paste-image') {
684
+ overlayStateRef.current = { ...createEmptyState(), history: current.history, historyIdx: -1 };
685
+ renderOverlay(overlayStateRef.current);
686
+ void handleClipboardImagePaste('overlay');
687
+ return;
688
+ }
339
689
  onCommand?.(cmd, args);
340
690
  }
341
691
  else {
@@ -412,18 +762,125 @@ export const InputArea = ({ onSubmit, onCommand, commands = [], onStateChange, o
412
762
  renderOverlay(overlayStateRef.current);
413
763
  return;
414
764
  }
415
- if (key.backspace || key.delete) {
416
- overlayStateRef.current = key.backspace ? backspace(current) : deleteForward(current);
765
+ if (key.ctrl && input === 'b') {
766
+ overlayStateRef.current = moveLeft(current);
767
+ renderOverlay(overlayStateRef.current);
768
+ return;
769
+ }
770
+ if (key.ctrl && input === 'f') {
771
+ overlayStateRef.current = moveRight(current);
772
+ renderOverlay(overlayStateRef.current);
773
+ return;
774
+ }
775
+ if (key.ctrl && input === 'p') {
776
+ const direction = 'up';
777
+ if (historyEnabled && current.history.length > 0) {
778
+ let newIdx = current.historyIdx === -1 ? current.history.length - 1 : Math.max(0, current.historyIdx - 1);
779
+ const historyValue = current.history[newIdx] || '';
780
+ overlayStateRef.current = {
781
+ ...current,
782
+ historyIdx: newIdx,
783
+ segments: historyValue.length > 0 ? [{ type: 'text', text: historyValue }] : [],
784
+ cursor: { index: historyValue.length > 0 ? 0 : 0, offset: historyValue.length }
785
+ };
786
+ renderOverlay(overlayStateRef.current);
787
+ }
788
+ return;
789
+ }
790
+ if (key.ctrl && input === 'n') {
791
+ if (historyEnabled && current.history.length > 0) {
792
+ let newIdx = current.historyIdx === -1 ? -1 : current.historyIdx + 1;
793
+ if (newIdx >= current.history.length)
794
+ newIdx = -1;
795
+ if (newIdx === -1) {
796
+ overlayStateRef.current = { ...createEmptyState(), history: current.history, historyIdx: -1 };
797
+ }
798
+ else {
799
+ const historyValue = current.history[newIdx];
800
+ overlayStateRef.current = {
801
+ ...current,
802
+ historyIdx: newIdx,
803
+ segments: historyValue.length > 0 ? [{ type: 'text', text: historyValue }] : [],
804
+ cursor: { index: historyValue.length > 0 ? 0 : 0, offset: historyValue.length }
805
+ };
806
+ }
807
+ renderOverlay(overlayStateRef.current);
808
+ }
809
+ return;
810
+ }
811
+ if (isBackspace) {
812
+ // On macOS, backspace key reports as key.delete=true, so always use backspace() for backward deletion
813
+ overlayStateRef.current = backspace(current);
417
814
  renderOverlay(overlayStateRef.current);
418
815
  return;
419
816
  }
420
817
  if (key.ctrl && input === 'u') {
421
- overlayStateRef.current = { ...createEmptyState(), history: current.history, historyIdx: -1 };
818
+ const { next, killed } = killToStart(current);
819
+ pushKill(killed);
820
+ overlayStateRef.current = { ...next, history: current.history, historyIdx: -1 };
821
+ renderOverlay(overlayStateRef.current);
822
+ return;
823
+ }
824
+ if (key.ctrl && input === 'k') {
825
+ const { next, killed } = killToEnd(current);
826
+ pushKill(killed);
827
+ overlayStateRef.current = next;
828
+ renderOverlay(overlayStateRef.current);
829
+ return;
830
+ }
831
+ if (key.ctrl && input === 'y') {
832
+ overlayStateRef.current = yank(current);
833
+ renderOverlay(overlayStateRef.current);
834
+ return;
835
+ }
836
+ if (key.meta && input === 'y') {
837
+ overlayStateRef.current = yankPop(current);
838
+ renderOverlay(overlayStateRef.current);
839
+ return;
840
+ }
841
+ if (key.ctrl && input === 't') {
842
+ overlayStateRef.current = transposeChars(current);
843
+ renderOverlay(overlayStateRef.current);
844
+ return;
845
+ }
846
+ if (key.meta && input === 't') {
847
+ overlayStateRef.current = transposeWords(current);
848
+ renderOverlay(overlayStateRef.current);
849
+ return;
850
+ }
851
+ if (key.ctrl && input === 'd') {
852
+ overlayStateRef.current = deleteForward(current);
422
853
  renderOverlay(overlayStateRef.current);
423
854
  return;
424
855
  }
425
856
  if (key.ctrl && input === 'w') {
426
- overlayStateRef.current = backspace(moveWordLeft(current));
857
+ const { next, killed } = killWordBackward(current);
858
+ pushKill(killed);
859
+ overlayStateRef.current = next;
860
+ renderOverlay(overlayStateRef.current);
861
+ return;
862
+ }
863
+ if (key.meta && (input === '\b' || input === '\x7f')) {
864
+ const { next, killed } = killWordBackward(current);
865
+ pushKill(killed);
866
+ overlayStateRef.current = next;
867
+ renderOverlay(overlayStateRef.current);
868
+ return;
869
+ }
870
+ if (key.meta && input === 'd') {
871
+ const { next, killed } = killWordForward(current);
872
+ pushKill(killed);
873
+ overlayStateRef.current = next;
874
+ renderOverlay(overlayStateRef.current);
875
+ return;
876
+ }
877
+ if (key.meta && input === 'b') {
878
+ overlayStateRef.current = moveWordLeft(current);
879
+ renderOverlay(overlayStateRef.current);
880
+ return;
881
+ }
882
+ if (key.meta && input === 'f') {
883
+ overlayStateRef.current = moveWordRight(current);
427
884
  renderOverlay(overlayStateRef.current);
428
885
  return;
429
886
  }