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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user