@@ -453,26 +453,63 @@ pub async fn spawn_pty( |
| 453 | 453 | }, |
| 454 | 454 | ); |
| 455 | 455 | |
| 456 | | - // Forwarder task: base64-encode each chunk off `data_rx` and emit |
| 457 | | - // a per-PTY event named `pty:data:<pty_id>`. Per-PTY events |
| 458 | | - // avoid the O(N) fan-out that the previous single `pty:data` |
| 459 | | - // channel had — every TerminalPane subscribes only to events |
| 460 | | - // for its own ptyId, so one PTY's stdout chunk doesn't wake up |
| 461 | | - // every listener in the webview. |
| 456 | + // Forwarder task: write-combines stdout chunks off `data_rx` |
| 457 | + // into ~frame-sized batches and emits them on a per-PTY event |
| 458 | + // `pty:data:<pty_id>`. Write combining matters because: |
| 462 | 459 | // |
| 463 | | - // When `data_rx` closes (reader task hit EOF), wait on |
| 464 | | - // `exit_rx` for the final exit code and emit `pty:exit` |
| 465 | | - // (globally, since the store subscribes to it to clean up |
| 466 | | - // `ptyIds` and doesn't want to listen per-pty). Then remove |
| 467 | | - // our entry from active_ptys — if close_pty already removed |
| 468 | | - // it, the remove here is a harmless no-op. |
| 460 | + // * Each emit is a Tauri IPC call that dispatches a DOM |
| 461 | + // event on the webview's main thread. |
| 462 | + // * Each frontend callback does base64 decode + `term.write` |
| 463 | + // which is non-trivial main-thread work. |
| 464 | + // |
| 465 | + // Without coalescing, a claude TUI spinner + token stream |
| 466 | + // easily pushed 30-60 discrete chunks per second through the |
| 467 | + // bridge, stalling the main thread enough to beachball mouse |
| 468 | + // input. We now buffer up to `MAX_COALESCE_BYTES` or until |
| 469 | + // `MAX_COALESCE_MS` elapses since the first queued chunk, |
| 470 | + // whichever comes first. |
| 471 | + const MAX_COALESCE_BYTES: usize = 16 * 1024; |
| 472 | + const MAX_COALESCE_MS: u64 = 16; |
| 473 | + |
| 469 | 474 | let app_clone = app.clone(); |
| 470 | 475 | let active_ptys = state.active_ptys.clone(); |
| 471 | 476 | let pid = pty_id.clone(); |
| 472 | 477 | let data_event = format!("pty:data:{pid}"); |
| 473 | 478 | tauri::async_runtime::spawn(async move { |
| 474 | | - while let Some(chunk) = data_rx.recv().await { |
| 475 | | - let b64 = BASE64_STANDARD.encode(&chunk); |
| 479 | + use std::time::Duration; |
| 480 | + |
| 481 | + let mut buffer: Vec<u8> = Vec::with_capacity(MAX_COALESCE_BYTES); |
| 482 | + loop { |
| 483 | + // Block for the first chunk of a batch. If the channel |
| 484 | + // has closed AND the buffer is empty, we're done. |
| 485 | + let first = match data_rx.recv().await { |
| 486 | + Some(chunk) => chunk, |
| 487 | + None => break, |
| 488 | + }; |
| 489 | + buffer.extend_from_slice(&first); |
| 490 | + |
| 491 | + // Pull additional chunks until we hit the byte or time |
| 492 | + // budget. `try_recv` is non-blocking; `time::timeout` |
| 493 | + // on `recv` bounds the wait. |
| 494 | + let deadline = tokio::time::Instant::now() |
| 495 | + + Duration::from_millis(MAX_COALESCE_MS); |
| 496 | + while buffer.len() < MAX_COALESCE_BYTES { |
| 497 | + let now = tokio::time::Instant::now(); |
| 498 | + if now >= deadline { |
| 499 | + break; |
| 500 | + } |
| 501 | + let remaining = deadline - now; |
| 502 | + match tokio::time::timeout(remaining, data_rx.recv()).await { |
| 503 | + Ok(Some(chunk)) => buffer.extend_from_slice(&chunk), |
| 504 | + Ok(None) => { |
| 505 | + // Sender closed — flush and exit after emit. |
| 506 | + break; |
| 507 | + } |
| 508 | + Err(_) => break, // timeout elapsed |
| 509 | + } |
| 510 | + } |
| 511 | + |
| 512 | + let b64 = BASE64_STANDARD.encode(&buffer); |
| 476 | 513 | let payload = PtyDataPayload { |
| 477 | 514 | pty_id: pid.clone(), |
| 478 | 515 | base64: b64, |
@@ -480,9 +517,10 @@ pub async fn spawn_pty( |
| 480 | 517 | if let Err(err) = app_clone.emit(&data_event, &payload) { |
| 481 | 518 | tracing::warn!(error = %err, "failed to emit pty:data"); |
| 482 | 519 | } |
| 520 | + buffer.clear(); |
| 483 | 521 | } |
| 484 | 522 | |
| 485 | | - // data_rx closed — now block on the wait task's exit code. |
| 523 | + // data_rx closed — wait on the exit code and emit pty:exit. |
| 486 | 524 | let exit_code = exit_rx.await.ok().flatten(); |
| 487 | 525 | let payload = PtyExitPayload { |
| 488 | 526 | pty_id: pid.clone(), |