diff --git a/CHANGELOG.md b/CHANGELOG.md index 85fd9bfe..a0be1004 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -134,6 +134,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Changed +- The in-flight turn indicator now feels alive: instead of a static gray + `running…`, it shows a rotating curated status verb (`Pondering…`, + `Conjuring…`, …) with an animated ellipsis. Respects reduced-motion (static + verb) and keeps a stable a11y label. (T-255) - The `clide` CLI launch check now distinguishes a dev-tree build (`native//clide`) from a packaged install — surfaced as an info note on a checkout rather than treated as a clean install or prompting a reinstall. diff --git a/lib/builtin/claude/src/claude_composer.dart b/lib/builtin/claude/src/claude_composer.dart index 58a1fe0f..ba2d51d6 100644 --- a/lib/builtin/claude/src/claude_composer.dart +++ b/lib/builtin/claude/src/claude_composer.dart @@ -13,6 +13,7 @@ import 'dart:io'; import 'package:clide/builtin/claude/src/claude_config.dart'; import 'package:clide/builtin/claude/src/clipboard_paste.dart'; +import 'package:clide/builtin/claude/src/running_indicator.dart'; import 'package:clide/builtin/claude/src/slash_commands.dart'; import 'package:clide/kernel/src/theme/controller.dart'; import 'package:clide/kernel/src/theme/tokens.dart'; @@ -483,7 +484,7 @@ class _ClaudeComposerState extends State { padding: const EdgeInsets.only(bottom: 8), child: Row( children: [ - ClideText('running…', muted: true, fontSize: clideFontMeta), + const RunningIndicator(), const Spacer(), ClideButton( label: 'Stop ⎋', diff --git a/lib/builtin/claude/src/running_indicator.dart b/lib/builtin/claude/src/running_indicator.dart new file mode 100644 index 00000000..d2e428b3 --- /dev/null +++ b/lib/builtin/claude/src/running_indicator.dart @@ -0,0 +1,89 @@ +/// 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 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 createState() => _RunningIndicatorState(); +} + +class _RunningIndicatorState extends State 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); + }, + ), + ), + ); + } +} diff --git a/test/builtin/claude/running_indicator_test.dart b/test/builtin/claude/running_indicator_test.dart new file mode 100644 index 00000000..ee05b27c --- /dev/null +++ b/test/builtin/claude/running_indicator_test.dart @@ -0,0 +1,54 @@ +/// T-255: RunningIndicator — animated ellipsis + rotating verb, reduced-motion +/// fallback. Driven off the AnimationController's value, so the test advances +/// it with bounded pumps (no real timers). +library; + +import 'package:clide/builtin/claude/src/running_indicator.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +String? _text(WidgetTester t) { + final w = t.widget(find.byType(ClideText)); + return w.data; +} + +void main() { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() => f.dispose()); + + Widget wrap({bool reducedMotion = false}) => MediaQuery( + data: MediaQueryData(disableAnimations: reducedMotion), + child: const RunningIndicator(), + ); + + testWidgets('animates the ellipsis and rotates the verb', (tester) async { + await tester.pumpWidget(harness(f, wrap())); + await tester.pump(); + expect(_text(tester), 'Pondering'); // t≈0: first verb, no dots + + await tester.pump(const Duration(milliseconds: 1100)); + expect(_text(tester), 'Pondering.'); // ~1.1s → 1 dot + + await tester.pump(const Duration(milliseconds: 1100)); + expect(_text(tester), 'Pondering..'); // ~2.2s → 2 dots + + await tester.pump(const Duration(milliseconds: 2000)); + expect(_text(tester), 'Conjuring'); // ~4.2s → second verb, dots wrapped to 0 + + // Dispose the infinite animation before the test ends. + await tester.pumpWidget(harness(f, const SizedBox())); + }); + + testWidgets('reduced motion shows a static verb that does not change', (tester) async { + await tester.pumpWidget(harness(f, wrap(reducedMotion: true))); + await tester.pump(); + expect(_text(tester), 'Pondering…'); + await tester.pump(const Duration(seconds: 6)); + expect(_text(tester), 'Pondering…'); // unchanged — no animation running + }); +}