diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 5b297ad44..8171f727a 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -327,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 83575261e..cb03bfb25 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -29,11 +29,18 @@ 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, + handleImagePasteBlob as handleImagePasteBlobUtil, + supportsImageInput as supportsImageInputUtil, +} from "./termutil"; import { TermWrap } from "./termwrap"; import { getBlockingCommand } from "./shellblocking"; export class TermViewModel implements ViewModel { + viewType: string; nodeModel: BlockNodeModel; connected: boolean; @@ -391,6 +398,51 @@ export class TermViewModel implements ViewModel { RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, inputdata64: b64data }); } + async handlePaste() { + try { + const clipboardItems = await navigator.clipboard.read(); + + for (const item of clipboardItems) { + // Check for images first + const imageTypes = item.types.filter((type) => type.startsWith("image/")); + if (imageTypes.length > 0 && this.supportsImageInput()) { + 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(); + 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 { + return supportsImageInputUtil(); + } + + async handleImagePasteBlob(blob: Blob): Promise { + await handleImagePasteBlobUtil(blob, TabRpcClient, (text) => { + this.termRef.current?.terminal.paste(text); + }); + } + setTermMode(mode: "term" | "vdom") { if (mode == "term") { mode = null; @@ -489,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(); @@ -496,7 +557,7 @@ 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"); event.preventDefault(); @@ -505,10 +566,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/termutil.ts b/frontend/app/view/term/termutil.ts index 4874d7903..a57c81bae 100644 --- a/frontend/app/view/term/termutil.ts +++ b/frontend/app/view/term/termutil.ts @@ -34,3 +34,101 @@ 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; +} + +/** + * 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 7f15cbcb6..ed1d0f71d 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -18,6 +18,11 @@ import { Terminal } from "@xterm/xterm"; import debug from "debug"; import * as jotai from "jotai"; import { debounce } from "throttle-debounce"; +import { + createTempFileFromBlob, + handleImagePasteBlob as handleImagePasteBlobUtil, + supportsImageInput as supportsImageInputUtil, +} from "./termutil"; import { FitAddon } from "./fitaddon"; const dlog = debug("wave:termwrap"); @@ -351,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, @@ -422,11 +441,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); @@ -437,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))); @@ -457,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)); @@ -506,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); } @@ -608,6 +781,26 @@ export class TermWrap { } } + supportsImageInput(): boolean { + return supportsImageInputUtil(); + } + + async handleImagePasteBlob(blob: Blob): Promise { + await handleImagePasteBlobUtil(blob, TabRpcClient, (text) => { + this.terminal.paste(text); + }); + } + + async handleImagePaste(item: ClipboardItem, mimeType: string): Promise { + try { + const blob = await item.getType(mimeType); + // Reuse the existing handleImagePasteBlob logic + await this.handleImagePasteBlob(blob); + } catch (err) { + console.error("Error processing image:", err); + } + } + handleResize() { const oldRows = this.terminal.rows; const oldCols = this.terminal.cols; 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 2505f2c19..883760c8c 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -398,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 c5d96088e..a639bf0f0 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, 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 @@ -622,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 df7f7a29c..2f0f50a6b 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -445,6 +445,21 @@ func (ws *WshServer) FileRestoreBackupCommand(ctx context.Context, data wshrpc.C return filebackup.RestoreBackup(data.BackupFilePath, data.RestoreToFileName) } +func (ws *WshServer) GetTempDirCommand(ctx context.Context, data wshrpc.CommandGetTempDirData) (string, error) { + tempDir := os.TempDir() + if data.FileName != "" { + // 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 +} + func (ws *WshServer) DeleteSubBlockCommand(ctx context.Context, data wshrpc.CommandDeleteBlockData) error { err := wcore.DeleteBlock(ctx, data.BlockId, false) if err != nil {