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