Files
clide/lib/builtin/claude/src/transcript_reader.dart
T
jpmschweitzerandClaude Opus 4.8 281fb2a869 feat(icon): Phosphor glyph card — hero + real-UI-size strip (T-313)
The icon card renders each glyph at a hero size plus a continuous sample
strip (10–48px) so legibility is judged at the sizes the app uses, with the
entry's optional label + description and a per-entry or card-level color.
Wired end to end: icon.show publishes on the `icon` bus, the Claude
extension injects an IconMessage, and conversation_view paints the card
(display-only, D-78). en+nl catalogs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:10:53 +02:00

812 lines
29 KiB
Dart

/// Tails Claude Code's transcript JSONL and emits parsed conversation items.
///
/// Data layer only — no UI. Feeds native Claude rendering (epic T-132).
///
/// # Path convention
/// Claude Code stores transcripts at:
/// `~/.claude/projects/<munged-cwd>/<session-id>.jsonl`
/// where `munged-cwd = absolutePath.replaceAll('/', '-')` (leading `-` kept).
/// Subagent transcripts live under:
/// `<munged-cwd>/<session-id>/subagents/agent-<id>.jsonl`
///
/// # Session discovery
/// The caller supplies a workspace path. [TranscriptReader] resolves the
/// munged directory and picks the newest `.jsonl` by mtime. It polls mtime
/// and switches to a newer file if one appears (e.g. the user starts a new
/// Claude Code session).
///
/// # Streaming
/// The stream uses an append-only byte-cursor so it never re-processes bytes
/// it has already seen. Each iteration:
/// 1. Reads from the cursor position to EOF.
/// 2. Splits on newlines.
/// 3. Parses each line as JSON and emits any recognised [ConversationItem].
///
/// # Version drift-guard
/// If the envelope `version` field has an unfamiliar major version the reader
/// warns via `onWarn` (or stderr if omitted) and degrades gracefully — it
/// parses whatever it can and skips the rest rather than crashing.
library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
// ---------------------------------------------------------------------------
// Data model
// ---------------------------------------------------------------------------
/// Discriminated union of conversation items the reader can emit.
sealed class ConversationItem {
const ConversationItem({required this.uuid, required this.timestamp, required this.isSidechain, this.parentUuid, this.parentToolUseId});
final String uuid;
final DateTime timestamp;
final bool isSidechain;
/// The uuid of this record's predecessor in its chain (from the transcript
/// envelope's `parentUuid`), or null when absent/empty. A sidechain prompt
/// branches off the assistant message that issued its spawning Agent/Task
/// tool-use, so this links the prompt to the right Agent card (T-263).
final String? parentUuid;
/// The `parent_tool_use_id` from the stream-json wire (T-338): the tool-use
/// id of the Agent/Task call that spawned this sub-agent message. Stream-json
/// tags every sidechain item with it — the transcript JSONL instead uses
/// [isSidechain] + [parentUuid]. When present it routes the item straight to
/// its Agent card by tool-use id, no uuid-chain walk needed, and on its own
/// marks the item as a sidechain message.
final String? parentToolUseId;
}
/// A user-typed message (plain text, possibly multi-part).
final class UserMessage extends ConversationItem {
const UserMessage({
required super.uuid,
required super.timestamp,
required super.isSidechain,
super.parentUuid,
super.parentToolUseId,
required this.text,
this.injected = false,
});
/// The concatenated text of all `text` parts in the content array.
final String text;
/// True when this "user" message was injected by the harness (a skill load,
/// a slash-command expansion, a system reminder) rather than typed by the
/// user — the view de-emphasises these (D-78).
final bool injected;
@override
String toString() => 'UserMessage(${_shortId(uuid)}, ${text.length} chars${injected ? ', injected' : ''})';
}
/// A tool-result delivered from the host back to Claude as a user message.
final class ToolResultMessage extends ConversationItem {
const ToolResultMessage({
required super.uuid,
required super.timestamp,
required super.isSidechain,
super.parentUuid,
super.parentToolUseId,
required this.toolUseId,
required this.content,
required this.isError,
});
final String toolUseId;
final String content;
final bool isError;
@override
String toString() => 'ToolResultMessage(toolUseId=$toolUseId, isError=$isError)';
}
/// Plain text from an assistant turn.
final class AssistantTextMessage extends ConversationItem {
const AssistantTextMessage({
required super.uuid,
required super.timestamp,
required super.isSidechain,
super.parentUuid,
super.parentToolUseId,
required this.text,
this.synthetic = false,
});
final String text;
/// CLI-local output, not the model: the wire marks it `model: "<synthetic>"`
/// (a forwarded local command's response — /usage output, "/x isn't
/// available in this environment", …). clide-injected notices use it too.
/// Rendered as a muted "clide" card, never coral Claude prose (T-411).
final bool synthetic;
@override
String toString() => 'AssistantTextMessage(${_shortId(uuid)}, ${text.length} chars${synthetic ? ', synthetic' : ''})';
}
/// Extended thinking block from an assistant turn.
final class AssistantThinkingMessage extends ConversationItem {
const AssistantThinkingMessage({
required super.uuid,
required super.timestamp,
required super.isSidechain,
super.parentUuid,
super.parentToolUseId,
required this.thinking,
});
final String thinking;
@override
String toString() => 'AssistantThinkingMessage(${_shortId(uuid)}, ${thinking.length} chars)';
}
/// A tool-use invocation in an assistant turn.
final class AssistantToolUse extends ConversationItem {
const AssistantToolUse({
required super.uuid,
required super.timestamp,
required super.isSidechain,
super.parentUuid,
super.parentToolUseId,
required this.toolUseId,
required this.name,
required this.input,
});
/// Tool invocation id (matches the [ToolResultMessage.toolUseId]).
final String toolUseId;
/// Tool name, e.g. `"Bash"` or `"Read"`.
final String name;
/// Raw decoded input map.
final Map<String, dynamic> input;
@override
String toString() => 'AssistantToolUse(name=$name, id=$toolUseId)';
}
/// A locally-injected image card (T-249). Not parsed from the transcript —
/// driven into the conversation by `clide image show <path>` (D-6 parity) and
/// rendered display-only per D-78. [path] is an absolute, on-disk file the
/// driver has already resolved (workspace-relative paths are resolved before
/// injection); [caption] is an optional one-line label.
final class ImageMessage extends ConversationItem {
const ImageMessage({
required super.uuid,
required super.timestamp,
required super.isSidechain,
required this.path,
this.caption,
this.label,
this.description,
});
/// Absolute path to the image file on disk.
final String path;
/// Optional caption shown under the image.
final String? caption;
/// Optional richer annotations from a `--file` metadata payload (T-316): a
/// title/label above the image and a longer description beneath it.
final String? label, description;
@override
String toString() => 'ImageMessage($path${caption != null ? ', "$caption"' : ''})';
}
/// A locally-injected drawing card (T-318). Not parsed from the transcript —
/// driven into the conversation by `clide draw --file <doc>` (D-6 parity) and
/// rendered display-only per D-78. [svg] is the SVG substrate the renderer
/// paints (already lowered from the doc's template / primitive source);
/// [label] / [description] are the optional card caption.
final class DrawingMessage extends ConversationItem {
const DrawingMessage({
required super.uuid,
required super.timestamp,
required super.isSidechain,
required this.svg,
this.label,
this.description,
this.source,
});
/// The SVG document source the renderer paints.
final String svg;
/// Optional card-level caption (label + supporting description).
final String? label, description;
/// Optional template source (e.g. the d2 diagram text) — shown in a collapsed
/// "view source" disclosure on the card when present (T-494).
final String? source;
@override
String toString() => 'DrawingMessage(${label ?? '<svg>'})';
}
/// One glyph entry on an [IconMessage] (T-313): a resolved Phosphor [codepoint]
/// (its [name] kept for copy/debug), with optional per-entry [label],
/// [description], and [color] (hex or CSS name, parsed at render).
final class IconEntry {
const IconEntry({required this.codepoint, required this.name, this.label, this.description, this.color});
final int codepoint;
final String name;
final String? label, description, color;
}
/// A locally-injected Phosphor glyph card (T-313). Driven by `clide icon show`
/// (D-6 parity), display-only per D-78. Renders each [entries] glyph at a hero
/// size plus a sample strip of real UI sizes, with its optional label +
/// description; [color] is the card-level default glyph color.
final class IconMessage extends ConversationItem {
const IconMessage({required super.uuid, required super.timestamp, required super.isSidechain, required this.entries, this.color});
final List<IconEntry> entries;
final String? color;
@override
String toString() => 'IconMessage(${entries.length} glyph${entries.length == 1 ? '' : 's'})';
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Safe short UUID prefix for use in [toString] methods.
String _shortId(String uuid) => uuid.length >= 8 ? uuid.substring(0, 8) : uuid;
// ---------------------------------------------------------------------------
// Reader
// ---------------------------------------------------------------------------
/// On first attach to a session file, read at most this many recent
/// bytes (not the whole file) — an active transcript can be many MB and
/// parsing it all synchronously would freeze the UI. Appends after that
/// stream incrementally.
const _defaultInitialTailBytes = 256 * 1024;
/// Chunks at least this large are parsed in a background isolate; smaller
/// ones parse inline. Streaming appends are small, so this keeps the
/// off-thread parse to the initial-tail case that actually janks a frame.
const _isolateParseThreshold = 64 * 1024;
/// Known major transcript versions.
const _knownMajorVersions = {1, 2};
/// Record types to skip (do not emit as conversation items).
const _skipTypes = {'attachment', 'system', 'last-prompt', 'permission-mode', 'file-history-snapshot', 'queue-operation'};
/// Tails Claude Code's transcript JSONL and emits [ConversationItem]s.
///
/// Call [stream] to obtain the live stream. Dispose with [dispose] when done.
class TranscriptReader {
/// Creates a reader for [workspacePath].
///
/// [pollInterval] controls how often the reader polls for new data and
/// session switches (default 500 ms).
///
/// `onWarn` receives warning messages from the version drift-guard.
/// If omitted, warnings are written to stderr.
TranscriptReader(
this.workspacePath, {
Duration pollInterval = const Duration(milliseconds: 500),
void Function(String)? onWarn,
String? projectsBase,
int? initialTailBytes,
String? file,
}) : _pollInterval = pollInterval,
_onWarn = onWarn ?? _defaultWarn,
_projectsBase = projectsBase ?? _defaultProjectsBase(),
_initialTailBytes = initialTailBytes ?? _defaultInitialTailBytes,
_explicitFile = file;
final String workspacePath;
final Duration _pollInterval;
final void Function(String) _onWarn;
/// Max bytes of recent history to read when first attaching to a
/// session file (overridable for tests).
final int _initialTailBytes;
/// Base dir holding the per-workspace transcript dirs. Defaults to
/// `~/.claude/projects`; overridable so tests point the real reader at a
/// temp directory instead of the user's home.
final String _projectsBase;
/// When set, tail this exact file instead of discovering the newest
/// `.jsonl` in the munged dir. Used for teammate subagent transcripts
/// (T-139), whose path the team observer resolves explicitly.
final String? _explicitFile;
static String _defaultProjectsBase() {
final home = Platform.environment['HOME'] ?? '';
return home.isNotEmpty ? '$home/.claude/projects' : '.claude/projects';
}
StreamController<ConversationItem>? _controller;
final StreamController<SessionStatus> _statusController = StreamController<SessionStatus>.broadcast();
SessionStatus _status = const SessionStatus();
Timer? _timer;
String? _currentPath;
int _cursor = 0;
static void _defaultWarn(String msg) => stderr.writeln('[TranscriptReader] $msg');
// ---------------------------------------------------------------------------
// Path resolution
// ---------------------------------------------------------------------------
/// Resolves `<projectsBase>/<munged-cwd>`.
String _mungedDir() {
final munged = workspacePath.replaceAll('/', '-');
return '$_projectsBase/$munged';
}
/// Finds the newest `.jsonl` by mtime inside [dir], or null if none exist.
static Future<String?> _newestJsonl(String dir) async {
final d = Directory(dir);
if (!await d.exists()) return null;
FileStat? bestStat;
String? bestPath;
await for (final entity in d.list()) {
if (entity is! File) continue;
if (!entity.path.endsWith('.jsonl')) continue;
final stat = await entity.stat();
if (bestStat == null || stat.modified.isAfter(bestStat.modified)) {
bestStat = stat;
bestPath = entity.path;
}
}
return bestPath;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// A broadcast stream of [ConversationItem]s from the active transcript.
///
/// The stream is created lazily on first call. Subsequent calls return the
/// same stream. Call [dispose] to cancel polling and close the stream.
Stream<ConversationItem> get stream {
_controller ??= _start();
return _controller!.stream;
}
/// Live [SessionStatus] updates (model / permission-mode / context
/// tokens), emitted only when a value changes (T-145). Starts polling
/// too, so a status-only consumer still drives the tail.
Stream<SessionStatus> get statusStream {
_controller ??= _start();
return _statusController.stream;
}
/// Cancels polling and closes the underlying stream.
Future<void> dispose() async {
_timer?.cancel();
_timer = null;
unawaited(_statusController.close());
await _controller?.close();
_controller = null;
}
// ---------------------------------------------------------------------------
// Internal — polling
// ---------------------------------------------------------------------------
StreamController<ConversationItem> _start() {
final controller = StreamController<ConversationItem>.broadcast();
_scheduleNext(controller);
return controller;
}
void _scheduleNext(StreamController<ConversationItem> controller) {
_timer = Timer(_pollInterval, () async {
if (controller.isClosed) return;
await _tick(controller);
if (!controller.isClosed) _scheduleNext(controller);
});
}
Future<void> _tick(StreamController<ConversationItem> controller) async {
// A pane/teammate reader tails one fixed file; otherwise discover the
// newest session `.jsonl` in the munged dir.
final newest = _explicitFile ?? await _newestJsonl(_mungedDir());
if (newest == null) return;
// An explicit file may not exist yet (claude writes it shortly after
// spawn) — wait for it rather than throwing in the poll loop.
if (!await File(newest).exists()) return;
if (newest != _currentPath) {
// New session file. Start from the recent tail rather than byte 0:
// an active transcript can be many MB (thousands of records), and
// parsing the whole thing synchronously on first attach freezes the
// UI. Read at most [_initialTailBytes] of recent history, then stream
// appends. A partial first line (from landing mid-record) simply
// fails to JSON-parse and is skipped. Older scrollback is a future
// load-more concern.
_currentPath = newest;
final length = await File(newest).length();
_cursor = length > _initialTailBytes ? length - _initialTailBytes : 0;
}
await _tail(controller, newest);
}
Future<void> _tail(StreamController<ConversationItem> controller, String path) async {
final file = File(path);
final length = await file.length();
if (length <= _cursor) return; // no new bytes
final String chunk;
final raf = await file.open();
try {
await raf.setPosition(_cursor);
final newBytes = await raf.read(length - _cursor);
_cursor = length;
chunk = utf8.decode(newBytes, allowMalformed: true);
} finally {
await raf.close();
}
if (controller.isClosed) return;
// Parse off the UI isolate only when the chunk is big enough to jank a
// frame — the initial tail read (up to [_initialTailBytes]) is the case
// that froze the app. Streaming appends are small (a message at a time);
// parsing those inline avoids spawning a one-shot isolate every poll
// tick, which is pure overhead and adds latency under load.
final parsed = chunk.length >= _isolateParseThreshold ? await Isolate.run(() => parseTranscriptChunk(chunk)) : parseTranscriptChunk(chunk);
if (controller.isClosed) return;
for (final w in parsed.warnings) {
_onWarn(w);
}
for (final item in parsed.items) {
if (controller.isClosed) break;
controller.add(item);
}
// Fold this chunk's status deltas into the running status; emit only
// on change so listeners (the status strip) don't churn.
final merged = _status.merge(parsed.status);
if (merged != _status) {
_status = merged;
if (!_statusController.isClosed) _statusController.add(_status);
}
}
/// Parse a single JSONL line into its items (forwarding any version
/// warnings to `onWarn`). Public so tests exercise the real parser.
List<ConversationItem> parseLine(String line) {
final parsed = parseTranscriptChunk(line);
for (final w in parsed.warnings) {
_onWarn(w);
}
return parsed.items;
}
}
/// Live per-session status surfaced for the status strip / sidebar
/// (T-145, T-168). All fields nullable — a chunk only carries what it saw,
/// and the reader [merge]s deltas into a running status.
class SessionStatus {
const SessionStatus({this.model, this.permissionMode, this.contextTokens, this.cost, this.contextWindow, this.rateLimitInfo, this.effort});
/// Assistant `message.model`, e.g. `claude-opus-4-7`.
final String? model;
/// Latest `permissionMode` (default / acceptEdits / plan / bypassPermissions).
final String? permissionMode;
/// Input context-window tokens in the most recent assistant turn
/// (input + cache-read + cache-creation). A count, not a percentage —
/// the transcript doesn't carry the model's context limit.
final int? contextTokens;
/// Cumulative cost in USD from the `result` event's `total_cost_usd`
/// field (T-168). Null until the first result event arrives.
final double? cost;
/// The model's context-window size in tokens from the `result` event's
/// `modelUsage.<model>.contextWindow` (T-168). Null until first result.
final int? contextWindow;
/// Latest rate-limit info string from `rate_limit_event`, e.g.
/// `"rate limited — resets 14:32"` (T-168). Null when not rate-limited.
final String? rateLimitInfo;
/// The session's effort level (`--effort`, T-412). The wire never reports
/// it — clide records what it spawned with via [StreamJsonSession.noteEffort];
/// null means the CLI default (settings.json `effortLevel`).
final String? effort;
bool get isEmpty =>
model == null && permissionMode == null && contextTokens == null && cost == null && contextWindow == null && rateLimitInfo == null && effort == null;
/// Overlay [other]'s non-null fields onto this one.
SessionStatus merge(SessionStatus other) => SessionStatus(
model: other.model ?? model,
permissionMode: other.permissionMode ?? permissionMode,
contextTokens: other.contextTokens ?? contextTokens,
cost: other.cost ?? cost,
contextWindow: other.contextWindow ?? contextWindow,
rateLimitInfo: other.rateLimitInfo ?? rateLimitInfo,
effort: other.effort ?? effort,
);
@override
bool operator ==(Object other) =>
other is SessionStatus &&
other.model == model &&
other.permissionMode == permissionMode &&
other.contextTokens == contextTokens &&
other.cost == cost &&
other.contextWindow == contextWindow &&
other.rateLimitInfo == rateLimitInfo &&
other.effort == effort;
@override
int get hashCode => Object.hash(model, permissionMode, contextTokens, cost, contextWindow, rateLimitInfo, effort);
}
/// Result of [parseTranscriptChunk]: items, version-drift warnings, and
/// the latest [SessionStatus] deltas seen in the chunk.
typedef ParsedChunk = ({List<ConversationItem> items, List<String> warnings, SessionStatus status});
class _StatusAcc {
String? model;
String? permissionMode;
int? contextTokens;
double? cost;
int? contextWindow;
String? rateLimitInfo;
SessionStatus toStatus() => SessionStatus(
model: model,
permissionMode: permissionMode,
contextTokens: contextTokens,
cost: cost,
contextWindow: contextWindow,
rateLimitInfo: rateLimitInfo,
);
}
// ---------------------------------------------------------------------------
// Parsing — pure + isolate-safe. Top-level (no instance state) so it can run
// via Isolate.run. Malformed JSON and skip/unknown types are dropped; an
// unfamiliar major `version` adds a warning but parsing still proceeds.
// ---------------------------------------------------------------------------
ParsedChunk parseTranscriptChunk(String chunk) {
final items = <ConversationItem>[];
final warnings = <String>[];
final status = _StatusAcc();
for (final raw in chunk.split('\n')) {
final line = raw.trim();
if (line.isEmpty) continue;
_parseLineInto(line, items, warnings, status);
}
return (items: items, warnings: warnings, status: status.toStatus());
}
void _parseLineInto(String line, List<ConversationItem> out, List<String> warnings, _StatusAcc status) {
Map<String, dynamic> envelope;
try {
envelope = (jsonDecode(line) as Map).cast<String, dynamic>();
} catch (_) {
return; // malformed JSON — skip silently
}
final rawVersion = envelope['version'] as String?;
if (rawVersion != null) {
final dotIdx = rawVersion.indexOf('.');
final majorStr = dotIdx > 0 ? rawVersion.substring(0, dotIdx) : rawVersion;
final major = int.tryParse(majorStr);
if (major != null && !_knownMajorVersions.contains(major)) {
warnings.add(
'unfamiliar transcript version "$rawVersion" (major=$major); '
'parsing will degrade gracefully',
);
}
}
final type = envelope['type'] as String?;
if (type == null) return;
// Status extraction runs for skip-types too (permission-mode is skipped
// as an item but carries the current mode).
if (type == 'permission-mode') {
final pm = envelope['permissionMode'] as String?;
if (pm != null && pm.isNotEmpty) status.permissionMode = pm;
}
if (_skipTypes.contains(type)) return;
final uuid = envelope['uuid'] as String? ?? '';
final rawParent = envelope['parentUuid'] as String?;
final parentUuid = (rawParent != null && rawParent.isNotEmpty) ? rawParent : null;
// Stream-json tags sub-agent messages with `parent_tool_use_id` (the spawning
// Agent/Task tool-use), not the transcript's isSidechain/parentUuid (T-338).
// Treat its presence as a sidechain marker so the fold + de-emphasis kick in.
final rawParentTool = envelope['parent_tool_use_id'] as String?;
final parentToolUseId = (rawParentTool != null && rawParentTool.isNotEmpty) ? rawParentTool : null;
final isSidechain = (envelope['isSidechain'] as bool? ?? false) || parentToolUseId != null;
DateTime timestamp;
try {
timestamp = DateTime.parse(envelope['timestamp'] as String? ?? '');
} catch (_) {
timestamp = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
}
switch (type) {
case 'user':
_parseUserInto(envelope, uuid, timestamp, isSidechain, parentUuid, parentToolUseId, out);
case 'assistant':
_parseAssistantInto(envelope, uuid, timestamp, isSidechain, parentUuid, parentToolUseId, out);
_extractAssistantStatus(envelope, status);
default:
break; // unknown type — degrade gracefully
}
}
/// Capture model + context tokens from an assistant turn's message.
void _extractAssistantStatus(Map<String, dynamic> envelope, _StatusAcc status) {
final message = envelope['message'] as Map?;
if (message == null) return;
final model = message['model'] as String?;
// "<synthetic>" marks CLI-local output (a forwarded local command's
// response) — not a model switch; it must not clobber the tracked model.
if (model != null && model.isNotEmpty && model != kSyntheticModel) status.model = model;
final usage = message['usage'] as Map?;
if (usage != null) {
int n(String k) => (usage[k] as num?)?.toInt() ?? 0;
status.contextTokens = n('input_tokens') + n('cache_read_input_tokens') + n('cache_creation_input_tokens');
}
}
void _parseUserInto(
Map<String, dynamic> envelope,
String uuid,
DateTime timestamp,
bool isSidechain,
String? parentUuid,
String? parentToolUseId,
List<ConversationItem> out,
) {
final message = envelope['message'] as Map?;
if (message == null) return;
final content = message['content'];
// Harness-injected user messages (skill loads, slash-command expansions,
// system reminders) — `isSynthetic` on the stream-json wire, `isMeta` in the
// transcript. The view de-emphasises these (D-78).
final injected = envelope['isSynthetic'] == true || envelope['isMeta'] == true;
if (content is String) {
if (content.isNotEmpty) {
out.add(
UserMessage(
uuid: uuid,
timestamp: timestamp,
isSidechain: isSidechain,
parentUuid: parentUuid,
parentToolUseId: parentToolUseId,
text: content,
injected: injected,
),
);
}
return;
}
if (content is! List) return;
final textParts = <String>[];
for (final item in content) {
if (item is! Map) continue;
switch (item['type'] as String?) {
case 'text':
final text = item['text'] as String? ?? '';
if (text.isNotEmpty) textParts.add(text);
case 'tool_result':
final rawContent = item['content'];
out.add(
ToolResultMessage(
uuid: uuid,
timestamp: timestamp,
isSidechain: isSidechain,
parentUuid: parentUuid,
parentToolUseId: parentToolUseId,
toolUseId: item['tool_use_id'] as String? ?? '',
content: rawContent is String ? rawContent : jsonEncode(rawContent),
isError: item['is_error'] as bool? ?? false,
),
);
default:
break;
}
}
if (textParts.isNotEmpty) {
out.add(UserMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, text: textParts.join('\n'), injected: injected));
}
}
/// The model marker on CLI-local output (forwarded local-command responses).
const String kSyntheticModel = '<synthetic>';
void _parseAssistantInto(
Map<String, dynamic> envelope,
String uuid,
DateTime timestamp,
bool isSidechain,
String? parentUuid,
String? parentToolUseId,
List<ConversationItem> out,
) {
final message = envelope['message'] as Map?;
if (message == null) return;
final content = message['content'];
if (content is! List) return;
final synthetic = (message['model'] as String?) == kSyntheticModel;
for (final item in content) {
if (item is! Map) continue;
switch (item['type'] as String?) {
case 'text':
final text = item['text'] as String? ?? '';
if (text.isNotEmpty) {
out.add(
AssistantTextMessage(
uuid: uuid,
timestamp: timestamp,
isSidechain: isSidechain,
parentUuid: parentUuid,
parentToolUseId: parentToolUseId,
text: text,
synthetic: synthetic,
),
);
}
case 'thinking':
final thinking = item['thinking'] as String? ?? '';
if (thinking.isNotEmpty) {
out.add(
AssistantThinkingMessage(
uuid: uuid,
timestamp: timestamp,
isSidechain: isSidechain,
parentUuid: parentUuid,
parentToolUseId: parentToolUseId,
thinking: thinking,
),
);
}
case 'tool_use':
final rawInput = item['input'];
out.add(
AssistantToolUse(
uuid: uuid,
timestamp: timestamp,
isSidechain: isSidechain,
parentUuid: parentUuid,
parentToolUseId: parentToolUseId,
toolUseId: item['id'] as String? ?? '',
name: item['name'] as String? ?? '',
input: rawInput is Map ? rawInput.cast<String, dynamic>() : <String, dynamic>{},
),
);
default:
break;
}
}
}