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
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,15 @@ so an update never silently changes what an existing user sees; the bridge to ma
leaves mid-install (the host's, never the user's), and a running install's window comes back
when the question has gone; `updateGuard` holds it. The window's Tab trap also leaves Ctrl+Tab
alone, which used to switch tabs AND move the focus inside the window in one press.
- **ONE CTRL+V IS ONE PASTE** (owner, 2026-09-22: "found a bug in the terminals. when I copy text and
paste it pastes twice"). The key handler pastes and returns `false`, and returning false only
tells XTERM to leave the key alone: it does not cancel the BROWSER's default for Ctrl+V, which is
a native paste event into xterm's textarea, which xterm pastes as well. Every paste arrived
twice, in both apps. The handler now calls `preventDefault()` for Ctrl+V and Ctrl+Shift+V, so
its own paste (the one that knows images and bracketed framing) is the only one. Any key the
core takes over from the browser needs the same: returning false is not cancelling. The
`paste` e2e counts what reached the shell for Ctrl+V, Ctrl+Shift+V and the right-click Paste
(it failed with 2 copies before the fix).
- **COMMAND HELP IS A POPUP THAT SHOWS AND COPIES, AND NOTHING ELSE** (#12; owner, 2026-09-19: "an
easy to use panel where you can find shell commands... searchable... metadata on each command so a
natural-language search finds it... optional in settings", and 2026-09-20: "a pop up with copy
Expand Down
2 changes: 1 addition & 1 deletion core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "prism-term-core",
"version": "0.6.0",
"version": "0.6.1",
"description": "What Prism Terminal and Prism share: the terminal (pty, shells, agent detection and indicator, themes, links, the panel, dictation) and the update chip with its window. TypeScript source, compiled by the host.",
"license": "MIT",
"private": true,
Expand Down
10 changes: 10 additions & 0 deletions core/renderer/components/TerminalPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@

/** Wipe a session's screen and scrollback, back to a bare prompt. The shell
* itself is untouched: same process, same cwd, same history. */
export function clearTermSession(id: string): void {

Check warning on line 206 in core/renderer/components/TerminalPanel.tsx

View workflow job for this annotation

GitHub Actions / check

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
sessions.get(id)?.term.clear()
}

Expand All @@ -211,14 +211,14 @@
* Claude session must resume now, not when the tab is first visited. The
* xterm lives against its detached element (the same way hidden tabs keep
* theirs) and the panel simply attaches it later, scrollback intact. */
export function ensureTermSession(id: string, root: string, shellId: string | undefined): void {

Check warning on line 214 in core/renderer/components/TerminalPanel.tsx

View workflow job for this annotation

GitHub Actions / check

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
if (!sessions.has(id)) createSession(id, root, shellId)
}

/** Give a live session the keyboard back. Used after a tab interaction (a
* click, a reorder drag) stole focus from a shell the user never left: the
* next keystroke would otherwise go to the strip instead of Claude Code. */
export function focusTermSession(id: string): void {

Check warning on line 221 in core/renderer/components/TerminalPanel.tsx

View workflow job for this annotation

GitHub Actions / check

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
sessions.get(id)?.term.focus()
}

Expand All @@ -242,7 +242,7 @@

/** Kill a session's renderer half: the xterm instance and its element. Main's
* pty half is killed separately (term:kill) or already exited. */
export function disposeTermSession(id: string): void {

Check warning on line 245 in core/renderer/components/TerminalPanel.tsx

View workflow job for this annotation

GitHub Actions / check

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
const s = sessions.get(id)
if (!s) return
sessions.delete(id)
Expand Down Expand Up @@ -296,7 +296,7 @@
* character at a time. Enter and the arrows pass false, which is what makes
* them step.
*/
export function findInTerm(id: string, query: string, dir: 1 | -1, incremental = false): boolean {

Check warning on line 299 in core/renderer/components/TerminalPanel.tsx

View workflow job for this annotation

GitHub Actions / check

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
const s = sessions.get(id)
if (!s) return false
if (!query) {
Expand All @@ -309,13 +309,13 @@
}

/** Drop the highlights: the bar closed, or the query emptied. */
export function clearTermFind(id: string): void {

Check warning on line 312 in core/renderer/components/TerminalPanel.tsx

View workflow job for this annotation

GitHub Actions / check

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
sessions.get(id)?.search.clearDecorations()
}

/** Hear the running count. `resultIndex` is -1 past xterm's match threshold,
* which the bar reports as "many" rather than as "none". */
export function onTermFindResults(

Check warning on line 318 in core/renderer/components/TerminalPanel.tsx

View workflow job for this annotation

GitHub Actions / check

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
id: string,
cb: (r: { index: number; count: number }) => void
): () => void {
Expand Down Expand Up @@ -480,11 +480,21 @@
termApi().termInput(id, '\\\r')
return false
}
// ONE CTRL+V IS ONE PASTE (owner, 2026-09-22: "when I copy text and paste
// it pastes twice"). Returning false only tells XTERM to leave the key
// alone; it does not cancel the BROWSER's own action for Ctrl+V, which is
// a native paste event into xterm's textarea - and xterm pastes on that
// event too. So every paste arrived twice, once from here and once from
// there. preventDefault cancels the native one; this handler is the paste,
// since it is the one that knows about images (Claude Code reads those
// itself when it gets the ^V keystroke) and bracketed framing.
if ((e.key === 'v' || e.key === 'V') && e.ctrlKey && !e.shiftKey) {
e.preventDefault()
pasteHere()
return false
}
if ((e.key === 'v' || e.key === 'V') && e.ctrlKey && e.shiftKey) {
e.preventDefault()
// The escape hatch: plain text paste even when an image rides along.
const clip = termApi().readClipboard()
if (clip.text) {
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "prism-terminal",
"productName": "Prism Terminal",
"version": "0.7.0",
"version": "0.7.1",
"description": "A tabbed Windows terminal for AI CLIs.",
"main": "./out/main/index.js",
"author": "Max",
Expand Down
65 changes: 65 additions & 0 deletions tools/e2e/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,71 @@ const scenarios = {
},

/** Tabs come back in their folders; a folder that has gone is dropped. */
/**
* ONE CTRL+V IS ONE PASTE (owner, 2026-09-22: "found a bug in the terminals.
* when I copy text and paste it pastes twice", with a screenshot of every
* word arriving doubled). What is counted is what reached the SHELL, read off
* the terminal, for a plain Ctrl+V, for the Ctrl+Shift+V text-only escape
* hatch, and for the right-click Paste. The clipboard is the owner's: it is
* saved first and put back at the end.
*/
async paste(ok) {
const w = world()
const { app, page } = await launch(w, { args: [w.alpha] })
await until(async () => (await tabLabels(page)).length === 1)
const held = await app.evaluate(({ clipboard }) => ({
text: clipboard.readText(),
html: clipboard.readHTML(),
rtf: clipboard.readRTF(),
image: clipboard.readImage().isEmpty() ? null : clipboard.readImage().toDataURL(),
formats: clipboard.availableFormats()
}))
try {
await page.waitForFunction(() => /PS [^>]*>\s*$/.test((document.querySelector('.xterm .xterm-rows')?.textContent ?? '').trimEnd()), null, { timeout: 45000 })
const count = async (word) => ((await termText(page)).match(new RegExp(word, 'g')) ?? []).length
const clear = async () => {
// Esc clears PSReadLine's line, so each paste is counted on its own.
await page.keyboard.press('Escape')
await sleep(300)
}
await page.locator('.xterm').first().click()

await app.evaluate(({ clipboard }) => clipboard.writeText('PASTEONCEA'))
await page.keyboard.press('Control+v')
await sleep(900)
ok((await count('PASTEONCEA')) === 1, `Ctrl+V pastes the text ONCE (${await count('PASTEONCEA')} copies arrived)`)
await clear()

await app.evaluate(({ clipboard }) => clipboard.writeText('PASTEONCEB'))
await page.keyboard.press('Control+Shift+v')
await sleep(900)
ok((await count('PASTEONCEB')) === 1, `Ctrl+Shift+V pastes the text ONCE (${await count('PASTEONCEB')} copies arrived)`)
await clear()

// The right-click menu's Paste reaches the same one paste, by another door.
await app.evaluate(({ clipboard }) => clipboard.writeText('PASTEONCEC'))
await page.locator('.xterm').first().click({ button: 'right' })
await page.locator('[role="menuitem"]:has-text("Paste")').first().click()
await sleep(900)
ok((await count('PASTEONCEC')) === 1, `the right-click Paste pastes ONCE (${await count('PASTEONCEC')} copies arrived)`)
await clear()
} finally {
await app
.evaluate(({ clipboard, nativeImage }, was) => {
const data = {}
if (was.text) data.text = was.text
if (was.html) data.html = was.html
if (was.rtf) data.rtf = was.rtf
if (was.image) data.image = nativeImage.createFromDataURL(was.image)
if (Object.keys(data).length) clipboard.write(data)
else clipboard.clear()
}, held)
.catch(() => {})
if (held.formats.some((f) => /FileName|uri-list/i.test(f))) console.log(' (the clipboard held copied FILES, which cannot be put back; it is empty now)')
await app.close().catch(() => {})
}
},

async restore(ok) {
const w = world()
let { app, page } = await launch(w, { args: [w.alpha, w.beta] })
Expand Down
Loading