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:
@@ -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