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,119 @@
|
||||
/// Detect the file a Bash command follows, for the live-tail sub-card (T-325).
|
||||
///
|
||||
/// Claude Code runs every Bash tool itself and clide only sees the final
|
||||
/// `tool_result` block — we never tap the running command's stdout. So instead
|
||||
/// of mirroring the process, we detect a *file-backed source* the command
|
||||
/// reads/follows and open our own read-only follower on the same file.
|
||||
///
|
||||
/// Deliberately conservative (the ticket's "small, explicit allowlist"): only
|
||||
/// the read/follow verbs below, only a single file argument, and only paths
|
||||
/// that resolve INSIDE the workspace. Anything else — a pipe into `tail`, a
|
||||
/// redirect, two files, a path outside the repo — returns null so the caller
|
||||
/// shows a "nothing to follow" affordance instead of following the wrong thing.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/src/files/path_safety.dart';
|
||||
|
||||
/// Verbs whose single file argument clide can independently follow read-only.
|
||||
const Set<String> _followVerbs = {'tail', 'cat', 'less'};
|
||||
|
||||
/// The file [command] reads/follows that clide can mirror read-only, as an
|
||||
/// absolute path inside [workspaceRoot] — or null when there is no single,
|
||||
/// safe, file-backed source. See the library doc for the policy.
|
||||
String? detectBashTailSource(String command, {required Directory workspaceRoot}) {
|
||||
String? found;
|
||||
for (final segment in _commandSegments(command)) {
|
||||
final tokens = _tokenize(segment);
|
||||
if (tokens.isEmpty || !_followVerbs.contains(tokens.first)) continue;
|
||||
final files = _fileArgs(tokens.first, tokens.sublist(1));
|
||||
if (files.length != 1) continue; // 0 → reads stdin (a pipe); >1 → ambiguous
|
||||
|
||||
final String resolved;
|
||||
try {
|
||||
resolved = resolveUnderRoot(workspaceRoot, files.single);
|
||||
} on PathOutsideRoot {
|
||||
continue; // outside the workspace → don't follow (v1 policy)
|
||||
}
|
||||
if (found != null && found != resolved) return null; // two distinct sources
|
||||
found = resolved;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/// Whether [command] expresses an intent to FOLLOW a file — used to decide
|
||||
/// when to surface the live-tail segment at all, so ordinary commands (`ls`,
|
||||
/// `git status`, a plain `cat`) get no segment, but a `tail …` with no
|
||||
/// followable file still shows the "nothing to follow" note. v1 triggers on
|
||||
/// `tail` or a follow flag (`-f`/`-F`/`--follow`); `cat`/`less` are detectable
|
||||
/// sources but don't trigger the UI on their own (T-325).
|
||||
bool bashHasTailIntent(String command) {
|
||||
for (final segment in _commandSegments(command)) {
|
||||
final tokens = _tokenize(segment);
|
||||
if (tokens.isEmpty) continue;
|
||||
if (tokens.first == 'tail') return true;
|
||||
if (tokens.any((t) => t == '-f' || t == '-F' || t == '--follow')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Split a command line into command/pipeline segments on `|`, `;`, `&`. The
|
||||
/// doubled forms (`&&`, `||`) fall out as empty middles and are dropped.
|
||||
Iterable<String> _commandSegments(String command) => command.split(RegExp(r'[|;&]')).where((s) => s.trim().isNotEmpty);
|
||||
|
||||
/// Positional (non-flag) file arguments for [verb]. Skips flags, consumes the
|
||||
/// value of `tail -n N` / `-c N`, honours `--` (end of options), and stops at a
|
||||
/// redirect (`>` / `<`) — everything after a redirect targets a fd, not the
|
||||
/// command's input.
|
||||
List<String> _fileArgs(String verb, List<String> args) {
|
||||
final files = <String>[];
|
||||
for (var i = 0; i < args.length; i++) {
|
||||
final a = args[i];
|
||||
if (a == '--') {
|
||||
files.addAll(args.sublist(i + 1).where((t) => !t.contains('>') && !t.contains('<')));
|
||||
break;
|
||||
}
|
||||
if (a.contains('>') || a.contains('<')) break; // a redirect ends positional args
|
||||
if (a.startsWith('-')) {
|
||||
if (verb == 'tail' && (a == '-n' || a == '-c') && i + 1 < args.length) i++; // -n N / -c N
|
||||
continue;
|
||||
}
|
||||
files.add(a);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/// Minimal shell tokeniser: splits on whitespace, honours single/double quotes
|
||||
/// (no escape or expansion handling — enough to recover file arguments).
|
||||
List<String> _tokenize(String s) {
|
||||
final out = <String>[];
|
||||
final buf = StringBuffer();
|
||||
String? quote;
|
||||
var has = false;
|
||||
for (var i = 0; i < s.length; i++) {
|
||||
final ch = s[i];
|
||||
if (quote != null) {
|
||||
if (ch == quote) {
|
||||
quote = null;
|
||||
} else {
|
||||
buf.write(ch);
|
||||
}
|
||||
has = true;
|
||||
} else if (ch == '"' || ch == "'") {
|
||||
quote = ch;
|
||||
has = true;
|
||||
} else if (ch == ' ' || ch == '\t') {
|
||||
if (has) {
|
||||
out.add(buf.toString());
|
||||
buf.clear();
|
||||
has = false;
|
||||
}
|
||||
} else {
|
||||
buf.write(ch);
|
||||
has = true;
|
||||
}
|
||||
}
|
||||
if (has) out.add(buf.toString());
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/// Read-only file follower for the Bash live-tail sub-card (T-325).
|
||||
///
|
||||
/// clide can't see a running Bash command's stdout (Claude Code owns the
|
||||
/// process), so to "watch the same output" we open our OWN read-only follower
|
||||
/// on the file the command tails. This never spawns a process and never
|
||||
/// touches Claude's command — it just reads the file as it grows, like
|
||||
/// `tail -f`, and hands new bytes to [onData].
|
||||
///
|
||||
/// Pure dart:io/dart:async (no Flutter) so it's unit-testable. Polls rather
|
||||
/// than using a watcher so it works uniformly across platforms and survives
|
||||
/// truncation/rotation (size shrinking → re-read from the top).
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
class FileTailFollower {
|
||||
FileTailFollower(this.path, {required this.onData, this.tailBytes = 16384, this.interval = const Duration(milliseconds: 300)});
|
||||
|
||||
/// Absolute path of the file to follow.
|
||||
final String path;
|
||||
|
||||
/// New bytes appended since the last read (or the initial tail window).
|
||||
final void Function(Uint8List bytes) onData;
|
||||
|
||||
/// On first read, start this many bytes from the end (a `tail -c` window)
|
||||
/// rather than dumping the whole file.
|
||||
final int tailBytes;
|
||||
|
||||
final Duration interval;
|
||||
|
||||
int _pos = 0;
|
||||
bool _primed = false;
|
||||
bool _stopped = false;
|
||||
Timer? _timer;
|
||||
|
||||
/// Begin following: emit the initial tail window, then poll for growth.
|
||||
Future<void> start() async {
|
||||
await pollOnce();
|
||||
if (_stopped) return;
|
||||
_timer = Timer.periodic(interval, (_) => pollOnce());
|
||||
}
|
||||
|
||||
/// One read cycle. Public so tests can drive it deterministically without
|
||||
/// waiting on the timer. Reads any bytes appended since the last position
|
||||
/// (or, on the first call, the trailing [tailBytes]); resets to the top if
|
||||
/// the file shrank (truncated/rotated).
|
||||
Future<void> pollOnce() async {
|
||||
if (_stopped) return;
|
||||
final file = File(path);
|
||||
if (!await file.exists()) return; // not created yet — keep waiting
|
||||
final length = await file.length();
|
||||
|
||||
if (!_primed) {
|
||||
_pos = length > tailBytes ? length - tailBytes : 0;
|
||||
_primed = true;
|
||||
} else if (length < _pos) {
|
||||
_pos = 0; // truncated / rotated → re-read from the top
|
||||
}
|
||||
if (length <= _pos) return;
|
||||
|
||||
final raf = await file.open();
|
||||
try {
|
||||
await raf.setPosition(_pos);
|
||||
final bytes = await raf.read(length - _pos);
|
||||
_pos = length;
|
||||
if (!_stopped && bytes.isNotEmpty) onData(Uint8List.fromList(bytes));
|
||||
} finally {
|
||||
await raf.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop following and release the timer. Idempotent.
|
||||
void stop() {
|
||||
_stopped = true;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user