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
@@ -2278,3 +2278,5 @@ Refinement (2026-05-30, /whats-next): pulled into the T-167/T-168 batch and sequ
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-167', 'status', 'backlog', 'in_progress', NULL, '2026-05-30 10:48:32', '2026-05-30 10:48:32', '2026-05-30 10:48:32', NULL, '229c9cb92eb8554dbba51ca65e1eeba9', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-168', 'status', 'backlog', 'in_progress', NULL, '2026-05-30 10:48:32', '2026-05-30 10:48:32', '2026-05-30 10:48:32', NULL, '4469254d98eba45964ca51d5855e5014', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-160', 'status', 'backlog', 'in_progress', NULL, '2026-05-30 10:48:32', '2026-05-30 10:48:32', '2026-05-30 10:48:32', NULL, 'e410b6f78d0e4d96bc37f3a351d72767', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-160', 'status', 'in_progress', 'done', NULL, '2026-05-30 11:05:56', '2026-05-30 11:05:56', '2026-05-30 11:05:56', NULL, '0024103be5bf0ab0653b9dbe03c52f71', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-167', 'status', 'in_progress', 'done', NULL, '2026-05-30 11:05:56', '2026-05-30 11:05:56', '2026-05-30 11:05:56', NULL, 'fee8b67a0ad9c7c1c7ec7d9f75f42bfc', 1) ON CONFLICT(hash) DO NOTHING;
+10
View File
@@ -2816,3 +2816,13 @@ Fix direction: give the in-pane context slot a flexible/bounded width in the sta
---
Refinement (2026-05-30, /whats-next): pulled into the T-167/T-168 batch and sequenced FIRST. T-168 adds live cost / permission-mode / context / rate-limit to this same in-pane status slot; the Flexible/marquee fix here must land before T-168 lengthens the line. After this fix, T-168 can safely add fields to the slot.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-05-24 09:13:42', '2026-05-30 10:48:32', NULL, 'be801b09fd2aeb3a4bb71b782499f9a7', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-167', 'task', 'T-164', 'P1: session persistence + /clear + /resume on stream-json (--resume)', 'Move session lifecycle onto the stream-json model. New session: --session-id <uuid>; resume an existing one: --resume <id> (T-161 confirmed --session-id refuses an existing id). Re-implement the clide-owned commands on this model: /clear = new fresh session, /resume = picker then resume the picked id. Retire the tmux session lifecycle for Claude (kill/hasSession/reap, new-session -A) and the --session-id-in-use handling once the transport no longer uses tmux. Acceptance: restart resumes the primary''s session; /clear starts empty; /resume switches to a picked session; all without tmux; tests for the new/resume argv selection and the command flows. Supersedes the mechanics of T-156/T-161. Blocked by T-165. Amends D-41 (tmux persistence → --resume).', 'done', 'high', NULL, NULL, 'D-77', '2026-05-24 16:26:30', '2026-05-30 11:05:56', NULL, '8da00e6f4b27462157ed80d908b91a6b', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-160', 'bug', 'T-132', 'Status bar overflows instead of marquee-scrolling the in-pane slot', 'Observed via make run (2026-05-24): the bottom status bar shows a RenderFlex ''overflowed by 77 PIXELS'' stripe after the in-pane context line (opus 4.7 · default · 21k ctx · 10 skills); the ClideMarquee never engages.
Cause: StatusbarHost (lib/app.dart ~1143) lays items in a Row [left items..., Spacer(), right items...], each at INTRINSIC width with no Flexible. PaneContextStatusItem wraps its text in ConstrainedBox(maxWidth:360) + ClideMarquee, but because the text (~280px) is under 360 the slot takes content width, so the marquee''s viewport == its content and it never scrolls. T-154 appended ''· N skills'', lengthening the line enough that git-branch + context slot + right items exceed the bar width -> the Row overflows (~77px) instead of the slot shrinking + marquee scrolling.
Fix direction: give the in-pane context slot a flexible/bounded width in the status bar (e.g. wrap it in Flexible(fit: loose), or have the bar allot it a shrinkable share) so it yields width when the bar is tight and ClideMarquee scrolls within the allotted width. Verify the interaction with the Spacer and the right-aligned items (priority>=100), and reconsider the fixed maxWidth:360. Acceptance: at narrow widths the in-pane slot marquee-scrolls and the status bar never shows an overflow stripe; widget test at a constrained width. Introduced by T-150 (marquee) + T-154 (longer line).
---
Refinement (2026-05-30, /whats-next): pulled into the T-167/T-168 batch and sequenced FIRST. T-168 adds live cost / permission-mode / context / rate-limit to this same in-pane status slot; the Flexible/marquee fix here must land before T-168 lengthens the line. After this fix, T-168 can safely add fields to the slot.', 'done', 'medium', NULL, NULL, NULL, '2026-05-24 09:13:42', '2026-05-30 11:05:56', NULL, 'c9c1801d8523ed0af1aff7da39393a0c', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-184', 'task', 'T-164', 'Validate partial-message wire shape from --include-partial-messages against a live capture', 'T-168 follow-up. T-168 wired --include-partial-messages into the interactive stream-json session (stream_json_session.dart) and added a partial-streaming handler that ASSUMES claude emits incremental assistant events carrying partial:true sharing one message.id. That shape is UNVERIFIED: the 2.1.150 spike (docs/spikes/cc-stream-json-control-protocol-2.1.150.md) only documents non-partial assistant events (one per content block, no partial flag), and the flag''s --help says it ''only works with --print'' while clide runs the interactive control protocol (--input-format/--output-format stream-json, not --print). The handler degrades gracefully (unmatched shapes fall through to parseTranscriptChunk and are ignored — token streaming is inert but nothing breaks), and the T-168 fixtures are synthetic so they pass regardless of reality. Do: capture a real claude turn over the interactive stream-json transport with --include-partial-messages and record the actual partial event envelope (likely a stream_event content_block_delta, not assistant+partial:true). Then either (a) fix the handler in stream_json_session.dart::_onLine to match the real shape and add a fixture from the live capture, or (b) if the flag is a true no-op in the interactive (non-print) mode, drop --include-partial-messages from the argv and the dead partial-handler branch, and pin the finding in the spike doc. Either way, update the UNVERIFIED-WIRE-SHAPE comment in stream_json_session.dart and the spike. Acceptance: the partial path is validated against a live capture, the handler matches the real shape (or is removed), a fixture derived from the capture covers it, and the spike + code comment reflect the confirmed shape.', 'backlog', 'medium', NULL, NULL, 'D-77', '2026-05-30 11:42:53', '2026-05-30 11:42:53', NULL, '38dccbc403b8ac945bd2ad01d9ac1640', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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);
+7
View File
@@ -30,6 +30,13 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Changed
- Claude conversation tool cards are now typed — Edit/Write render a diff,
Bash shows the command and its output, Read/Grep show the file/query, and
each tool result pairs back to its call to render the diff or error in
place instead of an indented JSON dump (T-168).
- Claude status line reflects live session state from stream-json events —
model, permission mode, context size, and turn cost come straight off the
init/result events rather than a separate config probe (T-168).
- Claude session persistence now rides on `claude --resume` instead of tmux
(T-167, amends D-41). A restart resumes the primary session, `/clear`
starts a fresh one, and `/resume` reopens a picked session — all without
+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,
);
}
// ---------------------------------------------------------------------------
@@ -44,5 +44,41 @@ void main() {
expect(formatStatusLine(const SessionStatus(model: 'claude-sonnet-4-6')), 'sonnet 4.6');
expect(formatStatusLine(const SessionStatus()), '');
});
test('includes cost when present (T-168)', () {
const s = SessionStatus(model: 'claude-opus-4-7', cost: 0.123);
expect(formatStatusLine(s), contains('\$0.12'));
});
test('shows ctx as fraction when contextWindow is known (T-168)', () {
const s = SessionStatus(contextTokens: 21000, contextWindow: 1000000);
expect(formatStatusLine(s), contains('21k / 1.0M ctx'));
});
test('shows plain ctx count when contextWindow is absent', () {
const s = SessionStatus(contextTokens: 21000);
expect(formatStatusLine(s), contains('21k ctx'));
expect(formatStatusLine(s), isNot(contains('/')));
});
test('includes rateLimitInfo when present (T-168)', () {
const s = SessionStatus(rateLimitInfo: 'rate limited — resets 14:32');
expect(formatStatusLine(s), 'rate limited — resets 14:32');
});
test('full status line with all fields (T-168)', () {
const s = SessionStatus(
model: 'claude-opus-4-7',
permissionMode: 'default',
contextTokens: 21000,
contextWindow: 1000000,
cost: 0.05,
);
final line = formatStatusLine(s);
expect(line, contains('opus 4.7'));
expect(line, contains('default'));
expect(line, contains('21k / 1.0M ctx'));
expect(line, contains('\$0.05'));
});
});
}
@@ -79,6 +79,35 @@ void main() {
expect(disposed, isTrue);
await ctrl.close();
});
test('toolUseById indexes AssistantToolUse by toolUseId (T-168)', () async {
final ctrl = StreamController<ConversationItem>();
final c = ConversationController(stream: ctrl.stream);
addTearDown(c.dispose);
ctrl.add(_tool('Bash', {'command': 'ls'}));
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(c.toolUseById['x1'], isNotNull);
expect(c.toolUseById['x1']!.name, 'Bash');
await ctrl.close();
});
test('partial-uuid items upsert in the controller (T-168)', () async {
final ctrl = StreamController<ConversationItem>();
final c = ConversationController(stream: ctrl.stream);
addTearDown(c.dispose);
// Two partials with the same `partial-` uuid — second replaces first.
ctrl.add(AssistantTextMessage(uuid: 'partial-m1', timestamp: _t, isSidechain: false, text: 'hello'));
await Future<void>.delayed(const Duration(milliseconds: 20));
ctrl.add(AssistantTextMessage(uuid: 'partial-m1', timestamp: _t, isSidechain: false, text: 'hello world'));
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(c.items.whereType<AssistantTextMessage>(), hasLength(1));
expect(c.items.whereType<AssistantTextMessage>().first.text, 'hello world');
await ctrl.close();
});
});
group('ConversationController.fromBus', () {
@@ -167,8 +196,9 @@ void main() {
expect(find.text('claude'), findsOneWidget);
expect(find.text('thinking'), findsOneWidget);
expect(find.text('Bash'), findsOneWidget);
expect(find.text('result'), findsOneWidget);
expect(find.text('error'), findsOneWidget);
// Result labels now include the paired tool name (T-168).
expect(find.text('Bash · result'), findsOneWidget);
expect(find.text('Bash · error'), findsOneWidget);
});
testWidgets('AskUserQuestion tool-use and its result are hidden (it shows as a prompt)', (tester) async {
@@ -194,7 +224,8 @@ void main() {
);
expect(find.text('Write'), findsNothing); // payload hidden
expect(find.text('done'), findsOneWidget); // result kept
expect(find.text('result'), findsOneWidget);
// Label now includes paired tool name (T-168).
expect(find.text('Write · result'), findsOneWidget);
});
testWidgets('a resolved permission tool-use is shown collapsed, not hidden', (tester) async {
@@ -220,6 +251,68 @@ void main() {
expect(find.text('you'), findsOneWidget); // the real one
});
testWidgets('tool-use body: Bash shows the command in the collapsed summary (T-168)', (tester) async {
await pumpWith(tester, [
_tool('Bash', {'command': 'ls -la'})
]);
// Card starts collapsed — the command appears as the collapsed summary.
expect(find.text('ls -la'), findsOneWidget);
// Expand to verify the body is a bash code block.
await tester.tap(find.byType(ClideIcon));
await tester.pump();
final blocks = tester.widgetList<ClideCodeBlock>(find.byType(ClideCodeBlock)).toList();
expect(blocks.any((b) => b.language == 'bash' && b.source.contains('ls -la')), isTrue);
});
testWidgets('tool-use body: Read/Grep/LS shows a compact path label (T-168)', (tester) async {
await pumpWith(tester, [
_tool('Read', {'file_path': '/foo/bar.dart'})
]);
// The path label appears (collapsed summary or body).
expect(find.text('/foo/bar.dart'), findsOneWidget);
});
testWidgets('result label includes paired tool name (T-168)', (tester) async {
await pumpWith(tester, [
_tool('Read', {'file_path': '/x'}),
_result('file content'),
]);
expect(find.text('Read · result'), findsOneWidget);
});
testWidgets('error result label includes paired tool name (T-168)', (tester) async {
await pumpWith(tester, [
_tool('Bash', {'command': 'cat nonexistent'}),
_result('No such file', isError: true),
]);
expect(find.text('Bash · error'), findsOneWidget);
});
testWidgets('result without a paired tool_use uses plain "result" label (T-168)', (tester) async {
// Orphan result (no matching tool_use in the controller).
await pumpWith(tester, [
ToolResultMessage(
uuid: 'r-orphan',
timestamp: _t,
isSidechain: false,
toolUseId: 'unknown-id',
content: 'ok',
isError: false,
),
]);
expect(find.text('result'), findsOneWidget);
});
testWidgets('error result defaults expanded so it is visible (T-168)', (tester) async {
// An error result should show its content without requiring an expand tap.
await pumpWith(tester, [
_tool('Bash', {'command': 'bad'}),
_result('permission denied', isError: true),
]);
// Error content visible without expand.
expect(find.text('permission denied'), findsOneWidget);
});
testWidgets('a one-line tool result renders inline (no collapse caret)', (tester) async {
await pumpWith(tester, [_result('hello-from-spike')]);
expect(find.text('hello-from-spike'), findsOneWidget);
+39
View File
@@ -357,4 +357,43 @@ void main() {
await tester.pump();
expect(decision, isA<DenyTool>());
});
// -- shared tool-input rendering helpers (T-168) ----------------------------
group('permission card: Edit shows before/after diff via shared helper', () {
testWidgets('Edit card has two code blocks (before/after)', (tester) async {
const prompt = ToolPrompt(
promptId: 'req-e',
toolName: 'Edit',
displayName: 'Edit',
input: {
'file_path': '/tmp/foo.dart',
'old_string': 'void main() {}',
'new_string': 'void main() => run();',
},
);
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, __) {})));
await tester.pump();
// Two code blocks: before + after.
expect(find.byType(ClideCodeBlock), findsNWidgets(2));
expect(find.text('— before'), findsOneWidget);
expect(find.text('+ after'), findsOneWidget);
});
});
group('permission card: Read/Grep show compact path via shared helper', () {
testWidgets('Read shows the file path label', (tester) async {
const prompt = ToolPrompt(
promptId: 'req-r',
toolName: 'Read',
displayName: 'Read',
input: {'file_path': '/docs/readme.md'},
);
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, __) {})));
await tester.pump();
expect(find.text('/docs/readme.md'), findsOneWidget);
// No code blocks — just a text label for Read.
expect(find.byType(ClideCodeBlock), findsNothing);
});
});
}
@@ -88,6 +88,35 @@ String initEvent() => jsonEncode({
'permissionMode': 'default',
});
String resultEvent({double? cost, Map<String, dynamic>? modelUsage}) => jsonEncode({
'type': 'result',
'result': '',
'usage': <String, dynamic>{},
if (cost != null) 'total_cost_usd': cost,
if (modelUsage != null) 'modelUsage': modelUsage,
});
String rateLimitEvent({String? status, String? resetsAt}) => jsonEncode({
'type': 'rate_limit_event',
'rate_limit_info': <String, dynamic>{
if (status != null) 'status': status,
if (resetsAt != null) 'resetsAt': resetsAt,
},
});
String partialAssistantText(String messageId, String text) => jsonEncode({
'type': 'assistant',
'partial': true,
'uuid': 'partial-uuid',
'message': {
'id': messageId,
'role': 'assistant',
'content': [
{'type': 'text', 'text': text},
],
},
});
String canUseTool(String rid, {String tool = 'Write', Map<String, dynamic>? input}) => jsonEncode({
'type': 'control_request',
'request_id': rid,
@@ -151,6 +180,91 @@ void main() {
expect(statuses, hasLength(1));
});
group('live cost/context from result events (T-168)', () {
test('result event with total_cost_usd populates cost field', () async {
proc.emit(resultEvent(cost: 0.042));
await Future<void>.delayed(Duration.zero);
expect(statuses.last.cost, closeTo(0.042, 1e-9));
});
test('result event with modelUsage populates contextWindow', () async {
proc.emit(resultEvent(
cost: 0.01,
modelUsage: {
'claude-opus-4-7': {'contextWindow': 1000000, 'maxOutputTokens': 8192},
},
));
await Future<void>.delayed(Duration.zero);
expect(statuses.last.contextWindow, 1000000);
});
test('result event does not clear existing model/permissionMode fields', () async {
proc.emit(initEvent());
proc.emit(assistantText('hi'));
proc.emit(resultEvent(cost: 0.05));
await Future<void>.delayed(Duration.zero);
expect(statuses.last.model, 'claude-opus-4-7');
expect(statuses.last.permissionMode, 'default');
expect(statuses.last.cost, closeTo(0.05, 1e-9));
});
test('result event without cost or modelUsage emits nothing', () async {
final before = statuses.length;
proc.emit(jsonEncode({'type': 'result', 'result': '', 'usage': <String, dynamic>{}}));
await Future<void>.delayed(Duration.zero);
expect(statuses.length, before); // no change → no emit
});
});
group('rate_limit_event status (T-168)', () {
test('rate_limit_event with status populates rateLimitInfo', () async {
proc.emit(rateLimitEvent(status: 'rate_limited'));
await Future<void>.delayed(Duration.zero);
expect(statuses.last.rateLimitInfo, contains('rate limited'));
});
test('rate_limit_event with an ISO resetsAt includes the time', () async {
// 2026-05-30T14:32:00Z → shows 14:32 (UTC, local may differ but contains digits)
proc.emit(rateLimitEvent(status: 'rate_limited', resetsAt: '2026-05-30T14:32:00Z'));
await Future<void>.delayed(Duration.zero);
expect(statuses.last.rateLimitInfo, contains('rate limited'));
expect(statuses.last.rateLimitInfo, contains('resets'));
});
});
group('partial-message streaming (T-168)', () {
test('a partial assistant event uses a stable uuid (partial-<msgId>) for in-place updates', () async {
proc.emit(partialAssistantText('msg-1', 'hello so far'));
await Future<void>.delayed(Duration.zero);
// The item emitted uses the stable partial uuid so the controller can upsert it.
expect(items.whereType<AssistantTextMessage>(), hasLength(1));
expect(items.whereType<AssistantTextMessage>().first.uuid, 'partial-msg-1');
expect(items.whereType<AssistantTextMessage>().first.text, 'hello so far');
});
test('two partial events for the same message.id both carry the stable uuid', () async {
proc.emit(partialAssistantText('msg-2', 'hello'));
await Future<void>.delayed(Duration.zero);
proc.emit(partialAssistantText('msg-2', 'hello world'));
await Future<void>.delayed(Duration.zero);
// Both carry the same stable uuid so a ConversationController can upsert them.
final parts = items.whereType<AssistantTextMessage>().toList();
expect(parts.every((m) => m.uuid == 'partial-msg-2'), isTrue);
});
test('partial tracking is cleared after a result event', () async {
proc.emit(partialAssistantText('msg-3', 'streaming'));
await Future<void>.delayed(Duration.zero);
proc.emit(jsonEncode({'type': 'result', 'result': '', 'usage': <String, dynamic>{}}));
await Future<void>.delayed(Duration.zero);
// Clearing partial tracking is internal state — observable only via the
// session not crashing and accepting a new partial for the same id.
proc.emit(partialAssistantText('msg-3', 'new turn'));
await Future<void>.delayed(Duration.zero);
expect(items.whereType<AssistantTextMessage>(), isNotEmpty);
});
});
test('ignores blank and non-JSON lines', () async {
proc.emit('');
proc.emit('not json');
@@ -580,9 +580,29 @@ void main() {
expect(m.contextTokens, 10);
});
test('equality compares all fields', () {
test('merge overlays cost, contextWindow, and rateLimitInfo (T-168)', () {
const a = SessionStatus(model: 'm1', cost: 0.01);
const b = SessionStatus(contextWindow: 200000, rateLimitInfo: 'rate limited');
final m = a.merge(b);
expect(m.model, 'm1');
expect(m.cost, 0.01);
expect(m.contextWindow, 200000);
expect(m.rateLimitInfo, 'rate limited');
});
test('equality compares all fields including cost/contextWindow/rateLimitInfo (T-168)', () {
expect(const SessionStatus(model: 'x'), const SessionStatus(model: 'x'));
expect(const SessionStatus(model: 'x'), isNot(const SessionStatus(model: 'y')));
expect(const SessionStatus(cost: 0.1), const SessionStatus(cost: 0.1));
expect(const SessionStatus(cost: 0.1), isNot(const SessionStatus(cost: 0.2)));
expect(const SessionStatus(contextWindow: 1000000), const SessionStatus(contextWindow: 1000000));
expect(const SessionStatus(rateLimitInfo: 'x'), isNot(const SessionStatus()));
});
test('isEmpty returns false when any new field is set (T-168)', () {
expect(const SessionStatus(cost: 0.0).isEmpty, isFalse);
expect(const SessionStatus(contextWindow: 0).isEmpty, isFalse);
expect(const SessionStatus(rateLimitInfo: 'rate limited').isEmpty, isFalse);
});
});