add Terminal.writeBytes with a persistent UTF-8 decoder (T-373)

The terminal's only ingestion API was write(String), so both byte
consumers decoded per chunk — a multi-byte rune split across PTY
reads (or a tail window starting mid-character, which FileTailFollower
does by construction) rendered as U+FFFD garbage. writeBytes feeds a
per-instance chunked Utf8Decoder that carries partial-rune state
across calls; the terminal pane and the Bash live-tail follower now
use it, and write(String) stays for tests and programmatic writes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 01:32:13 +02:00
co-authored by Claude Fable 5
parent 928dede847
commit c31f5bfb14
7 changed files with 78 additions and 3 deletions
@@ -463,7 +463,9 @@ class _BashLiveTailState extends State<_BashLiveTail> {
if (source == null) return; // no file-backed source → muted note in build
final term = Terminal(maxLines: 1000);
_terminal = term;
_follower = FileTailFollower(source, onData: (bytes) => term.write(utf8.decode(bytes, allowMalformed: true)));
// writeBytes: the follower's chunk boundaries are arbitrary (it can even
// start mid-rune by construction) — keep decode state across reads (T-373).
_follower = FileTailFollower(source, onData: term.writeBytes);
unawaited(_follower!.start());
}
+3 -2
View File
@@ -114,8 +114,9 @@ class _TerminalPaneState extends State<TerminalPane> {
case 'pane.output':
final b64 = event.data['bytes_b64'];
if (b64 is String) {
final bytes = base64Decode(b64);
_terminal.write(utf8.decode(bytes, allowMalformed: true));
// writeBytes keeps UTF-8 decode state across chunks — a rune
// split across PTY reads must not become U+FFFD (T-373).
_terminal.writeBytes(base64Decode(b64));
}
case 'pane.exit':
setState(() => _error = 'Shell exited.');
+30
View File
@@ -1,5 +1,6 @@
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
import 'dart:convert' show ByteConversionSink, Utf8Decoder;
import 'dart:math' show max;
import 'package:clide/src/terminal/src/base/observable.dart';
@@ -215,11 +216,28 @@ class Terminal with Observable implements TerminalState, EscapeHandler {
/// Writes the data from the underlying program to the terminal. Calling this
/// updates the states of the terminal and emits events such as [onBell] or
/// [onTitleChange] when the escape sequences in [data] request it.
///
/// Byte-stream consumers (PTY output, file tails) should use [writeBytes]
/// instead — decoding per-chunk corrupts a multi-byte rune split across
/// reads (T-373). This String entry point stays for tests and
/// programmatic writes.
void write(String data) {
_parser.write(data);
notifyListeners();
}
/// Persistent chunked UTF-8 decoder feeding [write] — carries partial
/// rune state across [writeBytes] calls so a glyph split across two PTY
/// reads still renders as one glyph (T-373).
late final ByteConversionSink _byteSink = const Utf8Decoder(allowMalformed: true).startChunkedConversion(_WriteSink(this));
/// Byte-stream twin of [write]: decodes UTF-8 with state retained across
/// calls, so chunk boundaries can never split a rune into U+FFFD garbage.
void writeBytes(List<int> bytes) {
if (bytes.isEmpty) return;
_byteSink.add(bytes);
}
/// Sends a key event to the underlying program.
///
/// See also:
@@ -863,3 +881,15 @@ class Terminal with Observable implements TerminalState, EscapeHandler {
onPrivateOSC?.call(ps, pt);
}
}
/// Routes the chunked UTF-8 decoder's output into [Terminal.write] (T-373).
class _WriteSink implements Sink<String> {
_WriteSink(this._terminal);
final Terminal _terminal;
@override
void add(String data) => _terminal.write(data);
@override
void close() {}
}