fix: streaming UTF-8 decoder + rAF write batching for terminal performance
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 1m6s

Three fixes:

1. Streaming TextDecoder: a single TextDecoder instance with {stream: true}
   persists across events. Split multi-byte UTF-8 sequences at Go read()
   boundaries are now buffered and decoded correctly across chunks.

2. requestAnimationFrame batching: incoming SSH data is accumulated and
   flushed to xterm.js once per frame instead of on every Wails event.
   Eliminates the laggy typewriter effect when output arrives in small
   chunks (which is normal for SSH PTY output).

3. PTY baud rate: bumped TTY_OP_ISPEED/OSPEED from 14400 (modem speed)
   to 115200. Some remote PTYs throttle output to match the declared rate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-17 13:22:50 -04:00
parent 9fce0b6c1e
commit 05776b7eb6
2 changed files with 44 additions and 5 deletions

View File

@ -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 0x000xFF.
// 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;

View File

@ -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 {