diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dd08fc0..4618068f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,19 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ## [Unreleased] +### Added + +- **Live tail inside expanded Bash activity cards.** Expanding a Bash card whose + command follows a file (`tail -f …`, `tail -n N …`) now shows a live, scrolling + terminal of that file below the result, so you can watch progress on a + long-running tail instead of waiting for the final block. clide can't see + Claude's running process, so it opens its OWN read-only follower on the same + file — never re-running or intercepting the command — connected lazily only + while the card is expanded and torn down on collapse. A command with no + independent file-backed source (a pipe into `tail`, a path outside the repo) + shows a muted "no independent source to follow" note rather than an empty + terminal. (T-325) + ### Changed - **Each spawned subagent gets its own collapsing activity card.** A fan-out of diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index 2b126950..28c4a08a 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -14,8 +14,10 @@ import 'dart:convert'; import 'dart:io'; import 'package:clide/builtin/claude/src/activity_cluster.dart'; +import 'package:clide/builtin/claude/src/bash_tail_source.dart'; import 'package:clide/builtin/claude/src/conversation_card.dart'; import 'package:clide/builtin/claude/src/conversation_controller.dart'; +import 'package:clide/builtin/claude/src/file_tail_follower.dart'; import 'package:clide/builtin/claude/src/image_thumbnail.dart'; import 'package:clide/builtin/claude/src/prompt_card.dart'; import 'package:clide/builtin/claude/src/transcript_reader.dart'; @@ -23,6 +25,7 @@ import 'package:clide/kernel/src/facade.dart'; import 'package:clide/kernel/src/syntax/language_map.dart'; import 'package:clide/kernel/src/theme/controller.dart'; import 'package:clide/kernel/src/theme/tokens.dart'; +import 'package:clide/src/terminal/terminal.dart'; import 'package:clide/widgets/widgets.dart'; import 'package:flutter/widgets.dart'; @@ -423,6 +426,64 @@ void _openFile(BuildContext context, String path, int? line) { unawaited(ClideKernel.of(context).ipc.request('editor.open', args: {'path': path, 'line': ?line})); } +/// A live, read-only tail of the file a Bash command follows (T-325). +/// +/// Mounts when the Bash card is EXPANDED — the collapser builds its children +/// lazily (clide_collapser_card.dart), so initialising here and tearing down in +/// [dispose] gives the "connect on expand, disconnect on collapse" lifecycle +/// for free. Resolves the followed file from the command against the open +/// workspace; when there's no independent file-backed source (a pipe into +/// `tail`, a path outside the repo) it shows a muted note instead of an empty +/// terminal. +class _BashLiveTail extends StatefulWidget { + const _BashLiveTail({required this.command}); + + final String command; + + @override + State<_BashLiveTail> createState() => _BashLiveTailState(); +} + +class _BashLiveTailState extends State<_BashLiveTail> { + Terminal? _terminal; + FileTailFollower? _follower; + bool _resolved = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_resolved) return; // resolve once — InheritedWidget access needs context + _resolved = true; + final root = ClideKernel.of(context).project.current; + final source = root == null ? null : detectBashTailSource(widget.command, workspaceRoot: root); + if (source == null) return; // no file-backed source → muted note in build + final term = Terminal(maxLines: 1000); + _terminal = term; + _follower = FileTailFollower(source, onData: (bytes) => term.write(utf8.decode(bytes, allowMalformed: true))); + unawaited(_follower!.start()); + } + + @override + void dispose() { + _follower?.stop(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final term = _terminal; + if (term == null) { + return ClideText('no independent source to follow', muted: true, fontSize: clideFontMeta); + } + return SizedBox( + height: 160, + child: ClipRect( + child: ClidePtyView(terminal: term, label: 'live tail', fontSize: clideFontMeta), + ), + ); + } +} + /// One conversation item, rendered by kind. class _ConversationTurn extends StatelessWidget { const _ConversationTurn({ @@ -694,6 +755,14 @@ class _ConversationTurn extends StatelessWidget { label: 'result', child: ClideCodeBlock(source: result.content, language: _resultLanguage(t)), ), + // T-325: a Bash card that follows a file (`tail -f …`) gets a live, + // scrolling tail of that file below the result — connected lazily, only + // while the card is expanded (the collapser builds segments on expand). + if (t.name == 'Bash' && t.input['command'] is String && bashHasTailIntent(t.input['command'] as String)) + CardSegment( + label: 'live tail', + child: _BashLiveTail(command: t.input['command'] as String), + ), ]; // A resolved permission-prompted call is tinted green if approved / red if diff --git a/test/builtin/claude/conversation_view_test.dart b/test/builtin/claude/conversation_view_test.dart index 4d7cedd7..79318f0c 100644 --- a/test/builtin/claude/conversation_view_test.dart +++ b/test/builtin/claude/conversation_view_test.dart @@ -818,6 +818,29 @@ void main() { expect(copied, contains('question text')); expect(copied, contains('answer text')); }); + + testWidgets('a tail Bash card shows a live-tail segment; no workspace source → muted note (T-325)', (tester) async { + await pumpWith(tester, [ + AssistantToolUse(uuid: 'b1', timestamp: _t, isSidechain: false, toolUseId: 'tb', name: 'Bash', input: const {'command': 'tail -f app.log'}), + ]); + // Collapsed by default — the segment only builds (and connects) on expand. + expect(find.text('live tail'), findsNothing); + await tester.tap(find.bySemanticsLabel('Bash, 1 step, collapsed')); + await tester.pumpAndSettle(); + expect(find.text('live tail'), findsOneWidget); // segment surfaced for a tail command + // No project open in the fixture → no resolvable source → the muted note, + // never a broken/empty terminal. + expect(find.text('no independent source to follow'), findsOneWidget); + }); + + testWidgets('an ordinary Bash card has no live-tail segment (T-325)', (tester) async { + await pumpWith(tester, [ + AssistantToolUse(uuid: 'b2', timestamp: _t, isSidechain: false, toolUseId: 'tb2', name: 'Bash', input: const {'command': 'ls -la'}), + ]); + await tester.tap(find.bySemanticsLabel('Bash, 1 step, collapsed')); + await tester.pumpAndSettle(); + expect(find.text('live tail'), findsNothing); // no tail intent → no segment + }); }); group('ClaudeBanner', () {