render typed tool cards and live session status from stream-json

The conversation pane now exploits the structured stream instead of
dumping tool input as JSON. ConversationController indexes tool_use by id
so a tool_result pairs back to its call and renders the Edit/Write diff or
is_error failure in place; per-tool bodies (Bash command+output, Read/Grep
file/query) reuse the shared renderers factored out of the permission
card. SessionStatus gains cost + contextWindow + rate-limit, read straight
off the init/result/rate_limit_event events, so the in-pane status line
reflects live state without the config probe.

Partial-message streaming is wired behind --include-partial-messages but
its event shape is unverified against the live binary and degrades to a
no-op if it differs — see T-184.

T-168.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-30 13:44:19 +02:00
co-authored by Claude
parent f6b88f503f
commit c5e58733a2
14 changed files with 722 additions and 117 deletions
+16 -3
View File
@@ -8,17 +8,30 @@ library;
import 'package:clide/builtin/claude/src/transcript_reader.dart';
/// Build the status-bar line, e.g. `opus 4.7 · default · 21k ctx`.
/// Empty string when there's nothing to show.
/// Build the status-bar line, e.g. `opus 4.7 · default · 21k ctx · $0.12`.
/// Includes rate-limit info when active. Empty string when there's nothing to show.
String formatStatusLine(SessionStatus status) {
final parts = [
if (status.model != null) shortModelLabel(status.model!),
if (status.permissionMode != null) permissionModeLabel(status.permissionMode!),
if (status.contextTokens != null) '${formatTokenCount(status.contextTokens!)} ctx',
if (status.contextTokens != null) _contextLabel(status),
if (status.cost != null) '\$${status.cost!.toStringAsFixed(2)}',
if (status.rateLimitInfo != null) status.rateLimitInfo!,
];
return parts.join(' · ');
}
/// Context token count, optionally shown as a fraction when the window
/// size is known: `21k / 1M ctx` vs `21k ctx`.
String _contextLabel(SessionStatus status) {
final tokens = formatTokenCount(status.contextTokens!);
if (status.contextWindow != null) {
final window = formatTokenCount(status.contextWindow!);
return '$tokens / $window ctx';
}
return '$tokens ctx';
}
/// `claude-opus-4-7` → `opus 4.7`; unknown shapes pass through.
String shortModelLabel(String model) {
final s = model.startsWith('claude-') ? model.substring('claude-'.length) : model;
@@ -47,16 +47,58 @@ class ConversationController extends ChangeNotifier {
final Future<void> Function()? _onDispose;
late final StreamSubscription<ConversationItem> _sub;
final List<ConversationItem> _items = [];
/// Index from uuid → position in [_items] for the FIRST item with that
/// uuid. Used to upsert partial-message streaming updates in-place (T-168):
/// when a partial arrives, the session emits an item with the same uuid as
/// the previous partial so the controller replaces rather than appends it.
/// Only the first occurrence is indexed — full (non-partial) items that
/// share a uuid after a session resume are appended normally (uuid reuse
/// across turns is rare; correctness wins over perf there).
final Map<String, int> _uuidIndex = {};
Timer? _notifyTimer;
bool _disposed = false;
/// Items in arrival (transcript) order.
List<ConversationItem> get items => List.unmodifiable(_items);
/// Index from `tool_use_id` to the corresponding [AssistantToolUse] item,
/// built as items arrive. Used by the conversation view to render the
/// result card in the context of its tool_use (T-168).
Map<String, AssistantToolUse> get toolUseById => Map.unmodifiable(_toolUseById);
final Map<String, AssistantToolUse> _toolUseById = {};
bool get isEmpty => _items.isEmpty;
void _onItem(ConversationItem item) {
_items.add(item);
// Track AssistantToolUse items by toolUseId for result-card pairing (T-168).
if (item is AssistantToolUse) {
_toolUseById[item.toolUseId] = item;
}
// Upsert-by-uuid only for partial-message streaming items (T-168). Partial
// items are distinguished by a `partial-<message.id>` uuid prefix assigned
// by [StreamJsonSession]. Real transcript items always have distinct uuids
// (or at least should not be collapsed even when they collide, since the
// transcript records separate turns). This guard prevents test items with
// fixed uuids from accidentally replacing each other.
if (item.uuid.startsWith('partial-')) {
final existing = _uuidIndex[item.uuid];
if (existing != null && existing < _items.length) {
_items[existing] = item;
if (item is AssistantToolUse) _toolUseById[item.toolUseId] = item;
// Coalesce-notify path below handles notifications.
} else {
_uuidIndex[item.uuid] = _items.length;
_items.add(item);
}
} else {
// Normal (non-partial) item: always append. Index only if not already
// seen (so the first real occurrence wins in the upsert table — there
// should be no real collision, but be safe).
_uuidIndex.putIfAbsent(item.uuid, () => _items.length);
_items.add(item);
}
// Coalesce notifications: the reader emits a burst (the initial tail
// read), and a notify-per-item would thrash the view's rebuild +
// auto-scroll. A zero-duration Timer fires only after the microtask
+69 -22
View File
@@ -13,6 +13,7 @@ import 'dart:convert';
import 'package:clide/builtin/claude/src/conversation_card.dart';
import 'package:clide/builtin/claude/src/conversation_controller.dart';
import 'package:clide/builtin/claude/src/prompt_card.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
@@ -136,7 +137,12 @@ class _ConversationViewState extends State<ConversationView> {
controller: _scroll,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
itemCount: items.length,
itemBuilder: (context, i) => _ConversationTurn(item: items[i], tokens: tokens, toolUseOutcomes: widget.toolUseOutcomes),
itemBuilder: (context, i) => _ConversationTurn(
item: items[i],
tokens: tokens,
toolUseOutcomes: widget.toolUseOutcomes,
toolUseById: widget.controller.toolUseById,
),
),
);
return ColoredBox(
@@ -152,12 +158,20 @@ const claudeAccent = Color(0xFFD97757);
/// One conversation item, rendered by kind.
class _ConversationTurn extends StatelessWidget {
const _ConversationTurn({required this.item, required this.tokens, this.toolUseOutcomes = const <String, bool>{}});
const _ConversationTurn({
required this.item,
required this.tokens,
this.toolUseOutcomes = const <String, bool>{},
this.toolUseById = const <String, AssistantToolUse>{},
});
final ConversationItem item;
final SurfaceTokens tokens;
final Map<String, bool> toolUseOutcomes;
/// Index from toolUseId → AssistantToolUse, for result-card pairing (T-168).
final Map<String, AssistantToolUse> toolUseById;
@override
Widget build(BuildContext context) {
final i = item;
@@ -203,7 +217,6 @@ class _ConversationTurn extends StatelessWidget {
}
Widget _toolUse(AssistantToolUse t) {
final pretty = const JsonEncoder.withIndent(' ').convert(t.input);
// A resolved permission-prompted call: collapsed, green if approved / red
// if denied — a quiet record of what was permitted (D-78).
final outcome = toolUseOutcomes[t.toolUseId];
@@ -214,47 +227,81 @@ class _ConversationTurn extends StatelessWidget {
accent: color,
borderColor: color,
label: t.name,
copyText: pretty,
copyText: const JsonEncoder.withIndent(' ').convert(t.input),
collapsible: true,
collapsedByDefault: true,
collapsedSummary: _toolUseSummary(t),
body: ClideCodeBlock(source: pretty, language: 'json'),
body: toolInputBody(tokens, t.name, t.input),
);
}
// Collapse only the bulky multi-line form; a trivial one-liner just shows.
final multiline = pretty.contains('\n');
// Per-tool body rendering (T-168): Bash → command block, Edit/Write → diff,
// Read/Grep/LS → path label, others → indented JSON. Always collapsible so
// a bulky write body doesn't dominate the scroll.
final body = toolInputBody(tokens, t.name, t.input);
final summary = _toolUseSummary(t);
return ConversationCard(
variant: ConversationCardVariant.bordered,
accent: tokens.globalFocus,
label: t.name,
copyText: pretty,
collapsible: multiline,
collapsedByDefault: multiline,
collapsedSummary: multiline ? _toolUseSummary(t) : null,
body: ClideCodeBlock(source: pretty, language: 'json'),
copyText: const JsonEncoder.withIndent(' ').convert(t.input),
collapsible: true,
collapsedByDefault: true,
collapsedSummary: summary,
body: body,
);
}
Widget _toolResult(ToolResultMessage t) {
final paired = toolUseById[t.toolUseId];
final accent = t.isError ? tokens.statusError : tokens.globalTextMuted;
// A one-line result is all chrome to collapse — show it inline. Only fold
// away multi-line output, behind a summary of its first line.
final label = t.isError ? 'error' : 'result';
// Error result: render the error message prominently (T-168). If we have
// the paired tool_use, show the tool name as a sub-label so the user can
// see what failed without expanding.
if (t.isError) {
final multiline = t.content.contains('\n');
return ConversationCard(
variant: ConversationCardVariant.bordered,
accent: accent,
borderColor: tokens.statusError,
label: paired != null ? '${paired.name} · $label' : label,
copyText: t.content,
collapsible: multiline,
collapsedByDefault: false, // errors default expanded so they're visible
collapsedSummary: multiline ? _firstLine(t.content) : null,
body: ClideText(
t.content,
fontSize: clideFontMeta,
fontFamily: clideMonoFamily,
color: tokens.statusError,
),
);
}
// Success result (T-168): for tools where the output is the main event
// (Bash, Read, Grep, LS), show the output as a code block so it's readable.
// For Write/Edit, the result is usually "OK" — keep it as plain text.
final multiline = t.content.contains('\n');
final isOutputTool = paired != null && const {'Bash', 'Read', 'Grep', 'LS'}.contains(paired.name);
final resultLabel = paired != null ? '${paired.name} · $label' : label;
return ConversationCard(
variant: ConversationCardVariant.bordered,
accent: accent,
borderColor: t.isError ? tokens.statusError : tokens.panelBorder,
label: t.isError ? 'error' : 'result',
borderColor: tokens.panelBorder,
label: resultLabel,
copyText: t.content,
collapsible: multiline,
collapsedByDefault: multiline,
collapsedSummary: multiline ? _firstLine(t.content) : null,
body: ClideText(
t.content,
fontSize: clideFontMeta,
fontFamily: clideMonoFamily,
color: tokens.globalForeground,
),
body: isOutputTool
? ClideCodeBlock(source: t.content, language: 'text')
: ClideText(
t.content,
fontSize: clideFontMeta,
fontFamily: clideMonoFamily,
color: tokens.globalForeground,
),
);
}
+112 -76
View File
@@ -160,82 +160,9 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
);
}
/// Render the tool input in the shape that best fits the tool. Bash gets a
/// shell code block, file-writing tools show the path + content with syntax
/// highlighting derived from the extension, anything else falls back to the
/// indented-JSON dump.
Widget _inputBody(SurfaceTokens tokens, ToolPrompt p) {
switch (p.toolName) {
case 'Bash':
return _bashBody(tokens, p.input);
case 'Write':
return _writeBody(tokens, p.input);
case 'Edit':
case 'MultiEdit':
return _editBody(tokens, p.input);
default:
return ClideCodeBlock(source: const JsonEncoder.withIndent(' ').convert(p.input), language: 'json');
}
}
Widget _bashBody(SurfaceTokens tokens, Map<String, dynamic> input) {
final cmd = (input['command'] as String? ?? '').trimRight();
final notes = <String>[
if (input['run_in_background'] == true) 'background',
if (input['timeout'] is num) 'timeout ${input['timeout']}ms',
];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
ClideCodeBlock(source: cmd, language: 'bash'),
if (notes.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 6),
child: ClideText(notes.join(' · '), fontSize: clideFontMeta, color: tokens.globalTextMuted),
),
],
);
}
Widget _writeBody(SurfaceTokens tokens, Map<String, dynamic> input) {
final path = input['file_path'] as String? ?? '';
final content = input['content'] as String? ?? '';
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (path.isNotEmpty) _pathLine(tokens, path),
ClideCodeBlock(source: content, language: grammarForPath(path)),
],
);
}
Widget _editBody(SurfaceTokens tokens, Map<String, dynamic> input) {
final path = input['file_path'] as String? ?? '';
final oldStr = input['old_string'] as String? ?? '';
final newStr = input['new_string'] as String? ?? '';
final lang = grammarForPath(path);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (path.isNotEmpty) _pathLine(tokens, path),
ClideText('— before', fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
const SizedBox(height: 4),
ClideCodeBlock(source: oldStr, language: lang),
const SizedBox(height: 8),
ClideText('+ after', fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
const SizedBox(height: 4),
ClideCodeBlock(source: newStr, language: lang),
],
);
}
Widget _pathLine(SurfaceTokens tokens, String path) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: ClideText(path, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
);
/// Render the tool input in the shape that best fits the tool. Delegates to
/// the shared top-level helpers (also used by ConversationView — T-168).
Widget _inputBody(SurfaceTokens tokens, ToolPrompt p) => toolInputBody(tokens, p.toolName, p.input);
// -- AskUserQuestion: single = bare, multi = stepper + review --------------
@@ -416,6 +343,115 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
}
}
// -- shared tool-input rendering (used by ToolPromptCard + ConversationView) --
/// Render [input] for [toolName] in the most informative shape: Bash → shell
/// code block; Write → path + content; Edit/MultiEdit → before/after diff;
/// Read/Grep/LS → path/pattern; anything else → indented JSON.
///
/// Shared between [ToolPromptCard] (permission prompt body) and the
/// [ConversationView] tool-use card bodies (T-168).
Widget toolInputBody(SurfaceTokens tokens, String toolName, Map<String, dynamic> input) {
switch (toolName) {
case 'Bash':
return toolBashBody(tokens, input);
case 'Write':
return toolWriteBody(tokens, input);
case 'Edit':
case 'MultiEdit':
return toolEditBody(tokens, input);
case 'Read':
case 'Grep':
case 'LS':
return toolReadLikeBody(tokens, toolName, input);
default:
return ClideCodeBlock(source: const JsonEncoder.withIndent(' ').convert(input), language: 'json');
}
}
/// Bash tool body: the command as a shell code block, with optional background
/// / timeout annotations.
Widget toolBashBody(SurfaceTokens tokens, Map<String, dynamic> input) {
final cmd = (input['command'] as String? ?? '').trimRight();
final notes = <String>[
if (input['run_in_background'] == true) 'background',
if (input['timeout'] is num) 'timeout ${input['timeout']}ms',
];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
ClideCodeBlock(source: cmd, language: 'bash'),
if (notes.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 6),
child: ClideText(notes.join(' · '), fontSize: clideFontMeta, color: tokens.globalTextMuted),
),
],
);
}
/// Write tool body: the file path + content with syntax highlighting.
Widget toolWriteBody(SurfaceTokens tokens, Map<String, dynamic> input) {
final path = input['file_path'] as String? ?? '';
final content = input['content'] as String? ?? '';
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (path.isNotEmpty) toolPathLine(tokens, path),
ClideCodeBlock(source: content, language: grammarForPath(path)),
],
);
}
/// Edit / MultiEdit tool body: before/after diff view.
Widget toolEditBody(SurfaceTokens tokens, Map<String, dynamic> input) {
final path = input['file_path'] as String? ?? '';
final oldStr = input['old_string'] as String? ?? '';
final newStr = input['new_string'] as String? ?? '';
final lang = grammarForPath(path);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (path.isNotEmpty) toolPathLine(tokens, path),
ClideText('— before', fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
const SizedBox(height: 4),
ClideCodeBlock(source: oldStr, language: lang),
const SizedBox(height: 8),
ClideText('+ after', fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
const SizedBox(height: 4),
ClideCodeBlock(source: newStr, language: lang),
],
);
}
/// Read / Grep / LS body: show the file path or pattern as a one-liner label
/// so the card stays compact. These tools produce the interesting output in the
/// result card rather than their input.
Widget toolReadLikeBody(SurfaceTokens tokens, String toolName, Map<String, dynamic> input) {
final path = input['file_path'] ?? input['path'] ?? input['pattern'] ?? '';
final extra = <String>[];
if (toolName == 'Grep') {
final pat = input['pattern'] as String?;
if (pat != null && pat.isNotEmpty) extra.add('"$pat"');
}
final label = [path.toString(), ...extra].where((s) => s.isNotEmpty).join(' ');
return ClideText(
label.isNotEmpty ? label : toolName,
fontSize: clideFontMeta,
fontFamily: clideMonoFamily,
color: tokens.globalForeground,
);
}
/// A muted file path line, shared across tool bodies.
Widget toolPathLine(SurfaceTokens tokens, String path) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: ClideText(path, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
);
// -- shared note / free-text field -------------------------------------------
/// A no-Material single-ish-line text field (D-7) with a muted placeholder,
+112 -4
View File
@@ -58,6 +58,9 @@ class ClaudeStreamJsonProcess implements StreamJsonProcess {
// auto-denies anything needing approval (D-78).
'--permission-prompt-tool',
'stdio',
// Emit partial assistant messages as they stream in so the view
// can update in real time (T-168).
'--include-partial-messages',
...sessionArgs,
],
workingDirectory: cwd,
@@ -204,6 +207,19 @@ class StreamJsonSession {
SessionStatus _status = const SessionStatus();
int _localSeq = 0;
/// Partial-message accumulation keyed by `message.id` (T-168).
///
/// When `--include-partial-messages` is active, the claude process emits
/// incremental `assistant` events that share the same `message.id`. Emitting
/// each partial as a new [ConversationItem] would produce duplicates. Instead
/// we accumulate the latest content per message id here and emit only a
/// [_PartialUpdate] signal so the controller can upsert rather than append.
///
/// A null value means the message has been finalised (a non-partial event
/// with the same id arrived) — subsequent partial events for that id are
/// ignored (shouldn't happen, but guard against it).
final _partialIds = <String>{};
/// Prompts awaiting a [resolvePrompt] decision, in arrival order. The head
/// is the one currently shown in the composer zone.
final _queue = <ToolPrompt>[];
@@ -286,8 +302,55 @@ class StreamJsonSession {
_onControlRequest(ev);
return;
}
// A `result` ends the turn — clear the busy/interruptible state.
if (ev['type'] == 'result') _setBusy(false);
// A `result` ends the turn — clear the busy/interruptible state and clear
// any partial-message tracking so the next turn is fresh.
if (ev['type'] == 'result') {
_setBusy(false);
_partialIds.clear();
}
// Partial-message streaming (T-168). UNVERIFIED WIRE SHAPE: this assumes
// `--include-partial-messages` emits incremental `assistant` events with
// `partial: true` sharing one `message.id`. That shape has NOT been
// confirmed against the live binary — the 2.1.150 spike only documents
// non-partial assistant events (one per content block, no `partial` flag),
// and the flag's help says it "only works with --print" (we run the
// interactive control protocol). If the real shape differs (e.g. a
// `stream_event` delta envelope), these lines fall through to the normal
// parse below and are ignored — streaming is inert but nothing breaks.
// T-184 validates this against a live capture and fixes or removes it.
// When matched, we override the uuid with `partial-<message.id>` so the
// controller upserts the item in place rather than appending each tick.
final isPartial = ev['partial'] == true;
if (isPartial && ev['type'] == 'assistant') {
final message = ev['message'];
final msgId = message is Map ? message['id'] as String? : null;
if (msgId != null) {
_partialIds.add(msgId);
// Use a stable uuid derived from the message id so the controller
// can upsert this item in place on every partial update.
final stableUuid = 'partial-$msgId';
final overridden = Map<String, dynamic>.from(ev);
overridden['uuid'] = stableUuid;
final parsed = parseTranscriptChunk(jsonEncode(overridden));
for (final item in parsed.items) {
_items.add(item);
}
_mergeStatus(parsed.status.merge(_statusFromEvent(ev)));
return;
}
}
// For a final (non-partial) assistant event whose message.id was seen as
// a partial, remove the partial-id from tracking and let the normal path
// emit the final item (it will append at a new position since its uuid
// differs from the `partial-<id>` placeholder).
if (!isPartial && ev['type'] == 'assistant') {
final message = ev['message'];
final msgId = message is Map ? message['id'] as String? : null;
if (msgId != null) _partialIds.remove(msgId);
}
// Items + assistant model/tokens reuse the transcript parser (identical
// message.content shapes).
final parsed = parseTranscriptChunk(trimmed);
@@ -441,8 +504,53 @@ class StreamJsonSession {
}
SessionStatus _statusFromEvent(Map<String, dynamic> j) {
if (j['type'] == 'system' && j['subtype'] == 'init') {
return SessionStatus(model: j['model'] as String?, permissionMode: j['permissionMode'] as String?);
switch (j['type'] as String?) {
case 'system':
if (j['subtype'] == 'init') {
return SessionStatus(model: j['model'] as String?, permissionMode: j['permissionMode'] as String?);
}
case 'result':
// Extract cumulative cost and context-window size from the result event
// (T-168). `total_cost_usd` is the turn cost. `modelUsage.<model>.contextWindow`
// is the model's context limit in tokens (e.g. 1_000_000 for claude-opus-4-7[1m]).
final costRaw = j['total_cost_usd'];
final cost = costRaw is num ? costRaw.toDouble() : null;
int? contextWindow;
final modelUsage = j['modelUsage'];
if (modelUsage is Map) {
for (final entry in modelUsage.values) {
if (entry is Map) {
final cw = entry['contextWindow'];
if (cw is num) {
contextWindow = cw.toInt();
break; // first model entry wins
}
}
}
}
if (cost != null || contextWindow != null) {
return SessionStatus(cost: cost, contextWindow: contextWindow);
}
case 'rate_limit_event':
// Surface the rate-limit status as a compact string (T-168).
final info = j['rate_limit_info'];
if (info is Map) {
final status = info['status'] as String?;
final resetsAt = info['resetsAt'] as String?;
if (status != null) {
String label = 'rate limited';
if (resetsAt != null) {
// Show just the time portion if it's an ISO timestamp.
final t = DateTime.tryParse(resetsAt);
if (t != null) {
label = 'rate limited — resets ${t.toLocal().hour.toString().padLeft(2, '0')}:${t.toLocal().minute.toString().padLeft(2, '0')}';
} else {
label = 'rate limited — resets $resetsAt';
}
}
return SessionStatus(rateLimitInfo: label);
}
}
}
return const SessionStatus();
}
+45 -7
View File
@@ -388,10 +388,17 @@ class TranscriptReader {
}
/// Live per-session status surfaced for the status strip / sidebar
/// (T-145). All fields nullable — a chunk only carries what it saw, and
/// the reader [merge]s deltas into a running status.
/// (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});
const SessionStatus({
this.model,
this.permissionMode,
this.contextTokens,
this.cost,
this.contextWindow,
this.rateLimitInfo,
});
/// Assistant `message.model`, e.g. `claude-opus-4-7`.
final String? model;
@@ -404,21 +411,42 @@ class SessionStatus {
/// the transcript doesn't carry the model's context limit.
final int? contextTokens;
bool get isEmpty => model == null && permissionMode == null && contextTokens == null;
/// 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;
bool get isEmpty => model == null && permissionMode == null && contextTokens == null && cost == null && contextWindow == null && rateLimitInfo == 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,
);
@override
bool operator ==(Object other) =>
other is SessionStatus && other.model == model && other.permissionMode == permissionMode && other.contextTokens == contextTokens;
other is SessionStatus &&
other.model == model &&
other.permissionMode == permissionMode &&
other.contextTokens == contextTokens &&
other.cost == cost &&
other.contextWindow == contextWindow &&
other.rateLimitInfo == rateLimitInfo;
@override
int get hashCode => Object.hash(model, permissionMode, contextTokens);
int get hashCode => Object.hash(model, permissionMode, contextTokens, cost, contextWindow, rateLimitInfo);
}
/// Result of [parseTranscriptChunk]: items, version-drift warnings, and
@@ -429,7 +457,17 @@ class _StatusAcc {
String? model;
String? permissionMode;
int? contextTokens;
SessionStatus toStatus() => SessionStatus(model: model, permissionMode: permissionMode, contextTokens: contextTokens);
double? cost;
int? contextWindow;
String? rateLimitInfo;
SessionStatus toStatus() => SessionStatus(
model: model,
permissionMode: permissionMode,
contextTokens: contextTokens,
cost: cost,
contextWindow: contextWindow,
rateLimitInfo: rateLimitInfo,
);
}
// ---------------------------------------------------------------------------