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:
@@ -4037,3 +4037,5 @@ Acceptance: every tile on the welcome screen performs its action (widget test ta
|
||||
Resolved by removal, not stub flows: the two inert tiles are gone (each returns with its real flow — clone is honorable-mention territory in Q-49), the Open-folder shortcut glyph now matches the actual ctrl+o binding, and the tips card was corrected to six bindings that actually exist (quick open, palette, sidebar/context collapse, find-in-files, focus mode — the old card advertised four bindings that were never registered).', NULL, '2026-06-11 23:24:39', '2026-06-11 23:24:39', '2026-06-11 23:24:39', NULL, '53b1878fe9fd9c383d11bade928e811d', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDAK04ZA0PBT69ZWNBXPSR', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 23:25:05', '2026-06-11 23:25:05', '2026-06-11 23:25:05', NULL, '9da97fcbea83b39c381d63cb06ea7b50', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDAK04ZA0PBT69ZWNBXPSR', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:28:30', '2026-06-11 23:28:30', '2026-06-11 23:28:30', NULL, 'bd49ff4ff2fa7892ab5b667e46236300', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC7KDFW07S8WTCC3MD71J0', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 23:28:58', '2026-06-11 23:28:58', '2026-06-11 23:28:58', NULL, 'baf6395b93ca8eea9fd9044debffd54d', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC7KDFW07S8WTCC3MD71J0', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:31:55', '2026-06-11 23:31:55', '2026-06-11 23:31:55', NULL, '06da283bf11516ecae7fd4bd326e470a', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -4193,3 +4193,13 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat
|
||||
Fix: sweep the claude builtin handlers; on failure return the error envelope (non-zero CLI exit) like the rest of the dispatcher. Audit callers/UI that may currently rely on ok-with-error.
|
||||
|
||||
Acceptance: clide claude.agent.set-permission-mode bogus exits non-zero; a table-driven test walks the claude verbs'' failure paths asserting error envelopes; D-6 conformance restored.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 22:02:31', '2026-06-11 23:28:30', NULL, 'd3e727b4dd8848404845c9cf5af32891', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC7KDFW07S8WTCC3MD71J0', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Split-chunk UTF-8 corruption at byte-to-String seams: terminal write(String), FileTailFollower', 'The terminal''s only ingestion API is write(String) (lib/src/terminal/src/terminal.dart:218), so both consumers decode bytes per-chunk — a multi-byte rune split across PTY reads renders as U+FFFD garbage. FileTailFollower starts reading mid-file by construction, so it can begin mid-character too.
|
||||
|
||||
Fix: add Terminal.writeBytes(List<int>) backed by a persistent chunked Utf8Decoder (allowMalformed) per terminal instance; migrate the PTY consumer and FileTailFollower to it. Keep write(String) for tests/programmatic use.
|
||||
|
||||
Acceptance: test feeding a multi-byte rune split across two writeBytes calls renders one glyph; FileTailFollower starting mid-rune resyncs without emitting replacement chars mid-stream.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:44', '2026-06-11 23:28:58', NULL, 'aa06de288d298b3e4bf6add9e128e0ff', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC7KDFW07S8WTCC3MD71J0', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Split-chunk UTF-8 corruption at byte-to-String seams: terminal write(String), FileTailFollower', 'The terminal''s only ingestion API is write(String) (lib/src/terminal/src/terminal.dart:218), so both consumers decode bytes per-chunk — a multi-byte rune split across PTY reads renders as U+FFFD garbage. FileTailFollower starts reading mid-file by construction, so it can begin mid-character too.
|
||||
|
||||
Fix: add Terminal.writeBytes(List<int>) backed by a persistent chunked Utf8Decoder (allowMalformed) per terminal instance; migrate the PTY consumer and FileTailFollower to it. Keep write(String) for tests/programmatic use.
|
||||
|
||||
Acceptance: test feeding a multi-byte rune split across two writeBytes calls renders one glyph; FileTailFollower starting mid-rune resyncs without emitting replacement chars mid-stream.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:44', '2026-06-11 23:31:55', NULL, 'fdf8ae8544253d4ae0b79ab17e0d9003', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
|
||||
@@ -137,6 +137,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
could not detect failures like an unknown permission mode; they now
|
||||
return proper error envelopes per the D-6 contract. (T-391)
|
||||
|
||||
- **Terminal output no longer garbles multi-byte characters split across
|
||||
reads.** PTY output and live-tail bytes were decoded per chunk, turning a
|
||||
rune split across reads into replacement-character noise; the terminal
|
||||
now ingests bytes through a persistent decoder. (T-373)
|
||||
|
||||
- **Accepting ExitPlanMode now leaves plan mode in the conversation panel.**
|
||||
Approving Claude's plan (the ExitPlanMode tool) transitioned the underlying
|
||||
session out of plan mode, but clide's tracked permission mode didn't follow,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
/// dependency.
|
||||
library;
|
||||
|
||||
import 'dart:convert' show utf8;
|
||||
|
||||
import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart';
|
||||
import 'package:clide/src/terminal/src/core/input/keys.dart';
|
||||
import 'package:clide/src/terminal/src/core/mouse/button.dart';
|
||||
@@ -121,6 +123,29 @@ void main() {
|
||||
t.write('\x1b[31mred'); // SGR red foreground
|
||||
expect(t.cursor.foreground, isNot(0)); // foreground was set
|
||||
});
|
||||
|
||||
// T-373: byte consumers used to utf8.decode per chunk — a rune split
|
||||
// across PTY reads rendered as U+FFFD garbage.
|
||||
test('writeBytes joins a multi-byte rune split across two calls', () {
|
||||
final t = _Recorder().build();
|
||||
final euro = utf8.encode('€'); // 3 bytes: E2 82 AC
|
||||
t.writeBytes(euro.sublist(0, 1));
|
||||
t.writeBytes(euro.sublist(1));
|
||||
expect(t.buffer.lines[0].getCodePoint(0), '€'.codeUnitAt(0));
|
||||
});
|
||||
|
||||
test('writeBytes decodes consecutive whole chunks like write', () {
|
||||
final t = _Recorder().build();
|
||||
t.writeBytes(utf8.encode('héllo'));
|
||||
final line = [for (var i = 0; i < 5; i++) t.buffer.lines[0].getCodePoint(i)];
|
||||
expect(String.fromCharCodes(line), 'héllo');
|
||||
});
|
||||
|
||||
test('writeBytes with an empty chunk is a no-op', () {
|
||||
final t = _Recorder().build();
|
||||
t.writeBytes(const []);
|
||||
expect(t.buffer.cursorX, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('Terminal — keyInput', () {
|
||||
|
||||
Reference in New Issue
Block a user