-
Notifications
You must be signed in to change notification settings - Fork 5.3k
fix(terminal): restore copying from Claude Code’s fullscreen TUI #9949
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
callmemorgan
wants to merge
16
commits into
pingdotgg:main
Choose a base branch
from
callmemorgan:t3code/fix-neo-fullscreen-copy
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,891
−123
Open
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
ad33ce7
fix(terminal): preserve fullscreen selection through native drags
callmemorgan 9d256b0
fix(terminal): support focused OSC 52 clipboard writes
callmemorgan b2f5e95
fix(terminal): preserve OSC clipboard text and write order
callmemorgan 4124f32
fix(terminal): clear completed selections on application scroll
callmemorgan f8e58e2
fix(mobile): copy live OSC 52 output on iOS and Android
callmemorgan 947f0ae
fix(terminal): honor empty OSC 52 clipboard writes
callmemorgan c07362c
fix(web): offer terminal copy on plain HTTP connections
callmemorgan d36bab5
fix(terminal): accept combined secondary clipboard selectors
callmemorgan e19814a
fix(web): offer copy after async clipboard rejection
callmemorgan ea6fd30
fix(mobile): parse clipboard requests before output retention
callmemorgan fa8ab05
fix(terminal): observe web clipboard writes before retention
callmemorgan e94635e
fix(mobile): preserve clipboard parsing across equivalent inputs
callmemorgan 113a441
fix(terminal): match clipboard observers to provider attachments
callmemorgan cbb0282
fix(terminal): retire invalid selection and pointer state
callmemorgan 8c458f6
fix(terminal): free selection coordinates when conversion fails
callmemorgan 8339d2e
fix(terminal): share clipboard lifecycle across clients
callmemorgan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| import { useFocusEffect } from "@react-navigation/native"; | ||
| import { environmentRpcKey } from "@t3tools/client-runtime/state/runtime"; | ||
| import { | ||
| createTerminalClipboardSession, | ||
| createTerminalClipboardWriter, | ||
| } from "@t3tools/client-runtime/terminal-clipboard"; | ||
| import type { EnvironmentId, TerminalAttachInput } from "@t3tools/contracts"; | ||
| import * as Clipboard from "expo-clipboard"; | ||
| import { useCallback, useLayoutEffect, useMemo, useRef } from "react"; | ||
| import { AppState, Platform } from "react-native"; | ||
| import { terminalEnvironment } from "../../state/terminal"; | ||
|
|
||
| const writeClipboard = createTerminalClipboardWriter((text) => Clipboard.setStringAsync(text)); | ||
|
|
||
| /** Route focus, not keyboard visibility, owns touch-driven TUI copies on iOS and Android. */ | ||
| export function useTerminalClipboard( | ||
| environmentId: EnvironmentId | null, | ||
| input: TerminalAttachInput | null, | ||
| ) { | ||
| const target = useMemo( | ||
| () => (environmentId !== null && input !== null ? { environmentId, input } : null), | ||
| [environmentId, input], | ||
| ); | ||
| const targetKey = useMemo(() => (target === null ? null : environmentRpcKey(target)), [target]); | ||
| const latestTarget = useRef(target); | ||
| useLayoutEffect(() => { | ||
| latestTarget.current = target; | ||
| }, [target]); | ||
|
|
||
| useFocusEffect( | ||
| useCallback(() => { | ||
| // The key, not the object, restarts the session: equal attach values | ||
| // recreated by a render must not drop a copy that is mid-sequence. | ||
| const target = targetKey === null ? null : latestTarget.current; | ||
| if (target === null) return; | ||
| let active = false; | ||
| const session = createTerminalClipboardSession({ | ||
| isEligible: () => active, | ||
| onCopy: (text, canWrite) => void writeClipboard(text, canWrite), | ||
| }); | ||
| const setActive = (next: boolean) => { | ||
| active = next; | ||
| if (!next) session.invalidate(); | ||
| }; | ||
| const stop = terminalEnvironment.observeAttach(target, session.update); | ||
| const activate = () => setActive(AppState.currentState === "active"); | ||
| activate(); | ||
| const change = AppState.addEventListener("change", activate); | ||
| // Android can lose interaction focus without changing AppState (e.g. its notification drawer). | ||
| const blur = | ||
| Platform.OS === "android" | ||
| ? AppState.addEventListener("blur", () => setActive(false)) | ||
| : undefined; | ||
| const focus = | ||
| Platform.OS === "android" ? AppState.addEventListener("focus", activate) : undefined; | ||
| return () => { | ||
| setActive(false); | ||
| stop(); | ||
| change.remove(); | ||
| blur?.remove(); | ||
| focus?.remove(); | ||
| }; | ||
| }, [targetKey]), | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| import { copyTerminalClipboardFromGesture, writeTerminalClipboard } from "./clipboard"; | ||
|
|
||
| describe("application clipboard writes", () => { | ||
| afterEach(() => vi.unstubAllGlobals()); | ||
|
|
||
| it.each(["text", ""])("copies %j during a gesture and removes its copy handler", (text) => { | ||
| const setData = vi.fn(); | ||
| const document = Object.assign(new EventTarget(), { | ||
| execCommand: () => | ||
| document.dispatchEvent( | ||
| Object.assign(new Event("copy", { cancelable: true }), { clipboardData: { setData } }), | ||
| ), | ||
| }); | ||
| vi.stubGlobal("document", document); | ||
| expect(setData).not.toHaveBeenCalled(); | ||
| expect(copyTerminalClipboardFromGesture(text)).toBe(true); | ||
| expect(setData.mock.calls).toEqual([["text/plain", text]]); | ||
| document.execCommand(); | ||
| expect(setData).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it("reports a blocked gesture without leaving a copy handler installed", () => { | ||
| const document = Object.assign(new EventTarget(), { | ||
| execCommand: () => { | ||
| throw new Error("denied"); | ||
| }, | ||
| }); | ||
| vi.stubGlobal("document", document); | ||
| expect(copyTerminalClipboardFromGesture("blocked")).toBe(false); | ||
| const setData = vi.fn(); | ||
| document.dispatchEvent(Object.assign(new Event("copy"), { clipboardData: { setData } })); | ||
| expect(setData).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it.each(["success", "denied"])( | ||
| "serializes writes and keeps only the newest pending text after %s", | ||
| async (result) => { | ||
| let finishFirst!: () => void; | ||
| const first = new Promise<void>((resolve) => { | ||
| finishFirst = resolve; | ||
| }); | ||
| let clipboard = ""; | ||
| const writeText = vi.fn(async (text: string) => { | ||
| if (text === "first") { | ||
| await first; | ||
| if (result === "denied") throw new Error("NotAllowedError"); | ||
| } | ||
| clipboard = text; | ||
| }); | ||
| vi.stubGlobal("navigator", { clipboard: { writeText } }); | ||
| const writes = [ | ||
| writeTerminalClipboard("first"), | ||
| writeTerminalClipboard("superseded"), | ||
| writeTerminalClipboard("last"), | ||
| ]; | ||
| const callsWhilePending = [...writeText.mock.calls]; | ||
| finishFirst(); | ||
| expect(await Promise.all(writes)).toEqual([ | ||
| result === "denied" ? "failed" : "written", | ||
| "skipped", | ||
| "written", | ||
| ]); | ||
| expect(callsWhilePending).toEqual([["first"]]); | ||
| expect(writeText.mock.calls).toEqual([["first"], ["last"]]); | ||
| expect(clipboard).toBe("last"); | ||
| }, | ||
| ); | ||
|
|
||
| it.each(["success", "denied", "unavailable"])( | ||
| "keeps browser focus and consumes failures when clipboard access is %s", | ||
| async (result) => { | ||
| const writeText = vi.fn(() => | ||
| result === "denied" ? Promise.reject(new Error("NotAllowedError")) : Promise.resolve(), | ||
| ); | ||
| const createElement = vi.fn(); | ||
| const execCommand = vi.fn(); | ||
| vi.stubGlobal("navigator", result === "unavailable" ? {} : { clipboard: { writeText } }); | ||
| vi.stubGlobal("document", { createElement, execCommand }); | ||
| await expect(writeTerminalClipboard("application text")).resolves.toBe( | ||
| result === "success" ? "written" : "failed", | ||
| ); | ||
| await expect(writeTerminalClipboard("inactive", () => false)).resolves.toBe("skipped"); | ||
| expect(writeText.mock.calls).toEqual(result === "unavailable" ? [] : [["application text"]]); | ||
| expect(createElement).not.toHaveBeenCalled(); | ||
| expect(execCommand).not.toHaveBeenCalled(); | ||
| }, | ||
| ); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import { createTerminalClipboardWriter } from "@t3tools/client-runtime/terminal-clipboard"; | ||
|
|
||
| /** Application output cannot use the focus-stealing, user-gesture copy fallback. */ | ||
| export const writeTerminalClipboard = createTerminalClipboardWriter((text) => | ||
| navigator.clipboard?.writeText(text), | ||
| ); | ||
|
|
||
| /** A click can authorize copying on HTTP pages, including clearing with empty text. */ | ||
| export function copyTerminalClipboardFromGesture(text: string): boolean { | ||
| let copied = false; | ||
| const onCopy = (event: ClipboardEvent) => { | ||
| if (!event.clipboardData) return; | ||
| event.clipboardData.setData("text/plain", text); | ||
| event.preventDefault(); | ||
| event.stopPropagation(); | ||
| copied = true; | ||
| }; | ||
| document.addEventListener("copy", onCopy, { capture: true }); | ||
| try { | ||
| document.execCommand("copy"); | ||
| return copied; | ||
| } catch { | ||
| return false; | ||
| } finally { | ||
| document.removeEventListener("copy", onCopy, { capture: true }); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.