Skip to content

Commit 1f44658

Browse files
authored
fix: coalesce fragmented paste events over SSH (#305)
Some terminals (e.g., MobaXterm) fragment large pastes into multiple bracketed paste sequences, causing premature submit when newlines trigger the submit handler before the full paste is received. This adds paste coalescing with a 100ms debounce to buffer consecutive paste events and process them as a single paste operation. Submit is blocked while paste coalescing is active to prevent partial submissions.
1 parent a457828 commit 1f44658

1 file changed

Lines changed: 91 additions & 59 deletions

File tree

  • packages/opencode/src/cli/cmd/tui/component/prompt

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 91 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,20 @@ export function Prompt(props: PromptProps) {
5858
let anchor: BoxRenderable
5959
let autocomplete: AutocompleteRef
6060

61+
// Paste coalescing: buffer rapid consecutive paste events (e.g., from MobaXterm
62+
// which fragments large pastes into multiple bracketed paste sequences)
63+
const pasteBuffer: { chunks: string[]; timer: Timer | null } = {
64+
chunks: [],
65+
timer: null,
66+
}
67+
const [isPasting, setIsPasting] = createSignal(false)
68+
const PASTE_DEBOUNCE_MS = 100
69+
70+
// Cleanup paste timer on unmount
71+
onCleanup(() => {
72+
if (pasteBuffer.timer) clearTimeout(pasteBuffer.timer)
73+
})
74+
6175
const keybind = useKeybind()
6276
const local = useLocal()
6377
const sdk = useSDK()
@@ -488,6 +502,7 @@ export function Prompt(props: PromptProps) {
488502
async function submit() {
489503
if (props.disabled) return
490504
if (autocomplete?.visible) return
505+
if (isPasting()) return // Block submit during paste coalescing
491506
if (!store.prompt.input) return
492507
const trimmed = store.prompt.input.trim()
493508
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
@@ -688,6 +703,59 @@ export function Prompt(props: PromptProps) {
688703
return
689704
}
690705

706+
// Process a coalesced paste (called after debounce timer expires)
707+
async function processCoalescedPaste(pastedContent: string) {
708+
if (!pastedContent) {
709+
command.trigger("prompt.paste")
710+
return
711+
}
712+
713+
// Check if pasted content is a file path
714+
const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ")
715+
const isUrl = /^(https?):\/\//.test(filepath)
716+
if (!isUrl) {
717+
try {
718+
const file = Bun.file(filepath)
719+
// Handle SVG as raw text content, not as base64 image
720+
if (file.type === "image/svg+xml") {
721+
const content = await file.text().catch(() => {})
722+
if (content) {
723+
pasteText(content, `[SVG: ${file.name ?? "image"}]`)
724+
return
725+
}
726+
}
727+
if (file.type.startsWith("image/")) {
728+
const content = await file
729+
.arrayBuffer()
730+
.then((buffer) => Buffer.from(buffer).toString("base64"))
731+
.catch(() => {})
732+
if (content) {
733+
await pasteImage({
734+
filename: file.name,
735+
mime: file.type,
736+
content,
737+
})
738+
return
739+
}
740+
}
741+
} catch {}
742+
}
743+
744+
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
745+
if ((lineCount >= 3 || pastedContent.length > 150) && !sync.data.config.experimental?.disable_paste_summary) {
746+
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
747+
return
748+
}
749+
750+
// Insert the text directly for small pastes
751+
input.insertText(pastedContent)
752+
setTimeout(() => {
753+
input.getLayoutNode().markDirty()
754+
input.gotoBufferEnd()
755+
renderer.requestRender()
756+
}, 0)
757+
}
758+
691759
const highlight = createMemo(() => {
692760
if (keybind.leader) return theme.border
693761
if (store.mode === "shell") return theme.primary
@@ -853,72 +921,36 @@ export function Prompt(props: PromptProps) {
853921
}
854922
}}
855923
onSubmit={submit}
856-
onPaste={async (event: PasteEvent) => {
857-
if (props.disabled) {
858-
event.preventDefault()
859-
return
860-
}
924+
onPaste={(event: PasteEvent) => {
925+
event.preventDefault()
926+
if (props.disabled) return
861927

862928
// Normalize line endings at the boundary
863929
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
864930
// Replace CRLF first, then any remaining CR
865931
const normalizedText = event.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
866-
const pastedContent = normalizedText.trim()
867-
if (!pastedContent) {
868-
command.trigger("prompt.paste")
869-
return
870-
}
871932

872-
// trim ' from the beginning and end of the pasted content. just
873-
// ' and nothing else
874-
const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ")
875-
const isUrl = /^(https?):\/\//.test(filepath)
876-
if (!isUrl) {
933+
// Buffer the paste content for coalescing
934+
// Some terminals (e.g., MobaXterm) fragment large pastes into multiple
935+
// bracketed paste sequences, which would otherwise trigger premature submit
936+
// Don't trim individual chunks - preserve inter-fragment whitespace
937+
pasteBuffer.chunks.push(normalizedText)
938+
setIsPasting(true)
939+
940+
// Reset the debounce timer
941+
if (pasteBuffer.timer) clearTimeout(pasteBuffer.timer)
942+
pasteBuffer.timer = setTimeout(async () => {
943+
// Coalesce all chunks and process
944+
const coalesced = pasteBuffer.chunks.join("").trim()
945+
pasteBuffer.chunks = []
946+
pasteBuffer.timer = null
877947
try {
878-
const file = Bun.file(filepath)
879-
// Handle SVG as raw text content, not as base64 image
880-
if (file.type === "image/svg+xml") {
881-
event.preventDefault()
882-
const content = await file.text().catch(() => {})
883-
if (content) {
884-
pasteText(content, `[SVG: ${file.name ?? "image"}]`)
885-
return
886-
}
887-
}
888-
if (file.type.startsWith("image/")) {
889-
event.preventDefault()
890-
const content = await file
891-
.arrayBuffer()
892-
.then((buffer) => Buffer.from(buffer).toString("base64"))
893-
.catch(() => {})
894-
if (content) {
895-
await pasteImage({
896-
filename: file.name,
897-
mime: file.type,
898-
content,
899-
})
900-
return
901-
}
902-
}
903-
} catch {}
904-
}
905-
906-
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
907-
if (
908-
(lineCount >= 3 || pastedContent.length > 150) &&
909-
!sync.data.config.experimental?.disable_paste_summary
910-
) {
911-
event.preventDefault()
912-
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
913-
return
914-
}
915-
916-
// Force layout update and render for the pasted content
917-
setTimeout(() => {
918-
input.getLayoutNode().markDirty()
919-
input.gotoBufferEnd()
920-
renderer.requestRender()
921-
}, 0)
948+
await processCoalescedPaste(coalesced)
949+
} finally {
950+
// Only clear isPasting if no new paste arrived during processing
951+
if (!pasteBuffer.timer) setIsPasting(false)
952+
}
953+
}, PASTE_DEBOUNCE_MS)
922954
}}
923955
ref={(r: TextareaRenderable) => {
924956
input = r

0 commit comments

Comments
 (0)