claude: Bash live-tail detection + read-only file follower (T-325, core)
The detection/follow core for the live-tail sub-card, with the UI wiring to follow. Claude Code runs every Bash tool itself and clide only sees the final tool_result block — we can't mirror the running process, so instead we detect a file-backed source the command follows and open our own read-only follower on the same file. - bash_tail_source.dart: detectBashTailSource() parses a Bash command for a single, safe, file-backed source (tail/cat/less with one file arg, inside the workspace via resolveUnderRoot). Returns null for a pipe-into-tail, a redirect, two files, or a path outside the repo — the caller then shows a "nothing to follow" note. bashHasTailIntent() gates WHEN the segment appears: v1 triggers on `tail`/follow-flags only, so ordinary cat/ls/git cards stay clean (cat/less remain detectable for later). - file_tail_follower.dart: a polling, read-only `tail -f`-style follower (no subprocess, no touching Claude's command) that emits the trailing window then appended deltas, and re-reads from the top on truncation. Tested: 19 parser cases (incl. the `git push | tail -25` and outside- workspace null cases), the intent predicate, and the follower (initial window / appended delta / missing file / rotation / start / stop). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
/// Unit tests for the Bash live-tail source parser (T-325).
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/bash_tail_source.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
// resolveUnderRoot is pure string normalisation — the dir need not exist.
|
||||
final root = Directory('/repo');
|
||||
String? detect(String cmd) => detectBashTailSource(cmd, workspaceRoot: root);
|
||||
|
||||
group('detectBashTailSource — followable file sources (T-325)', () {
|
||||
test('tail -f a relative file', () {
|
||||
expect(detect('tail -f app.log'), '/repo/app.log');
|
||||
});
|
||||
|
||||
test('tail -f a nested file', () {
|
||||
expect(detect('tail -f logs/build.log'), '/repo/logs/build.log');
|
||||
});
|
||||
|
||||
test('tail with -n N before the file', () {
|
||||
expect(detect('tail -n 200 -f logs/build.log'), '/repo/logs/build.log');
|
||||
});
|
||||
|
||||
test('tail -F (retry-follow)', () {
|
||||
expect(detect('tail -F server.log'), '/repo/server.log');
|
||||
});
|
||||
|
||||
test('cat a file', () {
|
||||
expect(detect('cat notes.txt'), '/repo/notes.txt');
|
||||
});
|
||||
|
||||
test('less a file', () {
|
||||
expect(detect('less README.md'), '/repo/README.md');
|
||||
});
|
||||
|
||||
test('an absolute path INSIDE the workspace is followed', () {
|
||||
expect(detect('tail -f /repo/sub/x.log'), '/repo/sub/x.log');
|
||||
});
|
||||
|
||||
test('a quoted path with a space', () {
|
||||
expect(detect('cat "my file.log"'), '/repo/my file.log');
|
||||
});
|
||||
|
||||
test('a redirect after the file is ignored', () {
|
||||
expect(detect('tail -f app.log 2>/dev/null'), '/repo/app.log');
|
||||
});
|
||||
|
||||
test('a downstream pipe stage is ignored; the tail still has its file', () {
|
||||
expect(detect('tail -f logs/app.log | grep ERROR'), '/repo/logs/app.log');
|
||||
});
|
||||
|
||||
test('two segments naming the SAME file resolve to one source', () {
|
||||
expect(detect('cat a.txt && tail -f a.txt'), '/repo/a.txt');
|
||||
});
|
||||
});
|
||||
|
||||
group('detectBashTailSource — no followable source (T-325)', () {
|
||||
test('a pipe INTO tail (reads stdin, no file)', () {
|
||||
expect(detect('git push origin main | tail -25'), isNull);
|
||||
});
|
||||
|
||||
test('tail -f reading a pipe (no file arg)', () {
|
||||
expect(detect('cmd | tail -f'), isNull);
|
||||
});
|
||||
|
||||
test('a non-follow command', () {
|
||||
expect(detect('echo hi'), isNull);
|
||||
});
|
||||
|
||||
test('an absolute path OUTSIDE the workspace', () {
|
||||
expect(detect('tail -f /etc/passwd'), isNull);
|
||||
});
|
||||
|
||||
test('a traversal escaping the workspace', () {
|
||||
expect(detect('tail -f ../secrets.txt'), isNull);
|
||||
});
|
||||
|
||||
test('two distinct files are ambiguous', () {
|
||||
expect(detect('tail -f a.log b.log'), isNull);
|
||||
});
|
||||
|
||||
test('two segments naming DIFFERENT files are ambiguous', () {
|
||||
expect(detect('cat a.txt && tail -f b.txt'), isNull);
|
||||
});
|
||||
|
||||
test('empty command', () {
|
||||
expect(detect(''), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('bashHasTailIntent — when to surface the segment (T-325)', () {
|
||||
test('a tail command has tail intent (even into a pipe → "nothing to follow")', () {
|
||||
expect(bashHasTailIntent('tail -f app.log'), isTrue);
|
||||
expect(bashHasTailIntent('tail -100 app.log'), isTrue);
|
||||
expect(bashHasTailIntent('git push | tail -25'), isTrue);
|
||||
});
|
||||
|
||||
test('a bare follow flag counts', () {
|
||||
expect(bashHasTailIntent('some-cmd --follow build.log'), isTrue);
|
||||
});
|
||||
|
||||
test('ordinary commands have no tail intent (no segment)', () {
|
||||
expect(bashHasTailIntent('ls -la'), isFalse);
|
||||
expect(bashHasTailIntent('git status'), isFalse);
|
||||
expect(bashHasTailIntent('cat README.md'), isFalse); // cat is detectable but not a v1 trigger
|
||||
expect(bashHasTailIntent('grep -rn foo lib/'), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/// Unit tests for the read-only file tail follower (T-325).
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/file_tail_follower.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
late Directory dir;
|
||||
late File file;
|
||||
late List<String> chunks;
|
||||
FileTailFollower follower(File f) => FileTailFollower(f.path, tailBytes: 8, onData: (b) => chunks.add(utf8.decode(b)));
|
||||
|
||||
setUp(() async {
|
||||
dir = await Directory.systemTemp.createTemp('clide-tail-test-');
|
||||
file = File('${dir.path}/app.log');
|
||||
chunks = [];
|
||||
});
|
||||
tearDown(() async => dir.existsSync() ? dir.delete(recursive: true) : null);
|
||||
|
||||
test('emits the trailing window on the first read, not the whole file', () async {
|
||||
file.writeAsStringSync('0123456789ABCDEF'); // 16 bytes, tailBytes=8
|
||||
final f = follower(file);
|
||||
await f.pollOnce();
|
||||
expect(chunks, ['89ABCDEF']); // last 8 bytes only
|
||||
f.stop();
|
||||
});
|
||||
|
||||
test('emits only newly-appended bytes on subsequent reads', () async {
|
||||
file.writeAsStringSync('start');
|
||||
final f = follower(file);
|
||||
await f.pollOnce(); // primes at the tail
|
||||
chunks.clear();
|
||||
file.writeAsStringSync(' MORE', mode: FileMode.append);
|
||||
await f.pollOnce();
|
||||
expect(chunks, [' MORE']); // only the appended delta
|
||||
f.stop();
|
||||
});
|
||||
|
||||
test('a missing file is tolerated until it appears', () async {
|
||||
final f = follower(File('${dir.path}/not-yet.log'));
|
||||
await f.pollOnce(); // no file → no emit, no throw
|
||||
expect(chunks, isEmpty);
|
||||
f.stop();
|
||||
});
|
||||
|
||||
test('truncation/rotation re-reads from the top', () async {
|
||||
file.writeAsStringSync('aaaaaaaaaaaa'); // 12 bytes
|
||||
final f = follower(file);
|
||||
await f.pollOnce();
|
||||
chunks.clear();
|
||||
file.writeAsStringSync('XY'); // shrink to 2 bytes (rotated)
|
||||
await f.pollOnce();
|
||||
expect(chunks, ['XY']);
|
||||
f.stop();
|
||||
});
|
||||
|
||||
test('start() emits the initial window and arms the poll', () async {
|
||||
file.writeAsStringSync('hello world'); // 11 bytes, tailBytes=8
|
||||
final f = follower(file);
|
||||
await f.start(); // awaits the initial pollOnce before arming the timer
|
||||
f.stop(); // tear the timer down before it fires
|
||||
expect(chunks, ['lo world']); // last 8 bytes
|
||||
});
|
||||
|
||||
test('stop() makes further polls no-ops', () async {
|
||||
file.writeAsStringSync('hello');
|
||||
final f = follower(file);
|
||||
f.stop();
|
||||
await f.pollOnce();
|
||||
expect(chunks, isEmpty);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user