Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions frontend/app/store/wshclientapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,11 @@ class RpcApiType {
return client.wshRpcCall("gettab", data, opts);
}

// command "gettempdir" [call]
GetTempDirCommand(client: WshClient, data: CommandGetTempDirData, opts?: RpcOpts): Promise<string> {
return client.wshRpcCall("gettempdir", data, opts);
}

// command "getupdatechannel" [call]
GetUpdateChannelCommand(client: WshClient, opts?: RpcOpts): Promise<string> {
return client.wshRpcCall("getupdatechannel", null, opts);
Expand Down
70 changes: 64 additions & 6 deletions frontend/app/view/term/term-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<void> {
await handleImagePasteBlobUtil(blob, TabRpcClient, (text) => {
this.termRef.current?.terminal.paste(text);
});
}

setTermMode(mode: "term" | "vdom") {
if (mode == "term") {
mode = null;
Expand Down Expand Up @@ -489,14 +541,23 @@ 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();
return false;
}
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();
Expand All @@ -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;
Expand Down
98 changes: 98 additions & 0 deletions frontend/app/view/term/termutil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
"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<string> {
// 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<string>((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<void> {
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);
}
}
Loading