fix (tui): coalesce rapid git-bash submit bursts into multiline paste

This commit is contained in:
Vignesh Natarajan
2026-02-14 20:58:53 -08:00
parent d815c7caf8
commit cd53387c9e
2 changed files with 170 additions and 2 deletions

View File

@@ -1,5 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { createEditorSubmitHandler } from "./tui.js";
import {
createEditorSubmitHandler,
createSubmitBurstCoalescer,
shouldEnableWindowsGitBashPasteFallback,
} from "./tui.js";
describe("createEditorSubmitHandler", () => {
it("routes lines starting with ! to handleBangLine", () => {
@@ -118,3 +122,70 @@ describe("createEditorSubmitHandler", () => {
expect(handleBangLine).not.toHaveBeenCalled();
});
});
describe("createSubmitBurstCoalescer", () => {
it("coalesces rapid single-line submits into one multiline submit when enabled", () => {
vi.useFakeTimers();
const submit = vi.fn();
let now = 1_000;
const onSubmit = createSubmitBurstCoalescer({
submit,
enabled: true,
burstWindowMs: 50,
now: () => now,
});
onSubmit("Line 1");
now += 10;
onSubmit("Line 2");
now += 10;
onSubmit("Line 3");
expect(submit).not.toHaveBeenCalled();
vi.advanceTimersByTime(50);
expect(submit).toHaveBeenCalledTimes(1);
expect(submit).toHaveBeenCalledWith("Line 1\nLine 2\nLine 3");
vi.useRealTimers();
});
it("passes through immediately when disabled", () => {
const submit = vi.fn();
const onSubmit = createSubmitBurstCoalescer({
submit,
enabled: false,
});
onSubmit("Line 1");
onSubmit("Line 2");
expect(submit).toHaveBeenCalledTimes(2);
expect(submit).toHaveBeenNthCalledWith(1, "Line 1");
expect(submit).toHaveBeenNthCalledWith(2, "Line 2");
});
});
describe("shouldEnableWindowsGitBashPasteFallback", () => {
it("enables fallback on Windows Git Bash env", () => {
expect(
shouldEnableWindowsGitBashPasteFallback({
platform: "win32",
env: {
MSYSTEM: "MINGW64",
} as NodeJS.ProcessEnv,
}),
).toBe(true);
});
it("disables fallback outside Windows", () => {
expect(
shouldEnableWindowsGitBashPasteFallback({
platform: "darwin",
env: {
MSYSTEM: "MINGW64",
} as NodeJS.ProcessEnv,
}),
).toBe(false);
});
});

View File

@@ -77,6 +77,99 @@ export function createEditorSubmitHandler(params: {
};
}
export function shouldEnableWindowsGitBashPasteFallback(params?: {
platform?: string;
env?: NodeJS.ProcessEnv;
}): boolean {
const platform = params?.platform ?? process.platform;
if (platform !== "win32") {
return false;
}
const env = params?.env ?? process.env;
const msystem = (env.MSYSTEM ?? "").toUpperCase();
const shell = env.SHELL ?? "";
const termProgram = (env.TERM_PROGRAM ?? "").toLowerCase();
if (msystem.startsWith("MINGW") || msystem.startsWith("MSYS")) {
return true;
}
if (shell.toLowerCase().includes("bash")) {
return true;
}
return termProgram.includes("mintty");
}
export function createSubmitBurstCoalescer(params: {
submit: (value: string) => void;
enabled: boolean;
burstWindowMs?: number;
now?: () => number;
setTimer?: typeof setTimeout;
clearTimer?: typeof clearTimeout;
}) {
const windowMs = Math.max(1, params.burstWindowMs ?? 50);
const now = params.now ?? (() => Date.now());
const setTimer = params.setTimer ?? setTimeout;
const clearTimer = params.clearTimer ?? clearTimeout;
let pending: string | null = null;
let pendingAt = 0;
let flushTimer: ReturnType<typeof setTimeout> | null = null;
const clearFlushTimer = () => {
if (!flushTimer) {
return;
}
clearTimer(flushTimer);
flushTimer = null;
};
const flushPending = () => {
if (pending === null) {
return;
}
const value = pending;
pending = null;
pendingAt = 0;
clearFlushTimer();
params.submit(value);
};
const scheduleFlush = () => {
clearFlushTimer();
flushTimer = setTimer(() => {
flushPending();
}, windowMs);
};
return (value: string) => {
if (!params.enabled) {
params.submit(value);
return;
}
if (value.includes("\n")) {
flushPending();
params.submit(value);
return;
}
const ts = now();
if (pending === null) {
pending = value;
pendingAt = ts;
scheduleFlush();
return;
}
if (ts - pendingAt <= windowMs) {
pending = `${pending}\n${value}`;
pendingAt = ts;
scheduleFlush();
return;
}
flushPending();
pending = value;
pendingAt = ts;
scheduleFlush();
};
}
export function resolveTuiSessionKey(params: {
raw?: string;
sessionScope: SessionScope;
@@ -612,12 +705,16 @@ export async function runTui(opts: TuiOptions) {
closeOverlay,
});
updateAutocompleteProvider();
editor.onSubmit = createEditorSubmitHandler({
const submitHandler = createEditorSubmitHandler({
editor,
handleCommand,
sendMessage,
handleBangLine: runLocalShellLine,
});
editor.onSubmit = createSubmitBurstCoalescer({
submit: submitHandler,
enabled: shouldEnableWindowsGitBashPasteFallback(),
});
editor.onEscape = () => {
void abortActive();