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