claude: live-tail terminal sub-card in expanded Bash cards (T-325, UI)

Wire the detection + follower core into the Bash tool card. A Bash card
with a follow intent (`tail -f …`) gains a "live tail" segment below the
result: an embedded read-only TerminalView fed by FileTailFollower on the
file the command follows, resolved against the open workspace.

Lazy lifecycle for free: the collapser builds its children only when
expanded (clide_collapser_card.dart), so _BashLiveTail starts the follower
in didChangeDependencies on expand and stops it in dispose on collapse —
no follower runs until the card is expanded. No resolvable file-backed
source → a muted "no independent source to follow" note, never an empty
terminal. The workspace root comes from kernel.project.current, so no new
plumbing through the conversation widget tree.

Tests: a tail Bash card surfaces the segment (+ the muted note when no
project/source); an ordinary `ls` card gets no segment; the segment only
builds on expand.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 19:09:01 +02:00
co-authored by Claude Opus 4.8
parent 898a0316e5
commit d43377ac89
3 changed files with 105 additions and 0 deletions
+13
View File
@@ -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
@@ -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
@@ -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', () {