wave-code 0.19.9 → 1.0.1
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/cli.js +20 -1
- package/dist/components/App.js +7 -0
- package/dist/components/BtwDisplay.js +13 -3
- package/dist/components/ChatInterface.js +21 -6
- package/dist/components/HelpView.js +6 -0
- package/dist/components/InputBox.d.ts +1 -2
- package/dist/components/InputBox.js +16 -5
- package/dist/components/LoadingIndicator.d.ts +1 -2
- package/dist/components/LoadingIndicator.js +2 -2
- package/dist/components/Markdown.js +13 -16
- package/dist/components/MessageList.d.ts +2 -1
- package/dist/components/MessageList.js +2 -2
- package/dist/components/RewindCommand.js +4 -2
- package/dist/components/StatusLine.d.ts +0 -2
- package/dist/components/StatusLine.js +6 -6
- package/dist/components/TaskList.js +2 -1
- package/dist/components/ToolDisplay.d.ts +1 -0
- package/dist/components/ToolDisplay.js +17 -9
- package/dist/constants/commands.js +0 -6
- package/dist/contexts/useChat.d.ts +3 -5
- package/dist/contexts/useChat.js +242 -82
- package/dist/daemon-cli.d.ts +10 -0
- package/dist/daemon-cli.js +15 -0
- package/dist/hooks/useInputManager.d.ts +1 -0
- package/dist/hooks/useInputManager.js +120 -40
- package/dist/index.js +10 -0
- package/dist/managers/inputHandlers.js +55 -30
- package/dist/managers/inputReducer.d.ts +22 -22
- package/dist/managers/inputReducer.js +361 -177
- package/dist/print-cli.js +36 -10
- package/dist/stdio/agentBridge.d.ts +23 -0
- package/dist/stdio/agentBridge.js +151 -18
- package/dist/stdio/daemonServer.d.ts +67 -0
- package/dist/stdio/daemonServer.js +191 -0
- package/dist/stdio/index.d.ts +2 -0
- package/dist/stdio/index.js +2 -0
- package/dist/stdio/jsonRpcConnection.d.ts +30 -0
- package/dist/stdio/jsonRpcConnection.js +127 -0
- package/dist/stdio/protocol.d.ts +2 -2
- package/dist/stdio/stdioServer.d.ts +2 -7
- package/dist/stdio/stdioServer.js +9 -100
- package/dist/utils/bracketedPaste.d.ts +39 -0
- package/dist/utils/bracketedPaste.js +122 -0
- package/dist/utils/markdownTable.d.ts +34 -0
- package/dist/utils/markdownTable.js +302 -0
- package/dist/utils/rewindCheckpoints.d.ts +8 -0
- package/dist/utils/rewindCheckpoints.js +15 -0
- package/dist/utils/throttle.d.ts +3 -3
- package/dist/utils/worktree.d.ts +8 -0
- package/dist/utils/worktree.js +32 -1
- package/package.json +4 -2
- package/src/cli.tsx +20 -1
- package/src/components/App.tsx +5 -0
- package/src/components/BtwDisplay.tsx +36 -12
- package/src/components/ChatInterface.tsx +26 -11
- package/src/components/HelpView.tsx +6 -0
- package/src/components/InputBox.tsx +21 -10
- package/src/components/LoadingIndicator.tsx +1 -4
- package/src/components/Markdown.tsx +15 -18
- package/src/components/MessageList.tsx +6 -0
- package/src/components/RewindCommand.tsx +4 -2
- package/src/components/StatusLine.tsx +0 -10
- package/src/components/TaskList.tsx +2 -1
- package/src/components/ToolDisplay.tsx +17 -6
- package/src/constants/commands.ts +0 -6
- package/src/contexts/useChat.tsx +310 -95
- package/src/daemon-cli.ts +17 -0
- package/src/hooks/useInputManager.ts +135 -43
- package/src/index.ts +12 -0
- package/src/managers/inputHandlers.ts +55 -32
- package/src/managers/inputReducer.ts +442 -214
- package/src/print-cli.ts +48 -11
- package/src/stdio/agentBridge.ts +213 -18
- package/src/stdio/daemonServer.ts +212 -0
- package/src/stdio/index.ts +2 -0
- package/src/stdio/jsonRpcConnection.ts +160 -0
- package/src/stdio/protocol.ts +5 -2
- package/src/stdio/stdioServer.ts +14 -120
- package/src/utils/bracketedPaste.ts +170 -0
- package/src/utils/markdownTable.ts +359 -0
- package/src/utils/rewindCheckpoints.ts +15 -0
- package/src/utils/throttle.ts +8 -8
- package/src/utils/worktree.ts +50 -1
|
@@ -27,6 +27,16 @@ export interface BtwState {
|
|
|
27
27
|
isLoading: boolean;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* True while the /btw overlay is up (a question is on display, loading or
|
|
32
|
+
* answered). App's Ctrl+C exit handler checks this so Ctrl+C does not quit
|
|
33
|
+
* the app while the overlay owns the keys. Synced from useInputManager via
|
|
34
|
+
* an effect.
|
|
35
|
+
*/
|
|
36
|
+
export const btwOverlayActiveRef: { current: boolean } = { current: false };
|
|
37
|
+
|
|
38
|
+
export const ESC_DOUBLE_PRESS_TIMEOUT_MS = 1000;
|
|
39
|
+
|
|
30
40
|
export type PendingEffect =
|
|
31
41
|
| {
|
|
32
42
|
type: "SEND_MESSAGE";
|
|
@@ -34,9 +44,15 @@ export type PendingEffect =
|
|
|
34
44
|
images?: Array<{ path: string; mimeType: string }>;
|
|
35
45
|
longTextMap: Record<string, string>;
|
|
36
46
|
}
|
|
47
|
+
| {
|
|
48
|
+
type: "SAVE_PROMPT_HISTORY";
|
|
49
|
+
content: string;
|
|
50
|
+
longTextMap: Record<string, string>;
|
|
51
|
+
}
|
|
37
52
|
| { type: "ABORT_MESSAGE" }
|
|
38
53
|
| { type: "BACKGROUND_CURRENT_TASK" }
|
|
39
54
|
| { type: "ASK_BTW"; question: string }
|
|
55
|
+
| { type: "ABORT_BTW" }
|
|
40
56
|
| { type: "PERMISSION_MODE_CHANGE"; mode: PermissionMode }
|
|
41
57
|
| { type: "FETCH_HISTORY" }
|
|
42
58
|
| { type: "PASTE_IMAGE" }
|
|
@@ -76,10 +92,13 @@ export interface InputManagerCallbacks {
|
|
|
76
92
|
onAbortMessage?: () => void;
|
|
77
93
|
onBackgroundCurrentTask?: () => void;
|
|
78
94
|
onPermissionModeChange?: (mode: PermissionMode) => void;
|
|
79
|
-
onAskBtw?: (
|
|
95
|
+
onAskBtw?: (
|
|
96
|
+
question: string,
|
|
97
|
+
abortSignal?: AbortSignal,
|
|
98
|
+
onContent?: (content: string) => void,
|
|
99
|
+
) => Promise<string>;
|
|
80
100
|
onClearMessages?: () => Promise<void>;
|
|
81
101
|
onCompact?: (instructions?: string) => Promise<void>;
|
|
82
|
-
onGoalCommand?: (args?: string) => Promise<void>;
|
|
83
102
|
sessionId?: string;
|
|
84
103
|
workdir?: string;
|
|
85
104
|
getFullMessageThread?: () => Promise<{
|
|
@@ -88,6 +107,7 @@ export interface InputManagerCallbacks {
|
|
|
88
107
|
}>;
|
|
89
108
|
logger?: Logger;
|
|
90
109
|
hasQueuedMessages?: boolean;
|
|
110
|
+
isIdle?: boolean;
|
|
91
111
|
onRecallQueuedMessage?: () => void;
|
|
92
112
|
}
|
|
93
113
|
|
|
@@ -118,9 +138,6 @@ export interface InputState {
|
|
|
118
138
|
showWorkflowManager: boolean;
|
|
119
139
|
permissionMode: PermissionMode;
|
|
120
140
|
selectorJustUsed: boolean;
|
|
121
|
-
isPasting: boolean;
|
|
122
|
-
pasteBuffer: string;
|
|
123
|
-
initialPasteCursorPosition: number;
|
|
124
141
|
history: PromptEntry[];
|
|
125
142
|
historyIndex: number;
|
|
126
143
|
originalInputText: string;
|
|
@@ -128,6 +145,7 @@ export interface InputState {
|
|
|
128
145
|
isFileSearching: boolean;
|
|
129
146
|
btwState: BtwState;
|
|
130
147
|
pendingEffect: PendingEffect | null;
|
|
148
|
+
escClearPending: boolean;
|
|
131
149
|
}
|
|
132
150
|
|
|
133
151
|
export const initialState: InputState = {
|
|
@@ -157,9 +175,6 @@ export const initialState: InputState = {
|
|
|
157
175
|
showWorkflowManager: false,
|
|
158
176
|
permissionMode: "default",
|
|
159
177
|
selectorJustUsed: false,
|
|
160
|
-
isPasting: false,
|
|
161
|
-
pasteBuffer: "",
|
|
162
|
-
initialPasteCursorPosition: 0,
|
|
163
178
|
history: [],
|
|
164
179
|
historyIndex: -1,
|
|
165
180
|
originalInputText: "",
|
|
@@ -170,8 +185,199 @@ export const initialState: InputState = {
|
|
|
170
185
|
isLoading: false,
|
|
171
186
|
},
|
|
172
187
|
pendingEffect: null,
|
|
188
|
+
escClearPending: false,
|
|
173
189
|
};
|
|
174
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Insert text at the cursor position, folding text longer than 200 chars
|
|
193
|
+
* into a [LongText#N] placeholder. Shared by the INSERT_TEXT_WITH_PLACEHOLDER
|
|
194
|
+
* action and multi-char chunk inserts (typed bursts, terminal paste, tmux
|
|
195
|
+
* send-keys).
|
|
196
|
+
*/
|
|
197
|
+
function insertTextWithPlaceholder(
|
|
198
|
+
textToInsert: string,
|
|
199
|
+
state: InputState,
|
|
200
|
+
): InputState {
|
|
201
|
+
let text = textToInsert;
|
|
202
|
+
let newLongTextCounter = state.longTextCounter;
|
|
203
|
+
const newLongTextMap = { ...state.longTextMap };
|
|
204
|
+
|
|
205
|
+
if (text.length > 200) {
|
|
206
|
+
newLongTextCounter += 1;
|
|
207
|
+
const placeholderLabel = `[LongText#${newLongTextCounter}]`;
|
|
208
|
+
newLongTextMap[placeholderLabel] = text;
|
|
209
|
+
text = placeholderLabel;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const beforeCursor = state.inputText.substring(0, state.cursorPosition);
|
|
213
|
+
const afterCursor = state.inputText.substring(state.cursorPosition);
|
|
214
|
+
const newText = beforeCursor + text + afterCursor;
|
|
215
|
+
const newCursorPosition = state.cursorPosition + text.length;
|
|
216
|
+
|
|
217
|
+
const newState: InputState = {
|
|
218
|
+
...state,
|
|
219
|
+
inputText: newText,
|
|
220
|
+
cursorPosition: newCursorPosition,
|
|
221
|
+
longTextCounter: newLongTextCounter,
|
|
222
|
+
longTextMap: newLongTextMap,
|
|
223
|
+
historyIndex: -1,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// Sync selectors
|
|
227
|
+
const atPos = getAtSelectorPosition(newText, newCursorPosition);
|
|
228
|
+
if (atPos !== -1 && !newState.showFileSelector) {
|
|
229
|
+
newState.showFileSelector = true;
|
|
230
|
+
newState.atPosition = atPos;
|
|
231
|
+
newState.isFileSearching = true;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const slashPos = getSlashSelectorPosition(newText, newCursorPosition);
|
|
235
|
+
if (slashPos !== -1 && !newState.showCommandSelector) {
|
|
236
|
+
newState.showCommandSelector = true;
|
|
237
|
+
newState.slashPosition = slashPos;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (newState.showFileSelector && newState.atPosition >= 0) {
|
|
241
|
+
newState.fileSearchQuery = newText.substring(
|
|
242
|
+
newState.atPosition + 1,
|
|
243
|
+
newCursorPosition,
|
|
244
|
+
);
|
|
245
|
+
} else if (newState.showCommandSelector && newState.slashPosition >= 0) {
|
|
246
|
+
newState.commandSearchQuery = newText.substring(
|
|
247
|
+
newState.slashPosition + 1,
|
|
248
|
+
newCursorPosition,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return newState;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Submit the current input text: extract [Image #N] references, route /btw
|
|
257
|
+
* and CLI-internal slash commands, otherwise send as a message. Returns null
|
|
258
|
+
* when there is nothing to submit (empty text).
|
|
259
|
+
*/
|
|
260
|
+
function submitInput(state: InputState): InputState | null {
|
|
261
|
+
if (!state.inputText.trim()) {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
const imageRegex = /\[Image #(\d+)\]/g;
|
|
265
|
+
const matches = [...state.inputText.matchAll(imageRegex)];
|
|
266
|
+
const referencedImages = matches
|
|
267
|
+
.map((match) => {
|
|
268
|
+
const imageId = parseInt(match[1], 10);
|
|
269
|
+
return state.attachedImages.find((img) => img.id === imageId);
|
|
270
|
+
})
|
|
271
|
+
.filter((img): img is AttachedImage => img !== undefined)
|
|
272
|
+
.map((img) => ({ path: img.path, mimeType: img.mimeType }));
|
|
273
|
+
|
|
274
|
+
const contentWithPlaceholders = state.inputText
|
|
275
|
+
.replace(imageRegex, "")
|
|
276
|
+
.trim();
|
|
277
|
+
|
|
278
|
+
if (contentWithPlaceholders.startsWith("/btw ")) {
|
|
279
|
+
const question = contentWithPlaceholders.substring(5).trim();
|
|
280
|
+
if (!question) {
|
|
281
|
+
// "/btw " with no question text — show usage (aligned with Claude Code)
|
|
282
|
+
return {
|
|
283
|
+
...state,
|
|
284
|
+
inputText: "",
|
|
285
|
+
cursorPosition: 0,
|
|
286
|
+
historyIndex: -1,
|
|
287
|
+
longTextMap: {},
|
|
288
|
+
attachedImages: [],
|
|
289
|
+
btwState: {
|
|
290
|
+
question: "",
|
|
291
|
+
isLoading: false,
|
|
292
|
+
answer: "Usage: /btw <your question>",
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
...state,
|
|
299
|
+
inputText: "",
|
|
300
|
+
cursorPosition: 0,
|
|
301
|
+
historyIndex: -1,
|
|
302
|
+
longTextMap: {},
|
|
303
|
+
attachedImages: [],
|
|
304
|
+
btwState: {
|
|
305
|
+
question,
|
|
306
|
+
isLoading: true,
|
|
307
|
+
answer: undefined,
|
|
308
|
+
},
|
|
309
|
+
pendingEffect: { type: "ASK_BTW", question },
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (contentWithPlaceholders === "/btw") {
|
|
314
|
+
// Bare /btw — show usage (aligned with Claude Code)
|
|
315
|
+
return {
|
|
316
|
+
...state,
|
|
317
|
+
inputText: "",
|
|
318
|
+
cursorPosition: 0,
|
|
319
|
+
historyIndex: -1,
|
|
320
|
+
longTextMap: {},
|
|
321
|
+
attachedImages: [],
|
|
322
|
+
btwState: {
|
|
323
|
+
question: "",
|
|
324
|
+
isLoading: false,
|
|
325
|
+
answer: "Usage: /btw <your question>",
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Check if the content is a CLI-internal slash command (help, tasks,
|
|
331
|
+
// etc.) that should be executed locally rather than sent as a message.
|
|
332
|
+
// Agent slash commands and unknown /commands always go to SEND_MESSAGE.
|
|
333
|
+
if (contentWithPlaceholders.startsWith("/")) {
|
|
334
|
+
const spaceIndex = contentWithPlaceholders.indexOf(" ");
|
|
335
|
+
const commandName =
|
|
336
|
+
spaceIndex === -1
|
|
337
|
+
? contentWithPlaceholders.substring(1)
|
|
338
|
+
: contentWithPlaceholders.substring(1, spaceIndex);
|
|
339
|
+
|
|
340
|
+
const isInternalCommand = AVAILABLE_COMMANDS.some(
|
|
341
|
+
(cmd) => cmd.id === commandName,
|
|
342
|
+
);
|
|
343
|
+
if (isInternalCommand) {
|
|
344
|
+
const argsText =
|
|
345
|
+
spaceIndex === -1
|
|
346
|
+
? undefined
|
|
347
|
+
: contentWithPlaceholders.substring(spaceIndex + 1).trim() ||
|
|
348
|
+
undefined;
|
|
349
|
+
return {
|
|
350
|
+
...state,
|
|
351
|
+
inputText: "",
|
|
352
|
+
cursorPosition: 0,
|
|
353
|
+
historyIndex: -1,
|
|
354
|
+
longTextMap: {},
|
|
355
|
+
attachedImages: [],
|
|
356
|
+
pendingEffect: {
|
|
357
|
+
type: "EXECUTE_COMMAND",
|
|
358
|
+
command: commandName,
|
|
359
|
+
args: argsText,
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
...state,
|
|
367
|
+
inputText: "",
|
|
368
|
+
cursorPosition: 0,
|
|
369
|
+
historyIndex: -1,
|
|
370
|
+
longTextMap: {},
|
|
371
|
+
attachedImages: [],
|
|
372
|
+
pendingEffect: {
|
|
373
|
+
type: "SEND_MESSAGE",
|
|
374
|
+
content: contentWithPlaceholders,
|
|
375
|
+
images: referencedImages.length > 0 ? referencedImages : undefined,
|
|
376
|
+
longTextMap: state.longTextMap,
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
175
381
|
export type InputAction =
|
|
176
382
|
| { type: "SET_INPUT_TEXT"; payload: string }
|
|
177
383
|
| { type: "SET_CURSOR_POSITION"; payload: number }
|
|
@@ -205,13 +411,6 @@ export type InputAction =
|
|
|
205
411
|
| { type: "INSERT_TEXT_WITH_PLACEHOLDER"; payload: string }
|
|
206
412
|
| { type: "CLEAR_LONG_TEXT_MAP" }
|
|
207
413
|
| { 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
414
|
| {
|
|
216
415
|
type: "ADD_IMAGE_AND_INSERT_PLACEHOLDER";
|
|
217
416
|
payload: { path: string; mimeType: string };
|
|
@@ -225,6 +424,7 @@ export type InputAction =
|
|
|
225
424
|
| { type: "SELECT_FILE"; payload: string }
|
|
226
425
|
| { type: "SET_BTW_STATE"; payload: Partial<BtwState> }
|
|
227
426
|
| { type: "CLEAR_PENDING_EFFECT" }
|
|
427
|
+
| { type: "RESET_ESC_CLEAR_PENDING" }
|
|
228
428
|
| {
|
|
229
429
|
type: "HANDLE_KEY";
|
|
230
430
|
payload: {
|
|
@@ -232,6 +432,7 @@ export type InputAction =
|
|
|
232
432
|
key: Key;
|
|
233
433
|
hasSlashCommand: (cmd: string) => boolean;
|
|
234
434
|
hasQueuedMessages?: boolean;
|
|
435
|
+
isIdle?: boolean;
|
|
235
436
|
};
|
|
236
437
|
};
|
|
237
438
|
|
|
@@ -433,60 +634,8 @@ export function inputReducer(
|
|
|
433
634
|
return { ...state, permissionMode: action.payload };
|
|
434
635
|
case "SET_SELECTOR_JUST_USED":
|
|
435
636
|
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
|
-
}
|
|
637
|
+
case "INSERT_TEXT_WITH_PLACEHOLDER":
|
|
638
|
+
return insertTextWithPlaceholder(action.payload, state);
|
|
490
639
|
case "CLEAR_LONG_TEXT_MAP":
|
|
491
640
|
return { ...state, longTextMap: {} };
|
|
492
641
|
case "CLEAR_INPUT":
|
|
@@ -496,39 +645,6 @@ export function inputReducer(
|
|
|
496
645
|
cursorPosition: 0,
|
|
497
646
|
historyIndex: -1,
|
|
498
647
|
};
|
|
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
648
|
case "ADD_IMAGE_AND_INSERT_PLACEHOLDER": {
|
|
533
649
|
const newImage: AttachedImage = {
|
|
534
650
|
id: state.imageIdCounter,
|
|
@@ -685,14 +801,115 @@ export function inputReducer(
|
|
|
685
801
|
};
|
|
686
802
|
case "CLEAR_PENDING_EFFECT":
|
|
687
803
|
return { ...state, pendingEffect: null };
|
|
804
|
+
case "RESET_ESC_CLEAR_PENDING":
|
|
805
|
+
return { ...state, escClearPending: false };
|
|
688
806
|
case "HANDLE_KEY": {
|
|
689
807
|
const { input, key } = action.payload;
|
|
690
808
|
const hasQueuedMessages = action.payload.hasQueuedMessages ?? false;
|
|
809
|
+
const isIdle = action.payload.isIdle ?? false;
|
|
691
810
|
|
|
692
|
-
//
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
811
|
+
// 0. Raw DEL (\x7f) filtering.
|
|
812
|
+
// SSH/tmux auto-repeat backspace coalesces multiple DEL bytes into one
|
|
813
|
+
// chunk (e.g. "\x7f\x7f") that ink cannot parse into a key event, so it
|
|
814
|
+
// arrives as raw input and would otherwise be treated as a paste and
|
|
815
|
+
// inserted literally. Treat each DEL as a synchronous backspace instead
|
|
816
|
+
// (aligned with Claude Code Issue #1853).
|
|
817
|
+
if (!key.backspace && !key.delete && input.includes("\x7f")) {
|
|
818
|
+
const delCount = (input.match(/\x7f/g) || []).length;
|
|
819
|
+
|
|
820
|
+
if (state.showHistorySearch) {
|
|
821
|
+
return {
|
|
822
|
+
...state,
|
|
823
|
+
historySearchQuery: state.historySearchQuery.slice(0, -delCount),
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
if (state.cursorPosition > 0) {
|
|
828
|
+
const newCursorPosition = Math.max(
|
|
829
|
+
0,
|
|
830
|
+
state.cursorPosition - delCount,
|
|
831
|
+
);
|
|
832
|
+
const newInputText =
|
|
833
|
+
state.inputText.substring(0, newCursorPosition) +
|
|
834
|
+
state.inputText.substring(state.cursorPosition);
|
|
835
|
+
|
|
836
|
+
const newState = {
|
|
837
|
+
...state,
|
|
838
|
+
inputText: newInputText,
|
|
839
|
+
cursorPosition: newCursorPosition,
|
|
840
|
+
historyIndex: -1,
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
// Deactivate selectors if their trigger character was deleted
|
|
844
|
+
if (
|
|
845
|
+
newState.showFileSelector &&
|
|
846
|
+
newCursorPosition <= newState.atPosition
|
|
847
|
+
) {
|
|
848
|
+
newState.showFileSelector = false;
|
|
849
|
+
newState.atPosition = -1;
|
|
850
|
+
newState.fileSearchQuery = "";
|
|
851
|
+
newState.isFileSearching = false;
|
|
852
|
+
}
|
|
853
|
+
if (
|
|
854
|
+
newState.showCommandSelector &&
|
|
855
|
+
newCursorPosition <= newState.slashPosition
|
|
856
|
+
) {
|
|
857
|
+
newState.showCommandSelector = false;
|
|
858
|
+
newState.slashPosition = -1;
|
|
859
|
+
newState.commandSearchQuery = "";
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// Reactivate selectors if cursor is within a trigger word
|
|
863
|
+
const atPos = getAtSelectorPosition(newInputText, newCursorPosition);
|
|
864
|
+
if (atPos !== -1 && !state.showFileSelector) {
|
|
865
|
+
newState.showFileSelector = true;
|
|
866
|
+
newState.atPosition = atPos;
|
|
867
|
+
newState.isFileSearching = true;
|
|
868
|
+
}
|
|
869
|
+
const slashPos = getSlashSelectorPosition(
|
|
870
|
+
newInputText,
|
|
871
|
+
newCursorPosition,
|
|
872
|
+
);
|
|
873
|
+
if (slashPos !== -1 && !state.showCommandSelector) {
|
|
874
|
+
newState.showCommandSelector = true;
|
|
875
|
+
newState.slashPosition = slashPos;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Update queries
|
|
879
|
+
if (newState.showFileSelector && newState.atPosition >= 0) {
|
|
880
|
+
newState.fileSearchQuery = newInputText.substring(
|
|
881
|
+
newState.atPosition + 1,
|
|
882
|
+
newCursorPosition,
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
if (newState.showCommandSelector && newState.slashPosition >= 0) {
|
|
886
|
+
newState.commandSearchQuery = newInputText.substring(
|
|
887
|
+
newState.slashPosition + 1,
|
|
888
|
+
newCursorPosition,
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
return newState;
|
|
893
|
+
}
|
|
894
|
+
return state;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// 1. /btw overlay handling (active while a question is displayed, or
|
|
898
|
+
// the bare-/btw usage message). Only Escape dismisses (or aborts the
|
|
899
|
+
// in-flight side question while loading); every other key is ignored.
|
|
900
|
+
if (state.btwState.question || state.btwState.answer) {
|
|
901
|
+
if (key.escape) {
|
|
902
|
+
if (state.btwState.isLoading) {
|
|
903
|
+
return {
|
|
904
|
+
...state,
|
|
905
|
+
btwState: {
|
|
906
|
+
question: "",
|
|
907
|
+
answer: undefined,
|
|
908
|
+
isLoading: false,
|
|
909
|
+
},
|
|
910
|
+
pendingEffect: { type: "ABORT_BTW" },
|
|
911
|
+
};
|
|
912
|
+
}
|
|
696
913
|
return {
|
|
697
914
|
...state,
|
|
698
915
|
btwState: {
|
|
@@ -702,6 +919,13 @@ export function inputReducer(
|
|
|
702
919
|
},
|
|
703
920
|
};
|
|
704
921
|
}
|
|
922
|
+
|
|
923
|
+
// Any other key while the overlay is up is ignored
|
|
924
|
+
return state;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// 1. Escape Handling
|
|
928
|
+
if (key.escape) {
|
|
705
929
|
if (state.showFileSelector) {
|
|
706
930
|
return {
|
|
707
931
|
...state,
|
|
@@ -754,7 +978,39 @@ export function inputReducer(
|
|
|
754
978
|
state.showWorkflowManager
|
|
755
979
|
)
|
|
756
980
|
) {
|
|
757
|
-
|
|
981
|
+
// While AI is running (or any busy state) Esc keeps the abort
|
|
982
|
+
// semantics. Only when idle does Esc fall through to the text-level
|
|
983
|
+
// double-press clear (aligned with Claude Code's mutual-exclusion
|
|
984
|
+
// design: Esc aborts only when a task is running).
|
|
985
|
+
if (!isIdle) {
|
|
986
|
+
return {
|
|
987
|
+
...state,
|
|
988
|
+
escClearPending: false,
|
|
989
|
+
pendingEffect: { type: "ABORT_MESSAGE" },
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
// Idle: double-press Esc clears the input and saves to history.
|
|
993
|
+
if (state.inputText) {
|
|
994
|
+
if (state.escClearPending) {
|
|
995
|
+
const originalText = state.inputText;
|
|
996
|
+
const originalLongTextMap = state.longTextMap;
|
|
997
|
+
return {
|
|
998
|
+
...state,
|
|
999
|
+
inputText: "",
|
|
1000
|
+
cursorPosition: 0,
|
|
1001
|
+
historyIndex: -1,
|
|
1002
|
+
longTextMap: {},
|
|
1003
|
+
escClearPending: false,
|
|
1004
|
+
pendingEffect: {
|
|
1005
|
+
type: "SAVE_PROMPT_HISTORY",
|
|
1006
|
+
content: originalText,
|
|
1007
|
+
longTextMap: originalLongTextMap,
|
|
1008
|
+
},
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
return { ...state, escClearPending: true };
|
|
1012
|
+
}
|
|
1013
|
+
return state;
|
|
758
1014
|
}
|
|
759
1015
|
return state;
|
|
760
1016
|
}
|
|
@@ -797,6 +1053,59 @@ export function inputReducer(
|
|
|
797
1053
|
};
|
|
798
1054
|
}
|
|
799
1055
|
|
|
1056
|
+
// Emacs-style line editing (aligned with Claude Code): Ctrl+A/E move the
|
|
1057
|
+
// cursor to line start/end, Ctrl+U/K delete to line start/end, Ctrl+W
|
|
1058
|
+
// deletes the word before the cursor. Skipped while a selector is open.
|
|
1059
|
+
if (
|
|
1060
|
+
key.ctrl &&
|
|
1061
|
+
input &&
|
|
1062
|
+
!state.showFileSelector &&
|
|
1063
|
+
!state.showCommandSelector &&
|
|
1064
|
+
!state.showHistorySearch
|
|
1065
|
+
) {
|
|
1066
|
+
const editKey = input.toLowerCase();
|
|
1067
|
+
if (editKey === "a") {
|
|
1068
|
+
return { ...state, cursorPosition: 0 };
|
|
1069
|
+
}
|
|
1070
|
+
if (editKey === "e") {
|
|
1071
|
+
return { ...state, cursorPosition: state.inputText.length };
|
|
1072
|
+
}
|
|
1073
|
+
if (editKey === "u") {
|
|
1074
|
+
return {
|
|
1075
|
+
...state,
|
|
1076
|
+
inputText: state.inputText.substring(state.cursorPosition),
|
|
1077
|
+
cursorPosition: 0,
|
|
1078
|
+
historyIndex: -1,
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
if (editKey === "k") {
|
|
1082
|
+
return {
|
|
1083
|
+
...state,
|
|
1084
|
+
inputText: state.inputText.substring(0, state.cursorPosition),
|
|
1085
|
+
historyIndex: -1,
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
if (editKey === "w") {
|
|
1089
|
+
// Find the start of the word before the cursor (skip trailing
|
|
1090
|
+
// whitespace, then the word itself).
|
|
1091
|
+
let start = state.cursorPosition - 1;
|
|
1092
|
+
while (start >= 0 && /\s/.test(state.inputText[start])) {
|
|
1093
|
+
start--;
|
|
1094
|
+
}
|
|
1095
|
+
while (start >= 0 && !/\s/.test(state.inputText[start])) {
|
|
1096
|
+
start--;
|
|
1097
|
+
}
|
|
1098
|
+
return {
|
|
1099
|
+
...state,
|
|
1100
|
+
inputText:
|
|
1101
|
+
state.inputText.substring(0, start + 1) +
|
|
1102
|
+
state.inputText.substring(state.cursorPosition),
|
|
1103
|
+
cursorPosition: start + 1,
|
|
1104
|
+
historyIndex: -1,
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
800
1109
|
// 4. History Navigation
|
|
801
1110
|
if (
|
|
802
1111
|
key.upArrow &&
|
|
@@ -980,101 +1289,7 @@ export function inputReducer(
|
|
|
980
1289
|
|
|
981
1290
|
// 6. Return / Submit
|
|
982
1291
|
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;
|
|
1292
|
+
return submitInput(state) ?? state;
|
|
1078
1293
|
}
|
|
1079
1294
|
|
|
1080
1295
|
// 7. Regular Input
|
|
@@ -1090,19 +1305,32 @@ export function inputReducer(
|
|
|
1090
1305
|
!("home" in key && key.home) &&
|
|
1091
1306
|
!("end" in key && key.end)
|
|
1092
1307
|
) {
|
|
1093
|
-
|
|
1094
|
-
|
|
1308
|
+
// SSH-coalesced Enter: on slow links, "text" + Enter arrive as one
|
|
1309
|
+
// chunk ("o\r"). ink's parseKeypress only matches a lone \r, so
|
|
1310
|
+
// key.return is false here. Text with exactly one trailing \r is a
|
|
1311
|
+
// coalesced Enter — strip the \r, insert, and submit immediately
|
|
1312
|
+
// (aligned with Claude Code's useTextInput).
|
|
1313
|
+
const isCoalescedEnter =
|
|
1314
|
+
input.length > 1 &&
|
|
1315
|
+
input.endsWith("\r") &&
|
|
1316
|
+
!input.slice(0, -1).includes("\r") &&
|
|
1317
|
+
// Backslash+CR is a stale VS Code Shift+Enter binding, not a
|
|
1318
|
+
// coalesced Enter — keep it as regular input.
|
|
1319
|
+
input[input.length - 2] !== "\\";
|
|
1095
1320
|
|
|
1096
|
-
if (
|
|
1097
|
-
const
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1321
|
+
if (isCoalescedEnter) {
|
|
1322
|
+
const insertedState = insertTextWithPlaceholder(
|
|
1323
|
+
input.slice(0, -1),
|
|
1324
|
+
state,
|
|
1325
|
+
);
|
|
1326
|
+
return submitInput(insertedState) ?? insertedState;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
if (input.length > 1) {
|
|
1330
|
+
// Multi-char chunk (typed burst, terminal paste, tmux send-keys):
|
|
1331
|
+
// insert immediately — no debounce or paste buffer. \r → \n
|
|
1332
|
+
// normalizes carriage returns from CRLF terminals.
|
|
1333
|
+
return insertTextWithPlaceholder(input.replace(/\r/g, "\n"), state);
|
|
1106
1334
|
} else {
|
|
1107
1335
|
let char = input;
|
|
1108
1336
|
if (char === "!" && state.cursorPosition === 0) {
|