@plusonelabs/cue 0.0.38 → 0.0.40

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.
@@ -0,0 +1,632 @@
1
+ import { useInput } from 'ink';
2
+ import { useState, useCallback, useRef, useEffect } from 'react';
3
+ import { useDoublePress } from './useDoublePress.js';
4
+ import { Cursor } from '../utils/Cursor.js';
5
+ import { detectCursorCommand } from '../utils/keyMapping.js';
6
+
7
+ const ESC = '\u001B';
8
+ const PASTE_START = `[200~`;
9
+ const PASTE_END = `[201~`;
10
+ const PASTE_START_WITH_ESC = `${ESC}[200~`;
11
+ const PASTE_END_WITH_ESC = `${ESC}[201~`;
12
+
13
+ type Props = {
14
+ value: string;
15
+ onChange: (value: string) => void;
16
+ onSubmit: (value: string, isSubmittingSlashCommand?: boolean) => void;
17
+ commands: { name: string; description: string }[];
18
+ onToggleShortcuts?: () => void;
19
+ onClearShortcuts?: () => void;
20
+ onCancel?: () => boolean;
21
+ isLoading?: boolean;
22
+ disabled?: boolean;
23
+ };
24
+
25
+ export function useTextInput({
26
+ value,
27
+ onChange,
28
+ onSubmit,
29
+ commands,
30
+ onToggleShortcuts,
31
+ onClearShortcuts,
32
+ onCancel,
33
+ isLoading = false,
34
+ disabled = false,
35
+ }: Props) {
36
+ const [suggestions, setSuggestions] = useState<string[]>([]);
37
+ const [selectedSuggestion, setSelectedSuggestion] = useState(-1);
38
+ const [message, setMessage] = useState<{ show: boolean; text?: string }>({
39
+ show: false,
40
+ });
41
+ const [offset, setOffset] = useState(value.length);
42
+ const cursor = Cursor.fromText(value, 80, offset);
43
+ const [selection, setSelection] = useState<{
44
+ start: number;
45
+ end: number;
46
+ } | null>(null);
47
+ const [pasteInfo, setPasteInfo] = useState<{
48
+ pastes: Array<{
49
+ content: string;
50
+ lineCount: number;
51
+ charCount: number;
52
+ }>;
53
+ userInput: string;
54
+ totalLineCount: number;
55
+ totalCharCount: number;
56
+ isSummarized: boolean;
57
+ } | null>(null);
58
+
59
+ const [isPasteMode, setIsPasteMode] = useState(false);
60
+ const isPasteModeRef = useRef(false);
61
+ const [pasteBuffer, setPasteBuffer] = useState<string>('');
62
+ const pasteBufferRef = useRef<string>('');
63
+ const accumulatedInputRef = useRef<string>('');
64
+ const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
65
+ const lastInputTimeRef = useRef<number>(Date.now());
66
+ const [isPasting, setIsPasting] = useState(false);
67
+ const PASTE_TIMEOUT = 500;
68
+ const lastDeleteTimeRef = useRef<number>(0);
69
+
70
+ useEffect(() => {
71
+ process.stdout.write(`${ESC}[?2004h`);
72
+ return () => {
73
+ process.stdout.write(`${ESC}[?2004l`);
74
+ };
75
+ }, []);
76
+
77
+ const finalizePaste = useCallback(
78
+ (buffer: string) => {
79
+ const LINES_THRESHOLD = 30;
80
+ const CHARS_THRESHOLD = 1000;
81
+ const normalizedBuffer = buffer
82
+ .replace(/\r\n/g, '\n')
83
+ .replace(/\r/g, '\n');
84
+
85
+ let lines = normalizedBuffer.split('\n');
86
+ if (lines.length > 0 && lines[lines.length - 1] === '') {
87
+ lines = lines.slice(0, -1);
88
+ }
89
+ const lineCount = lines.length;
90
+ const charCount = normalizedBuffer.length;
91
+ const shouldSummarize =
92
+ lineCount > LINES_THRESHOLD && charCount > CHARS_THRESHOLD;
93
+
94
+ if (shouldSummarize && process.stdout.isTTY) {
95
+ process.stdout.write('\x1b[2K\r');
96
+ }
97
+
98
+ if (shouldSummarize) {
99
+ setPasteInfo((prev) => {
100
+ const newPaste = {
101
+ content: normalizedBuffer,
102
+ lineCount: lineCount,
103
+ charCount: charCount,
104
+ };
105
+
106
+ if (prev) {
107
+ const updatedPastes = [...prev.pastes, newPaste];
108
+ onChange(prev.userInput);
109
+ setOffset(prev.userInput.length);
110
+ return {
111
+ pastes: updatedPastes,
112
+ userInput: prev.userInput,
113
+ totalLineCount: prev.totalLineCount + lineCount,
114
+ totalCharCount: prev.totalCharCount + charCount,
115
+ isSummarized: true,
116
+ };
117
+ } else {
118
+ onChange('');
119
+ setOffset(0);
120
+ return {
121
+ pastes: [newPaste],
122
+ userInput: '',
123
+ totalLineCount: lineCount,
124
+ totalCharCount: charCount,
125
+ isSummarized: true,
126
+ };
127
+ }
128
+ });
129
+ } else {
130
+ const newCursor = cursor.insert(normalizedBuffer);
131
+ onChange(newCursor.text);
132
+ setOffset(newCursor.offset);
133
+ setSelection(null);
134
+ }
135
+
136
+ setIsPasting(false);
137
+ setPasteBuffer('');
138
+ setMessage({ show: false });
139
+ },
140
+ [onChange, cursor, setPasteInfo]
141
+ );
142
+
143
+ const processAccumulatedInput = useCallback(() => {
144
+ const input = accumulatedInputRef.current;
145
+ if (!input) return;
146
+
147
+ let pasteStartIdx = input.indexOf(PASTE_START_WITH_ESC);
148
+ let pasteEndIdx = input.indexOf(PASTE_END_WITH_ESC);
149
+ let pasteStartLen = PASTE_START_WITH_ESC.length;
150
+ let pasteEndLen = PASTE_END_WITH_ESC.length;
151
+
152
+ if (pasteStartIdx === -1) {
153
+ pasteStartIdx = input.indexOf(PASTE_START);
154
+ pasteStartLen = PASTE_START.length;
155
+ }
156
+ if (pasteEndIdx === -1) {
157
+ pasteEndIdx = input.indexOf(PASTE_END);
158
+ pasteEndLen = PASTE_END.length;
159
+ }
160
+
161
+ if (
162
+ pasteStartIdx !== -1 &&
163
+ pasteEndIdx !== -1 &&
164
+ pasteEndIdx > pasteStartIdx
165
+ ) {
166
+ const beforePaste = input.substring(0, pasteStartIdx);
167
+ const pasteContent = input.substring(
168
+ pasteStartIdx + pasteStartLen,
169
+ pasteEndIdx
170
+ );
171
+ const afterPaste = input.substring(pasteEndIdx + pasteEndLen);
172
+
173
+ if (beforePaste) {
174
+ const newCursor = cursor.insert(beforePaste);
175
+ onChange(newCursor.text);
176
+ setOffset(newCursor.offset);
177
+ }
178
+
179
+ finalizePaste(pasteContent);
180
+ accumulatedInputRef.current = afterPaste;
181
+ setIsPasteMode(false);
182
+
183
+ if (afterPaste) {
184
+ setTimeout(() => processAccumulatedInput(), 0);
185
+ }
186
+ } else if (pasteStartIdx !== -1 && pasteEndIdx === -1) {
187
+ setIsPasteMode(true);
188
+ } else if (pasteEndIdx !== -1 && pasteStartIdx === -1) {
189
+ const pasteContent = input.substring(0, pasteEndIdx);
190
+ if (isPasteMode && pasteBuffer) {
191
+ finalizePaste(pasteBuffer + pasteContent);
192
+ }
193
+ accumulatedInputRef.current = input.substring(pasteEndIdx + pasteEndLen);
194
+ setIsPasteMode(false);
195
+ setPasteBuffer('');
196
+ } else if (!isPasteMode) {
197
+ if (input.length > 100 || input.includes('\n') || input.includes('\r')) {
198
+ const toProcess = input;
199
+ accumulatedInputRef.current = '';
200
+ return toProcess;
201
+ }
202
+ return null;
203
+ }
204
+
205
+ return null;
206
+ }, [cursor, onChange, isPasteMode, pasteBuffer, finalizePaste]);
207
+
208
+ const handleEscape = useDoublePress(
209
+ (show) => {
210
+ onClearShortcuts?.();
211
+ // If loading and no input, show cancel message instead
212
+ if (isLoading && !value && !pasteInfo) {
213
+ setMessage({
214
+ show: show,
215
+ text: 'Press Escape again to cancel',
216
+ });
217
+ } else {
218
+ const hasContent = !!(value || pasteInfo);
219
+ setMessage({
220
+ show: hasContent && show,
221
+ text: 'Press Escape again to clear',
222
+ });
223
+ }
224
+ },
225
+ () => {
226
+ // First try to cancel if loading and no input
227
+ if (isLoading && !value && !pasteInfo && onCancel) {
228
+ const cancelled = onCancel();
229
+ if (cancelled) {
230
+ setMessage({ show: false });
231
+ return;
232
+ }
233
+ }
234
+
235
+ // Otherwise clear input/paste as before
236
+ if (value || pasteInfo) {
237
+ onChange('');
238
+ updateSuggestions('');
239
+ setPasteInfo(null);
240
+ setPasteBuffer('');
241
+ setIsPasting(false);
242
+ setOffset(0);
243
+ if (pasteTimeoutRef.current) {
244
+ clearTimeout(pasteTimeoutRef.current);
245
+ }
246
+ }
247
+ }
248
+ );
249
+
250
+ function updateSuggestions(newValue: string) {
251
+ if (newValue.startsWith('/') && newValue.length > 0) {
252
+ const input = newValue.slice(1).toLowerCase();
253
+
254
+ if (input.includes(' ')) {
255
+ setSuggestions([]);
256
+ setSelectedSuggestion(-1);
257
+ return;
258
+ }
259
+
260
+ const matchingCommands = commands.filter((cmd) =>
261
+ cmd.name.toLowerCase().startsWith(input)
262
+ );
263
+ const filtered = matchingCommands.map((cmd) => cmd.name);
264
+ setSuggestions(filtered);
265
+
266
+ setSelectedSuggestion(filtered.length > 0 ? 0 : -1);
267
+ } else {
268
+ setSuggestions([]);
269
+ setSelectedSuggestion(-1);
270
+ }
271
+ }
272
+
273
+ const clearSuggestions = useCallback(() => {
274
+ setSuggestions([]);
275
+ setSelectedSuggestion(-1);
276
+ }, []);
277
+
278
+ useInput((inputChar, key) => {
279
+ // Don't handle input when disabled (e.g., when a modal is open)
280
+ if (disabled) {
281
+ return;
282
+ }
283
+
284
+ const isRestrictedMode = pasteInfo?.isSummarized || false;
285
+
286
+ if (key.escape) {
287
+ handleEscape();
288
+ return;
289
+ }
290
+
291
+ if (key.delete || inputChar === '\x7f' || inputChar === '\x08') {
292
+ if (isRestrictedMode && pasteInfo) {
293
+ if (cursor.offset <= 0 && pasteInfo.userInput.length === 0) {
294
+ return;
295
+ }
296
+ if (pasteInfo.userInput.length > 0) {
297
+ const newUserInput = pasteInfo.userInput.slice(0, -1);
298
+ setPasteInfo({
299
+ ...pasteInfo,
300
+ userInput: newUserInput,
301
+ });
302
+ onChange(newUserInput);
303
+ setOffset(newUserInput.length);
304
+ }
305
+ return;
306
+ }
307
+
308
+ const newCursor = cursor.backspace();
309
+ onChange(newCursor.text);
310
+ setOffset(newCursor.offset);
311
+ updateSuggestions(newCursor.text);
312
+ setMessage({ show: false });
313
+ return;
314
+ }
315
+
316
+ const cursorCommand = detectCursorCommand(inputChar, key);
317
+
318
+ if (
319
+ cursorCommand &&
320
+ (suggestions.length === 0 ||
321
+ cursorCommand === 'deleteBackward' ||
322
+ cursorCommand === 'deleteForward' ||
323
+ cursorCommand.startsWith('select'))
324
+ ) {
325
+ let newCursor: Cursor = cursor;
326
+
327
+ switch (cursorCommand) {
328
+ case 'moveLeft':
329
+ if (isRestrictedMode) {
330
+ if (cursor.offset <= 0) {
331
+ return;
332
+ }
333
+ }
334
+ newCursor = cursor.left();
335
+ setSelection(null);
336
+ break;
337
+ case 'moveRight':
338
+ newCursor = cursor.right();
339
+ setSelection(null);
340
+ break;
341
+ case 'moveToStart':
342
+ if (isRestrictedMode) {
343
+ newCursor = new Cursor(value, 0);
344
+ } else {
345
+ newCursor = cursor.startOfLine();
346
+ }
347
+ setSelection(null);
348
+ break;
349
+ case 'moveToEnd':
350
+ newCursor = cursor.endOfLine();
351
+ setSelection(null);
352
+ break;
353
+ case 'moveWordLeft':
354
+ newCursor = cursor.prevWord();
355
+ if (isRestrictedMode && pasteInfo) {
356
+ if (newCursor.offset < 0) {
357
+ return;
358
+ }
359
+ }
360
+ setSelection(null);
361
+ break;
362
+ case 'moveWordRight':
363
+ newCursor = cursor.nextWord();
364
+ setSelection(null);
365
+ break;
366
+ case 'selectLeft':
367
+ newCursor = cursor.left();
368
+ break;
369
+ case 'selectRight':
370
+ newCursor = cursor.right();
371
+ break;
372
+ case 'selectToStart':
373
+ newCursor = cursor.startOfLine();
374
+ break;
375
+ case 'selectToEnd':
376
+ newCursor = cursor.endOfLine();
377
+ break;
378
+ case 'selectWordLeft':
379
+ newCursor = cursor.prevWord();
380
+ break;
381
+ case 'selectWordRight':
382
+ newCursor = cursor.nextWord();
383
+ break;
384
+ case 'deleteBackward':
385
+ if (isRestrictedMode && pasteInfo) {
386
+ if (cursor.offset <= 0 && pasteInfo.userInput.length === 0) {
387
+ return;
388
+ }
389
+ if (pasteInfo.userInput.length > 0) {
390
+ const newUserInput = pasteInfo.userInput.slice(0, -1);
391
+ setPasteInfo({
392
+ ...pasteInfo,
393
+ userInput: newUserInput,
394
+ });
395
+ onChange(newUserInput);
396
+ setOffset(newUserInput.length);
397
+ }
398
+ return;
399
+ }
400
+ newCursor = cursor.backspace();
401
+ onChange(newCursor.text);
402
+ setOffset(newCursor.offset);
403
+ updateSuggestions(newCursor.text);
404
+ setMessage({ show: false });
405
+ return;
406
+ case 'deleteForward': {
407
+ const now = Date.now();
408
+ if (now - lastDeleteTimeRef.current < 50) {
409
+ return;
410
+ }
411
+ lastDeleteTimeRef.current = now;
412
+ newCursor = cursor.del();
413
+ onChange(newCursor.text);
414
+ setOffset(newCursor.offset);
415
+ updateSuggestions(newCursor.text);
416
+ setMessage({ show: false });
417
+ return;
418
+ }
419
+ default:
420
+ return;
421
+ }
422
+
423
+ setOffset(newCursor.offset);
424
+ setSelection(null);
425
+ setMessage({ show: false });
426
+ return;
427
+ }
428
+
429
+ if (key.upArrow) {
430
+ if (suggestions.length > 0) {
431
+ setSelectedSuggestion((prev) =>
432
+ prev <= 0 ? suggestions.length - 1 : prev - 1
433
+ );
434
+ return;
435
+ }
436
+ if (isRestrictedMode) {
437
+ return;
438
+ }
439
+ const newCursor = cursor.up();
440
+ setOffset(newCursor.offset);
441
+ onChange(newCursor.text);
442
+ return;
443
+ }
444
+
445
+ if (key.downArrow) {
446
+ if (suggestions.length > 0) {
447
+ setSelectedSuggestion((prev) =>
448
+ prev >= suggestions.length - 1 ? 0 : prev + 1
449
+ );
450
+ return;
451
+ }
452
+ const newCursor = cursor.down();
453
+ setOffset(newCursor.offset);
454
+ onChange(newCursor.text);
455
+ return;
456
+ }
457
+
458
+ // Handle other navigation when suggestions are present
459
+ if (suggestions.length > 0) {
460
+ if (key.tab || (key.return && selectedSuggestion >= 0)) {
461
+ const suggestionIndex =
462
+ selectedSuggestion >= 0 ? selectedSuggestion : 0;
463
+ const suggestion = suggestions[suggestionIndex];
464
+ if (suggestion) {
465
+ const input = '/' + suggestion + ' ';
466
+ onChange(input);
467
+ setSuggestions([]);
468
+ setSelectedSuggestion(-1);
469
+
470
+ if (key.return) {
471
+ onSubmit(input, true);
472
+ }
473
+ }
474
+ return;
475
+ }
476
+ }
477
+
478
+ // Handle return
479
+ if (key.return) {
480
+ // Reconstruct full content if we have summarized pastes
481
+ let submitValue = value;
482
+ if (pasteInfo?.isSummarized) {
483
+ const totalPasteContent = pasteInfo.pastes
484
+ .map((p) => p.content)
485
+ .join('');
486
+ submitValue = totalPasteContent + pasteInfo.userInput;
487
+ }
488
+ if (submitValue.trim()) {
489
+ onSubmit(submitValue.trim());
490
+ setPasteInfo(null);
491
+ }
492
+ return;
493
+ }
494
+
495
+ // Handle ? shortcut for shortcuts (only if first character, don't add to input)
496
+ if (inputChar === '?' && !key.ctrl && !key.meta && value === '') {
497
+ onToggleShortcuts?.();
498
+ return;
499
+ }
500
+
501
+ // Handle regular character input (including pasted text)
502
+ // Note: Terminal may chunk long pastes at ~50 character boundaries
503
+ if (
504
+ inputChar &&
505
+ !key.ctrl &&
506
+ !key.escape &&
507
+ !key.tab &&
508
+ !key.return &&
509
+ !key.upArrow &&
510
+ !key.downArrow &&
511
+ !key.leftArrow &&
512
+ !key.rightArrow &&
513
+ !key.backspace &&
514
+ !key.delete
515
+ ) {
516
+ const now = Date.now();
517
+ const timeSinceLastInput = now - lastInputTimeRef.current;
518
+ lastInputTimeRef.current = now;
519
+
520
+ // Check if we're in the middle of a bracketed paste FIRST (use ref for immediate check)
521
+ if (isPasteModeRef.current && !inputChar.includes('[201~')) {
522
+ const newBuffer = pasteBufferRef.current + inputChar;
523
+ pasteBufferRef.current = newBuffer;
524
+ setPasteBuffer(newBuffer);
525
+ return;
526
+ }
527
+
528
+ // Then check if input contains bracketed paste sequences
529
+ if (inputChar.includes('[200~') || inputChar.includes('[201~')) {
530
+ const startIdx = inputChar.indexOf('[200~');
531
+ const endIdx = inputChar.indexOf('[201~');
532
+
533
+ if (startIdx !== -1 && endIdx !== -1) {
534
+ // Complete paste in one chunk
535
+ const pasteContent = inputChar.substring(startIdx + 5, endIdx);
536
+ finalizePaste(pasteContent);
537
+ return;
538
+ } else if (startIdx !== -1) {
539
+ // Paste started, accumulate
540
+ const pasteContent = inputChar.substring(startIdx + 5);
541
+ pasteBufferRef.current = pasteContent; // Update ref immediately
542
+ setPasteBuffer(pasteContent);
543
+ setIsPasting(true);
544
+ isPasteModeRef.current = true; // Update ref immediately
545
+ setIsPasteMode(true);
546
+ return;
547
+ } else if (endIdx !== -1 && (isPasting || isPasteModeRef.current)) {
548
+ // Paste ending
549
+ const beforeEndMarker = inputChar.substring(0, endIdx);
550
+ const pasteContent = pasteBufferRef.current + beforeEndMarker;
551
+ finalizePaste(pasteContent);
552
+ setIsPasting(false);
553
+ setPasteBuffer('');
554
+ pasteBufferRef.current = ''; // Reset ref
555
+ setIsPasteMode(false);
556
+ isPasteModeRef.current = false; // Reset ref
557
+ // Clear any pending timeout
558
+ if (pasteTimeoutRef.current) {
559
+ clearTimeout(pasteTimeoutRef.current);
560
+ pasteTimeoutRef.current = null;
561
+ }
562
+ return;
563
+ }
564
+ }
565
+
566
+ // If we already have a summarized paste and this is not a paste, handle as user input
567
+ if (
568
+ pasteInfo?.isSummarized &&
569
+ timeSinceLastInput > 50 &&
570
+ !inputChar.includes('\n') &&
571
+ !inputChar.includes('\r')
572
+ ) {
573
+ const newUserInput = pasteInfo.userInput + inputChar;
574
+ setPasteInfo({
575
+ ...pasteInfo,
576
+ userInput: newUserInput,
577
+ });
578
+ // Only show user input when summarized
579
+ onChange(newUserInput);
580
+ setOffset(newUserInput.length);
581
+ setMessage({ show: false });
582
+ return;
583
+ }
584
+
585
+ // Fallback: timeout-based detection for rapid input (only if not in bracketed paste mode)
586
+ if (
587
+ !isPasteModeRef.current &&
588
+ (timeSinceLastInput < 50 ||
589
+ inputChar.includes('\n') ||
590
+ inputChar.includes('\r'))
591
+ ) {
592
+ // Likely part of a paste
593
+ const newBuffer = pasteBuffer + inputChar;
594
+ setPasteBuffer(newBuffer);
595
+ setIsPasting(true);
596
+
597
+ if (pasteTimeoutRef.current) {
598
+ clearTimeout(pasteTimeoutRef.current);
599
+ }
600
+
601
+ pasteTimeoutRef.current = setTimeout(() => {
602
+ finalizePaste(newBuffer);
603
+ setIsPasting(false);
604
+ setPasteBuffer('');
605
+ accumulatedInputRef.current = ''; // Clear accumulator after paste
606
+ }, PASTE_TIMEOUT);
607
+ } else {
608
+ // Normal typing - but skip if we have a summarized paste to avoid garbled text
609
+ if (pasteInfo?.isSummarized) {
610
+ return;
611
+ }
612
+
613
+ const newCursor = cursor.insert(inputChar);
614
+ onChange(newCursor.text);
615
+ setOffset(newCursor.offset);
616
+ setSelection(null);
617
+ updateSuggestions(newCursor.text);
618
+ setMessage({ show: false });
619
+ }
620
+ }
621
+ });
622
+
623
+ return {
624
+ suggestions,
625
+ selectedSuggestion,
626
+ message,
627
+ clearSuggestions,
628
+ cursor: offset,
629
+ selection,
630
+ pasteInfo,
631
+ };
632
+ }
Binary file