wave-code 0.19.9 → 1.0.0
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/components/ChatInterface.js +1 -1
- package/dist/components/HelpView.js +6 -0
- package/dist/components/InputBox.d.ts +2 -0
- package/dist/components/InputBox.js +11 -2
- package/dist/components/RewindCommand.js +4 -2
- package/dist/hooks/useInputManager.d.ts +1 -0
- package/dist/hooks/useInputManager.js +23 -20
- package/dist/managers/inputHandlers.js +5 -8
- package/dist/managers/inputReducer.d.ts +10 -20
- package/dist/managers/inputReducer.js +309 -173
- package/dist/print-cli.js +36 -10
- package/dist/stdio/agentBridge.js +25 -2
- package/dist/utils/rewindCheckpoints.d.ts +8 -0
- package/dist/utils/rewindCheckpoints.js +15 -0
- package/dist/utils/worktree.d.ts +8 -0
- package/dist/utils/worktree.js +32 -1
- package/package.json +2 -2
- package/src/components/ChatInterface.tsx +2 -0
- package/src/components/HelpView.tsx +6 -0
- package/src/components/InputBox.tsx +19 -0
- package/src/components/RewindCommand.tsx +4 -2
- package/src/hooks/useInputManager.ts +27 -21
- package/src/managers/inputHandlers.ts +6 -10
- package/src/managers/inputReducer.ts +381 -208
- package/src/print-cli.ts +48 -11
- package/src/stdio/agentBridge.ts +25 -1
- package/src/utils/rewindCheckpoints.ts +15 -0
- package/src/utils/worktree.ts +50 -1
|
@@ -27,6 +27,8 @@ export interface BtwState {
|
|
|
27
27
|
isLoading: boolean;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
export const ESC_DOUBLE_PRESS_TIMEOUT_MS = 1000;
|
|
31
|
+
|
|
30
32
|
export type PendingEffect =
|
|
31
33
|
| {
|
|
32
34
|
type: "SEND_MESSAGE";
|
|
@@ -34,6 +36,11 @@ export type PendingEffect =
|
|
|
34
36
|
images?: Array<{ path: string; mimeType: string }>;
|
|
35
37
|
longTextMap: Record<string, string>;
|
|
36
38
|
}
|
|
39
|
+
| {
|
|
40
|
+
type: "SAVE_PROMPT_HISTORY";
|
|
41
|
+
content: string;
|
|
42
|
+
longTextMap: Record<string, string>;
|
|
43
|
+
}
|
|
37
44
|
| { type: "ABORT_MESSAGE" }
|
|
38
45
|
| { type: "BACKGROUND_CURRENT_TASK" }
|
|
39
46
|
| { type: "ASK_BTW"; question: string }
|
|
@@ -88,6 +95,7 @@ export interface InputManagerCallbacks {
|
|
|
88
95
|
}>;
|
|
89
96
|
logger?: Logger;
|
|
90
97
|
hasQueuedMessages?: boolean;
|
|
98
|
+
isIdle?: boolean;
|
|
91
99
|
onRecallQueuedMessage?: () => void;
|
|
92
100
|
}
|
|
93
101
|
|
|
@@ -118,9 +126,6 @@ export interface InputState {
|
|
|
118
126
|
showWorkflowManager: boolean;
|
|
119
127
|
permissionMode: PermissionMode;
|
|
120
128
|
selectorJustUsed: boolean;
|
|
121
|
-
isPasting: boolean;
|
|
122
|
-
pasteBuffer: string;
|
|
123
|
-
initialPasteCursorPosition: number;
|
|
124
129
|
history: PromptEntry[];
|
|
125
130
|
historyIndex: number;
|
|
126
131
|
originalInputText: string;
|
|
@@ -128,6 +133,7 @@ export interface InputState {
|
|
|
128
133
|
isFileSearching: boolean;
|
|
129
134
|
btwState: BtwState;
|
|
130
135
|
pendingEffect: PendingEffect | null;
|
|
136
|
+
escClearPending: boolean;
|
|
131
137
|
}
|
|
132
138
|
|
|
133
139
|
export const initialState: InputState = {
|
|
@@ -157,9 +163,6 @@ export const initialState: InputState = {
|
|
|
157
163
|
showWorkflowManager: false,
|
|
158
164
|
permissionMode: "default",
|
|
159
165
|
selectorJustUsed: false,
|
|
160
|
-
isPasting: false,
|
|
161
|
-
pasteBuffer: "",
|
|
162
|
-
initialPasteCursorPosition: 0,
|
|
163
166
|
history: [],
|
|
164
167
|
historyIndex: -1,
|
|
165
168
|
originalInputText: "",
|
|
@@ -170,8 +173,175 @@ export const initialState: InputState = {
|
|
|
170
173
|
isLoading: false,
|
|
171
174
|
},
|
|
172
175
|
pendingEffect: null,
|
|
176
|
+
escClearPending: false,
|
|
173
177
|
};
|
|
174
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Insert text at the cursor position, folding text longer than 200 chars
|
|
181
|
+
* into a [LongText#N] placeholder. Shared by the INSERT_TEXT_WITH_PLACEHOLDER
|
|
182
|
+
* action and multi-char chunk inserts (typed bursts, terminal paste, tmux
|
|
183
|
+
* send-keys).
|
|
184
|
+
*/
|
|
185
|
+
function insertTextWithPlaceholder(
|
|
186
|
+
textToInsert: string,
|
|
187
|
+
state: InputState,
|
|
188
|
+
): InputState {
|
|
189
|
+
let text = textToInsert;
|
|
190
|
+
let newLongTextCounter = state.longTextCounter;
|
|
191
|
+
const newLongTextMap = { ...state.longTextMap };
|
|
192
|
+
|
|
193
|
+
if (text.length > 200) {
|
|
194
|
+
newLongTextCounter += 1;
|
|
195
|
+
const placeholderLabel = `[LongText#${newLongTextCounter}]`;
|
|
196
|
+
newLongTextMap[placeholderLabel] = text;
|
|
197
|
+
text = placeholderLabel;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const beforeCursor = state.inputText.substring(0, state.cursorPosition);
|
|
201
|
+
const afterCursor = state.inputText.substring(state.cursorPosition);
|
|
202
|
+
const newText = beforeCursor + text + afterCursor;
|
|
203
|
+
const newCursorPosition = state.cursorPosition + text.length;
|
|
204
|
+
|
|
205
|
+
const newState: InputState = {
|
|
206
|
+
...state,
|
|
207
|
+
inputText: newText,
|
|
208
|
+
cursorPosition: newCursorPosition,
|
|
209
|
+
longTextCounter: newLongTextCounter,
|
|
210
|
+
longTextMap: newLongTextMap,
|
|
211
|
+
historyIndex: -1,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// Sync selectors
|
|
215
|
+
const atPos = getAtSelectorPosition(newText, newCursorPosition);
|
|
216
|
+
if (atPos !== -1 && !newState.showFileSelector) {
|
|
217
|
+
newState.showFileSelector = true;
|
|
218
|
+
newState.atPosition = atPos;
|
|
219
|
+
newState.isFileSearching = true;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const slashPos = getSlashSelectorPosition(newText, newCursorPosition);
|
|
223
|
+
if (slashPos !== -1 && !newState.showCommandSelector) {
|
|
224
|
+
newState.showCommandSelector = true;
|
|
225
|
+
newState.slashPosition = slashPos;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (newState.showFileSelector && newState.atPosition >= 0) {
|
|
229
|
+
newState.fileSearchQuery = newText.substring(
|
|
230
|
+
newState.atPosition + 1,
|
|
231
|
+
newCursorPosition,
|
|
232
|
+
);
|
|
233
|
+
} else if (newState.showCommandSelector && newState.slashPosition >= 0) {
|
|
234
|
+
newState.commandSearchQuery = newText.substring(
|
|
235
|
+
newState.slashPosition + 1,
|
|
236
|
+
newCursorPosition,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return newState;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Submit the current input text: extract [Image #N] references, route /btw
|
|
245
|
+
* and CLI-internal slash commands, otherwise send as a message. Returns null
|
|
246
|
+
* when there is nothing to submit (empty text, bare /btw).
|
|
247
|
+
*/
|
|
248
|
+
function submitInput(state: InputState): InputState | null {
|
|
249
|
+
if (!state.inputText.trim()) {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
const imageRegex = /\[Image #(\d+)\]/g;
|
|
253
|
+
const matches = [...state.inputText.matchAll(imageRegex)];
|
|
254
|
+
const referencedImages = matches
|
|
255
|
+
.map((match) => {
|
|
256
|
+
const imageId = parseInt(match[1], 10);
|
|
257
|
+
return state.attachedImages.find((img) => img.id === imageId);
|
|
258
|
+
})
|
|
259
|
+
.filter((img): img is AttachedImage => img !== undefined)
|
|
260
|
+
.map((img) => ({ path: img.path, mimeType: img.mimeType }));
|
|
261
|
+
|
|
262
|
+
const contentWithPlaceholders = state.inputText
|
|
263
|
+
.replace(imageRegex, "")
|
|
264
|
+
.trim();
|
|
265
|
+
|
|
266
|
+
if (contentWithPlaceholders.startsWith("/btw ")) {
|
|
267
|
+
const question = contentWithPlaceholders.substring(5).trim();
|
|
268
|
+
if (!question) {
|
|
269
|
+
// Bare /btw with no question text — ignore
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
...state,
|
|
275
|
+
inputText: "",
|
|
276
|
+
cursorPosition: 0,
|
|
277
|
+
historyIndex: -1,
|
|
278
|
+
longTextMap: {},
|
|
279
|
+
attachedImages: [],
|
|
280
|
+
btwState: {
|
|
281
|
+
question,
|
|
282
|
+
isLoading: true,
|
|
283
|
+
answer: undefined,
|
|
284
|
+
},
|
|
285
|
+
pendingEffect: { type: "ASK_BTW", question },
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (contentWithPlaceholders === "/btw") {
|
|
290
|
+
// Bare /btw — ignore
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Check if the content is a CLI-internal slash command (help, tasks,
|
|
295
|
+
// etc.) that should be executed locally rather than sent as a message.
|
|
296
|
+
// Agent slash commands and unknown /commands always go to SEND_MESSAGE.
|
|
297
|
+
if (contentWithPlaceholders.startsWith("/")) {
|
|
298
|
+
const spaceIndex = contentWithPlaceholders.indexOf(" ");
|
|
299
|
+
const commandName =
|
|
300
|
+
spaceIndex === -1
|
|
301
|
+
? contentWithPlaceholders.substring(1)
|
|
302
|
+
: contentWithPlaceholders.substring(1, spaceIndex);
|
|
303
|
+
|
|
304
|
+
const isInternalCommand = AVAILABLE_COMMANDS.some(
|
|
305
|
+
(cmd) => cmd.id === commandName,
|
|
306
|
+
);
|
|
307
|
+
if (isInternalCommand) {
|
|
308
|
+
const argsText =
|
|
309
|
+
spaceIndex === -1
|
|
310
|
+
? undefined
|
|
311
|
+
: contentWithPlaceholders.substring(spaceIndex + 1).trim() ||
|
|
312
|
+
undefined;
|
|
313
|
+
return {
|
|
314
|
+
...state,
|
|
315
|
+
inputText: "",
|
|
316
|
+
cursorPosition: 0,
|
|
317
|
+
historyIndex: -1,
|
|
318
|
+
longTextMap: {},
|
|
319
|
+
attachedImages: [],
|
|
320
|
+
pendingEffect: {
|
|
321
|
+
type: "EXECUTE_COMMAND",
|
|
322
|
+
command: commandName,
|
|
323
|
+
args: argsText,
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
...state,
|
|
331
|
+
inputText: "",
|
|
332
|
+
cursorPosition: 0,
|
|
333
|
+
historyIndex: -1,
|
|
334
|
+
longTextMap: {},
|
|
335
|
+
attachedImages: [],
|
|
336
|
+
pendingEffect: {
|
|
337
|
+
type: "SEND_MESSAGE",
|
|
338
|
+
content: contentWithPlaceholders,
|
|
339
|
+
images: referencedImages.length > 0 ? referencedImages : undefined,
|
|
340
|
+
longTextMap: state.longTextMap,
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
175
345
|
export type InputAction =
|
|
176
346
|
| { type: "SET_INPUT_TEXT"; payload: string }
|
|
177
347
|
| { type: "SET_CURSOR_POSITION"; payload: number }
|
|
@@ -205,13 +375,6 @@ export type InputAction =
|
|
|
205
375
|
| { type: "INSERT_TEXT_WITH_PLACEHOLDER"; payload: string }
|
|
206
376
|
| { type: "CLEAR_LONG_TEXT_MAP" }
|
|
207
377
|
| { type: "CLEAR_INPUT" }
|
|
208
|
-
| { type: "START_PASTE"; payload: { buffer: string; cursorPosition: number } }
|
|
209
|
-
| { type: "APPEND_PASTE_BUFFER"; payload: string }
|
|
210
|
-
| {
|
|
211
|
-
type: "APPEND_PASTE_CHUNK";
|
|
212
|
-
payload: { chunk: string; cursorPosition: number };
|
|
213
|
-
}
|
|
214
|
-
| { type: "END_PASTE" }
|
|
215
378
|
| {
|
|
216
379
|
type: "ADD_IMAGE_AND_INSERT_PLACEHOLDER";
|
|
217
380
|
payload: { path: string; mimeType: string };
|
|
@@ -225,6 +388,7 @@ export type InputAction =
|
|
|
225
388
|
| { type: "SELECT_FILE"; payload: string }
|
|
226
389
|
| { type: "SET_BTW_STATE"; payload: Partial<BtwState> }
|
|
227
390
|
| { type: "CLEAR_PENDING_EFFECT" }
|
|
391
|
+
| { type: "RESET_ESC_CLEAR_PENDING" }
|
|
228
392
|
| {
|
|
229
393
|
type: "HANDLE_KEY";
|
|
230
394
|
payload: {
|
|
@@ -232,6 +396,7 @@ export type InputAction =
|
|
|
232
396
|
key: Key;
|
|
233
397
|
hasSlashCommand: (cmd: string) => boolean;
|
|
234
398
|
hasQueuedMessages?: boolean;
|
|
399
|
+
isIdle?: boolean;
|
|
235
400
|
};
|
|
236
401
|
};
|
|
237
402
|
|
|
@@ -433,60 +598,8 @@ export function inputReducer(
|
|
|
433
598
|
return { ...state, permissionMode: action.payload };
|
|
434
599
|
case "SET_SELECTOR_JUST_USED":
|
|
435
600
|
return { ...state, selectorJustUsed: action.payload };
|
|
436
|
-
case "INSERT_TEXT_WITH_PLACEHOLDER":
|
|
437
|
-
|
|
438
|
-
let newLongTextCounter = state.longTextCounter;
|
|
439
|
-
const newLongTextMap = { ...state.longTextMap };
|
|
440
|
-
|
|
441
|
-
if (textToInsert.length > 200) {
|
|
442
|
-
newLongTextCounter += 1;
|
|
443
|
-
const placeholderLabel = `[LongText#${newLongTextCounter}]`;
|
|
444
|
-
newLongTextMap[placeholderLabel] = textToInsert;
|
|
445
|
-
textToInsert = placeholderLabel;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
const beforeCursor = state.inputText.substring(0, state.cursorPosition);
|
|
449
|
-
const afterCursor = state.inputText.substring(state.cursorPosition);
|
|
450
|
-
const newText = beforeCursor + textToInsert + afterCursor;
|
|
451
|
-
const newCursorPosition = state.cursorPosition + textToInsert.length;
|
|
452
|
-
|
|
453
|
-
const newState: InputState = {
|
|
454
|
-
...state,
|
|
455
|
-
inputText: newText,
|
|
456
|
-
cursorPosition: newCursorPosition,
|
|
457
|
-
longTextCounter: newLongTextCounter,
|
|
458
|
-
longTextMap: newLongTextMap,
|
|
459
|
-
historyIndex: -1,
|
|
460
|
-
};
|
|
461
|
-
|
|
462
|
-
// Sync selectors
|
|
463
|
-
const atPos = getAtSelectorPosition(newText, newCursorPosition);
|
|
464
|
-
if (atPos !== -1 && !newState.showFileSelector) {
|
|
465
|
-
newState.showFileSelector = true;
|
|
466
|
-
newState.atPosition = atPos;
|
|
467
|
-
newState.isFileSearching = true;
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
const slashPos = getSlashSelectorPosition(newText, newCursorPosition);
|
|
471
|
-
if (slashPos !== -1 && !newState.showCommandSelector) {
|
|
472
|
-
newState.showCommandSelector = true;
|
|
473
|
-
newState.slashPosition = slashPos;
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
if (newState.showFileSelector && newState.atPosition >= 0) {
|
|
477
|
-
newState.fileSearchQuery = newText.substring(
|
|
478
|
-
newState.atPosition + 1,
|
|
479
|
-
newCursorPosition,
|
|
480
|
-
);
|
|
481
|
-
} else if (newState.showCommandSelector && newState.slashPosition >= 0) {
|
|
482
|
-
newState.commandSearchQuery = newText.substring(
|
|
483
|
-
newState.slashPosition + 1,
|
|
484
|
-
newCursorPosition,
|
|
485
|
-
);
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
return newState;
|
|
489
|
-
}
|
|
601
|
+
case "INSERT_TEXT_WITH_PLACEHOLDER":
|
|
602
|
+
return insertTextWithPlaceholder(action.payload, state);
|
|
490
603
|
case "CLEAR_LONG_TEXT_MAP":
|
|
491
604
|
return { ...state, longTextMap: {} };
|
|
492
605
|
case "CLEAR_INPUT":
|
|
@@ -496,39 +609,6 @@ export function inputReducer(
|
|
|
496
609
|
cursorPosition: 0,
|
|
497
610
|
historyIndex: -1,
|
|
498
611
|
};
|
|
499
|
-
case "APPEND_PASTE_CHUNK": {
|
|
500
|
-
// The reducer determines if this is a new paste or a continuation
|
|
501
|
-
// by checking if pasteBuffer is already set. This avoids the
|
|
502
|
-
// handler needing to track isPasting state, which can be stale
|
|
503
|
-
// when multiple dispatches fire before React state updates.
|
|
504
|
-
const isNewPaste = !state.pasteBuffer;
|
|
505
|
-
return {
|
|
506
|
-
...state,
|
|
507
|
-
isPasting: true,
|
|
508
|
-
pasteBuffer: state.pasteBuffer + action.payload.chunk,
|
|
509
|
-
initialPasteCursorPosition: isNewPaste
|
|
510
|
-
? action.payload.cursorPosition
|
|
511
|
-
: state.initialPasteCursorPosition,
|
|
512
|
-
};
|
|
513
|
-
}
|
|
514
|
-
case "START_PASTE":
|
|
515
|
-
return {
|
|
516
|
-
...state,
|
|
517
|
-
isPasting: true,
|
|
518
|
-
pasteBuffer: action.payload.buffer,
|
|
519
|
-
initialPasteCursorPosition: action.payload.cursorPosition,
|
|
520
|
-
};
|
|
521
|
-
case "APPEND_PASTE_BUFFER":
|
|
522
|
-
return {
|
|
523
|
-
...state,
|
|
524
|
-
pasteBuffer: state.pasteBuffer + action.payload,
|
|
525
|
-
};
|
|
526
|
-
case "END_PASTE":
|
|
527
|
-
return {
|
|
528
|
-
...state,
|
|
529
|
-
isPasting: false,
|
|
530
|
-
pasteBuffer: "",
|
|
531
|
-
};
|
|
532
612
|
case "ADD_IMAGE_AND_INSERT_PLACEHOLDER": {
|
|
533
613
|
const newImage: AttachedImage = {
|
|
534
614
|
id: state.imageIdCounter,
|
|
@@ -685,9 +765,98 @@ export function inputReducer(
|
|
|
685
765
|
};
|
|
686
766
|
case "CLEAR_PENDING_EFFECT":
|
|
687
767
|
return { ...state, pendingEffect: null };
|
|
768
|
+
case "RESET_ESC_CLEAR_PENDING":
|
|
769
|
+
return { ...state, escClearPending: false };
|
|
688
770
|
case "HANDLE_KEY": {
|
|
689
771
|
const { input, key } = action.payload;
|
|
690
772
|
const hasQueuedMessages = action.payload.hasQueuedMessages ?? false;
|
|
773
|
+
const isIdle = action.payload.isIdle ?? false;
|
|
774
|
+
|
|
775
|
+
// 0. Raw DEL (\x7f) filtering.
|
|
776
|
+
// SSH/tmux auto-repeat backspace coalesces multiple DEL bytes into one
|
|
777
|
+
// chunk (e.g. "\x7f\x7f") that ink cannot parse into a key event, so it
|
|
778
|
+
// arrives as raw input and would otherwise be treated as a paste and
|
|
779
|
+
// inserted literally. Treat each DEL as a synchronous backspace instead
|
|
780
|
+
// (aligned with Claude Code Issue #1853).
|
|
781
|
+
if (!key.backspace && !key.delete && input.includes("\x7f")) {
|
|
782
|
+
const delCount = (input.match(/\x7f/g) || []).length;
|
|
783
|
+
|
|
784
|
+
if (state.showHistorySearch) {
|
|
785
|
+
return {
|
|
786
|
+
...state,
|
|
787
|
+
historySearchQuery: state.historySearchQuery.slice(0, -delCount),
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
if (state.cursorPosition > 0) {
|
|
792
|
+
const newCursorPosition = Math.max(
|
|
793
|
+
0,
|
|
794
|
+
state.cursorPosition - delCount,
|
|
795
|
+
);
|
|
796
|
+
const newInputText =
|
|
797
|
+
state.inputText.substring(0, newCursorPosition) +
|
|
798
|
+
state.inputText.substring(state.cursorPosition);
|
|
799
|
+
|
|
800
|
+
const newState = {
|
|
801
|
+
...state,
|
|
802
|
+
inputText: newInputText,
|
|
803
|
+
cursorPosition: newCursorPosition,
|
|
804
|
+
historyIndex: -1,
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
// Deactivate selectors if their trigger character was deleted
|
|
808
|
+
if (
|
|
809
|
+
newState.showFileSelector &&
|
|
810
|
+
newCursorPosition <= newState.atPosition
|
|
811
|
+
) {
|
|
812
|
+
newState.showFileSelector = false;
|
|
813
|
+
newState.atPosition = -1;
|
|
814
|
+
newState.fileSearchQuery = "";
|
|
815
|
+
newState.isFileSearching = false;
|
|
816
|
+
}
|
|
817
|
+
if (
|
|
818
|
+
newState.showCommandSelector &&
|
|
819
|
+
newCursorPosition <= newState.slashPosition
|
|
820
|
+
) {
|
|
821
|
+
newState.showCommandSelector = false;
|
|
822
|
+
newState.slashPosition = -1;
|
|
823
|
+
newState.commandSearchQuery = "";
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Reactivate selectors if cursor is within a trigger word
|
|
827
|
+
const atPos = getAtSelectorPosition(newInputText, newCursorPosition);
|
|
828
|
+
if (atPos !== -1 && !state.showFileSelector) {
|
|
829
|
+
newState.showFileSelector = true;
|
|
830
|
+
newState.atPosition = atPos;
|
|
831
|
+
newState.isFileSearching = true;
|
|
832
|
+
}
|
|
833
|
+
const slashPos = getSlashSelectorPosition(
|
|
834
|
+
newInputText,
|
|
835
|
+
newCursorPosition,
|
|
836
|
+
);
|
|
837
|
+
if (slashPos !== -1 && !state.showCommandSelector) {
|
|
838
|
+
newState.showCommandSelector = true;
|
|
839
|
+
newState.slashPosition = slashPos;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Update queries
|
|
843
|
+
if (newState.showFileSelector && newState.atPosition >= 0) {
|
|
844
|
+
newState.fileSearchQuery = newInputText.substring(
|
|
845
|
+
newState.atPosition + 1,
|
|
846
|
+
newCursorPosition,
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
if (newState.showCommandSelector && newState.slashPosition >= 0) {
|
|
850
|
+
newState.commandSearchQuery = newInputText.substring(
|
|
851
|
+
newState.slashPosition + 1,
|
|
852
|
+
newCursorPosition,
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
return newState;
|
|
857
|
+
}
|
|
858
|
+
return state;
|
|
859
|
+
}
|
|
691
860
|
|
|
692
861
|
// 1. Escape Handling
|
|
693
862
|
if (key.escape) {
|
|
@@ -754,7 +923,39 @@ export function inputReducer(
|
|
|
754
923
|
state.showWorkflowManager
|
|
755
924
|
)
|
|
756
925
|
) {
|
|
757
|
-
|
|
926
|
+
// While AI is running (or any busy state) Esc keeps the abort
|
|
927
|
+
// semantics. Only when idle does Esc fall through to the text-level
|
|
928
|
+
// double-press clear (aligned with Claude Code's mutual-exclusion
|
|
929
|
+
// design: Esc aborts only when a task is running).
|
|
930
|
+
if (!isIdle) {
|
|
931
|
+
return {
|
|
932
|
+
...state,
|
|
933
|
+
escClearPending: false,
|
|
934
|
+
pendingEffect: { type: "ABORT_MESSAGE" },
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
// Idle: double-press Esc clears the input and saves to history.
|
|
938
|
+
if (state.inputText) {
|
|
939
|
+
if (state.escClearPending) {
|
|
940
|
+
const originalText = state.inputText;
|
|
941
|
+
const originalLongTextMap = state.longTextMap;
|
|
942
|
+
return {
|
|
943
|
+
...state,
|
|
944
|
+
inputText: "",
|
|
945
|
+
cursorPosition: 0,
|
|
946
|
+
historyIndex: -1,
|
|
947
|
+
longTextMap: {},
|
|
948
|
+
escClearPending: false,
|
|
949
|
+
pendingEffect: {
|
|
950
|
+
type: "SAVE_PROMPT_HISTORY",
|
|
951
|
+
content: originalText,
|
|
952
|
+
longTextMap: originalLongTextMap,
|
|
953
|
+
},
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
return { ...state, escClearPending: true };
|
|
957
|
+
}
|
|
958
|
+
return state;
|
|
758
959
|
}
|
|
759
960
|
return state;
|
|
760
961
|
}
|
|
@@ -797,6 +998,59 @@ export function inputReducer(
|
|
|
797
998
|
};
|
|
798
999
|
}
|
|
799
1000
|
|
|
1001
|
+
// Emacs-style line editing (aligned with Claude Code): Ctrl+A/E move the
|
|
1002
|
+
// cursor to line start/end, Ctrl+U/K delete to line start/end, Ctrl+W
|
|
1003
|
+
// deletes the word before the cursor. Skipped while a selector is open.
|
|
1004
|
+
if (
|
|
1005
|
+
key.ctrl &&
|
|
1006
|
+
input &&
|
|
1007
|
+
!state.showFileSelector &&
|
|
1008
|
+
!state.showCommandSelector &&
|
|
1009
|
+
!state.showHistorySearch
|
|
1010
|
+
) {
|
|
1011
|
+
const editKey = input.toLowerCase();
|
|
1012
|
+
if (editKey === "a") {
|
|
1013
|
+
return { ...state, cursorPosition: 0 };
|
|
1014
|
+
}
|
|
1015
|
+
if (editKey === "e") {
|
|
1016
|
+
return { ...state, cursorPosition: state.inputText.length };
|
|
1017
|
+
}
|
|
1018
|
+
if (editKey === "u") {
|
|
1019
|
+
return {
|
|
1020
|
+
...state,
|
|
1021
|
+
inputText: state.inputText.substring(state.cursorPosition),
|
|
1022
|
+
cursorPosition: 0,
|
|
1023
|
+
historyIndex: -1,
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
if (editKey === "k") {
|
|
1027
|
+
return {
|
|
1028
|
+
...state,
|
|
1029
|
+
inputText: state.inputText.substring(0, state.cursorPosition),
|
|
1030
|
+
historyIndex: -1,
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
if (editKey === "w") {
|
|
1034
|
+
// Find the start of the word before the cursor (skip trailing
|
|
1035
|
+
// whitespace, then the word itself).
|
|
1036
|
+
let start = state.cursorPosition - 1;
|
|
1037
|
+
while (start >= 0 && /\s/.test(state.inputText[start])) {
|
|
1038
|
+
start--;
|
|
1039
|
+
}
|
|
1040
|
+
while (start >= 0 && !/\s/.test(state.inputText[start])) {
|
|
1041
|
+
start--;
|
|
1042
|
+
}
|
|
1043
|
+
return {
|
|
1044
|
+
...state,
|
|
1045
|
+
inputText:
|
|
1046
|
+
state.inputText.substring(0, start + 1) +
|
|
1047
|
+
state.inputText.substring(state.cursorPosition),
|
|
1048
|
+
cursorPosition: start + 1,
|
|
1049
|
+
historyIndex: -1,
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
800
1054
|
// 4. History Navigation
|
|
801
1055
|
if (
|
|
802
1056
|
key.upArrow &&
|
|
@@ -980,101 +1234,7 @@ export function inputReducer(
|
|
|
980
1234
|
|
|
981
1235
|
// 6. Return / Submit
|
|
982
1236
|
if (key.return) {
|
|
983
|
-
|
|
984
|
-
const imageRegex = /\[Image #(\d+)\]/g;
|
|
985
|
-
const matches = [...state.inputText.matchAll(imageRegex)];
|
|
986
|
-
const referencedImages = matches
|
|
987
|
-
.map((match) => {
|
|
988
|
-
const imageId = parseInt(match[1], 10);
|
|
989
|
-
return state.attachedImages.find((img) => img.id === imageId);
|
|
990
|
-
})
|
|
991
|
-
.filter((img): img is AttachedImage => img !== undefined)
|
|
992
|
-
.map((img) => ({ path: img.path, mimeType: img.mimeType }));
|
|
993
|
-
|
|
994
|
-
const contentWithPlaceholders = state.inputText
|
|
995
|
-
.replace(imageRegex, "")
|
|
996
|
-
.trim();
|
|
997
|
-
|
|
998
|
-
if (contentWithPlaceholders.startsWith("/btw ")) {
|
|
999
|
-
const question = contentWithPlaceholders.substring(5).trim();
|
|
1000
|
-
if (!question) {
|
|
1001
|
-
// Bare /btw with no question text — ignore
|
|
1002
|
-
return state;
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
return {
|
|
1006
|
-
...state,
|
|
1007
|
-
inputText: "",
|
|
1008
|
-
cursorPosition: 0,
|
|
1009
|
-
historyIndex: -1,
|
|
1010
|
-
longTextMap: {},
|
|
1011
|
-
attachedImages: [],
|
|
1012
|
-
btwState: {
|
|
1013
|
-
question,
|
|
1014
|
-
isLoading: true,
|
|
1015
|
-
answer: undefined,
|
|
1016
|
-
},
|
|
1017
|
-
pendingEffect: { type: "ASK_BTW", question },
|
|
1018
|
-
};
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
if (contentWithPlaceholders === "/btw") {
|
|
1022
|
-
// Bare /btw — ignore
|
|
1023
|
-
return state;
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
// Check if the content is a CLI-internal slash command (help, tasks,
|
|
1027
|
-
// etc.) that should be executed locally rather than sent as a message.
|
|
1028
|
-
// Agent slash commands and unknown /commands always go to SEND_MESSAGE.
|
|
1029
|
-
if (contentWithPlaceholders.startsWith("/")) {
|
|
1030
|
-
const spaceIndex = contentWithPlaceholders.indexOf(" ");
|
|
1031
|
-
const commandName =
|
|
1032
|
-
spaceIndex === -1
|
|
1033
|
-
? contentWithPlaceholders.substring(1)
|
|
1034
|
-
: contentWithPlaceholders.substring(1, spaceIndex);
|
|
1035
|
-
|
|
1036
|
-
const isInternalCommand = AVAILABLE_COMMANDS.some(
|
|
1037
|
-
(cmd) => cmd.id === commandName,
|
|
1038
|
-
);
|
|
1039
|
-
if (isInternalCommand) {
|
|
1040
|
-
const argsText =
|
|
1041
|
-
spaceIndex === -1
|
|
1042
|
-
? undefined
|
|
1043
|
-
: contentWithPlaceholders.substring(spaceIndex + 1).trim() ||
|
|
1044
|
-
undefined;
|
|
1045
|
-
return {
|
|
1046
|
-
...state,
|
|
1047
|
-
inputText: "",
|
|
1048
|
-
cursorPosition: 0,
|
|
1049
|
-
historyIndex: -1,
|
|
1050
|
-
longTextMap: {},
|
|
1051
|
-
attachedImages: [],
|
|
1052
|
-
pendingEffect: {
|
|
1053
|
-
type: "EXECUTE_COMMAND",
|
|
1054
|
-
command: commandName,
|
|
1055
|
-
args: argsText,
|
|
1056
|
-
},
|
|
1057
|
-
};
|
|
1058
|
-
}
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
return {
|
|
1062
|
-
...state,
|
|
1063
|
-
inputText: "",
|
|
1064
|
-
cursorPosition: 0,
|
|
1065
|
-
historyIndex: -1,
|
|
1066
|
-
longTextMap: {},
|
|
1067
|
-
attachedImages: [],
|
|
1068
|
-
pendingEffect: {
|
|
1069
|
-
type: "SEND_MESSAGE",
|
|
1070
|
-
content: contentWithPlaceholders,
|
|
1071
|
-
images:
|
|
1072
|
-
referencedImages.length > 0 ? referencedImages : undefined,
|
|
1073
|
-
longTextMap: state.longTextMap,
|
|
1074
|
-
},
|
|
1075
|
-
};
|
|
1076
|
-
}
|
|
1077
|
-
return state;
|
|
1237
|
+
return submitInput(state) ?? state;
|
|
1078
1238
|
}
|
|
1079
1239
|
|
|
1080
1240
|
// 7. Regular Input
|
|
@@ -1090,19 +1250,32 @@ export function inputReducer(
|
|
|
1090
1250
|
!("home" in key && key.home) &&
|
|
1091
1251
|
!("end" in key && key.end)
|
|
1092
1252
|
) {
|
|
1093
|
-
|
|
1094
|
-
|
|
1253
|
+
// SSH-coalesced Enter: on slow links, "text" + Enter arrive as one
|
|
1254
|
+
// chunk ("o\r"). ink's parseKeypress only matches a lone \r, so
|
|
1255
|
+
// key.return is false here. Text with exactly one trailing \r is a
|
|
1256
|
+
// coalesced Enter — strip the \r, insert, and submit immediately
|
|
1257
|
+
// (aligned with Claude Code's useTextInput).
|
|
1258
|
+
const isCoalescedEnter =
|
|
1259
|
+
input.length > 1 &&
|
|
1260
|
+
input.endsWith("\r") &&
|
|
1261
|
+
!input.slice(0, -1).includes("\r") &&
|
|
1262
|
+
// Backslash+CR is a stale VS Code Shift+Enter binding, not a
|
|
1263
|
+
// coalesced Enter — keep it as regular input.
|
|
1264
|
+
input[input.length - 2] !== "\\";
|
|
1095
1265
|
|
|
1096
|
-
if (
|
|
1097
|
-
const
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1266
|
+
if (isCoalescedEnter) {
|
|
1267
|
+
const insertedState = insertTextWithPlaceholder(
|
|
1268
|
+
input.slice(0, -1),
|
|
1269
|
+
state,
|
|
1270
|
+
);
|
|
1271
|
+
return submitInput(insertedState) ?? insertedState;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
if (input.length > 1) {
|
|
1275
|
+
// Multi-char chunk (typed burst, terminal paste, tmux send-keys):
|
|
1276
|
+
// insert immediately — no debounce or paste buffer. \r → \n
|
|
1277
|
+
// normalizes carriage returns from CRLF terminals.
|
|
1278
|
+
return insertTextWithPlaceholder(input.replace(/\r/g, "\n"), state);
|
|
1106
1279
|
} else {
|
|
1107
1280
|
let char = input;
|
|
1108
1281
|
if (char === "!" && state.cursorPosition === 0) {
|