Skip to content

Commit cd77593

Browse files
feat: rich tool detail views for read/write/bash/grep (#2)
* feat: add rich detail views for read, write, bash, grep tools Replace the raw Dart toString() / key-value args dump with purpose-built detail screens for each named tool: - read: file path in app bar, Content section with file text - write: file path in app bar, Content written + optional Result - bash: Command block + Output section (error-coloured on failure) - grep: Search params (pattern/glob/path) + Results section Fallback _DefaultDetail and generic _pretty() now use JsonEncoder.withIndent(' ') instead of Map.toString(), so unknown/future tools also render as valid indented JSON rather than Dart-syntax maps. Adds _ToolSection, _MonoText, _ParamRow shared helper widgets to keep the per-tool detail views concise. Tests: widget tests for each new detail view (read, write, bash, grep). Co-authored-by: Milan Le <leduckhc@users.noreply.github.com> * style: apply dart format to tool renderer files CI lint-and-analyze runs 'dart format --set-exit-if-changed'; the new detail views used manual line wrapping that dart format rewrites. Purely cosmetic — no behaviour change. Co-authored-by: Milan Le <leduckhc@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Milan Le <leduckhc@users.noreply.github.com>
1 parent 75afd4b commit cd77593

3 files changed

Lines changed: 337 additions & 2 deletions

File tree

app/lib/ui/session/tool_call_detail_screen.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'dart:convert';
2+
13
import 'package:flutter/material.dart';
24
import 'package:flutter_riverpod/flutter_riverpod.dart';
35
import 'package:go_router/go_router.dart';
@@ -72,7 +74,7 @@ class ToolCallDetailScreen extends ConsumerWidget {
7274
}
7375

7476
static String _pretty(Map<String, dynamic> m) =>
75-
m.entries.map((e) => '${e.key}: ${e.value}').join('\n');
77+
const JsonEncoder.withIndent(' ').convert(m);
7678
}
7779

7880
class _Section extends StatelessWidget {

app/lib/ui/session/tool_renderers.dart

Lines changed: 212 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
/// transcript; tapping it opens [detail] full-screen.
1010
library;
1111

12+
import 'dart:convert';
13+
1214
import 'package:flutter/material.dart';
1315

1416
import '../../store/models.dart';
@@ -124,7 +126,7 @@ class _DefaultDetail extends StatelessWidget {
124126
),
125127
const SizedBox(height: 4),
126128
SelectableText(
127-
item.args.toString(),
129+
const JsonEncoder.withIndent(' ').convert(item.args),
128130
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
129131
),
130132
const SizedBox(height: 16),
@@ -158,6 +160,95 @@ class _DefaultDetail extends StatelessWidget {
158160
// Built-in renderers
159161
// ---------------------------------------------------------------------------
160162

163+
// ---------------------------------------------------------------------------
164+
// Shared detail-view helpers
165+
// ---------------------------------------------------------------------------
166+
167+
/// Titled section used in tool detail pages.
168+
class _ToolSection extends StatelessWidget {
169+
const _ToolSection({required this.title, required this.child});
170+
final String title;
171+
final Widget child;
172+
173+
@override
174+
Widget build(BuildContext context) {
175+
return Padding(
176+
padding: const EdgeInsets.only(bottom: 16),
177+
child: Column(
178+
crossAxisAlignment: CrossAxisAlignment.start,
179+
children: [
180+
Text(title, style: Theme.of(context).textTheme.titleSmall),
181+
const SizedBox(height: 6),
182+
Container(
183+
width: double.infinity,
184+
padding: const EdgeInsets.all(12),
185+
decoration: BoxDecoration(
186+
color: Theme.of(context).colorScheme.surfaceContainer,
187+
borderRadius: BorderRadius.circular(10),
188+
),
189+
child: child,
190+
),
191+
],
192+
),
193+
);
194+
}
195+
}
196+
197+
/// Selectable monospace text — used for file content, command output, etc.
198+
class _MonoText extends StatelessWidget {
199+
const _MonoText(this.text, {this.error = false});
200+
final String text;
201+
final bool error;
202+
203+
@override
204+
Widget build(BuildContext context) => SelectableText(
205+
text,
206+
style: TextStyle(
207+
fontFamily: 'monospace',
208+
fontSize: 12.5,
209+
color: error ? Theme.of(context).colorScheme.error : null,
210+
),
211+
);
212+
}
213+
214+
/// Small label + value row, used to summarise tool parameters.
215+
class _ParamRow extends StatelessWidget {
216+
const _ParamRow(this.label, this.value);
217+
final String label;
218+
final String value;
219+
220+
@override
221+
Widget build(BuildContext context) {
222+
return Padding(
223+
padding: const EdgeInsets.only(bottom: 4),
224+
child: Row(
225+
crossAxisAlignment: CrossAxisAlignment.start,
226+
children: [
227+
SizedBox(
228+
width: 72,
229+
child: Text(
230+
label,
231+
style: Theme.of(context).textTheme.bodySmall?.copyWith(
232+
color: Theme.of(context).colorScheme.outline,
233+
),
234+
),
235+
),
236+
Expanded(
237+
child: SelectableText(
238+
value,
239+
style: const TextStyle(fontFamily: 'monospace', fontSize: 12.5),
240+
),
241+
),
242+
],
243+
),
244+
);
245+
}
246+
}
247+
248+
// ---------------------------------------------------------------------------
249+
// Built-in renderers
250+
// ---------------------------------------------------------------------------
251+
161252
class _ReadRenderer extends ToolRenderer {
162253
const _ReadRenderer();
163254
@override
@@ -166,6 +257,36 @@ class _ReadRenderer extends ToolRenderer {
166257
IconData get icon => Icons.menu_book_outlined;
167258
@override
168259
String? subtitle(ToolCallItem item) => item.args['path']?.toString();
260+
261+
@override
262+
Widget detail(BuildContext context, ToolCallItem item) {
263+
final path = item.args['path']?.toString() ?? '(no path)';
264+
final content = item.output ?? item.deltas.join();
265+
final offset = item.args['offset'];
266+
final limit = item.args['limit'];
267+
return Scaffold(
268+
appBar: AppBar(title: Text(path, overflow: TextOverflow.ellipsis)),
269+
body: ListView(
270+
padding: const EdgeInsets.all(12),
271+
children: [
272+
if (offset != null || limit != null)
273+
_ToolSection(
274+
title: 'Range',
275+
child: Row(
276+
children: [
277+
if (offset != null) _ParamRow('offset', '$offset'),
278+
if (limit != null) _ParamRow('limit', '$limit'),
279+
],
280+
),
281+
),
282+
_ToolSection(
283+
title: 'Content',
284+
child: _MonoText(content.isEmpty ? '(empty)' : content),
285+
),
286+
],
287+
),
288+
);
289+
}
169290
}
170291

171292
class _WriteRenderer extends ToolRenderer {
@@ -176,6 +297,28 @@ class _WriteRenderer extends ToolRenderer {
176297
IconData get icon => Icons.edit_note_outlined;
177298
@override
178299
String? subtitle(ToolCallItem item) => item.args['path']?.toString();
300+
301+
@override
302+
Widget detail(BuildContext context, ToolCallItem item) {
303+
final path = item.args['path']?.toString() ?? '(no path)';
304+
final content =
305+
item.args['content']?.toString() ?? item.args['text']?.toString() ?? '';
306+
final result = item.output ?? item.summary ?? '';
307+
return Scaffold(
308+
appBar: AppBar(title: Text(path, overflow: TextOverflow.ellipsis)),
309+
body: ListView(
310+
padding: const EdgeInsets.all(12),
311+
children: [
312+
_ToolSection(
313+
title: 'Content written',
314+
child: _MonoText(content.isEmpty ? '(empty)' : content),
315+
),
316+
if (result.isNotEmpty)
317+
_ToolSection(title: 'Result', child: Text(result)),
318+
],
319+
),
320+
);
321+
}
179322
}
180323

181324
class _EditRenderer extends ToolRenderer {
@@ -205,6 +348,35 @@ class _BashRenderer extends ToolRenderer {
205348
if (cmd == null) return null;
206349
return cmd.length > 80 ? '${cmd.substring(0, 80)}…' : cmd;
207350
}
351+
352+
@override
353+
Widget detail(BuildContext context, ToolCallItem item) {
354+
final command = item.args['command']?.toString() ?? '';
355+
final output = item.deltas.isNotEmpty
356+
? item.deltas.join()
357+
: (item.output ?? '');
358+
final failed = item.ended && (item.exitCode ?? 0) != 0;
359+
return Scaffold(
360+
appBar: AppBar(title: const Text('bash')),
361+
body: ListView(
362+
padding: const EdgeInsets.all(12),
363+
children: [
364+
if (command.isNotEmpty)
365+
_ToolSection(title: 'Command', child: _MonoText(command)),
366+
if (output.isNotEmpty)
367+
_ToolSection(
368+
title: 'Output',
369+
child: _MonoText(output, error: failed),
370+
)
371+
else if (item.ended)
372+
_ToolSection(
373+
title: 'Result',
374+
child: Text(item.summary ?? 'exit ${item.exitCode ?? 0}'),
375+
),
376+
],
377+
),
378+
);
379+
}
208380
}
209381

210382
class _GrepRenderer extends ToolRenderer {
@@ -219,6 +391,45 @@ class _GrepRenderer extends ToolRenderer {
219391
final g = item.args['glob']?.toString();
220392
return [p, if (g != null) 'glob:$g'].whereType<String>().join(' · ');
221393
}
394+
395+
@override
396+
Widget detail(BuildContext context, ToolCallItem item) {
397+
final pattern = item.args['pattern']?.toString() ?? '';
398+
final glob = item.args['glob']?.toString();
399+
final path = item.args['path']?.toString();
400+
final output = item.output ?? item.deltas.join();
401+
return Scaffold(
402+
appBar: AppBar(
403+
title: Text(
404+
pattern.isEmpty ? 'grep' : 'grep: $pattern',
405+
overflow: TextOverflow.ellipsis,
406+
),
407+
),
408+
body: ListView(
409+
padding: const EdgeInsets.all(12),
410+
children: [
411+
_ToolSection(
412+
title: 'Search',
413+
child: Column(
414+
crossAxisAlignment: CrossAxisAlignment.start,
415+
children: [
416+
if (pattern.isNotEmpty) _ParamRow('pattern', pattern),
417+
if (glob != null) _ParamRow('glob', glob),
418+
if (path != null) _ParamRow('path', path),
419+
],
420+
),
421+
),
422+
if (output.isNotEmpty)
423+
_ToolSection(title: 'Results', child: _MonoText(output))
424+
else if (item.ended)
425+
_ToolSection(
426+
title: 'Results',
427+
child: Text(item.summary ?? 'No matches found'),
428+
),
429+
],
430+
),
431+
);
432+
}
222433
}
223434

224435
/// Registry. Order does not matter — first matching name wins.

0 commit comments

Comments
 (0)