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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getAtSelectorPosition, getSlashSelectorPosition, getWordEnd, SELECTOR_TRIGGERS, getProjectedState, } from "../utils/inputUtils.js";
|
|
2
2
|
import { AVAILABLE_COMMANDS } from "../constants/commands.js";
|
|
3
|
+
export const ESC_DOUBLE_PRESS_TIMEOUT_MS = 1000;
|
|
3
4
|
export const initialState = {
|
|
4
5
|
inputText: "",
|
|
5
6
|
cursorPosition: 0,
|
|
@@ -27,9 +28,6 @@ export const initialState = {
|
|
|
27
28
|
showWorkflowManager: false,
|
|
28
29
|
permissionMode: "default",
|
|
29
30
|
selectorJustUsed: false,
|
|
30
|
-
isPasting: false,
|
|
31
|
-
pasteBuffer: "",
|
|
32
|
-
initialPasteCursorPosition: 0,
|
|
33
31
|
history: [],
|
|
34
32
|
historyIndex: -1,
|
|
35
33
|
originalInputText: "",
|
|
@@ -40,7 +38,146 @@ export const initialState = {
|
|
|
40
38
|
isLoading: false,
|
|
41
39
|
},
|
|
42
40
|
pendingEffect: null,
|
|
41
|
+
escClearPending: false,
|
|
43
42
|
};
|
|
43
|
+
/**
|
|
44
|
+
* Insert text at the cursor position, folding text longer than 200 chars
|
|
45
|
+
* into a [LongText#N] placeholder. Shared by the INSERT_TEXT_WITH_PLACEHOLDER
|
|
46
|
+
* action and multi-char chunk inserts (typed bursts, terminal paste, tmux
|
|
47
|
+
* send-keys).
|
|
48
|
+
*/
|
|
49
|
+
function insertTextWithPlaceholder(textToInsert, state) {
|
|
50
|
+
let text = textToInsert;
|
|
51
|
+
let newLongTextCounter = state.longTextCounter;
|
|
52
|
+
const newLongTextMap = { ...state.longTextMap };
|
|
53
|
+
if (text.length > 200) {
|
|
54
|
+
newLongTextCounter += 1;
|
|
55
|
+
const placeholderLabel = `[LongText#${newLongTextCounter}]`;
|
|
56
|
+
newLongTextMap[placeholderLabel] = text;
|
|
57
|
+
text = placeholderLabel;
|
|
58
|
+
}
|
|
59
|
+
const beforeCursor = state.inputText.substring(0, state.cursorPosition);
|
|
60
|
+
const afterCursor = state.inputText.substring(state.cursorPosition);
|
|
61
|
+
const newText = beforeCursor + text + afterCursor;
|
|
62
|
+
const newCursorPosition = state.cursorPosition + text.length;
|
|
63
|
+
const newState = {
|
|
64
|
+
...state,
|
|
65
|
+
inputText: newText,
|
|
66
|
+
cursorPosition: newCursorPosition,
|
|
67
|
+
longTextCounter: newLongTextCounter,
|
|
68
|
+
longTextMap: newLongTextMap,
|
|
69
|
+
historyIndex: -1,
|
|
70
|
+
};
|
|
71
|
+
// Sync selectors
|
|
72
|
+
const atPos = getAtSelectorPosition(newText, newCursorPosition);
|
|
73
|
+
if (atPos !== -1 && !newState.showFileSelector) {
|
|
74
|
+
newState.showFileSelector = true;
|
|
75
|
+
newState.atPosition = atPos;
|
|
76
|
+
newState.isFileSearching = true;
|
|
77
|
+
}
|
|
78
|
+
const slashPos = getSlashSelectorPosition(newText, newCursorPosition);
|
|
79
|
+
if (slashPos !== -1 && !newState.showCommandSelector) {
|
|
80
|
+
newState.showCommandSelector = true;
|
|
81
|
+
newState.slashPosition = slashPos;
|
|
82
|
+
}
|
|
83
|
+
if (newState.showFileSelector && newState.atPosition >= 0) {
|
|
84
|
+
newState.fileSearchQuery = newText.substring(newState.atPosition + 1, newCursorPosition);
|
|
85
|
+
}
|
|
86
|
+
else if (newState.showCommandSelector && newState.slashPosition >= 0) {
|
|
87
|
+
newState.commandSearchQuery = newText.substring(newState.slashPosition + 1, newCursorPosition);
|
|
88
|
+
}
|
|
89
|
+
return newState;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Submit the current input text: extract [Image #N] references, route /btw
|
|
93
|
+
* and CLI-internal slash commands, otherwise send as a message. Returns null
|
|
94
|
+
* when there is nothing to submit (empty text, bare /btw).
|
|
95
|
+
*/
|
|
96
|
+
function submitInput(state) {
|
|
97
|
+
if (!state.inputText.trim()) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const imageRegex = /\[Image #(\d+)\]/g;
|
|
101
|
+
const matches = [...state.inputText.matchAll(imageRegex)];
|
|
102
|
+
const referencedImages = matches
|
|
103
|
+
.map((match) => {
|
|
104
|
+
const imageId = parseInt(match[1], 10);
|
|
105
|
+
return state.attachedImages.find((img) => img.id === imageId);
|
|
106
|
+
})
|
|
107
|
+
.filter((img) => img !== undefined)
|
|
108
|
+
.map((img) => ({ path: img.path, mimeType: img.mimeType }));
|
|
109
|
+
const contentWithPlaceholders = state.inputText
|
|
110
|
+
.replace(imageRegex, "")
|
|
111
|
+
.trim();
|
|
112
|
+
if (contentWithPlaceholders.startsWith("/btw ")) {
|
|
113
|
+
const question = contentWithPlaceholders.substring(5).trim();
|
|
114
|
+
if (!question) {
|
|
115
|
+
// Bare /btw with no question text — ignore
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
...state,
|
|
120
|
+
inputText: "",
|
|
121
|
+
cursorPosition: 0,
|
|
122
|
+
historyIndex: -1,
|
|
123
|
+
longTextMap: {},
|
|
124
|
+
attachedImages: [],
|
|
125
|
+
btwState: {
|
|
126
|
+
question,
|
|
127
|
+
isLoading: true,
|
|
128
|
+
answer: undefined,
|
|
129
|
+
},
|
|
130
|
+
pendingEffect: { type: "ASK_BTW", question },
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (contentWithPlaceholders === "/btw") {
|
|
134
|
+
// Bare /btw — ignore
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
// Check if the content is a CLI-internal slash command (help, tasks,
|
|
138
|
+
// etc.) that should be executed locally rather than sent as a message.
|
|
139
|
+
// Agent slash commands and unknown /commands always go to SEND_MESSAGE.
|
|
140
|
+
if (contentWithPlaceholders.startsWith("/")) {
|
|
141
|
+
const spaceIndex = contentWithPlaceholders.indexOf(" ");
|
|
142
|
+
const commandName = spaceIndex === -1
|
|
143
|
+
? contentWithPlaceholders.substring(1)
|
|
144
|
+
: contentWithPlaceholders.substring(1, spaceIndex);
|
|
145
|
+
const isInternalCommand = AVAILABLE_COMMANDS.some((cmd) => cmd.id === commandName);
|
|
146
|
+
if (isInternalCommand) {
|
|
147
|
+
const argsText = spaceIndex === -1
|
|
148
|
+
? undefined
|
|
149
|
+
: contentWithPlaceholders.substring(spaceIndex + 1).trim() ||
|
|
150
|
+
undefined;
|
|
151
|
+
return {
|
|
152
|
+
...state,
|
|
153
|
+
inputText: "",
|
|
154
|
+
cursorPosition: 0,
|
|
155
|
+
historyIndex: -1,
|
|
156
|
+
longTextMap: {},
|
|
157
|
+
attachedImages: [],
|
|
158
|
+
pendingEffect: {
|
|
159
|
+
type: "EXECUTE_COMMAND",
|
|
160
|
+
command: commandName,
|
|
161
|
+
args: argsText,
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
...state,
|
|
168
|
+
inputText: "",
|
|
169
|
+
cursorPosition: 0,
|
|
170
|
+
historyIndex: -1,
|
|
171
|
+
longTextMap: {},
|
|
172
|
+
attachedImages: [],
|
|
173
|
+
pendingEffect: {
|
|
174
|
+
type: "SEND_MESSAGE",
|
|
175
|
+
content: contentWithPlaceholders,
|
|
176
|
+
images: referencedImages.length > 0 ? referencedImages : undefined,
|
|
177
|
+
longTextMap: state.longTextMap,
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
44
181
|
export function inputReducer(state, action) {
|
|
45
182
|
switch (action.type) {
|
|
46
183
|
case "SET_INPUT_TEXT":
|
|
@@ -225,48 +362,8 @@ export function inputReducer(state, action) {
|
|
|
225
362
|
return { ...state, permissionMode: action.payload };
|
|
226
363
|
case "SET_SELECTOR_JUST_USED":
|
|
227
364
|
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
|
-
}
|
|
365
|
+
case "INSERT_TEXT_WITH_PLACEHOLDER":
|
|
366
|
+
return insertTextWithPlaceholder(action.payload, state);
|
|
270
367
|
case "CLEAR_LONG_TEXT_MAP":
|
|
271
368
|
return { ...state, longTextMap: {} };
|
|
272
369
|
case "CLEAR_INPUT":
|
|
@@ -276,39 +373,6 @@ export function inputReducer(state, action) {
|
|
|
276
373
|
cursorPosition: 0,
|
|
277
374
|
historyIndex: -1,
|
|
278
375
|
};
|
|
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
376
|
case "ADD_IMAGE_AND_INSERT_PLACEHOLDER": {
|
|
313
377
|
const newImage = {
|
|
314
378
|
id: state.imageIdCounter,
|
|
@@ -462,9 +526,73 @@ export function inputReducer(state, action) {
|
|
|
462
526
|
};
|
|
463
527
|
case "CLEAR_PENDING_EFFECT":
|
|
464
528
|
return { ...state, pendingEffect: null };
|
|
529
|
+
case "RESET_ESC_CLEAR_PENDING":
|
|
530
|
+
return { ...state, escClearPending: false };
|
|
465
531
|
case "HANDLE_KEY": {
|
|
466
532
|
const { input, key } = action.payload;
|
|
467
533
|
const hasQueuedMessages = action.payload.hasQueuedMessages ?? false;
|
|
534
|
+
const isIdle = action.payload.isIdle ?? false;
|
|
535
|
+
// 0. Raw DEL (\x7f) filtering.
|
|
536
|
+
// SSH/tmux auto-repeat backspace coalesces multiple DEL bytes into one
|
|
537
|
+
// chunk (e.g. "\x7f\x7f") that ink cannot parse into a key event, so it
|
|
538
|
+
// arrives as raw input and would otherwise be treated as a paste and
|
|
539
|
+
// inserted literally. Treat each DEL as a synchronous backspace instead
|
|
540
|
+
// (aligned with Claude Code Issue #1853).
|
|
541
|
+
if (!key.backspace && !key.delete && input.includes("\x7f")) {
|
|
542
|
+
const delCount = (input.match(/\x7f/g) || []).length;
|
|
543
|
+
if (state.showHistorySearch) {
|
|
544
|
+
return {
|
|
545
|
+
...state,
|
|
546
|
+
historySearchQuery: state.historySearchQuery.slice(0, -delCount),
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
if (state.cursorPosition > 0) {
|
|
550
|
+
const newCursorPosition = Math.max(0, state.cursorPosition - delCount);
|
|
551
|
+
const newInputText = state.inputText.substring(0, newCursorPosition) +
|
|
552
|
+
state.inputText.substring(state.cursorPosition);
|
|
553
|
+
const newState = {
|
|
554
|
+
...state,
|
|
555
|
+
inputText: newInputText,
|
|
556
|
+
cursorPosition: newCursorPosition,
|
|
557
|
+
historyIndex: -1,
|
|
558
|
+
};
|
|
559
|
+
// Deactivate selectors if their trigger character was deleted
|
|
560
|
+
if (newState.showFileSelector &&
|
|
561
|
+
newCursorPosition <= newState.atPosition) {
|
|
562
|
+
newState.showFileSelector = false;
|
|
563
|
+
newState.atPosition = -1;
|
|
564
|
+
newState.fileSearchQuery = "";
|
|
565
|
+
newState.isFileSearching = false;
|
|
566
|
+
}
|
|
567
|
+
if (newState.showCommandSelector &&
|
|
568
|
+
newCursorPosition <= newState.slashPosition) {
|
|
569
|
+
newState.showCommandSelector = false;
|
|
570
|
+
newState.slashPosition = -1;
|
|
571
|
+
newState.commandSearchQuery = "";
|
|
572
|
+
}
|
|
573
|
+
// Reactivate selectors if cursor is within a trigger word
|
|
574
|
+
const atPos = getAtSelectorPosition(newInputText, newCursorPosition);
|
|
575
|
+
if (atPos !== -1 && !state.showFileSelector) {
|
|
576
|
+
newState.showFileSelector = true;
|
|
577
|
+
newState.atPosition = atPos;
|
|
578
|
+
newState.isFileSearching = true;
|
|
579
|
+
}
|
|
580
|
+
const slashPos = getSlashSelectorPosition(newInputText, newCursorPosition);
|
|
581
|
+
if (slashPos !== -1 && !state.showCommandSelector) {
|
|
582
|
+
newState.showCommandSelector = true;
|
|
583
|
+
newState.slashPosition = slashPos;
|
|
584
|
+
}
|
|
585
|
+
// Update queries
|
|
586
|
+
if (newState.showFileSelector && newState.atPosition >= 0) {
|
|
587
|
+
newState.fileSearchQuery = newInputText.substring(newState.atPosition + 1, newCursorPosition);
|
|
588
|
+
}
|
|
589
|
+
if (newState.showCommandSelector && newState.slashPosition >= 0) {
|
|
590
|
+
newState.commandSearchQuery = newInputText.substring(newState.slashPosition + 1, newCursorPosition);
|
|
591
|
+
}
|
|
592
|
+
return newState;
|
|
593
|
+
}
|
|
594
|
+
return state;
|
|
595
|
+
}
|
|
468
596
|
// 1. Escape Handling
|
|
469
597
|
if (key.escape) {
|
|
470
598
|
// Dismiss btw answer
|
|
@@ -526,7 +654,39 @@ export function inputReducer(state, action) {
|
|
|
526
654
|
state.showPluginManager ||
|
|
527
655
|
state.showModelSelector ||
|
|
528
656
|
state.showWorkflowManager)) {
|
|
529
|
-
|
|
657
|
+
// While AI is running (or any busy state) Esc keeps the abort
|
|
658
|
+
// semantics. Only when idle does Esc fall through to the text-level
|
|
659
|
+
// double-press clear (aligned with Claude Code's mutual-exclusion
|
|
660
|
+
// design: Esc aborts only when a task is running).
|
|
661
|
+
if (!isIdle) {
|
|
662
|
+
return {
|
|
663
|
+
...state,
|
|
664
|
+
escClearPending: false,
|
|
665
|
+
pendingEffect: { type: "ABORT_MESSAGE" },
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
// Idle: double-press Esc clears the input and saves to history.
|
|
669
|
+
if (state.inputText) {
|
|
670
|
+
if (state.escClearPending) {
|
|
671
|
+
const originalText = state.inputText;
|
|
672
|
+
const originalLongTextMap = state.longTextMap;
|
|
673
|
+
return {
|
|
674
|
+
...state,
|
|
675
|
+
inputText: "",
|
|
676
|
+
cursorPosition: 0,
|
|
677
|
+
historyIndex: -1,
|
|
678
|
+
longTextMap: {},
|
|
679
|
+
escClearPending: false,
|
|
680
|
+
pendingEffect: {
|
|
681
|
+
type: "SAVE_PROMPT_HISTORY",
|
|
682
|
+
content: originalText,
|
|
683
|
+
longTextMap: originalLongTextMap,
|
|
684
|
+
},
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
return { ...state, escClearPending: true };
|
|
688
|
+
}
|
|
689
|
+
return state;
|
|
530
690
|
}
|
|
531
691
|
return state;
|
|
532
692
|
}
|
|
@@ -563,6 +723,55 @@ export function inputReducer(state, action) {
|
|
|
563
723
|
pendingEffect: { type: "BACKGROUND_CURRENT_TASK" },
|
|
564
724
|
};
|
|
565
725
|
}
|
|
726
|
+
// Emacs-style line editing (aligned with Claude Code): Ctrl+A/E move the
|
|
727
|
+
// cursor to line start/end, Ctrl+U/K delete to line start/end, Ctrl+W
|
|
728
|
+
// deletes the word before the cursor. Skipped while a selector is open.
|
|
729
|
+
if (key.ctrl &&
|
|
730
|
+
input &&
|
|
731
|
+
!state.showFileSelector &&
|
|
732
|
+
!state.showCommandSelector &&
|
|
733
|
+
!state.showHistorySearch) {
|
|
734
|
+
const editKey = input.toLowerCase();
|
|
735
|
+
if (editKey === "a") {
|
|
736
|
+
return { ...state, cursorPosition: 0 };
|
|
737
|
+
}
|
|
738
|
+
if (editKey === "e") {
|
|
739
|
+
return { ...state, cursorPosition: state.inputText.length };
|
|
740
|
+
}
|
|
741
|
+
if (editKey === "u") {
|
|
742
|
+
return {
|
|
743
|
+
...state,
|
|
744
|
+
inputText: state.inputText.substring(state.cursorPosition),
|
|
745
|
+
cursorPosition: 0,
|
|
746
|
+
historyIndex: -1,
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
if (editKey === "k") {
|
|
750
|
+
return {
|
|
751
|
+
...state,
|
|
752
|
+
inputText: state.inputText.substring(0, state.cursorPosition),
|
|
753
|
+
historyIndex: -1,
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
if (editKey === "w") {
|
|
757
|
+
// Find the start of the word before the cursor (skip trailing
|
|
758
|
+
// whitespace, then the word itself).
|
|
759
|
+
let start = state.cursorPosition - 1;
|
|
760
|
+
while (start >= 0 && /\s/.test(state.inputText[start])) {
|
|
761
|
+
start--;
|
|
762
|
+
}
|
|
763
|
+
while (start >= 0 && !/\s/.test(state.inputText[start])) {
|
|
764
|
+
start--;
|
|
765
|
+
}
|
|
766
|
+
return {
|
|
767
|
+
...state,
|
|
768
|
+
inputText: state.inputText.substring(0, start + 1) +
|
|
769
|
+
state.inputText.substring(state.cursorPosition),
|
|
770
|
+
cursorPosition: start + 1,
|
|
771
|
+
historyIndex: -1,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
}
|
|
566
775
|
// 4. History Navigation
|
|
567
776
|
if (key.upArrow &&
|
|
568
777
|
!state.showFileSelector &&
|
|
@@ -713,89 +922,7 @@ export function inputReducer(state, action) {
|
|
|
713
922
|
}
|
|
714
923
|
// 6. Return / Submit
|
|
715
924
|
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;
|
|
925
|
+
return submitInput(state) ?? state;
|
|
799
926
|
}
|
|
800
927
|
// 7. Regular Input
|
|
801
928
|
if (input &&
|
|
@@ -808,17 +935,26 @@ export function inputReducer(state, action) {
|
|
|
808
935
|
!key.rightArrow &&
|
|
809
936
|
!("home" in key && key.home) &&
|
|
810
937
|
!("end" in key && key.end)) {
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
938
|
+
// SSH-coalesced Enter: on slow links, "text" + Enter arrive as one
|
|
939
|
+
// chunk ("o\r"). ink's parseKeypress only matches a lone \r, so
|
|
940
|
+
// key.return is false here. Text with exactly one trailing \r is a
|
|
941
|
+
// coalesced Enter — strip the \r, insert, and submit immediately
|
|
942
|
+
// (aligned with Claude Code's useTextInput).
|
|
943
|
+
const isCoalescedEnter = input.length > 1 &&
|
|
944
|
+
input.endsWith("\r") &&
|
|
945
|
+
!input.slice(0, -1).includes("\r") &&
|
|
946
|
+
// Backslash+CR is a stale VS Code Shift+Enter binding, not a
|
|
947
|
+
// coalesced Enter — keep it as regular input.
|
|
948
|
+
input[input.length - 2] !== "\\";
|
|
949
|
+
if (isCoalescedEnter) {
|
|
950
|
+
const insertedState = insertTextWithPlaceholder(input.slice(0, -1), state);
|
|
951
|
+
return submitInput(insertedState) ?? insertedState;
|
|
952
|
+
}
|
|
953
|
+
if (input.length > 1) {
|
|
954
|
+
// Multi-char chunk (typed burst, terminal paste, tmux send-keys):
|
|
955
|
+
// insert immediately — no debounce or paste buffer. \r → \n
|
|
956
|
+
// normalizes carriage returns from CRLF terminals.
|
|
957
|
+
return insertTextWithPlaceholder(input.replace(/\r/g, "\n"), state);
|
|
822
958
|
}
|
|
823
959
|
else {
|
|
824
960
|
let char = input;
|
package/dist/print-cli.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Agent, hasUncommittedChanges, hasNewCommits, getDefaultRemoteBranch, } from "wave-agent-sdk";
|
|
1
|
+
import { Agent, hasUncommittedChanges, hasNewCommits, getDefaultRemoteBranch, validateWorktreeRemovalPath, } from "wave-agent-sdk";
|
|
2
2
|
import { displayUsageSummary } from "./utils/usageSummary.js";
|
|
3
3
|
import { removeWorktree } from "./utils/worktree.js";
|
|
4
4
|
function displayTimingInfo(startTime, showStats) {
|
|
@@ -129,21 +129,35 @@ export async function startPrintCli(options) {
|
|
|
129
129
|
}
|
|
130
130
|
// Display timing information
|
|
131
131
|
displayTimingInfo(startTime, showStats);
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
// Trigger WorktreeRemove hook (before destroy — it needs a live agent) and
|
|
133
|
+
// decide whether the worktree is clean enough to remove
|
|
134
|
+
let cleanWorktree = false;
|
|
135
135
|
if (worktreeSession) {
|
|
136
136
|
const cwd = workdir || worktreeSession.path;
|
|
137
137
|
const baseBranch = getDefaultRemoteBranch(cwd);
|
|
138
138
|
const hasChanges = hasUncommittedChanges(cwd);
|
|
139
139
|
const hasCommits = hasNewCommits(cwd, baseBranch);
|
|
140
|
-
|
|
141
|
-
|
|
140
|
+
cleanWorktree = !hasChanges && !hasCommits;
|
|
141
|
+
if (cleanWorktree) {
|
|
142
|
+
await agent.triggerWorktreeRemoveHook(worktreeSession.path);
|
|
142
143
|
}
|
|
143
144
|
else {
|
|
144
145
|
process.stdout.write(`\n⚠️ Worktree '${worktreeSession.name}' has changes or commits. Keeping it at: ${worktreeSession.path}\n`);
|
|
145
146
|
}
|
|
146
147
|
}
|
|
148
|
+
// Destroy agent and exit after sendMessage completes
|
|
149
|
+
await agent.destroy();
|
|
150
|
+
// Handle worktree cleanup for print mode (git removal stays after destroy)
|
|
151
|
+
if (worktreeSession && cleanWorktree) {
|
|
152
|
+
try {
|
|
153
|
+
validateWorktreeRemovalPath(worktreeSession.path, worktreeSession.repoRoot);
|
|
154
|
+
await removeWorktree(worktreeSession);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
// Never block print-mode exit on worktree cleanup failures
|
|
158
|
+
process.stdout.write(`\n⚠️ Skipping worktree removal: ${error.message}\n`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
147
161
|
process.exit(0);
|
|
148
162
|
}
|
|
149
163
|
catch (error) {
|
|
@@ -162,20 +176,32 @@ export async function startPrintCli(options) {
|
|
|
162
176
|
}
|
|
163
177
|
// Display timing information even on error
|
|
164
178
|
displayTimingInfo(startTime, showStats);
|
|
165
|
-
|
|
166
|
-
|
|
179
|
+
// Trigger WorktreeRemove hook (before destroy) when the worktree is clean
|
|
180
|
+
let cleanWorktree = false;
|
|
167
181
|
if (worktreeSession) {
|
|
168
182
|
const cwd = workdir || worktreeSession.path;
|
|
169
183
|
const baseBranch = getDefaultRemoteBranch(cwd);
|
|
170
184
|
const hasChanges = hasUncommittedChanges(cwd);
|
|
171
185
|
const hasCommits = hasNewCommits(cwd, baseBranch);
|
|
172
|
-
|
|
173
|
-
|
|
186
|
+
cleanWorktree = !hasChanges && !hasCommits;
|
|
187
|
+
if (cleanWorktree) {
|
|
188
|
+
await agent.triggerWorktreeRemoveHook(worktreeSession.path);
|
|
174
189
|
}
|
|
175
190
|
else {
|
|
176
191
|
process.stdout.write(`\n⚠️ Worktree '${worktreeSession.name}' has changes or commits. Keeping it at: ${worktreeSession.path}\n`);
|
|
177
192
|
}
|
|
178
193
|
}
|
|
194
|
+
await agent.destroy();
|
|
195
|
+
// Handle worktree cleanup for print mode even on error
|
|
196
|
+
if (worktreeSession && cleanWorktree) {
|
|
197
|
+
try {
|
|
198
|
+
validateWorktreeRemovalPath(worktreeSession.path, worktreeSession.repoRoot);
|
|
199
|
+
await removeWorktree(worktreeSession);
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
process.stdout.write(`\n⚠️ Skipping worktree removal: ${error.message}\n`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
179
205
|
}
|
|
180
206
|
process.exit(1);
|
|
181
207
|
}
|