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.
Files changed (83) hide show
  1. package/dist/cli.js +20 -1
  2. package/dist/components/App.js +7 -0
  3. package/dist/components/BtwDisplay.js +13 -3
  4. package/dist/components/ChatInterface.js +21 -6
  5. package/dist/components/HelpView.js +6 -0
  6. package/dist/components/InputBox.d.ts +1 -2
  7. package/dist/components/InputBox.js +16 -5
  8. package/dist/components/LoadingIndicator.d.ts +1 -2
  9. package/dist/components/LoadingIndicator.js +2 -2
  10. package/dist/components/Markdown.js +13 -16
  11. package/dist/components/MessageList.d.ts +2 -1
  12. package/dist/components/MessageList.js +2 -2
  13. package/dist/components/RewindCommand.js +4 -2
  14. package/dist/components/StatusLine.d.ts +0 -2
  15. package/dist/components/StatusLine.js +6 -6
  16. package/dist/components/TaskList.js +2 -1
  17. package/dist/components/ToolDisplay.d.ts +1 -0
  18. package/dist/components/ToolDisplay.js +17 -9
  19. package/dist/constants/commands.js +0 -6
  20. package/dist/contexts/useChat.d.ts +3 -5
  21. package/dist/contexts/useChat.js +242 -82
  22. package/dist/daemon-cli.d.ts +10 -0
  23. package/dist/daemon-cli.js +15 -0
  24. package/dist/hooks/useInputManager.d.ts +1 -0
  25. package/dist/hooks/useInputManager.js +120 -40
  26. package/dist/index.js +10 -0
  27. package/dist/managers/inputHandlers.js +55 -30
  28. package/dist/managers/inputReducer.d.ts +22 -22
  29. package/dist/managers/inputReducer.js +361 -177
  30. package/dist/print-cli.js +36 -10
  31. package/dist/stdio/agentBridge.d.ts +23 -0
  32. package/dist/stdio/agentBridge.js +151 -18
  33. package/dist/stdio/daemonServer.d.ts +67 -0
  34. package/dist/stdio/daemonServer.js +191 -0
  35. package/dist/stdio/index.d.ts +2 -0
  36. package/dist/stdio/index.js +2 -0
  37. package/dist/stdio/jsonRpcConnection.d.ts +30 -0
  38. package/dist/stdio/jsonRpcConnection.js +127 -0
  39. package/dist/stdio/protocol.d.ts +2 -2
  40. package/dist/stdio/stdioServer.d.ts +2 -7
  41. package/dist/stdio/stdioServer.js +9 -100
  42. package/dist/utils/bracketedPaste.d.ts +39 -0
  43. package/dist/utils/bracketedPaste.js +122 -0
  44. package/dist/utils/markdownTable.d.ts +34 -0
  45. package/dist/utils/markdownTable.js +302 -0
  46. package/dist/utils/rewindCheckpoints.d.ts +8 -0
  47. package/dist/utils/rewindCheckpoints.js +15 -0
  48. package/dist/utils/throttle.d.ts +3 -3
  49. package/dist/utils/worktree.d.ts +8 -0
  50. package/dist/utils/worktree.js +32 -1
  51. package/package.json +4 -2
  52. package/src/cli.tsx +20 -1
  53. package/src/components/App.tsx +5 -0
  54. package/src/components/BtwDisplay.tsx +36 -12
  55. package/src/components/ChatInterface.tsx +26 -11
  56. package/src/components/HelpView.tsx +6 -0
  57. package/src/components/InputBox.tsx +21 -10
  58. package/src/components/LoadingIndicator.tsx +1 -4
  59. package/src/components/Markdown.tsx +15 -18
  60. package/src/components/MessageList.tsx +6 -0
  61. package/src/components/RewindCommand.tsx +4 -2
  62. package/src/components/StatusLine.tsx +0 -10
  63. package/src/components/TaskList.tsx +2 -1
  64. package/src/components/ToolDisplay.tsx +17 -6
  65. package/src/constants/commands.ts +0 -6
  66. package/src/contexts/useChat.tsx +310 -95
  67. package/src/daemon-cli.ts +17 -0
  68. package/src/hooks/useInputManager.ts +135 -43
  69. package/src/index.ts +12 -0
  70. package/src/managers/inputHandlers.ts +55 -32
  71. package/src/managers/inputReducer.ts +442 -214
  72. package/src/print-cli.ts +48 -11
  73. package/src/stdio/agentBridge.ts +213 -18
  74. package/src/stdio/daemonServer.ts +212 -0
  75. package/src/stdio/index.ts +2 -0
  76. package/src/stdio/jsonRpcConnection.ts +160 -0
  77. package/src/stdio/protocol.ts +5 -2
  78. package/src/stdio/stdioServer.ts +14 -120
  79. package/src/utils/bracketedPaste.ts +170 -0
  80. package/src/utils/markdownTable.ts +359 -0
  81. package/src/utils/rewindCheckpoints.ts +15 -0
  82. package/src/utils/throttle.ts +8 -8
  83. 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?: (question: string) => Promise<string>;
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
- 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
- }
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
- // 1. Escape Handling
693
- if (key.escape) {
694
- // Dismiss btw answer
695
- if (state.btwState.question && !state.btwState.isLoading) {
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
- return { ...state, pendingEffect: { type: "ABORT_MESSAGE" } };
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
- 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;
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
- const isPasteOperation =
1094
- input.length > 1 || input.includes("\n") || input.includes("\r");
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 (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
- };
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) {