diff --git a/crates/pi-natives/src/keys.rs b/crates/pi-natives/src/keys.rs index c1d530fa7..bfa19b343 100644 --- a/crates/pi-natives/src/keys.rs +++ b/crates/pi-natives/src/keys.rs @@ -841,9 +841,14 @@ fn matches_key_inner(bytes: &[u8], key_id: &str, kitty_protocol_active: bool) -> let is_letter = ch.is_ascii_lowercase(); // ctrl+alt+letter in legacy mode + // Legacy: ctrl+alt+letter is ESC followed by the control character. + // If that legacy form does not match, continue so CSI-u and + // modifyOtherKeys sequences from tmux can still be recognized. if modifier == (MOD_CTRL | MOD_ALT) && !kitty_protocol_active && is_letter { let ctrl_char = raw_ctrl_char(ch); - return bytes.len() == 2 && bytes[0] == 0x1b && bytes[1] == ctrl_char; + if bytes.len() == 2 && bytes[0] == 0x1b && bytes[1] == ctrl_char { + return true; + } } // alt+letter in legacy mode @@ -1470,4 +1475,16 @@ mod tests { assert!(matches_key_inner(b"\x1b[57400;133u", "ctrl+end", true)); assert!(!matches_key_inner(b"\x1b[57400;133u", "1", true)); } + + #[test] + fn ctrl_alt_letter_falls_through_to_csi_u_and_mok() { + // Legacy ESC+ctrl-char form (tmux without modifyOtherKeys) keeps matching. + assert!(matches_key_inner(b"\x1b\x01", "ctrl+alt+a", false)); + // CSI-u form: \x1b[;u, mod = (ctrl|alt)+1 = 7. + assert!(matches_key_inner(b"\x1b[97;7u", "ctrl+alt+a", false)); + // modifyOtherKeys form: \x1b[27;;~, mod = 7. + assert!(matches_key_inner(b"\x1b[27;7;97~", "ctrl+alt+a", false)); + // Unrelated bytes still do not match. + assert!(!matches_key_inner(b"\x1b[97;7u", "ctrl+alt+b", false)); + } } diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 07ccf87ae..3227a221a 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,8 +1,33 @@ # Changelog ## [Unreleased] + ### Added +- Added `Terminal.setProgress(active)` to emit OSC 9;4 progress sequences with a ~1s keepalive interval so Ghostty does not clear the indicator during long-running work (ports pi-mono `a900d251` + `76bc605a`) +- Added optional `argumentHint?: string` to `SlashCommand`; rendered before the description in the autocomplete dropdown (ports pi-mono `aa25726e`) +- Added `VirtualTerminal.waitForRender()` test helper for the throttled render pipeline (ports pi-mono `41377ee8`) + +### Changed + +- `ProcessTerminal` `columns`/`rows` getters consult `Bun.env.COLUMNS` / `Bun.env.LINES` before falling back to 80×24, so piped/non-TTY runs honour environment-provided dimensions (ports pi-mono `32f7fc6a`) +- `requestRender()` non-force calls are coalesced to a ~16ms frame budget; `requestRender(true)` still flushes immediately via `process.nextTick` (ports pi-mono `6f5f37f8`) +- `KNOWN_TERMINALS.base` / `KNOWN_TERMINALS.trueColor` default `hyperlinks: false`; tmux and screen (`TMUX` env or `TERM` starts with `tmux`/`screen`) force `hyperlinks: false` even when the outer terminal would advertise OSC 8 (adapts pi-mono `30a8a41f`) +- `SlashCommand.getArgumentCompletions()` may return a `Promise`; results are now awaited and non-array returns are ignored (ports pi-mono `a1e10789`) +- Fuzzy `@` autocomplete now follows symlinked directories via `ScanOptions.follow_links` plumbed through the native walker (ports pi-mono `780d5367`) +- Plain `@` (no slash) fuzzy matches by basename only, so `@plan` no longer surfaces every file whose ancestor directories contain `plan` (ports pi-mono `968430f6`) + +### Fixed + +- Fixed editor corruption on Thai Sara Am (U+0E33) and Lao AM (U+0EB3) vowels by normalizing to their compatibility decompositions on the terminal-write path while keeping editor content logically unchanged (ports pi-mono `bc668826` + `338ce3a3` + `20ca45d5`) +- Fixed cell-size detection (`CSI 6;h;w t` response) to consume only exact replies, so a bare `Escape` keystroke is no longer swallowed while waiting for terminal image metadata (ports pi-mono `49c0d860`) +- Fixed Kitty CSI-u printable input duplicating on layouts (e.g. Italian) where the terminal also emits the raw character: the immediately-following matching codepoint is now suppressed (ports pi-mono `bdb416cb`) +- Fixed bracketed-paste CSI-u `Ctrl+` re-encoding (tmux popup with `extended-keys-format=csi-u`) leaking literal `[;5u` into the editor; control bytes are decoded back to their literal byte before per-char filtering (ports pi-mono `d06db09a`) +- Fixed xterm `modifyOtherKeys` shifted printable input so uppercase letters inserted via `CSI 27;mod;codepoint~` reach the editor correctly (ports pi-mono `6b55d685`) +- Fixed `super`-modified Kitty shortcuts (`super+k`, `ctrl+super+enter`, …) to parse and match via the new `KITTY_MOD_SUPER` mask (ports pi-mono `ddb8454c` + `5ed46003`) +- Fixed `ctrl+alt+` in tmux falling through to CSI-u / `modifyOtherKeys` when the legacy `ESC` form does not match (ports pi-mono `6cf5098f`) +- Fixed Markdown strikethrough requiring strict `~~text~~` delimiters with non-whitespace boundaries; single tildes no longer render strikethrough (ports pi-mono `db5274b4`) + - Allowed `SlashCommand.getArgumentCompletions` to return asynchronous results by accepting Promise-based completions - Added `argumentHint` support to slash command definitions and displayed it in command suggestion descriptions - Added support for xterm `modifyOtherKeys` printable key sequences by decoding `CSI 27;mod;key~` into text input diff --git a/packages/tui/test/overlay-scroll.test.ts b/packages/tui/test/overlay-scroll.test.ts index 198dd1776..21f160670 100644 --- a/packages/tui/test/overlay-scroll.test.ts +++ b/packages/tui/test/overlay-scroll.test.ts @@ -90,7 +90,7 @@ describe("TUI overlays", () => { tui.start(); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); // Simulate a large historical working area (max lines ever rendered) without actually // rendering that many lines in the current view. @@ -98,7 +98,7 @@ describe("TUI overlays", () => { tui.showOverlay(new LineComponent("overlay-", 3), { anchor: "center" }); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); // The scroll buffer should stay small; we should not have printed hundreds/thousands of blank lines. expect(term.getScrollBuffer().length).toBeLessThan(200); @@ -107,7 +107,7 @@ describe("TUI overlays", () => { it("clears preexisting terminal scrollback on startup full redraw", async () => { const term = new VirtualTerminal(40, 4); term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\nshell-4\r\n"); - await term.flush(); + await term.waitForRender(); const tui = new TUI(term); const component = new MutableContentComponent(["ui-0", "ui-1", "ui-2", "ui-3", "ui-4", "ui-5"]); @@ -115,11 +115,11 @@ describe("TUI overlays", () => { tui.start(); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); term.resize(39, 4); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); const scrollback = term.getScrollBuffer().join("\n"); expect(scrollback.includes("shell-0")).toBeFalsy(); @@ -135,14 +135,14 @@ describe("TUI overlays", () => { tui.start(); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); const before = term.getScrollBuffer().join("\n"); expect(before.includes("row-0")).toBeTruthy(); tui.requestRender(true); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); const after = term.getScrollBuffer().join("\n"); expect(after.includes("row-0")).toBeTruthy(); @@ -152,7 +152,7 @@ describe("TUI overlays", () => { it("fully redraws on height increase to avoid stale viewport rows", async () => { const term = new VirtualTerminal(40, 4); term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\nshell-4\r\n"); - await term.flush(); + await term.waitForRender(); const tui = new TUI(term); const component = new MutableContentComponent(["ui-0", "ui-1", "ui-2", "ui-3"]); @@ -160,11 +160,11 @@ describe("TUI overlays", () => { tui.start(); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); term.resize(40, 8); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); const viewport = term.getViewport().join("\n"); expect(viewport.includes("shell-")).toBeFalsy(); @@ -179,13 +179,13 @@ describe("TUI overlays", () => { try { tui.start(); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); const before = term.getScrollBuffer().length; for (let i = 0; i < 8; i++) { term.resize(i % 2 === 0 ? 59 : 60, i % 2 === 0 ? 9 : 8); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); } const after = term.getScrollBuffer().length; @@ -203,11 +203,11 @@ describe("TUI overlays", () => { try { tui.start(); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); component.setLines(Array.from({ length: 140 }, (_v, i) => `row-${i}`)); term.resize(59, 9); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); const viewport = term.getViewport(); expect(viewport.at(-1)?.includes("row-139")).toBeTruthy(); } finally { @@ -218,16 +218,16 @@ describe("TUI overlays", () => { it("keeps scrollback on viewport-only resize redraw", async () => { const term = new VirtualTerminal(40, 4); term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\n"); - await term.flush(); + await term.waitForRender(); const tui = new TUI(term); tui.addChild(new MutableContentComponent(["ui-0", "ui-1", "ui-2", "ui-3", "ui-4"])); try { tui.start(); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); term.resize(39, 4); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); const scrollback = term.getScrollBuffer().join("\n"); expect(scrollback.includes("shell-0")).toBeFalsy(); } finally { @@ -243,13 +243,13 @@ describe("TUI overlays", () => { try { tui.start(); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); for (let count = 5; count <= 45; count++) { component.setLines(buildRows(count)); term.resize(40, count % 2 === 0 ? 4 : 5); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); } const scrollbackLines = term.getScrollBuffer().map(line => line.trim()); @@ -270,18 +270,18 @@ describe("TUI overlays", () => { try { tui.start(); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); for (let cycle = 0; cycle < 5; cycle++) { component.setLines(Array.from({ length: 120 - cycle * 8 }, (_v, i) => `row-${i}`)); tui.requestRender(); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); component.setLines(Array.from({ length: 120 - cycle * 8 + 4 }, (_v, i) => `row-${i}`)); tui.requestRender(); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); } const viewport = term.getViewport().map(line => line.trim()); @@ -302,14 +302,14 @@ describe("TUI overlays", () => { try { tui.start(); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); const before = term.getScrollBuffer().length; for (let col = 0; col <= 10; col++) { component.setCursorCol(col); tui.requestRender(); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); } const viewport = term.getViewport(); @@ -328,7 +328,7 @@ describe("TUI overlays", () => { try { tui.start(); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); const before = term.getScrollBuffer().length; for (let i = 0; i < 80; i++) { @@ -336,7 +336,7 @@ describe("TUI overlays", () => { term.resize(i % 2 === 0 ? 59 : 60, i % 3 === 0 ? 11 : 10); tui.requestRender(); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); const viewportRows = viewportRowNumbers(term); expect(viewportRows.length).toBeGreaterThan(0); } @@ -357,22 +357,22 @@ describe("TUI overlays", () => { try { tui.start(); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); const before = term.getScrollBuffer().length; for (let i = 0; i < 50; i++) { const handle = tui.showOverlay(new LineComponent(`overlay-${i}-`, 3), { anchor: "center" }); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); handle.hide(); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); if (i % 5 === 0) { component.setLines(buildRows(280 + (i % 4) * 10)); tui.requestRender(); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); } expect(viewportRowNumbers(term).length).toBeGreaterThan(0); @@ -394,13 +394,13 @@ describe("TUI overlays", () => { try { tui.start(); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); const before = term.getScrollBuffer().length; for (let i = 0; i < 120; i++) { term.resize(i % 2 === 0 ? 79 : 80, i % 3 === 0 ? 11 : 12); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); expect(viewportRowNumbers(term).length).toBeGreaterThan(0); } @@ -419,13 +419,13 @@ describe("TUI overlays", () => { try { tui.start(); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); const before = term.getScrollBuffer().length; for (let i = 0; i < 80; i++) { tui.requestRender(); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); } const scrollback = term.getScrollBuffer(); @@ -442,7 +442,7 @@ describe("TUI overlays", () => { try { tui.start(); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); const before = term.getScrollBuffer().length; for (let i = 0; i < 60; i++) { @@ -450,7 +450,7 @@ describe("TUI overlays", () => { term.resize(i % 2 === 0 ? 50 : 49, i % 3 === 0 ? 11 : 10); tui.requestRender(); await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); expect(viewportRowNumbers(term).length).toBeGreaterThan(0); } diff --git a/packages/tui/test/render-regressions.test.ts b/packages/tui/test/render-regressions.test.ts index 9dd4da21e..748558177 100644 --- a/packages/tui/test/render-regressions.test.ts +++ b/packages/tui/test/render-regressions.test.ts @@ -25,8 +25,7 @@ function rows(prefix: string, count: number): string[] { } async function settle(term: VirtualTerminal): Promise { - await Bun.sleep(0); - await term.flush(); + await term.waitForRender(); } function visible(term: VirtualTerminal): string[] { diff --git a/packages/tui/test/virtual-terminal.ts b/packages/tui/test/virtual-terminal.ts index 406b20492..eec8878d5 100644 --- a/packages/tui/test/virtual-terminal.ts +++ b/packages/tui/test/virtual-terminal.ts @@ -113,6 +113,13 @@ export class VirtualTerminal implements Terminal { this.xterm.write(active ? "\x1b]9;4;3\x07" : "\x1b]9;4;0;\x07"); } + /** Wait for TUI's throttled render pipeline to settle (matches the 16ms frame budget). */ + async waitForRender(): Promise { + await new Promise(resolve => process.nextTick(resolve)); + await new Promise(resolve => setTimeout(resolve, 20)); + await this.flush(); + } + // Test-specific methods not in Terminal interface /**