From 5607f737569562b024c11f678d85bdf22af40b92 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 7 Nov 2025 14:22:51 -0800 Subject: [PATCH 1/8] make writetempfile command, use that instead. cleanup base64 encoding flow etc use \n instead of ESC-\n for shift+enter... --- frontend/app/store/wshclientapi.ts | 5 +++++ frontend/app/view/term/term-model.ts | 7 ++---- frontend/app/view/term/termutil.ts | 33 ++++++++++------------------ frontend/types/gotypes.d.ts | 6 +++++ pkg/wshrpc/wshclient/wshclient.go | 6 +++++ pkg/wshrpc/wshrpctypes.go | 7 ++++++ pkg/wshrpc/wshserver/wshserver.go | 24 ++++++++++++++++++++ 7 files changed, 61 insertions(+), 27 deletions(-) diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 8171f727a6..ee7d5a87e0 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -612,6 +612,11 @@ class RpcApiType { return client.wshRpcCall("writeappfile", data, opts); } + // command "writetempfile" [call] + WriteTempFileCommand(client: WshClient, data: CommandWriteTempFileData, opts?: RpcOpts): Promise { + return client.wshRpcCall("writetempfile", data, opts); + } + // command "wshactivity" [call] WshActivityCommand(client: WshClient, data: {[key: string]: number}, opts?: RpcOpts): Promise { return client.wshRpcCall("wshactivity", data, opts); diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index cb03bfb259..9987366566 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -1,7 +1,6 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 - import { BlockNodeModel } from "@/app/block/blocktypes"; import { appHandleKeyDown } from "@/app/store/keymodel"; import { waveEventSubscribe } from "@/app/store/wps"; @@ -29,18 +28,16 @@ import * as keyutil from "@/util/keyutil"; import { boundNumber, stringToBase64 } from "@/util/util"; import * as jotai from "jotai"; import * as React from "react"; +import { getBlockingCommand } from "./shellblocking"; 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; @@ -559,7 +556,7 @@ export class TermViewModel implements ViewModel { const shiftEnterNewlineAtom = getOverrideConfigAtom(this.blockId, "term:shiftenternewline"); const shiftEnterNewlineEnabled = globalStore.get(shiftEnterNewlineAtom) ?? true; if (shiftEnterNewlineEnabled) { - this.sendDataToController("\u001b\n"); + this.sendDataToController("\n"); event.preventDefault(); event.stopPropagation(); return false; diff --git a/frontend/app/view/term/termutil.ts b/frontend/app/view/term/termutil.ts index a57c81baed..70d08818d5 100644 --- a/frontend/app/view/term/termutil.ts +++ b/frontend/app/view/term/termutil.ts @@ -2,6 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 export const DefaultTermTheme = "default-dark"; +import { WshClient } from "@/app/store/wshclient"; +import { RpcApi } from "@/app/store/wshclientapi"; +import base64 from "base64-js"; import { colord } from "colord"; function applyTransparencyToColor(hexColor: string, transparency: number): string { @@ -10,7 +13,7 @@ function applyTransparencyToColor(hexColor: string, transparency: number): strin } // returns (theme, bgcolor, transparency (0 - 1.0)) -function computeTheme( +export function computeTheme( fullConfig: FullConfigType, themeName: string, termTransparency: number @@ -33,11 +36,6 @@ function computeTheme( return [themeCopy, bgcolor]; } -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", @@ -73,27 +71,18 @@ export async function createTempFileFromBlob(blob: Blob, client: WshClient): Pro 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 arrayBuffer = await new Promise((resolve, reject) => { const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); + reader.onload = () => resolve(reader.result as ArrayBuffer); reader.onerror = reject; - reader.readAsDataURL(blob); + reader.readAsArrayBuffer(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]; + const base64Data = base64.fromByteArray(new Uint8Array(arrayBuffer)); - // Write image to temp file - await RpcApi.FileWriteCommand(client, { - info: { path: tempPath }, + // Write image to temp file and get path + const tempPath = await RpcApi.WriteTempFileCommand(client, { + filename, data64: base64Data, }); diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 5b751a68ce..4d72546820 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -463,6 +463,12 @@ declare global { data64: string; }; + // wshrpc.CommandWriteTempFileData + type CommandWriteTempFileData = { + filename?: string; + data64: string; + }; + // wconfig.ConfigError type ConfigError = { file: string; diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 883760c8c5..18485f525e 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -731,6 +731,12 @@ func WriteAppFileCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteAppFileData, return err } +// command "writetempfile", wshserver.WriteTempFileCommand +func WriteTempFileCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteTempFileData, opts *wshrpc.RpcOpts) (string, error) { + resp, err := sendRpcRequestCallHelper[string](w, "writetempfile", data, opts) + return resp, err +} + // command "wshactivity", wshserver.WshActivityCommand func WshActivityCommand(w *wshutil.WshRpc, data map[string]int, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "wshactivity", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index a639bf0f0c..1cb21472d3 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -84,6 +84,7 @@ const ( Command_FileShareCapability = "filesharecapability" Command_FileRestoreBackup = "filerestorebackup" Command_GetTempDir = "gettempdir" + Command_WriteTempFile = "writetempfile" Command_EventPublish = "eventpublish" Command_EventRecv = "eventrecv" @@ -224,6 +225,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) + WriteTempFileCommand(ctx context.Context, data CommandWriteTempFileData) (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 @@ -628,6 +630,11 @@ type CommandGetTempDirData struct { FileName string `json:"filename,omitempty"` } +type CommandWriteTempFileData struct { + FileName string `json:"filename,omitempty"` + Data64 string `json:"data64"` +} + 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 6e2c32a576..46d38f8266 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -468,6 +468,30 @@ func (ws *WshServer) GetTempDirCommand(ctx context.Context, data wshrpc.CommandG return tempDir, nil } +func (ws *WshServer) WriteTempFileCommand(ctx context.Context, data wshrpc.CommandWriteTempFileData) (string, error) { + if data.FileName == "" { + return "", fmt.Errorf("filename is required") + } + name := filepath.Base(data.FileName) + if name == "" || name == "." || name == ".." { + return "", fmt.Errorf("invalid filename") + } + tempDir, err := os.MkdirTemp("", "waveterm-") + if err != nil { + return "", fmt.Errorf("error creating temp directory: %w", err) + } + decoded, err := base64.StdEncoding.DecodeString(data.Data64) + if err != nil { + return "", fmt.Errorf("error decoding base64 data: %w", err) + } + tempPath := filepath.Join(tempDir, name) + err = os.WriteFile(tempPath, decoded, 0600) + if err != nil { + return "", fmt.Errorf("error writing temp file: %w", err) + } + return tempPath, nil +} + func (ws *WshServer) DeleteSubBlockCommand(ctx context.Context, data wshrpc.CommandDeleteBlockData) error { err := wcore.DeleteBlock(ctx, data.BlockId, false) if err != nil { From 7f5b01ba1c913ee5f5e498c61420bf5776be6c1c Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 7 Nov 2025 14:35:46 -0800 Subject: [PATCH 2/8] cleanup, dont pass client --- frontend/app/view/term/term-model.ts | 7 +++++-- frontend/app/view/term/termutil.ts | 16 +++++----------- frontend/app/view/term/termwrap.ts | 5 ++--- 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index 9987366566..39e6f9b816 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -397,6 +397,7 @@ export class TermViewModel implements ViewModel { async handlePaste() { try { + // this can fail under weird circumstances (e.g. no user-gesture, which is why we have fallback to readText() which generally works everywhere) const clipboardItems = await navigator.clipboard.read(); for (const item of clipboardItems) { @@ -412,7 +413,9 @@ export class TermViewModel implements ViewModel { if (item.types.includes("text/plain")) { const blob = await item.getType("text/plain"); const text = await blob.text(); - this.termRef.current?.terminal.paste(text); + if (text) { + this.termRef.current?.terminal.paste(text); + } return; } } @@ -435,7 +438,7 @@ export class TermViewModel implements ViewModel { } async handleImagePasteBlob(blob: Blob): Promise { - await handleImagePasteBlobUtil(blob, TabRpcClient, (text) => { + await handleImagePasteBlobUtil(blob, (text) => { this.termRef.current?.terminal.paste(text); }); } diff --git a/frontend/app/view/term/termutil.ts b/frontend/app/view/term/termutil.ts index 70d08818d5..8eb7d0a3b6 100644 --- a/frontend/app/view/term/termutil.ts +++ b/frontend/app/view/term/termutil.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 export const DefaultTermTheme = "default-dark"; -import { WshClient } from "@/app/store/wshclient"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; import { RpcApi } from "@/app/store/wshclientapi"; import base64 from "base64-js"; import { colord } from "colord"; @@ -53,11 +53,10 @@ export const MIME_TO_EXT: Record = { * 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 { +export async function createTempFileFromBlob(blob: Blob): Promise { // Check size limit (5MB) if (blob.size > 5 * 1024 * 1024) { throw new Error("Image too large (>5MB)"); @@ -81,7 +80,7 @@ export async function createTempFileFromBlob(blob: Blob, client: WshClient): Pro const base64Data = base64.fromByteArray(new Uint8Array(arrayBuffer)); // Write image to temp file and get path - const tempPath = await RpcApi.WriteTempFileCommand(client, { + const tempPath = await RpcApi.WriteTempFileCommand(TabRpcClient, { filename, data64: base64Data, }); @@ -104,16 +103,11 @@ export function supportsImageInput(): boolean { * 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 { +export async function handleImagePasteBlob(blob: Blob, pasteFn: (text: string) => void): Promise { try { - const tempPath = await createTempFileFromBlob(blob, client); + const tempPath = await createTempFileFromBlob(blob); // 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 + " "); diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index ed1d0f71d2..be726f2a0e 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -18,12 +18,11 @@ import { Terminal } from "@xterm/xterm"; import debug from "debug"; import * as jotai from "jotai"; import { debounce } from "throttle-debounce"; +import { FitAddon } from "./fitaddon"; import { - createTempFileFromBlob, handleImagePasteBlob as handleImagePasteBlobUtil, supportsImageInput as supportsImageInputUtil, } from "./termutil"; -import { FitAddon } from "./fitaddon"; const dlog = debug("wave:termwrap"); @@ -786,7 +785,7 @@ export class TermWrap { } async handleImagePasteBlob(blob: Blob): Promise { - await handleImagePasteBlobUtil(blob, TabRpcClient, (text) => { + await handleImagePasteBlobUtil(blob, (text) => { this.terminal.paste(text); }); } From cb8700a5072cf1ca096497e748420fe8b1c7f41e Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 7 Nov 2025 14:54:55 -0800 Subject: [PATCH 3/8] more cleanup, remove unnecessary util funcs --- frontend/app/view/term/term-model.ts | 25 ++++------------- frontend/app/view/term/termutil.ts | 40 +++++++--------------------- frontend/app/view/term/termwrap.ts | 23 +++++++--------- 3 files changed, 25 insertions(+), 63 deletions(-) diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index 39e6f9b816..d13b0fb7ee 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -29,19 +29,14 @@ import { boundNumber, stringToBase64 } from "@/util/util"; import * as jotai from "jotai"; import * as React from "react"; import { getBlockingCommand } from "./shellblocking"; -import { - computeTheme, - DefaultTermTheme, - handleImagePasteBlob as handleImagePasteBlobUtil, - supportsImageInput as supportsImageInputUtil, -} from "./termutil"; -import { TermWrap } from "./termwrap"; +import { computeTheme, DefaultTermTheme } from "./termutil"; +import { SupportsImageInput, TermWrap } from "./termwrap"; export class TermViewModel implements ViewModel { viewType: string; nodeModel: BlockNodeModel; connected: boolean; - termRef: React.MutableRefObject = { current: null }; + termRef: React.RefObject = { current: null }; blockAtom: jotai.Atom; termMode: jotai.Atom; blockId: string; @@ -403,9 +398,9 @@ export class TermViewModel implements ViewModel { for (const item of clipboardItems) { // Check for images first const imageTypes = item.types.filter((type) => type.startsWith("image/")); - if (imageTypes.length > 0 && this.supportsImageInput()) { + if (imageTypes.length > 0 && SupportsImageInput) { const blob = await item.getType(imageTypes[0]); - await this.handleImagePasteBlob(blob); + await this.termRef.current?.handleImagePasteBlob(blob); return; } @@ -433,16 +428,6 @@ export class TermViewModel implements ViewModel { } } - supportsImageInput(): boolean { - return supportsImageInputUtil(); - } - - async handleImagePasteBlob(blob: Blob): Promise { - await handleImagePasteBlobUtil(blob, (text) => { - this.termRef.current?.terminal.paste(text); - }); - } - setTermMode(mode: "term" | "vdom") { if (mode == "term") { mode = null; diff --git a/frontend/app/view/term/termutil.ts b/frontend/app/view/term/termutil.ts index 8eb7d0a3b6..ca7d6e30b7 100644 --- a/frontend/app/view/term/termutil.ts +++ b/frontend/app/view/term/termutil.ts @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 export const DefaultTermTheme = "default-dark"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; import base64 from "base64-js"; import { colord } from "colord"; @@ -45,6 +45,11 @@ export const MIME_TO_EXT: Record = { "image/bmp": "bmp", "image/svg+xml": "svg", "image/tiff": "tiff", + "image/heic": "heic", + "image/heif": "heif", + "image/avif": "avif", + "image/x-icon": "ico", + "image/vnd.microsoft.icon": "ico", }; /** @@ -63,7 +68,10 @@ export async function createTempFileFromBlob(blob: Blob): Promise { } // Get file extension from MIME type - const ext = MIME_TO_EXT[blob.type] || "png"; + if (!blob.type.startsWith("image/") || !MIME_TO_EXT[blob.type]) { + throw new Error(`Unsupported or invalid image type: ${blob.type}`); + } + const ext = MIME_TO_EXT[blob.type]; // Generate unique filename with timestamp and random component const timestamp = Date.now(); @@ -87,31 +95,3 @@ export async function createTempFileFromBlob(blob: Blob): Promise { 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 pasteFn - Function to paste the file path into the terminal - */ -export async function handleImagePasteBlob(blob: Blob, pasteFn: (text: string) => void): Promise { - try { - const tempPath = await createTempFileFromBlob(blob); - // 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 be726f2a0e..e162ff3ba7 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -19,16 +19,14 @@ import debug from "debug"; import * as jotai from "jotai"; import { debounce } from "throttle-debounce"; import { FitAddon } from "./fitaddon"; -import { - handleImagePasteBlob as handleImagePasteBlobUtil, - supportsImageInput as supportsImageInputUtil, -} from "./termutil"; +import { createTempFileFromBlob } from "./termutil"; const dlog = debug("wave:termwrap"); const TermFileName = "term"; const TermCacheFileName = "cache:term:full"; const MinDataProcessedForCache = 100 * 1024; +export const SupportsImageInput = true; // detect webgl support function detectWebGLSupport(): boolean { @@ -453,7 +451,7 @@ export class TermWrap { const item = items[i]; if (item.type.startsWith("image/")) { - if (this.supportsImageInput()) { + if (SupportsImageInput) { e.preventDefault(); const blob = item.getAsFile(); if (blob) { @@ -476,7 +474,7 @@ export class TermWrap { 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()) { + if (imageTypes.length > 0 && SupportsImageInput) { await this.handleImagePaste(item, imageTypes[0]); return; } @@ -780,14 +778,13 @@ export class TermWrap { } } - supportsImageInput(): boolean { - return supportsImageInputUtil(); - } - async handleImagePasteBlob(blob: Blob): Promise { - await handleImagePasteBlobUtil(blob, (text) => { - this.terminal.paste(text); - }); + try { + const tempPath = await createTempFileFromBlob(blob); + this.terminal.paste(tempPath + " "); + } catch (err) { + console.error("Error pasting image:", err); + } } async handleImagePaste(item: ClipboardItem, mimeType: string): Promise { From d492eec92f4f13299c1a60ac50e4d85e482b8a28 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 7 Nov 2025 16:16:41 -0800 Subject: [PATCH 4/8] fix pasting to be more consistent and support multiple images... --- frontend/app/view/term/term-model.ts | 45 +--------- frontend/app/view/term/termutil.ts | 99 ++++++++++++++++++++++ frontend/app/view/term/termwrap.ts | 120 ++++++++------------------- 3 files changed, 137 insertions(+), 127 deletions(-) diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index d13b0fb7ee..cc046b44db 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -30,7 +30,7 @@ import * as jotai from "jotai"; import * as React from "react"; import { getBlockingCommand } from "./shellblocking"; import { computeTheme, DefaultTermTheme } from "./termutil"; -import { SupportsImageInput, TermWrap } from "./termwrap"; +import { TermWrap } from "./termwrap"; export class TermViewModel implements ViewModel { viewType: string; @@ -390,43 +390,6 @@ export class TermViewModel implements ViewModel { RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, inputdata64: b64data }); } - async handlePaste() { - try { - // this can fail under weird circumstances (e.g. no user-gesture, which is why we have fallback to readText() which generally works everywhere) - 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 && SupportsImageInput) { - const blob = await item.getType(imageTypes[0]); - await this.termRef.current?.handleImagePasteBlob(blob); - return; - } - - // Handle text - if (item.types.includes("text/plain")) { - const blob = await item.getType("text/plain"); - const text = await blob.text(); - if (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); - } - } - } setTermMode(mode: "term" | "vdom") { if (mode == "term") { @@ -551,15 +514,15 @@ export class TermViewModel implements ViewModel { } } if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:v")) { - this.handlePaste(); event.preventDefault(); event.stopPropagation(); + this.termRef.current?.pasteHandler(); return false; } else if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:c")) { - const sel = this.termRef.current?.terminal.getSelection(); - navigator.clipboard.writeText(sel); event.preventDefault(); event.stopPropagation(); + const sel = this.termRef.current?.terminal.getSelection(); + navigator.clipboard.writeText(sel); return false; } else if (keyutil.checkKeyPressed(waveEvent, "Cmd:k")) { event.preventDefault(); diff --git a/frontend/app/view/term/termutil.ts b/frontend/app/view/term/termutil.ts index ca7d6e30b7..3f82b14ba2 100644 --- a/frontend/app/view/term/termutil.ts +++ b/frontend/app/view/term/termutil.ts @@ -95,3 +95,102 @@ export async function createTempFileFromBlob(blob: Blob): Promise { return tempPath; } + +/** + * Extracts text or image data from a clipboard item. + * Prioritizes images over text - if an image is found, only the image is returned. + * + * @param item - Either a DataTransferItem or ClipboardItem + * @returns Object with either text or image, or null if neither could be extracted + */ +export async function extractClipboardData( + item: DataTransferItem | ClipboardItem +): Promise<{ text?: string; image?: Blob } | null> { + // Check if it's a DataTransferItem (has 'kind' property) + if ("kind" in item) { + const dataTransferItem = item as DataTransferItem; + + // Check for image first + if (dataTransferItem.type.startsWith("image/")) { + const blob = dataTransferItem.getAsFile(); + if (blob) { + return { image: blob }; + } + } + + // If not an image, try text + if (dataTransferItem.kind === "string") { + return new Promise((resolve) => { + dataTransferItem.getAsString((text) => { + resolve(text ? { text } : null); + }); + }); + } + + return null; + } + + // It's a ClipboardItem + const clipboardItem = item as ClipboardItem; + + // Check for image first + const imageTypes = clipboardItem.types.filter((type) => type.startsWith("image/")); + if (imageTypes.length > 0) { + const blob = await clipboardItem.getType(imageTypes[0]); + return { image: blob }; + } + + // If not an image, try text + const textType = clipboardItem.types.find((t) => ["text/plain", "text/html", "text/rtf"].includes(t)); + if (textType) { + const blob = await clipboardItem.getType(textType); + const text = await blob.text(); + return text ? { text } : null; + } + + return null; +} + +/** + * Extracts all clipboard data from a ClipboardEvent using multiple fallback methods. + * Tries ClipboardEvent.clipboardData.items first, then Clipboard API, then simple getData(). + * + * @param e - The ClipboardEvent (optional) + * @returns Array of objects containing text and/or image data + */ +export async function extractAllClipboardData(e?: ClipboardEvent): Promise> { + const results: Array<{ text?: string; image?: Blob }> = []; + + try { + // First try using ClipboardEvent.clipboardData.items + if (e?.clipboardData?.items) { + for (let i = 0; i < e.clipboardData.items.length; i++) { + const data = await extractClipboardData(e.clipboardData.items[i]); + if (data) { + results.push(data); + } + } + return results; + } + + // Fallback: Try Clipboard API + const clipboardItems = await navigator.clipboard.read(); + for (const item of clipboardItems) { + const data = await extractClipboardData(item); + if (data) { + results.push(data); + } + } + return results; + } catch (err) { + console.error("Clipboard read error:", err); + // Final fallback: simple text paste + if (e?.clipboardData) { + const text = e.clipboardData.getData("text/plain"); + if (text) { + results.push({ text }); + } + } + return results; + } +} diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index e162ff3ba7..ac152c4c6e 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -19,7 +19,7 @@ import debug from "debug"; import * as jotai from "jotai"; import { debounce } from "throttle-debounce"; import { FitAddon } from "./fitaddon"; -import { createTempFileFromBlob } from "./termutil"; +import { createTempFileFromBlob, extractAllClipboardData } from "./termutil"; const dlog = debug("wave:termwrap"); @@ -438,74 +438,11 @@ export class TermWrap { this.handleResize_debounced = debounce(50, this.handleResize.bind(this)); this.terminal.open(this.connectElem); this.handleResize(); - let pasteEventHandler = async (e: ClipboardEvent) => { - this.pasteActive = true; - - 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 (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 && 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); + const pasteHandler = this.pasteHandler.bind(this); + this.connectElem.addEventListener("paste", pasteHandler, true); this.toDispose.push({ dispose: () => { - this.connectElem.removeEventListener("paste", pasteEventHandler, true); + this.connectElem.removeEventListener("paste", pasteHandler, true); }, }); } @@ -778,25 +715,6 @@ export class TermWrap { } } - async handleImagePasteBlob(blob: Blob): Promise { - try { - const tempPath = await createTempFileFromBlob(blob); - 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); - // 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; @@ -838,4 +756,34 @@ export class TermWrap { }); }, 5000); } + + async pasteHandler(e?: ClipboardEvent): Promise { + this.pasteActive = true; + e?.preventDefault(); + e?.stopPropagation(); + + try { + const clipboardData = await extractAllClipboardData(e); + let firstImage = true; + for (const data of clipboardData) { + if (data.image && SupportsImageInput) { + if (!firstImage) { + await new Promise((r) => setTimeout(r, 150)); + } + const tempPath = await createTempFileFromBlob(data.image); + this.terminal.paste(tempPath + " "); + firstImage = false; + } + if (data.text) { + this.terminal.paste(data.text); + } + } + } catch (err) { + console.error("Paste error:", err); + } finally { + setTimeout(() => { + this.pasteActive = false; + }, 30); + } + } } From 71beb06b9122a79d9f5e4707ae91572377a73014 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 7 Nov 2025 16:33:59 -0800 Subject: [PATCH 5/8] implement a native paste for Ctrl:Shift:v which actually lets images go through! --- emain/emain-ipc.ts | 4 ++++ emain/preload.ts | 1 + frontend/app/view/term/term-model.ts | 5 +++-- frontend/app/view/term/termwrap.ts | 10 +--------- frontend/types/custom.d.ts | 1 + 5 files changed, 10 insertions(+), 11 deletions(-) diff --git a/emain/emain-ipc.ts b/emain/emain-ipc.ts index 88e4e24b32..7ae95ef6c7 100644 --- a/emain/emain-ipc.ts +++ b/emain/emain-ipc.ts @@ -400,6 +400,10 @@ export function initIpcHandlers() { incrementTermCommandsRun(); }); + electron.ipcMain.on("native-paste", (event) => { + event.sender.paste(); + }); + electron.ipcMain.on("open-builder", (event, appId?: string) => { fireAndForget(() => createBuilderWindow(appId || "")); }); diff --git a/emain/preload.ts b/emain/preload.ts index 28e1b17808..d511b28403 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -62,6 +62,7 @@ contextBridge.exposeInMainWorld("api", { setWaveAIOpen: (isOpen: boolean) => ipcRenderer.send("set-waveai-open", isOpen), closeBuilderWindow: () => ipcRenderer.send("close-builder-window"), incrementTermCommands: () => ipcRenderer.send("increment-term-commands"), + nativePaste: () => ipcRenderer.send("native-paste"), }); // Custom event for "new-window" diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index cc046b44db..4c9b19fac2 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -14,6 +14,7 @@ import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { atoms, getAllBlockComponentModels, + getApi, getBlockComponentModel, getBlockMetaKeyAtom, getConnStatusAtom, @@ -390,7 +391,6 @@ export class TermViewModel implements ViewModel { RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, inputdata64: b64data }); } - setTermMode(mode: "term" | "vdom") { if (mode == "term") { mode = null; @@ -516,7 +516,8 @@ export class TermViewModel implements ViewModel { if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:v")) { event.preventDefault(); event.stopPropagation(); - this.termRef.current?.pasteHandler(); + getApi().nativePaste(); + // this.termRef.current?.pasteHandler(); return false; } else if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:c")) { event.preventDefault(); diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index ac152c4c6e..723f10874a 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -586,18 +586,9 @@ export class TermWrap { 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) @@ -764,6 +755,7 @@ export class TermWrap { try { const clipboardData = await extractAllClipboardData(e); + console.log("CLIPBOARD DATA", clipboardData); let firstImage = true; for (const data of clipboardData) { if (data.image && SupportsImageInput) { diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index a5e24baae9..d66b6622e9 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -123,6 +123,7 @@ declare global { setWaveAIOpen: (isOpen: boolean) => void; // set-waveai-open closeBuilderWindow: () => void; // close-builder-window incrementTermCommands: () => void; // increment-term-commands + nativePaste: () => void; // native-paste }; type ElectronContextMenuItem = { From 68c6c2cbe4339ccad66c45f0a51b8b650460b3a2 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 7 Nov 2025 16:37:28 -0800 Subject: [PATCH 6/8] final touches --- frontend/app/view/term/termwrap.ts | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 723f10874a..dc150711e0 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -576,15 +576,6 @@ export class TermWrap { 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) { if (this.multiInputCallback) { this.multiInputCallback(data); @@ -594,9 +585,10 @@ export class TermWrap { // 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 IMEDedupWindowMs = 50; + const now = Date.now(); const timeSinceCompositionEnd = now - this.lastCompositionEnd; - - if (timeSinceCompositionEnd < DEDUP_WINDOW_MS && data === this.lastComposedText && this.lastComposedText) { + if (timeSinceCompositionEnd < IMEDedupWindowMs && data === this.lastComposedText && this.lastComposedText) { if (!this.firstDataAfterCompositionSent) { // First send after composition - allow it but mark as sent this.firstDataAfterCompositionSent = true; @@ -755,7 +747,6 @@ export class TermWrap { try { const clipboardData = await extractAllClipboardData(e); - console.log("CLIPBOARD DATA", clipboardData); let firstImage = true; for (const data of clipboardData) { if (data.image && SupportsImageInput) { From 91237ea5688fe9c2b1863ef9a87deda0ccf3b9a2 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 7 Nov 2025 18:12:58 -0800 Subject: [PATCH 7/8] small updates --- frontend/app/view/term/term-model.ts | 3 +++ frontend/types/gotypes.d.ts | 2 +- pkg/wshrpc/wshrpctypes.go | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index 4c9b19fac2..0a5c39ad86 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -523,6 +523,9 @@ export class TermViewModel implements ViewModel { event.preventDefault(); event.stopPropagation(); const sel = this.termRef.current?.terminal.getSelection(); + if (!sel) { + return false; + } navigator.clipboard.writeText(sel); return false; } else if (keyutil.checkKeyPressed(waveEvent, "Cmd:k")) { diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 4d72546820..bed436226c 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -465,7 +465,7 @@ declare global { // wshrpc.CommandWriteTempFileData type CommandWriteTempFileData = { - filename?: string; + filename: string; data64: string; }; diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 1cb21472d3..6666785ea6 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -631,7 +631,7 @@ type CommandGetTempDirData struct { } type CommandWriteTempFileData struct { - FileName string `json:"filename,omitempty"` + FileName string `json:"filename"` Data64 string `json:"data64"` } From 6cb1da33901a85d7da5e04be797209d7da3d8c5d Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 7 Nov 2025 18:15:19 -0800 Subject: [PATCH 8/8] change MutableRefObject => RefObject (react deprecations) --- aiprompts/view-prompt.md | 10 ++-------- frontend/app/element/flyoutmenu.tsx | 2 +- frontend/app/element/input.tsx | 2 +- frontend/app/modals/typeaheadmodal.tsx | 2 +- frontend/app/view/preview/csvview.tsx | 2 +- frontend/app/view/preview/preview-model.tsx | 4 ++-- frontend/types/custom.d.ts | 4 ++-- 7 files changed, 10 insertions(+), 16 deletions(-) diff --git a/aiprompts/view-prompt.md b/aiprompts/view-prompt.md index 4a7f2ad5a5..b88ba17bff 100644 --- a/aiprompts/view-prompt.md +++ b/aiprompts/view-prompt.md @@ -7,7 +7,6 @@ Wave Terminal uses a modular ViewModel system to define interactive blocks. Each ### Key Concepts 1. **ViewModel Structure** - - Implements the `ViewModel` interface. - Defines: - `viewType`: Unique block type identifier. @@ -19,14 +18,12 @@ Wave Terminal uses a modular ViewModel system to define interactive blocks. Each - Lifecycle methods like `dispose()`, `giveFocus()`, `keyDownHandler()`. 2. **ViewComponent Structure** - - A **React function component** implementing `ViewComponentProps`. - Uses `blockId`, `blockRef`, `contentRef`, and `model` as props. - Retrieves ViewModel state using Jotai atoms. - Returns JSX for rendering. 3. **Header Elements (`HeaderElem[]`)** - - Can include: - **Icons (`IconButtonDecl`)**: Clickable buttons. - **Text (`HeaderText`)**: Metadata or status. @@ -34,13 +31,11 @@ Wave Terminal uses a modular ViewModel system to define interactive blocks. Each - **Menu Buttons (`MenuButton`)**: Dropdowns. 4. **Jotai Atoms for State Management** - - Use `atom`, `PrimitiveAtom`, `WritableAtom` for dynamic properties. - `splitAtom` for managing lists of atoms. - Read settings from `globalStore` and override with block metadata. 5. **Metadata vs. Global Config** - - **Block Metadata (`SetMetaCommand`)**: Each block persists its **own configuration** in its metadata (`blockAtom.meta`). - **Global Config (`SetConfigCommand`)**: Provides **default settings** for all blocks, stored in config files. - **Cascading Behavior**: @@ -50,7 +45,6 @@ Wave Terminal uses a modular ViewModel system to define interactive blocks. Each - Updating a global setting is done via `SetConfigCommand` (applies globally unless overridden). 6. **Useful Helper Functions** - - To avoid repetitive boilerplate, use these global utilities from `global.ts`: - `useBlockMetaKeyAtom(blockId, key)`: Retrieves and updates block-specific metadata. - `useOverrideConfigAtom(blockId, key)`: Reads from global config but allows per-block overrides. @@ -139,7 +133,7 @@ type HeaderTextButton = { type HeaderText = { elemtype: "text"; text: string; - ref?: React.MutableRefObject; + ref?: React.RefObject; className?: string; noGrow?: boolean; onClick?: (e: React.MouseEvent) => void; @@ -150,7 +144,7 @@ type HeaderInput = { value: string; className?: string; isDisabled?: boolean; - ref?: React.MutableRefObject; + ref?: React.RefObject; onChange?: (e: React.ChangeEvent) => void; onKeyDown?: (e: React.KeyboardEvent) => void; onFocus?: (e: React.FocusEvent) => void; diff --git a/frontend/app/element/flyoutmenu.tsx b/frontend/app/element/flyoutmenu.tsx index 502421f302..7f327c6fb5 100644 --- a/frontend/app/element/flyoutmenu.tsx +++ b/frontend/app/element/flyoutmenu.tsx @@ -206,7 +206,7 @@ type SubMenuProps = { }; visibleSubMenus: { [key: string]: any }; hoveredItems: string[]; - subMenuRefs: React.MutableRefObject<{ [key: string]: React.RefObject }>; + subMenuRefs: React.RefObject<{ [key: string]: React.RefObject }>; handleMouseEnterItem: ( event: React.MouseEvent, parentKey: string | null, diff --git a/frontend/app/element/input.tsx b/frontend/app/element/input.tsx index 83dcb11d15..189a732ac8 100644 --- a/frontend/app/element/input.tsx +++ b/frontend/app/element/input.tsx @@ -70,7 +70,7 @@ interface InputProps { autoSelect?: boolean; disabled?: boolean; isNumber?: boolean; - inputRef?: React.MutableRefObject; + inputRef?: React.RefObject; manageFocus?: (isFocused: boolean) => void; } diff --git a/frontend/app/modals/typeaheadmodal.tsx b/frontend/app/modals/typeaheadmodal.tsx index 6dc25cb32f..e8df6fd79a 100644 --- a/frontend/app/modals/typeaheadmodal.tsx +++ b/frontend/app/modals/typeaheadmodal.tsx @@ -82,7 +82,7 @@ interface TypeAheadModalProps { onSelect?: (_: string) => void; onClickBackdrop?: () => void; onKeyDown?: (_) => void; - giveFocusRef?: React.MutableRefObject<() => boolean>; + giveFocusRef?: React.RefObject<() => boolean>; autoFocus?: boolean; selectIndex?: number; } diff --git a/frontend/app/view/preview/csvview.tsx b/frontend/app/view/preview/csvview.tsx index 9cb889ac1e..2c49f46770 100644 --- a/frontend/app/view/preview/csvview.tsx +++ b/frontend/app/view/preview/csvview.tsx @@ -23,7 +23,7 @@ type CSVRow = { }; interface CSVViewProps { - parentRef: React.MutableRefObject; + parentRef: React.RefObject; content: string; filename: string; readonly: boolean; diff --git a/frontend/app/view/preview/preview-model.tsx b/frontend/app/view/preview/preview-model.tsx index a68585c8ae..b6aa8313ba 100644 --- a/frontend/app/view/preview/preview-model.tsx +++ b/frontend/app/view/preview/preview-model.tsx @@ -152,11 +152,11 @@ export class PreviewModel implements ViewModel { openFileModal: PrimitiveAtom; openFileModalDelay: PrimitiveAtom; openFileError: PrimitiveAtom; - openFileModalGiveFocusRef: React.MutableRefObject<() => boolean>; + openFileModalGiveFocusRef: React.RefObject<() => boolean>; markdownShowToc: PrimitiveAtom; - monacoRef: React.MutableRefObject; + monacoRef: React.RefObject; showHiddenFiles: PrimitiveAtom; refreshVersion: PrimitiveAtom; diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index d66b6622e9..43078bba60 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -207,7 +207,7 @@ declare global { type HeaderText = { elemtype: "text"; text: string; - ref?: React.MutableRefObject; + ref?: React.RefObject; className?: string; noGrow?: boolean; onClick?: (e: React.MouseEvent) => void; @@ -218,7 +218,7 @@ declare global { value: string; className?: string; isDisabled?: boolean; - ref?: React.MutableRefObject; + ref?: React.RefObject; onChange?: (e: React.ChangeEvent) => void; onKeyDown?: (e: React.KeyboardEvent) => void; onFocus?: (e: React.FocusEvent) => void;