| author | |
| committer | |
| log | d5b94100863057fb4629f9ad4a35753d16917924 |
| tree | 46a5b3a3749a836ca5b654d57bb25977591122e7 |
| parent | d487dfbf46be344e818725be70ee04be2436bfb4 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 7 | 7 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 8 | 8 | import { Platform, Pressable, View } from "react-native"; |
| 9 | import * as Clipboard from "expo-clipboard"; | |
| 10 | import * as Schema from "effect/Schema"; | |
| 9 | 11 | import { |
| 10 | 12 | KeyboardController, |
| 11 | 13 | KeyboardEvents, |
| ... | ... | @@ -27,6 +29,7 @@ import { environmentCatalog } from "../../connection/catalog"; |
| 27 | 29 | import { useEnvironmentPresentation } from "../../state/presentation"; |
| 28 | 30 | import { terminalEnvironment } from "../../state/terminal"; |
| 29 | 31 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 32 | import { useServerConfigs } from "../../state/entities"; | |
| 30 | 33 | import { useWorkspaceState } from "../../state/workspace"; |
| 31 | 34 | import { |
| 32 | 35 | MAX_TERMINAL_FONT_SIZE, |
| ... | ... | @@ -65,6 +68,13 @@ import { |
| 65 | 68 | resolveTerminalSessionLabel, |
| 66 | 69 | type TerminalMenuSession, |
| 67 | 70 | } from "./terminalMenu"; |
| 71 | import { | |
| 72 | hostPlatformFromOs, | |
| 73 | resolveModifiedTerminalInput, | |
| 74 | type HostPlatform, | |
| 75 | type PendingModifier, | |
| 76 | } from "./terminalInput"; | |
| 77 | import { createTerminalPasteSession } from "./terminalPaste"; | |
| 68 | 78 | import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState"; |
| 69 | 79 | |
| 70 | 80 | const DEFAULT_TERMINAL_COLS = 80; |
| ... | ... | @@ -72,12 +82,19 @@ const DEFAULT_TERMINAL_ROWS = 24; |
| 72 | 82 | const TERMINAL_ACCESSORY_HEIGHT = 52; |
| 73 | 83 | const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; |
| 74 | 84 | |
| 75 | type PendingModifier = "ctrl" | "meta"; | |
| 76 | type HostPlatform = "mac" | "linux" | "windows" | "unknown"; | |
| 85 | class 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 | } | |
| 77 | 93 | |
| 78 | 94 | type TerminalToolbarAction = |
| 79 | 95 | | { readonly kind: "send"; readonly key: string; readonly label: string; readonly data: string } |
| 80 | 96 | | { readonly kind: "clear"; readonly key: string; readonly label: string } |
| 97 | | { readonly kind: "paste"; readonly key: string; readonly label: string } | |
| 81 | 98 | | { |
| 82 | 99 | readonly kind: "modifier"; |
| 83 | 100 | readonly key: string; |
| ... | ... | @@ -114,28 +131,6 @@ function inferHostPlatform(environmentLabel: string | null): HostPlatform { |
| 114 | 131 | return "unknown"; |
| 115 | 132 | } |
| 116 | 133 | |
| 117 | function 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 | ||
| 139 | 134 | function pickRunningTerminalSessionForBootstrap( |
| 140 | 135 | sessions: ReadonlyArray<KnownTerminalSession>, |
| 141 | 136 | ): KnownTerminalSession | null { |
| ... | ... | @@ -462,9 +457,17 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 462 | 457 | }); |
| 463 | 458 | }, [terminal.buffer, terminal.buffer.length, terminalKey]); |
| 464 | 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 | 466 | const hostPlatform = useMemo( |
| 466 | () => inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null), | |
| 467 | [selectedEnvironmentConnection?.environmentLabel], | |
| 467 | () => | |
| 468 | hostPlatformFromOs(hostOs) ?? | |
| 469 | inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null), | |
| 470 | [hostOs, selectedEnvironmentConnection?.environmentLabel], | |
| 468 | 471 | ); |
| 469 | 472 | |
| 470 | 473 | const terminalTheme = getMobileTerminalTheme(themeId, appearanceScheme); |
| ... | ... | @@ -488,6 +491,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 488 | 491 | { kind: "send", key: "esc", label: "esc", data: "\u001b" }, |
| 489 | 492 | ...modifierActions, |
| 490 | 493 | { kind: "send", key: "tab", label: "tab", data: "\t" }, |
| 494 | { kind: "paste", key: "paste", label: "paste" }, | |
| 491 | 495 | { kind: "clear", key: "clear", label: "clear" }, |
| 492 | 496 | { kind: "send", key: "up", label: "↑", data: "\u001b[A" }, |
| 493 | 497 | { kind: "send", key: "down", label: "↓", data: "\u001b[B" }, |
| ... | ... | @@ -693,13 +697,14 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 693 | 697 | setHasMeasuredSurface(true); |
| 694 | 698 | }, [routeEnvironmentId, routeThreadId, terminalId]); |
| 695 | 699 | |
| 700 | /** Resolves true once the pty accepted the write, false if it was skipped or rejected. */ | |
| 696 | 701 | const writeInput = useCallback( |
| 697 | (data: string) => { | |
| 702 | async (data: string): Promise<boolean> => { | |
| 698 | 703 | if (!selectedThread || !isRunning) { |
| 699 | return; | |
| 704 | return false; | |
| 700 | 705 | } |
| 701 | 706 | |
| 702 | void writeTerminal({ | |
| 707 | const result = await writeTerminal({ | |
| 703 | 708 | environmentId: selectedThread.environmentId, |
| 704 | 709 | input: { |
| 705 | 710 | threadId: selectedThread.id, |
| ... | ... | @@ -707,27 +712,67 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 707 | 712 | data, |
| 708 | 713 | }, |
| 709 | 714 | }); |
| 715 | return result._tag === "Success"; | |
| 710 | 716 | }, |
| 711 | 717 | [isRunning, selectedThread, terminalId, writeTerminal], |
| 712 | 718 | ); |
| 713 | 719 | |
| 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 | 767 | const handleInput = useCallback( |
| 715 | 768 | (data: string) => { |
| 716 | 769 | if (data.length === 0) { |
| 717 | 770 | return; |
| 718 | 771 | } |
| 719 | 772 | |
| 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); | |
| 729 | 774 | }, |
| 730 | [pendingModifier, terminalId, writeInput], | |
| 775 | [writeModifiedInput], | |
| 731 | 776 | ); |
| 732 | 777 | |
| 733 | 778 | const handleResize = useCallback( |
| ... | ... | @@ -1021,16 +1066,15 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 1021 | 1066 | return; |
| 1022 | 1067 | } |
| 1023 | 1068 | |
| 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; | |
| 1031 | 1073 | } |
| 1074 | ||
| 1075 | writeModifiedInput(action.data); | |
| 1032 | 1076 | }, |
| 1033 | [handleClearTerminal, pendingModifier, terminalId, writeInput], | |
| 1077 | [handleClearTerminal, pasteFromClipboard, terminalId, writeModifiedInput], | |
| 1034 | 1078 | ); |
| 1035 | 1079 | |
| 1036 | 1080 | const handleDismissKeyboard = useCallback(() => { |
apps/mobile/src/features/terminal/terminalInput.test.ts created+125| ... | ... | @@ -0,0 +1,125 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | applyCtrlModifier, | |
| 5 | chunkTerminalWrite, | |
| 6 | encodeTerminalPaste, | |
| 7 | hostPlatformFromOs, | |
| 8 | resolveModifiedTerminalInput, | |
| 9 | TERMINAL_WRITE_MAX_LENGTH, | |
| 10 | } from "./terminalInput"; | |
| 11 | ||
| 12 | const byte = (code: number) => String.fromCharCode(code); | |
| 13 | const ESC = byte(0x1b); | |
| 14 | const CTRL_C = byte(0x03); | |
| 15 | const CTRL_V = byte(0x16); | |
| 16 | ||
| 17 | describe("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 | ||
| 32 | describe("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 | ||
| 69 | describe("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 | ||
| 88 | describe("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 | ||
| 114 | describe("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 @@ |
| 1 | import type { ExecutionEnvironmentPlatformOs } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | export type PendingModifier = "ctrl" | "meta"; | |
| 4 | export type HostPlatform = "mac" | "linux" | "windows" | "unknown"; | |
| 5 | ||
| 6 | /** Upper bound of `TerminalWriteInput.data`; longer writes are rejected by the server. */ | |
| 7 | export const TERMINAL_WRITE_MAX_LENGTH = 65_536; | |
| 8 | ||
| 9 | export 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. | |
| 15 | const 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 | */ | |
| 21 | export 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 | */ | |
| 52 | export 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 | */ | |
| 76 | export 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 | */ | |
| 84 | export 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 | */ | |
| 103 | export 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 | 61 | hasRunningSubprocess: false, |
| 62 | 62 | updatedAt: input.updatedAt ?? "2026-04-15T20:00:00.000Z", |
| 63 | 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { TERMINAL_WRITE_MAX_LENGTH } from "./terminalInput"; | |
| 4 | import { createTerminalPasteSession } from "./terminalPaste"; | |
| 5 | ||
| 6 | function 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 | ||
| 14 | describe("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 @@ |
| 1 | import { chunkTerminalWrite, encodeTerminalPaste } from "./terminalInput"; | |
| 2 | ||
| 3 | interface TerminalPasteInput { | |
| 4 | readonly readText: () => Promise<string>; | |
| 5 | readonly write: (data: string) => Promise<boolean>; | |
| 6 | readonly onReadError: (cause: unknown) => void; | |
| 7 | } | |
| 8 | ||
| 9 | export 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. */ | |
| 15 | export 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 | 133 | }); |
| 134 | 134 | }); |
| 135 | 135 | |
| 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 | 174 | it("reduces terminal metadata snapshots, upserts, and removals", () => { |
| 137 | 175 | const initial = applyTerminalMetadataStreamEvent([], { |
| 138 | 176 | type: "snapshot", |
packages/client-runtime/src/state/terminalSession.ts+15-1| ... | ... | @@ -15,6 +15,7 @@ export interface TerminalSessionState { |
| 15 | 15 | readonly hasRunningSubprocess: boolean; |
| 16 | 16 | readonly updatedAt: string | null; |
| 17 | 17 | readonly version: number; |
| 18 | readonly lifecycleVersion: number; | |
| 18 | 19 | } |
| 19 | 20 | |
| 20 | 21 | export interface TerminalBufferState { |
| ... | ... | @@ -23,6 +24,7 @@ export interface TerminalBufferState { |
| 23 | 24 | readonly error: string | null; |
| 24 | 25 | readonly updatedAt: string | null; |
| 25 | 26 | readonly version: number; |
| 27 | readonly lifecycleVersion: number; | |
| 26 | 28 | } |
| 27 | 29 | |
| 28 | 30 | export interface KnownTerminalSessionTarget { |
| ... | ... | @@ -50,6 +52,7 @@ export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze<TerminalBufferState>({ |
| 50 | 52 | error: null, |
| 51 | 53 | updatedAt: null, |
| 52 | 54 | version: 0, |
| 55 | lifecycleVersion: 0, | |
| 53 | 56 | }); |
| 54 | 57 | |
| 55 | 58 | export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze<TerminalSessionState>({ |
| ... | ... | @@ -60,6 +63,7 @@ export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze<TerminalSessionState>( |
| 60 | 63 | hasRunningSubprocess: false, |
| 61 | 64 | updatedAt: null, |
| 62 | 65 | version: 0, |
| 66 | lifecycleVersion: 0, | |
| 63 | 67 | }); |
| 64 | 68 | |
| 65 | 69 | export const DEFAULT_MAX_TERMINAL_BUFFER_BYTES = 512 * 1024; |
| ... | ... | @@ -98,6 +102,7 @@ export function terminalBufferStateFromSnapshot( |
| 98 | 102 | error: null, |
| 99 | 103 | updatedAt: snapshot.updatedAt, |
| 100 | 104 | version: 1, |
| 105 | lifecycleVersion: 0, | |
| 101 | 106 | }; |
| 102 | 107 | } |
| 103 | 108 | |
| ... | ... | @@ -119,6 +124,7 @@ export function combineTerminalSessionState( |
| 119 | 124 | hasRunningSubprocess: summary?.hasRunningSubprocess ?? false, |
| 120 | 125 | updatedAt: latestTimestamp(summary?.updatedAt ?? null, buffer.updatedAt), |
| 121 | 126 | version: buffer.version, |
| 127 | lifecycleVersion: buffer.lifecycleVersion, | |
| 122 | 128 | }; |
| 123 | 129 | } |
| 124 | 130 | |
| ... | ... | @@ -129,8 +135,16 @@ export function applyTerminalAttachStreamEvent( |
| 129 | 135 | ): TerminalBufferState { |
| 130 | 136 | switch (event.type) { |
| 131 | 137 | case "snapshot": |
| 138 | return { | |
| 139 | ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes), | |
| 140 | lifecycleVersion: | |
| 141 | current.version === 0 ? current.lifecycleVersion : current.lifecycleVersion + 1, | |
| 142 | }; | |
| 132 | 143 | case "restarted": |
| 133 | return terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes); | |
| 144 | return { | |
| 145 | ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes), | |
| 146 | lifecycleVersion: current.lifecycleVersion + 1, | |
| 147 | }; | |
| 134 | 148 | case "output": |
| 135 | 149 | return { |
| 136 | 150 | ...current, |