Skip to content

Commit 76c105e

Browse files
committed
feat(app): /model picker + subtle model·thinking header indicator
Consume the new session.meta event: SessionMeta/ModelInfo models, a sessionMetaProvider (mirrors commandsProvider), and reducer handling that stores meta without polluting the chat list. Adds: - /model client command → bottom-sheet picker of selectable models (current marked) → session.action model {provider,id}. - A dim second line under the session title showing '<model> · <thinking>'. Tests: reducer test for session.meta; analyze --fatal-infos clean; 84/84.
1 parent 75e83bc commit 76c105e

6 files changed

Lines changed: 188 additions & 19 deletions

File tree

app/lib/store/models.dart

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,48 @@ class SlashCmd {
3737
}
3838
}
3939

40+
/// A model the agent can run, as pushed via `session.meta`. Also used for the
41+
/// currently-active model.
42+
class ModelInfo {
43+
const ModelInfo({required this.provider, required this.id, required this.name});
44+
45+
final String provider;
46+
final String id;
47+
final String name;
48+
49+
static ModelInfo? fromJson(Map<String, dynamic> j) {
50+
final provider = j['provider'] as String?;
51+
final id = j['id'] as String?;
52+
if (provider == null || id == null) return null;
53+
return ModelInfo(provider: provider, id: id, name: (j['name'] as String?) ?? id);
54+
}
55+
}
56+
57+
/// Per-session model + thinking-level snapshot. Drives the subtle header
58+
/// indicator and the `/model` picker. Pushed via the `session.meta` event.
59+
class SessionMeta {
60+
const SessionMeta({this.model, required this.thinking, required this.models});
61+
62+
final ModelInfo? model;
63+
final String thinking;
64+
final List<ModelInfo> models;
65+
66+
static SessionMeta fromJson(Map<String, dynamic> j) {
67+
final rawModel = j['model'];
68+
return SessionMeta(
69+
model: rawModel is Map
70+
? ModelInfo.fromJson(Map<String, dynamic>.from(rawModel))
71+
: null,
72+
thinking: (j['thinking'] as String?) ?? '',
73+
models: ((j['models'] as List?) ?? const [])
74+
.whereType<Map<dynamic, dynamic>>()
75+
.map((m) => ModelInfo.fromJson(Map<String, dynamic>.from(m)))
76+
.whereType<ModelInfo>()
77+
.toList(),
78+
);
79+
}
80+
}
81+
4082
class Project {
4183
Project({
4284
required this.id,
@@ -421,6 +463,9 @@ List<ChatItem> foldEvents(Iterable<SessionEvent> events) {
421463
case EventKind.sessionCommands:
422464
// Handled by store, not as a chat item.
423465
break;
466+
case EventKind.sessionMeta:
467+
// Model/thinking snapshot — handled by store, not a chat item.
468+
break;
424469
}
425470
}
426471
return items;

app/lib/store/store.dart

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ class StoreState {
4444
required this.events,
4545
required this.cursors,
4646
required this.commands,
47+
required this.meta,
4748
});
4849

4950
factory StoreState.empty() => StoreState(
@@ -52,6 +53,7 @@ class StoreState {
5253
events: const {},
5354
cursors: const {},
5455
commands: const {},
56+
meta: const {},
5557
);
5658

5759
final List<Project> projects;
@@ -62,18 +64,23 @@ class StoreState {
6264
/// Per-session list of slash commands advertised by the agent.
6365
final Map<String, List<SlashCmd>> commands;
6466

67+
/// Per-session model + thinking-level snapshot from `session.meta`.
68+
final Map<String, SessionMeta> meta;
69+
6570
StoreState copyWith({
6671
List<Project>? projects,
6772
List<Session>? sessions,
6873
Map<String, List<SessionEvent>>? events,
6974
Map<String, int>? cursors,
7075
Map<String, List<SlashCmd>>? commands,
76+
Map<String, SessionMeta>? meta,
7177
}) => StoreState(
7278
projects: projects ?? this.projects,
7379
sessions: sessions ?? this.sessions,
7480
events: events ?? this.events,
7581
cursors: cursors ?? this.cursors,
7682
commands: commands ?? this.commands,
83+
meta: meta ?? this.meta,
7784
);
7885
}
7986

@@ -115,6 +122,13 @@ StoreState reduceEvent(StoreState state, SessionEvent ev) {
115122
return state.copyWith(commands: commands, cursors: cursors);
116123
}
117124

125+
// session.meta updates the model/thinking indicator + /model picker, not chat.
126+
if (ev.kind == EventKind.sessionMeta) {
127+
final meta = Map<String, SessionMeta>.from(state.meta);
128+
meta[ev.sessionId] = SessionMeta.fromJson(Map<String, dynamic>.from(ev.payload));
129+
return state.copyWith(meta: meta, cursors: cursors);
130+
}
131+
118132
final events = Map<String, List<SessionEvent>>.from(state.events);
119133
final list = List<SessionEvent>.from(events[ev.sessionId] ?? const []);
120134
list.add(ev);
@@ -392,3 +406,13 @@ final commandsProvider = Provider.family<List<SlashCmd>, String>((
392406
final s = ref.watch(storeControllerProvider);
393407
return s.commands[sessionId] ?? const [];
394408
});
409+
410+
/// Current model + thinking level + selectable models for a session (or null
411+
/// until the host pushes `session.meta`).
412+
final sessionMetaProvider = Provider.family<SessionMeta?, String>((
413+
ref,
414+
sessionId,
415+
) {
416+
final s = ref.watch(storeControllerProvider);
417+
return s.meta[sessionId];
418+
});

app/lib/transport/protocol.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@ enum EventKind {
9696
toolCallEnd,
9797
sessionStatus,
9898
sessionError,
99-
sessionCommands;
99+
sessionCommands,
100+
sessionMeta;
100101

101102
String get wire => switch (this) {
102103
EventKind.userMessage => 'user.message',
@@ -109,6 +110,7 @@ enum EventKind {
109110
EventKind.sessionStatus => 'session.status',
110111
EventKind.sessionError => 'session.error',
111112
EventKind.sessionCommands => 'session.commands',
113+
EventKind.sessionMeta => 'session.meta',
112114
};
113115

114116
static EventKind? fromWire(String s) {

app/lib/ui/composer/client_commands.dart

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,30 @@ final List<ClientCommand> clientCommands = <ClientCommand>[
175175
);
176176
},
177177
),
178+
ClientCommand(
179+
name: 'model',
180+
description: 'Switch the agent model',
181+
handler: (context, ref, {required sessionId}) async {
182+
final meta = ref.read(sessionMetaProvider(sessionId));
183+
final models = meta?.models ?? const [];
184+
if (models.isEmpty) {
185+
ScaffoldMessenger.of(context).showSnackBar(
186+
const SnackBar(content: Text('No models available for this session')),
187+
);
188+
return;
189+
}
190+
final picked = await _pickModel(context, models, meta?.model);
191+
if (picked == null || !context.mounted) return;
192+
ref.read(storeControllerProvider.notifier).sendSessionAction(
193+
sessionId,
194+
'model',
195+
args: {'provider': picked.provider, 'id': picked.id},
196+
);
197+
ScaffoldMessenger.of(context).showSnackBar(
198+
SnackBar(content: Text('Model: ${picked.name}')),
199+
);
200+
},
201+
),
178202
];
179203

180204
/// pi's thinking levels, low → high. `off` disables reasoning.
@@ -206,3 +230,39 @@ Future<String?> _pickThinkingLevel(BuildContext context) {
206230
),
207231
);
208232
}
233+
234+
235+
/// Present the selectable models in a modal sheet, marking [current]. Resolves
236+
/// with the chosen model or null if dismissed.
237+
Future<ModelInfo?> _pickModel(
238+
BuildContext context,
239+
List<ModelInfo> models,
240+
ModelInfo? current,
241+
) {
242+
return showModalBottomSheet<ModelInfo>(
243+
context: context,
244+
isScrollControlled: true,
245+
builder: (sheetContext) => SafeArea(
246+
child: ListView(
247+
shrinkWrap: true,
248+
children: [
249+
const ListTile(
250+
dense: true,
251+
title: Text('Model', style: TextStyle(fontWeight: FontWeight.bold)),
252+
),
253+
for (final m in models)
254+
ListTile(
255+
title: Text(m.name),
256+
subtitle: Text(m.provider),
257+
trailing: (current != null &&
258+
current.provider == m.provider &&
259+
current.id == m.id)
260+
? const Icon(Icons.check)
261+
: null,
262+
onTap: () => Navigator.pop(sheetContext, m),
263+
),
264+
],
265+
),
266+
),
267+
);
268+
}

app/lib/ui/session/session_screen.dart

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class _SessionScreenState extends ConsumerState<SessionScreen> {
4141
Widget build(BuildContext context) {
4242
final session = ref.watch(sessionsProvider).byId(widget.sessionId);
4343
final items = ref.watch(chatItemsProvider(widget.sessionId));
44+
final meta = ref.watch(sessionMetaProvider(widget.sessionId));
4445

4546
if (items.isNotEmpty && items.last.seq != _lastSeq) {
4647
final firstLoad = _lastSeq == 0;
@@ -173,26 +174,41 @@ class _SessionScreenState extends ConsumerState<SessionScreen> {
173174
),
174175
const SizedBox(width: 12),
175176
Expanded(
176-
child: Text(
177-
label,
178-
maxLines: 1,
179-
overflow: TextOverflow.ellipsis,
180-
style: Theme.of(context)
181-
.textTheme
182-
.titleMedium
183-
?.copyWith(
184-
fontWeight: FontWeight.w600,
185-
shadows: [
186-
Shadow(
187-
color: cs.surface,
188-
blurRadius: 6,
177+
child: Column(
178+
crossAxisAlignment: CrossAxisAlignment.start,
179+
mainAxisSize: MainAxisSize.min,
180+
children: [
181+
Text(
182+
label,
183+
maxLines: 1,
184+
overflow: TextOverflow.ellipsis,
185+
style: Theme.of(context)
186+
.textTheme
187+
.titleMedium
188+
?.copyWith(
189+
fontWeight: FontWeight.w600,
190+
shadows: [
191+
Shadow(color: cs.surface, blurRadius: 6),
192+
Shadow(color: cs.surface, blurRadius: 12),
193+
],
189194
),
190-
Shadow(
191-
color: cs.surface,
192-
blurRadius: 12,
193-
),
194-
],
195+
),
196+
if (meta?.model != null)
197+
Text(
198+
'${meta!.model!.name} · ${meta.thinking}',
199+
maxLines: 1,
200+
overflow: TextOverflow.ellipsis,
201+
style: Theme.of(context)
202+
.textTheme
203+
.bodySmall
204+
?.copyWith(
205+
color: cs.onSurface.withValues(alpha: 0.55),
206+
shadows: [
207+
Shadow(color: cs.surface, blurRadius: 6),
208+
],
209+
),
195210
),
211+
],
196212
),
197213
),
198214
const SizedBox(width: 8),

app/test/store_reducer_test.dart

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,28 @@ void main() {
7171
expect(state.commands[_sid]!.single.name, 'fix');
7272
});
7373

74+
test('session.meta advances cursor, stores meta, adds no chat item', () {
75+
var state = _seeded();
76+
state = reduce(
77+
state,
78+
SessionEventFrame(_ev(1, EventKind.sessionMeta, {
79+
'model': {'provider': 'anthropic', 'id': 'opus', 'name': 'Opus'},
80+
'thinking': 'high',
81+
'models': [
82+
{'provider': 'anthropic', 'id': 'opus', 'name': 'Opus'},
83+
{'provider': 'anthropic', 'id': 'sonnet', 'name': 'Sonnet'},
84+
],
85+
})),
86+
);
87+
88+
expect(state.cursors[_sid], 1);
89+
expect(state.events[_sid] ?? const [], isEmpty);
90+
final meta = state.meta[_sid]!;
91+
expect(meta.model!.name, 'Opus');
92+
expect(meta.thinking, 'high');
93+
expect(meta.models.length, 2);
94+
});
95+
7496
test('session.status + message preview bubble up to the session', () {
7597
var state = _seeded();
7698
state = reduce(

0 commit comments

Comments
 (0)