authorgravatar for 35393046+lnieuwenhuis@users.noreply.github.comLars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> 2026-09-04 13:41:57+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 11:41:57+00:00
logd5b94100863057fb4629f9ad4a35753d16917924
tree46a5b3a3749a836ca5b654d57bb25977591122e7
parentd487dfbf46be344e818725be70ee04be2436bfb4
signature Signed by PGP key B5690EEEBB952194

feat(mobile): paste the phone clipboard into the terminal (#9199)

Co-authored-by: Jake Leventhal <jakeleventhal@me.com> Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com>

8 files changed, 520 insertions(+), 48 deletions(-)

apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+91-47
......@@ -6,6 +6,8 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac
66import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
77import { useCallback, useEffect, useMemo, useRef, useState } from "react";
88import { Platform, Pressable, View } from "react-native";
9import * as Clipboard from "expo-clipboard";
10import * as Schema from "effect/Schema";
911import {
1012 KeyboardController,
1113 KeyboardEvents,
......@@ -27,6 +29,7 @@ import { environmentCatalog } from "../../connection/catalog";
2729import { useEnvironmentPresentation } from "../../state/presentation";
2830import { terminalEnvironment } from "../../state/terminal";
2931import { useAtomCommand } from "../../state/use-atom-command";
32import { useServerConfigs } from "../../state/entities";
3033import { useWorkspaceState } from "../../state/workspace";
3134import {
3235 MAX_TERMINAL_FONT_SIZE,
......@@ -65,6 +68,13 @@ import {
6568 resolveTerminalSessionLabel,
6669 type TerminalMenuSession,
6770} from "./terminalMenu";
71import {
72 hostPlatformFromOs,
73 resolveModifiedTerminalInput,
74 type HostPlatform,
75 type PendingModifier,
76} from "./terminalInput";
77import { createTerminalPasteSession } from "./terminalPaste";
6878import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState";
6979
7080const DEFAULT_TERMINAL_COLS = 80;
......@@ -72,12 +82,19 @@ const DEFAULT_TERMINAL_ROWS = 24;
7282const TERMINAL_ACCESSORY_HEIGHT = 52;
7383const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
7484
75type PendingModifier = "ctrl" | "meta";
76type HostPlatform = "mac" | "linux" | "windows" | "unknown";
85class TerminalClipboardReadError extends Schema.TaggedErrorClass<TerminalClipboardReadError>()(
86 "TerminalClipboardReadError",
87 { terminalId: Schema.String, cause: Schema.Defect() },
88) {
89 override get message(): string {
90 return `Failed to read the clipboard for a paste into terminal ${this.terminalId}.`;
91 }
92}
7793
7894type TerminalToolbarAction =
7995 | { readonly kind: "send"; readonly key: string; readonly label: string; readonly data: string }
8096 | { readonly kind: "clear"; readonly key: string; readonly label: string }
97 | { readonly kind: "paste"; readonly key: string; readonly label: string }
8198 | {
8299 readonly kind: "modifier";
83100 readonly key: string;
......@@ -114,28 +131,6 @@ function inferHostPlatform(environmentLabel: string | null): HostPlatform {
114131 return "unknown";
115132}
116133
117function applyCtrlModifier(input: string): string {
118 const firstCharacter = input[0];
119 if (!firstCharacter) {
120 return input;
121 }
122
123 const lowerCharacter = firstCharacter.toLowerCase();
124 if (lowerCharacter >= "a" && lowerCharacter <= "z") {
125 return String.fromCharCode(lowerCharacter.charCodeAt(0) - 96);
126 }
127
128 if (firstCharacter === "@") return "\u0000";
129 if (firstCharacter === "[") return "\u001b";
130 if (firstCharacter === "\\") return "\u001c";
131 if (firstCharacter === "]") return "\u001d";
132 if (firstCharacter === "^") return "\u001e";
133 if (firstCharacter === "_") return "\u001f";
134 if (firstCharacter === "?") return "\u007f";
135
136 return input;
137}
138
139134function pickRunningTerminalSessionForBootstrap(
140135 sessions: ReadonlyArray<KnownTerminalSession>,
141136): KnownTerminalSession | null {
......@@ -462,9 +457,17 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
462457 });
463458 }, [terminal.buffer, terminal.buffer.length, terminalKey]);
464459 const cwd = terminal.summary?.cwd ?? selectedThreadProject?.workspaceRoot ?? null;
460 const serverConfigs = useServerConfigs();
461 const hostOs =
462 routeEnvironmentId === null
463 ? null
464 : (serverConfigs.get(routeEnvironmentId)?.environment.platform.os ?? null);
465 // The descriptor is authoritative; the label is only a hint until it arrives.
465466 const hostPlatform = useMemo(
466 () => inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null),
467 [selectedEnvironmentConnection?.environmentLabel],
467 () =>
468 hostPlatformFromOs(hostOs) ??
469 inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null),
470 [hostOs, selectedEnvironmentConnection?.environmentLabel],
468471 );
469472
470473 const terminalTheme = getMobileTerminalTheme(themeId, appearanceScheme);
......@@ -488,6 +491,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
488491 { kind: "send", key: "esc", label: "esc", data: "\u001b" },
489492 ...modifierActions,
490493 { kind: "send", key: "tab", label: "tab", data: "\t" },
494 { kind: "paste", key: "paste", label: "paste" },
491495 { kind: "clear", key: "clear", label: "clear" },
492496 { kind: "send", key: "up", label: "↑", data: "\u001b[A" },
493497 { kind: "send", key: "down", label: "↓", data: "\u001b[B" },
......@@ -693,13 +697,14 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
693697 setHasMeasuredSurface(true);
694698 }, [routeEnvironmentId, routeThreadId, terminalId]);
695699
700 /** Resolves true once the pty accepted the write, false if it was skipped or rejected. */
696701 const writeInput = useCallback(
697 (data: string) => {
702 async (data: string): Promise<boolean> => {
698703 if (!selectedThread || !isRunning) {
699 return;
704 return false;
700705 }
701706
702 void writeTerminal({
707 const result = await writeTerminal({
703708 environmentId: selectedThread.environmentId,
704709 input: {
705710 threadId: selectedThread.id,
......@@ -707,27 +712,67 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
707712 data,
708713 },
709714 });
715 return result._tag === "Success";
710716 },
711717 [isRunning, selectedThread, terminalId, writeTerminal],
712718 );
713719
720 const pasteSessionRef = useRef<ReturnType<typeof createTerminalPasteSession> | null>(null);
721 if (pasteSessionRef.current === null) {
722 pasteSessionRef.current = createTerminalPasteSession();
723 }
724 const pasteSession = pasteSessionRef.current;
725
726 // Drop delayed clipboard reads whenever the route or attached pty changes.
727 useEffect(() => {
728 pasteSession.reset(isRunning);
729 return () => {
730 pasteSession.reset(false);
731 };
732 }, [isRunning, pasteSession, terminal.lifecycleVersion, terminalKey]);
733
734 const pasteFromClipboard = useCallback(async () => {
735 await pasteSession.paste({
736 readText: Clipboard.getStringAsync,
737 write: writeInput,
738 onReadError: (cause) => {
739 console.error(new TerminalClipboardReadError({ terminalId, cause }));
740 },
741 });
742 }, [pasteSession, terminalId, writeInput]);
743
744 /** Sends a key through the armed toolbar modifier, if any, and disarms it. */
745 const writeModifiedInput = useCallback(
746 (data: string) => {
747 if (pendingModifier === null) {
748 void writeInput(data);
749 return;
750 }
751
752 setPendingModifierState({ terminalId, value: null });
753 const resolved = resolveModifiedTerminalInput({
754 data,
755 modifier: pendingModifier,
756 hostPlatform,
757 });
758 if (resolved.kind === "paste") {
759 void pasteFromClipboard();
760 return;
761 }
762 void writeInput(resolved.data);
763 },
764 [hostPlatform, pasteFromClipboard, pendingModifier, terminalId, writeInput],
765 );
766
714767 const handleInput = useCallback(
715768 (data: string) => {
716769 if (data.length === 0) {
717770 return;
718771 }
719772
720 if (pendingModifier === "ctrl") {
721 setPendingModifierState({ terminalId, value: null });
722 writeInput(applyCtrlModifier(data));
723 } else if (pendingModifier === "meta") {
724 setPendingModifierState({ terminalId, value: null });
725 writeInput(`\u001b${data}`);
726 } else {
727 writeInput(data);
728 }
773 writeModifiedInput(data);
729774 },
730 [pendingModifier, terminalId, writeInput],
775 [writeModifiedInput],
731776 );
732777
733778 const handleResize = useCallback(
......@@ -1021,16 +1066,15 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
10211066 return;
10221067 }
10231068
1024 setPendingModifierState({ terminalId, value: null });
1025 if (pendingModifier === "ctrl") {
1026 writeInput(applyCtrlModifier(action.data));
1027 } else if (pendingModifier === "meta") {
1028 writeInput(`\u001b${action.data}`);
1029 } else {
1030 writeInput(action.data);
1069 if (action.kind === "paste") {
1070 setPendingModifierState({ terminalId, value: null });
1071 void pasteFromClipboard();
1072 return;
10311073 }
1074
1075 writeModifiedInput(action.data);
10321076 },
1033 [handleClearTerminal, pendingModifier, terminalId, writeInput],
1077 [handleClearTerminal, pasteFromClipboard, terminalId, writeModifiedInput],
10341078 );
10351079
10361080 const handleDismissKeyboard = useCallback(() => {
apps/mobile/src/features/terminal/terminalInput.test.ts created+125
......@@ -0,0 +1,125 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 applyCtrlModifier,
5 chunkTerminalWrite,
6 encodeTerminalPaste,
7 hostPlatformFromOs,
8 resolveModifiedTerminalInput,
9 TERMINAL_WRITE_MAX_LENGTH,
10} from "./terminalInput";
11
12const byte = (code: number) => String.fromCharCode(code);
13const ESC = byte(0x1b);
14const CTRL_C = byte(0x03);
15const CTRL_V = byte(0x16);
16
17describe("applyCtrlModifier", () => {
18 it("maps letters to control bytes regardless of case", () => {
19 expect(applyCtrlModifier("c")).toBe(CTRL_C);
20 expect(applyCtrlModifier("C")).toBe(CTRL_C);
21 expect(applyCtrlModifier("z")).toBe(byte(0x1a));
22 });
23
24 it("maps the punctuation control keys and leaves the rest untouched", () => {
25 expect(applyCtrlModifier("[")).toBe(ESC);
26 expect(applyCtrlModifier("?")).toBe(byte(0x7f));
27 expect(applyCtrlModifier("1")).toBe("1");
28 expect(applyCtrlModifier("")).toBe("");
29 });
30});
31
32describe("resolveModifiedTerminalInput", () => {
33 it("pastes on ctrl+v for windows, linux, and unknown hosts", () => {
34 for (const hostPlatform of ["windows", "linux", "unknown"] as const) {
35 expect(resolveModifiedTerminalInput({ data: "v", modifier: "ctrl", hostPlatform })).toEqual({
36 kind: "paste",
37 });
38 expect(resolveModifiedTerminalInput({ data: "V", modifier: "ctrl", hostPlatform })).toEqual({
39 kind: "paste",
40 });
41 }
42 });
43
44 it("keeps alt+v as a meta chord on non-mac hosts", () => {
45 expect(
46 resolveModifiedTerminalInput({ data: "v", modifier: "meta", hostPlatform: "windows" }),
47 ).toEqual({ kind: "write", data: `${ESC}v` });
48 });
49
50 it("pastes on cmd+v and forwards raw ctrl+v on mac hosts", () => {
51 expect(
52 resolveModifiedTerminalInput({ data: "v", modifier: "meta", hostPlatform: "mac" }),
53 ).toEqual({ kind: "paste" });
54 expect(
55 resolveModifiedTerminalInput({ data: "v", modifier: "ctrl", hostPlatform: "mac" }),
56 ).toEqual({ kind: "write", data: CTRL_V });
57 });
58
59 it("still encodes every other modified key", () => {
60 expect(
61 resolveModifiedTerminalInput({ data: "c", modifier: "ctrl", hostPlatform: "windows" }),
62 ).toEqual({ kind: "write", data: CTRL_C });
63 expect(
64 resolveModifiedTerminalInput({ data: "[A", modifier: "meta", hostPlatform: "linux" }),
65 ).toEqual({ kind: "write", data: `${ESC}[A` });
66 });
67});
68
69describe("encodeTerminalPaste", () => {
70 it("passes single-line text through unchanged", () => {
71 expect(encodeTerminalPaste("git switch -c fix/paste")).toBe("git switch -c fix/paste");
72 expect(encodeTerminalPaste("")).toBe("");
73 });
74
75 it("turns LF and CRLF line breaks into a single carriage return each", () => {
76 expect(encodeTerminalPaste("one\ntwo\r\nthree\n")).toBe("one\rtwo\rthree\r");
77 });
78
79 it("replaces unsafe control bytes with spaces but keeps tabs", () => {
80 expect(encodeTerminalPaste(`a${byte(0)}b${ESC}c${byte(0x7f)}d\te`)).toBe("a b c d\te");
81 });
82
83 it("never lets a bracketed-paste end marker reach the shell", () => {
84 expect(encodeTerminalPaste(`safe${ESC}[201~; rm -rf /\n`)).toBe("safe [201~; rm -rf /\r");
85 });
86});
87
88describe("chunkTerminalWrite", () => {
89 it("leaves writes within the wire limit whole", () => {
90 expect(chunkTerminalWrite("")).toEqual([]);
91 expect(chunkTerminalWrite("ls")).toEqual(["ls"]);
92 expect(chunkTerminalWrite("x".repeat(TERMINAL_WRITE_MAX_LENGTH))).toHaveLength(1);
93 });
94
95 it("splits oversized writes so every chunk fits the contract", () => {
96 const chunks = chunkTerminalWrite("y".repeat(TERMINAL_WRITE_MAX_LENGTH * 2 + 5));
97 expect(chunks.map((chunk) => chunk.length)).toEqual([
98 TERMINAL_WRITE_MAX_LENGTH,
99 TERMINAL_WRITE_MAX_LENGTH,
100 5,
101 ]);
102 expect(chunks.join("")).toHaveLength(TERMINAL_WRITE_MAX_LENGTH * 2 + 5);
103 });
104
105 it("does not cut a surrogate pair in half at the boundary", () => {
106 const data = `${"z".repeat(TERMINAL_WRITE_MAX_LENGTH - 1)}😀tail`;
107 const chunks = chunkTerminalWrite(data);
108 expect(chunks[0]).toHaveLength(TERMINAL_WRITE_MAX_LENGTH - 1);
109 expect(chunks[1]).toBe("😀tail");
110 expect(chunks.join("")).toBe(data);
111 });
112});
113
114describe("hostPlatformFromOs", () => {
115 it("maps the descriptor os onto the toolbar layout", () => {
116 expect(hostPlatformFromOs("darwin")).toBe("mac");
117 expect(hostPlatformFromOs("windows")).toBe("windows");
118 expect(hostPlatformFromOs("linux")).toBe("linux");
119 });
120
121 it("defers to the caller when the os is unknown or not loaded yet", () => {
122 expect(hostPlatformFromOs("unknown")).toBeNull();
123 expect(hostPlatformFromOs(null)).toBeNull();
124 });
125});
apps/mobile/src/features/terminal/terminalInput.ts created+108
......@@ -0,0 +1,108 @@
1import type { ExecutionEnvironmentPlatformOs } from "@t3tools/contracts";
2
3export type PendingModifier = "ctrl" | "meta";
4export type HostPlatform = "mac" | "linux" | "windows" | "unknown";
5
6/** Upper bound of `TerminalWriteInput.data`; longer writes are rejected by the server. */
7export const TERMINAL_WRITE_MAX_LENGTH = 65_536;
8
9export type ModifiedTerminalInput =
10 | { readonly kind: "write"; readonly data: string }
11 | { readonly kind: "paste" };
12
13// C0 controls other than tab, LF, and CR, plus DEL.
14// eslint-disable-next-line no-control-regex -- Pasted text must not carry raw terminal controls.
15const UNSAFE_PASTE_BYTES = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g;
16
17/**
18 * Encodes a key pressed while the toolbar's one-shot ctrl modifier is armed
19 * into the control byte a terminal expects.
20 */
21export function applyCtrlModifier(input: string): string {
22 const firstCharacter = input[0];
23 if (!firstCharacter) {
24 return input;
25 }
26
27 const lowerCharacter = firstCharacter.toLowerCase();
28 if (lowerCharacter >= "a" && lowerCharacter <= "z") {
29 return String.fromCharCode(lowerCharacter.charCodeAt(0) - 96);
30 }
31
32 if (firstCharacter === "@") return "\u0000";
33 if (firstCharacter === "[") return "\u001b";
34 if (firstCharacter === "\\") return "\u001c";
35 if (firstCharacter === "]") return "\u001d";
36 if (firstCharacter === "^") return "\u001e";
37 if (firstCharacter === "_") return "\u001f";
38 if (firstCharacter === "?") return "\u007f";
39
40 return input;
41}
42
43/**
44 * Resolves what a keypress means once a toolbar modifier is armed. The host's
45 * paste chord (cmd+v on a macOS host, ctrl+v elsewhere) pastes the device
46 * clipboard instead of reaching the remote shell as a raw control byte, which
47 * matches what the web terminal does with the same chord. Forwarding the byte
48 * is never what a phone user means: PowerShell binds ctrl+v to paste from the
49 * host machine's clipboard, so the shell inserts whatever the desktop last
50 * copied rather than the text on the phone.
51 */
52export function resolveModifiedTerminalInput(input: {
53 readonly data: string;
54 readonly modifier: PendingModifier;
55 readonly hostPlatform: HostPlatform;
56}): ModifiedTerminalInput {
57 const pasteModifier: PendingModifier = input.hostPlatform === "mac" ? "meta" : "ctrl";
58 if (input.modifier === pasteModifier && input.data.toLowerCase() === "v") {
59 return { kind: "paste" };
60 }
61
62 return {
63 kind: "write",
64 data: input.modifier === "ctrl" ? applyCtrlModifier(input.data) : `\u001b${input.data}`,
65 };
66}
67
68/**
69 * Encodes clipboard text for the remote pty the way the web terminal does when
70 * bracketed paste is off: unsafe control bytes become spaces (which also
71 * defuses an embedded bracketed-paste end marker, since its ESC goes too) and
72 * line breaks become carriage returns, since a bare LF is Ctrl+J to a raw-mode
73 * TUI. The native mobile surface does not expose DECSET 2004, so mobile never
74 * wraps a paste in bracketed-paste markers.
75 */
76export function encodeTerminalPaste(text: string): string {
77 return text.replace(UNSAFE_PASTE_BYTES, " ").replace(/\r\n|\n/g, "\r");
78}
79
80/**
81 * Splits terminal input into writes the wire contract accepts, never cutting
82 * through a surrogate pair so every chunk stays valid UTF-16.
83 */
84export function chunkTerminalWrite(data: string): ReadonlyArray<string> {
85 const chunks: string[] = [];
86 let start = 0;
87 while (start < data.length) {
88 let end = Math.min(start + TERMINAL_WRITE_MAX_LENGTH, data.length);
89 const last = data.charCodeAt(end - 1);
90 if (end < data.length && last >= 0xd800 && last <= 0xdbff) {
91 end -= 1;
92 }
93 chunks.push(data.slice(start, end));
94 start = end;
95 }
96 return chunks;
97}
98
99/**
100 * Maps the OS reported by the environment descriptor onto the toolbar's host
101 * layout. Returns null for "unknown" so callers can fall back to a weaker signal.
102 */
103export function hostPlatformFromOs(os: ExecutionEnvironmentPlatformOs | null): HostPlatform | null {
104 if (os === "darwin") return "mac";
105 if (os === "linux") return "linux";
106 if (os === "windows") return "windows";
107 return null;
108}
apps/mobile/src/features/terminal/terminalMenu.test.ts+1
......@@ -61,6 +61,7 @@ function makeKnownSession(input: {
6161 hasRunningSubprocess: false,
6262 updatedAt: input.updatedAt ?? "2026-04-15T20:00:00.000Z",
6363 version: 1,
64 lifecycleVersion: 1,
6465 },
6566 };
6667}
apps/mobile/src/features/terminal/terminalPaste.test.ts created+82
......@@ -0,0 +1,82 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { TERMINAL_WRITE_MAX_LENGTH } from "./terminalInput";
4import { createTerminalPasteSession } from "./terminalPaste";
5
6function deferred<T>() {
7 let resolve!: (value: T) => void;
8 const promise = new Promise<T>((resolvePromise) => {
9 resolve = resolvePromise;
10 });
11 return { promise, resolve };
12}
13
14describe("terminal paste session", () => {
15 it("drops a clipboard read when the pty restarts in place", async () => {
16 const session = createTerminalPasteSession();
17 session.reset(true);
18 const clipboardRead = deferred<string>();
19 const writes: string[] = [];
20
21 const paste = session.paste({
22 readText: () => clipboardRead.promise,
23 write: async (data) => {
24 writes.push(data);
25 return true;
26 },
27 onReadError: () => undefined,
28 });
29
30 session.reset(true);
31 clipboardRead.resolve("stale");
32 await paste;
33
34 expect(writes).toEqual([]);
35 });
36
37 it("never overlaps writes from rapid paste requests", async () => {
38 const session = createTerminalPasteSession();
39 session.reset(true);
40
41 const firstWrite = deferred<boolean>();
42 const firstWriteStarted = deferred<void>();
43 const writes: string[] = [];
44 let activeWrites = 0;
45 let maximumActiveWrites = 0;
46 const write = async (data: string) => {
47 writes.push(data);
48 activeWrites += 1;
49 maximumActiveWrites = Math.max(maximumActiveWrites, activeWrites);
50 if (writes.length === 1) {
51 firstWriteStarted.resolve();
52 await firstWrite.promise;
53 }
54 activeWrites -= 1;
55 return true;
56 };
57
58 const olderPaste = session.paste({
59 readText: async () => "a".repeat(TERMINAL_WRITE_MAX_LENGTH + 1),
60 write,
61 onReadError: () => undefined,
62 });
63 await firstWriteStarted.promise;
64
65 const newerPaste = session.paste({
66 readText: async () => "newer",
67 write,
68 onReadError: () => undefined,
69 });
70 await Promise.resolve();
71
72 expect(writes.map((chunk) => chunk.length)).toEqual([TERMINAL_WRITE_MAX_LENGTH]);
73 expect(maximumActiveWrites).toBe(1);
74
75 firstWrite.resolve(true);
76 await Promise.all([olderPaste, newerPaste]);
77
78 expect(writes.map((chunk) => chunk.length)).toEqual([TERMINAL_WRITE_MAX_LENGTH, 5]);
79 expect(writes[1]).toBe("newer");
80 expect(maximumActiveWrites).toBe(1);
81 });
82});
apps/mobile/src/features/terminal/terminalPaste.ts created+60
......@@ -0,0 +1,60 @@
1import { chunkTerminalWrite, encodeTerminalPaste } from "./terminalInput";
2
3interface TerminalPasteInput {
4 readonly readText: () => Promise<string>;
5 readonly write: (data: string) => Promise<boolean>;
6 readonly onReadError: (cause: unknown) => void;
7}
8
9export interface TerminalPasteSession {
10 readonly reset: (active: boolean) => void;
11 readonly paste: (input: TerminalPasteInput) => Promise<void>;
12}
13
14/** Coordinates clipboard reads and writes for the currently attached pty. */
15export function createTerminalPasteSession(): TerminalPasteSession {
16 let liveTarget: object | null = null;
17 let latestRequest = 0;
18 let writeTail: Promise<void> = Promise.resolve();
19
20 return {
21 reset(active) {
22 liveTarget = active ? {} : null;
23 },
24
25 async paste({ readText, write, onReadError }) {
26 const target = liveTarget;
27 if (target === null) {
28 return;
29 }
30 const request = ++latestRequest;
31 const isCurrent = () => liveTarget === target && latestRequest === request;
32
33 let text: string;
34 try {
35 text = await readText();
36 } catch (cause) {
37 onReadError(cause);
38 return;
39 }
40
41 if (!isCurrent()) {
42 return;
43 }
44
45 const writePaste = async () => {
46 for (const chunk of chunkTerminalWrite(encodeTerminalPaste(text))) {
47 if (!isCurrent() || !(await write(chunk))) {
48 return;
49 }
50 }
51 };
52 const queuedWrite = writeTail.then(writePaste, writePaste);
53 writeTail = queuedWrite.then(
54 () => undefined,
55 () => undefined,
56 );
57 await queuedWrite;
58 },
59 };
60}
packages/client-runtime/src/state/terminalSession.test.ts+38
......@@ -133,6 +133,44 @@ describe("terminal session reducers", () => {
133133 });
134134 });
135135
136 it("does not advance the lifecycle for the initial attach snapshot", () => {
137 const snapshot = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
138 type: "snapshot",
139 snapshot: BASE_SNAPSHOT,
140 });
141
142 expect(snapshot).toMatchObject({ status: "running", lifecycleVersion: 0 });
143 });
144
145 it("advances the lifecycle for a live started snapshot", () => {
146 const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
147 type: "snapshot",
148 snapshot: BASE_SNAPSHOT,
149 });
150 const started = applyTerminalAttachStreamEvent(initial, {
151 type: "snapshot",
152 snapshot: { ...BASE_SNAPSHOT, pid: 456 },
153 });
154
155 expect(started).toMatchObject({ status: "running", lifecycleVersion: 1 });
156 });
157
158 it("advances the lifecycle when a running terminal restarts in place", () => {
159 const snapshot = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
160 type: "snapshot",
161 snapshot: BASE_SNAPSHOT,
162 });
163 const restarted = applyTerminalAttachStreamEvent(snapshot, {
164 type: "restarted",
165 threadId: TARGET.threadId,
166 terminalId: TARGET.terminalId,
167 snapshot: { ...BASE_SNAPSHOT, pid: 456 },
168 });
169
170 expect(snapshot).toMatchObject({ status: "running", lifecycleVersion: 0 });
171 expect(restarted).toMatchObject({ status: "running", lifecycleVersion: 1 });
172 });
173
136174 it("reduces terminal metadata snapshots, upserts, and removals", () => {
137175 const initial = applyTerminalMetadataStreamEvent([], {
138176 type: "snapshot",
packages/client-runtime/src/state/terminalSession.ts+15-1
......@@ -15,6 +15,7 @@ export interface TerminalSessionState {
1515 readonly hasRunningSubprocess: boolean;
1616 readonly updatedAt: string | null;
1717 readonly version: number;
18 readonly lifecycleVersion: number;
1819}
1920
2021export interface TerminalBufferState {
......@@ -23,6 +24,7 @@ export interface TerminalBufferState {
2324 readonly error: string | null;
2425 readonly updatedAt: string | null;
2526 readonly version: number;
27 readonly lifecycleVersion: number;
2628}
2729
2830export interface KnownTerminalSessionTarget {
......@@ -50,6 +52,7 @@ export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze<TerminalBufferState>({
5052 error: null,
5153 updatedAt: null,
5254 version: 0,
55 lifecycleVersion: 0,
5356});
5457
5558export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze<TerminalSessionState>({
......@@ -60,6 +63,7 @@ export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze<TerminalSessionState>(
6063 hasRunningSubprocess: false,
6164 updatedAt: null,
6265 version: 0,
66 lifecycleVersion: 0,
6367});
6468
6569export const DEFAULT_MAX_TERMINAL_BUFFER_BYTES = 512 * 1024;
......@@ -98,6 +102,7 @@ export function terminalBufferStateFromSnapshot(
98102 error: null,
99103 updatedAt: snapshot.updatedAt,
100104 version: 1,
105 lifecycleVersion: 0,
101106 };
102107}
103108
......@@ -119,6 +124,7 @@ export function combineTerminalSessionState(
119124 hasRunningSubprocess: summary?.hasRunningSubprocess ?? false,
120125 updatedAt: latestTimestamp(summary?.updatedAt ?? null, buffer.updatedAt),
121126 version: buffer.version,
127 lifecycleVersion: buffer.lifecycleVersion,
122128 };
123129}
124130
......@@ -129,8 +135,16 @@ export function applyTerminalAttachStreamEvent(
129135): TerminalBufferState {
130136 switch (event.type) {
131137 case "snapshot":
138 return {
139 ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes),
140 lifecycleVersion:
141 current.version === 0 ? current.lifecycleVersion : current.lifecycleVersion + 1,
142 };
132143 case "restarted":
133 return terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes);
144 return {
145 ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes),
146 lifecycleVersion: current.lifecycleVersion + 1,
147 };
134148 case "output":
135149 return {
136150 ...current,