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.
- package/dist/App.d.ts.map +1 -1
- package/dist/App.js +71 -13
- package/dist/App.js.map +1 -1
- package/dist/agent/agent.d.ts.map +1 -1
- package/dist/agent/agent.js +3 -1
- package/dist/agent/agent.js.map +1 -1
- package/dist/agent/commands/index.d.ts.map +1 -1
- package/dist/agent/commands/index.js +3 -1
- package/dist/agent/commands/index.js.map +1 -1
- package/dist/agent/commands/input_mode.d.ts +3 -0
- package/dist/agent/commands/input_mode.d.ts.map +1 -0
- package/dist/agent/commands/input_mode.js +21 -0
- package/dist/agent/commands/input_mode.js.map +1 -0
- package/dist/agent/commands/keybindings.d.ts +3 -0
- package/dist/agent/commands/keybindings.d.ts.map +1 -0
- package/dist/agent/commands/keybindings.js +38 -0
- package/dist/agent/commands/keybindings.js.map +1 -0
- package/dist/agent/commands/types.d.ts +2 -0
- package/dist/agent/commands/types.d.ts.map +1 -1
- package/dist/cli.js +38 -1
- package/dist/cli.js.map +1 -1
- package/dist/components/FullScreen.d.ts.map +1 -1
- package/dist/components/FullScreen.js +29 -29
- package/dist/components/FullScreen.js.map +1 -1
- package/dist/components/InputArea.d.ts.map +1 -1
- package/dist/components/InputArea.js +476 -19
- package/dist/components/InputArea.js.map +1 -1
- package/dist/components/StatusBar.d.ts +1 -0
- package/dist/components/StatusBar.d.ts.map +1 -1
- package/dist/components/StatusBar.js +2 -1
- package/dist/components/StatusBar.js.map +1 -1
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/core/input_segments.d.ts +1 -0
- package/dist/ui/core/input_segments.d.ts.map +1 -1
- package/dist/ui/core/input_segments.js +46 -14
- package/dist/ui/core/input_segments.js.map +1 -1
- package/dist/ui/core/types.d.ts +1 -0
- package/dist/ui/core/types.d.ts.map +1 -1
- package/dist/ui/ink/render.d.ts +3 -1
- package/dist/ui/ink/render.d.ts.map +1 -1
- package/dist/ui/ink/render.js +7 -5
- package/dist/ui/ink/render.js.map +1 -1
- package/dist/ui/views/app.d.ts +2 -1
- package/dist/ui/views/app.d.ts.map +1 -1
- package/dist/ui/views/app.js +2 -1
- package/dist/ui/views/app.js.map +1 -1
- package/dist/ui/views/header.d.ts.map +1 -1
- package/dist/ui/views/header.js +8 -5
- package/dist/ui/views/header.js.map +1 -1
- package/dist/ui/views/status_bar.d.ts +2 -1
- package/dist/ui/views/status_bar.d.ts.map +1 -1
- package/dist/ui/views/status_bar.js +5 -1
- package/dist/ui/views/status_bar.js.map +1 -1
- package/package.json +1 -1
- package/src/App.tsx +71 -13
- package/src/agent/agent.ts +3 -1
- package/src/agent/commands/index.ts +4 -0
- package/src/agent/commands/input_mode.ts +22 -0
- package/src/agent/commands/keybindings.ts +40 -0
- package/src/agent/commands/types.ts +2 -0
- package/src/cli.tsx +43 -1
- package/src/components/FullScreen.tsx +39 -34
- package/src/components/InputArea.tsx +489 -19
- package/src/components/StatusBar.tsx +3 -0
- package/src/types.ts +1 -0
- package/src/ui/core/input_segments.ts +49 -14
- package/src/ui/core/types.ts +1 -0
- package/src/ui/ink/render.tsx +16 -5
- package/src/ui/views/app.ts +3 -0
- package/src/ui/views/header.ts +8 -5
- 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
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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 (
|
|
357
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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 (
|
|
487
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|