From d7208f5cb5c5e628189463806677f64cf14411a5 Mon Sep 17 00:00:00 2001 From: Togo01 Date: Wed, 5 Nov 2025 15:14:27 +0800 Subject: [PATCH 01/12] feat(terminal): enable Shift+Enter newline by default Enable Shift+Enter to insert newlines in terminal by default, improving multi-line input support for interactive CLI tools like Claude Code. Changes: - Change default shiftenternewline config from false to true - Send standard newline character (\n) instead of escape sequence (\u001b\n) This allows users to input multi-line commands and text without requiring manual configuration changes. --- frontend/app/view/term/term-model.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index 83575261e..afe5cb534 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -496,9 +496,9 @@ export class TermViewModel implements ViewModel { } if (keyutil.checkKeyPressed(waveEvent, "Shift:Enter")) { const shiftEnterNewlineAtom = getOverrideConfigAtom(this.blockId, "term:shiftenternewline"); - const shiftEnterNewlineEnabled = globalStore.get(shiftEnterNewlineAtom) ?? false; + const shiftEnterNewlineEnabled = globalStore.get(shiftEnterNewlineAtom) ?? true; if (shiftEnterNewlineEnabled) { - this.sendDataToController("\u001b\n"); + this.sendDataToController("\n"); event.preventDefault(); event.stopPropagation(); return false; From 27433be2e4e86d1cf42bfc377e32c4847ddaabb7 Mon Sep 17 00:00:00 2001 From: Togo01 Date: Wed, 5 Nov 2025 15:15:22 +0800 Subject: [PATCH 02/12] feat(terminal): add image paste support Add support for pasting images in terminal by saving them as temporary files and pasting the file path, similar to iTerm2 behavior. This enables AI tools like Claude Code to receive and process pasted images by reading the temporary file path. Changes: - Add handlePaste() method to intercept Cmd+Shift+V paste events - Add handleImagePasteBlob() to save images to /tmp and paste path - Detect image data in clipboard using ClipboardEvent and Clipboard API - Support both screenshot paste and file copy scenarios - Add 5MB size limit for pasted images The implementation creates temporary files in /tmp with format: waveterm_paste_[timestamp].[ext] --- frontend/app/view/term/term-model.ts | 91 +++++++++++++++++- frontend/app/view/term/termwrap.ts | 137 ++++++++++++++++++++++++++- 2 files changed, 219 insertions(+), 9 deletions(-) diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index afe5cb534..b616ced23 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -391,6 +391,92 @@ export class TermViewModel implements ViewModel { RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, inputdata64: b64data }); } + async handlePaste() { + console.log("🔍 handlePaste() called"); + try { + const clipboardItems = await navigator.clipboard.read(); + console.log("Clipboard items:", clipboardItems.length); + + for (const item of clipboardItems) { + console.log("Clipboard item types:", item.types); + + // Check for images first + const imageTypes = item.types.filter((type) => type.startsWith("image/")); + if (imageTypes.length > 0 && this.supportsImageInput()) { + console.log("Found image, processing..."); + const blob = await item.getType(imageTypes[0]); + await this.handleImagePasteBlob(blob); + return; + } + + // Handle text + if (item.types.includes("text/plain")) { + const blob = await item.getType("text/plain"); + const text = await blob.text(); + console.log("Pasting text, length:", text.length); + this.termRef.current?.terminal.paste(text); + return; + } + } + } catch (err) { + console.error("Paste error:", err); + // Fallback to text-only paste + try { + const text = await navigator.clipboard.readText(); + if (text) { + this.termRef.current?.terminal.paste(text); + } + } catch (fallbackErr) { + console.error("Fallback paste error:", fallbackErr); + } + } + } + + supportsImageInput(): boolean { + // Enable image paste for all terminals + // Images will be saved as temp files and the path will be pasted + // Claude Code and other AI tools can then read the file + return true; + } + + async handleImagePasteBlob(blob: Blob): Promise { + try { + // Check size limit (5MB) + if (blob.size > 5 * 1024 * 1024) { + console.error("Image too large (>5MB):", blob.size); + return; + } + + // Generate temp filename + const ext = blob.type.split('/')[1] || 'png'; + const filename = `waveterm_paste_${Date.now()}.${ext}`; + const tempPath = `/tmp/${filename}`; + + // Convert blob to base64 using FileReader + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + + // Extract base64 data from data URL (remove "data:image/png;base64," prefix) + const base64Data = dataUrl.split(',')[1]; + + // Write image to temp file + await RpcApi.FileWriteCommand(TabRpcClient, { + info: { path: tempPath }, + data64: base64Data, + }); + + // Paste the file path (like iTerm2 does when you copy a file) + // Claude Code will read the file and display it as [Image #N] + this.termRef.current?.terminal.paste(tempPath + " "); + } catch (err) { + console.error("Error pasting image:", err); + } + } + setTermMode(mode: "term" | "vdom") { if (mode == "term") { mode = null; @@ -505,10 +591,7 @@ export class TermViewModel implements ViewModel { } } if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:v")) { - const p = navigator.clipboard.readText(); - p.then((text) => { - this.termRef.current?.terminal.paste(text); - }); + this.handlePaste(); event.preventDefault(); event.stopPropagation(); return false; diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 7f15cbcb6..9bca378c2 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -8,7 +8,7 @@ import { TabRpcClient } from "@/app/store/wshrpcutil"; import { WOS, atoms, fetchWaveFile, getApi, getSettingsKeyAtom, globalStore, openLink } from "@/store/global"; import * as services from "@/store/services"; import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; -import { base64ToArray, base64ToString, fireAndForget } from "@/util/util"; +import { base64ToArray, base64ToString, fireAndForget, stringToBase64 } from "@/util/util"; import { SearchAddon } from "@xterm/addon-search"; import { SerializeAddon } from "@xterm/addon-serialize"; import { WebLinksAddon } from "@xterm/addon-web-links"; @@ -422,11 +422,68 @@ export class TermWrap { this.handleResize_debounced = debounce(50, this.handleResize.bind(this)); this.terminal.open(this.connectElem); this.handleResize(); - let pasteEventHandler = () => { + let pasteEventHandler = async (e: ClipboardEvent) => { this.pasteActive = true; - setTimeout(() => { - this.pasteActive = false; - }, 30); + + try { + // First try using ClipboardEvent.clipboardData (works in Electron) + if (e.clipboardData && e.clipboardData.items) { + const items = e.clipboardData.items; + + // Check for images first + for (let i = 0; i < items.length; i++) { + const item = items[i]; + + if (item.type.startsWith("image/")) { + if (this.supportsImageInput()) { + e.preventDefault(); + const blob = item.getAsFile(); + if (blob) { + await this.handleImagePasteBlob(blob); + return; + } + } + } + } + + // Handle text + const text = e.clipboardData.getData("text/plain"); + if (text) { + this.terminal.paste(text); + return; + } + } + + // Fallback: Try Clipboard API for newer browsers + const clipboardItems = await navigator.clipboard.read(); + for (const item of clipboardItems) { + const imageTypes = item.types.filter((type) => type.startsWith("image/")); + if (imageTypes.length > 0 && this.supportsImageInput()) { + await this.handleImagePaste(item, imageTypes[0]); + return; + } + + if (item.types.includes("text/plain")) { + const blob = await item.getType("text/plain"); + const text = await blob.text(); + this.terminal.paste(text); + return; + } + } + } catch (err) { + console.error("Paste error:", err); + // Final fallback to simple text paste + if (e.clipboardData) { + const text = e.clipboardData.getData("text/plain"); + if (text) { + this.terminal.paste(text); + } + } + } finally { + setTimeout(() => { + this.pasteActive = false; + }, 30); + } }; pasteEventHandler = pasteEventHandler.bind(this); this.connectElem.addEventListener("paste", pasteEventHandler, true); @@ -608,6 +665,76 @@ export class TermWrap { } } + supportsImageInput(): boolean { + // Enable image paste for all terminals + // Images will be saved as temp files and the path will be pasted + // Claude Code and other AI tools can then read the file + return true; + } + + async handleImagePasteBlob(blob: Blob): Promise { + try { + // Check size limit (5MB) + if (blob.size > 5 * 1024 * 1024) { + console.error("Image too large (>5MB):", blob.size); + return; + } + + // Generate temp filename + const ext = blob.type.split('/')[1] || 'png'; + const filename = `waveterm_paste_${Date.now()}.${ext}`; + const tempPath = `/tmp/${filename}`; + + // Convert blob to base64 using FileReader + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + + // Extract base64 data from data URL (remove "data:image/png;base64," prefix) + const base64Data = dataUrl.split(',')[1]; + + // Write image to temp file + await RpcApi.FileWriteCommand(TabRpcClient, { + info: { path: tempPath }, + data64: base64Data, + }); + + // Paste the file path (like iTerm2 does when you copy a file) + // Claude Code will read the file and display it as [Image #N] + this.terminal.paste(tempPath + " "); + } catch (err) { + console.error("Error pasting image:", err); + } + } + + async handleImagePaste(item: ClipboardItem, mimeType: string): Promise { + try { + const blob = await item.getType(mimeType); + + // Check size limit (5MB) + if (blob.size > 5 * 1024 * 1024) { + console.error("Image too large:", blob.size); + return; + } + + // Convert to base64 + const arrayBuffer = await blob.arrayBuffer(); + const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))); + const dataUrl = `data:${mimeType};base64,${base64}`; + + console.log("Pasting image as data URL, size:", blob.size, "type:", mimeType); + + // For now, just paste as data URL text + // TODO: Implement proper image transmission to controller when RPC support is added + this.terminal.paste(dataUrl); + } catch (err) { + console.error("Error processing image:", err); + } + } + handleResize() { const oldRows = this.terminal.rows; const oldCols = this.terminal.cols; From 68d8fba34612d1ce0ddb92e4ea79dc3697e8766b Mon Sep 17 00:00:00 2001 From: Togo01 Date: Wed, 5 Nov 2025 15:45:50 +0800 Subject: [PATCH 03/12] fix(terminal): prevent stack overflow in image paste fallback Reuse handleImagePasteBlob() method instead of manually converting blob to base64 with String.fromCharCode(), which causes RangeError for images larger than ~64KB. This ensures consistent behavior and prevents stack overflow errors when pasting large screenshots. Fixes: CodeRabbit review feedback --- frontend/app/view/term/termwrap.ts | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 9bca378c2..a12499bf6 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -713,23 +713,8 @@ export class TermWrap { async handleImagePaste(item: ClipboardItem, mimeType: string): Promise { try { const blob = await item.getType(mimeType); - - // Check size limit (5MB) - if (blob.size > 5 * 1024 * 1024) { - console.error("Image too large:", blob.size); - return; - } - - // Convert to base64 - const arrayBuffer = await blob.arrayBuffer(); - const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))); - const dataUrl = `data:${mimeType};base64,${base64}`; - - console.log("Pasting image as data URL, size:", blob.size, "type:", mimeType); - - // For now, just paste as data URL text - // TODO: Implement proper image transmission to controller when RPC support is added - this.terminal.paste(dataUrl); + // Reuse the existing handleImagePasteBlob logic + await this.handleImagePasteBlob(blob); } catch (err) { console.error("Error processing image:", err); } From d104e5e7f6809bf64d943b06549d22e9caa8b406 Mon Sep 17 00:00:00 2001 From: Togo01 Date: Wed, 5 Nov 2025 15:56:43 +0800 Subject: [PATCH 04/12] refactor(terminal): improve image paste robustness Address CodeRabbit review feedback: - Use MIME type lookup table instead of split() to handle complex types like image/svg+xml correctly - Add random component to filename to prevent collisions when pasting multiple images in quick succession - Remove unused stringToBase64 import from termwrap.ts - Add TODO comment documenting cross-platform temp directory limitation The /tmp/ path currently works on macOS/Linux. Windows support would require backend API to provide platform-appropriate temp directory. --- frontend/app/view/term/term-model.ts | 24 +++++++++++++++++++++--- frontend/app/view/term/termwrap.ts | 26 ++++++++++++++++++++++---- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index b616ced23..fcd5fdb2d 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -447,9 +447,27 @@ export class TermViewModel implements ViewModel { return; } - // Generate temp filename - const ext = blob.type.split('/')[1] || 'png'; - const filename = `waveterm_paste_${Date.now()}.${ext}`; + // Map MIME types to file extensions + const mimeToExt: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/jpg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + "image/bmp": "bmp", + "image/svg+xml": "svg", + "image/tiff": "tiff", + }; + const ext = mimeToExt[blob.type] || "png"; + + // Generate unique filename with timestamp and random component + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 8); + const filename = `waveterm_paste_${timestamp}_${random}.${ext}`; + + // TODO: Use platform-appropriate temp directory + // Currently hardcoded to /tmp/ which works on macOS/Linux + // Windows support would require backend API to provide temp path const tempPath = `/tmp/${filename}`; // Convert blob to base64 using FileReader diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index a12499bf6..d238f1985 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -8,7 +8,7 @@ import { TabRpcClient } from "@/app/store/wshrpcutil"; import { WOS, atoms, fetchWaveFile, getApi, getSettingsKeyAtom, globalStore, openLink } from "@/store/global"; import * as services from "@/store/services"; import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; -import { base64ToArray, base64ToString, fireAndForget, stringToBase64 } from "@/util/util"; +import { base64ToArray, base64ToString, fireAndForget } from "@/util/util"; import { SearchAddon } from "@xterm/addon-search"; import { SerializeAddon } from "@xterm/addon-serialize"; import { WebLinksAddon } from "@xterm/addon-web-links"; @@ -680,9 +680,27 @@ export class TermWrap { return; } - // Generate temp filename - const ext = blob.type.split('/')[1] || 'png'; - const filename = `waveterm_paste_${Date.now()}.${ext}`; + // Map MIME types to file extensions + const mimeToExt: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/jpg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + "image/bmp": "bmp", + "image/svg+xml": "svg", + "image/tiff": "tiff", + }; + const ext = mimeToExt[blob.type] || "png"; + + // Generate unique filename with timestamp and random component + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 8); + const filename = `waveterm_paste_${timestamp}_${random}.${ext}`; + + // TODO: Use platform-appropriate temp directory + // Currently hardcoded to /tmp/ which works on macOS/Linux + // Windows support would require backend API to provide temp path const tempPath = `/tmp/${filename}`; // Convert blob to base64 using FileReader From 4f423b314dfbef51a694121e6542af1cc822c738 Mon Sep 17 00:00:00 2001 From: Togo01 Date: Wed, 5 Nov 2025 16:32:56 +0800 Subject: [PATCH 05/12] feat(terminal): add cross-platform temp directory support Add GetTempDirCommand RPC to provide platform-appropriate temp directory paths from the backend, replacing hardcoded /tmp/ paths. This ensures image paste works correctly on: - macOS: /tmp or /var/folders/... - Linux: /tmp or $TMPDIR - Windows: %TEMP% or %LOCALAPPDATA%\Temp Backend changes: - Add GetTempDirCommand to wshrpc interface (wshrpctypes.go) - Implement GetTempDirCommand using os.TempDir() (wshserver.go) - Add client wrapper in wshclient.go Frontend changes: - Add GetTempDirCommand to RpcApi (wshclientapi.ts) - Update handleImagePasteBlob to use GetTempDirCommand in term-model.ts - Update handleImagePasteBlob to use GetTempDirCommand in termwrap.ts Addresses CodeRabbit review feedback about cross-platform compatibility. --- frontend/app/store/wshclientapi.ts | 5 +++++ frontend/app/view/term/term-model.ts | 7 +++---- frontend/app/view/term/termwrap.ts | 7 +++---- pkg/wshrpc/wshclient/wshclient.go | 5 +++++ pkg/wshrpc/wshrpctypes.go | 2 ++ pkg/wshrpc/wshserver/wshserver.go | 4 ++++ 6 files changed, 22 insertions(+), 8 deletions(-) diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 5b297ad44..f6a334cd1 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -262,6 +262,11 @@ class RpcApiType { return client.wshRpcCall("filerestorebackup", data, opts); } + // command "gettempdir" [call] + GetTempDirCommand(client: WshClient, opts?: RpcOpts): Promise { + return client.wshRpcCall("gettempdir", null, opts); + } + // command "filesharecapability" [call] FileShareCapabilityCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { return client.wshRpcCall("filesharecapability", data, opts); diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index fcd5fdb2d..130105937 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -465,10 +465,9 @@ export class TermViewModel implements ViewModel { const random = Math.random().toString(36).substring(2, 8); const filename = `waveterm_paste_${timestamp}_${random}.${ext}`; - // TODO: Use platform-appropriate temp directory - // Currently hardcoded to /tmp/ which works on macOS/Linux - // Windows support would require backend API to provide temp path - const tempPath = `/tmp/${filename}`; + // Get platform-appropriate temp directory from backend + const tempDir = await RpcApi.GetTempDirCommand(TabRpcClient); + const tempPath = `${tempDir}/${filename}`; // Convert blob to base64 using FileReader const dataUrl = await new Promise((resolve, reject) => { diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index d238f1985..0f9cc882f 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -698,10 +698,9 @@ export class TermWrap { const random = Math.random().toString(36).substring(2, 8); const filename = `waveterm_paste_${timestamp}_${random}.${ext}`; - // TODO: Use platform-appropriate temp directory - // Currently hardcoded to /tmp/ which works on macOS/Linux - // Windows support would require backend API to provide temp path - const tempPath = `/tmp/${filename}`; + // Get platform-appropriate temp directory from backend + const tempDir = await RpcApi.GetTempDirCommand(TabRpcClient); + const tempPath = `${tempDir}/${filename}`; // Convert blob to base64 using FileReader const dataUrl = await new Promise((resolve, reject) => { diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 2505f2c19..b0c257fd2 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -321,6 +321,11 @@ func FileRestoreBackupCommand(w *wshutil.WshRpc, data wshrpc.CommandFileRestoreB return err } +// command "gettempdir", wshserver.GetTempDirCommand +func GetTempDirCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (string, error) { + return sendRpcRequestCallHelper[string](w, "gettempdir", nil, opts) +} + // command "filesharecapability", wshserver.FileShareCapabilityCommand func FileShareCapabilityCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (wshrpc.FileShareCapability, error) { resp, err := sendRpcRequestCallHelper[wshrpc.FileShareCapability](w, "filesharecapability", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index c5d96088e..279e28389 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -83,6 +83,7 @@ const ( Command_FileJoin = "filejoin" Command_FileShareCapability = "filesharecapability" Command_FileRestoreBackup = "filerestorebackup" + Command_GetTempDir = "gettempdir" Command_EventPublish = "eventpublish" Command_EventRecv = "eventrecv" @@ -222,6 +223,7 @@ type WshRpcInterface interface { FileShareCapabilityCommand(ctx context.Context, path string) (FileShareCapability, error) FileRestoreBackupCommand(ctx context.Context, data CommandFileRestoreBackupData) error + GetTempDirCommand(ctx context.Context) (string, error) EventPublishCommand(ctx context.Context, data wps.WaveEvent) error EventSubCommand(ctx context.Context, data wps.SubscriptionRequest) error EventUnsubCommand(ctx context.Context, data string) error diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index df7f7a29c..b916056cc 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -445,6 +445,10 @@ func (ws *WshServer) FileRestoreBackupCommand(ctx context.Context, data wshrpc.C return filebackup.RestoreBackup(data.BackupFilePath, data.RestoreToFileName) } +func (ws *WshServer) GetTempDirCommand(ctx context.Context) (string, error) { + return os.TempDir(), nil +} + func (ws *WshServer) DeleteSubBlockCommand(ctx context.Context, data wshrpc.CommandDeleteBlockData) error { err := wcore.DeleteBlock(ctx, data.BlockId, false) if err != nil { From 1f9d2b3659a987f4edcffda0503151b1601c1d0c Mon Sep 17 00:00:00 2001 From: Togo01 Date: Wed, 5 Nov 2025 20:50:48 +0800 Subject: [PATCH 06/12] chore(terminal): remove debug console.log statements Remove temporary debug logging from handlePaste() method: - Remove handlePaste() called log - Remove clipboard items count log - Remove clipboard item types log - Remove found image processing log - Remove pasting text length log Preserve console.error statements for actual error reporting. Addresses CodeRabbit review feedback. --- frontend/app/view/term/term-model.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index 130105937..755d9e6a5 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -392,18 +392,13 @@ export class TermViewModel implements ViewModel { } async handlePaste() { - console.log("🔍 handlePaste() called"); try { const clipboardItems = await navigator.clipboard.read(); - console.log("Clipboard items:", clipboardItems.length); for (const item of clipboardItems) { - console.log("Clipboard item types:", item.types); - // Check for images first const imageTypes = item.types.filter((type) => type.startsWith("image/")); if (imageTypes.length > 0 && this.supportsImageInput()) { - console.log("Found image, processing..."); const blob = await item.getType(imageTypes[0]); await this.handleImagePasteBlob(blob); return; @@ -413,7 +408,6 @@ export class TermViewModel implements ViewModel { if (item.types.includes("text/plain")) { const blob = await item.getType("text/plain"); const text = await blob.text(); - console.log("Pasting text, length:", text.length); this.termRef.current?.terminal.paste(text); return; } From 3900e7d69aecf097027e5955f0110b81cd168c29 Mon Sep 17 00:00:00 2001 From: Togo01 Date: Wed, 5 Nov 2025 22:52:37 +0800 Subject: [PATCH 07/12] refactor(terminal): address CodeRabbit review feedback Address three critical review items: 1. Extract MIME type mapping as class constants - Add MIME_TO_EXT static readonly in TermViewModel and TermWrap - Improves reusability and maintainability 2. Validate data URL format before splitting - Add defensive check for comma separator in data URL - Throw error if format is invalid - Prevents potential undefined access 3. Fix cross-platform path joining issue - Extend GetTempDirCommand to accept optional filename parameter - Backend now joins paths using filepath.Join (platform-aware) - Eliminates hardcoded '/' separator that breaks on Windows Backend changes: - Add CommandGetTempDirData struct with optional FileName field - Update GetTempDirCommand signature and implementation - Use filepath.Join for cross-platform path handling Frontend changes: - Extract MIME type mappings to static constants - Pass filename to GetTempDirCommand instead of manual joining - Add data URL format validation with error handling Addresses CodeRabbit PR review feedback. --- frontend/app/store/wshclientapi.ts | 10 ++++---- frontend/app/view/term/term-model.ts | 36 +++++++++++++++------------- frontend/app/view/term/termwrap.ts | 36 +++++++++++++++------------- frontend/types/gotypes.d.ts | 5 ++++ pkg/wshrpc/wshclient/wshclient.go | 11 +++++---- pkg/wshrpc/wshrpctypes.go | 6 ++++- pkg/wshrpc/wshserver/wshserver.go | 8 +++++-- 7 files changed, 67 insertions(+), 45 deletions(-) diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index f6a334cd1..8171f727a 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -262,11 +262,6 @@ class RpcApiType { return client.wshRpcCall("filerestorebackup", data, opts); } - // command "gettempdir" [call] - GetTempDirCommand(client: WshClient, opts?: RpcOpts): Promise { - return client.wshRpcCall("gettempdir", null, opts); - } - // command "filesharecapability" [call] FileShareCapabilityCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { return client.wshRpcCall("filesharecapability", data, opts); @@ -332,6 +327,11 @@ class RpcApiType { return client.wshRpcCall("gettab", data, opts); } + // command "gettempdir" [call] + GetTempDirCommand(client: WshClient, data: CommandGetTempDirData, opts?: RpcOpts): Promise { + return client.wshRpcCall("gettempdir", data, opts); + } + // command "getupdatechannel" [call] GetUpdateChannelCommand(client: WshClient, opts?: RpcOpts): Promise { return client.wshRpcCall("getupdatechannel", null, opts); diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index 755d9e6a5..9fbafff3b 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -34,6 +34,17 @@ import { TermWrap } from "./termwrap"; import { getBlockingCommand } from "./shellblocking"; export class TermViewModel implements ViewModel { + private static readonly MIME_TO_EXT: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/jpg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + "image/bmp": "bmp", + "image/svg+xml": "svg", + "image/tiff": "tiff", + }; + viewType: string; nodeModel: BlockNodeModel; connected: boolean; @@ -441,27 +452,16 @@ export class TermViewModel implements ViewModel { return; } - // Map MIME types to file extensions - const mimeToExt: Record = { - "image/png": "png", - "image/jpeg": "jpg", - "image/jpg": "jpg", - "image/gif": "gif", - "image/webp": "webp", - "image/bmp": "bmp", - "image/svg+xml": "svg", - "image/tiff": "tiff", - }; - const ext = mimeToExt[blob.type] || "png"; + // Get file extension from MIME type + const ext = TermViewModel.MIME_TO_EXT[blob.type] || "png"; // Generate unique filename with timestamp and random component const timestamp = Date.now(); const random = Math.random().toString(36).substring(2, 8); const filename = `waveterm_paste_${timestamp}_${random}.${ext}`; - // Get platform-appropriate temp directory from backend - const tempDir = await RpcApi.GetTempDirCommand(TabRpcClient); - const tempPath = `${tempDir}/${filename}`; + // Get platform-appropriate temp file path from backend + const tempPath = await RpcApi.GetTempDirCommand(TabRpcClient, { filename }); // Convert blob to base64 using FileReader const dataUrl = await new Promise((resolve, reject) => { @@ -472,7 +472,11 @@ export class TermViewModel implements ViewModel { }); // Extract base64 data from data URL (remove "data:image/png;base64," prefix) - const base64Data = dataUrl.split(',')[1]; + const parts = dataUrl.split(","); + if (parts.length < 2) { + throw new Error("Invalid data URL format"); + } + const base64Data = parts[1]; // Write image to temp file await RpcApi.FileWriteCommand(TabRpcClient, { diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 0f9cc882f..5bbacaf6d 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -328,6 +328,17 @@ function handleOsc16162Command(data: string, blockId: string, loaded: boolean, t } export class TermWrap { + private static readonly MIME_TO_EXT: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/jpg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + "image/bmp": "bmp", + "image/svg+xml": "svg", + "image/tiff": "tiff", + }; + blockId: string; ptyOffset: number; dataBytesProcessed: number; @@ -680,27 +691,16 @@ export class TermWrap { return; } - // Map MIME types to file extensions - const mimeToExt: Record = { - "image/png": "png", - "image/jpeg": "jpg", - "image/jpg": "jpg", - "image/gif": "gif", - "image/webp": "webp", - "image/bmp": "bmp", - "image/svg+xml": "svg", - "image/tiff": "tiff", - }; - const ext = mimeToExt[blob.type] || "png"; + // Get file extension from MIME type + const ext = TermWrap.MIME_TO_EXT[blob.type] || "png"; // Generate unique filename with timestamp and random component const timestamp = Date.now(); const random = Math.random().toString(36).substring(2, 8); const filename = `waveterm_paste_${timestamp}_${random}.${ext}`; - // Get platform-appropriate temp directory from backend - const tempDir = await RpcApi.GetTempDirCommand(TabRpcClient); - const tempPath = `${tempDir}/${filename}`; + // Get platform-appropriate temp file path from backend + const tempPath = await RpcApi.GetTempDirCommand(TabRpcClient, { filename }); // Convert blob to base64 using FileReader const dataUrl = await new Promise((resolve, reject) => { @@ -711,7 +711,11 @@ export class TermWrap { }); // Extract base64 data from data URL (remove "data:image/png;base64," prefix) - const base64Data = dataUrl.split(',')[1]; + const parts = dataUrl.split(","); + if (parts.length < 2) { + throw new Error("Invalid data URL format"); + } + const base64Data = parts[1]; // Write image to temp file await RpcApi.FileWriteCommand(TabRpcClient, { diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 11dd2bfed..5b751a68c 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -279,6 +279,11 @@ declare global { oref: ORef; }; + // wshrpc.CommandGetTempDirData + type CommandGetTempDirData = { + filename?: string; + }; + // wshrpc.CommandGetWaveAIChatData type CommandGetWaveAIChatData = { chatid: string; diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index b0c257fd2..883760c8c 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -321,11 +321,6 @@ func FileRestoreBackupCommand(w *wshutil.WshRpc, data wshrpc.CommandFileRestoreB return err } -// command "gettempdir", wshserver.GetTempDirCommand -func GetTempDirCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (string, error) { - return sendRpcRequestCallHelper[string](w, "gettempdir", nil, opts) -} - // command "filesharecapability", wshserver.FileShareCapabilityCommand func FileShareCapabilityCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (wshrpc.FileShareCapability, error) { resp, err := sendRpcRequestCallHelper[wshrpc.FileShareCapability](w, "filesharecapability", data, opts) @@ -403,6 +398,12 @@ func GetTabCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (*waveo return resp, err } +// command "gettempdir", wshserver.GetTempDirCommand +func GetTempDirCommand(w *wshutil.WshRpc, data wshrpc.CommandGetTempDirData, opts *wshrpc.RpcOpts) (string, error) { + resp, err := sendRpcRequestCallHelper[string](w, "gettempdir", data, opts) + return resp, err +} + // command "getupdatechannel", wshserver.GetUpdateChannelCommand func GetUpdateChannelCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (string, error) { resp, err := sendRpcRequestCallHelper[string](w, "getupdatechannel", nil, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 279e28389..a639bf0f0 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -223,7 +223,7 @@ type WshRpcInterface interface { FileShareCapabilityCommand(ctx context.Context, path string) (FileShareCapability, error) FileRestoreBackupCommand(ctx context.Context, data CommandFileRestoreBackupData) error - GetTempDirCommand(ctx context.Context) (string, error) + GetTempDirCommand(ctx context.Context, data CommandGetTempDirData) (string, error) EventPublishCommand(ctx context.Context, data wps.WaveEvent) error EventSubCommand(ctx context.Context, data wps.SubscriptionRequest) error EventUnsubCommand(ctx context.Context, data string) error @@ -624,6 +624,10 @@ type CommandFileRestoreBackupData struct { RestoreToFileName string `json:"restoretofilename"` } +type CommandGetTempDirData struct { + FileName string `json:"filename,omitempty"` +} + type CommandRemoteStreamTarData struct { Path string `json:"path"` Opts *FileCopyOpts `json:"opts,omitempty"` diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index b916056cc..34204e5c5 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -445,8 +445,12 @@ func (ws *WshServer) FileRestoreBackupCommand(ctx context.Context, data wshrpc.C return filebackup.RestoreBackup(data.BackupFilePath, data.RestoreToFileName) } -func (ws *WshServer) GetTempDirCommand(ctx context.Context) (string, error) { - return os.TempDir(), nil +func (ws *WshServer) GetTempDirCommand(ctx context.Context, data wshrpc.CommandGetTempDirData) (string, error) { + tempDir := os.TempDir() + if data.FileName != "" { + return filepath.Join(tempDir, data.FileName), nil + } + return tempDir, nil } func (ws *WshServer) DeleteSubBlockCommand(ctx context.Context, data wshrpc.CommandDeleteBlockData) error { From 271a9b7239cb284d5deff3d641397e4be326f27b Mon Sep 17 00:00:00 2001 From: Togo01 Date: Wed, 5 Nov 2025 23:13:17 +0800 Subject: [PATCH 08/12] refactor(terminal): extract shared image paste utilities Extract MIME type mapping and image paste logic to shared termutil module: - Move MIME_TO_EXT constant from TermViewModel and TermWrap to termutil.ts - Create createTempFileFromBlob() utility function to handle image blob processing - Refactor both classes to use shared utilities, reducing code duplication - Maintain all functionality: size validation, format detection, temp file creation This addresses CodeRabbit review feedback about code duplication. --- frontend/app/view/term/term-model.ts | 51 +--------------------- frontend/app/view/term/termutil.ts | 65 ++++++++++++++++++++++++++++ frontend/app/view/term/termwrap.ts | 51 +--------------------- 3 files changed, 69 insertions(+), 98 deletions(-) diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index 9fbafff3b..5ade8fa85 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -29,21 +29,11 @@ import * as keyutil from "@/util/keyutil"; import { boundNumber, stringToBase64 } from "@/util/util"; import * as jotai from "jotai"; import * as React from "react"; -import { computeTheme, DefaultTermTheme } from "./termutil"; +import { computeTheme, createTempFileFromBlob, DefaultTermTheme } from "./termutil"; import { TermWrap } from "./termwrap"; import { getBlockingCommand } from "./shellblocking"; export class TermViewModel implements ViewModel { - private static readonly MIME_TO_EXT: Record = { - "image/png": "png", - "image/jpeg": "jpg", - "image/jpg": "jpg", - "image/gif": "gif", - "image/webp": "webp", - "image/bmp": "bmp", - "image/svg+xml": "svg", - "image/tiff": "tiff", - }; viewType: string; nodeModel: BlockNodeModel; @@ -446,44 +436,7 @@ export class TermViewModel implements ViewModel { async handleImagePasteBlob(blob: Blob): Promise { try { - // Check size limit (5MB) - if (blob.size > 5 * 1024 * 1024) { - console.error("Image too large (>5MB):", blob.size); - return; - } - - // Get file extension from MIME type - const ext = TermViewModel.MIME_TO_EXT[blob.type] || "png"; - - // Generate unique filename with timestamp and random component - const timestamp = Date.now(); - const random = Math.random().toString(36).substring(2, 8); - const filename = `waveterm_paste_${timestamp}_${random}.${ext}`; - - // Get platform-appropriate temp file path from backend - const tempPath = await RpcApi.GetTempDirCommand(TabRpcClient, { filename }); - - // Convert blob to base64 using FileReader - const dataUrl = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.onerror = reject; - reader.readAsDataURL(blob); - }); - - // Extract base64 data from data URL (remove "data:image/png;base64," prefix) - const parts = dataUrl.split(","); - if (parts.length < 2) { - throw new Error("Invalid data URL format"); - } - const base64Data = parts[1]; - - // Write image to temp file - await RpcApi.FileWriteCommand(TabRpcClient, { - info: { path: tempPath }, - data64: base64Data, - }); - + const tempPath = await createTempFileFromBlob(blob, TabRpcClient); // Paste the file path (like iTerm2 does when you copy a file) // Claude Code will read the file and display it as [Image #N] this.termRef.current?.terminal.paste(tempPath + " "); diff --git a/frontend/app/view/term/termutil.ts b/frontend/app/view/term/termutil.ts index 4874d7903..aef6a1518 100644 --- a/frontend/app/view/term/termutil.ts +++ b/frontend/app/view/term/termutil.ts @@ -34,3 +34,68 @@ function computeTheme( } export { computeTheme }; + +import { RpcApi } from "@/app/store/wshclientapi"; +import { WshClient } from "@/app/store/wshclient"; + +export const MIME_TO_EXT: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/jpg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + "image/bmp": "bmp", + "image/svg+xml": "svg", + "image/tiff": "tiff", +}; + +/** + * Creates a temporary file from a Blob (typically an image). + * Validates size, generates a unique filename, saves to temp directory, + * and returns the file path. + * + * @param blob - The Blob to save + * @param client - The WshClient for RPC calls + * @returns The path to the created temporary file + * @throws Error if blob is too large (>5MB) or data URL is invalid + */ +export async function createTempFileFromBlob(blob: Blob, client: WshClient): Promise { + // Check size limit (5MB) + if (blob.size > 5 * 1024 * 1024) { + throw new Error("Image too large (>5MB)"); + } + + // Get file extension from MIME type + const ext = MIME_TO_EXT[blob.type] || "png"; + + // Generate unique filename with timestamp and random component + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 8); + const filename = `waveterm_paste_${timestamp}_${random}.${ext}`; + + // Get platform-appropriate temp file path from backend + const tempPath = await RpcApi.GetTempDirCommand(client, { filename }); + + // Convert blob to base64 using FileReader + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + + // Extract base64 data from data URL (remove "data:image/png;base64," prefix) + const parts = dataUrl.split(","); + if (parts.length < 2) { + throw new Error("Invalid data URL format"); + } + const base64Data = parts[1]; + + // Write image to temp file + await RpcApi.FileWriteCommand(client, { + info: { path: tempPath }, + data64: base64Data, + }); + + return tempPath; +} diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 5bbacaf6d..69e21d5dc 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -18,6 +18,7 @@ import { Terminal } from "@xterm/xterm"; import debug from "debug"; import * as jotai from "jotai"; import { debounce } from "throttle-debounce"; +import { createTempFileFromBlob } from "./termutil"; import { FitAddon } from "./fitaddon"; const dlog = debug("wave:termwrap"); @@ -328,17 +329,6 @@ function handleOsc16162Command(data: string, blockId: string, loaded: boolean, t } export class TermWrap { - private static readonly MIME_TO_EXT: Record = { - "image/png": "png", - "image/jpeg": "jpg", - "image/jpg": "jpg", - "image/gif": "gif", - "image/webp": "webp", - "image/bmp": "bmp", - "image/svg+xml": "svg", - "image/tiff": "tiff", - }; - blockId: string; ptyOffset: number; dataBytesProcessed: number; @@ -685,44 +675,7 @@ export class TermWrap { async handleImagePasteBlob(blob: Blob): Promise { try { - // Check size limit (5MB) - if (blob.size > 5 * 1024 * 1024) { - console.error("Image too large (>5MB):", blob.size); - return; - } - - // Get file extension from MIME type - const ext = TermWrap.MIME_TO_EXT[blob.type] || "png"; - - // Generate unique filename with timestamp and random component - const timestamp = Date.now(); - const random = Math.random().toString(36).substring(2, 8); - const filename = `waveterm_paste_${timestamp}_${random}.${ext}`; - - // Get platform-appropriate temp file path from backend - const tempPath = await RpcApi.GetTempDirCommand(TabRpcClient, { filename }); - - // Convert blob to base64 using FileReader - const dataUrl = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.onerror = reject; - reader.readAsDataURL(blob); - }); - - // Extract base64 data from data URL (remove "data:image/png;base64," prefix) - const parts = dataUrl.split(","); - if (parts.length < 2) { - throw new Error("Invalid data URL format"); - } - const base64Data = parts[1]; - - // Write image to temp file - await RpcApi.FileWriteCommand(TabRpcClient, { - info: { path: tempPath }, - data64: base64Data, - }); - + const tempPath = await createTempFileFromBlob(blob, TabRpcClient); // Paste the file path (like iTerm2 does when you copy a file) // Claude Code will read the file and display it as [Image #N] this.terminal.paste(tempPath + " "); From f8ca0ba3ae8ea489def1760fc3f1d7a8f0e25920 Mon Sep 17 00:00:00 2001 From: Togo01 Date: Thu, 6 Nov 2025 01:33:20 +0800 Subject: [PATCH 09/12] refactor(terminal): extract image paste methods to shared utilities Further reduce code duplication by extracting supportsImageInput() and handleImagePasteBlob() methods to termutil.ts: - Add supportsImageInput() utility function - Add handleImagePasteBlob() utility with callback for terminal paste - Update both TermViewModel and TermWrap to delegate to shared utilities - Maintain all functionality while eliminating duplicate method implementations This addresses additional CodeRabbit review feedback about method duplication. --- frontend/app/view/term/term-model.ts | 24 ++++++++++---------- frontend/app/view/term/termutil.ts | 33 ++++++++++++++++++++++++++++ frontend/app/view/term/termwrap.ts | 22 ++++++++----------- 3 files changed, 53 insertions(+), 26 deletions(-) diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index 5ade8fa85..976968cca 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -29,7 +29,13 @@ import * as keyutil from "@/util/keyutil"; import { boundNumber, stringToBase64 } from "@/util/util"; import * as jotai from "jotai"; import * as React from "react"; -import { computeTheme, createTempFileFromBlob, DefaultTermTheme } from "./termutil"; +import { + computeTheme, + createTempFileFromBlob, + DefaultTermTheme, + handleImagePasteBlob as handleImagePasteBlobUtil, + supportsImageInput as supportsImageInputUtil, +} from "./termutil"; import { TermWrap } from "./termwrap"; import { getBlockingCommand } from "./shellblocking"; @@ -428,21 +434,13 @@ export class TermViewModel implements ViewModel { } supportsImageInput(): boolean { - // Enable image paste for all terminals - // Images will be saved as temp files and the path will be pasted - // Claude Code and other AI tools can then read the file - return true; + return supportsImageInputUtil(); } async handleImagePasteBlob(blob: Blob): Promise { - try { - const tempPath = await createTempFileFromBlob(blob, TabRpcClient); - // Paste the file path (like iTerm2 does when you copy a file) - // Claude Code will read the file and display it as [Image #N] - this.termRef.current?.terminal.paste(tempPath + " "); - } catch (err) { - console.error("Error pasting image:", err); - } + await handleImagePasteBlobUtil(blob, TabRpcClient, (text) => { + this.termRef.current?.terminal.paste(text); + }); } setTermMode(mode: "term" | "vdom") { diff --git a/frontend/app/view/term/termutil.ts b/frontend/app/view/term/termutil.ts index aef6a1518..a57c81bae 100644 --- a/frontend/app/view/term/termutil.ts +++ b/frontend/app/view/term/termutil.ts @@ -99,3 +99,36 @@ export async function createTempFileFromBlob(blob: Blob, client: WshClient): Pro return tempPath; } + +/** + * Checks if image input is supported. + * Images will be saved as temp files and the path will be pasted. + * Claude Code and other AI tools can then read the file. + * + * @returns true if image input is supported + */ +export function supportsImageInput(): boolean { + return true; +} + +/** + * Handles pasting an image blob by creating a temp file and pasting its path. + * + * @param blob - The image blob to paste + * @param client - The WshClient for RPC calls + * @param pasteFn - Function to paste the file path into the terminal + */ +export async function handleImagePasteBlob( + blob: Blob, + client: WshClient, + pasteFn: (text: string) => void +): Promise { + try { + const tempPath = await createTempFileFromBlob(blob, client); + // Paste the file path (like iTerm2 does when you copy a file) + // Claude Code will read the file and display it as [Image #N] + pasteFn(tempPath + " "); + } catch (err) { + console.error("Error pasting image:", err); + } +} diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 69e21d5dc..d5d17d419 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -18,7 +18,11 @@ import { Terminal } from "@xterm/xterm"; import debug from "debug"; import * as jotai from "jotai"; import { debounce } from "throttle-debounce"; -import { createTempFileFromBlob } from "./termutil"; +import { + createTempFileFromBlob, + handleImagePasteBlob as handleImagePasteBlobUtil, + supportsImageInput as supportsImageInputUtil, +} from "./termutil"; import { FitAddon } from "./fitaddon"; const dlog = debug("wave:termwrap"); @@ -667,21 +671,13 @@ export class TermWrap { } supportsImageInput(): boolean { - // Enable image paste for all terminals - // Images will be saved as temp files and the path will be pasted - // Claude Code and other AI tools can then read the file - return true; + return supportsImageInputUtil(); } async handleImagePasteBlob(blob: Blob): Promise { - try { - const tempPath = await createTempFileFromBlob(blob, TabRpcClient); - // Paste the file path (like iTerm2 does when you copy a file) - // Claude Code will read the file and display it as [Image #N] - this.terminal.paste(tempPath + " "); - } catch (err) { - console.error("Error pasting image:", err); - } + await handleImagePasteBlobUtil(blob, TabRpcClient, (text) => { + this.terminal.paste(text); + }); } async handleImagePaste(item: ClipboardItem, mimeType: string): Promise { From c955ed8771637ea7583a73d16f321bc552fb537c Mon Sep 17 00:00:00 2001 From: Togo01 Date: Thu, 6 Nov 2025 12:27:57 +0800 Subject: [PATCH 10/12] fix(terminal): use correct escape sequence for Shift+Enter newline Use escape sequence + newline (\u001b\n) instead of plain newline (\n) for Shift+Enter, following the terminal standard and matching VSCode's /term-setup implementation. This ensures proper multi-line input support in Claude Code and other AI coding tools without accidentally submitting incomplete commands. --- frontend/app/view/term/term-model.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index 976968cca..81545eb77 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -550,7 +550,7 @@ export class TermViewModel implements ViewModel { const shiftEnterNewlineAtom = getOverrideConfigAtom(this.blockId, "term:shiftenternewline"); const shiftEnterNewlineEnabled = globalStore.get(shiftEnterNewlineAtom) ?? true; if (shiftEnterNewlineEnabled) { - this.sendDataToController("\n"); + this.sendDataToController("\u001b\n"); event.preventDefault(); event.stopPropagation(); return false; From 1cf388f782b29446ea39e04c003cdbe6b1160ba4 Mon Sep 17 00:00:00 2001 From: Togo01 Date: Fri, 7 Nov 2025 04:01:19 +0800 Subject: [PATCH 11/12] feat(terminal): fix IME duplicate input on input method switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix duplicate text input when switching input methods via Capslock during Chinese/Japanese/Korean IME composition. ## Problem When using Chinese Zhuyin IME in terminal, switching to English mode with Capslock during composition causes the composed text to be sent twice, resulting in duplicate output. ## Solution ### IME Composition Handling - Add IME composition state tracking in TermWrap (isComposing, etc.) - Register compositionstart/update/end event listeners on xterm textarea - Block all data sends during composition, only allow after compositionend - Prevents xterm.js from sending data during compositionupdate phase ### Deduplication Logic - Implement 50ms time window deduplication mechanism - Track first send, block duplicate send from Capslock switching - Ensure Ctrl+Space and Fn switching work correctly (single send) ### Paste Deduplication - Fix duplicate paste issue caused by xterm.js paste() method - paste() triggers onData event, causing handleTermData to be called twice - Use pasteActive flag and time window to allow only first send ### Edge Cases - Add blur event handler to reset composition state on focus loss - Add Escape key handling in term-model.ts to cancel composition ## Test Results ✅ macOS Zhuyin + Capslock switching - no duplicate ✅ macOS Zhuyin + Ctrl+Space switching - normal input ✅ macOS Zhuyin + Fn switching - normal input ✅ English input - normal ✅ Shift+Enter multiline - normal ✅ Text paste - no duplicate ✅ Image paste - normal ✅ Basic commands - normal --- frontend/app/view/term/term-model.ts | 9 +++ frontend/app/view/term/termwrap.ts | 111 +++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index 81545eb77..cb03bfb25 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -541,6 +541,15 @@ export class TermViewModel implements ViewModel { if (waveEvent.type != "keydown") { return true; } + + // Handle Escape key during IME composition + if (keyutil.checkKeyPressed(waveEvent, "Escape")) { + if (this.termRef.current?.isComposing) { + // Reset composition state when Escape is pressed during composition + this.termRef.current.resetCompositionState(); + } + } + if (this.keyDownHandler(waveEvent)) { event.preventDefault(); event.stopPropagation(); diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index d5d17d419..ed1d0f71d 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -356,6 +356,20 @@ export class TermWrap { shellIntegrationStatusAtom: jotai.PrimitiveAtom<"ready" | "running-command" | null>; lastCommandAtom: jotai.PrimitiveAtom; + // IME composition state tracking + // Prevents duplicate input when switching input methods during composition (e.g., using Capslock) + // xterm.js sends data during compositionupdate AND after compositionend, causing duplicates + isComposing: boolean = false; + composingData: string = ""; + lastCompositionEnd: number = 0; + lastComposedText: string = ""; + firstDataAfterCompositionSent: boolean = false; + + // Paste deduplication + // xterm.js paste() method triggers onData event, which can cause duplicate sends + lastPasteData: string = ""; + lastPasteTime: number = 0; + constructor( blockId: string, connectElem: HTMLDivElement, @@ -499,6 +513,30 @@ export class TermWrap { }); } + resetCompositionState() { + this.isComposing = false; + this.composingData = ""; + } + + private handleCompositionStart = (e: CompositionEvent) => { + dlog("compositionstart", e.data); + this.isComposing = true; + this.composingData = ""; + }; + + private handleCompositionUpdate = (e: CompositionEvent) => { + dlog("compositionupdate", e.data); + this.composingData = e.data || ""; + }; + + private handleCompositionEnd = (e: CompositionEvent) => { + dlog("compositionend", e.data); + this.isComposing = false; + this.lastComposedText = e.data || ""; + this.lastCompositionEnd = Date.now(); + this.firstDataAfterCompositionSent = false; + }; + async initTerminal() { const copyOnSelectAtom = getSettingsKeyAtom("term:copyonselect"); this.toDispose.push(this.terminal.onData(this.handleTermData.bind(this))); @@ -519,6 +557,33 @@ export class TermWrap { if (this.onSearchResultsDidChange != null) { this.toDispose.push(this.searchAddon.onDidChangeResults(this.onSearchResultsDidChange.bind(this))); } + + // Register IME composition event listeners on the xterm.js textarea + const textareaElem = this.connectElem.querySelector("textarea"); + if (textareaElem) { + textareaElem.addEventListener("compositionstart", this.handleCompositionStart); + textareaElem.addEventListener("compositionupdate", this.handleCompositionUpdate); + textareaElem.addEventListener("compositionend", this.handleCompositionEnd); + + // Handle blur during composition - reset state to avoid stale data + const blurHandler = () => { + if (this.isComposing) { + dlog("Terminal lost focus during composition, resetting IME state"); + this.resetCompositionState(); + } + }; + textareaElem.addEventListener("blur", blurHandler); + + this.toDispose.push({ + dispose: () => { + textareaElem.removeEventListener("compositionstart", this.handleCompositionStart); + textareaElem.removeEventListener("compositionupdate", this.handleCompositionUpdate); + textareaElem.removeEventListener("compositionend", this.handleCompositionEnd); + textareaElem.removeEventListener("blur", blurHandler); + }, + }); + } + this.mainFileSubject = getFileSubject(this.blockId, TermFileName); this.mainFileSubject.subscribe(this.handleNewFileSubjectData.bind(this)); @@ -568,12 +633,58 @@ export class TermWrap { if (!this.loaded) { return; } + + // IME Composition Handling + // Block all data during composition - only send the final text after compositionend + // This prevents xterm.js from sending intermediate composition data (e.g., during compositionupdate) + if (this.isComposing) { + dlog("Blocked data during composition:", data); + return; + } + + // Paste Deduplication + // xterm.js paste() method triggers onData event, causing handleTermData to be called twice: + // 1. From our paste handler (pasteActive=true) + // 2. From xterm.js onData (pasteActive=false) + // We allow the first call and block the second duplicate + const DEDUP_WINDOW_MS = 50; + const now = Date.now(); + const timeSinceLastPaste = now - this.lastPasteTime; + if (this.pasteActive) { + // First paste event - record it and allow through this.pasteActive = false; + this.lastPasteData = data; + this.lastPasteTime = now; if (this.multiInputCallback) { this.multiInputCallback(data); } + } else if (timeSinceLastPaste < DEDUP_WINDOW_MS && data === this.lastPasteData && this.lastPasteData) { + // Second paste event with same data within time window - this is a duplicate, block it + dlog("Blocked duplicate paste data:", data); + this.lastPasteData = ""; // Clear to allow same data to be pasted later + return; + } + + // IME Deduplication (for Capslock input method switching) + // When switching input methods with Capslock during composition, some systems send the + // composed text twice. We allow the first send and block subsequent duplicates. + const timeSinceCompositionEnd = now - this.lastCompositionEnd; + + if (timeSinceCompositionEnd < DEDUP_WINDOW_MS && data === this.lastComposedText && this.lastComposedText) { + if (!this.firstDataAfterCompositionSent) { + // First send after composition - allow it but mark as sent + this.firstDataAfterCompositionSent = true; + dlog("First data after composition, allowing:", data); + } else { + // Second send of the same data - this is a duplicate from Capslock switching, block it + dlog("Blocked duplicate IME data:", data); + this.lastComposedText = ""; // Clear to allow same text to be typed again later + this.firstDataAfterCompositionSent = false; + return; + } } + this.sendDataHandler?.(data); } From 80403e1d17445d20bd37040befc554b2e5721732 Mon Sep 17 00:00:00 2001 From: Togo01 Date: Fri, 7 Nov 2025 15:17:59 +0800 Subject: [PATCH 12/12] fix(security): prevent path traversal in GetTempDirCommand Fix critical path traversal vulnerability where attackers could use ../ sequences or absolute paths to access files outside the temp directory. Now sanitizes input by extracting only the base filename and rejecting traversal attempts. --- pkg/wshrpc/wshserver/wshserver.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 34204e5c5..2f0f50a6b 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -448,7 +448,14 @@ func (ws *WshServer) FileRestoreBackupCommand(ctx context.Context, data wshrpc.C func (ws *WshServer) GetTempDirCommand(ctx context.Context, data wshrpc.CommandGetTempDirData) (string, error) { tempDir := os.TempDir() if data.FileName != "" { - return filepath.Join(tempDir, data.FileName), nil + // Reduce to a simple file name to avoid absolute paths or traversal + name := filepath.Base(data.FileName) + // Normalize/trim any stray separators and whitespace + name = strings.Trim(name, `/\`+" ") + if name == "" || name == "." { + return tempDir, nil + } + return filepath.Join(tempDir, name), nil } return tempDir, nil }