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
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.40.11",
"version": "0.40.12",
"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
68 changes: 66 additions & 2 deletions core/renderer/components/TerminalPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import { attachReplies } from './termReplyHandlers'
import { followsHostStyle, paintsGround, termApi, termHost, type ClipboardRead } from '../host'
import { findLinks, linkColor } from '../lib/termLinks'
import { watchAppCopies } from '../lib/appCopyNotice'
import { announceCopied, copyText } from '../lib/copyNotice'
import { arrowKeys, caretClickAllowed, caretDelta, type ClickGate } from '../lib/termClickCaret'
import {
Expand Down Expand Up @@ -75,7 +76,7 @@
suppressActivity,
takeResume
} from '../lib/termActivity'
import { termKeyAction } from '../lib/termKeys'
import { isKey, termKeyAction } from '../lib/termKeys'
import '@xterm/xterm/css/xterm.css'

// The terminal surface. This module is a lazy chunk (xterm is ~350KB the
Expand Down Expand Up @@ -103,6 +104,8 @@
/** The uri of the OSC 8 link over buffer cell (`line`, `x`) of the screen in
* front, while it still stands (#169). */
oscLink(line: number, x: number): string | null
/** The program in front holds a selection of its own (lib/appCopyNotice). */
programSelecting(): boolean
}

const sessions = new Map<string, Session>()
Expand Down Expand Up @@ -384,14 +387,14 @@
* files inside Prism), else main's: the file's own app, a folder in Explorer,
* anything runnable only SHOWN in Explorer.
*/
export function openTermPath(id: string, target: TermPathAt, mode: 'open' | 'reveal'): void {

Check warning on line 390 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
if (termHost().openPath?.({ abs: target.abs, kind: target.kind, mode })) return
termApi().termOpenPath?.(target.base, target.text, mode)
}

export function termContextAt(

Check warning on line 397 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,
clientX: number,
clientY: number
Expand Down Expand Up @@ -473,7 +476,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 479 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 @@ -481,7 +484,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 487 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 @@ -492,7 +495,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 498 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 @@ -501,10 +504,23 @@
return false
}

/**
* Does the program in the shell that has the keyboard hold a selection of its
* own (Claude Code's fullscreen view, lib/appCopyNotice)? Then Ctrl+C is its
* copy, and a host that keeps Ctrl+C from an idle agent lets it through.
*/
export function focusedTermProgramSelecting(): boolean {

Check warning on line 512 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)) return s.programSelecting()
}
return false
}

/** 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 523 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 @@ -528,7 +544,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 547 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 @@ -583,7 +599,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 602 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 Down Expand Up @@ -1025,7 +1041,37 @@
// The title arms the agent's keys before the poll (#175), and takes back an
// agent only it vouched for (`agentArm` has the rules).
term.onTitleChange((t) => moveArm(armOnTitle(arm, t)))
// "COPIED" FOR A PROGRAM'S OWN SELECTION (lib/appCopyNotice): Claude
// Code's fullscreen view copies on the mouse's release itself, past the
// terminal; a selecting press is followed by a look at the clipboard.
// Mouse events, not pointer events: only they carry the click count.
// Shift held is xterm's own selection, which Ctrl+C copies and announces.
const appCopies = watchAppCopies({
read: async () => {
const api = termApi()
if (!api.readClipboardAsync) return null
try {
return (await api.readClipboardAsync()).text
} catch {
return null
}
},
programHasMouse: () => term.modes.mouseTrackingMode !== 'none',
announce: announceCopied
})
const copyDown = (e: MouseEvent): void => {
if (e.button === 0 && !e.shiftKey) appCopies.down(e.clientX, e.clientY, e.detail)
}
const copyUp = (e: MouseEvent): void => {
if (e.button === 0) appCopies.up(e.clientX, e.clientY)
}
el.addEventListener('mousedown', copyDown, true)
window.addEventListener('mouseup', copyUp, true)
const unsub = [
() => {
el.removeEventListener('mousedown', copyDown, true)
window.removeEventListener('mouseup', copyUp, true)
},
attachClickCaret(term, el, id, (line, x) => oscLink(line, x) !== null),
// A path this session asked about turned out to exist: paint the lines
// that asked (#167; it used to be every line of every tab).
Expand Down Expand Up @@ -1055,6 +1101,13 @@
hostOwns: (k) => termHost().ownsKey(k as KeyboardEvent),
agentHere: arm.here
})
// THE PROGRAM'S OWN SELECTION (lib/appCopyNotice) ends with this key:
// a Ctrl+C that reached it was its copy, any other key ends it. After
// the host's own handler has read it for this same key (a task later).
if (e.type === 'keydown' && !['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) {
const copy = action === 'xterm' && e.ctrlKey && !e.shiftKey && !e.altKey && isKey(e, 'c')
setTimeout(() => (copy ? appCopies.copyKey() : appCopies.otherKey()), 0)
}
switch (action) {
case 'xterm':
return true
Expand Down Expand Up @@ -1114,7 +1167,18 @@
const now = spanText(sp, term.cols, (l) => bufferRowCells(term.buffer.active.getLine(l), term.cols))
return now === sp.text ? sp.uri : null
}
const session: Session = { term, fit, search, links, el, unsub, cwd: () => cwdNow, tellTheme, oscLink }
const session: Session = {
term,
fit,
search,
links,
el,
unsub,
cwd: () => cwdNow,
tellTheme,
oscLink,
programSelecting: () => term.modes.mouseTrackingMode !== 'none' && appCopies.selecting()
}
sessions.set(id, session)

// A session restored over a Claude conversation launches straight into it:
Expand Down
124 changes: 124 additions & 0 deletions core/renderer/lib/appCopyNotice.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, expect, it, vi } from 'vitest'
import { watchAppCopies } from './appCopyNotice'

const flush = async (): Promise<void> => {
for (let i = 0; i < 10; i += 1) await Promise.resolve()
}

const setup = (opts: { mouse?: boolean } = {}) => {
let clip = 'old'
const announce = vi.fn()
const waits: Array<() => void> = []
const w = watchAppCopies({
read: async () => clip,
programHasMouse: () => opts.mouse ?? true,
announce,
wait: () => new Promise<void>((r) => waits.push(r))
})
const tick = async (): Promise<void> => {
await flush()
waits.shift()?.()
await flush()
}
return { w, announce, set: (t: string) => (clip = t), tick }
}

describe('"Copied" for a program that copies its own selection (2026-10-16)', () => {
it('a drag that changes the clipboard shows the badge, once', async () => {
const { w, announce, set, tick } = setup()
w.down(10, 10, 1)
await flush()
w.up(80, 10)
set('copied text')
await tick()
await tick()
await tick()
expect(announce).toHaveBeenCalledTimes(1)
})

it('a copy that lands late is still heard', async () => {
const { w, announce, set, tick } = setup()
w.down(10, 10, 1)
await flush()
w.up(80, 10)
await tick()
await tick()
set('late')
await tick()
expect(announce).toHaveBeenCalledTimes(1)
})

it('a plain click, or a clipboard that did not change, says nothing', async () => {
const a = setup()
a.w.down(10, 10, 1)
a.w.up(11, 10)
a.set('changed')
for (let i = 0; i < 3; i += 1) await a.tick()
expect(a.announce).not.toHaveBeenCalled()
const b = setup()
b.w.down(10, 10, 1)
b.w.up(90, 10)
for (let i = 0; i < 3; i += 1) await b.tick()
expect(b.announce).not.toHaveBeenCalled()
})

it('a double click counts; a terminal that has the mouse itself is not watched', async () => {
const a = setup()
a.w.down(10, 10, 2)
await flush()
a.w.up(10, 10)
a.set('word')
for (let i = 0; i < 3; i += 1) await a.tick()
expect(a.announce).toHaveBeenCalledTimes(1)
const b = setup({ mouse: false })
b.w.down(10, 10, 1)
b.w.up(90, 10)
b.set('x')
for (let i = 0; i < 3; i += 1) await b.tick()
expect(b.announce).not.toHaveBeenCalled()
})
})

// Owner, 2026-10-16: with Claude's Copy on select off, "highlighting text then
// clicking ctrl c does nothing ... it shows no chip".
describe('the program\'s own selection, copied with Ctrl+C', () => {
it('stands from the selecting release until a press, another key or the copy', async () => {
const { w } = setup()
expect(w.selecting()).toBe(false)
w.down(10, 10, 1)
w.up(80, 10)
expect(w.selecting()).toBe(true)
w.otherKey()
expect(w.selecting()).toBe(false)
w.down(10, 10, 2)
w.up(10, 10)
expect(w.selecting()).toBe(true)
w.down(50, 50, 1)
expect(w.selecting()).toBe(false)
w.up(51, 50)
expect(w.selecting()).toBe(false)
})

it('Ctrl+C on it shows the badge once the program has copied', async () => {
const { w, announce, set, tick } = setup()
w.down(10, 10, 2)
await flush()
w.up(10, 10)
// Copy on select off: the release copies nothing.
for (let i = 0; i < 3; i += 1) await tick()
expect(announce).not.toHaveBeenCalled()
w.copyKey()
expect(w.selecting()).toBe(false)
set('the word')
await tick()
expect(announce).toHaveBeenCalledTimes(1)
})

it('a Ctrl+C with no selection of its own looks at nothing', async () => {
const { w, announce, set, tick } = setup()
w.copyKey()
set('something')
for (let i = 0; i < 3; i += 1) await tick()
expect(announce).not.toHaveBeenCalled()
})
})
98 changes: 98 additions & 0 deletions core/renderer/lib/appCopyNotice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* "COPIED" FOR A PROGRAM'S OWN SELECTION (owner, 2026-10-16: "the copied chip
* dont always show"). A full-screen program that asked for the mouse (Claude
* Code's fullscreen view) draws its own selection and copies it itself,
* through the system clipboard and not through the terminal (no OSC 52): on
* the mouse's release with its Copy on select on, on Ctrl+C with it off. The
* app never heard of either and said nothing. Now a press that selects (a
* drag, or a double or triple click) is followed by a look at the clipboard's
* text, and so is a Ctrl+C while that selection stands: changed since the
* press, it was the program's copy, and the badge shows. The text is compared
* and dropped, never kept or logged.
*
* WHILE THE PROGRAM'S SELECTION STANDS, Ctrl+C is its copy, not a "clear the
* prompt" (owner, 2026-10-16: "highlighting text then clicking ctrl c does
* nothing": the keep-agents-open rule had taken it). `selecting()` says so
* from the selecting release until the next press, the next other key, or
* the Ctrl+C that copies it.
*
* Pure apart from the clock and the reads it is handed, so a test holds it.
*/

/** When the clipboard is looked at after the release or the Ctrl+C: the
* program copies through a helper process, which takes a moment. */
export const COPY_LOOKS_MS = [150, 500, 1000] as const
/** A press that moved this far is a drag. */
export const DRAG_PX = 4

export interface AppCopyDeps {
/** The clipboard's text now; null when it cannot be read. */
read(): Promise<string | null>
/** The program in front has the mouse (it draws its own selection). */
programHasMouse(): boolean
announce(): void
wait?: (ms: number) => Promise<void>
}

export interface AppCopyWatch {
down(x: number, y: number, clicks: number): void
up(x: number, y: number): void
/** Ctrl+C reached the program: its copy, while its selection stands. */
copyKey(): void
/** Any other key: the program's selection is over. */
otherKey(): void
/** The program's own selection stands (a selecting release since the
* last press, key or copy). */
selecting(): boolean
}

export function watchAppCopies(deps: AppCopyDeps): AppCopyWatch {
const wait = deps.wait ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)))
let press: { x: number; y: number; clicks: number; before: Promise<string | null> } | null = null
/** The selection the program holds: the clipboard as it was at its press. */
let selection: { before: Promise<string | null> } | null = null
/** Bumped by each press: a look from an older press stops. */
let gen = 0
const look = (before: Promise<string | null>): void => {
const mine = gen
void (async () => {
const was = await before
let waited = 0
for (const at of COPY_LOOKS_MS) {
await wait(at - waited)
waited = at
if (mine !== gen) return
const now = await deps.read()
if (now && now !== was) {
deps.announce()
return
}
}
})()
}
return {
down: (x, y, clicks) => {
gen += 1
selection = null
press = deps.programHasMouse() ? { x, y, clicks, before: deps.read() } : null
},
up: (x, y) => {
const p = press
press = null
if (!p) return
const dragged = Math.hypot(x - p.x, y - p.y) >= DRAG_PX
if (!dragged && p.clicks < 2) return
selection = { before: p.before }
look(p.before)
},
copyKey: () => {
const s = selection
selection = null
if (s) look(s.before)
},
otherKey: () => {
selection = null
},
selecting: () => selection !== null
}
}
2 changes: 1 addition & 1 deletion core/shared/termVersion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@
* so a core release that bumps `core/package.json` (or xterm) without this
* fails `npm test` (core/README.md, "Releasing the core").
*/
export const TERM_CORE_VERSION = '0.40.11'
export const TERM_CORE_VERSION = '0.40.12'
export const XTERM_VERSION = '6.0.0'
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.53.4",
"version": "0.54.0",
"description": "A tabbed Windows terminal for AI CLIs.",
"main": "./out/main/index.js",
"author": "Max",
Expand Down
Loading
Loading