diff --git a/crates/alacritty-driver/src/lib.rs b/crates/alacritty-driver/src/lib.rs index 8e254536..1a92398f 100644 --- a/crates/alacritty-driver/src/lib.rs +++ b/crates/alacritty-driver/src/lib.rs @@ -13,6 +13,11 @@ use blit_remote::FrameState; const CELL_SIZE: usize = blit_remote::CELL_SIZE; +// Kitty keyboard protocol flags live in TermMode bits 18-22 +// (DISAMBIGUATE_ESC_CODES .. REPORT_ASSOCIATED_TEXT). We surface them to the +// browser as a small integer by shifting the masked bits down to bit 0. +const KITTY_FLAGS_SHIFT: u32 = 18; + // ── Search scoring constants ──────────────────────────────────────────── const SEARCH_TITLE_BASE: u32 = 1400; @@ -312,6 +317,10 @@ impl alacritty_terminal::vte::ansi::Timeout for NoSyncTimeout { struct BlitEventProxy { title: Arc>>, clipboard_stores: Arc>>, + /// Bytes the terminal wants written back to the PTY (query replies such as + /// the kitty `CSI ? u` capability response). The server drains these after + /// each `process()` and writes them to the PTY master. + pty_writes: Arc>>, } impl BlitEventProxy { @@ -319,6 +328,7 @@ impl BlitEventProxy { Self { title: Arc::new(Mutex::new(None)), clipboard_stores: Arc::new(Mutex::new(Vec::new())), + pty_writes: Arc::new(Mutex::new(Vec::new())), } } fn take_title(&self) -> Option { @@ -327,6 +337,9 @@ impl BlitEventProxy { fn take_clipboard_stores(&self) -> Vec { std::mem::take(&mut *self.clipboard_stores.lock().unwrap()) } + fn take_pty_writes(&self) -> Vec { + std::mem::take(&mut *self.pty_writes.lock().unwrap()) + } } impl EventListener for BlitEventProxy { @@ -341,6 +354,12 @@ impl EventListener for BlitEventProxy { Event::ClipboardStore(_, text) => { self.clipboard_stores.lock().unwrap().push(text); } + // The terminal replies to some queries (kitty `CSI ? u`, DSR, etc.) + // by asking the host to write bytes back to the PTY. We used to + // drop these; capture them so the server can forward them. + Event::PtyWrite(text) => { + self.pty_writes.lock().unwrap().push(text); + } _ => {} } } @@ -415,6 +434,12 @@ impl TerminalDriver { pub fn new(rows: u16, cols: u16, scrollback: usize) -> Self { let config = Config { scrolling_history: scrollback, + // Enable the kitty keyboard protocol machinery in the fork: it + // early-returns on every push/pop/query unless this is set (the + // fork's default is `false`). With it on, `Term` tracks the + // push/pop flag stack in `TermMode` bits 18-22 and emits the + // `CSI ? u` capability reply via `Event::PtyWrite`. + kitty_keyboard: true, ..Config::default() }; let dims = TermDims { @@ -584,6 +609,19 @@ impl TerminalDriver { self.event_proxy.take_clipboard_stores() } + /// Bytes the terminal wants written back to the PTY since the last drain + /// (e.g. the kitty `CSI ? u` capability reply). The server writes these to + /// the PTY master after each `process()`. + pub fn take_pty_writes(&mut self) -> Vec { + self.event_proxy.take_pty_writes() + } + + /// Active kitty keyboard protocol flags (bits 18-22 of `TermMode`, shifted + /// to bit 0). 0 when the protocol is inactive. + pub fn kitty_flags(&self) -> u8 { + ((*self.term.mode() & TermMode::KITTY_KEYBOARD_PROTOCOL).bits() >> KITTY_FLAGS_SHIFT) as u8 + } + pub fn synced_output(&self) -> bool { self.modes.synced_output } @@ -617,6 +655,7 @@ impl TerminalDriver { mode, ); frame.set_scrollback_lines(scrollback_lines.min(u32::MAX as usize) as u32); + frame.set_kitty_flags(self.kitty_flags()); frame } @@ -628,6 +667,9 @@ impl TerminalDriver { let mut frame = self.build_frame(offset, rows as usize, cols as usize, 0, 0, 0); frame.set_scrollback_lines(scrollback_lines.min(u32::MAX as usize) as u32); + // Keep the real kitty flags while scrolled back — the keyboard mode is + // a property of the running app, not of the viewport position. + frame.set_kitty_flags(self.kitty_flags()); frame } @@ -1240,6 +1282,28 @@ mod tests { driver.resize(40, 120); assert_eq!(driver.size(), (40, 120)); } + + #[test] + fn kitty_keyboard_protocol() { + let mut driver = TerminalDriver::new(24, 80, 1000); + assert_eq!(driver.kitty_flags(), 0); + + // Push the disambiguate flag (CSI > 1 u). + driver.process(b"\x1b[>1u"); + assert_eq!(driver.kitty_flags(), 1); + // The snapshot must carry the live flags too. + assert_eq!(driver.snapshot(true, true).kitty_flags(), 1); + + // The capability query echoes the current flags as CSI ? u and + // is exposed as a pty write (drained once). + driver.process(b"\x1b[?u"); + assert_eq!(driver.take_pty_writes(), vec!["\x1b[?1u".to_string()]); + assert!(driver.take_pty_writes().is_empty()); + + // Pop (CSI < u) restores the empty flag set. + driver.process(b"\x1b[ u32 { self.inner.frame().scrollback_lines() } + /// Active kitty keyboard protocol flags (0 when inactive). + pub fn kitty_flags(&self) -> u8 { + self.inner.frame().kitty_flags() + } pub fn cursor_visible(&self) -> bool { self.inner.mode() & 1 != 0 } diff --git a/crates/remote/src/lib.rs b/crates/remote/src/lib.rs index a511d73b..9ca77eda 100644 --- a/crates/remote/src/lib.rs +++ b/crates/remote/src/lib.rs @@ -376,6 +376,10 @@ pub struct FrameState { line_flags: Vec, /// Total scrollback lines available for this PTY. scrollback_lines: u32, + /// Active kitty keyboard protocol flags (bits 18-22 of the terminal mode, + /// shifted to bit 0). 0 when the protocol is inactive. Surfaced to the + /// browser so `keyToBytes` can emit CSI-u encoding. + kitty_flags: u8, } impl FrameState { @@ -392,6 +396,7 @@ impl FrameState { overflow: BTreeMap::new(), line_flags: vec![0; rows as usize], scrollback_lines: 0, + kitty_flags: 0, } } @@ -471,6 +476,14 @@ impl FrameState { self.scrollback_lines = lines; } + pub fn kitty_flags(&self) -> u8 { + self.kitty_flags + } + + pub fn set_kitty_flags(&mut self, flags: u8) { + self.kitty_flags = flags; + } + pub fn is_wrapped(&self, row: u16) -> bool { self.line_flags.get(row as usize).copied().unwrap_or(0) & ROW_FLAG_WRAPPED != 0 } @@ -1012,6 +1025,11 @@ impl TerminalState { payload[after_line_flags + 3], ]); } + // Trailing kitty keyboard flags (backward-compatible extension). + // Guard on length so short payloads from older servers are tolerated. + if payload.len() >= after_line_flags + 5 { + self.frame.kitty_flags = payload[after_line_flags + 4]; + } self.frame.cursor_row = new_cursor_row.min(self.frame.rows.saturating_sub(1)); self.frame.cursor_col = new_cursor_col.min(self.frame.cols.saturating_sub(1)); @@ -2543,11 +2561,14 @@ pub fn build_update_msg( } if op_count == 0 { - // No cell changes — still emit a frame if cursor/mode/title changed. + // No cell changes — still emit a frame if cursor/mode/title/kitty + // changed. A bare kitty push/pop (`CSI > 1 u`) alters no cells, so + // without this gate the flag change would never reach the client. if !title_changed && current.cursor_row == previous.cursor_row && current.cursor_col == previous.cursor_col && current.mode == previous.mode + && current.kitty_flags == previous.kitty_flags { return None; } @@ -2597,7 +2618,8 @@ pub fn build_update_msg( } else { 0 } - + 4, + + 4 + + 1, ); payload.extend_from_slice(¤t.rows.to_le_bytes()); payload.extend_from_slice(¤t.cols.to_le_bytes()); @@ -2616,6 +2638,9 @@ pub fn build_update_msg( } // Trailing scrollback count — old clients ignore extra bytes. payload.extend_from_slice(¤t.scrollback_lines.to_le_bytes()); + // Trailing kitty keyboard flags — another backward-compatible extension; + // old clients stop reading after the scrollback count and ignore it. + payload.push(current.kitty_flags); let compressed = compress_prepend_size(&payload); let mut msg = Vec::with_capacity(3 + compressed.len()); @@ -3051,6 +3076,60 @@ mod tests { assert_eq!(term.title(), ""); } + #[test] + fn kitty_flags_change_emits_frame_and_round_trips() { + // A bare kitty flag change touches no cells, cursor, mode, or title, so + // the diff gate must still force an update. + let style = CellStyle::default(); + let mut prev = FrameState::new(1, 4); + prev.write_text(0, 0, "hi", style); + let mut next = prev.clone(); + next.set_kitty_flags(1); + let delta = build_update_msg(3, &next, &prev).expect("flags-only change must emit a frame"); + + let mut term = TerminalState::new(1, 4); + let baseline = build_update_msg(3, &prev, &FrameState::default()).unwrap(); + let ServerMsg::Update { payload, .. } = parse_server_msg(&baseline).unwrap() else { + panic!("expected update"); + }; + term.feed_compressed(payload); + assert_eq!(term.frame().kitty_flags(), 0); + + let ServerMsg::Update { payload, .. } = parse_server_msg(&delta).unwrap() else { + panic!("expected update"); + }; + // The frame carries no visible change, only the flag, so the return + // bool may be false; the flag itself must land. + term.feed_compressed(payload); + assert_eq!(term.frame().kitty_flags(), 1); + } + + #[test] + fn kitty_flags_short_payload_tolerated() { + // Simulate an older server that never appended the kitty byte: drop the + // trailing byte and confirm the client applies cleanly, leaving flags 0. + let style = CellStyle::default(); + let mut next = FrameState::new(1, 4); + next.write_text(0, 0, "hi", style); + next.set_kitty_flags(1); + let msg = build_update_msg(1, &next, &FrameState::default()).unwrap(); + let ServerMsg::Update { payload, .. } = parse_server_msg(&msg).unwrap() else { + panic!("expected update"); + }; + let raw = decompress_size_prepended(payload).unwrap(); + + // Full payload → flags applied. + let mut full = TerminalState::new(1, 4); + full.apply_payload(&raw); + assert_eq!(full.frame().kitty_flags(), 1); + + // Truncated payload (kitty byte missing) → tolerated, no panic, flags + // left at their default 0. + let mut truncated = TerminalState::new(1, 4); + truncated.apply_payload(&raw[..raw.len() - 1]); + assert_eq!(truncated.frame().kitty_flags(), 0); + } + #[test] fn scroll_heavy_update_can_use_ops_payload() { let style = CellStyle::default(); diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 0daa21db..dd5694d1 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -83,6 +83,7 @@ trait PtyDriver: Send { fn search_result(&self, query: &str) -> Option; fn take_title_dirty(&mut self) -> bool; fn take_clipboard_stores(&mut self) -> Vec; + fn take_pty_writes(&mut self) -> Vec; fn used_rows(&self) -> u16; fn take_used_rows_dirty(&mut self) -> bool; fn cursor_position(&self) -> (u16, u16); @@ -154,6 +155,10 @@ impl PtyDriver for AlacrittyDriver { AlacrittyDriver::take_clipboard_stores(self) } + fn take_pty_writes(&mut self) -> Vec { + AlacrittyDriver::take_pty_writes(self) + } + fn used_rows(&self) -> u16 { AlacrittyDriver::used_rows(self) } @@ -369,6 +374,16 @@ struct Pty { cwd: Option, } +/// Forward any bytes the terminal wants written back to the PTY (kitty +/// `CSI ? u` capability reply, etc.). Called right after `driver.process()` +/// and before `respond_to_queries` so terminal-generated replies precede our +/// own DA1/DSR answers. +fn drain_pty_writes(pty: &mut Pty) { + for text in pty.driver.take_pty_writes() { + pty::pty_write_handle(&pty.handle, text.as_bytes()); + } +} + impl Pty { fn mark_dirty(&mut self) { self.dirty = true; @@ -4331,24 +4346,32 @@ async fn tick(state: &AppState) -> TickOutcome { }; match input { PtyInput::Data(data) => { + // Process first so the terminal can push/pop kitty modes and + // generate its own replies (the `CSI ? u` capability + // response), then drain those replies to the PTY, and only + // then answer our own DA1/DSR queries. Kitty probes send + // `CSI ? u` + `CSI c` together; a DA1-first reply reads as + // "no kitty", so the kitty reply must go out first. + pty.driver.process(&data); + drain_pty_writes(pty); pty::respond_to_queries( &pty.handle, &data, pty.driver.size(), pty.driver.cursor_position(), ); - pty.driver.process(&data); pty.mark_dirty(); } PtyInput::SyncBoundary { before } => { if !before.is_empty() { + pty.driver.process(&before); + drain_pty_writes(pty); pty::respond_to_queries( &pty.handle, &before, pty.driver.size(), pty.driver.cursor_position(), ); - pty.driver.process(&before); pty.mark_dirty(); } if !pty.driver.synced_output() { @@ -8353,6 +8376,25 @@ mod tests { assert!(results.is_empty()); } + #[test] + fn parse_tq_kitty_keyboard_sequences_ignored() { + // The kitty keyboard protocol reuses the `u` final byte with `?`, `=`, + // `>`, and `<` intermediates. The terminal driver owns those replies; + // parse_terminal_queries must never answer them (or it would race the + // driver's `CSI ? u` reply and DA1). + for seq in [ + &b"\x1b[?u"[..], // capability query + &b"\x1b[=1;1u"[..], // set flags + &b"\x1b[>1u"[..], // push flags + &b"\x1b[, notify: Arc) { unsafe { let flags = libc::fcntl(fd, libc::F_GETFL); diff --git a/crates/server/src/pty/pty_windows.rs b/crates/server/src/pty/pty_windows.rs index b5245349..d0c7c5b9 100644 --- a/crates/server/src/pty/pty_windows.rs +++ b/crates/server/src/pty/pty_windows.rs @@ -106,6 +106,12 @@ pub fn respond_to_queries(handle: &PtyHandle, data: &[u8], size: (u16, u16), cur } } +/// Write raw bytes to the PTY input. Used to forward terminal-generated +/// replies (e.g. the kitty `CSI ? u` capability response) back to the child. +pub fn pty_write_handle(handle: &PtyHandle, data: &[u8]) { + pty_write_all(PtyWriteTarget(handle.input), data); +} + pub(crate) struct SendHandle(pub(crate) HANDLE); unsafe impl Send for SendHandle {} diff --git a/docs/server.md b/docs/server.md index 23db8445..cf27bffd 100644 --- a/docs/server.md +++ b/docs/server.md @@ -4,18 +4,18 @@ ## Configuration -| Variable | Default | Purpose | -| ----------------------- | -------------------------------------------------- | -------------------------------- | -| `BLIT_SOCK` | see path cascade in [transports.md](transports.md) | Unix socket listen path | -| `SHELL` | `$SHELL` or `/bin/sh` | Shell spawned for new PTYs | -| `BLIT_SHELL_FLAGS` | `li` (Unix) / `` (Windows) | Shell invocation flags | -| `BLIT_SCROLLBACK` | `1000000` | Scrollback buffer rows per PTY | -| `BLIT_VAAPI_DEVICE` | `/dev/dri/renderD128` | VA-API render node for encoding | -| `BLIT_CUDA_DEVICE` | `0` | CUDA device ordinal (NVENC) | -| `BLIT_FD_CHANNEL` | unset | fd-channel file descriptor | +| Variable | Default | Purpose | +| ----------------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `BLIT_SOCK` | see path cascade in [transports.md](transports.md) | Unix socket listen path | +| `SHELL` | `$SHELL` or `/bin/sh` | Shell spawned for new PTYs | +| `BLIT_SHELL_FLAGS` | `li` (Unix) / `` (Windows) | Shell invocation flags | +| `BLIT_SCROLLBACK` | `1000000` | Scrollback buffer rows per PTY | +| `BLIT_VAAPI_DEVICE` | `/dev/dri/renderD128` | VA-API render node for encoding | +| `BLIT_CUDA_DEVICE` | `0` | CUDA device ordinal (NVENC) | +| `BLIT_FD_CHANNEL` | unset | fd-channel file descriptor | | `BLIT_EXPORT_SOCK` | unset | `1` exports the socket path as `BLIT_SOCK` in spawned terminals (also `--export-sock`) | -| `BLIT_SURFACE_ENCODERS` | see encoder table | Comma-separated encoder priority | -| `BLIT_SURFACE_QUALITY` | `medium` | Video quality preset | +| `BLIT_SURFACE_ENCODERS` | see encoder table | Comma-separated encoder priority | +| `BLIT_SURFACE_QUALITY` | `medium` | Video quality preset | ## PTY lifecycle diff --git a/js/core/src/BlitTerminalSurface.ts b/js/core/src/BlitTerminalSurface.ts index ac587509..47c9d7a8 100644 --- a/js/core/src/BlitTerminalSurface.ts +++ b/js/core/src/BlitTerminalSurface.ts @@ -5,9 +5,36 @@ import type { TerminalPalette, ConnectionStatus, SessionId } from "./types"; import { DEFAULT_FONT, DEFAULT_FONT_SIZE } from "./types"; import { measureCell, cssFontFamily, type CellMetrics } from "./measure"; import type { GlRenderer } from "./gl-renderer"; -import { keyToBytes, ctrlCharToByte, encoder } from "./keyboard"; +import { + keyToBytes, + ctrlCharToByte, + encoder, + macEditingKeybind, + type KittyState, +} from "./keyboard"; +import { + encodeKittyKey, + KITTY_EVENT_TYPES, + KITTY_SUPPORTED_MASK, +} from "./kitty"; import { MOUSE_DOWN, MOUSE_UP, MOUSE_MOVE } from "./protocol"; +// The ^V control byte. Sent for a plain Ctrl+V (quoted-insert in shells, and +// the paste-trigger TUIs like Claude Code use to read the clipboard). +const CTRL_V = 0x16; + +// Snapshot of a keydown, kept per `e.code` while the kitty event-types flag is +// on so a keyup (or a blur, for keys stuck by Cmd+Tab) can be re-encoded as a +// release event. +interface SavedKeyEvent { + key: string; + code: string; + ctrlKey: boolean; + altKey: boolean; + metaKey: boolean; + shiftKey: boolean; +} + // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- @@ -23,6 +50,12 @@ export interface BlitTerminalSurfaceOptions { scrollbarColor?: string; scrollbarWidth?: number; advanceRatio?: number; + /** + * Enable the macOS "natural text editing" keybinds (Cmd+Backspace → kill to + * line start, Cmd/Option+arrows, etc.). Defaults to true on macOS. Only + * applied while the kitty keyboard protocol is inactive. + */ + macKeybinds?: boolean; } export interface BlitTerminalSurfaceHandle { @@ -65,6 +98,38 @@ function isAndroid(): boolean { return /android/i.test(navigator.userAgent); } +function isIOS(): boolean { + if (typeof navigator === "undefined") return false; + // iPadOS reports as MacIntel — isIPadOS() covers that via maxTouchPoints. + return isIPadOS() || /iPhone|iPod/.test(navigator.platform); +} + +// Desktop macOS (and iPad with a hardware keyboard, which reports as Mac). Used +// to enable the "natural text editing" Cmd/Option keybinds by default; the chord +// itself requires a physical Cmd/Option key, so this stays inert elsewhere. +function isMacOS(): boolean { + if (typeof navigator === "undefined") return false; + return /Mac/i.test(navigator.platform || navigator.userAgent); +} + +// iOS soft keyboards only auto-repeat Backspace while the focused field still +// has content to delete. The hidden capture textarea is otherwise empty, so a +// held Backspace fires a single deleteContentBackward and stops. We keep the +// textarea seeded with this filler run so iOS's own key-repeat streams a +// deleteContentBackward per repeat; each one forwards a DEL and consumes one +// filler char. U+00A0 (NBSP) is a real, deletable character the user will +// never type, so it is trivial to strip back off the typed-text path. +const IOS_PAD_CODE = 0x00a0; +const IOS_PAD = String.fromCharCode(IOS_PAD_CODE).repeat(64); + +/** Strip the leading NBSP filler run seeded into the iOS capture textarea, + * leaving only the text the user actually typed/pasted. */ +function stripIosPad(value: string): string { + let i = 0; + while (i < value.length && value.charCodeAt(i) === IOS_PAD_CODE) i++; + return value.slice(i); +} + function effectiveDpr(): number { if (typeof window === "undefined") return 1; const base = window.devicePixelRatio || 1; @@ -115,6 +180,7 @@ export class BlitTerminalSurface { private _scrollbarColor: string | undefined; private _scrollbarWidth: number; private _advanceRatio: number | undefined; + private _macKeybinds: boolean; // --- external collaborators --- private _workspace: BlitWorkspace | null = null; @@ -197,6 +263,12 @@ export class BlitTerminalSurface { * so insertCompositionText updates can be streamed letter-by-letter instead * of waiting for compositionend and dumping the whole word at once. */ private _androidCompositionValue = ""; + /** True when the hidden textarea is kept seeded with filler so iOS soft + * keyboards auto-repeat a held Backspace (see IOS_PAD). */ + private _iosPad = false; + /** Idle timer that tops the iOS filler buffer back up once a Backspace + * repeat burst ends (re-padding mid-burst would cancel iOS's repeat). */ + private _iosRepadTimer: ReturnType | null = null; // --- subscriptions / observers --- private dirtyUnsub: (() => void) | null = null; @@ -211,10 +283,29 @@ export class BlitTerminalSurface { private boundCompositionStart: (() => void) | null = null; private boundCompositionEnd: ((e: CompositionEvent) => void) | null = null; private boundInput: ((e: Event) => void) | null = null; + private boundPaste: ((e: ClipboardEvent) => void) | null = null; private boundScrollListener: (() => void) | null = null; + + // --- Ctrl+V image-paste deferral --- + // Ctrl+V is the paste shortcut TUIs like Claude Code read an image from the + // clipboard on. A textarea can't hold an image, so we grab it from the + // browser `paste` event and offer it to the server clipboard *before* + // letting the app process ^V. These fields coordinate the keydown (which + // arms the deferral) with the paste handler / fallback timer (which sends + // the ^V byte once the clipboard has been forwarded). + private _ctrlVPastePending = false; + private _ctrlVFallbackTimer: ReturnType | null = null; private mouseCleanup: (() => void) | null = null; private windowResizeHandler: (() => void) | null = null; + // --- Kitty keyboard protocol release tracking --- + // While the terminal negotiates event reporting (flag 2), each forwarded + // keydown is saved by `e.code` so its keyup — or a blur, which papers over + // Cmd+Tab losing the keyup — can be re-emitted as a CSI-u release. + private boundKeyUp: ((e: KeyboardEvent) => void) | null = null; + private boundBlur: (() => void) | null = null; + private kittyPressed = new Map(); + constructor(options: BlitTerminalSurfaceOptions) { this._sessionId = options.sessionId; this._fontFamily = options.fontFamily ?? DEFAULT_FONT; @@ -226,6 +317,7 @@ export class BlitTerminalSurface { this._scrollbarColor = options.scrollbarColor; this._scrollbarWidth = options.scrollbarWidth ?? 4; this._advanceRatio = options.advanceRatio; + this._macKeybinds = options.macKeybinds ?? isMacOS(); this.dpr = effectiveDpr(); this.cell = measureCell( @@ -263,6 +355,47 @@ export class BlitTerminalSurface { focus(): void { this.inputEl?.focus(); + // Re-seed the iOS Backspace-repeat filler in case the field was cleared. + this.seedIosPad(); + } + + /** Fill the hidden textarea with the NBSP filler buffer and park the cursor + * at the end, so a held Backspace on the iOS soft keyboard keeps having + * content to delete and iOS auto-repeats the deletion. No-op off iOS. */ + private seedIosPad(): void { + if (!this._iosPad) return; + const input = this.inputEl; + if (!input) return; + if (this._iosRepadTimer !== null) { + clearTimeout(this._iosRepadTimer); + this._iosRepadTimer = null; + } + input.value = IOS_PAD; + const end = IOS_PAD.length; + try { + input.setSelectionRange(end, end); + } catch { + // Some browsers reject setSelectionRange on a detached/hidden field. + } + } + + /** Top the filler buffer back up once a Backspace repeat burst has gone + * idle. Re-padding while the burst is live would reset the field and + * cancel iOS's key-repeat, so we wait for a gap between deletions. */ + private scheduleIosRepad(): void { + if (!this._iosPad) return; + if (this._iosRepadTimer !== null) clearTimeout(this._iosRepadTimer); + this._iosRepadTimer = setTimeout(() => { + this._iosRepadTimer = null; + this.seedIosPad(); + }, 400); + } + + /** Reset the capture textarea after an input event: re-seed the iOS filler + * buffer, or just empty the field on every other platform. */ + private resetCaptureField(): void { + if (this._iosPad) this.seedIosPad(); + else if (this.inputEl) this.inputEl.value = ""; } /** @@ -1353,6 +1486,10 @@ export class BlitTerminalSurface { const input = this.inputEl; if (!input) return; + // iOS soft keyboards need the capture textarea to stay non-empty for a + // held Backspace to auto-repeat. Read-only surfaces never take input. + this._iosPad = !this._readOnly && isIOS(); + this.boundKeyDown = (e: KeyboardEvent) => { if (e.defaultPrevented) return; if (this._sessionId === null || this.status !== "connected") return; @@ -1438,9 +1575,66 @@ export class BlitTerminalSurface { return; } + // Ctrl+V (no Shift): TUIs like Claude Code read an image from the + // clipboard when they receive ^V. A textarea can't surface a pasted + // image via the `input` event, so we must let the browser fire a + // `paste` event (do NOT preventDefault here), grab any image there, and + // offer it to the server clipboard before ^V reaches the app. The + // paste handler / fallback timer sends the ^V byte itself. + if ( + e.ctrlKey && + !e.shiftKey && + !e.altKey && + !e.metaKey && + (e.key === "v" || e.key === "V") && + !e.repeat + ) { + this.beginCtrlVPaste(); + return; + } + + // Cmd+C / Cmd+V must reach the browser's native copy/paste. Under the + // kitty protocol meta combos are otherwise forwarded as CSI-u, which + // would swallow them; bail here before the encoder (harmless without + // kitty, where these already produce no bytes). + if ( + e.metaKey && + !e.ctrlKey && + !e.altKey && + (e.key === "c" || e.key === "C" || e.key === "v" || e.key === "V") + ) { + return; + } + const t = this.terminal; const appCursor = t ? t.app_cursor() : false; - const bytes = keyToBytes(e, appCursor); + const kittyFlags = t + ? (t.kitty_flags?.() ?? 0) & KITTY_SUPPORTED_MASK + : 0; + // macOS "natural text editing" chords (Cmd+Backspace, Cmd/Option+arrows). + // Only while kitty is inactive — a kitty-aware app gets the real combo as + // CSI-u and edits itself. Sits after the Cmd+C/V bail above, so native + // copy/paste is unaffected. + if (this._macKeybinds && kittyFlags === 0) { + const edit = macEditingKeybind(e); + if (edit) { + e.preventDefault(); + if (this.scrollOffset > 0) { + this.scrollOffset = 0; + this.sendScroll(this._sessionId!, 0); + } + this.sendInput(this._sessionId!, edit); + return; + } + } + // If the app dropped event reporting while keys were held, forget them. + if (!(kittyFlags & KITTY_EVENT_TYPES) && this.kittyPressed.size > 0) { + this.kittyPressed.clear(); + } + const kitty: KittyState | undefined = kittyFlags + ? { flags: kittyFlags, eventType: e.repeat ? "repeat" : "press" } + : undefined; + const bytes = keyToBytes(e, appCursor, kitty); if (bytes) { e.preventDefault(); if (this.scrollOffset > 0) { @@ -1465,6 +1659,17 @@ export class BlitTerminalSurface { this.predicted = ""; } this.sendInput(this._sessionId!, bytes); + // Track the press so its release can be re-emitted (event types only). + if (kittyFlags & KITTY_EVENT_TYPES) { + this.kittyPressed.set(e.code, { + key: e.key, + code: e.code, + ctrlKey: e.ctrlKey, + altKey: e.altKey, + metaKey: e.metaKey, + shiftKey: e.shiftKey, + }); + } } }; @@ -1490,7 +1695,9 @@ export class BlitTerminalSurface { if (e.data && this._sessionId !== null && this.status === "connected") { this.sendInput(this._sessionId, encoder.encode(e.data)); } - input.value = ""; + // Re-seed the iOS filler so Backspace-repeat keeps working after a + // dictation/accent composition (no-op off iOS → empties the field). + this.resetCaptureField(); }; this.boundInput = (e: Event) => { @@ -1510,6 +1717,20 @@ export class BlitTerminalSurface { } return; } + // iOS soft-keyboard Backspace: the textarea is kept seeded with NBSP + // filler (see IOS_PAD) so a held Backspace always has content to delete + // and iOS streams a deleteContentBackward per key-repeat. Forward one + // DEL each and leave the now-shorter buffer alone — re-padding here would + // reset the field and cancel iOS's repeat. Top it back up once the burst + // goes idle, or immediately if it is about to run dry mid-hold. + if (this._iosPad && inputEvent.inputType === "deleteContentBackward") { + if (this._sessionId !== null && this.status === "connected") { + this.sendInput(this._sessionId, new Uint8Array([0x7f])); + } + if (input.value.length <= 4) this.seedIosPad(); + else this.scheduleIosRepad(); + return; + } // iPadOS (and desktop spellcheck) ignore autocorrect="off" on this // hidden capture textarea and instead deliver autocorrect/suggestion // substitutions as an "insertReplacementText" input event. Each @@ -1518,49 +1739,52 @@ export class BlitTerminalSurface { // duplicate and "correct" terminal input. Drop it — this is what makes // autocorrect-off actually stick on iPad keyboards. if (inputEvent.inputType === "insertReplacementText") { - input.value = ""; + this.resetCaptureField(); return; } + // On iOS the field carries the filler buffer; strip it so we only act on + // what the user actually typed/pasted. + const typed = this._iosPad ? stripIosPad(input.value) : input.value; // Ctrl modifier: convert the next typed character to Ctrl+char if ( this._ctrlModifier && - input.value && + typed && this._sessionId !== null && this.status === "connected" ) { - const char = input.value[0]; + const char = typed[0]; const bytes = ctrlCharToByte(char); if (bytes) { this.sendInput(this._sessionId, bytes); } this.setCtrlModifier(false); - input.value = ""; + this.resetCaptureField(); return; } // Alt modifier: prefix next typed character with ESC if ( this._altModifier && - input.value && + typed && this._sessionId !== null && this.status === "connected" ) { - const char = input.value[0]; + const char = typed[0]; const charCode = char.charCodeAt(0); this.sendInput(this._sessionId, new Uint8Array([0x1b, charCode])); this.setAltModifier(false); - input.value = ""; + this.resetCaptureField(); return; } - if (inputEvent.inputType === "deleteContentBackward" && !input.value) { + if (inputEvent.inputType === "deleteContentBackward" && !typed) { if (this._sessionId !== null && this.status === "connected") { this.sendInput(this._sessionId, new Uint8Array([0x7f])); } } else if ( - input.value && + typed && this._sessionId !== null && this.status === "connected" ) { - const payload = encoder.encode(input.value.replace(/\n/g, "\r")); + const payload = encoder.encode(typed.replace(/\n/g, "\r")); const isPaste = inputEvent.inputType === "insertFromPaste"; const t = this.terminal; if (isPaste && t && t.bracketed_paste()) { @@ -1577,13 +1801,58 @@ export class BlitTerminalSurface { this.sendInput(this._sessionId, payload); } } - input.value = ""; + this.resetCaptureField(); + }; + + this.boundPaste = (e: ClipboardEvent) => this.handlePaste(e); + + // Kitty event reporting: a keyup re-encodes the saved press as a release. + this.boundKeyUp = (e: KeyboardEvent) => { + if (this._readOnly) return; + if (e.isComposing) return; + const saved = this.kittyPressed.get(e.code); + if (!saved) return; + this.kittyPressed.delete(e.code); + this.sendKittyRelease(saved); }; + // Losing focus (e.g. Cmd+Tab) swallows keyups, so flush releases for every + // still-held key or the app would see them as stuck down. + this.boundBlur = () => this.flushKittyReleases(); + input.addEventListener("keydown", this.boundKeyDown); + input.addEventListener("keyup", this.boundKeyUp); + input.addEventListener("blur", this.boundBlur); input.addEventListener("compositionstart", this.boundCompositionStart); input.addEventListener("compositionend", this.boundCompositionEnd); input.addEventListener("input", this.boundInput); + input.addEventListener("paste", this.boundPaste); + + this.seedIosPad(); + } + + /** Re-emit a saved press as a CSI-u release (no-op unless event types are on). */ + private sendKittyRelease(saved: SavedKeyEvent): void { + if (this._sessionId === null || this.status !== "connected") return; + const t = this.terminal; + const flags = t ? (t.kitty_flags?.() ?? 0) & KITTY_SUPPORTED_MASK : 0; + if (!(flags & KITTY_EVENT_TYPES)) return; + const appCursor = t ? t.app_cursor() : false; + const bytes = encodeKittyKey( + saved as unknown as KeyboardEvent, + flags, + "release", + appCursor, + ); + if (bytes) this.sendInput(this._sessionId, bytes); + } + + /** Synthesize releases for every tracked key and clear the map. */ + private flushKittyReleases(): void { + if (this.kittyPressed.size === 0) return; + const held = Array.from(this.kittyPressed.values()); + this.kittyPressed.clear(); + for (const saved of held) this.sendKittyRelease(saved); } private teardownKeyboard(): void { @@ -1591,15 +1860,146 @@ export class BlitTerminalSurface { if (!input) return; if (this.boundKeyDown) input.removeEventListener("keydown", this.boundKeyDown); + if (this.boundKeyUp) input.removeEventListener("keyup", this.boundKeyUp); + if (this.boundBlur) input.removeEventListener("blur", this.boundBlur); if (this.boundCompositionStart) input.removeEventListener("compositionstart", this.boundCompositionStart); if (this.boundCompositionEnd) input.removeEventListener("compositionend", this.boundCompositionEnd); if (this.boundInput) input.removeEventListener("input", this.boundInput); + if (this.boundPaste) input.removeEventListener("paste", this.boundPaste); + if (this._ctrlVFallbackTimer !== null) { + clearTimeout(this._ctrlVFallbackTimer); + this._ctrlVFallbackTimer = null; + } + this._ctrlVPastePending = false; + if (this._iosRepadTimer !== null) { + clearTimeout(this._iosRepadTimer); + this._iosRepadTimer = null; + } this.boundKeyDown = null; + this.boundKeyUp = null; + this.boundBlur = null; this.boundCompositionStart = null; this.boundCompositionEnd = null; this.boundInput = null; + this.boundPaste = null; + this.kittyPressed.clear(); + } + + // --- Ctrl+V image paste --------------------------------------------------- + + /** Arm the Ctrl+V deferral: don't send ^V yet, wait for the `paste` event + * to forward any clipboard image first. A fallback timer sends the raw + * ^V if no paste event materialises (empty clipboard, denied permission, + * or a browser that won't fire paste without content) so quoted-insert and + * app paste-triggers still work. */ + private beginCtrlVPaste(): void { + if (this._sessionId === null || this.status !== "connected") return; + // A pending press being replaced (autorepeat is filtered by !e.repeat, but + // guard anyway): flush the old one as a plain ^V before re-arming. + if (this._ctrlVFallbackTimer !== null) { + clearTimeout(this._ctrlVFallbackTimer); + this._ctrlVFallbackTimer = null; + } + // Scrolling back and pasting should jump to the live prompt, matching the + // keyToBytes input path. + if (this.scrollOffset > 0) { + this.scrollOffset = 0; + this.sendScroll(this._sessionId, 0); + } + this._ctrlVPastePending = true; + this._ctrlVFallbackTimer = setTimeout(() => { + this._ctrlVFallbackTimer = null; + if (this._ctrlVPastePending) { + this._ctrlVPastePending = false; + this.sendCtrlV(); + } + }, 0); + } + + /** + * The bytes a Ctrl+V produces: the raw ^V control byte normally, or its + * CSI-u form (`\x1b[118;5u`, 'v' + ctrl) when the kitty protocol is active. + */ + private ctrlVBytes(): Uint8Array { + const t = this.terminal; + const flags = t ? (t.kitty_flags?.() ?? 0) & KITTY_SUPPORTED_MASK : 0; + return flags ? encoder.encode("\x1b[118;5u") : new Uint8Array([CTRL_V]); + } + + private sendCtrlV(): void { + if (this._readOnly) return; + if (this._sessionId === null || this.status !== "connected") return; + this.sendInput(this._sessionId, this.ctrlVBytes()); + } + + /** Find the first image entry on a clipboard payload, if any. */ + private findClipboardImage(dt: DataTransfer | null): DataTransferItem | null { + const items = dt?.items; + if (!items) return null; + for (let i = 0; i < items.length; i++) { + const it = items[i]; + if (it.kind === "file" && it.type.startsWith("image/")) return it; + } + return null; + } + + private handlePaste(e: ClipboardEvent): void { + if (this._readOnly) return; + if (this._sessionId === null || this.status !== "connected") return; + + // Consume the pending Ctrl+V arm (if this paste came from Ctrl+V) so the + // fallback timer doesn't also fire a ^V. + const wasCtrlV = this._ctrlVPastePending; + this._ctrlVPastePending = false; + if (this._ctrlVFallbackTimer !== null) { + clearTimeout(this._ctrlVFallbackTimer); + this._ctrlVFallbackTimer = null; + } + + const imageItem = wasCtrlV + ? this.findClipboardImage(e.clipboardData) + : null; + + if (imageItem) { + // We own this paste: stop the textarea from doing anything with it (it + // can't hold an image anyway) and forward the bytes to the server + // clipboard, then trigger the app's read with ^V. + e.preventDefault(); + const file = imageItem.getAsFile(); + const conn = this._blitConn; + const sid = this._sessionId; + if (!file || !conn) { + this.sendCtrlV(); + return; + } + const mime = file.type || "image/png"; + void file + .arrayBuffer() + .then((buf) => { + if (this._sessionId !== sid || this.status !== "connected") return; + // Transport messages are ordered, so the clipboard is populated + // server-side before the ^V input arrives and the app reads it. + conn.sendClipboard(mime, new Uint8Array(buf)); + this.sendInput(sid, this.ctrlVBytes()); + }) + .catch(() => { + // Reading the blob failed — fall back to a bare ^V so the keypress + // isn't swallowed entirely. + this.sendCtrlV(); + }); + return; + } + + if (wasCtrlV) { + // Plain Ctrl+V with no image: preserve ^V (quoted-insert / paste-trigger) + // and suppress the textarea's own text paste so we don't double-send. + e.preventDefault(); + this.sendCtrlV(); + } + // Otherwise (Cmd+V / Ctrl+Shift+V text paste): leave it to the existing + // input(insertFromPaste) path — do not touch the event. } /** Stream Android IME composition updates to the shell one character at a diff --git a/js/core/src/__tests__/BlitTerminalSurface.test.ts b/js/core/src/__tests__/BlitTerminalSurface.test.ts index 74d468ca..a838b37a 100644 --- a/js/core/src/__tests__/BlitTerminalSurface.test.ts +++ b/js/core/src/__tests__/BlitTerminalSurface.test.ts @@ -211,10 +211,13 @@ describe("BlitTerminalSurface Ctrl+Shift+V paste shortcut", () => { expect(new TextDecoder().decode(payload)).toBe("pasted-text"); }); - it("Ctrl+V sends the ^V control character (0x16)", () => { + it("Ctrl+V sends the ^V control character (0x16) when no paste follows", async () => { const sendInput = vi.fn(); const { input } = attachKeyboard(sendInput); + // Ctrl+V now defers ^V so a `paste` event can forward a clipboard image + // first. When no paste event materialises (jsdom dispatches none), the + // fallback timer sends the raw ^V so quoted-insert still works. fireKeyDown(input, { key: "v", code: "KeyV", @@ -226,12 +229,129 @@ describe("BlitTerminalSurface Ctrl+Shift+V paste shortcut", () => { }); expect(navigator.clipboard.readText).not.toHaveBeenCalled(); + expect(sendInput).not.toHaveBeenCalled(); + + await new Promise((resolve) => setTimeout(resolve, 0)); expect(sendInput).toHaveBeenCalledTimes(1); const payload = sendInput.mock.calls[0][1] as Uint8Array; expect(Array.from(payload)).toEqual([0x16]); }); }); +describe("BlitTerminalSurface Ctrl+V image paste", () => { + beforeEach(() => { + mockCanvasContext(); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + writable: true, + value: { + writeText: vi.fn().mockResolvedValue(undefined), + readText: vi.fn().mockResolvedValue(""), + }, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function attach(sendInput: (data: Uint8Array) => void) { + const s = new BlitTerminalSurface({ sessionId: "s1" }); + const sendClipboard = vi.fn(); + // @ts-expect-error — install a fake workspace stub. + s["_workspace"] = { sendInput }; + // @ts-expect-error — connection exposing a connected transport + clipboard. + s["_blitConn"] = { transport: { status: "connected" }, sendClipboard }; + const input = document.createElement("textarea"); + // @ts-expect-error — install the hidden capture textarea directly. + s["inputEl"] = input; + // @ts-expect-error — wire the keydown/input/paste listeners. + s["setupKeyboard"](); + return { s, input, sendClipboard }; + } + + function firePaste(input: HTMLTextAreaElement, file: File | null) { + const item: DataTransferItem = { + kind: file ? "file" : "string", + type: file ? file.type : "text/plain", + getAsFile: () => file, + getAsString: () => {}, + webkitGetAsEntry: () => null, + } as unknown as DataTransferItem; + const clipboardData = { + items: file ? ([item] as unknown as DataTransferItemList) : null, + getData: () => "", + } as unknown as DataTransfer; + const ev = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(ev, "clipboardData", { value: clipboardData }); + input.dispatchEvent(ev); + return ev; + } + + it("forwards a pasted image to the server clipboard then sends ^V", async () => { + const sendInput = vi.fn(); + const { input, sendClipboard } = attach(sendInput); + + // Arm the Ctrl+V deferral, as a real keydown would. + input.dispatchEvent( + new KeyboardEvent("keydown", { + key: "v", + code: "KeyV", + ctrlKey: true, + bubbles: true, + }), + ); + + const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // PNG magic + const file = new File([bytes], "clip.png", { type: "image/png" }); + const ev = firePaste(input, file); + + // The textarea paste is consumed so it doesn't also emit an input event. + expect(ev.defaultPrevented).toBe(true); + // arrayBuffer() resolves on a microtask; let it settle. + await Promise.resolve(); + await Promise.resolve(); + + expect(sendClipboard).toHaveBeenCalledTimes(1); + expect(sendClipboard.mock.calls[0][0]).toBe("image/png"); + expect(Array.from(sendClipboard.mock.calls[0][1] as Uint8Array)).toEqual( + Array.from(bytes), + ); + // ^V is sent after the image so the app reads a populated clipboard. + expect(sendInput).toHaveBeenCalledTimes(1); + expect(Array.from(sendInput.mock.calls[0][1] as Uint8Array)).toEqual([ + 0x16, + ]); + }); + + it("cancels the fallback ^V once the image paste is handled", async () => { + const sendInput = vi.fn(); + const { input, sendClipboard } = attach(sendInput); + + input.dispatchEvent( + new KeyboardEvent("keydown", { + key: "v", + code: "KeyV", + ctrlKey: true, + bubbles: true, + }), + ); + const file = new File([new Uint8Array([1, 2, 3])], "clip.png", { + type: "image/png", + }); + firePaste(input, file); + + await Promise.resolve(); + await Promise.resolve(); + // Let the (now-cancelled) fallback timer window elapse. + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Exactly one ^V — the fallback timer must not double-send. + expect(sendClipboard).toHaveBeenCalledTimes(1); + expect(sendInput).toHaveBeenCalledTimes(1); + }); +}); + describe("BlitTerminalSurface Android composition", () => { beforeEach(() => { mockCanvasContext(); @@ -411,6 +531,104 @@ describe("BlitTerminalSurface iPad autocorrect", () => { }); }); +describe("BlitTerminalSurface iOS backspace repeat", () => { + const NBSP = String.fromCharCode(0xa0); + + beforeEach(() => { + mockCanvasContext(); + vi.stubGlobal("navigator", { + userAgent: + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", + platform: "iPhone", + maxTouchPoints: 5, + clipboard: navigator.clipboard, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + function attachIOS(sendInput: (data: Uint8Array) => void) { + const s = new BlitTerminalSurface({ sessionId: "s1" }); + // @ts-expect-error — install a fake workspace stub. + s["_workspace"] = { sendInput }; + // @ts-expect-error — minimal connection exposing only a connected transport. + s["_blitConn"] = { transport: { status: "connected" } }; + const input = document.createElement("textarea"); + // @ts-expect-error — install the hidden capture textarea directly. + s["inputEl"] = input; + // @ts-expect-error — wire the keydown/compositionend/input listeners. + s["setupKeyboard"](); + return { s, input }; + } + + function fireInput( + input: HTMLTextAreaElement, + value: string, + inputType: string, + ) { + input.value = value; + const ev = new Event("input") as InputEvent; + Object.defineProperty(ev, "inputType", { value: inputType }); + Object.defineProperty(ev, "isComposing", { value: false }); + input.dispatchEvent(ev); + } + + it("seeds the capture textarea with non-empty filler", () => { + const { input } = attachIOS(vi.fn()); + expect(input.value.length).toBeGreaterThan(0); + expect(input.value).toBe(NBSP.repeat(input.value.length)); + }); + + it("forwards a DEL for each deleteContentBackward while the buffer holds", () => { + const sendInput = vi.fn(); + const { input } = attachIOS(sendInput); + const seeded = input.value.length; + + // iOS deletes one filler char per key-repeat; each fires its own event. + for (let i = 1; i <= 3; i++) { + fireInput(input, NBSP.repeat(seeded - i), "deleteContentBackward"); + } + + const calls = sendInput.mock.calls.map((c) => + Array.from(c[1] as Uint8Array), + ); + expect(calls).toEqual([[0x7f], [0x7f], [0x7f]]); + // Buffer is left in place (not emptied) so iOS keeps auto-repeating. + expect(input.value.length).toBeGreaterThan(0); + }); + + it("re-seeds the buffer before it runs dry mid-hold", () => { + const sendInput = vi.fn(); + const { input } = attachIOS(sendInput); + + // Simulate the buffer nearly exhausted; the handler tops it back up. + fireInput(input, NBSP.repeat(2), "deleteContentBackward"); + expect(Array.from(sendInput.mock.calls.at(-1)![1] as Uint8Array)).toEqual([ + 0x7f, + ]); + expect(input.value.length).toBeGreaterThan(4); + }); + + it("forwards only the typed character, not the filler", () => { + const sendInput = vi.fn(); + const { input } = attachIOS(sendInput); + const seeded = input.value; + + fireInput(input, seeded + "a", "insertText"); + + expect(sendInput).toHaveBeenCalledTimes(1); + expect( + new TextDecoder().decode(sendInput.mock.calls[0][1] as Uint8Array), + ).toBe("a"); + // Field is re-seeded, not emptied. + expect(input.value.length).toBeGreaterThan(0); + expect(input.value).toBe(NBSP.repeat(input.value.length)); + }); +}); + describe("BlitTerminalSurface DPR detection", () => { beforeEach(() => { mockCanvasContext(); @@ -571,3 +789,249 @@ describe("BlitTerminalSurface native scroll surface", () => { expect(el.scrollTop).toBe(1000); }); }); + +describe("BlitTerminalSurface kitty keyboard protocol", () => { + beforeEach(() => { + mockCanvasContext(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + /** Minimal Terminal stub exposing just what the keydown path reads. */ + function mockTerminal(kittyFlags: number) { + return { + kitty_flags: () => kittyFlags, + app_cursor: () => false, + echo: () => false, + bracketed_paste: () => false, + scrollback_lines: () => 0, + cursor_row: 0, + cursor_col: 0, + }; + } + + function attach(kittyFlags: number) { + const s = new BlitTerminalSurface({ sessionId: "s1" }); + const sendInput = vi.fn(); + // @ts-expect-error — install a fake workspace stub. + s["_workspace"] = { sendInput }; + // @ts-expect-error — minimal connection exposing a connected transport. + s["_blitConn"] = { transport: { status: "connected" } }; + // @ts-expect-error — install the fake terminal directly. + s["terminal"] = mockTerminal(kittyFlags); + const input = document.createElement("textarea"); + // @ts-expect-error — install the hidden capture textarea directly. + s["inputEl"] = input; + // @ts-expect-error — wire the keydown/keyup/blur listeners. + s["setupKeyboard"](); + return { s, input, sendInput }; + } + + const dec = new TextDecoder(); + const sent = (fn: ReturnType, i: number) => + dec.decode(fn.mock.calls[i][1] as Uint8Array); + + it("forwards Shift+Enter as CSI-u", () => { + const { input, sendInput } = attach(1); + input.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + code: "Enter", + shiftKey: true, + bubbles: true, + }), + ); + expect(sendInput).toHaveBeenCalledTimes(1); + expect(sent(sendInput, 0)).toBe("\x1b[13;2u"); + }); + + it("tags an autorepeat with :2", () => { + const { input, sendInput } = attach(3); + input.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowUp", + code: "ArrowUp", + repeat: true, + bubbles: true, + }), + ); + expect(sent(sendInput, 0)).toBe("\x1b[1;1:2A"); + }); + + it("emits a release on keyup only when event types are on", () => { + // flags 3 → event reporting on: keyup re-emits a release. + const withEvents = attach(3); + withEvents.input.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowUp", + code: "ArrowUp", + bubbles: true, + }), + ); + withEvents.input.dispatchEvent( + new KeyboardEvent("keyup", { + key: "ArrowUp", + code: "ArrowUp", + bubbles: true, + }), + ); + expect(withEvents.sendInput).toHaveBeenCalledTimes(2); + expect(sent(withEvents.sendInput, 1)).toBe("\x1b[1;1:3A"); + + // flags 1 → no event reporting: keyup sends nothing. + const noEvents = attach(1); + noEvents.input.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowUp", + code: "ArrowUp", + bubbles: true, + }), + ); + noEvents.input.dispatchEvent( + new KeyboardEvent("keyup", { + key: "ArrowUp", + code: "ArrowUp", + bubbles: true, + }), + ); + expect(noEvents.sendInput).toHaveBeenCalledTimes(1); + }); + + it("synthesizes releases for held keys on blur and clears them", () => { + const { input, sendInput } = attach(3); + input.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowUp", + code: "ArrowUp", + bubbles: true, + }), + ); + expect(sendInput).toHaveBeenCalledTimes(1); + + input.dispatchEvent(new FocusEvent("blur", { bubbles: false })); + expect(sendInput).toHaveBeenCalledTimes(2); + expect(sent(sendInput, 1)).toBe("\x1b[1;1:3A"); + + // A second blur has nothing left to flush. + input.dispatchEvent(new FocusEvent("blur", { bubbles: false })); + expect(sendInput).toHaveBeenCalledTimes(2); + }); + + it("does not preventDefault Cmd+C / Cmd+V (native copy/paste survives)", () => { + const { input, sendInput } = attach(1); + for (const key of ["c", "v"]) { + const ev = new KeyboardEvent("keydown", { + key, + code: key === "c" ? "KeyC" : "KeyV", + metaKey: true, + bubbles: true, + cancelable: true, + }); + input.dispatchEvent(ev); + expect(ev.defaultPrevented).toBe(false); + } + expect(sendInput).not.toHaveBeenCalled(); + }); +}); + +describe("BlitTerminalSurface macOS editing keybinds", () => { + beforeEach(() => { + mockCanvasContext(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function mockTerminal(kittyFlags: number) { + return { + kitty_flags: () => kittyFlags, + app_cursor: () => false, + echo: () => false, + bracketed_paste: () => false, + scrollback_lines: () => 0, + cursor_row: 0, + cursor_col: 0, + }; + } + + function attach(opts: { macKeybinds: boolean; kittyFlags?: number }) { + const s = new BlitTerminalSurface({ + sessionId: "s1", + macKeybinds: opts.macKeybinds, + }); + const sendInput = vi.fn(); + // @ts-expect-error — install a fake workspace stub. + s["_workspace"] = { sendInput }; + // @ts-expect-error — minimal connection exposing a connected transport. + s["_blitConn"] = { transport: { status: "connected" } }; + // @ts-expect-error — install the fake terminal directly. + s["terminal"] = mockTerminal(opts.kittyFlags ?? 0); + const input = document.createElement("textarea"); + // @ts-expect-error — install the hidden capture textarea directly. + s["inputEl"] = input; + // @ts-expect-error — wire the keydown listeners. + s["setupKeyboard"](); + return { s, input, sendInput }; + } + + const dec = new TextDecoder(); + const sent = (fn: ReturnType, i: number) => + dec.decode(fn.mock.calls[i][1] as Uint8Array); + + function press(input: HTMLTextAreaElement, init: KeyboardEventInit) { + const ev = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + ...init, + }); + input.dispatchEvent(ev); + return ev; + } + + it("Cmd+Backspace kills to line start (Ctrl+U)", () => { + const { input, sendInput } = attach({ macKeybinds: true }); + const ev = press(input, { + key: "Backspace", + code: "Backspace", + metaKey: true, + }); + expect(ev.defaultPrevented).toBe(true); + expect(sendInput).toHaveBeenCalledTimes(1); + expect(Array.from(sendInput.mock.calls[0][1] as Uint8Array)).toEqual([ + 0x15, + ]); + }); + + it("Option+ArrowLeft moves a word left (Meta-b)", () => { + const { input, sendInput } = attach({ macKeybinds: true }); + press(input, { key: "ArrowLeft", code: "ArrowLeft", altKey: true }); + expect(sent(sendInput, 0)).toBe("\x1bb"); + }); + + it("yields to the kitty protocol when it is active", () => { + // flags 1 → kitty on: Cmd+Backspace forwards as CSI-u, not Ctrl+U. + const { input, sendInput } = attach({ macKeybinds: true, kittyFlags: 1 }); + press(input, { key: "Backspace", code: "Backspace", metaKey: true }); + expect(sent(sendInput, 0)).toBe("\x1b[127;9u"); + }); + + it("falls through to the legacy byte when disabled", () => { + // Without the keybind, Cmd+Backspace is just the legacy DEL (0x7f), not the + // kill-to-line-start Ctrl+U (0x15). + const { input, sendInput } = attach({ macKeybinds: false }); + press(input, { key: "Backspace", code: "Backspace", metaKey: true }); + expect(Array.from(sendInput.mock.calls[0][1] as Uint8Array)).toEqual([ + 0x7f, + ]); + }); + + it("still lets Cmd+C reach native copy", () => { + const { input, sendInput } = attach({ macKeybinds: true }); + const ev = press(input, { key: "c", code: "KeyC", metaKey: true }); + expect(ev.defaultPrevented).toBe(false); + expect(sendInput).not.toHaveBeenCalled(); + }); +}); diff --git a/js/core/src/__tests__/keyboard.test.ts b/js/core/src/__tests__/keyboard.test.ts index 008bc7b0..61025713 100644 --- a/js/core/src/__tests__/keyboard.test.ts +++ b/js/core/src/__tests__/keyboard.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { keyToBytes } from "../keyboard"; +import { keyToBytes, macEditingKeybind } from "../keyboard"; function makeEvent( key: string, @@ -227,4 +227,107 @@ describe("keyToBytes", () => { expect(keyToBytes(makeEvent("c", { metaKey: true }), false)).toBeNull(); }); }); + + describe("kitty delegation", () => { + const enc = new TextEncoder(); + + it("flags 0 keeps the legacy body byte-identical", () => { + // Enter is a CR in legacy mode regardless of the (empty) kitty state. + expect(keyToBytes(makeEvent("Enter"), false, { flags: 0 })).toEqual( + enc.encode("\r"), + ); + }); + + it("delegates Shift+Enter to CSI-u when flags are active", () => { + expect( + keyToBytes(makeEvent("Enter", { shiftKey: true }), false, { flags: 1 }), + ).toEqual(enc.encode("\x1b[13;2u")); + }); + + it("Cmd+a: null in legacy, CSI-u once kitty is on", () => { + expect(keyToBytes(makeEvent("a", { metaKey: true }), false)).toBeNull(); + expect( + keyToBytes(makeEvent("a", { metaKey: true }), false, { flags: 1 }), + ).toEqual(enc.encode("\x1b[97;9u")); + }); + + it("masks unsupported bits: flags 24 ≡ flags 0 (legacy)", () => { + expect(keyToBytes(makeEvent("Enter"), false, { flags: 24 })).toEqual( + enc.encode("\r"), + ); + }); + + it("masks unsupported bits: flags 9 ≡ flags 1", () => { + expect( + keyToBytes(makeEvent("Enter", { shiftKey: true }), false, { flags: 9 }), + ).toEqual(enc.encode("\x1b[13;2u")); + }); + + it("passes the event type through to the encoder", () => { + expect( + keyToBytes(makeEvent("ArrowUp"), false, { + flags: 3, + eventType: "release", + }), + ).toEqual(enc.encode("\x1b[1;1:3A")); + }); + }); +}); + +describe("macEditingKeybind", () => { + const enc = new TextEncoder(); + + it("Cmd chords map to line-edge control bytes", () => { + expect( + macEditingKeybind(makeEvent("Backspace", { metaKey: true })), + ).toEqual( + new Uint8Array([0x15]), // Ctrl+U + ); + expect( + macEditingKeybind(makeEvent("ArrowLeft", { metaKey: true })), + ).toEqual( + new Uint8Array([0x01]), // Ctrl+A + ); + expect( + macEditingKeybind(makeEvent("ArrowRight", { metaKey: true })), + ).toEqual(new Uint8Array([0x05])); // Ctrl+E + }); + + it("Option chords map to word-wise escape sequences", () => { + expect(macEditingKeybind(makeEvent("Backspace", { altKey: true }))).toEqual( + enc.encode("\x1b\x7f"), + ); + expect(macEditingKeybind(makeEvent("ArrowLeft", { altKey: true }))).toEqual( + enc.encode("\x1bb"), + ); + expect( + macEditingKeybind(makeEvent("ArrowRight", { altKey: true })), + ).toEqual(enc.encode("\x1bf")); + }); + + it("requires a bare chord — Shift or the opposite modifier disqualifies", () => { + expect( + macEditingKeybind( + makeEvent("ArrowLeft", { metaKey: true, shiftKey: true }), + ), + ).toBeNull(); + expect( + macEditingKeybind( + makeEvent("Backspace", { metaKey: true, ctrlKey: true }), + ), + ).toBeNull(); + expect( + macEditingKeybind( + makeEvent("ArrowLeft", { metaKey: true, altKey: true }), + ), + ).toBeNull(); + }); + + it("returns null for unrelated keys and unmodified presses", () => { + expect(macEditingKeybind(makeEvent("a", { metaKey: true }))).toBeNull(); + expect(macEditingKeybind(makeEvent("Backspace"))).toBeNull(); + expect( + macEditingKeybind(makeEvent("ArrowUp", { metaKey: true })), + ).toBeNull(); + }); }); diff --git a/js/core/src/__tests__/kitty.test.ts b/js/core/src/__tests__/kitty.test.ts new file mode 100644 index 00000000..3b356c3e --- /dev/null +++ b/js/core/src/__tests__/kitty.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect } from "vitest"; +import { + encodeKittyKey, + KITTY_DISAMBIGUATE, + KITTY_EVENT_TYPES, + KITTY_ALTERNATE, + type KittyEventType, +} from "../kitty"; + +const enc = new TextEncoder(); + +function makeEvent( + key: string, + opts: Partial = {}, +): KeyboardEvent { + return { + key, + code: opts.code ?? "", + ctrlKey: opts.ctrlKey ?? false, + shiftKey: opts.shiftKey ?? false, + altKey: opts.altKey ?? false, + metaKey: opts.metaKey ?? false, + isComposing: false, + } as KeyboardEvent; +} + +/** Convenience wrapper defaulting event type / appCursor. */ +function encode( + key: string, + flags: number, + opts: Partial = {}, + eventType: KittyEventType = "press", + appCursor = false, +): Uint8Array | null { + return encodeKittyKey(makeEvent(key, opts), flags, eventType, appCursor); +} + +const DISAMBIG = KITTY_DISAMBIGUATE; // 1 +const EVENTS = KITTY_DISAMBIGUATE | KITTY_EVENT_TYPES; // 3 +const ALTERNATE = KITTY_DISAMBIGUATE | KITTY_ALTERNATE; // 5 + +describe("encodeKittyKey", () => { + describe("editing keys", () => { + it("Shift+Enter → CSI 13;2 u", () => { + expect(encode("Enter", DISAMBIG, { shiftKey: true })).toEqual( + enc.encode("\x1b[13;2u"), + ); + }); + + it("Ctrl+Enter → CSI 13;5 u", () => { + expect(encode("Enter", DISAMBIG, { ctrlKey: true })).toEqual( + enc.encode("\x1b[13;5u"), + ); + }); + + it("Cmd+Backspace → CSI 127;9 u", () => { + expect(encode("Backspace", DISAMBIG, { metaKey: true })).toEqual( + enc.encode("\x1b[127;9u"), + ); + }); + + it("plain Enter / Tab / Backspace stay legacy", () => { + expect(encode("Enter", DISAMBIG)).toEqual(enc.encode("\r")); + expect(encode("Tab", DISAMBIG)).toEqual(enc.encode("\t")); + expect(encode("Backspace", DISAMBIG)).toEqual(enc.encode("\x7f")); + }); + + it("editing-key releases are never forwarded", () => { + expect(encode("Enter", EVENTS, { shiftKey: true }, "release")).toBeNull(); + expect(encode("Backspace", EVENTS, {}, "release")).toBeNull(); + }); + }); + + describe("escape", () => { + it("unmodified Escape → CSI 27 u", () => { + expect(encode("Escape", DISAMBIG)).toEqual(enc.encode("\x1b[27u")); + }); + + it("Escape release (event types) → CSI 27;1:3 u", () => { + expect(encode("Escape", EVENTS, {}, "release")).toEqual( + enc.encode("\x1b[27;1:3u"), + ); + }); + }); + + describe("text keys", () => { + it("plain 'a' press → text, release → null", () => { + expect( + encode("a", KITTY_DISAMBIGUATE | KITTY_EVENT_TYPES | KITTY_ALTERNATE), + ).toEqual(enc.encode("a")); + expect( + encode( + "a", + KITTY_DISAMBIGUATE | KITTY_EVENT_TYPES | KITTY_ALTERNATE, + {}, + "release", + ), + ).toBeNull(); + }); + + it("Ctrl+C → CSI 99;5 u", () => { + expect(encode("c", DISAMBIG, { ctrlKey: true })).toEqual( + enc.encode("\x1b[99;5u"), + ); + }); + + it("Cmd+a → CSI 97;9 u when kitty active (was null before)", () => { + expect(encode("a", DISAMBIG, { metaKey: true })).toEqual( + enc.encode("\x1b[97;9u"), + ); + }); + + it("Ctrl+Shift+A with alternate flag → CSI 97:65;6 u", () => { + expect( + encode("A", ALTERNATE, { + ctrlKey: true, + shiftKey: true, + code: "KeyA", + }), + ).toEqual(enc.encode("\x1b[97:65;6u")); + }); + }); + + describe("functional keys", () => { + it("ArrowUp appCursor unmodified press → legacy SS3", () => { + expect(encode("ArrowUp", DISAMBIG, {}, "press", true)).toEqual( + enc.encode("\x1bOA"), + ); + }); + + it("ArrowUp repeat (event types) → CSI 1;1:2 A", () => { + expect(encode("ArrowUp", EVENTS, {}, "repeat")).toEqual( + enc.encode("\x1b[1;1:2A"), + ); + }); + + it("ArrowUp release (event types) → CSI 1;1:3 A", () => { + expect(encode("ArrowUp", EVENTS, {}, "release")).toEqual( + enc.encode("\x1b[1;1:3A"), + ); + }); + + it("Shift+ArrowLeft → CSI 1;2 D", () => { + expect(encode("ArrowLeft", DISAMBIG, { shiftKey: true })).toEqual( + enc.encode("\x1b[1;2D"), + ); + }); + + it("Ctrl+PageUp → CSI 5;5 ~", () => { + expect(encode("PageUp", DISAMBIG, { ctrlKey: true })).toEqual( + enc.encode("\x1b[5;5~"), + ); + }); + }); + + describe("non-forwarded events", () => { + it("modifier-only keys → null", () => { + for (const k of ["Shift", "Control", "Alt", "Meta"]) { + expect(encode(k, EVENTS)).toBeNull(); + } + }); + + it("release without event reporting → null", () => { + expect(encode("ArrowUp", DISAMBIG, {}, "release")).toBeNull(); + }); + }); +}); diff --git a/js/core/src/keyboard.ts b/js/core/src/keyboard.ts index 54ddce75..b3dc3bb3 100644 --- a/js/core/src/keyboard.ts +++ b/js/core/src/keyboard.ts @@ -1,5 +1,20 @@ +import { + encodeKittyKey, + KITTY_SUPPORTED_MASK, + type KittyEventType, +} from "./kitty"; + export const encoder = new TextEncoder(); +/** + * Active kitty keyboard protocol state pulled from the terminal per keystroke. + * `flags` is the raw negotiated integer; `eventType` defaults to "press". + */ +export interface KittyState { + flags: number; + eventType?: KittyEventType; +} + /** * Convert a single character to its Ctrl+char byte representation. * For a-z returns 0x01–0x1a, for special chars returns the standard mapping. @@ -16,6 +31,46 @@ export function ctrlCharToByte(char: string): Uint8Array | null { return null; } +/** + * macOS "natural text editing" chords, mirroring the default keybinds Ghostty + * ships on macOS. These translate the Cmd/Option editing shortcuts into the + * legacy control bytes that readline (bash) and ZLE (zsh) already bind, so they + * work at a bare shell prompt with no shell config. + * + * The caller is responsible for applying these only on macOS and only while the + * kitty keyboard protocol is inactive — a kitty-aware app receives the real + * combo as CSI-u and does its own line editing. + * + * Only bare Cmd / bare Option chords match (Shift and the opposite modifier must + * be absent), so combos like Cmd+Shift+ArrowLeft fall through to `keyToBytes`. + * Returns null when the event is not a recognised editing chord. + */ +export function macEditingKeybind(e: KeyboardEvent): Uint8Array | null { + // Cmd (super): jump/kill to line edges → Ctrl+U / Ctrl+A / Ctrl+E. + if (e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) { + switch (e.key) { + case "Backspace": + return new Uint8Array([0x15]); // Ctrl+U: kill to line start + case "ArrowLeft": + return new Uint8Array([0x01]); // Ctrl+A: beginning of line + case "ArrowRight": + return new Uint8Array([0x05]); // Ctrl+E: end of line + } + } + // Option (alt): word-wise editing → Meta-DEL / Meta-b / Meta-f. + if (e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) { + switch (e.key) { + case "Backspace": + return encoder.encode("\x1b\x7f"); // backward-kill-word + case "ArrowLeft": + return encoder.encode("\x1bb"); // word left + case "ArrowRight": + return encoder.encode("\x1bf"); // word right + } + } + return null; +} + /** * Encode a keyboard event into the byte sequence expected by the terminal. * Returns null if the event should not be forwarded. @@ -23,7 +78,16 @@ export function ctrlCharToByte(char: string): Uint8Array | null { export function keyToBytes( e: KeyboardEvent, appCursor: boolean, + kitty?: KittyState, ): Uint8Array | null { + // When the kitty keyboard protocol is active, all encoding lives in the + // dedicated CSI-u encoder. flags==0 (after masking) keeps the legacy body + // below byte-for-byte identical to what we sent before. + const flags = (kitty?.flags ?? 0) & KITTY_SUPPORTED_MASK; + if (flags !== 0) { + return encodeKittyKey(e, flags, kitty?.eventType ?? "press", appCursor); + } + if (e.ctrlKey && !e.altKey && !e.metaKey) { // Let Ctrl+Shift+V fall through to the browser so native paste works. // On macOS Cmd+V already bypasses this path via the metaKey guard. diff --git a/js/core/src/kitty.ts b/js/core/src/kitty.ts new file mode 100644 index 00000000..0c706571 --- /dev/null +++ b/js/core/src/kitty.ts @@ -0,0 +1,191 @@ +import { encoder, keyToBytes } from "./keyboard"; + +/** + * Kitty keyboard protocol (CSI u) encoding. + * + * All byte-level rules for the modern keyboard protocol live here; the legacy + * xterm encoder in `keyboard.ts` delegates to `encodeKittyKey` whenever the + * terminal has negotiated a non-zero flag set. The encoding mirrors + * alacritty's `SequenceBuilder` (alacritty/src/input/keyboard.rs in the fork), + * scoped to the three flags blit currently supports. + * + * Flag bits (see the kitty spec): + * 1 = disambiguate escape codes + * 2 = report event types (press / repeat / release) + * 4 = report alternate keys (shifted / base-layout codepoints) + * Bits 8 (report-associated-text) and 16 (report-all-as-escape-codes) are + * intentionally masked off by the caller — the encoder API still takes the full + * integer so those can be wired up later without changing the signature. + */ +export const KITTY_DISAMBIGUATE = 1; +export const KITTY_EVENT_TYPES = 2; +export const KITTY_ALTERNATE = 4; +export const KITTY_SUPPORTED_MASK = 0b111; + +export type KittyEventType = "press" | "repeat" | "release"; + +/** Codepoints for the three "editing" keys that stay legacy when unmodified. */ +const EDIT_KEYS: Record = { + Enter: 13, + Tab: 9, + Backspace: 127, +}; + +/** Legacy byte for an unmodified editing key press/repeat. */ +const EDIT_LEGACY: Record = { + Enter: "\r", + Tab: "\t", + Backspace: "\x7f", +}; + +/** Functional keys that use the `CSI 1 ; mods LETTER` form. */ +const LETTER_KEYS: Record = { + ArrowUp: "A", + ArrowDown: "B", + ArrowRight: "C", + ArrowLeft: "D", + Home: "H", + End: "F", + F1: "P", + F2: "Q", + F3: "R", + F4: "S", +}; + +/** Functional keys that use the `CSI number ; mods ~` form. */ +const TILDE_KEYS: Record = { + Insert: "2", + Delete: "3", + PageUp: "5", + PageDown: "6", + F5: "15", + F6: "17", + F7: "18", + F8: "19", + F9: "20", + F10: "21", + F11: "23", + F12: "24", +}; + +function modifierBitmask(e: KeyboardEvent): number { + return ( + (e.shiftKey ? 1 : 0) + + (e.altKey ? 2 : 0) + + (e.ctrlKey ? 4 : 0) + + (e.metaKey ? 8 : 0) + ); +} + +/** + * The `;[:]` suffix shared by every CSI-u / functional form. + * Emitted whenever there are modifiers or an event-type subfield to report; + * the empty string otherwise (e.g. a bare `CSI 27 u` for unmodified Escape). + */ +function modSuffix( + bitmask: number, + eventTypeActive: boolean, + eventType: KittyEventType, +): string { + if (bitmask === 0 && !eventTypeActive) return ""; + let out = `;${bitmask + 1}`; + if (eventTypeActive) out += eventType === "repeat" ? ":2" : ":3"; + return out; +} + +/** Best-effort base-layout codepoint from `e.code` for the alternate-key field. */ +function baseLayoutCodepoint(code: string): number | null { + if (code.length === 4 && code.startsWith("Key")) { + return code.charCodeAt(3) + 32; // "KeyA" → 'a' + } + if (code.length === 6 && code.startsWith("Digit")) { + return code.charCodeAt(5); // "Digit1" → '1' + } + return null; +} + +/** + * Encode a keyboard event as a kitty CSI-u sequence. Returns null when the + * event must not be forwarded (modifier-only keys; any release without event + * reporting; text-key and editing-key releases). + * + * `flags` is the full negotiated integer; only the supported bits are honoured. + * `appCursor` only affects the legacy fallback for unmodified functional keys. + */ +export function encodeKittyKey( + e: KeyboardEvent, + flags: number, + eventType: KittyEventType, + appCursor: boolean, +): Uint8Array | null { + const key = e.key; + + // Modifier-only keys are never forwarded on their own. + if (key === "Shift" || key === "Control" || key === "Alt" || key === "Meta") { + return null; + } + + const hasEventTypes = (flags & KITTY_EVENT_TYPES) !== 0; + const hasAlternate = (flags & KITTY_ALTERNATE) !== 0; + + // Releases can only be represented when event reporting is on. + if (eventType === "release" && !hasEventTypes) return null; + + const bitmask = modifierBitmask(e); + // Event types are only *reported* on the modifier param for repeat/release; + // a press carries no subfield. + const eventTypeActive = hasEventTypes && eventType !== "press"; + const suffix = () => modSuffix(bitmask, eventTypeActive, eventType); + + // --- Editing keys: Enter / Tab / Backspace ---------------------------- + if (key in EDIT_KEYS) { + if (eventType === "release") return null; // never report their release + if (bitmask === 0) return encoder.encode(EDIT_LEGACY[key]); // legacy CR/HT/DEL + return encoder.encode(`\x1b[${EDIT_KEYS[key]}${suffix()}u`); + } + + // --- Escape: always CSI-u -------------------------------------------- + if (key === "Escape") { + return encoder.encode(`\x1b[27${suffix()}u`); + } + + // --- Text keys (single character) ------------------------------------ + if (key.length === 1) { + const withMod = e.ctrlKey || e.altKey || e.metaKey; + if (!withMod) { + // Plain text (shift only, or nothing) is delivered as-is; no releases. + if (eventType === "release") return null; + return encoder.encode(key); + } + // ctrl / alt / super + char → CSI u on the unshifted codepoint. + if (eventType === "release") return null; // text keys carry no release + const unshifted = key.toLowerCase().codePointAt(0)!; + let field = `${unshifted}`; + if (hasAlternate) { + const shifted = key.codePointAt(0)!; + const base = baseLayoutCodepoint(e.code); + const includeShifted = shifted !== unshifted; + const includeBase = base !== null && base !== unshifted; + if (includeShifted || includeBase) { + field = includeShifted ? `${unshifted}:${shifted}` : `${unshifted}:`; + if (includeBase) field += `:${base}`; + } + } + return encoder.encode(`\x1b[${field}${suffix()}u`); + } + + // --- Functional keys: arrows / nav / F-keys -------------------------- + const needsKitty = bitmask !== 0 || eventTypeActive; + if (key in LETTER_KEYS) { + if (!needsKitty) return keyToBytes(e, appCursor); // legacy incl. appCursor SS3 + return encoder.encode(`\x1b[1${suffix()}${LETTER_KEYS[key]}`); + } + if (key in TILDE_KEYS) { + if (!needsKitty) return keyToBytes(e, appCursor); + return encoder.encode(`\x1b[${TILDE_KEYS[key]}${suffix()}~`); + } + + // Anything else (unknown/dead keys, etc.) falls back to the legacy encoder, + // which returns null when it too has nothing to send. + return keyToBytes(e, appCursor); +} diff --git a/js/ui/src/FontOverlay.tsx b/js/ui/src/FontOverlay.tsx index 793e95c9..b2606673 100644 --- a/js/ui/src/FontOverlay.tsx +++ b/js/ui/src/FontOverlay.tsx @@ -1,6 +1,6 @@ import { createSignal, createEffect, onMount, Show, For } from "solid-js"; import type { TerminalPalette } from "@blit-sh/core"; -import { themeFor, ui, uiScale } from "./theme"; +import { scrollbarStyle, themeFor, ui, uiScale } from "./theme"; import { OverlayBackdrop, OverlayHeader, OverlayPanel } from "./Overlay"; import { t } from "./i18n"; @@ -180,6 +180,7 @@ export function FontOverlay(props: { flex: 1, "min-height": 0, "max-height": "20em", + ...scrollbarStyle(theme), }} > diff --git a/js/ui/src/MobileToolbar.tsx b/js/ui/src/MobileToolbar.tsx index c94171cd..d4bcbf3c 100644 --- a/js/ui/src/MobileToolbar.tsx +++ b/js/ui/src/MobileToolbar.tsx @@ -32,6 +32,10 @@ function ToolbarButton(props: { active?: boolean; wide?: boolean; disabled?: boolean; + // When set, fire onPress from a real `click` instead of `pointerdown`. + // iOS Safari only authorises clipboard reads inside a genuine click/touch + // gesture, and preventDefault() on pointerdown suppresses that click. + clickToActivate?: boolean; theme: Theme; scale: UIScale; }) { @@ -40,9 +44,15 @@ function ToolbarButton(props: { type="button" disabled={props.disabled} onPointerDown={(e) => { - e.preventDefault(); + // Click-activated buttons must let the native click through, so + // don't preventDefault (which would cancel it on iOS Safari). + if (!props.clickToActivate) e.preventDefault(); e.stopPropagation(); - if (props.disabled) return; + if (props.disabled || props.clickToActivate) return; + props.onPress(); + }} + onClick={() => { + if (!props.clickToActivate || props.disabled) return; props.onPress(); }} title={props.title} @@ -192,6 +202,8 @@ export function MobileToolbar(props: { const surface = props.surface(); if (!surface) return; void surface.pasteFromClipboard(); + // Keep the keyboard up: some browsers move focus to the tapped button. + surface.focus(); }; const toggleCtrl = () => { @@ -274,6 +286,7 @@ export function MobileToolbar(props: { title="Paste clipboard" onPress={handlePaste} disabled={!canPaste} + clickToActivate wide theme={props.theme} scale={props.scale} diff --git a/js/ui/src/Overlay.tsx b/js/ui/src/Overlay.tsx index 157e85bc..c392c1ee 100644 --- a/js/ui/src/Overlay.tsx +++ b/js/ui/src/Overlay.tsx @@ -1,7 +1,13 @@ import type { JSX } from "solid-js"; import { Show } from "solid-js"; import type { TerminalPalette } from "@blit-sh/core"; -import { layout, overlayChromeStyles, themeFor, uiScale } from "./theme"; +import { + layout, + overlayChromeStyles, + scrollbarStyle, + themeFor, + uiScale, +} from "./theme"; import { t } from "./i18n"; export function OverlayBackdrop(props: { @@ -51,6 +57,7 @@ export function OverlayPanel(props: { style={{ ...layout.panel, ...styles().panel, + ...scrollbarStyle(themeFor(props.palette)), "font-size": `${scale().md}px`, ...props.style, }} diff --git a/js/ui/src/PaletteOverlay.tsx b/js/ui/src/PaletteOverlay.tsx index 702d036d..2c6c9f09 100644 --- a/js/ui/src/PaletteOverlay.tsx +++ b/js/ui/src/PaletteOverlay.tsx @@ -1,7 +1,7 @@ import { createSignal, createEffect, onMount, For } from "solid-js"; import { PALETTES } from "@blit-sh/core"; import type { TerminalPalette } from "@blit-sh/core"; -import { themeFor, ui, uiScale } from "./theme"; +import { scrollbarStyle, themeFor, ui, uiScale } from "./theme"; import { OverlayBackdrop, OverlayHeader, OverlayPanel } from "./Overlay"; import { t, tp } from "./i18n"; @@ -223,6 +223,7 @@ export function PaletteOverlay(props: { outline: "none", "max-height": "20em", overflow: "auto", + ...scrollbarStyle(theme), }} > diff --git a/js/ui/src/RemotesOverlay.tsx b/js/ui/src/RemotesOverlay.tsx index 0f9b71ee..1026d2d1 100644 --- a/js/ui/src/RemotesOverlay.tsx +++ b/js/ui/src/RemotesOverlay.tsx @@ -1,7 +1,7 @@ import { createSignal, Index, Show } from "solid-js"; import type { ConnectionStatus, TerminalPalette } from "@blit-sh/core"; import { OverlayBackdrop, OverlayHeader, OverlayPanel } from "./Overlay"; -import { themeFor, ui, uiScale } from "./theme"; +import { scrollbarStyle, themeFor, ui, uiScale } from "./theme"; import { t } from "./i18n"; import type { Remote } from "./storage"; @@ -280,6 +280,7 @@ export function RemotesOverlay(props: { "grid-template-columns": cols(), "max-height": "60vh", "overflow-y": "auto", + ...scrollbarStyle(theme()), }} > diff --git a/js/ui/src/SwitcherOverlay.tsx b/js/ui/src/SwitcherOverlay.tsx index 4798a4f9..657e107a 100644 --- a/js/ui/src/SwitcherOverlay.tsx +++ b/js/ui/src/SwitcherOverlay.tsx @@ -28,6 +28,7 @@ import type { import { OverlayBackdrop, OverlayPanel } from "./Overlay"; import { overlayChromeStyles, + scrollbarStyle, sessionName, sessionPrefix, sidebarWidth, @@ -1806,6 +1807,10 @@ export function SwitcherOverlay(props: { ? t("switcher.newTerminalPlaceholder") : t("switcher.placeholder") } + autocomplete="off" + autocorrect="off" + autocapitalize="off" + spellcheck={false} style={{ ...ui.input, flex: 1, @@ -1856,6 +1861,7 @@ export function SwitcherOverlay(props: { "align-content": "start", gap: `${scale().tightGap}px`, "padding-right": "2px", + ...scrollbarStyle(theme()), }} > diff --git a/js/ui/src/Workspace.tsx b/js/ui/src/Workspace.tsx index 5cb74f0a..e7c6a193 100644 --- a/js/ui/src/Workspace.tsx +++ b/js/ui/src/Workspace.tsx @@ -65,6 +65,7 @@ import type { UIScale, Theme } from "./theme"; import { sessionName, sessionPrefix, + scrollbarStyle, themeFor, layout, ui, @@ -2154,7 +2155,14 @@ function PreviewPanel(props: { {"\u00D7"} -
+
{(s) => ( void; active?: boolean; disabled?: boolean; + // When set, fire onPress from a real `click` instead of `pointerdown`. + // iOS Safari only authorises clipboard reads inside a genuine click/touch + // gesture, and preventDefault() on pointerdown suppresses that click. + clickToActivate?: boolean; onPointerDown?: (e: PointerEvent) => void; onPointerMove?: (e: PointerEvent) => void; onPointerUp?: (e: PointerEvent) => void; @@ -153,11 +157,17 @@ function ArcButton(props: { type="button" disabled={props.disabled} onPointerDown={(e) => { - e.preventDefault(); + // Click-activated buttons must let the native click through, so + // don't preventDefault (which would cancel it on iOS Safari). + if (!props.clickToActivate) e.preventDefault(); e.stopPropagation(); - if (props.disabled) return; + if (props.disabled || props.clickToActivate) return; props.onPointerDown?.(e) ?? props.onPress(); }} + onClick={() => { + if (!props.clickToActivate || props.disabled) return; + props.onPress(); + }} onPointerMove={props.onPointerMove} onPointerUp={props.onPointerUp} onPointerCancel={props.onPointerCancel} @@ -322,6 +332,8 @@ export default function MobileToolbar(props: { const surface = props.surface(); if (!surface) return; void surface.pasteFromClipboard(); + // Keep the keyboard up: some browsers move focus to the tapped button. + surface.focus(); setIsOpen(false); }; @@ -403,6 +415,7 @@ export default function MobileToolbar(props: { index={6} open={isOpen()} disabled={!canPaste} + clickToActivate onPress={handlePaste} > Paste diff --git a/js/website/src/lib/virtual-blit-wasm.d.ts b/js/website/src/lib/virtual-blit-wasm.d.ts new file mode 100644 index 00000000..acc5eff7 --- /dev/null +++ b/js/website/src/lib/virtual-blit-wasm.d.ts @@ -0,0 +1,4 @@ +declare module "virtual:blit-wasm" { + const buffer: ArrayBuffer; + export default buffer; +} diff --git a/nix/packages.nix b/nix/packages.nix index a3adf5a0..4fc5b0aa 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -582,6 +582,7 @@ pkgs.cargo-watch pkgs.curl pkgs.flyctl + pkgs.git pkgs.libopus pkgs.nodejs pkgs.pkg-config