From 1bdd88f4abd97c2f2fd78d10a34f67e98c9088a2 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 12 Jun 2026 12:56:09 +0200 Subject: [PATCH] own /effort: respawn-with-resume carrying --effort, picker UX (T-412) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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 `. - claude_pane: /effort 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 --- .pql/changelog/ticket_history/2026-06.sql | 2 + .pql/changelog/tickets/2026-06.sql | 8 +++ CHANGELOG.md | 4 ++ lib/builtin/claude/src/claude_pane.dart | 70 ++++++++++++++++++- lib/builtin/claude/src/model_picker_card.dart | 25 +++++-- .../claude/src/session_orchestrator.dart | 15 +++- lib/builtin/claude/src/slash_commands.dart | 8 ++- .../claude/src/stream_json_session.dart | 17 +++++ lib/builtin/claude/src/transcript_reader.dart | 16 +++-- .../claude/model_picker_card_test.dart | 20 ++++++ .../claude/session_orchestrator_test.dart | 16 +++++ test/builtin/claude/slash_commands_test.dart | 4 +- .../claude/stream_json_session_test.dart | 9 +++ .../claude/transcript_reader_test.dart | 10 +++ 14 files changed, 208 insertions(+), 16 deletions(-) diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 495f84e9..2b6146e5 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -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; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 26ada8d3..9bdd9a10 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -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_ 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 '' 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); diff --git a/CHANGELOG.md b/CHANGELOG.md index 075bbb6a..942223f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index c313fd2d..69e4bfb7 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -91,6 +91,11 @@ class _ClaudePaneState extends State { /// (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 { _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 { _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 { cwd: repoRoot, resume: resume, transcriptPath: resume ? transcriptFile : null, + effort: _effort, ), ); } catch (e) { @@ -339,6 +353,9 @@ class _ClaudePaneState extends State { _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 { 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 { _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 { /// 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 { _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 { 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( stream: _session?.busyStream, diff --git a/lib/builtin/claude/src/model_picker_card.dart b/lib/builtin/claude/src/model_picker_card.dart index 2c6c069a..a329c6bc 100644 --- a/lib/builtin/claude/src/model_picker_card.dart +++ b/lib/builtin/claude/src/model_picker_card.dart @@ -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 createState() => _ModelPickerCardState(); } @@ -49,7 +66,7 @@ class _ModelPickerCardState extends State { 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 { 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 { 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), diff --git a/lib/builtin/claude/src/session_orchestrator.dart b/lib/builtin/claude/src/session_orchestrator.dart index 5674f698..041e9f68 100644 --- a/lib/builtin/claude/src/session_orchestrator.dart +++ b/lib/builtin/claude/src/session_orchestrator.dart @@ -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(); diff --git a/lib/builtin/claude/src/slash_commands.dart b/lib/builtin/claude/src/slash_commands.dart index 775a6b7c..d81b9acc 100644 --- a/lib/builtin/claude/src/slash_commands.dart +++ b/lib/builtin/claude/src/slash_commands.dart @@ -32,8 +32,10 @@ bool isKnownSlashCommand(String text, Iterable 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 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 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 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 diff --git a/lib/builtin/claude/src/stream_json_session.dart b/lib/builtin/claude/src/stream_json_session.dart index e83cc694..353fbd27 100644 --- a/lib/builtin/claude/src/stream_json_session.dart +++ b/lib/builtin/claude/src/stream_json_session.dart @@ -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 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. diff --git a/lib/builtin/claude/src/transcript_reader.dart b/lib/builtin/claude/src/transcript_reader.dart index 3e17692d..aeb5e0be 100644 --- a/lib/builtin/claude/src/transcript_reader.dart +++ b/lib/builtin/claude/src/transcript_reader.dart @@ -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 diff --git a/test/builtin/claude/model_picker_card_test.dart b/test/builtin/claude/model_picker_card_test.dart index 32e03f1a..2ecd1fa6 100644 --- a/test/builtin/claude/model_picker_card_test.dart +++ b/test/builtin/claude/model_picker_card_test.dart @@ -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 + }); } diff --git a/test/builtin/claude/session_orchestrator_test.dart b/test/builtin/claude/session_orchestrator_test.dart index 50b68ac6..e2eaac57 100644 --- a/test/builtin/claude/session_orchestrator_test.dart +++ b/test/builtin/claude/session_orchestrator_test.dart @@ -22,14 +22,17 @@ class _FakeProc extends StreamJsonProcess { void main() { late List<_FakeProc> created; + late List> 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 (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')); diff --git a/test/builtin/claude/slash_commands_test.dart b/test/builtin/claude/slash_commands_test.dart index b67266ff..5cbbcb1a 100644 --- a/test/builtin/claude/slash_commands_test.dart +++ b/test/builtin/claude/slash_commands_test.dart @@ -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); } }); diff --git a/test/builtin/claude/stream_json_session_test.dart b/test/builtin/claude/stream_json_session_test.dart index c7a9fc25..49a0742d 100644 --- a/test/builtin/claude/stream_json_session_test.dart +++ b/test/builtin/claude/stream_json_session_test.dart @@ -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.delayed(Duration.zero); + session.noteEffort('xhigh'); + await Future.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'); diff --git a/test/builtin/claude/transcript_reader_test.dart b/test/builtin/claude/transcript_reader_test.dart index a802978b..eb5f9485 100644 --- a/test/builtin/claude/transcript_reader_test.dart +++ b/test/builtin/claude/transcript_reader_test.dart @@ -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)', () {