From c5e58733a2b1b3aeef85455e9ca34e144c0cdf44 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 30 May 2026 13:44:19 +0200 Subject: [PATCH] render typed tool cards and live session status from stream-json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .pql/changelog/ticket_history/2026-05.sql | 2 + .pql/changelog/tickets/2026-05.sql | 10 + CHANGELOG.md | 7 + lib/builtin/claude/src/claude_status.dart | 19 +- .../claude/src/conversation_controller.dart | 44 +++- lib/builtin/claude/src/conversation_view.dart | 91 +++++++-- lib/builtin/claude/src/prompt_card.dart | 188 +++++++++++------- .../claude/src/stream_json_session.dart | 116 ++++++++++- lib/builtin/claude/src/transcript_reader.dart | 52 ++++- test/builtin/claude/claude_status_test.dart | 36 ++++ .../claude/conversation_view_test.dart | 99 ++++++++- test/builtin/claude/prompt_card_test.dart | 39 ++++ .../claude/stream_json_session_test.dart | 114 +++++++++++ .../claude/transcript_reader_test.dart | 22 +- 14 files changed, 722 insertions(+), 117 deletions(-) diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index f686b9bb..fe0b03b4 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -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; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index d18933c6..3f4f7217 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -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 ; resume an existing one: --resume (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); diff --git a/CHANGELOG.md b/CHANGELOG.md index f44271ad..ed9395d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/builtin/claude/src/claude_status.dart b/lib/builtin/claude/src/claude_status.dart index 97649399..c5b01689 100644 --- a/lib/builtin/claude/src/claude_status.dart +++ b/lib/builtin/claude/src/claude_status.dart @@ -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; diff --git a/lib/builtin/claude/src/conversation_controller.dart b/lib/builtin/claude/src/conversation_controller.dart index 75bb3691..2bb35641 100644 --- a/lib/builtin/claude/src/conversation_controller.dart +++ b/lib/builtin/claude/src/conversation_controller.dart @@ -47,16 +47,58 @@ class ConversationController extends ChangeNotifier { final Future Function()? _onDispose; late final StreamSubscription _sub; final List _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 _uuidIndex = {}; + Timer? _notifyTimer; bool _disposed = false; /// Items in arrival (transcript) order. List 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 get toolUseById => Map.unmodifiable(_toolUseById); + final Map _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-` 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 diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index cdac2191..b01ade8b 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -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 { 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 {}}); + const _ConversationTurn({ + required this.item, + required this.tokens, + this.toolUseOutcomes = const {}, + this.toolUseById = const {}, + }); final ConversationItem item; final SurfaceTokens tokens; final Map toolUseOutcomes; + /// Index from toolUseId → AssistantToolUse, for result-card pairing (T-168). + final Map 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, + ), ); } diff --git a/lib/builtin/claude/src/prompt_card.dart b/lib/builtin/claude/src/prompt_card.dart index 197842a1..52f1366f 100644 --- a/lib/builtin/claude/src/prompt_card.dart +++ b/lib/builtin/claude/src/prompt_card.dart @@ -160,82 +160,9 @@ class _ToolPromptCardState extends State { ); } - /// 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 input) { - final cmd = (input['command'] as String? ?? '').trimRight(); - final notes = [ - 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 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 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 { } } +// -- 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 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 input) { + final cmd = (input['command'] as String? ?? '').trimRight(); + final notes = [ + 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 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 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 input) { + final path = input['file_path'] ?? input['path'] ?? input['pattern'] ?? ''; + final extra = []; + 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, diff --git a/lib/builtin/claude/src/stream_json_session.dart b/lib/builtin/claude/src/stream_json_session.dart index a8d01507..122a6c95 100644 --- a/lib/builtin/claude/src/stream_json_session.dart +++ b/lib/builtin/claude/src/stream_json_session.dart @@ -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 = {}; + /// Prompts awaiting a [resolvePrompt] decision, in arrival order. The head /// is the one currently shown in the composer zone. final _queue = []; @@ -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-` 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.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-` 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 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..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(); } diff --git a/lib/builtin/claude/src/transcript_reader.dart b/lib/builtin/claude/src/transcript_reader.dart index a0058330..e8192457 100644 --- a/lib/builtin/claude/src/transcript_reader.dart +++ b/lib/builtin/claude/src/transcript_reader.dart @@ -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..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, + ); } // --------------------------------------------------------------------------- diff --git a/test/builtin/claude/claude_status_test.dart b/test/builtin/claude/claude_status_test.dart index 70db86e3..1ab77466 100644 --- a/test/builtin/claude/claude_status_test.dart +++ b/test/builtin/claude/claude_status_test.dart @@ -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')); + }); }); } diff --git a/test/builtin/claude/conversation_view_test.dart b/test/builtin/claude/conversation_view_test.dart index 20caddeb..fe865776 100644 --- a/test/builtin/claude/conversation_view_test.dart +++ b/test/builtin/claude/conversation_view_test.dart @@ -79,6 +79,35 @@ void main() { expect(disposed, isTrue); await ctrl.close(); }); + + test('toolUseById indexes AssistantToolUse by toolUseId (T-168)', () async { + final ctrl = StreamController(); + final c = ConversationController(stream: ctrl.stream); + addTearDown(c.dispose); + + ctrl.add(_tool('Bash', {'command': 'ls'})); + await Future.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(); + 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.delayed(const Duration(milliseconds: 20)); + ctrl.add(AssistantTextMessage(uuid: 'partial-m1', timestamp: _t, isSidechain: false, text: 'hello world')); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(c.items.whereType(), hasLength(1)); + expect(c.items.whereType().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(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); diff --git a/test/builtin/claude/prompt_card_test.dart b/test/builtin/claude/prompt_card_test.dart index 129ce752..d75581d5 100644 --- a/test/builtin/claude/prompt_card_test.dart +++ b/test/builtin/claude/prompt_card_test.dart @@ -357,4 +357,43 @@ void main() { await tester.pump(); expect(decision, isA()); }); + + // -- 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); + }); + }); } diff --git a/test/builtin/claude/stream_json_session_test.dart b/test/builtin/claude/stream_json_session_test.dart index bf58afc1..2bfe6718 100644 --- a/test/builtin/claude/stream_json_session_test.dart +++ b/test/builtin/claude/stream_json_session_test.dart @@ -88,6 +88,35 @@ String initEvent() => jsonEncode({ 'permissionMode': 'default', }); +String resultEvent({double? cost, Map? modelUsage}) => jsonEncode({ + 'type': 'result', + 'result': '', + 'usage': {}, + 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': { + 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? 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.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.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.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': {}})); + await Future.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.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.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-) for in-place updates', () async { + proc.emit(partialAssistantText('msg-1', 'hello so far')); + await Future.delayed(Duration.zero); + // The item emitted uses the stable partial uuid so the controller can upsert it. + expect(items.whereType(), hasLength(1)); + expect(items.whereType().first.uuid, 'partial-msg-1'); + expect(items.whereType().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.delayed(Duration.zero); + proc.emit(partialAssistantText('msg-2', 'hello world')); + await Future.delayed(Duration.zero); + // Both carry the same stable uuid so a ConversationController can upsert them. + final parts = items.whereType().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.delayed(Duration.zero); + proc.emit(jsonEncode({'type': 'result', 'result': '', 'usage': {}})); + await Future.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.delayed(Duration.zero); + expect(items.whereType(), isNotEmpty); + }); + }); + test('ignores blank and non-JSON lines', () async { proc.emit(''); proc.emit('not json'); diff --git a/test/builtin/claude/transcript_reader_test.dart b/test/builtin/claude/transcript_reader_test.dart index 0b8c9c45..e65223f4 100644 --- a/test/builtin/claude/transcript_reader_test.dart +++ b/test/builtin/claude/transcript_reader_test.dart @@ -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); }); });