Files
clide/lib/builtin/claude/src/running_indicator.dart
T
jpmschweitzerandClaude Opus 4.8 ed97867a60 animate the running turn indicator with rotating verbs (T-255)
The static gray `running…` gave no sense anything was happening. Replace it
with a RunningIndicator: an animated ellipsis (Pondering → . → .. → ...) and a
curated, on-brand verb that rotates every few seconds (Pondering, Conjuring,
Brewing, …).

The verbs are clide-owned, not the Claude Code CLI's — that list is a TUI
cosmetic the stream-json protocol doesn't surface, and reusing the bundled
strings is a licensing gray area, so a curated list keeps us self-contained
(own-the-rendering-stack, D-75). Animation is driven off a single
AnimationController's value (no timers) so tests advance it with bounded
pumps; reduced-motion shows a static verb and the a11y label stays stable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:51:02 +02:00

90 lines
2.9 KiB
Dart

/// In-flight turn indicator (T-255): a muted, animated label shown next to the
/// Stop button while a Claude turn runs. Animated ellipsis (`Pondering` →
/// `Pondering.` → `..` → `...`) plus a verb that rotates every few seconds.
///
/// The verbs are clide-owned, NOT the Claude Code CLI's: that list is a TUI
/// cosmetic the stream-json protocol doesn't expose, and reusing the bundled
/// strings is a licensing gray area — a curated list keeps us self-contained
/// (own-the-rendering-stack, D-75). Animation is driven off a single
/// [AnimationController]'s value (no timers) so tests advance it with bounded
/// pumps; reduced-motion shows a static verb and the a11y label stays stable.
library;
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
/// Curated, on-brand present participles. Muted and tasteful — not the CLI's.
const List<String> runningVerbs = [
'Pondering',
'Conjuring',
'Brewing',
'Tinkering',
'Noodling',
'Percolating',
'Computing',
'Wrangling',
'Untangling',
'Synthesizing',
'Cogitating',
'Whirring',
];
/// Seconds each verb is shown before rotating to the next.
const int _secondsPerWord = 4;
class RunningIndicator extends StatefulWidget {
const RunningIndicator({super.key});
@override
State<RunningIndicator> createState() => _RunningIndicatorState();
}
class _RunningIndicatorState extends State<RunningIndicator> with SingleTickerProviderStateMixin {
// One full pass over every verb; value 0→1 maps linearly to elapsed seconds.
static final int _periodSeconds = runningVerbs.length * _secondsPerWord;
late final AnimationController _c = AnimationController(
vsync: this,
duration: Duration(seconds: _periodSeconds),
);
@override
void didChangeDependencies() {
super.didChangeDependencies();
final reduced = MediaQuery.maybeOf(context)?.disableAnimations ?? false;
if (reduced) {
if (_c.isAnimating) _c.stop();
} else if (!_c.isAnimating) {
_c.repeat();
}
}
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final reduced = MediaQuery.maybeOf(context)?.disableAnimations ?? false;
// The animated text is decorative; AT gets one stable label.
return Semantics(
label: 'Claude is running',
child: ExcludeSemantics(
child: reduced
? ClideText('${runningVerbs.first}…', muted: true, fontSize: clideFontMeta)
: AnimatedBuilder(
animation: _c,
builder: (ctx, _) {
final elapsed = _c.value * _periodSeconds;
final dots = '.' * (elapsed.floor() % 4);
final word = runningVerbs[(elapsed ~/ _secondsPerWord) % runningVerbs.length];
return ClideText('$word$dots', muted: true, fontSize: clideFontMeta);
},
),
),
);
}
}