fix(tui): fixed ctrl+alt+letter matching and render test synchronization

- Adjusted ctrl+alt+letter matching in pi-natives so legacy ESC+ctrl-char failures now fall through to CSI-u and modifyOtherKeys sequences when kitty protocol is inactive.
- Added VirtualTerminal.waitForRender() to wait for the throttled 16ms render budget before flushing.
- Reworked overlay and regression tests to await waitForRender() instead of manual sleep-and-flush synchronization.
This commit is contained in:
can1357
2026-05-12 10:22:26 +02:00
parent 718ab4e3ae
commit 3c246dfb11
5 changed files with 87 additions and 39 deletions
+18 -1
View File
@@ -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[<codepoint>;<mod>u, mod = (ctrl|alt)+1 = 7.
assert!(matches_key_inner(b"\x1b[97;7u", "ctrl+alt+a", false));
// modifyOtherKeys form: \x1b[27;<mod>;<codepoint>~, 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));
}
}
+25
View File
@@ -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 `@<query>` (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+<letter>` re-encoding (tmux popup with `extended-keys-format=csi-u`) leaking literal `[<code>;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+<letter>` in tmux falling through to CSI-u / `modifyOtherKeys` when the legacy `ESC<ctrl-char>` 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
+36 -36
View File
@@ -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);
}
+1 -2
View File
@@ -25,8 +25,7 @@ function rows(prefix: string, count: number): string[] {
}
async function settle(term: VirtualTerminal): Promise<void> {
await Bun.sleep(0);
await term.flush();
await term.waitForRender();
}
function visible(term: VirtualTerminal): string[] {
+7
View File
@@ -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<void> {
await new Promise<void>(resolve => process.nextTick(resolve));
await new Promise<void>(resolve => setTimeout(resolve, 20));
await this.flush();
}
// Test-specific methods not in Terminal interface
/**