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
5 changes: 5 additions & 0 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ Everything else happens **only when you ask for it**:
own machine (127.0.0.1, or straight into the engine's input for the Parakeet model) and is never
written to disk.
- **Links:** clicking a link printed in the terminal opens it in your browser.
- **Sonara** (the text-to-speech reader for Claude Code, if you run it): when Claude Code's
question widget in a tab changes question, Prism Terminal tells the Sonara running on your own
PC (127.0.0.1, at the address and with the key in Sonara's own `runtime.json`) the question
showing and a random id of that tab, so Sonara can read the question you moved to. Without
Sonara nothing is sent. Each tab's shell carries that id as `PRISM_TAB_ID`.
- **What you run:** the terminal runs your own shell, and whatever you run in it reaches the
network as you tell it to.

Expand Down
12 changes: 12 additions & 0 deletions core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,18 @@ the rainbow across the whole tab (`rainbowInk` picks its name's ink).
(`lib/switchKnob.ts`: the theme's black or white, flipped only under 2:1 on
the track), else `--p-on-accent` as before. Prism has not defined it.

**THE QUESTION FOLLOWER (Sonara #323, core 0.41.0)** tells Sonara, the
text-to-speech reader for Claude Code, which question of an AskUserQuestion
the user is on, which Claude Code itself tells no plugin.
`renderer/questionWidget.ts` reads the question off the visible rows (pure,
tested on measured screens), `renderer/questionWatch.ts` watches a session
after output settles and reports a change once, `term:question` carries it,
and `main/sonaraLink.ts` posts it to the Sonara on 127.0.0.1 named by its
`runtime.json`, naming the tab by its shell's `PRISM_TAB_ID` (`ptyEnv`, one
random token per shell, warm or not; Sonara's hook reads the same variable).
No Sonara: nothing is sent. `termQuestion` is optional on `TermApi`; a host on
`createTermApi` and `registerTermIpc` (both apps) gets it with nothing to add.

## Rules for code in here (lint-enforced in Prism Terminal's `eslint.config.js`)

- **Relative imports only.** No `@shared` / `@renderer` / `@core` aliases: a
Expand Down
12 changes: 11 additions & 1 deletion core/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { OSC52_MAX } from '../shared/termLimits'
import { validResume } from './agentResume'
import { pollAgentAgain, pollAgentsNow, pollAgentsSoon, startAgentPoll } from './agentPoll'
import { detectShells } from './shells'
import { cdTerm, killTerm, prewarmShell, resizeTerm, spawnTerm, writeTerm, type ClaudePluginEnv } from './terminal'
import { cdTerm, killTerm, prewarmShell, resizeTerm, spawnTerm, tabToken, writeTerm, type ClaudePluginEnv } from './terminal'
import { postQuestionShown } from './sonaraLink'
import { asShownQuestion } from '../shared/questionShown'
import { openTermPath, pathKinds, PATHS_MAX, type PathOpeners } from './termPathOpen'
import { isWebUrl } from '../shared/webUrl'

Expand Down Expand Up @@ -132,6 +134,14 @@ export function registerTermIpc(deps: TermIpcDeps): () => void {
if (typeof id === 'string') pollAgentAgain(id)
})

// Claude Code's question widget changed question in a tab (Sonara #323):
// Sonara, if it runs, reads the question the user is on. Quiet on failure.
ipcMain.on(CH.question, (_e: unknown, id: unknown, shown: unknown) => {
const q = asShownQuestion(shown)
const token = typeof id === 'string' ? tabToken(id) : undefined
if (q && token) void postQuestionShown(token, q)
})

ipcMain.on(CH.prewarm, (_e: unknown, cwd: unknown, shellId: unknown, hooks: unknown) => {
if (typeof cwd !== 'string') return
void deps.mayPrewarm(cwd).then((ok) => {
Expand Down
62 changes: 62 additions & 0 deletions core/main/sonaraLink.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { mkdtempSync, writeFileSync } from 'node:fs'
import { createServer, type IncomingMessage } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { postQuestionShown, sonaraHome } from './sonaraLink'

/** A stand-in Sonara: records each request and answers 200. */
async function fakeSonara(): Promise<{ port: number; got: Array<{ req: IncomingMessage; body: string }>; close: () => void }> {
const got: Array<{ req: IncomingMessage; body: string }> = []
const server = createServer((req, res) => {
let body = ''
req.on('data', (d) => (body += d))
req.on('end', () => {
got.push({ req, body })
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end('{"ok":true,"moved":true}')
})
})
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r))
const port = (server.address() as { port: number }).port
return { port, got, close: () => server.close() }
}

let close: (() => void) | undefined
afterEach(() => close?.())

describe('sonaraHome', () => {
it('is SONARA_HOME, else LOCALAPPDATA\\Sonara, else none', () => {
expect(sonaraHome({ SONARA_HOME: 'C:\\s', LOCALAPPDATA: 'C:\\l' })).toBe('C:\\s')
expect(sonaraHome({ LOCALAPPDATA: 'C:\\l' })).toBe(join('C:\\l', 'Sonara'))
expect(sonaraHome({})).toBeNull()
})
})

describe('postQuestionShown', () => {
it('posts the tab and the question to the Sonara in runtime.json', async () => {
const s = await fakeSonara()
close = s.close
const home = mkdtempSync(join(tmpdir(), 'pt-sonara-'))
writeFileSync(join(home, 'runtime.json'), JSON.stringify({ http_port: s.port, token: 'secret', port: 1 }))
expect(await postQuestionShown('tab-1', { text: 'Pick a size?' }, { SONARA_HOME: home })).toBe(true)
expect(await postQuestionShown('tab-1', { review: true }, { SONARA_HOME: home })).toBe(true)
expect(s.got.map((g) => g.req.url)).toEqual(['/v1/question_shown', '/v1/question_shown'])
expect(s.got[0].req.headers.authorization).toBe('Bearer secret')
expect(JSON.parse(s.got[0].body)).toEqual({ host_tab: 'tab-1', text: 'Pick a size?' })
expect(JSON.parse(s.got[1].body)).toEqual({ host_tab: 'tab-1', review: true })
})

it('does nothing without Sonara, and fails quietly when it is gone', async () => {
const home = mkdtempSync(join(tmpdir(), 'pt-sonara-'))
expect(await postQuestionShown('tab-1', { text: 'Q?' }, { SONARA_HOME: home })).toBe(false)
writeFileSync(join(home, 'runtime.json'), '{not json')
expect(await postQuestionShown('tab-1', { text: 'Q?' }, { SONARA_HOME: home })).toBe(false)
// A port nobody listens on.
const s = await fakeSonara()
s.close()
writeFileSync(join(home, 'runtime.json'), JSON.stringify({ http_port: s.port, token: 't' }))
expect(await postQuestionShown('tab-1', { text: 'Q?' }, { SONARA_HOME: home })).toBe(false)
expect(await postQuestionShown('', { text: 'Q?' }, { SONARA_HOME: home })).toBe(false)
})
})
78 changes: 78 additions & 0 deletions core/main/sonaraLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { readFile } from 'node:fs/promises'
import { request } from 'node:http'
import { join } from 'node:path'
import type { ShownQuestion } from '../shared/questionShown'

/**
* SONARA, THE TEXT-TO-SPEECH READER (Sonara #323). Claude Code tells its
* plugins nothing while the user moves between the questions of one
* AskUserQuestion, so Sonara cannot read the question the user is on. The
* tab can: the renderer reads it off the screen, and this hands it to a
* Sonara running on this PC, naming the tab by the `PRISM_TAB_ID` its shell
* carries (Sonara's own hook tagged the Claude session with the same value).
*
* Only to 127.0.0.1, only when Sonara wrote its `runtime.json` (its home is
* `SONARA_HOME`, else `%LOCALAPPDATA%\Sonara`). No Sonara: nothing is sent
* and nothing fails. Every failure is quiet; the caller gets false.
*/

/** Sonara's home folder, or null when neither variable says where. */
export function sonaraHome(env: NodeJS.ProcessEnv = process.env): string | null {
if (env.SONARA_HOME) return env.SONARA_HOME
return env.LOCALAPPDATA ? join(env.LOCALAPPDATA, 'Sonara') : null
}

interface Runtime {
httpPort: number
token: string
}

async function runtime(home: string): Promise<Runtime | null> {
try {
const r = JSON.parse(await readFile(join(home, 'runtime.json'), 'utf8')) as Record<string, unknown>
const port = r.http_port
const token = r.token
if (typeof port !== 'number' || !Number.isInteger(port) || port <= 0 || port > 65535) return null
if (typeof token !== 'string' || !token) return null
return { httpPort: port, token }
} catch {
return null
}
}

/** Tell Sonara what tab `tabToken` shows. True when Sonara took it. */
export async function postQuestionShown(
tabToken: string,
shown: ShownQuestion,
env: NodeJS.ProcessEnv = process.env,
timeoutMs = 1500
): Promise<boolean> {
const home = sonaraHome(env)
if (!home || !tabToken) return false
const rt = await runtime(home)
if (!rt) return false
const body = JSON.stringify({ host_tab: tabToken, ...shown })
return new Promise((resolve) => {
const req = request(
{
host: '127.0.0.1',
port: rt.httpPort,
path: '/v1/question_shown',
method: 'POST',
headers: {
Authorization: `Bearer ${rt.token}`,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body)
},
timeout: timeoutMs
},
(res) => {
res.resume()
resolve(res.statusCode === 200)
}
)
req.on('timeout', () => req.destroy())
req.on('error', () => resolve(false))
req.end(body)
})
}
8 changes: 8 additions & 0 deletions core/main/terminal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ describe('OutputBatcher', () => {
})

describe('ptyEnv', () => {
it('names the tab for Sonara and never keeps an inherited one (#323)', () => {
expect(ptyEnv({ PRISM_TAB_ID: 'old', Prism_Tab_Id: 'older' }, 'pwsh', undefined, undefined, 'tok-1')).toMatchObject({
PRISM_TAB_ID: 'tok-1'
})
const env = ptyEnv({ PRISM_TAB_ID: 'old' })
expect(Object.keys(env).some((k) => k.toUpperCase() === 'PRISM_TAB_ID')).toBe(false)
})

it('answers for what the panel can display, not for its launcher', () => {
const env = ptyEnv({ TERM: 'dumb', COLORTERM: undefined, PATH: 'C:\bin' })
expect(env.TERM).toBe('xterm-256color')
Expand Down
40 changes: 34 additions & 6 deletions core/main/terminal.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto'
import type { IPty } from 'node-pty'
import { cdCommand } from '../shared/termCwd'
import { detectShells, shellById } from './shells'
Expand Down Expand Up @@ -67,6 +68,10 @@ interface Session {
subs: Array<{ dispose(): void }>
/** Which shell this is, for the one line Prism may write into it (#99). */
defId: string
/** The shell's `PRISM_TAB_ID` (Sonara #323): Sonara's hook tags the
* Claude session it runs in with it, and the question follower names the
* tab to Sonara by it. One per shell, warm or not. */
tabToken: string
}

/**
Expand Down Expand Up @@ -162,7 +167,8 @@ export function ptyEnv(
from: NodeJS.ProcessEnv,
shellId?: string,
plugin?: ClaudePluginEnv,
isOurs?: (d: string) => boolean
isOurs?: (d: string) => boolean,
tabToken?: string
): Record<string, string> {
const env: Record<string, string> = {}
let prompt: string | undefined
Expand All @@ -178,6 +184,11 @@ export function ptyEnv(
}
env.TERM = PTY_OPTS.name
env.COLORTERM = 'truecolor'
// The tab, for Sonara (#323): its Claude hook reads PRISM_TAB_ID into the
// session's host tab, and the question follower names the tab by it. A
// value inherited from a Prism the app was started in is never kept.
for (const k of Object.keys(env)) if (k.toUpperCase() === 'PRISM_TAB_ID') delete env[k]
if (tabToken) env.PRISM_TAB_ID = tabToken
// NO FORCE_HYPERLINK (#173 set it; review 2026-10-11 took it out). Claude
// Code prints OSC 8 links only when told (MEASURED 2.1.296: "label (long
// url)" without it), but supports-hyperlinks, which it and the vercel CLI
Expand Down Expand Up @@ -219,6 +230,11 @@ export function cdTerm(id: string, path: string): boolean {

const sessions = new Map<string, Session>()

/** The `PRISM_TAB_ID` of tab `id`'s shell (Sonara #323), if it has one. */
export function tabToken(id: string): string | undefined {
return sessions.get(id)?.tabToken
}

/**
* Warm shells, one per recently-active root (two at most). Opening a terminal
* used to pay the whole bill at the click - chunk, pty, pwsh startup, the
Expand All @@ -236,6 +252,8 @@ interface WarmShell {
buf: string
sub: { dispose(): void }
exited: boolean
/** Its `PRISM_TAB_ID`, carried to the tab that adopts it. */
tabToken: string
}
const warm = new Map<string, WarmShell>()
const rootKey = (root: string): string => root.toLowerCase()
Expand Down Expand Up @@ -328,14 +346,23 @@ export async function prewarmShell(
try {
const pty = await import('node-pty')
const size = desiredSize.get('') ?? { cols: 80, rows: 24 }
const tabToken = randomUUID()
const p = pty.spawn(def.exe, def.args, {
...PTY_OPTS,
cols: size.cols,
rows: size.rows,
cwd: root,
env: ptyEnv(process.env, def.id, plugin, isOurPlugin)
env: ptyEnv(process.env, def.id, plugin, isOurPlugin, tabToken)
})
const w: WarmShell = { pty: p, defId: def.id, plugin: pluginKey(plugin), buf: '', sub: { dispose: () => {} }, exited: false }
const w: WarmShell = {
pty: p,
defId: def.id,
plugin: pluginKey(plugin),
buf: '',
sub: { dispose: () => {} },
exited: false,
tabToken
}
w.sub = p.onData((d) => {
// The banner and prompt, kept for replay. Capped: a warm shell should
// be quiet, and a runaway one is not worth adopting anyway.
Expand Down Expand Up @@ -501,7 +528,7 @@ async function spawnPending(
send('term:exit', id)
})
]
sessions.set(id, { pty: w.pty, batcher, subs, defId: w.defId })
sessions.set(id, { pty: w.pty, batcher, subs, defId: w.defId, tabToken: w.tabToken })
shellCrumb('shell-spawn', { id, pid: w.pty.pid, shell: def.id, warm: true })
const want = desiredSize.get(id)
if (want) resizeTerm(id, want.cols, want.rows)
Expand All @@ -512,12 +539,13 @@ async function spawnPending(
const pty = await import('node-pty')
const size = desiredSize.get(id) ?? { cols: 80, rows: 24 }
const launch = resume ? withResume(def, resume) : def
const tabToken = randomUUID()
const p = pty.spawn(launch.exe, launch.args, {
...PTY_OPTS,
cols: size.cols,
rows: size.rows,
cwd: root,
env: { ...ptyEnv(process.env, def.id, plugin, isOurPlugin), ...('env' in launch ? launch.env : {}) }
env: { ...ptyEnv(process.env, def.id, plugin, isOurPlugin, tabToken), ...('env' in launch ? launch.env : {}) }
})
// Closed while node-pty loaded: the tab is gone, so is this shell.
if (killedWhilePending.has(id)) {
Expand All @@ -537,7 +565,7 @@ async function spawnPending(
send('term:exit', id)
})
]
sessions.set(id, { pty: p, batcher, subs, defId: def.id })
sessions.set(id, { pty: p, batcher, subs, defId: def.id, tabToken })
shellCrumb('shell-spawn', { id, pid: p.pid, shell: def.id, cwd: root, resume: !!resume, ms: Math.round(performance.now() - t0) })
return true
} catch (err) {
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.40.12",
"version": "0.41.0",
"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
4 changes: 4 additions & 0 deletions core/preload/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { DetectedAgent, ShellDef } from '../shared/types'
import { CH } from '../shared/channels'
import type { ShownQuestion } from '../shared/questionShown'
import { isWebUrl } from '../shared/webUrl'

/**
Expand Down Expand Up @@ -50,6 +51,8 @@ export interface TermPreloadApi {
termAgentLook(): void
/** Ask the process poll for this session's next answer, changed or not. */
termAgentAgain(id: string): void
/** The question Claude Code's widget shows in tab `id` (Sonara #323). */
termQuestion(id: string, shown: ShownQuestion): void
/** The shell ended by itself (`exit`); a shell main was told to kill says nothing. */
onTermExit(cb: (id: string) => void): () => void
/**
Expand Down Expand Up @@ -119,6 +122,7 @@ export function createTermApi(ipc: IpcRendererLike): TermPreloadApi {
onTermAgent: (cb) => on(CH.agent, cb),
termAgentLook: () => ipc.send(CH.agentLook),
termAgentAgain: (id) => ipc.send(CH.agentAgain, id),
termQuestion: (id, shown) => ipc.send(CH.question, id, shown),
onTermExit: (cb) => on(CH.exit, cb),
readClipboard: () => ipc.sendSync(CH.clipboardRead) as ClipboardRead,
readClipboardAsync: () => ipc.invoke(CH.clipboardReadAsync) as Promise<ClipboardRead>,
Expand Down
5 changes: 5 additions & 0 deletions core/renderer/components/TerminalPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
takeResume
} from '../lib/termActivity'
import { isKey, termKeyAction } from '../lib/termKeys'
import { watchQuestions } from '../questionWatch'
import '@xterm/xterm/css/xterm.css'

// The terminal surface. This module is a lazy chunk (xterm is ~350KB the
Expand Down Expand Up @@ -387,14 +388,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 391 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 398 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 @@ -476,7 +477,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 480 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 @@ -484,7 +485,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 488 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 @@ -495,7 +496,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 499 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 @@ -509,7 +510,7 @@
* 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 513 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()
Expand All @@ -520,7 +521,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 524 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 @@ -544,7 +545,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 548 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 @@ -599,7 +600,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 603 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 @@ -1041,6 +1042,9 @@
// 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)))
// Claude Code's question widget: the question the user moved to is told
// to Sonara, the text-to-speech reader, which reads it (Sonara #323).
const questions = watchQuestions(term, (shown) => termApi().termQuestion?.(id, shown))
// "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.
Expand Down Expand Up @@ -1068,6 +1072,7 @@
el.addEventListener('mousedown', copyDown, true)
window.addEventListener('mouseup', copyUp, true)
const unsub = [
() => questions.dispose(),
() => {
el.removeEventListener('mousedown', copyDown, true)
window.removeEventListener('mouseup', copyUp, true)
Expand Down
Loading
Loading