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
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { getAtSelectorPosition, getSlashSelectorPosition, getWordEnd, SELECTOR_TRIGGERS, getProjectedState, } from "../utils/inputUtils.js";
|
|
2
2
|
import { AVAILABLE_COMMANDS } from "../constants/commands.js";
|
|
3
|
+
/**
|
|
4
|
+
* True while the /btw overlay is up (a question is on display, loading or
|
|
5
|
+
* answered). App's Ctrl+C exit handler checks this so Ctrl+C does not quit
|
|
6
|
+
* the app while the overlay owns the keys. Synced from useInputManager via
|
|
7
|
+
* an effect.
|
|
8
|
+
*/
|
|
9
|
+
export const btwOverlayActiveRef = { current: false };
|
|
10
|
+
export const ESC_DOUBLE_PRESS_TIMEOUT_MS = 1000;
|
|
3
11
|
export const initialState = {
|
|
4
12
|
inputText: "",
|
|
5
13
|
cursorPosition: 0,
|
|
@@ -27,9 +35,6 @@ export const initialState = {
|
|
|
27
35
|
showWorkflowManager: false,
|
|
28
36
|
permissionMode: "default",
|
|
29
37
|
selectorJustUsed: false,
|
|
30
|
-
isPasting: false,
|
|
31
|
-
pasteBuffer: "",
|
|
32
|
-
initialPasteCursorPosition: 0,
|
|
33
38
|
history: [],
|
|
34
39
|
historyIndex: -1,
|
|
35
40
|
originalInputText: "",
|
|
@@ -40,7 +45,170 @@ export const initialState = {
|
|
|
40
45
|
isLoading: false,
|
|
41
46
|
},
|
|
42
47
|
pendingEffect: null,
|
|
48
|
+
escClearPending: false,
|
|
43
49
|
};
|
|
50
|
+
/**
|
|
51
|
+
* Insert text at the cursor position, folding text longer than 200 chars
|
|
52
|
+
* into a [LongText#N] placeholder. Shared by the INSERT_TEXT_WITH_PLACEHOLDER
|
|
53
|
+
* action and multi-char chunk inserts (typed bursts, terminal paste, tmux
|
|
54
|
+
* send-keys).
|
|
55
|
+
*/
|
|
56
|
+
function insertTextWithPlaceholder(textToInsert, state) {
|
|
57
|
+
let text = textToInsert;
|
|
58
|
+
let newLongTextCounter = state.longTextCounter;
|
|
59
|
+
const newLongTextMap = { ...state.longTextMap };
|
|
60
|
+
if (text.length > 200) {
|
|
61
|
+
newLongTextCounter += 1;
|
|
62
|
+
const placeholderLabel = `[LongText#${newLongTextCounter}]`;
|
|
63
|
+
newLongTextMap[placeholderLabel] = text;
|
|
64
|
+
text = placeholderLabel;
|
|
65
|
+
}
|
|
66
|
+
const beforeCursor = state.inputText.substring(0, state.cursorPosition);
|
|
67
|
+
const afterCursor = state.inputText.substring(state.cursorPosition);
|
|
68
|
+
const newText = beforeCursor + text + afterCursor;
|
|
69
|
+
const newCursorPosition = state.cursorPosition + text.length;
|
|
70
|
+
const newState = {
|
|
71
|
+
...state,
|
|
72
|
+
inputText: newText,
|
|
73
|
+
cursorPosition: newCursorPosition,
|
|
74
|
+
longTextCounter: newLongTextCounter,
|
|
75
|
+
longTextMap: newLongTextMap,
|
|
76
|
+
historyIndex: -1,
|
|
77
|
+
};
|
|
78
|
+
// Sync selectors
|
|
79
|
+
const atPos = getAtSelectorPosition(newText, newCursorPosition);
|
|
80
|
+
if (atPos !== -1 && !newState.showFileSelector) {
|
|
81
|
+
newState.showFileSelector = true;
|
|
82
|
+
newState.atPosition = atPos;
|
|
83
|
+
newState.isFileSearching = true;
|
|
84
|
+
}
|
|
85
|
+
const slashPos = getSlashSelectorPosition(newText, newCursorPosition);
|
|
86
|
+
if (slashPos !== -1 && !newState.showCommandSelector) {
|
|
87
|
+
newState.showCommandSelector = true;
|
|
88
|
+
newState.slashPosition = slashPos;
|
|
89
|
+
}
|
|
90
|
+
if (newState.showFileSelector && newState.atPosition >= 0) {
|
|
91
|
+
newState.fileSearchQuery = newText.substring(newState.atPosition + 1, newCursorPosition);
|
|
92
|
+
}
|
|
93
|
+
else if (newState.showCommandSelector && newState.slashPosition >= 0) {
|
|
94
|
+
newState.commandSearchQuery = newText.substring(newState.slashPosition + 1, newCursorPosition);
|
|
95
|
+
}
|
|
96
|
+
return newState;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Submit the current input text: extract [Image #N] references, route /btw
|
|
100
|
+
* and CLI-internal slash commands, otherwise send as a message. Returns null
|
|
101
|
+
* when there is nothing to submit (empty text).
|
|
102
|
+
*/
|
|
103
|
+
function submitInput(state) {
|
|
104
|
+
if (!state.inputText.trim()) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
const imageRegex = /\[Image #(\d+)\]/g;
|
|
108
|
+
const matches = [...state.inputText.matchAll(imageRegex)];
|
|
109
|
+
const referencedImages = matches
|
|
110
|
+
.map((match) => {
|
|
111
|
+
const imageId = parseInt(match[1], 10);
|
|
112
|
+
return state.attachedImages.find((img) => img.id === imageId);
|
|
113
|
+
})
|
|
114
|
+
.filter((img) => img !== undefined)
|
|
115
|
+
.map((img) => ({ path: img.path, mimeType: img.mimeType }));
|
|
116
|
+
const contentWithPlaceholders = state.inputText
|
|
117
|
+
.replace(imageRegex, "")
|
|
118
|
+
.trim();
|
|
119
|
+
if (contentWithPlaceholders.startsWith("/btw ")) {
|
|
120
|
+
const question = contentWithPlaceholders.substring(5).trim();
|
|
121
|
+
if (!question) {
|
|
122
|
+
// "/btw " with no question text — show usage (aligned with Claude Code)
|
|
123
|
+
return {
|
|
124
|
+
...state,
|
|
125
|
+
inputText: "",
|
|
126
|
+
cursorPosition: 0,
|
|
127
|
+
historyIndex: -1,
|
|
128
|
+
longTextMap: {},
|
|
129
|
+
attachedImages: [],
|
|
130
|
+
btwState: {
|
|
131
|
+
question: "",
|
|
132
|
+
isLoading: false,
|
|
133
|
+
answer: "Usage: /btw <your question>",
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
...state,
|
|
139
|
+
inputText: "",
|
|
140
|
+
cursorPosition: 0,
|
|
141
|
+
historyIndex: -1,
|
|
142
|
+
longTextMap: {},
|
|
143
|
+
attachedImages: [],
|
|
144
|
+
btwState: {
|
|
145
|
+
question,
|
|
146
|
+
isLoading: true,
|
|
147
|
+
answer: undefined,
|
|
148
|
+
},
|
|
149
|
+
pendingEffect: { type: "ASK_BTW", question },
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
if (contentWithPlaceholders === "/btw") {
|
|
153
|
+
// Bare /btw — show usage (aligned with Claude Code)
|
|
154
|
+
return {
|
|
155
|
+
...state,
|
|
156
|
+
inputText: "",
|
|
157
|
+
cursorPosition: 0,
|
|
158
|
+
historyIndex: -1,
|
|
159
|
+
longTextMap: {},
|
|
160
|
+
attachedImages: [],
|
|
161
|
+
btwState: {
|
|
162
|
+
question: "",
|
|
163
|
+
isLoading: false,
|
|
164
|
+
answer: "Usage: /btw <your question>",
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
// Check if the content is a CLI-internal slash command (help, tasks,
|
|
169
|
+
// etc.) that should be executed locally rather than sent as a message.
|
|
170
|
+
// Agent slash commands and unknown /commands always go to SEND_MESSAGE.
|
|
171
|
+
if (contentWithPlaceholders.startsWith("/")) {
|
|
172
|
+
const spaceIndex = contentWithPlaceholders.indexOf(" ");
|
|
173
|
+
const commandName = spaceIndex === -1
|
|
174
|
+
? contentWithPlaceholders.substring(1)
|
|
175
|
+
: contentWithPlaceholders.substring(1, spaceIndex);
|
|
176
|
+
const isInternalCommand = AVAILABLE_COMMANDS.some((cmd) => cmd.id === commandName);
|
|
177
|
+
if (isInternalCommand) {
|
|
178
|
+
const argsText = spaceIndex === -1
|
|
179
|
+
? undefined
|
|
180
|
+
: contentWithPlaceholders.substring(spaceIndex + 1).trim() ||
|
|
181
|
+
undefined;
|
|
182
|
+
return {
|
|
183
|
+
...state,
|
|
184
|
+
inputText: "",
|
|
185
|
+
cursorPosition: 0,
|
|
186
|
+
historyIndex: -1,
|
|
187
|
+
longTextMap: {},
|
|
188
|
+
attachedImages: [],
|
|
189
|
+
pendingEffect: {
|
|
190
|
+
type: "EXECUTE_COMMAND",
|
|
191
|
+
command: commandName,
|
|
192
|
+
args: argsText,
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
...state,
|
|
199
|
+
inputText: "",
|
|
200
|
+
cursorPosition: 0,
|
|
201
|
+
historyIndex: -1,
|
|
202
|
+
longTextMap: {},
|
|
203
|
+
attachedImages: [],
|
|
204
|
+
pendingEffect: {
|
|
205
|
+
type: "SEND_MESSAGE",
|
|
206
|
+
content: contentWithPlaceholders,
|
|
207
|
+
images: referencedImages.length > 0 ? referencedImages : undefined,
|
|
208
|
+
longTextMap: state.longTextMap,
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
44
212
|
export function inputReducer(state, action) {
|
|
45
213
|
switch (action.type) {
|
|
46
214
|
case "SET_INPUT_TEXT":
|
|
@@ -225,48 +393,8 @@ export function inputReducer(state, action) {
|
|
|
225
393
|
return { ...state, permissionMode: action.payload };
|
|
226
394
|
case "SET_SELECTOR_JUST_USED":
|
|
227
395
|
return { ...state, selectorJustUsed: action.payload };
|
|
228
|
-
case "INSERT_TEXT_WITH_PLACEHOLDER":
|
|
229
|
-
|
|
230
|
-
let newLongTextCounter = state.longTextCounter;
|
|
231
|
-
const newLongTextMap = { ...state.longTextMap };
|
|
232
|
-
if (textToInsert.length > 200) {
|
|
233
|
-
newLongTextCounter += 1;
|
|
234
|
-
const placeholderLabel = `[LongText#${newLongTextCounter}]`;
|
|
235
|
-
newLongTextMap[placeholderLabel] = textToInsert;
|
|
236
|
-
textToInsert = placeholderLabel;
|
|
237
|
-
}
|
|
238
|
-
const beforeCursor = state.inputText.substring(0, state.cursorPosition);
|
|
239
|
-
const afterCursor = state.inputText.substring(state.cursorPosition);
|
|
240
|
-
const newText = beforeCursor + textToInsert + afterCursor;
|
|
241
|
-
const newCursorPosition = state.cursorPosition + textToInsert.length;
|
|
242
|
-
const newState = {
|
|
243
|
-
...state,
|
|
244
|
-
inputText: newText,
|
|
245
|
-
cursorPosition: newCursorPosition,
|
|
246
|
-
longTextCounter: newLongTextCounter,
|
|
247
|
-
longTextMap: newLongTextMap,
|
|
248
|
-
historyIndex: -1,
|
|
249
|
-
};
|
|
250
|
-
// Sync selectors
|
|
251
|
-
const atPos = getAtSelectorPosition(newText, newCursorPosition);
|
|
252
|
-
if (atPos !== -1 && !newState.showFileSelector) {
|
|
253
|
-
newState.showFileSelector = true;
|
|
254
|
-
newState.atPosition = atPos;
|
|
255
|
-
newState.isFileSearching = true;
|
|
256
|
-
}
|
|
257
|
-
const slashPos = getSlashSelectorPosition(newText, newCursorPosition);
|
|
258
|
-
if (slashPos !== -1 && !newState.showCommandSelector) {
|
|
259
|
-
newState.showCommandSelector = true;
|
|
260
|
-
newState.slashPosition = slashPos;
|
|
261
|
-
}
|
|
262
|
-
if (newState.showFileSelector && newState.atPosition >= 0) {
|
|
263
|
-
newState.fileSearchQuery = newText.substring(newState.atPosition + 1, newCursorPosition);
|
|
264
|
-
}
|
|
265
|
-
else if (newState.showCommandSelector && newState.slashPosition >= 0) {
|
|
266
|
-
newState.commandSearchQuery = newText.substring(newState.slashPosition + 1, newCursorPosition);
|
|
267
|
-
}
|
|
268
|
-
return newState;
|
|
269
|
-
}
|
|
396
|
+
case "INSERT_TEXT_WITH_PLACEHOLDER":
|
|
397
|
+
return insertTextWithPlaceholder(action.payload, state);
|
|
270
398
|
case "CLEAR_LONG_TEXT_MAP":
|
|
271
399
|
return { ...state, longTextMap: {} };
|
|
272
400
|
case "CLEAR_INPUT":
|
|
@@ -276,39 +404,6 @@ export function inputReducer(state, action) {
|
|
|
276
404
|
cursorPosition: 0,
|
|
277
405
|
historyIndex: -1,
|
|
278
406
|
};
|
|
279
|
-
case "APPEND_PASTE_CHUNK": {
|
|
280
|
-
// The reducer determines if this is a new paste or a continuation
|
|
281
|
-
// by checking if pasteBuffer is already set. This avoids the
|
|
282
|
-
// handler needing to track isPasting state, which can be stale
|
|
283
|
-
// when multiple dispatches fire before React state updates.
|
|
284
|
-
const isNewPaste = !state.pasteBuffer;
|
|
285
|
-
return {
|
|
286
|
-
...state,
|
|
287
|
-
isPasting: true,
|
|
288
|
-
pasteBuffer: state.pasteBuffer + action.payload.chunk,
|
|
289
|
-
initialPasteCursorPosition: isNewPaste
|
|
290
|
-
? action.payload.cursorPosition
|
|
291
|
-
: state.initialPasteCursorPosition,
|
|
292
|
-
};
|
|
293
|
-
}
|
|
294
|
-
case "START_PASTE":
|
|
295
|
-
return {
|
|
296
|
-
...state,
|
|
297
|
-
isPasting: true,
|
|
298
|
-
pasteBuffer: action.payload.buffer,
|
|
299
|
-
initialPasteCursorPosition: action.payload.cursorPosition,
|
|
300
|
-
};
|
|
301
|
-
case "APPEND_PASTE_BUFFER":
|
|
302
|
-
return {
|
|
303
|
-
...state,
|
|
304
|
-
pasteBuffer: state.pasteBuffer + action.payload,
|
|
305
|
-
};
|
|
306
|
-
case "END_PASTE":
|
|
307
|
-
return {
|
|
308
|
-
...state,
|
|
309
|
-
isPasting: false,
|
|
310
|
-
pasteBuffer: "",
|
|
311
|
-
};
|
|
312
407
|
case "ADD_IMAGE_AND_INSERT_PLACEHOLDER": {
|
|
313
408
|
const newImage = {
|
|
314
409
|
id: state.imageIdCounter,
|
|
@@ -462,13 +557,89 @@ export function inputReducer(state, action) {
|
|
|
462
557
|
};
|
|
463
558
|
case "CLEAR_PENDING_EFFECT":
|
|
464
559
|
return { ...state, pendingEffect: null };
|
|
560
|
+
case "RESET_ESC_CLEAR_PENDING":
|
|
561
|
+
return { ...state, escClearPending: false };
|
|
465
562
|
case "HANDLE_KEY": {
|
|
466
563
|
const { input, key } = action.payload;
|
|
467
564
|
const hasQueuedMessages = action.payload.hasQueuedMessages ?? false;
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
565
|
+
const isIdle = action.payload.isIdle ?? false;
|
|
566
|
+
// 0. Raw DEL (\x7f) filtering.
|
|
567
|
+
// SSH/tmux auto-repeat backspace coalesces multiple DEL bytes into one
|
|
568
|
+
// chunk (e.g. "\x7f\x7f") that ink cannot parse into a key event, so it
|
|
569
|
+
// arrives as raw input and would otherwise be treated as a paste and
|
|
570
|
+
// inserted literally. Treat each DEL as a synchronous backspace instead
|
|
571
|
+
// (aligned with Claude Code Issue #1853).
|
|
572
|
+
if (!key.backspace && !key.delete && input.includes("\x7f")) {
|
|
573
|
+
const delCount = (input.match(/\x7f/g) || []).length;
|
|
574
|
+
if (state.showHistorySearch) {
|
|
575
|
+
return {
|
|
576
|
+
...state,
|
|
577
|
+
historySearchQuery: state.historySearchQuery.slice(0, -delCount),
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
if (state.cursorPosition > 0) {
|
|
581
|
+
const newCursorPosition = Math.max(0, state.cursorPosition - delCount);
|
|
582
|
+
const newInputText = state.inputText.substring(0, newCursorPosition) +
|
|
583
|
+
state.inputText.substring(state.cursorPosition);
|
|
584
|
+
const newState = {
|
|
585
|
+
...state,
|
|
586
|
+
inputText: newInputText,
|
|
587
|
+
cursorPosition: newCursorPosition,
|
|
588
|
+
historyIndex: -1,
|
|
589
|
+
};
|
|
590
|
+
// Deactivate selectors if their trigger character was deleted
|
|
591
|
+
if (newState.showFileSelector &&
|
|
592
|
+
newCursorPosition <= newState.atPosition) {
|
|
593
|
+
newState.showFileSelector = false;
|
|
594
|
+
newState.atPosition = -1;
|
|
595
|
+
newState.fileSearchQuery = "";
|
|
596
|
+
newState.isFileSearching = false;
|
|
597
|
+
}
|
|
598
|
+
if (newState.showCommandSelector &&
|
|
599
|
+
newCursorPosition <= newState.slashPosition) {
|
|
600
|
+
newState.showCommandSelector = false;
|
|
601
|
+
newState.slashPosition = -1;
|
|
602
|
+
newState.commandSearchQuery = "";
|
|
603
|
+
}
|
|
604
|
+
// Reactivate selectors if cursor is within a trigger word
|
|
605
|
+
const atPos = getAtSelectorPosition(newInputText, newCursorPosition);
|
|
606
|
+
if (atPos !== -1 && !state.showFileSelector) {
|
|
607
|
+
newState.showFileSelector = true;
|
|
608
|
+
newState.atPosition = atPos;
|
|
609
|
+
newState.isFileSearching = true;
|
|
610
|
+
}
|
|
611
|
+
const slashPos = getSlashSelectorPosition(newInputText, newCursorPosition);
|
|
612
|
+
if (slashPos !== -1 && !state.showCommandSelector) {
|
|
613
|
+
newState.showCommandSelector = true;
|
|
614
|
+
newState.slashPosition = slashPos;
|
|
615
|
+
}
|
|
616
|
+
// Update queries
|
|
617
|
+
if (newState.showFileSelector && newState.atPosition >= 0) {
|
|
618
|
+
newState.fileSearchQuery = newInputText.substring(newState.atPosition + 1, newCursorPosition);
|
|
619
|
+
}
|
|
620
|
+
if (newState.showCommandSelector && newState.slashPosition >= 0) {
|
|
621
|
+
newState.commandSearchQuery = newInputText.substring(newState.slashPosition + 1, newCursorPosition);
|
|
622
|
+
}
|
|
623
|
+
return newState;
|
|
624
|
+
}
|
|
625
|
+
return state;
|
|
626
|
+
}
|
|
627
|
+
// 1. /btw overlay handling (active while a question is displayed, or
|
|
628
|
+
// the bare-/btw usage message). Only Escape dismisses (or aborts the
|
|
629
|
+
// in-flight side question while loading); every other key is ignored.
|
|
630
|
+
if (state.btwState.question || state.btwState.answer) {
|
|
631
|
+
if (key.escape) {
|
|
632
|
+
if (state.btwState.isLoading) {
|
|
633
|
+
return {
|
|
634
|
+
...state,
|
|
635
|
+
btwState: {
|
|
636
|
+
question: "",
|
|
637
|
+
answer: undefined,
|
|
638
|
+
isLoading: false,
|
|
639
|
+
},
|
|
640
|
+
pendingEffect: { type: "ABORT_BTW" },
|
|
641
|
+
};
|
|
642
|
+
}
|
|
472
643
|
return {
|
|
473
644
|
...state,
|
|
474
645
|
btwState: {
|
|
@@ -478,6 +649,11 @@ export function inputReducer(state, action) {
|
|
|
478
649
|
},
|
|
479
650
|
};
|
|
480
651
|
}
|
|
652
|
+
// Any other key while the overlay is up is ignored
|
|
653
|
+
return state;
|
|
654
|
+
}
|
|
655
|
+
// 1. Escape Handling
|
|
656
|
+
if (key.escape) {
|
|
481
657
|
if (state.showFileSelector) {
|
|
482
658
|
return {
|
|
483
659
|
...state,
|
|
@@ -526,7 +702,39 @@ export function inputReducer(state, action) {
|
|
|
526
702
|
state.showPluginManager ||
|
|
527
703
|
state.showModelSelector ||
|
|
528
704
|
state.showWorkflowManager)) {
|
|
529
|
-
|
|
705
|
+
// While AI is running (or any busy state) Esc keeps the abort
|
|
706
|
+
// semantics. Only when idle does Esc fall through to the text-level
|
|
707
|
+
// double-press clear (aligned with Claude Code's mutual-exclusion
|
|
708
|
+
// design: Esc aborts only when a task is running).
|
|
709
|
+
if (!isIdle) {
|
|
710
|
+
return {
|
|
711
|
+
...state,
|
|
712
|
+
escClearPending: false,
|
|
713
|
+
pendingEffect: { type: "ABORT_MESSAGE" },
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
// Idle: double-press Esc clears the input and saves to history.
|
|
717
|
+
if (state.inputText) {
|
|
718
|
+
if (state.escClearPending) {
|
|
719
|
+
const originalText = state.inputText;
|
|
720
|
+
const originalLongTextMap = state.longTextMap;
|
|
721
|
+
return {
|
|
722
|
+
...state,
|
|
723
|
+
inputText: "",
|
|
724
|
+
cursorPosition: 0,
|
|
725
|
+
historyIndex: -1,
|
|
726
|
+
longTextMap: {},
|
|
727
|
+
escClearPending: false,
|
|
728
|
+
pendingEffect: {
|
|
729
|
+
type: "SAVE_PROMPT_HISTORY",
|
|
730
|
+
content: originalText,
|
|
731
|
+
longTextMap: originalLongTextMap,
|
|
732
|
+
},
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
return { ...state, escClearPending: true };
|
|
736
|
+
}
|
|
737
|
+
return state;
|
|
530
738
|
}
|
|
531
739
|
return state;
|
|
532
740
|
}
|
|
@@ -563,6 +771,55 @@ export function inputReducer(state, action) {
|
|
|
563
771
|
pendingEffect: { type: "BACKGROUND_CURRENT_TASK" },
|
|
564
772
|
};
|
|
565
773
|
}
|
|
774
|
+
// Emacs-style line editing (aligned with Claude Code): Ctrl+A/E move the
|
|
775
|
+
// cursor to line start/end, Ctrl+U/K delete to line start/end, Ctrl+W
|
|
776
|
+
// deletes the word before the cursor. Skipped while a selector is open.
|
|
777
|
+
if (key.ctrl &&
|
|
778
|
+
input &&
|
|
779
|
+
!state.showFileSelector &&
|
|
780
|
+
!state.showCommandSelector &&
|
|
781
|
+
!state.showHistorySearch) {
|
|
782
|
+
const editKey = input.toLowerCase();
|
|
783
|
+
if (editKey === "a") {
|
|
784
|
+
return { ...state, cursorPosition: 0 };
|
|
785
|
+
}
|
|
786
|
+
if (editKey === "e") {
|
|
787
|
+
return { ...state, cursorPosition: state.inputText.length };
|
|
788
|
+
}
|
|
789
|
+
if (editKey === "u") {
|
|
790
|
+
return {
|
|
791
|
+
...state,
|
|
792
|
+
inputText: state.inputText.substring(state.cursorPosition),
|
|
793
|
+
cursorPosition: 0,
|
|
794
|
+
historyIndex: -1,
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
if (editKey === "k") {
|
|
798
|
+
return {
|
|
799
|
+
...state,
|
|
800
|
+
inputText: state.inputText.substring(0, state.cursorPosition),
|
|
801
|
+
historyIndex: -1,
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
if (editKey === "w") {
|
|
805
|
+
// Find the start of the word before the cursor (skip trailing
|
|
806
|
+
// whitespace, then the word itself).
|
|
807
|
+
let start = state.cursorPosition - 1;
|
|
808
|
+
while (start >= 0 && /\s/.test(state.inputText[start])) {
|
|
809
|
+
start--;
|
|
810
|
+
}
|
|
811
|
+
while (start >= 0 && !/\s/.test(state.inputText[start])) {
|
|
812
|
+
start--;
|
|
813
|
+
}
|
|
814
|
+
return {
|
|
815
|
+
...state,
|
|
816
|
+
inputText: state.inputText.substring(0, start + 1) +
|
|
817
|
+
state.inputText.substring(state.cursorPosition),
|
|
818
|
+
cursorPosition: start + 1,
|
|
819
|
+
historyIndex: -1,
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
}
|
|
566
823
|
// 4. History Navigation
|
|
567
824
|
if (key.upArrow &&
|
|
568
825
|
!state.showFileSelector &&
|
|
@@ -713,89 +970,7 @@ export function inputReducer(state, action) {
|
|
|
713
970
|
}
|
|
714
971
|
// 6. Return / Submit
|
|
715
972
|
if (key.return) {
|
|
716
|
-
|
|
717
|
-
const imageRegex = /\[Image #(\d+)\]/g;
|
|
718
|
-
const matches = [...state.inputText.matchAll(imageRegex)];
|
|
719
|
-
const referencedImages = matches
|
|
720
|
-
.map((match) => {
|
|
721
|
-
const imageId = parseInt(match[1], 10);
|
|
722
|
-
return state.attachedImages.find((img) => img.id === imageId);
|
|
723
|
-
})
|
|
724
|
-
.filter((img) => img !== undefined)
|
|
725
|
-
.map((img) => ({ path: img.path, mimeType: img.mimeType }));
|
|
726
|
-
const contentWithPlaceholders = state.inputText
|
|
727
|
-
.replace(imageRegex, "")
|
|
728
|
-
.trim();
|
|
729
|
-
if (contentWithPlaceholders.startsWith("/btw ")) {
|
|
730
|
-
const question = contentWithPlaceholders.substring(5).trim();
|
|
731
|
-
if (!question) {
|
|
732
|
-
// Bare /btw with no question text — ignore
|
|
733
|
-
return state;
|
|
734
|
-
}
|
|
735
|
-
return {
|
|
736
|
-
...state,
|
|
737
|
-
inputText: "",
|
|
738
|
-
cursorPosition: 0,
|
|
739
|
-
historyIndex: -1,
|
|
740
|
-
longTextMap: {},
|
|
741
|
-
attachedImages: [],
|
|
742
|
-
btwState: {
|
|
743
|
-
question,
|
|
744
|
-
isLoading: true,
|
|
745
|
-
answer: undefined,
|
|
746
|
-
},
|
|
747
|
-
pendingEffect: { type: "ASK_BTW", question },
|
|
748
|
-
};
|
|
749
|
-
}
|
|
750
|
-
if (contentWithPlaceholders === "/btw") {
|
|
751
|
-
// Bare /btw — ignore
|
|
752
|
-
return state;
|
|
753
|
-
}
|
|
754
|
-
// Check if the content is a CLI-internal slash command (help, tasks,
|
|
755
|
-
// etc.) that should be executed locally rather than sent as a message.
|
|
756
|
-
// Agent slash commands and unknown /commands always go to SEND_MESSAGE.
|
|
757
|
-
if (contentWithPlaceholders.startsWith("/")) {
|
|
758
|
-
const spaceIndex = contentWithPlaceholders.indexOf(" ");
|
|
759
|
-
const commandName = spaceIndex === -1
|
|
760
|
-
? contentWithPlaceholders.substring(1)
|
|
761
|
-
: contentWithPlaceholders.substring(1, spaceIndex);
|
|
762
|
-
const isInternalCommand = AVAILABLE_COMMANDS.some((cmd) => cmd.id === commandName);
|
|
763
|
-
if (isInternalCommand) {
|
|
764
|
-
const argsText = spaceIndex === -1
|
|
765
|
-
? undefined
|
|
766
|
-
: contentWithPlaceholders.substring(spaceIndex + 1).trim() ||
|
|
767
|
-
undefined;
|
|
768
|
-
return {
|
|
769
|
-
...state,
|
|
770
|
-
inputText: "",
|
|
771
|
-
cursorPosition: 0,
|
|
772
|
-
historyIndex: -1,
|
|
773
|
-
longTextMap: {},
|
|
774
|
-
attachedImages: [],
|
|
775
|
-
pendingEffect: {
|
|
776
|
-
type: "EXECUTE_COMMAND",
|
|
777
|
-
command: commandName,
|
|
778
|
-
args: argsText,
|
|
779
|
-
},
|
|
780
|
-
};
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
return {
|
|
784
|
-
...state,
|
|
785
|
-
inputText: "",
|
|
786
|
-
cursorPosition: 0,
|
|
787
|
-
historyIndex: -1,
|
|
788
|
-
longTextMap: {},
|
|
789
|
-
attachedImages: [],
|
|
790
|
-
pendingEffect: {
|
|
791
|
-
type: "SEND_MESSAGE",
|
|
792
|
-
content: contentWithPlaceholders,
|
|
793
|
-
images: referencedImages.length > 0 ? referencedImages : undefined,
|
|
794
|
-
longTextMap: state.longTextMap,
|
|
795
|
-
},
|
|
796
|
-
};
|
|
797
|
-
}
|
|
798
|
-
return state;
|
|
973
|
+
return submitInput(state) ?? state;
|
|
799
974
|
}
|
|
800
975
|
// 7. Regular Input
|
|
801
976
|
if (input &&
|
|
@@ -808,17 +983,26 @@ export function inputReducer(state, action) {
|
|
|
808
983
|
!key.rightArrow &&
|
|
809
984
|
!("home" in key && key.home) &&
|
|
810
985
|
!("end" in key && key.end)) {
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
986
|
+
// SSH-coalesced Enter: on slow links, "text" + Enter arrive as one
|
|
987
|
+
// chunk ("o\r"). ink's parseKeypress only matches a lone \r, so
|
|
988
|
+
// key.return is false here. Text with exactly one trailing \r is a
|
|
989
|
+
// coalesced Enter — strip the \r, insert, and submit immediately
|
|
990
|
+
// (aligned with Claude Code's useTextInput).
|
|
991
|
+
const isCoalescedEnter = input.length > 1 &&
|
|
992
|
+
input.endsWith("\r") &&
|
|
993
|
+
!input.slice(0, -1).includes("\r") &&
|
|
994
|
+
// Backslash+CR is a stale VS Code Shift+Enter binding, not a
|
|
995
|
+
// coalesced Enter — keep it as regular input.
|
|
996
|
+
input[input.length - 2] !== "\\";
|
|
997
|
+
if (isCoalescedEnter) {
|
|
998
|
+
const insertedState = insertTextWithPlaceholder(input.slice(0, -1), state);
|
|
999
|
+
return submitInput(insertedState) ?? insertedState;
|
|
1000
|
+
}
|
|
1001
|
+
if (input.length > 1) {
|
|
1002
|
+
// Multi-char chunk (typed burst, terminal paste, tmux send-keys):
|
|
1003
|
+
// insert immediately — no debounce or paste buffer. \r → \n
|
|
1004
|
+
// normalizes carriage returns from CRLF terminals.
|
|
1005
|
+
return insertTextWithPlaceholder(input.replace(/\r/g, "\n"), state);
|
|
822
1006
|
}
|
|
823
1007
|
else {
|
|
824
1008
|
let char = input;
|