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