diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 431003bb..fdee59f2 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -3943,3 +3943,5 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang 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 ('06FBHBV0465906BY3QFAY9F1YM', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:24:07', '2026-06-11 22:24:07', '2026-06-11 22:24:07', NULL, '6e0986eb4b0c15223d7b373bbbc421cb', 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 ('06FBHBYTJ4E7ZBY6DWWNT1S16M', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 22:24:34', '2026-06-11 22:24:34', '2026-06-11 22:24:34', NULL, '151c44c4c697f83900b1cc003c6a94f6', 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 ('06FBHBYTJ4E7ZBY6DWWNT1S16M', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:26:15', '2026-06-11 22:26:15', '2026-06-11 22:26:15', NULL, '05386705854fd48ee75d7a3dfbfd5bc9', 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 ('06FBHC0HYRZ86CWW0DDQJ5CAQM', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 22:26:38', '2026-06-11 22:26:38', '2026-06-11 22:26:38', NULL, '6d14a673d6733b5024726bfc227f6711', 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 ('06FBHC0HYRZ86CWW0DDQJ5CAQM', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:30:24', '2026-06-11 22:30:24', '2026-06-11 22:30:24', NULL, 'aa1fe975e147be83905f24c63662254d', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index da2d2933..a72503b5 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -3991,3 +3991,13 @@ Fix: gate the new-item auto-scroll on _atBottom (one-line) and add the missing t 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 ('06FBHBYTJ4E7ZBY6DWWNT1S16M', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Conversation auto-scroll yanks a scrolled-up reader to the bottom on every streamed token', 'lib/builtin/claude/src/conversation_view.dart:268-277 — the _atBottom pin exists but is only consulted on viewport resize, not on new items. Anyone reading earlier output during a long streaming reply is dragged to the bottom continuously. Fix: gate the new-item auto-scroll on _atBottom (one-line) and add the missing twin test: scrolled-up viewport stays put when items stream in; at-bottom viewport follows.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:56:33', '2026-06-11 22:26:15', NULL, '1340fb7aac3cdf06476ce74248fda6c0', 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 ('06FBHC0HYRZ86CWW0DDQJ5CAQM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Terminal throws RangeError on truncated SGR 38/48; colon sub-parameters mangled', 'lib/src/terminal/src/core/escape/parser.dart:501-516 — SGR 38/48 extended-color parsing does unguarded params[i + 1] lookahead, so a truncated sequence like printf ''\e[38m'' throws RangeError inside Terminal.write. An emulator must never throw on hostile bytes. Secondary, same code path: colon-form SGR sub-parameters (e.g. 38:2:r:g:b, emitted by modern terminfo) are not split out and get mangled into bogus params. + +Fix: bounds-check the lookahead (ignore incomplete 38/48 sequences), and parse colon-form sub-parameters per ECMA-48/ITU T.416 — treat 38:2::r:g:b and 38;2;r;g;b equivalently. Note T-123 (parser split) touches the same file; coordinate but do not block on it. + +Acceptance: feeding any truncated/garbled SGR byte sequence never throws (fuzz-style test over partial sequences); colon-form truecolor sets the same fg/bg as semicolon form; existing SGR tests stay green.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-11 21:56:47', '2026-06-11 22:26:38', NULL, '8cc428e286b918b46744a649ab1979f0', 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 ('06FBHC0HYRZ86CWW0DDQJ5CAQM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Terminal throws RangeError on truncated SGR 38/48; colon sub-parameters mangled', 'lib/src/terminal/src/core/escape/parser.dart:501-516 — SGR 38/48 extended-color parsing does unguarded params[i + 1] lookahead, so a truncated sequence like printf ''\e[38m'' throws RangeError inside Terminal.write. An emulator must never throw on hostile bytes. Secondary, same code path: colon-form SGR sub-parameters (e.g. 38:2:r:g:b, emitted by modern terminfo) are not split out and get mangled into bogus params. + +Fix: bounds-check the lookahead (ignore incomplete 38/48 sequences), and parse colon-form sub-parameters per ECMA-48/ITU T.416 — treat 38:2::r:g:b and 38;2;r;g;b equivalently. Note T-123 (parser split) touches the same file; coordinate but do not block on it. + +Acceptance: feeding any truncated/garbled SGR byte sequence never throws (fuzz-style test over partial sequences); colon-form truecolor sets the same fg/bg as semicolon form; existing SGR tests stay green.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:56:47', '2026-06-11 22:30:24', NULL, 'b64b55f7d40149848fc687efe1cf8f32', 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); diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f1b5244..c4a14f0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. scroll position, dragging a reader back to the bottom; it now follows only while already pinned there. (T-368) +- **The terminal no longer crashes on truncated SGR color sequences.** + `ESC[38m` and friends threw a RangeError inside the emulator; incomplete + 38/48 sequences are now ignored, and colon-form truecolor/256-color + sub-parameters (`38:2:r:g:b`, ITU T.416) parse like the semicolon form + instead of being mangled. (T-369) + - **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, diff --git a/lib/src/terminal/src/core/escape/parser.dart b/lib/src/terminal/src/core/escape/parser.dart index 8f99e345..71e9a3bd 100644 --- a/lib/src/terminal/src/core/escape/parser.dart +++ b/lib/src/terminal/src/core/escape/parser.dart @@ -220,6 +220,7 @@ class EscapeParser { } _csi.params.clear(); + _csi.subParam.clear(); // test whether the csi is a `CSI ? Ps ...` or `CSI Ps ...` final prefix = _queue.peek(); @@ -232,6 +233,11 @@ class EscapeParser { var param = 0; var hasParam = false; + // Whether the value being accumulated was attached to its predecessor + // with a colon (ECMA-48 sub-parameter separator, ITU T.416 SGR colors). + // Before T-369 colons were silently dropped mid-sequence, fusing + // `38:2:255:0:0` into one bogus parameter. + var linkedToPrev = false; while (true) { // The sequence isn't completed, just ignore it. if (_queue.isEmpty) { @@ -243,8 +249,21 @@ class EscapeParser { if (char == Ascii.semicolon) { if (hasParam) { _csi.params.add(param); + _csi.subParam.add(linkedToPrev); } param = 0; + linkedToPrev = false; + continue; + } + + if (char == Ascii.colon) { + // Push the current value even when empty — `38:2::r:g:b` carries an + // empty colorspace slot that must keep its position in the group. + _csi.params.add(hasParam ? param : 0); + _csi.subParam.add(linkedToPrev); + hasParam = true; + param = 0; + linkedToPrev = true; continue; } @@ -263,6 +282,7 @@ class EscapeParser { if (char >= Ascii.atSign && char <= Ascii.tilde) { if (hasParam) { _csi.params.add(param); + _csi.subParam.add(linkedToPrev); } _csi.finalByte = char; @@ -499,21 +519,7 @@ class EscapeParser { handler.setForegroundColor16(NamedColor.white); continue; case 38: - final mode = params[i + 1]; - switch (mode) { - case 2: - final r = params[i + 2]; - final g = params[i + 3]; - final b = params[i + 4]; - handler.setForegroundColorRgb(r, g, b); - i += 4; - break; - case 5: - final index = params[i + 2]; - handler.setForegroundColor256(index); - i += 2; - break; - } + i = _csiHandleExtendedColor(i, foreground: true); continue; case 39: handler.resetForeground(); @@ -544,21 +550,7 @@ class EscapeParser { handler.setBackgroundColor16(NamedColor.white); continue; case 48: - final mode = params[i + 1]; - switch (mode) { - case 2: - final r = params[i + 2]; - final g = params[i + 3]; - final b = params[i + 4]; - handler.setBackgroundColorRgb(r, g, b); - i += 4; - break; - case 5: - final index = params[i + 2]; - handler.setBackgroundColor256(index); - i += 2; - break; - } + i = _csiHandleExtendedColor(i, foreground: false); continue; case 49: handler.resetBackground(); @@ -621,6 +613,61 @@ class EscapeParser { } } + /// Extended fg/bg color (SGR 38/48), semicolon or colon form. + /// + /// Returns the index of the last parameter consumed. Never reads past the + /// end of the parameter list — a truncated sequence (`ESC [38m`, + /// `ESC [38;2;255m`) is ignored instead of throwing; an emulator must never + /// throw on hostile bytes (T-369). Colon-form sub-parameters per ITU T.416 + /// (`38:2:r:g:b`, `38:2::r:g:b`, `38:5:n`) are treated as one + /// logical group: parsed equivalently to the semicolon form, and dropped + /// whole when malformed so they never spill into neighbouring parameters. + int _csiHandleExtendedColor(int i, {required bool foreground}) { + final params = _csi.params; + final sub = _csi.subParam; + + // End of the colon-linked group starting at params[i] (exclusive). + var end = i + 1; + while (end < params.length && sub[end]) { + end++; + } + + if (end > i + 1) { + // Colon form. Group is params[i..end-1]; n includes the 38/48 itself. + final n = end - i; + final mode = params[i + 1]; + if (mode == 5 && n >= 3) { + foreground ? handler.setForegroundColor256(params[i + 2]) : handler.setBackgroundColor256(params[i + 2]); + } else if (mode == 2) { + // A 6+ element group carries the T.416 colorspace id slot — skip it. + final base = n >= 6 ? i + 3 : i + 2; + if (base + 2 < end) { + foreground + ? handler.setForegroundColorRgb(params[base], params[base + 1], params[base + 2]) + : handler.setBackgroundColorRgb(params[base], params[base + 1], params[base + 2]); + } + } + return end - 1; + } + + // Semicolon form (legacy). + if (i + 1 >= params.length) return i; // bare 38/48 — ignore + switch (params[i + 1]) { + case 2: + if (i + 4 >= params.length) return params.length - 1; // truncated — ignore + foreground + ? handler.setForegroundColorRgb(params[i + 2], params[i + 3], params[i + 4]) + : handler.setBackgroundColorRgb(params[i + 2], params[i + 3], params[i + 4]); + return i + 4; + case 5: + if (i + 2 >= params.length) return params.length - 1; // truncated — ignore + foreground ? handler.setForegroundColor256(params[i + 2]) : handler.setBackgroundColor256(params[i + 2]); + return i + 2; + } + // Unknown mode — consume it so it isn't re-interpreted as an SGR code. + return i + 1; + } + /// `ESC [ Ps n` Device Status Report [Dispatch] (DSR) /// /// https://terminalguide.namepad.de/seq/csi_sn/ @@ -1121,6 +1168,10 @@ class _Csi { List params; + /// Parallel to [params]: true when that parameter was attached to its + /// predecessor with a colon (ECMA-48 sub-parameter, ITU T.416 — T-369). + final List subParam = []; + int finalByte; // final List intermediates; diff --git a/test/terminal/escape/parser_test.dart b/test/terminal/escape/parser_test.dart index ba8dd0fe..64f1076c 100644 --- a/test/terminal/escape/parser_test.dart +++ b/test/terminal/escape/parser_test.dart @@ -676,6 +676,56 @@ void main() { f.parser.write('\x1b[123m'); expect(f.h.named('unsupportedStyle').first.args, [123]); }); + + // T-369: an emulator must never throw on hostile bytes. The old code did + // unguarded params[i+1] lookahead in 38/48 — `\x1b[38m` was a RangeError + // inside Terminal.write. + test('truncated 38/48 sequences are ignored, never throw', () { + final f = _newParser(); + for (final s in ['\x1b[38m', '\x1b[48m', '\x1b[38;2m', '\x1b[38;2;255m', '\x1b[38;2;255;10m', '\x1b[38;5m', '\x1b[48;5m', '\x1b[38:2m', '\x1b[38:5m', '\x1b[48:2:255m']) { + f.parser.write(s); + } + expect(f.h.named('setForegroundColorRgb'), isEmpty); + expect(f.h.named('setBackgroundColorRgb'), isEmpty); + expect(f.h.named('setForegroundColor256'), isEmpty); + expect(f.h.named('setBackgroundColor256'), isEmpty); + }); + + test('colon-form truecolor matches semicolon form (ITU T.416)', () { + final f = _newParser(); + f.parser.write('\x1b[38:2:10:20:30m\x1b[48:2:100:150:200m'); + expect(f.h.named('setForegroundColorRgb').first.args, [10, 20, 30]); + expect(f.h.named('setBackgroundColorRgb').first.args, [100, 150, 200]); + }); + + test('colon-form with empty colorspace slot — 38:2::r:g:b', () { + final f = _newParser(); + f.parser.write('\x1b[38:2::10:20:30m'); + expect(f.h.named('setForegroundColorRgb').first.args, [10, 20, 30]); + }); + + test('colon-form 256-colour — 38:5:n', () { + final f = _newParser(); + f.parser.write('\x1b[38:5:200m\x1b[48:5:42m'); + expect(f.h.named('setForegroundColor256').first.args, [200]); + expect(f.h.named('setBackgroundColor256').first.args, [42]); + }); + + test('a malformed colon group is dropped whole, neighbours still apply', () { + final f = _newParser(); + // The bogus 38:2:255 group must not bleed into the following bold. + f.parser.write('\x1b[38:2:255;1m'); + expect(f.h.named('setForegroundColorRgb'), isEmpty); + expect(f.h.named('setCursorBold').length, 1); + }); + + test('extended color followed by more SGR params keeps positions', () { + final f = _newParser(); + f.parser.write('\x1b[1;38;2;10;20;30;4m'); + expect(f.h.named('setCursorBold').length, 1); + expect(f.h.named('setForegroundColorRgb').first.args, [10, 20, 30]); + expect(f.h.named('setCursorUnderline').length, 1); + }); }); group('EscapeParser — OSC sequences', () {