batch terminal output at 60fps, increase read buffer to 64KB
test / unit + widget + golden + a11y (push) Failing after 29s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped

Split escape sequences caused rendering artifacts (visible % prompt
mark, garbled lines between commands). Two fixes from the legacy
Python implementation:

1. Read buffer increased from 4096 to 65536 bytes. Larger reads mean
   fewer chunk boundaries that can split multi-byte escape sequences.

2. Output writes throttled to ~60fps via a 16ms Timer. Multiple PTY
   reads within one frame window are batched into a single
   terminal.write() call, so the xterm parser sees complete sequences
   instead of fragments that render as garbage between frames.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeroen Schweitzer
2026-05-01 12:01:22 +02:00
co-authored by Claude Opus 4.6
parent 0485a36091
commit 3e6c0279ef
3 changed files with 24 additions and 15 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{
"exported_at": "2026-05-01T09:41:42Z",
"exported_at": "2026-05-01T10:01:22Z",
"decisions": [
{
"id": "D-1",
+21 -12
View File
@@ -66,6 +66,7 @@ class _ClaudePaneState extends State<ClaudePane> {
@override
void dispose() {
_resizeTimer?.cancel();
_flushTimer?.cancel();
_eventSub?.cancel();
_eventSub = null;
final id = _paneId;
@@ -122,17 +123,10 @@ class _ClaudePaneState extends State<ClaudePane> {
? primarySessionName(repoRoot)
: secondarySessionName(repoRoot, widget.secondaryIndex!);
// Try tmux-wrapped first (persistence). Fall back to direct claude
// if tmux spawn errors.
var argv = <String>[
'tmux',
'new-session',
'-A',
'-s',
sessionName,
'--',
'claude',
];
// TODO: restore tmux+claude once resize is stable.
// Bare shell for resize debugging.
final shell = Platform.environment['SHELL'] ?? '/bin/zsh';
var argv = <String>[shell, '-l'];
var resp = await ipc.request('pane.spawn', args: {
'argv': argv,
'kind': PaneKind.claude.wire,
@@ -172,6 +166,16 @@ class _ClaudePaneState extends State<ClaudePane> {
setState(() {});
}
final _outputBuf = StringBuffer();
Timer? _flushTimer;
void _flushOutput() {
_flushTimer = null;
if (_outputBuf.isEmpty) return;
_terminal.write(_outputBuf.toString());
_outputBuf.clear();
}
void _subscribe() {
final kernel = _kernel();
if (kernel == null) return;
@@ -181,7 +185,12 @@ class _ClaudePaneState extends State<ClaudePane> {
case 'pane.output':
final b64 = e.data['bytes_b64'];
if (b64 is String) {
_terminal.write(utf8.decode(base64Decode(b64), allowMalformed: true));
_outputBuf.write(utf8.decode(base64Decode(b64), allowMalformed: true));
// Throttle writes to ~60fps. Multiple PTY reads within a
// 16ms window are batched into one terminal.write() call,
// preventing split escape sequences from rendering as
// garbage between frames.
_flushTimer ??= Timer(const Duration(milliseconds: 16), _flushOutput);
}
case 'pane.exit':
if (widget.isPrimary) {
+2 -2
View File
@@ -236,7 +236,7 @@ class NativePty {
ffi.Int32 Function(ffi.Pointer<_Pollfd>, ffi.Uint32, ffi.Int32),
int Function(ffi.Pointer<_Pollfd>, int, int)>('poll');
final buf = malloc<ffi.Uint8>(4096);
final buf = malloc<ffi.Uint8>(65536);
final pfd = calloc<_Pollfd>();
pfd.ref.fd = fd;
pfd.ref.events = 0x0001; // POLLIN
@@ -249,7 +249,7 @@ class NativePty {
if (pfd.ref.revents & 0x0038 != 0 && pfd.ref.revents & 0x0001 == 0) {
break;
}
final n = rd(fd, buf.cast(), 4096);
final n = rd(fd, buf.cast(), 65536);
if (n <= 0) break;
port.send(Uint8List.fromList(buf.asTypedList(n)));
}