Skip to content

Commit 8435958

Browse files
authored
feat(terminal): improve input handling for interactive CLI tools (#2523)
## Summary This PR includes three terminal input improvements that enhance the experience when using interactive CLI tools and IME input: 1. **Shift+Enter newline support**: Enable Shift+Enter to insert newlines by default 2. **Image paste support**: Allow pasting images by saving them as temporary files and pasting the file path 3. **IME duplicate input fix**: Fix duplicate text when switching input methods during composition ## Motivation ### Shift+Enter for newlines Currently, pressing Shift+Enter in the terminal behaves the same as Enter, making it difficult to input multi-line commands or text in interactive CLI tools. This change enables Shift+Enter to insert newlines by default, matching common terminal emulator behavior. ### Image paste support Interactive AI tools like Claude Code support receiving images through file paths, but Wave Terminal currently doesn't support pasting images. This change implements image paste functionality similar to iTerm2's behavior: when an image is pasted, it's saved to a temporary file and the path is pasted into the terminal. ### IME duplicate input fix When using Chinese/Japanese/Korean IME in the terminal, switching input methods with Capslock during composition causes the composed text to be sent twice, resulting in duplicate output (e.g., "你好" becomes "你好你好"). This issue severely impacts users who frequently switch between languages. ## Changes ### Shift+Enter newline (`frontend/app/view/term/term-model.ts`) - Change default `shiftenternewline` config from `false` to `true` - Send standard newline character (`\n`) instead of escape sequence (`\^[\n`) ### Image paste (`frontend/app/view/term/term-model.ts`, `frontend/app/view/term/termwrap.ts`) - Add `handlePaste()` method to intercept Cmd+Shift+V paste events - Add `handleImagePasteBlob()` to save images to `/tmp` and paste the file path - Detect image data in clipboard using both `ClipboardEvent.clipboardData` and Clipboard API - Support both screenshot paste and file copy scenarios - Add 5MB size limit for pasted images - Temporary files are created with format: `waveterm_paste_[timestamp].[ext]` ### IME duplicate input fix (`frontend/app/view/term/termwrap.ts`, `frontend/app/view/term/term-model.ts`) **IME Composition Handling:** - Track composition state (isComposing, composingData, etc.) in TermWrap - Register compositionstart/update/end event listeners on xterm.js textarea - Block all data sends during composition, only allow after compositionend - Prevents xterm.js from sending intermediate data during compositionupdate phase **Deduplication Logic:** - Implement 50ms time window deduplication for both IME and paste operations - Track first send after composition, block duplicate sends from Capslock switching - Ensure Ctrl+Space and Fn switching work correctly (single send only) **Edge Case Handling:** - Add blur event handler to reset composition state on focus loss - Add Escape key handling to cancel composition in progress ## Testing ### Shift+Enter 1. Open a terminal in Wave 2. Press Shift+Enter 3. Verify that a newline is inserted instead of executing the command ### Image paste 1. Take a screenshot and copy it to clipboard (or copy an image file in Finder) 2. In a terminal running Claude Code, paste the image (Cmd+V or Cmd+Shift+V) 3. Verify that the image path appears and Claude Code recognizes it ### IME Input Testing **IME Input:** - [x] macOS Zhuyin IME + Capslock switching - no duplicate output ✅ - [x] macOS Zhuyin IME + Ctrl+Space switching - normal single input ✅ - [x] macOS Zhuyin IME + Fn switching - normal single input ✅ **Regression Testing:** - [x] English keyboard input - normal operation ✅ - [x] Shift+Enter multiline input - works correctly ✅ - [x] Text paste (Cmd+Shift+V) - no duplicates ✅ - [x] Image paste - works correctly ✅ - [x] Basic command execution (ls, echo, etc.) - normal ✅ - [x] Cmd+K clear terminal - works correctly ✅ - [x] Copy selected text (Cmd+Shift+C) - works correctly ✅ ## Demo https://github.com/user-attachments/assets/8341cdf9-6c57-413e-b940-89e50cc79ff0 https://github.com/user-attachments/assets/d3a6e72a-f488-45c1-ab58-88391639455a https://github.com/user-attachments/assets/ac178abd-caf3-40bf-9ef7-7cc0567a32c3 All features have been tested successfully on macOS with Claude Code in Wave Terminal.
1 parent a2f9825 commit 8435958

File tree

8 files changed

+396
-10
lines changed

8 files changed

+396
-10
lines changed

frontend/app/store/wshclientapi.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,11 @@ class RpcApiType {
327327
return client.wshRpcCall("gettab", data, opts);
328328
}
329329

330+
// command "gettempdir" [call]
331+
GetTempDirCommand(client: WshClient, data: CommandGetTempDirData, opts?: RpcOpts): Promise<string> {
332+
return client.wshRpcCall("gettempdir", data, opts);
333+
}
334+
330335
// command "getupdatechannel" [call]
331336
GetUpdateChannelCommand(client: WshClient, opts?: RpcOpts): Promise<string> {
332337
return client.wshRpcCall("getupdatechannel", null, opts);

frontend/app/view/term/term-model.ts

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,18 @@ import * as keyutil from "@/util/keyutil";
2929
import { boundNumber, stringToBase64 } from "@/util/util";
3030
import * as jotai from "jotai";
3131
import * as React from "react";
32-
import { computeTheme, DefaultTermTheme } from "./termutil";
32+
import {
33+
computeTheme,
34+
createTempFileFromBlob,
35+
DefaultTermTheme,
36+
handleImagePasteBlob as handleImagePasteBlobUtil,
37+
supportsImageInput as supportsImageInputUtil,
38+
} from "./termutil";
3339
import { TermWrap } from "./termwrap";
3440
import { getBlockingCommand } from "./shellblocking";
3541

3642
export class TermViewModel implements ViewModel {
43+
3744
viewType: string;
3845
nodeModel: BlockNodeModel;
3946
connected: boolean;
@@ -391,6 +398,51 @@ export class TermViewModel implements ViewModel {
391398
RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, inputdata64: b64data });
392399
}
393400

401+
async handlePaste() {
402+
try {
403+
const clipboardItems = await navigator.clipboard.read();
404+
405+
for (const item of clipboardItems) {
406+
// Check for images first
407+
const imageTypes = item.types.filter((type) => type.startsWith("image/"));
408+
if (imageTypes.length > 0 && this.supportsImageInput()) {
409+
const blob = await item.getType(imageTypes[0]);
410+
await this.handleImagePasteBlob(blob);
411+
return;
412+
}
413+
414+
// Handle text
415+
if (item.types.includes("text/plain")) {
416+
const blob = await item.getType("text/plain");
417+
const text = await blob.text();
418+
this.termRef.current?.terminal.paste(text);
419+
return;
420+
}
421+
}
422+
} catch (err) {
423+
console.error("Paste error:", err);
424+
// Fallback to text-only paste
425+
try {
426+
const text = await navigator.clipboard.readText();
427+
if (text) {
428+
this.termRef.current?.terminal.paste(text);
429+
}
430+
} catch (fallbackErr) {
431+
console.error("Fallback paste error:", fallbackErr);
432+
}
433+
}
434+
}
435+
436+
supportsImageInput(): boolean {
437+
return supportsImageInputUtil();
438+
}
439+
440+
async handleImagePasteBlob(blob: Blob): Promise<void> {
441+
await handleImagePasteBlobUtil(blob, TabRpcClient, (text) => {
442+
this.termRef.current?.terminal.paste(text);
443+
});
444+
}
445+
394446
setTermMode(mode: "term" | "vdom") {
395447
if (mode == "term") {
396448
mode = null;
@@ -489,14 +541,23 @@ export class TermViewModel implements ViewModel {
489541
if (waveEvent.type != "keydown") {
490542
return true;
491543
}
544+
545+
// Handle Escape key during IME composition
546+
if (keyutil.checkKeyPressed(waveEvent, "Escape")) {
547+
if (this.termRef.current?.isComposing) {
548+
// Reset composition state when Escape is pressed during composition
549+
this.termRef.current.resetCompositionState();
550+
}
551+
}
552+
492553
if (this.keyDownHandler(waveEvent)) {
493554
event.preventDefault();
494555
event.stopPropagation();
495556
return false;
496557
}
497558
if (keyutil.checkKeyPressed(waveEvent, "Shift:Enter")) {
498559
const shiftEnterNewlineAtom = getOverrideConfigAtom(this.blockId, "term:shiftenternewline");
499-
const shiftEnterNewlineEnabled = globalStore.get(shiftEnterNewlineAtom) ?? false;
560+
const shiftEnterNewlineEnabled = globalStore.get(shiftEnterNewlineAtom) ?? true;
500561
if (shiftEnterNewlineEnabled) {
501562
this.sendDataToController("\u001b\n");
502563
event.preventDefault();
@@ -505,10 +566,7 @@ export class TermViewModel implements ViewModel {
505566
}
506567
}
507568
if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:v")) {
508-
const p = navigator.clipboard.readText();
509-
p.then((text) => {
510-
this.termRef.current?.terminal.paste(text);
511-
});
569+
this.handlePaste();
512570
event.preventDefault();
513571
event.stopPropagation();
514572
return false;

frontend/app/view/term/termutil.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,101 @@ function computeTheme(
3434
}
3535

3636
export { computeTheme };
37+
38+
import { RpcApi } from "@/app/store/wshclientapi";
39+
import { WshClient } from "@/app/store/wshclient";
40+
41+
export const MIME_TO_EXT: Record<string, string> = {
42+
"image/png": "png",
43+
"image/jpeg": "jpg",
44+
"image/jpg": "jpg",
45+
"image/gif": "gif",
46+
"image/webp": "webp",
47+
"image/bmp": "bmp",
48+
"image/svg+xml": "svg",
49+
"image/tiff": "tiff",
50+
};
51+
52+
/**
53+
* Creates a temporary file from a Blob (typically an image).
54+
* Validates size, generates a unique filename, saves to temp directory,
55+
* and returns the file path.
56+
*
57+
* @param blob - The Blob to save
58+
* @param client - The WshClient for RPC calls
59+
* @returns The path to the created temporary file
60+
* @throws Error if blob is too large (>5MB) or data URL is invalid
61+
*/
62+
export async function createTempFileFromBlob(blob: Blob, client: WshClient): Promise<string> {
63+
// Check size limit (5MB)
64+
if (blob.size > 5 * 1024 * 1024) {
65+
throw new Error("Image too large (>5MB)");
66+
}
67+
68+
// Get file extension from MIME type
69+
const ext = MIME_TO_EXT[blob.type] || "png";
70+
71+
// Generate unique filename with timestamp and random component
72+
const timestamp = Date.now();
73+
const random = Math.random().toString(36).substring(2, 8);
74+
const filename = `waveterm_paste_${timestamp}_${random}.${ext}`;
75+
76+
// Get platform-appropriate temp file path from backend
77+
const tempPath = await RpcApi.GetTempDirCommand(client, { filename });
78+
79+
// Convert blob to base64 using FileReader
80+
const dataUrl = await new Promise<string>((resolve, reject) => {
81+
const reader = new FileReader();
82+
reader.onload = () => resolve(reader.result as string);
83+
reader.onerror = reject;
84+
reader.readAsDataURL(blob);
85+
});
86+
87+
// Extract base64 data from data URL (remove "data:image/png;base64," prefix)
88+
const parts = dataUrl.split(",");
89+
if (parts.length < 2) {
90+
throw new Error("Invalid data URL format");
91+
}
92+
const base64Data = parts[1];
93+
94+
// Write image to temp file
95+
await RpcApi.FileWriteCommand(client, {
96+
info: { path: tempPath },
97+
data64: base64Data,
98+
});
99+
100+
return tempPath;
101+
}
102+
103+
/**
104+
* Checks if image input is supported.
105+
* Images will be saved as temp files and the path will be pasted.
106+
* Claude Code and other AI tools can then read the file.
107+
*
108+
* @returns true if image input is supported
109+
*/
110+
export function supportsImageInput(): boolean {
111+
return true;
112+
}
113+
114+
/**
115+
* Handles pasting an image blob by creating a temp file and pasting its path.
116+
*
117+
* @param blob - The image blob to paste
118+
* @param client - The WshClient for RPC calls
119+
* @param pasteFn - Function to paste the file path into the terminal
120+
*/
121+
export async function handleImagePasteBlob(
122+
blob: Blob,
123+
client: WshClient,
124+
pasteFn: (text: string) => void
125+
): Promise<void> {
126+
try {
127+
const tempPath = await createTempFileFromBlob(blob, client);
128+
// Paste the file path (like iTerm2 does when you copy a file)
129+
// Claude Code will read the file and display it as [Image #N]
130+
pasteFn(tempPath + " ");
131+
} catch (err) {
132+
console.error("Error pasting image:", err);
133+
}
134+
}

0 commit comments

Comments
 (0)