diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 2528b71c..c70e1fc7 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -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; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 5ee67c23..718717b0 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -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); diff --git a/CHANGELOG.md b/CHANGELOG.md index c611b4c9..da3de249 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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").** diff --git a/lib/builtin/claude/src/stream_json_session.dart b/lib/builtin/claude/src/stream_json_session.dart index 942be21a..ce2d032e 100644 --- a/lib/builtin/claude/src/stream_json_session.dart +++ b/lib/builtin/claude/src/stream_json_session.dart @@ -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) { diff --git a/test/builtin/claude/stream_json_session_test.dart b/test/builtin/claude/stream_json_session_test.dart index 306c71c3..09708d71 100644 --- a/test/builtin/claude/stream_json_session_test.dart +++ b/test/builtin/claude/stream_json_session_test.dart @@ -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.delayed(Duration.zero); + expect(statuses.last.permissionMode, 'plan'); + + proc.emit(canUseTool('exit-1', tool: 'ExitPlanMode', input: {'plan': 'do the thing'})); + await Future.delayed(Duration.zero); + final p = session.pendingPrompt!; + expect(p.toolName, 'ExitPlanMode'); + + session.resolvePrompt(p.promptId, AllowTool(p.input)); + await Future.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.delayed(Duration.zero); + proc.emit(canUseTool('exit-2', tool: 'ExitPlanMode', input: {'plan': 'x'})); + await Future.delayed(Duration.zero); + session.resolvePrompt(session.pendingPrompt!.promptId, const DenyTool('keep planning')); + await Future.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.delayed(Duration.zero); + proc.emit(canUseTool('w1')); // a Write + await Future.delayed(Duration.zero); + session.resolvePrompt(session.pendingPrompt!.promptId, AllowTool(const {})); + await Future.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.delayed(Duration.zero);