Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions slack-bridge/session-ui-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
50 changes: 49 additions & 1 deletion slack-bridge/session-ui-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -73,7 +121,7 @@ export function createSessionUiRuntime(deps: SessionUiRuntimeDeps): SessionUiRun
}

function notePotentialInterruptInput(data: string): void {
if (data !== "\u001b") {
if (!isUnmodifiedEscapeInput(data)) {
return;
}

Expand Down
Loading