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:
@@ -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', () {
|
||||
|
||||
Reference in New Issue
Block a user