fix(tui,coding-agent): make keyboard protocol fallback response-driven
closes #5188
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
import { matchesKey } from "../src/keys.ts";
|
||||
import { ProcessTerminal } from "../src/terminal.ts";
|
||||
import { type Component, TUI } from "../src/tui.ts";
|
||||
import { truncateToWidth } from "../src/utils.ts";
|
||||
|
||||
/**
|
||||
* Simple key code logger component
|
||||
@@ -10,9 +11,11 @@ class KeyLogger implements Component {
|
||||
private log: string[] = [];
|
||||
private maxLines = 20;
|
||||
private tui: TUI;
|
||||
private terminal: ProcessTerminal;
|
||||
|
||||
constructor(tui: TUI) {
|
||||
constructor(tui: TUI, terminal: ProcessTerminal) {
|
||||
this.tui = tui;
|
||||
this.terminal = terminal;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
@@ -52,18 +55,29 @@ class KeyLogger implements Component {
|
||||
// No cached state to invalidate currently
|
||||
}
|
||||
|
||||
private protocolName(): string {
|
||||
if (this.terminal.kittyProtocolActive) return "kitty";
|
||||
if (this.terminal.modifyOtherKeysActive) return "modifyOtherKeys";
|
||||
return "legacy";
|
||||
}
|
||||
|
||||
private fit(line: string, width: number): string {
|
||||
return truncateToWidth(line, width).padEnd(width);
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Title
|
||||
lines.push("=".repeat(width));
|
||||
lines.push("Key Code Tester - Press keys to see their codes (Ctrl+C to exit)".padEnd(width));
|
||||
lines.push(this.fit("Key Code Tester - Press keys to see their codes (Ctrl+C to exit)", width));
|
||||
lines.push(this.fit(`Protocol: ${this.protocolName()}`, width));
|
||||
lines.push("=".repeat(width));
|
||||
lines.push("");
|
||||
|
||||
// Log entries
|
||||
for (const entry of this.log) {
|
||||
lines.push(entry.padEnd(width));
|
||||
lines.push(this.fit(entry, width));
|
||||
}
|
||||
|
||||
// Fill remaining space
|
||||
@@ -74,12 +88,12 @@ class KeyLogger implements Component {
|
||||
|
||||
// Footer
|
||||
lines.push("=".repeat(width));
|
||||
lines.push("Test these:".padEnd(width));
|
||||
lines.push(" - Shift + Enter (should show: \\x1b[13;2u with Kitty protocol)".padEnd(width));
|
||||
lines.push(" - Alt/Option + Enter".padEnd(width));
|
||||
lines.push(" - Option/Alt + Backspace".padEnd(width));
|
||||
lines.push(" - Cmd/Ctrl + Backspace".padEnd(width));
|
||||
lines.push(" - Regular Backspace".padEnd(width));
|
||||
lines.push(this.fit("Test these:", width));
|
||||
lines.push(this.fit(" - Shift + Enter (should show: \\x1b[13;2u with Kitty protocol)", width));
|
||||
lines.push(this.fit(" - Alt/Option + Enter", width));
|
||||
lines.push(this.fit(" - Option/Alt + Backspace", width));
|
||||
lines.push(this.fit(" - Cmd/Ctrl + Backspace", width));
|
||||
lines.push(this.fit(" - Regular Backspace", width));
|
||||
lines.push("=".repeat(width));
|
||||
|
||||
return lines;
|
||||
@@ -89,7 +103,7 @@ class KeyLogger implements Component {
|
||||
// Set up TUI
|
||||
const terminal = new ProcessTerminal();
|
||||
const tui = new TUI(terminal);
|
||||
const logger = new KeyLogger(tui);
|
||||
const logger = new KeyLogger(tui, terminal);
|
||||
|
||||
tui.addChild(logger);
|
||||
tui.setFocus(logger);
|
||||
@@ -103,3 +117,7 @@ process.on("SIGINT", () => {
|
||||
|
||||
// Start the TUI
|
||||
tui.start();
|
||||
|
||||
// Protocol negotiation completes asynchronously after the first render.
|
||||
// Refresh briefly/continuously so the displayed protocol state is not stale.
|
||||
setInterval(() => tui.requestRender(), 100);
|
||||
|
||||
@@ -82,58 +82,80 @@ describe("ProcessTerminal Kitty keyboard protocol negotiation", () => {
|
||||
};
|
||||
}
|
||||
|
||||
it("activates Kitty mode for non-zero negotiated flags", () => {
|
||||
mock.timers.enable({ apis: ["setTimeout"] });
|
||||
it("queries Kitty mode before enabling modifyOtherKeys fallback", () => {
|
||||
const harness = setupNegotiation();
|
||||
try {
|
||||
harness.send("\x1b[?1u");
|
||||
mock.timers.tick(150);
|
||||
|
||||
assert.equal(harness.writes[0], "\x1b[>7u\x1b[?u\x1b[c");
|
||||
assert.equal(harness.writes.includes("\x1b[>4;2m"), false);
|
||||
assert.equal(harness.terminal.kittyProtocolActive, false);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("activates Kitty mode for non-zero negotiated flags", () => {
|
||||
const harness = setupNegotiation();
|
||||
try {
|
||||
harness.send("\x1b[?7u");
|
||||
|
||||
assert.equal(harness.getInput(), undefined);
|
||||
assert.equal(harness.terminal.kittyProtocolActive, true);
|
||||
assert.equal(harness.writes.includes("\x1b[>4;2m"), false);
|
||||
assert.equal(harness.writes.includes("\x1b[>4;0m"), false);
|
||||
|
||||
harness.cleanup();
|
||||
assert.equal(harness.writes.filter((write) => write === "\x1b[<u").length, 1);
|
||||
assert.equal(harness.writes.includes("\x1b[>4;0m"), false);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
mock.timers.reset();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to modifyOtherKeys for unsupported or silent terminals", () => {
|
||||
const unsupported = setupNegotiation();
|
||||
it("falls back to modifyOtherKeys for zero Kitty flags", () => {
|
||||
const harness = setupNegotiation();
|
||||
try {
|
||||
unsupported.send("\x1b[?62;4;52c");
|
||||
harness.send("\x1b[?0u");
|
||||
|
||||
assert.equal(unsupported.writes[0], "\x1b[>7u\x1b[?u\x1b[c");
|
||||
assert.equal(unsupported.writes.includes("\x1b[>4;2m"), true);
|
||||
assert.equal(unsupported.getInput(), undefined);
|
||||
assert.equal(unsupported.terminal.kittyProtocolActive, false);
|
||||
assert.equal(harness.getInput(), undefined);
|
||||
assert.equal(harness.terminal.kittyProtocolActive, false);
|
||||
assert.equal(harness.writes.filter((write) => write === "\x1b[>4;2m").length, 1);
|
||||
|
||||
harness.cleanup();
|
||||
assert.equal(harness.writes.filter((write) => write === "\x1b[>4;0m").length, 1);
|
||||
} finally {
|
||||
unsupported.cleanup();
|
||||
}
|
||||
|
||||
mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const silent = setupNegotiation();
|
||||
try {
|
||||
mock.timers.tick(150);
|
||||
|
||||
assert.equal(silent.writes[0], "\x1b[>7u\x1b[?u\x1b[c");
|
||||
assert.equal(silent.writes.includes("\x1b[>4;2m"), true);
|
||||
assert.equal(silent.terminal.kittyProtocolActive, false);
|
||||
} finally {
|
||||
silent.cleanup();
|
||||
mock.timers.reset();
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("tracks late split Kitty confirmation after fallback", () => {
|
||||
it("falls back to modifyOtherKeys for device attributes without Kitty flags", () => {
|
||||
const harness = setupNegotiation();
|
||||
try {
|
||||
harness.send("\x1b[?62;4;52c");
|
||||
|
||||
assert.equal(harness.getInput(), undefined);
|
||||
assert.equal(harness.terminal.kittyProtocolActive, false);
|
||||
assert.equal(harness.writes.filter((write) => write === "\x1b[>4;2m").length, 1);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards normal input while waiting for Kitty response", () => {
|
||||
const harness = setupNegotiation();
|
||||
try {
|
||||
harness.send("a");
|
||||
|
||||
assert.equal(harness.getInput(), "a");
|
||||
assert.equal(harness.terminal.kittyProtocolActive, false);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("tracks split Kitty confirmation", () => {
|
||||
mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const harness = setupNegotiation();
|
||||
try {
|
||||
mock.timers.tick(150);
|
||||
harness.send("\x1b[?7");
|
||||
mock.timers.tick(10);
|
||||
|
||||
@@ -141,26 +163,21 @@ describe("ProcessTerminal Kitty keyboard protocol negotiation", () => {
|
||||
|
||||
harness.send("u");
|
||||
|
||||
assert.equal(harness.writes.includes("\x1b[>4;2m"), true);
|
||||
assert.equal(harness.terminal.kittyProtocolActive, true);
|
||||
|
||||
harness.cleanup();
|
||||
assert.equal(harness.writes.filter((write) => write === "\x1b[<u").length, 1);
|
||||
assert.equal(harness.writes.filter((write) => write === "\x1b[>4;0m").length, 1);
|
||||
assert.equal(harness.writes.includes("\x1b[>4;2m"), false);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
mock.timers.reset();
|
||||
}
|
||||
});
|
||||
|
||||
it("replays buffered CSI-prefix input after fallback", () => {
|
||||
it("replays buffered CSI-prefix input when it is not a Kitty response", () => {
|
||||
mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const harness = setupNegotiation();
|
||||
try {
|
||||
harness.send("\x1b[");
|
||||
mock.timers.tick(150);
|
||||
mock.timers.tick(10);
|
||||
|
||||
assert.equal(harness.writes.includes("\x1b[>4;2m"), true);
|
||||
assert.equal(harness.getInput(), undefined);
|
||||
|
||||
mock.timers.tick(150);
|
||||
|
||||
Reference in New Issue
Block a user