claude: accepting ExitPlanMode exits plan mode in the panel (T-337)

ExitPlanMode arrives as a can_use_tool permission prompt and was approved
like any other tool — the control_response was sent but the tracked
SessionStatus.permissionMode never changed, so the mode indicator and
composer stayed on "plan" after the plan was accepted.

On approving an ExitPlanMode prompt, sync the tracked mode to 'default'
(the CLI performs the transition itself, so no set_permission_mode control
request is sent — we only mirror it). The change rides the existing
statusStream → claude_pane._status plumbing, so the permission-mode
control and status indicator update with no extra wiring. Deny, and any
non-ExitPlanMode tool, leave the mode untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 14:34:50 +02:00
co-authored by Claude Opus 4.8
parent 430189d714
commit ce200765cc
5 changed files with 72 additions and 0 deletions
@@ -3528,3 +3528,5 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
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 ('06FB44SKPKTHFMV6WD28GZYPXM', 'status', 'ready', 'in_progress', NULL, '2026-06-11 10:47:15', '2026-06-11 10:47:15', '2026-06-11 10:47:15', NULL, '4ac3982fdaada5c776320c99cbf0763c', 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 ('06FB493JEW32CH0H3771TNHF7G', 'status', 'ready', 'in_progress', NULL, '2026-06-11 10:49:25', '2026-06-11 10:49:25', '2026-06-11 10:49:25', NULL, 'c4fca969ce9e6d31122706878554bbdb', 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 ('06FB3DWCJSGZH9WYDNFWZBAYYR', 'status', 'ready', 'in_progress', NULL, '2026-06-11 10:49:33', '2026-06-11 10:49:33', '2026-06-11 10:49:33', NULL, '6180a1491ff29428974ca84c0de4ebb0', 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 ('06FB44SKPKTHFMV6WD28GZYPXM', 'status', 'in_progress', 'done', NULL, '2026-06-11 11:27:00', '2026-06-11 11:27:00', '2026-06-11 11:27:00', NULL, 'b0cf6bb97619a79a419caf4287fe2a54', 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 ('06FB3DWCJSGZH9WYDNFWZBAYYR', 'status', 'in_progress', 'in_progress', NULL, '2026-06-11 11:27:07', '2026-06-11 11:27:07', '2026-06-11 11:27:07', NULL, '00d5d9b5cf592f3a3fc63fbef7a8b8f2', 2) ON CONFLICT(hash) DO NOTHING;
+17
View File
@@ -3311,3 +3311,20 @@ Where to look:
Repro: enter plan mode, let Claude present a plan via ExitPlanMode, accept it the pane stays showing plan mode.
Acceptance: accepting ExitPlanMode leaves plan mode in the conversation panel (mode indicator + behavior update), matching what the underlying session does.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 13:27:36', '2026-06-11 10:49:33', NULL, '96c4174428163a4998490d15c8463149', 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 ('06FB44SKPKTHFMV6WD28GZYPXM', 'task', NULL, 'Support double-tap / bare-modifier chords (JetBrains Search Everywhere)', 'The KeyChord matcher can''t represent a bare or double-tapped modifier: KeyChord.fromKeyEvent returns null for bare modifier presses (key_chord.dart _isBareModifier), and KeyChord.parse requires a base key after the last ''+'', so ''shift+shift'' / ''shift shift'' both fail. Consequence: JetBrains ''Search Everywhere'' (double-Shift) and any double-tap-modifier shortcut can''t be bound.
Decide (design): is search-everywhere its own intent + overlay, or an alias of quick-open? Then extend the chord/sequence matcher to represent a bare-modifier ''key'' (and a double-tap as a sequence) so a preset can bind it.
Surfaced by T-64/T-66 (VS Code / JetBrains keymap presets). Those tickets'' ''see Q-9'' reference is STALE Q-9 is ''Lua runtime vendoring'', unrelated. The JetBrains preset currently maps quick-open to Ctrl+Shift+N (Go to File) and the palette to Ctrl+Shift+A (Find Action) as the expressible IntelliJ equivalents.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 15:07:43', '2026-06-11 11:27:00', NULL, 'f6605c8b126930cccf4be2d74efa6c4d', 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 ('06FB3DWCJSGZH9WYDNFWZBAYYR', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'ExitPlanMode does not exit plan mode in the conversation panel', 'Observed live: Claude used the ExitPlanMode tool (approved the plan / left plan mode), but the clide conversation panel did not reflect the exit — it still appears to be in plan mode afterward.
Expected: once ExitPlanMode is accepted, the pane leaves plan mode the permission-mode indicator (claude_status / permission_mode_control) flips off ''plan'', and the composer / status reflect the new mode.
Where to look:
- How the stream-json session handles the ExitPlanMode tool/control event (stream_json_session.dart) and whether it updates the tracked permission mode (claude_status.dart permissionMode).
- The permission-mode control + status badge wiring (permission_mode_control.dart, claude_pane.dart) does an ExitPlanMode resolution feed back into the mode the way Ctrl/Cmd+M and the picker do?
- Whether ExitPlanMode arrives as a permission prompt (ToolPromptCard) and, if approved, actually transitions the session out of plan mode rather than just resolving the tool.
Repro: enter plan mode, let Claude present a plan via ExitPlanMode, accept it the pane stays showing plan mode.
Acceptance: accepting ExitPlanMode leaves plan mode in the conversation panel (mode indicator + behavior update), matching what the underlying session does.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 13:27:36', '2026-06-11 11:27:07', NULL, 'c1a9ecdf23d66e94b96feb08d6f93d60', 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);
+8
View File
@@ -16,6 +16,14 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
## [Unreleased]
### Fixed
- **Accepting ExitPlanMode now leaves plan mode in the conversation panel.**
Approving Claude's plan (the ExitPlanMode tool) transitioned the underlying
session out of plan mode, but clide's tracked permission mode didn't follow,
so the mode indicator and composer stayed stuck on "plan". The approval now
syncs the tracked mode to `default`. (T-337)
### Added
- **Double-tap-modifier shortcuts (e.g. double-Shift "Search Everywhere").**
@@ -570,6 +570,14 @@ class StreamJsonSession {
}),
);
if (decision is AllowTool) {
// Approving ExitPlanMode leaves plan mode. The CLI performs the
// transition itself on the approval, so we don't send a
// set_permission_mode control request — we just sync our tracked status
// (exits to 'default', matching Claude Code) so the permission-mode
// indicator and composer reflect the change (T-337).
if (prompt.toolName == 'ExitPlanMode') {
_mergeStatus(const SessionStatus(permissionMode: 'default'));
}
// The prompt card is ephemeral (it vanishes once resolved), so leave a
// compact record of an answered question in the conversation log (D-78).
if (prompt.isQuestion) {
@@ -478,6 +478,43 @@ void main() {
expect(session.toolUseOutcomes['toolu_1'], isFalse);
});
String planInit() => jsonEncode({'type': 'system', 'subtype': 'init', 'model': 'claude-opus-4-7', 'permissionMode': 'plan'});
test('approving ExitPlanMode leaves plan mode (T-337)', () async {
proc.emit(planInit());
await Future<void>.delayed(Duration.zero);
expect(statuses.last.permissionMode, 'plan');
proc.emit(canUseTool('exit-1', tool: 'ExitPlanMode', input: {'plan': 'do the thing'}));
await Future<void>.delayed(Duration.zero);
final p = session.pendingPrompt!;
expect(p.toolName, 'ExitPlanMode');
session.resolvePrompt(p.promptId, AllowTool(p.input));
await Future<void>.delayed(Duration.zero);
expect(statuses.last.permissionMode, 'default', reason: 'approving ExitPlanMode must exit plan mode');
});
test('denying ExitPlanMode stays in plan mode (T-337)', () async {
proc.emit(planInit());
await Future<void>.delayed(Duration.zero);
proc.emit(canUseTool('exit-2', tool: 'ExitPlanMode', input: {'plan': 'x'}));
await Future<void>.delayed(Duration.zero);
session.resolvePrompt(session.pendingPrompt!.promptId, const DenyTool('keep planning'));
await Future<void>.delayed(Duration.zero);
expect(statuses.last.permissionMode, 'plan', reason: 'a denied plan-exit keeps plan mode');
});
test('approving a non-ExitPlanMode tool does not change plan mode (T-337)', () async {
proc.emit(planInit());
await Future<void>.delayed(Duration.zero);
proc.emit(canUseTool('w1')); // a Write
await Future<void>.delayed(Duration.zero);
session.resolvePrompt(session.pendingPrompt!.promptId, AllowTool(const {}));
await Future<void>.delayed(Duration.zero);
expect(statuses.last.permissionMode, 'plan', reason: 'only ExitPlanMode exits plan mode');
});
test('resolvePrompt(deny) writes a deny decision with a message', () async {
proc.emit(canUseTool('req-3'));
await Future<void>.delayed(Duration.zero);