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