diff --git a/client/src/components/run/TerminalView.tsx b/client/src/components/run/TerminalView.tsx index 9619d03f..3b7fa6be 100644 --- a/client/src/components/run/TerminalView.tsx +++ b/client/src/components/run/TerminalView.tsx @@ -17,7 +17,7 @@ * Files (images or any other) dropped or pasted on it — Ctrl+V included — are saved on the server * (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a * local terminal does on a drop, and how `claude` attaches an image or reads - * a file. The pane also registers with + * a file. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with * `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be * pasted into whichever of the workspace's panes was focused last. * Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission @@ -63,7 +63,7 @@ import { notePaneFocus, registerPane } from "../../lib/panePaste"; import { attachImeInput } from "../../lib/imeInput"; import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont"; -type DropState = "hint" | "uploading" | "failed"; +type DropState = "hint" | "paste" | "uploading" | "failed"; /** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */ export const SCREEN_READER_KEY = "agenthub.terminal.screenReader"; @@ -134,6 +134,10 @@ export function loadXterm(): Promise { const RETRY_MIN_MS = 1500; const RETRY_MAX_MS = 30_000; const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files"); +/** A text drag (a Files/Changes row, a skill, selected text): xterm.js has no + * drop handling of its own, so the pane pastes it, as a local terminal does. */ +const hasText = (e: DragEvent) => + !hasFiles(e) && Array.from(e.dataTransfer.types).includes("text/plain"); interface TerminalViewProps { runId: string; @@ -200,7 +204,16 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter const ctrlVRef = useRef(false); const onDrop = (e: DragEvent) => { - if (!hasFiles(e)) return; // dragged text keeps xterm's own handling + if (hasText(e)) { + e.preventDefault(); + dragDepth.current = 0; + setDrop(null); + const text = e.dataTransfer.getData("text/plain"); + if (text) termRef.current?.paste(text + " "); + termRef.current?.focus(); + return; + } + if (!hasFiles(e)) return; e.preventDefault(); dragDepth.current = 0; void attachFiles(Array.from(e.dataTransfer.files)); @@ -673,20 +686,20 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter className="relative h-full w-full pb-1 pl-3 pr-1 pt-1.5" style={{ backgroundColor: palette.background }} onDragEnter={(e) => { - if (!hasFiles(e)) return; + if (!hasFiles(e) && !hasText(e)) return; e.preventDefault(); dragDepth.current += 1; - setDrop("hint"); + setDrop(hasFiles(e) ? "hint" : "paste"); }} onDragOver={(e) => { - if (!hasFiles(e)) return; + if (!hasFiles(e) && !hasText(e)) return; e.preventDefault(); // without it the browser refuses the drop e.dataTransfer.dropEffect = "copy"; }} onDragLeave={(e) => { - if (!hasFiles(e)) return; + if (!hasFiles(e) && !hasText(e)) return; dragDepth.current = Math.max(0, dragDepth.current - 1); - if (dragDepth.current === 0) setDrop((d) => (d === "hint" ? null : d)); + if (dragDepth.current === 0) setDrop((d) => (d === "hint" || d === "paste" ? null : d)); }} onDrop={onDrop} onPasteCapture={onPaste} diff --git a/client/src/components/run/__tests__/TerminalView.test.tsx b/client/src/components/run/__tests__/TerminalView.test.tsx index 6207d966..73bf61a2 100644 --- a/client/src/components/run/__tests__/TerminalView.test.tsx +++ b/client/src/components/run/__tests__/TerminalView.test.tsx @@ -512,6 +512,23 @@ describe("TerminalView", () => { ); }); + it("pastes the text of a dragged Files-panel row, uploading nothing", () => { + dropFileMock.mockReset(); + pasteMock.mockClear(); + render(); + const data: Record = { + "application/x-agenthub-attach": '{"path":"@src/a.ts","name":"a.ts"}', + "text/plain": "src/a.ts", + }; + const dataTransfer = { types: Object.keys(data), files: [], getData: (k: string) => data[k] ?? "" }; + fireEvent.dragEnter(screen.getByTestId("terminal-view"), { dataTransfer }); + expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop to paste into the terminal"); + fireEvent.drop(screen.getByTestId("terminal-view"), { dataTransfer }); + expect(pasteMock).toHaveBeenCalledWith("src/a.ts "); + expect(screen.queryByTestId("terminal-drop")).toBeNull(); + expect(dropFileMock).not.toHaveBeenCalled(); + }); + it("uploads a pasted image and pastes its path, like a drop", async () => { dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" }); pasteMock.mockClear(); diff --git a/client/src/i18n/locales/en/run.json b/client/src/i18n/locales/en/run.json index 65d8426c..24c08ec1 100644 --- a/client/src/i18n/locales/en/run.json +++ b/client/src/i18n/locales/en/run.json @@ -65,6 +65,7 @@ }, "drop": { "hint": "Drop files or images to attach", + "paste": "Drop to paste into the terminal", "uploading": "Uploading…", "failed": "Could not attach the file", "remove": "Remove attachment", diff --git a/client/src/i18n/locales/vi/run.json b/client/src/i18n/locales/vi/run.json index 54fe5ca5..46ae077e 100644 --- a/client/src/i18n/locales/vi/run.json +++ b/client/src/i18n/locales/vi/run.json @@ -65,6 +65,7 @@ }, "drop": { "hint": "Thả file hoặc ảnh để đính kèm", + "paste": "Thả để dán vào terminal", "uploading": "Đang tải lên…", "failed": "Không đính kèm được file", "remove": "Bỏ đính kèm",