ahp_flutter 0.1.0
ahp_flutter: ^0.1.0 copied to clipboard
Flutter bindings for the Agent Host Protocol — scoped runtime, refcounted channel subscriptions, and rebuild narrowing for streaming state.
/// A small client for a running agent host.
///
/// Shows the three things the bindings exist for: scoping a runtime, watching
/// a channel, and narrowing rebuilds to the slice a widget renders.
///
/// code agent host --port 51234 --without-connection-token
/// flutter run -d macos
library;
import 'package:ahp_flutter/ahp_flutter.dart';
import 'package:flutter/material.dart';
/// Where the host is listening. Override with
/// `--dart-define=AHP_URL=ws://host:port`.
const _hostUrl = String.fromEnvironment(
'AHP_URL',
defaultValue: 'ws://localhost:51234',
);
final _root = Uri.parse('ahp-root://');
void main() => runApp(const AhpExampleApp());
class AhpExampleApp extends StatefulWidget {
const AhpExampleApp({super.key});
@override
State<AhpExampleApp> createState() => _AhpExampleAppState();
}
class _AhpExampleAppState extends State<AhpExampleApp> {
late final AhpRuntime _runtime;
@override
void initState() {
super.initState();
_runtime = AhpRuntime(
// A factory, not a single client: it is called on every attempt, so a
// reconnect an hour later can dial a fresh URL or a refreshed token.
connect: () async => AhpConnection(
await WebSocketAhpTransport.connect(Uri.parse(_hostUrl)),
),
clientId: 'ahp-flutter-example',
);
}
@override
void dispose() {
_runtime.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => MaterialApp(
title: 'AHP example',
theme: ThemeData(
colorSchemeSeed: Colors.indigo,
brightness: Brightness.dark,
),
home: AhpProvider(runtime: _runtime, child: const _HomePage()),
);
}
class _HomePage extends StatelessWidget {
const _HomePage();
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('Agent host'),
bottom: const PreferredSize(
preferredSize: Size.fromHeight(28),
child: _ConnectionBar(),
),
),
body: const Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SessionCount(),
SizedBox(height: 16),
Expanded(child: _AgentList()),
],
),
),
);
}
/// Watches the connection lifecycle.
///
/// The only widget here that depends on the connection scope, so the only one
/// that rebuilds when the connection transitions — which is the point of
/// splitting the runtime and its lifecycle into separate scopes.
class _ConnectionBar extends StatelessWidget {
const _ConnectionBar();
@override
Widget build(BuildContext context) {
final state = context.ahpConnection;
final (label, color) = switch (state.phase) {
AhpLifecycle.connected => ('connected', Colors.green),
AhpLifecycle.connecting ||
AhpLifecycle.handshaking => ('connecting', Colors.amber),
AhpLifecycle.catchingUp => ('catching up', Colors.amber),
AhpLifecycle.reconnecting => (
'reconnecting (attempt ${state.attempt})',
Colors.amber,
),
AhpLifecycle.awaitingAuth => ('waiting for sign-in', Colors.orange),
AhpLifecycle.fatal => ('cannot connect', Colors.red),
AhpLifecycle.disconnected => ('disconnected', Colors.grey),
};
return Container(
width: double.infinity,
color: color.withValues(alpha: 0.15),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
children: [
Icon(Icons.circle, size: 8, color: color),
const SizedBox(width: 8),
Text(label),
const Spacer(),
if (state.protocolVersion case final version?)
Text(
'protocol $version',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
}
}
/// Narrows to one integer on the root channel.
///
/// Rebuilds only when the session count itself moves; an agent-list change, or
/// anything else arriving on the same channel, leaves this alone.
class _SessionCount extends StatelessWidget {
const _SessionCount();
@override
Widget build(BuildContext context) => AhpSelector<RootState, int>(
channel: _root,
select: (state) => state.activeSessions ?? 0,
builder: (context, count, _) => Text(
'$count active ${count == 1 ? 'session' : 'sessions'}',
style: Theme.of(context).textTheme.titleMedium,
),
);
}
/// Narrows to the agent list, which changes far less often than the channel.
class _AgentList extends StatelessWidget {
const _AgentList();
@override
Widget build(BuildContext context) => AhpSelector<RootState, List<AgentInfo>>(
channel: _root,
select: (state) => state.agents,
builder: (context, agents, _) {
if (agents.isEmpty) {
return const Center(child: Text('No agents reported yet.'));
}
return ListView.separated(
itemCount: agents.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, i) {
final agent = agents[i];
final models = agent.models.map((m) => m.id).join(', ');
return ListTile(
title: Text(agent.displayName),
subtitle: Text(
[agent.provider, if (models.isNotEmpty) models].join(' · '),
),
);
},
);
},
);
}