own /effort: respawn-with-resume carrying --effort, picker UX (T-412)
Spike result (probed claude 2.1.175 over stream-json): there is NO set_effort/set_thinking_effort control subtype — both are rejected. The lever is the `--effort <level>` spawn flag (low/medium/high/xhigh/max; settings.json effortLevel is the persisted default). So changing effort restarts the process: respawn-with-resume keeps the conversation and carries the flag — the same continuity /clear and /resume already rely on. - SpawnSpec.effort → orchestrator appends `--effort <level>`. - claude_pane: /effort <level> validates and respawns (toast explains the restart); bare /effort opens a picker; the pane re-applies its effort on every later respawn. Invalid level → local notice listing levels. - ModelPickerCard generalised minimally (title + isCurrent predicate) so the effort picker reuses it; effort needs exact matching because `high` is a substring of `xhigh` and alias-containment would mis-mark it. - SessionStatus.effort + StreamJsonSession.noteEffort: the wire never reports effort, so the spawner records what it set; status/sidebar read it from the normal status stream. - Routing: effort moves from the TUI-only catalog to kClideOwnedCommands. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -85,4 +85,24 @@ void main() {
|
||||
await tester.pump();
|
||||
expect(picked, isNull);
|
||||
});
|
||||
|
||||
testWidgets('effort reuse: custom title + exact-match marking (T-412)', (tester) async {
|
||||
// Exact-match isCurrent: `high` must not be marked when effort is `xhigh`.
|
||||
await tester.pumpWidget(
|
||||
harness(
|
||||
f,
|
||||
ModelPickerCard(
|
||||
title: 'effort',
|
||||
models: kEffortLevels,
|
||||
currentModel: 'xhigh',
|
||||
isCurrent: (o, c) => c != null && o.value == c,
|
||||
onPick: (_) {},
|
||||
onCancel: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('effort'), findsOneWidget); // the custom header
|
||||
expect(find.textContaining('● xhigh'), findsOneWidget);
|
||||
expect(find.textContaining('○ high'), findsOneWidget); // NOT containment-marked
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,14 +22,17 @@ class _FakeProc extends StreamJsonProcess {
|
||||
|
||||
void main() {
|
||||
late List<_FakeProc> created;
|
||||
late List<List<String>> spawnedArgs;
|
||||
late ClaudeSessionOrchestrator orch;
|
||||
|
||||
setUp(() {
|
||||
created = [];
|
||||
spawnedArgs = [];
|
||||
orch = ClaudeSessionOrchestrator(
|
||||
processFactory: ({required sessionArgs, required cwd, env}) async {
|
||||
final p = _FakeProc();
|
||||
created.add(p);
|
||||
spawnedArgs.add(sessionArgs);
|
||||
return p;
|
||||
},
|
||||
);
|
||||
@@ -37,6 +40,19 @@ void main() {
|
||||
|
||||
SpawnSpec spec(String id, {bool visible = true}) => SpawnSpec(id: id, role: id, sessionId: '$id-uuid', cwd: '/repo', visible: visible);
|
||||
|
||||
test('a spec with effort spawns claude with --effort <level> (T-412)', () async {
|
||||
await orch.spawn(SpawnSpec(id: 'e1', role: 'primary', sessionId: 'e1-uuid', cwd: '/repo', effort: 'xhigh'));
|
||||
final args = spawnedArgs.single;
|
||||
final i = args.indexOf('--effort');
|
||||
expect(i, isNonNegative, reason: 'sessionArgs: $args');
|
||||
expect(args[i + 1], 'xhigh');
|
||||
});
|
||||
|
||||
test('a spec without effort spawns without the flag (CLI default applies)', () async {
|
||||
await orch.spawn(spec('primary'));
|
||||
expect(spawnedArgs.single, isNot(contains('--effort')));
|
||||
});
|
||||
|
||||
test('spawns multiple concurrent sessions, each with its own process', () async {
|
||||
await orch.spawn(spec('primary'));
|
||||
await orch.spawn(spec('teammate:tyre'));
|
||||
|
||||
@@ -151,7 +151,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('owned beats everything', () {
|
||||
for (final t in ['/clear', '/resume', '/fork', '/model opus']) {
|
||||
for (final t in ['/clear', '/resume', '/fork', '/model opus', '/effort high']) {
|
||||
expect(routeSlashCommand(t, advertised: advertised), SlashRoute.owned, reason: t);
|
||||
}
|
||||
});
|
||||
@@ -163,7 +163,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('a known TUI-only builtin routes unavailable', () {
|
||||
for (final t in ['/effort high', '/status', '/permissions', '/doctor', '/login']) {
|
||||
for (final t in ['/status', '/permissions', '/doctor', '/login']) {
|
||||
expect(routeSlashCommand(t, advertised: advertised), SlashRoute.unavailable, reason: t);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -640,6 +640,15 @@ void main() {
|
||||
expect(statuses.last.permissionMode, 'plan', reason: 'only ExitPlanMode exits plan mode');
|
||||
});
|
||||
|
||||
test('noteEffort merges the effort level into the status (T-412)', () async {
|
||||
proc.emit(initEvent());
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
session.noteEffort('xhigh');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(statuses.last.effort, 'xhigh');
|
||||
expect(statuses.last.model, 'claude-opus-4-7'); // merge, not replace
|
||||
});
|
||||
|
||||
test('addLocalNotice emits a synthetic clide item and sends nothing (T-411)', () async {
|
||||
final before = proc.writes.length;
|
||||
session.addLocalNotice('/status is a Claude Code TUI command');
|
||||
|
||||
@@ -522,6 +522,16 @@ void main() {
|
||||
expect(const SessionStatus(contextWindow: 0).isEmpty, isFalse);
|
||||
expect(const SessionStatus(rateLimitInfo: 'rate limited').isEmpty, isFalse);
|
||||
});
|
||||
|
||||
test('effort merges, compares, and flips isEmpty (T-412)', () {
|
||||
const a = SessionStatus(model: 'm1');
|
||||
final m = a.merge(const SessionStatus(effort: 'high'));
|
||||
expect(m.effort, 'high');
|
||||
expect(m.model, 'm1');
|
||||
expect(const SessionStatus(effort: 'high'), const SessionStatus(effort: 'high'));
|
||||
expect(const SessionStatus(effort: 'high'), isNot(const SessionStatus(effort: 'max')));
|
||||
expect(const SessionStatus(effort: 'low').isEmpty, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('synthetic CLI-local output (T-411)', () {
|
||||
|
||||
Reference in New Issue
Block a user