diff --git a/frontend/src/composables/useTerminal.ts b/frontend/src/composables/useTerminal.ts index 2cc499e..f5c883f 100644 --- a/frontend/src/composables/useTerminal.ts +++ b/frontend/src/composables/useTerminal.ts @@ -91,6 +91,32 @@ export function useTerminal(sessionId: string): UseTerminalReturn { let resizeObserver: ResizeObserver | null = null; + // Streaming TextDecoder persists across events so split multi-byte UTF-8 + // sequences at chunk boundaries are decoded correctly (e.g. a 3-byte em-dash + // split across two Go read() calls). + const utf8Decoder = new TextDecoder("utf-8", { fatal: false }); + + // Write batching — accumulate chunks and flush once per animation frame. + // Without this, every tiny SSH read (sometimes single characters) triggers + // a separate terminal.write(), producing a laggy typewriter effect. + let pendingData = ""; + let rafId: number | null = null; + + function flushPendingData(): void { + rafId = null; + if (pendingData) { + terminal.write(pendingData); + pendingData = ""; + } + } + + function queueWrite(data: string): void { + pendingData += data; + if (rafId === null) { + rafId = requestAnimationFrame(flushPendingData); + } + } + function mount(container: HTMLElement): void { terminal.open(container); fitAddon.fit(); @@ -115,16 +141,20 @@ export function useTerminal(sessionId: string): UseTerminalReturn { try { // atob() returns Latin-1 — each byte becomes a char code 0x00–0xFF. - // We must reconstruct the raw bytes, then decode as UTF-8. + // Reconstruct raw bytes, then decode with the streaming TextDecoder + // which buffers incomplete multi-byte sequences between calls. const binaryStr = atob(b64data); const bytes = new Uint8Array(binaryStr.length); for (let i = 0; i < binaryStr.length; i++) { bytes[i] = binaryStr.charCodeAt(i); } - terminal.write(new TextDecoder().decode(bytes)); + const decoded = utf8Decoder.decode(bytes, { stream: true }); + if (decoded) { + queueWrite(decoded); + } } catch { // Fallback: write raw if not valid base64 - terminal.write(b64data); + queueWrite(b64data); } }); @@ -136,6 +166,15 @@ export function useTerminal(sessionId: string): UseTerminalReturn { } function destroy(): void { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + // Flush any remaining buffered data before teardown + if (pendingData) { + terminal.write(pendingData); + pendingData = ""; + } if (cleanupEvent) { cleanupEvent(); cleanupEvent = null; diff --git a/internal/ssh/service.go b/internal/ssh/service.go index be88557..181f002 100644 --- a/internal/ssh/service.go +++ b/internal/ssh/service.go @@ -72,8 +72,8 @@ func (s *SSHService) Connect(hostname string, port int, username string, authMet modes := ssh.TerminalModes{ ssh.ECHO: 1, - ssh.TTY_OP_ISPEED: 14400, - ssh.TTY_OP_OSPEED: 14400, + ssh.TTY_OP_ISPEED: 115200, + ssh.TTY_OP_OSPEED: 115200, } if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil {