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...@@ -6,6 +6,8 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac
6import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";6import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
7import { useCallback, useEffect, useMemo, useRef, useState } from "react";7import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8import { Platform, Pressable, View } from "react-native";8import { Platform, Pressable, View } from "react-native";
9import * as Clipboard from "expo-clipboard";
10import * as Schema from "effect/Schema";
9import {11import {
10 KeyboardController,12 KeyboardController,
11 KeyboardEvents,13 KeyboardEvents,
...@@ -27,6 +29,7 @@ import { environmentCatalog } from "../../connection/catalog";...@@ -27,6 +29,7 @@ import { environmentCatalog } from "../../connection/catalog";
27import { useEnvironmentPresentation } from "../../state/presentation";29import { useEnvironmentPresentation } from "../../state/presentation";
28import { terminalEnvironment } from "../../state/terminal";30import { terminalEnvironment } from "../../state/terminal";
29import { useAtomCommand } from "../../state/use-atom-command";31import { useAtomCommand } from "../../state/use-atom-command";
32import { useServerConfigs } from "../../state/entities";
30import { useWorkspaceState } from "../../state/workspace";33import { useWorkspaceState } from "../../state/workspace";
31import {34import {
32 MAX_TERMINAL_FONT_SIZE,35 MAX_TERMINAL_FONT_SIZE,
...@@ -65,6 +68,13 @@ import {...@@ -65,6 +68,13 @@ import {
65 resolveTerminalSessionLabel,68 resolveTerminalSessionLabel,
66 type TerminalMenuSession,69 type TerminalMenuSession,
67} from "./terminalMenu";70} from "./terminalMenu";
71import {
72 hostPlatformFromOs,
73 resolveModifiedTerminalInput,
74 type HostPlatform,
75 type PendingModifier,
76} from "./terminalInput";
77import { createTerminalPasteSession } from "./terminalPaste";
68import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState";78import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState";
6979
70const DEFAULT_TERMINAL_COLS = 80;80const DEFAULT_TERMINAL_COLS = 80;
...@@ -72,12 +82,19 @@ const DEFAULT_TERMINAL_ROWS = 24;...@@ -72,12 +82,19 @@ const DEFAULT_TERMINAL_ROWS = 24;
72const TERMINAL_ACCESSORY_HEIGHT = 52;82const TERMINAL_ACCESSORY_HEIGHT = 52;
73const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";83const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
7484
75type PendingModifier = "ctrl" | "meta";85class TerminalClipboardReadError extends Schema.TaggedErrorClass<TerminalClipboardReadError>()(
76type HostPlatform = "mac" | "linux" | "windows" | "unknown";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
78type TerminalToolbarAction =94type TerminalToolbarAction =
79 | { readonly kind: "send"; readonly key: string; readonly label: string; readonly data: string }95 | { readonly kind: "send"; readonly key: string; readonly label: string; readonly data: string }
80 | { readonly kind: "clear"; readonly key: string; readonly label: string }96 | { readonly kind: "clear"; readonly key: string; readonly label: string }
97 | { readonly kind: "paste"; readonly key: string; readonly label: string }
81 | {98 | {
82 readonly kind: "modifier";99 readonly kind: "modifier";
83 readonly key: string;100 readonly key: string;
...@@ -114,28 +131,6 @@ function inferHostPlatform(environmentLabel: string | null): HostPlatform {...@@ -114,28 +131,6 @@ function inferHostPlatform(environmentLabel: string | null): HostPlatform {
114 return "unknown";131 return "unknown";
115}132}
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
139function pickRunningTerminalSessionForBootstrap(134function pickRunningTerminalSessionForBootstrap(
140 sessions: ReadonlyArray<KnownTerminalSession>,135 sessions: ReadonlyArray<KnownTerminalSession>,
141): KnownTerminalSession | null {136): KnownTerminalSession | null {
...@@ -462,9 +457,17 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -462,9 +457,17 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
462 });457 });
463 }, [terminal.buffer, terminal.buffer.length, terminalKey]);458 }, [terminal.buffer, terminal.buffer.length, terminalKey]);
464 const cwd = terminal.summary?.cwd ?? selectedThreadProject?.workspaceRoot ?? null;459 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.
465 const hostPlatform = useMemo(466 const hostPlatform = useMemo(
466 () => inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null),467 () =>
467 [selectedEnvironmentConnection?.environmentLabel],468 hostPlatformFromOs(hostOs) ??
469 inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null),
470 [hostOs, selectedEnvironmentConnection?.environmentLabel],
468 );471 );
469472
470 const terminalTheme = getMobileTerminalTheme(themeId, appearanceScheme);473 const terminalTheme = getMobileTerminalTheme(themeId, appearanceScheme);
...@@ -488,6 +491,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -488,6 +491,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
488 { kind: "send", key: "esc", label: "esc", data: "\u001b" },491 { kind: "send", key: "esc", label: "esc", data: "\u001b" },
489 ...modifierActions,492 ...modifierActions,
490 { kind: "send", key: "tab", label: "tab", data: "\t" },493 { kind: "send", key: "tab", label: "tab", data: "\t" },
494 { kind: "paste", key: "paste", label: "paste" },
491 { kind: "clear", key: "clear", label: "clear" },495 { kind: "clear", key: "clear", label: "clear" },
492 { kind: "send", key: "up", label: "↑", data: "\u001b[A" },496 { kind: "send", key: "up", label: "↑", data: "\u001b[A" },
493 { kind: "send", key: "down", label: "↓", data: "\u001b[B" },497 { kind: "send", key: "down", label: "↓", data: "\u001b[B" },
...@@ -693,13 +697,14 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -693,13 +697,14 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
693 setHasMeasuredSurface(true);697 setHasMeasuredSurface(true);
694 }, [routeEnvironmentId, routeThreadId, terminalId]);698 }, [routeEnvironmentId, routeThreadId, terminalId]);
695699
700 /** Resolves true once the pty accepted the write, false if it was skipped or rejected. */
696 const writeInput = useCallback(701 const writeInput = useCallback(
697 (data: string) => {702 async (data: string): Promise<boolean> => {
698 if (!selectedThread || !isRunning) {703 if (!selectedThread || !isRunning) {
699 return;704 return false;
700 }705 }
701706
702 void writeTerminal({707 const result = await writeTerminal({
703 environmentId: selectedThread.environmentId,708 environmentId: selectedThread.environmentId,
704 input: {709 input: {
705 threadId: selectedThread.id,710 threadId: selectedThread.id,
...@@ -707,27 +712,67 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -707,27 +712,67 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
707 data,712 data,
708 },713 },
709 });714 });
715 return result._tag === "Success";
710 },716 },
711 [isRunning, selectedThread, terminalId, writeTerminal],717 [isRunning, selectedThread, terminalId, writeTerminal],
712 );718 );
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
714 const handleInput = useCallback(767 const handleInput = useCallback(
715 (data: string) => {768 (data: string) => {
716 if (data.length === 0) {769 if (data.length === 0) {
717 return;770 return;
718 }771 }
719772
720 if (pendingModifier === "ctrl") {773 writeModifiedInput(data);
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 }
729 },774 },
730 [pendingModifier, terminalId, writeInput],775 [writeModifiedInput],
731 );776 );
732777
733 const handleResize = useCallback(778 const handleResize = useCallback(
...@@ -1021,16 +1066,15 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -1021,16 +1066,15 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
1021 return;1066 return;
1022 }1067 }
10231068
1024 setPendingModifierState({ terminalId, value: null });1069 if (action.kind === "paste") {
1025 if (pendingModifier === "ctrl") {1070 setPendingModifierState({ terminalId, value: null });
1026 writeInput(applyCtrlModifier(action.data));1071 void pasteFromClipboard();
1027 } else if (pendingModifier === "meta") {1072 return;
1028 writeInput(`\u001b${action.data}`);
1029 } else {
1030 writeInput(action.data);
1031 }1073 }
1074
1075 writeModifiedInput(action.data);
1032 },1076 },
1033 [handleClearTerminal, pendingModifier, terminalId, writeInput],1077 [handleClearTerminal, pasteFromClipboard, terminalId, writeModifiedInput],
1034 );1078 );
10351079
1036 const handleDismissKeyboard = useCallback(() => {1080 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: {...@@ -61,6 +61,7 @@ function makeKnownSession(input: {
61 hasRunningSubprocess: false,61 hasRunningSubprocess: false,
62 updatedAt: input.updatedAt ?? "2026-04-15T20:00:00.000Z",62 updatedAt: input.updatedAt ?? "2026-04-15T20:00:00.000Z",
63 version: 1,63 version: 1,
64 lifecycleVersion: 1,
64 },65 },
65 };66 };
66}67}
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", () => {...@@ -133,6 +133,44 @@ describe("terminal session reducers", () => {
133 });133 });
134 });134 });
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
136 it("reduces terminal metadata snapshots, upserts, and removals", () => {174 it("reduces terminal metadata snapshots, upserts, and removals", () => {
137 const initial = applyTerminalMetadataStreamEvent([], {175 const initial = applyTerminalMetadataStreamEvent([], {
138 type: "snapshot",176 type: "snapshot",
packages/client-runtime/src/state/terminalSession.ts+15-1
...@@ -15,6 +15,7 @@ export interface TerminalSessionState {...@@ -15,6 +15,7 @@ export interface TerminalSessionState {
15 readonly hasRunningSubprocess: boolean;15 readonly hasRunningSubprocess: boolean;
16 readonly updatedAt: string | null;16 readonly updatedAt: string | null;
17 readonly version: number;17 readonly version: number;
18 readonly lifecycleVersion: number;
18}19}
1920
20export interface TerminalBufferState {21export interface TerminalBufferState {
...@@ -23,6 +24,7 @@ export interface TerminalBufferState {...@@ -23,6 +24,7 @@ export interface TerminalBufferState {
23 readonly error: string | null;24 readonly error: string | null;
24 readonly updatedAt: string | null;25 readonly updatedAt: string | null;
25 readonly version: number;26 readonly version: number;
27 readonly lifecycleVersion: number;
26}28}
2729
28export interface KnownTerminalSessionTarget {30export interface KnownTerminalSessionTarget {
...@@ -50,6 +52,7 @@ export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze<TerminalBufferState>({...@@ -50,6 +52,7 @@ export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze<TerminalBufferState>({
50 error: null,52 error: null,
51 updatedAt: null,53 updatedAt: null,
52 version: 0,54 version: 0,
55 lifecycleVersion: 0,
53});56});
5457
55export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze<TerminalSessionState>({58export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze<TerminalSessionState>({
...@@ -60,6 +63,7 @@ export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze<TerminalSessionState>(...@@ -60,6 +63,7 @@ export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze<TerminalSessionState>(
60 hasRunningSubprocess: false,63 hasRunningSubprocess: false,
61 updatedAt: null,64 updatedAt: null,
62 version: 0,65 version: 0,
66 lifecycleVersion: 0,
63});67});
6468
65export const DEFAULT_MAX_TERMINAL_BUFFER_BYTES = 512 * 1024;69export const DEFAULT_MAX_TERMINAL_BUFFER_BYTES = 512 * 1024;
...@@ -98,6 +102,7 @@ export function terminalBufferStateFromSnapshot(...@@ -98,6 +102,7 @@ export function terminalBufferStateFromSnapshot(
98 error: null,102 error: null,
99 updatedAt: snapshot.updatedAt,103 updatedAt: snapshot.updatedAt,
100 version: 1,104 version: 1,
105 lifecycleVersion: 0,
101 };106 };
102}107}
103108
...@@ -119,6 +124,7 @@ export function combineTerminalSessionState(...@@ -119,6 +124,7 @@ export function combineTerminalSessionState(
119 hasRunningSubprocess: summary?.hasRunningSubprocess ?? false,124 hasRunningSubprocess: summary?.hasRunningSubprocess ?? false,
120 updatedAt: latestTimestamp(summary?.updatedAt ?? null, buffer.updatedAt),125 updatedAt: latestTimestamp(summary?.updatedAt ?? null, buffer.updatedAt),
121 version: buffer.version,126 version: buffer.version,
127 lifecycleVersion: buffer.lifecycleVersion,
122 };128 };
123}129}
124130
...@@ -129,8 +135,16 @@ export function applyTerminalAttachStreamEvent(...@@ -129,8 +135,16 @@ export function applyTerminalAttachStreamEvent(
129): TerminalBufferState {135): TerminalBufferState {
130 switch (event.type) {136 switch (event.type) {
131 case "snapshot":137 case "snapshot":
138 return {
139 ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes),
140 lifecycleVersion:
141 current.version === 0 ? current.lifecycleVersion : current.lifecycleVersion + 1,
142 };
132 case "restarted":143 case "restarted":
133 return terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes);144 return {
145 ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes),
146 lifecycleVersion: current.lifecycleVersion + 1,
147 };
134 case "output":148 case "output":
135 return {149 return {
136 ...current,150 ...current,