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>
This commit is contained in:
@@ -134,6 +134,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
|
|
||||||
### Changed
|
### 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
|
- The `clide` CLI launch check now distinguishes a dev-tree build
|
||||||
(`native/<plat>/clide`) from a packaged install — surfaced as an info note on
|
(`native/<plat>/clide`) from a packaged install — surfaced as an info note on
|
||||||
a checkout rather than treated as a clean install or prompting a reinstall.
|
a checkout rather than treated as a clean install or prompting a reinstall.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:clide/builtin/claude/src/claude_config.dart';
|
import 'package:clide/builtin/claude/src/claude_config.dart';
|
||||||
import 'package:clide/builtin/claude/src/clipboard_paste.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/builtin/claude/src/slash_commands.dart';
|
||||||
import 'package:clide/kernel/src/theme/controller.dart';
|
import 'package:clide/kernel/src/theme/controller.dart';
|
||||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||||
@@ -483,7 +484,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
|
|||||||
padding: const EdgeInsets.only(bottom: 8),
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
ClideText('running…', muted: true, fontSize: clideFontMeta),
|
const RunningIndicator(),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
ClideButton(
|
ClideButton(
|
||||||
label: 'Stop ⎋',
|
label: 'Stop ⎋',
|
||||||
|
|||||||
@@ -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<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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ClideText>(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
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user