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}