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:
2026-06-12 12:56:09 +02:00
co-authored by Claude Fable 5
parent 02c6dd4cf0
commit 1bdd88f4ab
14 changed files with 208 additions and 16 deletions
@@ -4324,3 +4324,5 @@ the whole Claude sidebar (Activity/Team/Config): bump the row/label typography t
the panel scale used elsewhere, give sections clearer hierarchy (headers, spacing,
accent marks per ui-design tokens), and make the controls feel like controls.
Treat ui-design skill as the reference for token/type choices.', NULL, '2026-06-12 10:24:43', '2026-06-12 10:24:43', '2026-06-12 10:24:43', NULL, '5e15c4d2cdabba62bbaba701a93f6e79', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3GM6V0RZBY2PXE9ZQFR88', 'status', 'in_progress', 'done', NULL, '2026-06-12 10:25:53', '2026-06-12 10:25:53', '2026-06-12 10:25:53', NULL, 'f65cf1d99c1d22ced8d18e1579b048ad', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3J7TXMG0F9E2WQDENPVJG', 'status', 'backlog', 'in_progress', NULL, '2026-06-12 10:25:53', '2026-06-12 10:25:53', '2026-06-12 10:25:53', NULL, 'dca846f23cb4353cc826ddd79a6c14f0', 2) ON CONFLICT(hash) DO NOTHING;
+8
View File
@@ -4703,3 +4703,11 @@ Treat ui-design skill as the reference for token/type choices.', 'backlog', 'med
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Surface Claude Code Workflow runs in the convo + status surfaces', 'Claude Code''s Workflow mechanism (multi-agent orchestration: wf_<id> runs, phases, agent fan-outs, live progress) currently renders in clide as ordinary tool-use cards at best. Integrate it properly: (1) CONVO PANEL — recognise a Workflow tool-use and render a dedicated run card: phase groups, per-agent rows with live status, and the run''s result summary; reuse the collapser/agent-card machinery (T-305/T-342) rather than inventing new chrome. (2) STATUS/SIDEBAR — while a workflow runs, show an aggregate indicator (run id, phase, agents active/done) in the Claude sidebar Activity tab and/or status line.
SPIKE FIRST: capture what the stream-json wire actually emits during a Workflow run (tool_use input shape, progress/notification events, sidechain attribution for workflow-spawned agents) same probe method as T-410''s. Scope the rendering to what the wire really carries; if progress only exists in the harness UI and not on the wire, document that limit and render what''s available (start/end + result). Filed from user request 2026-06-12.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 10:25:00', '2026-06-12 10:25:00', NULL, '579c8318c3c217ae5bbbc90661dbe917', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3GM6V0RZBY2PXE9ZQFR88', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Slash-command routing table + graceful TUI-only capture', 'Replace kClideOwnedCommands (a bare Set) with a declarative routing table: token → forward | owned | unavailable. ''forward'' is probe-driven (the live initialize handshake''s slash_commands — skills + headless builtins); ''owned'' dispatches to clide implementations (clear/resume/fork/model today, growing per sibling stories); ''unavailable'' is a curated catalog of known TUI-only commands (doctor, login, logout, terminal-setup, ide, exit, vim, statusline, output-style, …) that get a friendly card explaining the clide equivalent — never forwarded.
Plus the reactive catch-all: a system/local_command event whose stdout matches "isn''t available in this environment" renders as a muted hint card (suggesting the sidebar control when the routing table maps one) instead of raw CLI error text. This auto-captures future/unknown TUI commands without catalog maintenance.
Where: slash_commands.dart (table), claude_pane._send (dispatch), stream_json_session/transcript handling for the local_command catch, conversation card for the hint. Tests: routing per class; the catch-all renders the hint card; advertised-but-unknown forwards untouched; typeahead lists owned + advertised, not ''unavailable'' ones (or lists them greyed).', 'done', 'high', NULL, NULL, NULL, '2026-06-12 08:58:42', '2026-06-12 10:25:53', NULL, 'd1aa3a143b9db8758ca20ba5197d9332', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3J7TXMG0F9E2WQDENPVJG', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Own /effort: control-request spike + arg/picker UX', 'Mirror T-408''s /model treatment for /effort (thinking-effort level). SPIKE FIRST (same method as the T-408 set_model spike against claude 2.1.175): does the stream-json control channel accept a set_effort-style control_request subtype? Probe candidate subtypes alongside set_permission_mode/set_model; document the result in the ticket. If no control subtype exists, decide a fallback explicitly — settings-file write + session restart, or declare it unavailable-with-explanation — don''t fake it.
UX when supported: ''/effort <level>'' sets directly (optimistic status merge, toast rollback on rejection, exactly the T-408 pattern); bare ''/effort'' swaps a picker card into the interaction zone (D-78). Surface the current effort in SessionStatus + the sidebar config row, and as an inline selector per the power-panel story. Levels per CLI docs (e.g. low/medium/high; verify against the CLI version).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-12 08:58:55', '2026-06-12 10:25:53', NULL, 'd0b0dbfec1edcdd4ffe78372f440cf1b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+4
View File
@@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added
- **`/effort` works in the Claude pane.** With a level (`/effort xhigh`) the
session restarts in place carrying `--effort` — resume keeps the
conversation; bare `/effort` opens a picker with the five levels and the
current one marked. The active effort shows in the session status. (T-412)
- **TUI-only slash commands get a helpful notice instead of failing.** A typed
`/effort`, `/status`, `/permissions`, … no longer errors raw from the CLI or
leaks to the model as literal text — clide routes known TUI-only commands to
+68 -2
View File
@@ -91,6 +91,11 @@ class _ClaudePaneState extends State<ClaudePane> {
/// (T-408). An open prompt takes precedence; the picker shows once it
/// resolves.
bool _modelPickerOpen = false;
bool _effortPickerOpen = false;
/// Effort level this pane's session runs at (`--effort`, T-412). Null =
/// the CLI default. Set by /effort; carried by every respawn.
String? _effort;
bool _spawned = false;
@@ -249,6 +254,7 @@ class _ClaudePaneState extends State<ClaudePane> {
_modelErrorSub?.cancel();
_modelErrorSub = null;
_modelPickerOpen = false;
_effortPickerOpen = false;
await activeSessionOrchestrator?.close(_orchId); // kills the old repo's session
_conversation = null;
_session = null;
@@ -296,7 +302,14 @@ class _ClaudePaneState extends State<ClaudePane> {
_sessionId ??= freshSessionId();
try {
managed = await orch.spawn(
SpawnSpec(id: _orchId, role: 'fork ${widget.secondaryIndex}', sessionId: _sessionId!, cwd: repoRoot, forkSourceSessionId: forkSource),
SpawnSpec(
id: _orchId,
role: 'fork ${widget.secondaryIndex}',
sessionId: _sessionId!,
cwd: repoRoot,
forkSourceSessionId: forkSource,
effort: _effort,
),
);
} catch (e) {
if (mounted) setState(() => _error = 'Could not start fork: $e');
@@ -327,6 +340,7 @@ class _ClaudePaneState extends State<ClaudePane> {
cwd: repoRoot,
resume: resume,
transcriptPath: resume ? transcriptFile : null,
effort: _effort,
),
);
} catch (e) {
@@ -339,6 +353,9 @@ class _ClaudePaneState extends State<ClaudePane> {
_session = managed.session;
_conversation = managed.conversation;
// The wire never reports effort — record what this session was spawned
// with so the status line / sidebar can show it (T-412).
if (_effort != null) managed.session.noteEffort(_effort!);
// Diagnostic (T-274 follow-up): record how this pane bound its session —
// a fresh spawn vs connecting to existing on-disk history (the seed read
// from the transcript/sidecar). Surfaces the resume path in `make run`.
@@ -397,6 +414,9 @@ class _ClaudePaneState extends State<ClaudePane> {
case 'model':
_modelCommand(slashCommandArg(text) ?? '');
return;
case 'effort':
_effortCommand(slashCommandArg(text) ?? '');
return;
}
// Route the rest (T-411): a known TUI-only builtin never reaches the
// session — forwarded it would error (or, un-advertised, bracket-paste to
@@ -431,6 +451,41 @@ class _ClaudePaneState extends State<ClaudePane> {
_composerFocus.requestFocus();
}
/// clide-owned `/effort` (T-412): with a level, respawn-with-resume carrying
/// `--effort`; bare, open the picker. No set_effort control subtype exists
/// (probed 2.1.175), so the respawn IS the mechanism — resume keeps the
/// conversation, only the process restarts.
void _effortCommand(String arg) {
if (_session == null) return;
if (arg.isEmpty) {
setState(() => _effortPickerOpen = true);
return;
}
if (!kEffortLevels.any((l) => l.value == arg)) {
_session!.addLocalNotice('unknown effort "$arg" — levels: ${kEffortLevels.map((l) => l.value).join(', ')}');
return;
}
_setEffort(arg);
}
void _pickEffort(String value) {
_closeEffortPicker();
_setEffort(value);
}
void _closeEffortPicker() {
setState(() => _effortPickerOpen = false);
_composerFocus.requestFocus();
}
void _setEffort(String level) {
final sid = _sessionId;
if (sid == null) return;
_effort = level;
_kernel?.notify.info('effort $level — restarting the session to apply', title: 'effort');
unawaited(_respawnWithSession(sid));
}
/// Record a submitted prompt in the active session's history (T-163),
/// de-duping immediate repeats. Empty/whitespace prompts are skipped.
void _appendHistory(String text) {
@@ -455,7 +510,7 @@ class _ClaudePaneState extends State<ClaudePane> {
/// background tap must never pull focus from (or resurrect) the composer
/// over an open prompt.
void _focusComposerOnTap() {
if (_session?.pendingPrompt != null || _modelPickerOpen) return;
if (_session?.pendingPrompt != null || _modelPickerOpen || _effortPickerOpen) return;
_composerFocus.requestFocus();
}
@@ -530,6 +585,7 @@ class _ClaudePaneState extends State<ClaudePane> {
_modelErrorSub?.cancel();
_modelErrorSub = null;
_modelPickerOpen = false;
_effortPickerOpen = false;
await activeSessionOrchestrator?.close(_orchId); // kills the old session
// Erase only after the process is dead, so claude isn't mid-write.
final root = _repoRoot;
@@ -612,6 +668,16 @@ class _ClaudePaneState extends State<ClaudePane> {
onPick: _pickModel,
onCancel: _closeModelPicker,
)
else if (_effortPickerOpen && _session != null)
ModelPickerCard(
title: 'effort',
models: kEffortLevels,
currentModel: _status.effort,
// Exact match — containment would mark `high` inside `xhigh`.
isCurrent: (o, c) => c != null && o.value == c,
onPick: _pickEffort,
onCancel: _closeEffortPicker,
)
else
StreamBuilder<bool>(
stream: _session?.busyStream,
+21 -4
View File
@@ -25,7 +25,15 @@ bool modelOptionIsCurrent(ModelOption option, String? currentModel) {
}
class ModelPickerCard extends StatefulWidget {
const ModelPickerCard({super.key, required this.models, this.currentModel, required this.onPick, required this.onCancel});
const ModelPickerCard({
super.key,
required this.models,
this.currentModel,
required this.onPick,
required this.onCancel,
this.title = 'model',
this.isCurrent = modelOptionIsCurrent,
});
/// Selectable entries, in display order. Callers pass [kFallbackModels]
/// when the session hasn't reported its list yet.
@@ -40,6 +48,15 @@ class ModelPickerCard extends StatefulWidget {
/// Called when the user dismisses the picker without choosing.
final VoidCallback onCancel;
/// Header label. The /effort picker reuses this card with its own title
/// and an exact-match [isCurrent] (T-412).
final String title;
/// Marks the active entry. The model default ([modelOptionIsCurrent]) also
/// alias-matches (`sonnet` ⊂ `claude-sonnet-4-6`); effort needs exact match
/// (`high` would falsely match inside `xhigh`).
final bool Function(ModelOption option, String? current) isCurrent;
@override
State<ModelPickerCard> createState() => _ModelPickerCardState();
}
@@ -49,7 +66,7 @@ class _ModelPickerCardState extends State<ModelPickerCard> {
int _initialHighlight() {
for (var i = 0; i < widget.models.length; i++) {
if (modelOptionIsCurrent(widget.models[i], widget.currentModel)) return i;
if (widget.isCurrent(widget.models[i], widget.currentModel)) return i;
}
return 0;
}
@@ -130,7 +147,7 @@ class _ModelPickerCardState extends State<ModelPickerCard> {
children: [
Row(
children: [
ClideText('model', fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.statusInfo),
ClideText(widget.title, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.statusInfo),
const Spacer(),
ClideText('↑↓ · 1-${widget.models.length} · Enter · Esc', fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
],
@@ -152,7 +169,7 @@ class _ModelPickerCardState extends State<ModelPickerCard> {
Widget _row(SurfaceTokens tokens, int i) {
final m = widget.models[i];
final current = modelOptionIsCurrent(m, widget.currentModel);
final current = widget.isCurrent(m, widget.currentModel);
final highlighted = i == _highlight;
return Padding(
padding: const EdgeInsets.only(bottom: 4),
@@ -49,6 +49,7 @@ class SpawnSpec {
this.team = false,
this.memberName,
this.forkSourceSessionId,
this.effort,
});
final String id;
@@ -81,6 +82,12 @@ class SpawnSpec {
/// Takes precedence over [resume]/[sessionId] for arg selection.
final String? forkSourceSessionId;
/// Effort level passed to `claude --effort` (low/medium/high/xhigh/max,
/// T-412). Null spawns without the flag — the CLI uses its configured
/// default (settings.json `effortLevel`). No set_effort control subtype
/// exists, so changing effort means respawn-with-resume carrying this.
final String? effort;
/// Whether this spec spawns a forked session.
bool get isFork => forkSourceSessionId != null;
}
@@ -238,7 +245,13 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
preambles.add(_teamSystemPrompt(name, spec.role));
}
final bootstrap = agentBootstrap(spec.cwd, base: spec.env);
sessionArgs = ['--append-system-prompt', preambles.join('\n\n'), ...bootstrap.extraArgs, ...sessionArgs];
sessionArgs = [
'--append-system-prompt',
preambles.join('\n\n'),
...bootstrap.extraArgs,
if (spec.effort != null) ...['--effort', spec.effort!],
...sessionArgs,
];
final proc = await _factory(sessionArgs: sessionArgs, cwd: spec.cwd, env: bootstrap.envDelta);
final session = StreamJsonSession(proc, mcpServers: mcpServers)..start();
+5 -3
View File
@@ -32,8 +32,10 @@ bool isKnownSlashCommand(String text, Iterable<String> known) {
/// transcript reader can't follow, so clide owns the semantics (T-156).
/// `/fork` branches the current session into a new pane (T-172). `/model`
/// is interactive in the CLI's TUI only — forwarded it does nothing — so
/// clide owns it as a set_model control request / picker (T-408).
const Set<String> kClideOwnedCommands = {'clear', 'resume', 'fork', 'model'};
/// clide owns it as a set_model control request / picker (T-408). `/effort`
/// has no control subtype, so clide owns it as a respawn-with-resume
/// carrying `--effort` (T-412).
const Set<String> kClideOwnedCommands = {'clear', 'resume', 'fork', 'model', 'effort'};
/// The clide-owned command in [text] (a single-line leading-slash token in
/// [kClideOwnedCommands]), or null.
@@ -64,7 +66,7 @@ enum SlashRoute {
/// model as literal text). Value = the clide-native pointer shown in the
/// notice card. Commands clide later implements move to [kClideOwnedCommands].
const Map<String, String> kTuiOnlyCommands = {
'effort': 'the session effort level is set at spawn time; clide support is tracked in T-412',
'effort': '', // owned (T-412) — only routes here if ever removed from owned
'status': 'session status lives in the Claude sidebar (Activity tab)',
'cost': 'cost and context usage live in the Claude sidebar (Activity tab)',
'context': '', // advertised on current CLIs — only routes here on older ones
@@ -162,6 +162,18 @@ class ModelOption {
final String description;
}
/// Effort levels `claude --effort` accepts (probed against 2.1.175). There is
/// NO set_effort control subtype (probed: rejected), so changing effort
/// respawns the session with the flag — resume keeps the conversation (T-412).
/// Expressed as [ModelOption]s so the /effort picker reuses the /model card.
const List<ModelOption> kEffortLevels = [
ModelOption(value: 'low', displayName: 'low', description: 'fastest, minimal thinking'),
ModelOption(value: 'medium', displayName: 'medium', description: 'balanced'),
ModelOption(value: 'high', displayName: 'high', description: 'thorough'),
ModelOption(value: 'xhigh', displayName: 'xhigh', description: 'deeper reasoning'),
ModelOption(value: 'max', displayName: 'max', description: 'maximum thinking budget'),
];
/// Fallback picker entries for when the `initialize` response hasn't arrived
/// (or carried no models): the stable aliases every claude build accepts
/// (T-408). `default` resets to the CLI's configured model.
@@ -801,6 +813,11 @@ class StreamJsonSession {
_items.add(AssistantTextMessage(uuid: 'local-${_localSeq++}', timestamp: DateTime.now(), isSidechain: false, text: text, synthetic: true));
}
/// Record the effort level this session was spawned with (`--effort`,
/// T-412). The wire never reports effort, so the spawner tells the status
/// what it set; the status line / sidebar read it from [SessionStatus].
void noteEffort(String level) => _mergeStatus(SessionStatus(effort: level));
/// Interrupt the running turn (the escape hatch for a runaway — D-78). Sends
/// the `interrupt` control_request; claude cancels the current turn and ends
/// it with a `result`, which clears [busy]. Safe to call when idle.
+12 -4
View File
@@ -433,7 +433,7 @@ class TranscriptReader {
/// (T-145, T-168). All fields nullable — a chunk only carries what it saw,
/// and the reader [merge]s deltas into a running status.
class SessionStatus {
const SessionStatus({this.model, this.permissionMode, this.contextTokens, this.cost, this.contextWindow, this.rateLimitInfo});
const SessionStatus({this.model, this.permissionMode, this.contextTokens, this.cost, this.contextWindow, this.rateLimitInfo, this.effort});
/// Assistant `message.model`, e.g. `claude-opus-4-7`.
final String? model;
@@ -458,7 +458,13 @@ class SessionStatus {
/// `"rate limited — resets 14:32"` (T-168). Null when not rate-limited.
final String? rateLimitInfo;
bool get isEmpty => model == null && permissionMode == null && contextTokens == null && cost == null && contextWindow == null && rateLimitInfo == null;
/// The session's effort level (`--effort`, T-412). The wire never reports
/// it — clide records what it spawned with via [StreamJsonSession.noteEffort];
/// null means the CLI default (settings.json `effortLevel`).
final String? effort;
bool get isEmpty =>
model == null && permissionMode == null && contextTokens == null && cost == null && contextWindow == null && rateLimitInfo == null && effort == null;
/// Overlay [other]'s non-null fields onto this one.
SessionStatus merge(SessionStatus other) => SessionStatus(
@@ -468,6 +474,7 @@ class SessionStatus {
cost: other.cost ?? cost,
contextWindow: other.contextWindow ?? contextWindow,
rateLimitInfo: other.rateLimitInfo ?? rateLimitInfo,
effort: other.effort ?? effort,
);
@override
@@ -478,10 +485,11 @@ class SessionStatus {
other.contextTokens == contextTokens &&
other.cost == cost &&
other.contextWindow == contextWindow &&
other.rateLimitInfo == rateLimitInfo;
other.rateLimitInfo == rateLimitInfo &&
other.effort == effort;
@override
int get hashCode => Object.hash(model, permissionMode, contextTokens, cost, contextWindow, rateLimitInfo);
int get hashCode => Object.hash(model, permissionMode, contextTokens, cost, contextWindow, rateLimitInfo, effort);
}
/// Result of [parseTranscriptChunk]: items, version-drift warnings, and
@@ -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'));
+2 -2
View File
@@ -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)', () {