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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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