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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user