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
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,16 @@ terminal theme, anything that reads or shows files.
on a resize (reflow) and on a theme change; not on the alternate screen. Columns are counted in
CELLS, since a wide character is one character and two cells. xterm splits a row into spans as
it likes, so the e2e finds a link's span by POSITION, never by its text.
- **A file dropped on the terminal types its quoted path, and the terminal answers a right-click**
(2026-09-19, #16). Both lived in Prism's `TermDock.tsx`, the split dock, and went with it when
the dock was stripped, while the README, the spec and PR #3 went on listing the drop as shipped
for a day: NOTHING IN A FEATURE LIST IS TRUE UNTIL A TEST HAS DONE IT. They are on App's terminal
host now (`data-term-host`): the drop goes through `quotePaths` and `termInput`, never Enter, and
only over a shell; the menu is Paste (the terminal's own paste rule, via `pasteInto`), Find in
scrollback and Close tab, with no Copy row because xterm owns its selection. The e2e performs a
REAL drop with Chromium's drag events (`Input.dispatchDragEvent` carrying a file path): a
synthetic DataTransfer holds a File with no path and proves nothing about `getPathForFile`.
When a Prism component is stripped, grep what ELSE it owned before calling a feature kept.
- **Opacity is a number read defensively** (`termOpacity`): `Number(null)` is 0, and never-set must
read as opaque.
- **Explorer verbs**: HKCU, `reg.exe` with argv only, on `Directory` and `Directory\Background`, no
Expand Down
59 changes: 56 additions & 3 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ import {
} from './lib/tabs'
import { useAgentIndicator } from './lib/useAgentIndicator'
import { humanFor, workingFor } from './lib/agentClock'
import { forgetSession, markResume } from './lib/termActivity'
import { onCwd } from './lib/termBus'
import { forgetSession, markResume, markTouched } from './lib/termActivity'
import { onCwd, pasteInto } from './lib/termBus'
import { quotePaths } from './lib/termPaste'
import { ContextMenu } from './components/ContextMenu'
import { rememberRoot } from './lib/recentRoots'
import { savedShellId } from './lib/termPrefs'
import { newTabFolder, newTabMode } from './lib/newTabPrefs'
Expand Down Expand Up @@ -73,6 +75,8 @@ export default function App(): JSX.Element {
/** The shell the find bar was opened over; it belongs to that shell alone. */
const [findFor, setFindFor] = useState<string | null>(null)
const [ask, setAsk] = useState<Ask | null>(null)
/** The terminal's own right-click menu, at the pointer. */
const [termMenu, setTermMenu] = useState<{ x: number; y: number } | null>(null)
const { tabs, activeId } = state
const active = tabs.find((t) => t.id === activeId) ?? null
const activeShell = active && active.kind !== 'settings' ? active : null
Expand Down Expand Up @@ -316,7 +320,39 @@ export default function App(): JSX.Element {
onOpenRecent={openRecent}
/>
)}
<div className="relative min-h-0 flex-1">
<div
className="relative min-h-0 flex-1"
data-term-host
// A FILE DROPPED ON THE TERMINAL TYPES ITS PATH (2026-09-19, #16): the
// other way a file, or a screenshot, gets into an agent's prompt.
// Quoted, at the cursor, as the paste rule quotes copied files, and
// never followed by Enter. In Prism this lived in the split dock, and
// went with it when the dock was stripped; the README went on
// claiming it for a day. Only over a shell: the start screen and
// Settings have nothing to type into.
onDragOver={(e) => {
if (!activeShell || !e.dataTransfer.types.includes('Files')) return
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
}}
onDrop={(e) => {
if (!activeShell) return
e.preventDefault()
e.stopPropagation()
const paths = [...e.dataTransfer.files]
.map((f) => window.prism.getDroppedPath(f))
.filter((path) => path.length > 0)
if (!paths.length) return
markTouched(activeShell.id) // input like any other: its echo is not the agent working
window.prism.termInput(activeShell.id, quotePaths(paths))
focusTermSession(activeShell.id)
}}
onContextMenu={(e) => {
if (!activeShell || !(e.target as HTMLElement).closest('[data-term-region]')) return
e.preventDefault()
setTermMenu({ x: e.clientX, y: e.clientY })
}}
>
{activeShell && (
<TerminalPanel
key={activeShell.id}
Expand Down Expand Up @@ -349,6 +385,23 @@ export default function App(): JSX.Element {
)}
</div>

{termMenu && activeShell && (
<ContextMenu
x={termMenu.x}
y={termMenu.y}
onClose={() => setTermMenu(null)}
items={[
// Paste through the terminal's own rule (an image forwards the
// keystroke to the agent, files become quoted paths, text is a
// bracketed paste). No Copy row: xterm owns its selection, and
// Ctrl+C over one already copies.
{ label: 'Paste', hint: 'Ctrl+V', onPick: () => void pasteInto(activeShell.id) },
{ label: 'Find in scrollback', hint: 'Ctrl+Shift+F', onPick: () => setFindFor(activeShell.id) },
{ label: 'Close tab', hint: 'Ctrl+Shift+W', onPick: () => requestClose(activeShell.id) }
]}
/>
)}

{ask && (
<Dialog
title={
Expand Down
45 changes: 44 additions & 1 deletion tools/e2e/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
// that "closed" its window has left a process holding the single-instance lock.
import { _electron as electron } from 'playwright-core'
import { execFileSync, spawn } from 'child_process'
import { existsSync, mkdirSync, mkdtempSync, readFileSync } from 'fs'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join, resolve } from 'path'
import { createRequire } from 'module'
Expand Down Expand Up @@ -508,6 +508,49 @@ const scenarios = {
await app.close().catch(() => {})
},

/** A file dropped on the terminal types its quoted path and never sends it;
* the terminal answers a right-click. */
async dropAndMenu(ok) {
const w = world()
const file = join(w.alpha, 'my notes.txt') // a space, so the quoting is visible
writeFileSync(file, 'x')
const { app, page } = await launch(w, { args: [w.alpha] })
await until(async () => (await tabLabels(page)).length === 1)
await typeLine(page, 'cls')
await sleep(600)
// A REAL drop: Chromium's own drag events carrying a file path, which is
// what Explorer hands over. A synthetic DataTransfer would carry a File
// with no path, and prove nothing about getPathForFile.
const box = await page.locator('[data-term-region]').boundingBox()
const x = Math.round(box.x + box.width / 2)
const y = Math.round(box.y + box.height / 2)
const cdp = await page.context().newCDPSession(page)
const data = { items: [], files: [file], dragOperationsMask: 1 }
for (const type of ['dragEnter', 'dragOver', 'drop']) {
await cdp.send('Input.dispatchDragEvent', { type, x, y, data })
}
const typed = await until(async () => (await termText(page)).includes('my notes.txt'), 8000)
ok(typed, 'a file dropped on the terminal types its path')
const text = (await termText(page)).replace(/\s+/g, ' ')
ok(/["']?[A-Z]:\\[^"']*my notes\.txt["']/.test(text), `and the path is quoted, since it holds a space (${text.slice(-90)})`)
ok(!/is not recognized|CommandNotFound|ObjectNotFound/.test(text), 'and it was NOT sent: nothing ran')
// Clear what the drop typed, so the shell is at a clean prompt again.
await page.keyboard.press('Escape')
// The right-click menu: Paste, Find, Close tab.
await page.locator('[data-term-region]').click({ button: 'right', position: { x: 200, y: 120 } })
const rows = await until(
async () => {
const t = await page.locator('[role="menu"] [role="menuitem"]').allTextContents()
return t.length ? t : null
},
4000
)
ok(!!rows && ['Paste', 'Find in scrollback', 'Close tab'].every((l) => rows.some((r) => r.includes(l))), `the terminal answers a right-click (${JSON.stringify(rows)})`)
await page.locator('[role="menu"] [role="menuitem"]', { hasText: 'Find in scrollback' }).click()
ok(await until(async () => (await page.locator('[data-term-find], input[placeholder*="ind"]').count()) > 0, 4000), 'and its Find row opens the find bar')
await app.close().catch(() => {})
},

/** `exit` closes the tab it was typed in. */
async exitClosesTab(ok) {
const w = world()
Expand Down
Loading