From cd53387c9e59e143ebaaab221ba84c304d81b4ed Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sat, 14 Feb 2026 20:58:53 -0800 Subject: [PATCH] fix (tui): coalesce rapid git-bash submit bursts into multiline paste --- src/tui/tui.submit-handler.test.ts | 73 +++++++++++++++++++++- src/tui/tui.ts | 99 +++++++++++++++++++++++++++++- 2 files changed, 170 insertions(+), 2 deletions(-) diff --git a/src/tui/tui.submit-handler.test.ts b/src/tui/tui.submit-handler.test.ts index 3b87cfd770..f33d1af13d 100644 --- a/src/tui/tui.submit-handler.test.ts +++ b/src/tui/tui.submit-handler.test.ts @@ -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); + }); +}); diff --git a/src/tui/tui.ts b/src/tui/tui.ts index f62fd2e646..d53c92a127 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -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 | 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();