From ae504bdc5afa33ec7ed3e77556674181b2f59a2b Mon Sep 17 00:00:00 2001 From: Thomas Mustier <6326440+tmustier@users.noreply.github.com> Date: Wed, 27 May 2026 12:36:11 +0100 Subject: [PATCH] Fix Pinet Escape suppression for encoded key events --- slack-bridge/session-ui-runtime.test.ts | 37 ++++++++++++++++++ slack-bridge/session-ui-runtime.ts | 50 ++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/slack-bridge/session-ui-runtime.test.ts b/slack-bridge/session-ui-runtime.test.ts index e576a63..38aee67 100644 --- a/slack-bridge/session-ui-runtime.test.ts +++ b/slack-bridge/session-ui-runtime.test.ts @@ -100,6 +100,43 @@ describe("createSessionUiRuntime", () => { expect(drainInbox).toHaveBeenCalledTimes(2); }); + it.each([ + ["raw Escape", "\u001b"], + ["Kitty CSI-u Escape", "\u001b[27u"], + ["Kitty CSI-u Escape with explicit no modifier", "\u001b[27;1u"], + ["Kitty CSI-u Escape release", "\u001b[27;1:3u"], + ["Kitty CSI-u Escape with Caps Lock", "\u001b[27;65u"], + ["xterm modifyOtherKeys Escape", "\u001b[27;1;27~"], + ])("gates idle inbox draining after %s input", (_name, data) => { + const { deps, drainInbox } = createDeps(); + const runtime = createSessionUiRuntime(deps); + const { ctx } = createContext({ idle: true }); + + runtime.notePotentialInterruptInput(data); + + expect(runtime.shouldSuppressAutomaticInboxDrain(Date.now())).toBe(true); + expect(runtime.maybeDrainInboxIfIdle(ctx)).toBe(false); + expect(drainInbox).not.toHaveBeenCalled(); + }); + + it.each([ + ["Alt+Escape Kitty CSI-u", "\u001b[27;3u"], + ["Ctrl+Escape Kitty CSI-u", "\u001b[27;5u"], + ["Alt+Escape modifyOtherKeys", "\u001b[27;3;27~"], + ["Alt+a legacy sequence", "\u001ba"], + ["arrow sequence", "\u001b[A"], + ])("does not gate inbox draining after non-plain Escape-like %s input", (_name, data) => { + const { deps, drainInbox } = createDeps(); + const runtime = createSessionUiRuntime(deps); + const { ctx } = createContext({ idle: true }); + + runtime.notePotentialInterruptInput(data); + + expect(runtime.shouldSuppressAutomaticInboxDrain(Date.now())).toBe(false); + expect(runtime.maybeDrainInboxIfIdle(ctx)).toBe(true); + expect(drainInbox).toHaveBeenCalledTimes(1); + }); + it("schedules an inbox drain retry until the session is truly idle", async () => { vi.useFakeTimers(); try { diff --git a/slack-bridge/session-ui-runtime.ts b/slack-bridge/session-ui-runtime.ts index afef7e2..af6bfeb 100644 --- a/slack-bridge/session-ui-runtime.ts +++ b/slack-bridge/session-ui-runtime.ts @@ -2,6 +2,9 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; const AUTO_DRAIN_INTERRUPT_SUPPRESSION_MS = 1_500; const AUTO_DRAIN_IDLE_RETRY_MS = 250; +const ESCAPE_SEQUENCE = "\u001b"; +const ESCAPE_CODEPOINT = 27; +const KITTY_LOCK_MODIFIER_MASK = 64 + 128; // Caps Lock + Num Lock. export type SessionUiStatusState = "ok" | "reconnecting" | "error" | "off"; @@ -30,6 +33,51 @@ type SessionUiWithTerminalInput = ExtensionContext["ui"] & { ) => () => void; }; +function isUnmodifiedEscapeInput(data: string): boolean { + // Pi enables Kitty keyboard protocol or xterm modifyOtherKeys when available, so + // plain Escape may arrive as an encoded key event rather than a raw ESC byte. + if (data === ESCAPE_SEQUENCE) { + return true; + } + + return isUnmodifiedKittyEscapeInput(data) || isUnmodifiedModifyOtherKeysEscapeInput(data); +} + +function isUnmodifiedKittyEscapeInput(data: string): boolean { + if (!data.startsWith(`${ESCAPE_SEQUENCE}[`) || !data.endsWith("u")) { + return false; + } + + const body = data.slice(2, -1); + const [codepointWithAlternateKeys, modifierWithEventType, ...extraParts] = body.split(";"); + if (!codepointWithAlternateKeys || extraParts.length > 0) { + return false; + } + + const codepoint = Number.parseInt(codepointWithAlternateKeys.split(":")[0] ?? "", 10); + const modifierValue = Number.parseInt(modifierWithEventType?.split(":")[0] ?? "1", 10); + return ( + codepoint === ESCAPE_CODEPOINT && + Number.isFinite(modifierValue) && + ((modifierValue - 1) & ~KITTY_LOCK_MODIFIER_MASK) === 0 + ); +} + +function isUnmodifiedModifyOtherKeysEscapeInput(data: string): boolean { + if (!data.startsWith(`${ESCAPE_SEQUENCE}[`) || !data.endsWith("~")) { + return false; + } + + const body = data.slice(2, -1); + const [prefix, modifier, codepoint, ...extraParts] = body.split(";"); + if (prefix !== "27" || codepoint !== "27" || extraParts.length > 0) { + return false; + } + + const modifierValue = Number.parseInt(modifier ?? "", 10); + return Number.isFinite(modifierValue) && ((modifierValue - 1) & ~KITTY_LOCK_MODIFIER_MASK) === 0; +} + export function createSessionUiRuntime(deps: SessionUiRuntimeDeps): SessionUiRuntime { let suppressAutoDrainUntil = 0; let terminalInputUnsubscribe: (() => void) | null = null; @@ -73,7 +121,7 @@ export function createSessionUiRuntime(deps: SessionUiRuntimeDeps): SessionUiRun } function notePotentialInterruptInput(data: string): void { - if (data !== "\u001b") { + if (!isUnmodifiedEscapeInput(data)) { return; }