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
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,8 +262,12 @@ so an update never silently changes what an existing user sees; the bridge to ma
- **THE MENU FITS WHAT WAS CLICKED** (#44; owner, same day: "if i click it on a link it shows copy
link, if i click it with text marked it says copy ... remove close tab from the right click
menu"). `termContextAt` (core, read-only) answers what is under the point: the selection's text
and the link there (whole, across a wrap, by `findLinks`). App's menu leads with Copy link and
Copy, both copied exactly through main (`writeClipboard`); Close tab stays on the TAB's menu.
and the link there (whole, across a wrap, by `findLinks`). App's menu leads with Copy link, Open link and
Copy, both copies exact through main (`writeClipboard`); Close tab stays on the TAB's menu.
**A RIGHT-CLICK ON A LINK OPENS NOTHING** (#95; owner, 2026-09-28): the link addon hands over a
click of ANY button, so it opened the link and the menu at once; only a left click opens now. Every
row of both menus carries a glyph (`components/MenuIcon.tsx`, Prism's `FileMenuIcon` paths; owner: "the items
should have icons like in prism explorer").
The `selectionEdit` e2e reads the clipboard back in main and puts the owner's back.
- **SETTINGS DESCRIPTIONS ARE PLAIN WORDS, AND THE ROWS KEEP ONE ORDER** (owner, 2026-09-22: "no
symbols other than comma and dot, no mentioning of specific keys or tips"; "the terminal
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.18.0",
"version": "0.18.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: 9 additions & 1 deletion core/renderer/components/TerminalPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@
return out
})

export function termContextAt(id: string, clientX: number, clientY: number): { selection: string; link: string | null } {

Check warning on line 359 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 { selection: '', link: null }
const term = s.term
Expand Down Expand Up @@ -425,7 +425,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 428 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 @@ -433,7 +433,7 @@
* 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 436 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)
}

Expand All @@ -444,7 +444,7 @@
* owner, 2026-09-23): in vim and less Ctrl+F is page down, and stays theirs.
* False when no shell has the keyboard.
*/
export function focusedTermFullScreen(): boolean {

Check warning on line 447 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 active = document.activeElement
for (const s of sessions.values()) {
if (!active || !s.el.contains(active)) continue
Expand All @@ -456,7 +456,7 @@
/** 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 459 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 @@ -480,7 +480,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 483 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 @@ -535,7 +535,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 538 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 @@ -548,13 +548,13 @@
}

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

Check warning on line 551 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 557 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 @@ -598,7 +598,15 @@
// a terminal that didn't bother.
term.loadAddon(new Unicode11Addon())
term.unicode.activeVersion = '11'
term.loadAddon(new WebLinksAddon((_e, url) => termApi().openExternal(url)))
// A LEFT click opens a link (owner, 2026-09-28: "right clicking a link opens
// the link instead of showing the right click menu"). The addon hands over a
// click of ANY button; the right one belongs to the menu, which offers Open
// link itself.
term.loadAddon(
new WebLinksAddon((e, url) => {
if (e.button === 0) termApi().openExternal(url)
})
)
// The addon makes a link clickable and underlines it under the pointer; this
// is what makes it LOOK like a link the rest of the time.
const links = attachLinkPaint(term, currentLinkColor)
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.22.0",
"version": "0.23.0",
"description": "A tabbed Windows terminal for AI CLIs.",
"main": "./out/main/index.js",
"author": "Max",
Expand Down
15 changes: 10 additions & 5 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { forgetSession, markResume, markTouched } from '@core/renderer/lib/termA
import { onCwd, pasteInto } from '@core/renderer/lib/termBus'
import { quotePaths } from '@core/renderer/lib/termPaste'
import { ContextMenu } from './components/ContextMenu'
import { MenuIcon } from './components/MenuIcon'
import { rememberRoot } from '@core/renderer/lib/recentRoots'
import { savedShellId } from '@core/renderer/lib/termPrefs'
import { helpEnabled, helpShell, setHelpShell, useHelpEnabled } from '@core/renderer/lib/helpPrefs'
Expand Down Expand Up @@ -655,18 +656,22 @@ export default function App(): JSX.Element {
// clipboard has it. Close tab left this menu the same day ("remove close
// tab from the right click menu"); the tab's own menu still has it.
...(termMenu.link
? [{ label: 'Copy link', onPick: () => void copyText(termMenu.link!) }]
? [
{ label: 'Copy link', icon: <MenuIcon name="link" />, onPick: () => void copyText(termMenu.link!) },
// A right-click never opens it (2026-09-28), so the menu does.
{ label: 'Open link', icon: <MenuIcon name="open" />, onPick: () => window.prism.openExternal(termMenu.link!) }
]
: []),
...(termMenu.selection
? [{ label: 'Copy', hint: 'Ctrl+C', onPick: () => void copyText(termMenu.selection) }]
? [{ label: 'Copy', icon: <MenuIcon name="copy" />, hint: 'Ctrl+C', onPick: () => void copyText(termMenu.selection) }]
: []),
// Paste through the terminal's own rule (an image forwards the
// keystroke to the agent, files become quoted paths, text is a
// bracketed paste).
{ label: 'Paste', hint: 'Ctrl+V', onPick: () => void pasteInto(activeShell.id) },
{ label: 'Find in scrollback', hint: 'Ctrl+F', onPick: () => setFindFor(activeShell.id) },
{ label: 'Paste', icon: <MenuIcon name="paste" />, hint: 'Ctrl+V', onPick: () => void pasteInto(activeShell.id) },
{ label: 'Find in scrollback', icon: <MenuIcon name="find" />, hint: 'Ctrl+F', onPick: () => setFindFor(activeShell.id) },
// Only while the setting is on: off means the app offers it nowhere.
...(helpOn ? [{ label: 'Command help', hint: 'F1', onPick: toggleHelp }] : [])
...(helpOn ? [{ label: 'Command help', icon: <MenuIcon name="help" />, hint: 'F1', onPick: toggleHelp }] : [])
]}
/>
)}
Expand Down
39 changes: 39 additions & 0 deletions src/renderer/src/components/MenuIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { JSX } from 'react'

// A GLYPH ON EVERY MENU ROW, AS IN PRISM'S EXPLORER (owner, 2026-09-28: "the
// items should have icons like in prism explorer"). The paths, stroke and
// size are Prism's `FileMenuIcon` where Prism has the action (open, copy,
// paste, the link, the folder), so a row reads the same in both apps; find,
// help and close are drawn to match.
const paths = {
open: 'M14 4h6v6M20 4l-9 9M18 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h6',
copy: 'M8 8h12v12H8zM16 8V4H4v12h4',
paste: 'M9 3.5h6v3H9zM7 5H4.5v15.5h15V5H17',
link: 'M9 15l6-6M7.5 10.5l-2 2a3.5 3.5 0 0 0 5 5l2-2M16.5 13.5l2-2a3.5 3.5 0 0 0-5-5l-2 2',
folder: 'M2.5 5.5h6.2l2 2.6h10.8v10.4H2.5z',
find: 'M10.5 4.5a6 6 0 1 0 0 12 6 6 0 0 0 0-12zM15 15l5 5',
help: 'M12 3.8a8.2 8.2 0 1 0 0 16.4 8.2 8.2 0 0 0 0-16.4zM9.6 9.4a2.5 2.5 0 1 1 3.7 2.2c-.8.5-1.3 1-1.3 1.9M12 16.6v.01',
close: 'M7 7l10 10M17 7L7 17'
}

export type MenuIconName = keyof typeof paths

export function MenuIcon({ name }: { name: MenuIconName }): JSX.Element {
return (
<svg
viewBox="0 0 24 24"
width={13}
height={13}
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0 opacity-80"
aria-hidden
data-menu-icon={name}
>
<path d={paths[name]} />
</svg>
)
}
4 changes: 3 additions & 1 deletion src/renderer/src/components/TabStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useAgentColors } from '@core/renderer/lib/agentColors'
import { contrastRatio } from '@core/renderer/lib/termAnsi'
import { pinnedRoots, plusMenuList, recentLabels, recentRoots, togglePin } from '@core/renderer/lib/recentRoots'
import { ContextMenu } from './ContextMenu'
import { MenuIcon } from './MenuIcon'
import { useTabWidth } from '../lib/tabWidthPrefs'
import { workingRuns } from '../lib/workingRuns'

Expand Down Expand Up @@ -583,14 +584,15 @@ export function TabStrip({
// Every row acts on the tab you clicked. "New tab" was here and
// went (2026-08-31): the + is one pixel away and its tooltip
// already teaches its key.
{ label: 'Close tab', hint: 'Ctrl+Shift+W', onPick: () => onClose(tabMenu.id) },
{ label: 'Close tab', icon: <MenuIcon name="close" />, hint: 'Ctrl+Shift+W', onPick: () => onClose(tabMenu.id) },
// Settings is a tab with no folder. Offering this there wrote an
// EMPTY STRING over the clipboard, which is worse than doing
// nothing.
...(tabMenu.cwd
? [
{
label: 'Copy folder path',
icon: <MenuIcon name="folder" />,
onPick: () => void navigator.clipboard.writeText(tabMenu.cwd)
}
]
Expand Down
25 changes: 24 additions & 1 deletion tools/e2e/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -717,19 +717,42 @@ const scenarios = {
await page.keyboard.press('Enter')
await prompt()
const onLink = await box(url, 12)
const opened = async () => ((await app.evaluate(() => globalThis.__e2eOpenedLinks)) ?? []).filter((u) => u === url).length
await page.mouse.click(onLink.left + 2, onLink.y, { button: 'right' })
let rows = await menuRows()
// A RIGHT-CLICK ON A LINK OPENS NOTHING (owner, 2026-09-28: "right
// clicking a link opens the link instead of showing the right click
// menu"): xterm's link addon hands over ANY button's click.
await sleep(400)
ok((await opened()) === 0, 'a right-click on a link does not open it')
ok(!!rows && rows[0].includes('Copy link') && !rows.some((r) => r.includes('Close tab')), `right-click on a link: Copy link first, no Close tab (${JSON.stringify(rows)})`)
ok(!!rows && rows[1]?.includes('Open link'), `and Open link beside it (${JSON.stringify(rows)})`)
// A GLYPH ON EVERY ROW, as in Prism's Explorer (owner, 2026-09-28).
const glyphs = await page.evaluate(() =>
[...document.querySelectorAll('[role="menu"] [role="menuitem"]')].map((r) => r.querySelector('[data-menu-icon]')?.getAttribute('data-menu-icon') ?? null)
)
ok(glyphs.length > 0 && glyphs.every(Boolean), `every row of the menu has its icon (${JSON.stringify(glyphs)})`)
await sleep(250) // past the menu's fade-in, for the picture
await page.screenshot({ path: resolve(process.cwd(), '.e2e-shots/term-menu-link.png') }).catch(() => {})
await page.locator('[role="menu"] [role="menuitem"]', { hasText: 'Copy link' }).click()
ok((await until(async () => (await clip()) === url, 4000)) === true, 'and it copies the whole link')
// A CLICKED LINK NEVER REACHES THE OWNER'S BROWSER UNDER --e2e (#64;
// owner, 2026-09-24: "make sure that future runs don't do that in my real
// browser"). Clicked for real: main records it and opens nothing.
await page.keyboard.press('Escape')
const linkAgain = await box(url, 12)
await page.mouse.click(linkAgain.left + 2, linkAgain.y, { button: 'right' })
await page.locator('[role="menu"] [role="menuitem"]', { hasText: 'Open link' }).click()
ok(!!(await until(async () => (await opened()) === 1, 4000)), 'Open link opens it (recorded under --e2e)')
// The menu took the pointer away; xterm finds a link only when the
// pointer comes onto it and rests, as a real hand's does.
await page.mouse.move(linkAgain.left + 40, linkAgain.y + 40)
await sleep(200)
await page.mouse.move(linkAgain.left + 2, linkAgain.y)
await sleep(300)
await page.mouse.click(linkAgain.left + 2, linkAgain.y)
ok(
!!(await until(async () => ((await app.evaluate(() => globalThis.__e2eOpenedLinks)) ?? []).includes(url), 4000)),
!!(await until(async () => (await opened()) === 2, 4000)),
'a clicked link is recorded under --e2e, and no browser is opened'
)
// THE "COPIED" BADGE (owner, 2026-09-23): at the bottom centre, then gone.
Expand Down
Loading