diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 17a3adb3..5c1b66da 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -360,3 +360,4 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, DONE (2026-06-06). Keybinding + nav were already wired (T-117 binds ctrl/meta+shift+p -> PaletteOpenIntent; _RootShell handles it -> palette.open(); T-100 added arrow/Enter/Esc nav + selected-index). Remaining acceptance implemented now: (1) FUZZY match — PaletteController.filtered() uses a shared subsequence matcher (lib/kernel/src/fuzzy.dart, extracted from quick_open so both share one source of truth), ranked best-score-first; (2) RECENCY — invoked commands float to the top on empty filter and break fuzzy-score ties (in-session MRU). DEFERRED: ''pinned'' commands + cross-session recency persistence need a pin affordance + settings storage — filed as a follow-up. Tests: test/kernel/src/commands/palette_test.dart + test/kernel/src/fuzzy_test.dart.', NULL, '2026-06-06 07:40:43', '2026-06-06 07:40:43', '2026-06-06 07:40:43', NULL, '725ba044d75722c460c7869a2249c386', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-23', 'status', 'ready', 'done', NULL, '2026-06-06 07:40:43', '2026-06-06 07:40:43', '2026-06-06 07:40:43', NULL, '121337ddae4598659f07bbe16c1ddcc3', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-250', 'status', 'ready', 'done', NULL, '2026-06-06 07:49:08', '2026-06-06 07:49:08', '2026-06-06 07:49:08', NULL, '0765a2ccbb37deedb402d06709511928', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index a2a28b94..0410dc79 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -784,3 +784,23 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, DONE (2026-06-06). Keybinding + nav were already wired (T-117 binds ctrl/meta+shift+p -> PaletteOpenIntent; _RootShell handles it -> palette.open(); T-100 added arrow/Enter/Esc nav + selected-index). Remaining acceptance implemented now: (1) FUZZY match — PaletteController.filtered() uses a shared subsequence matcher (lib/kernel/src/fuzzy.dart, extracted from quick_open so both share one source of truth), ranked best-score-first; (2) RECENCY — invoked commands float to the top on empty filter and break fuzzy-score ties (in-session MRU). DEFERRED: ''pinned'' commands + cross-session recency persistence need a pin affordance + settings storage — filed as a follow-up. Tests: test/kernel/src/commands/palette_test.dart + test/kernel/src/fuzzy_test.dart.', 'done', 'medium', NULL, NULL, 'D-6', '2026-04-22 14:08:40', '2026-06-06 07:40:43', NULL, '0a837620f95c93871c8dcbde152adffc', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-251', 'task', 'T-4', 'Command palette: pinned commands + cross-session recency persistence (T-23 follow-up)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-06 07:40:43', '2026-06-06 07:40:43', NULL, '3d69fb0be99289aa0d9f9bdd48f02aae', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-250', 'bug', NULL, 'Permission-mode badge + Ctrl-M cycle don''t update the status bar', 'Symptom: the permission-mode badge in the Claude status line does nothing visible when clicked, and Ctrl/Cmd+M (cycle mode, T-226) likewise appears to do nothing. Either the mode isn''t actually changing, or — more likely — it changes in the Claude process but the status-bar tracker is never updated. + +Likely root cause (confirmed by reading the code): the cycle is fire-and-forget with no status feedback. +- Composer Ctrl/Cmd+M -> widget.onCycleMode -> _ClaudePaneState._cycleMode (lib/builtin/claude/src/claude_pane.dart:302) -> s.setPermissionMode(nextSafePermissionMode(...)). +- The badge click path (_ModeBadge.onCycle) routes to the same _cycleMode, so both symptoms share one cause. +- StreamJsonSession.setPermissionMode (lib/builtin/claude/src/stream_json_session.dart:671) only writes a `set_permission_mode` control_request to the process. It does NOT optimistically update `_status` nor emit on `_statusCtl`. +- `_status.permissionMode` is only ever set from a `system/init` event via `_statusFromEvent` (stream_json_session.dart:567-571). A set_permission_mode control_request returns a control_response, which is not folded into status — so `_status` never re-emits, the badge label (claude_pane.dart:105-122, driven by `_status` over statusStream) is stale, and the UI looks dead. + +So the status bar / badge value is not updated on change — matching the reported hypothesis. + +Fix direction: +- Optimistically merge the new mode into `_status` and emit on `_statusCtl` immediately after writing the control_request (mirror how other state transitions surface), and/or handle the `control_response` for set_permission_mode and reconcile from it. +- Separately verify the control_request is actually honored by the Claude process (the ''they don''t work'' branch) — if the response reports failure, surface it rather than silently assuming success. + +Acceptance: +- Clicking the badge and pressing Ctrl/Cmd+M both visibly cycle default -> accept-edits -> plan -> default in the status line. +- The displayed mode reflects the session''s actual mode (reconciled from the response / next init), not just an optimistic guess. +- Regression test at the session level: setPermissionMode emits an updated SessionStatus on statusStream. + +Refs: T-226 (interactive mode badge + Ctrl/Cmd+M), T-181 (bypassPermissions behind confirmed path — keep excluded from the safe cycle), D-78 (interaction-zone / display-only conventions).', 'done', 'high', NULL, NULL, NULL, '2026-06-06 07:26:50', '2026-06-06 07:49:08', NULL, 'e381f4325ef95d18da12a82646d24d9a', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 cbd29dfd..91371a6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -112,6 +112,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Fixed +- The permission-mode badge and Ctrl/Cmd+M now visibly cycle the mode in the + status line. The mode was changed on the session but never reflected back, so + both looked dead. (T-250) - Recent-project rows (welcome screen and the project switcher) now ellipsize a long path instead of overflowing the row — a long repo path no longer spills past the edge. (T-122) diff --git a/lib/builtin/claude/src/stream_json_session.dart b/lib/builtin/claude/src/stream_json_session.dart index c9682633..cabc2e10 100644 --- a/lib/builtin/claude/src/stream_json_session.dart +++ b/lib/builtin/claude/src/stream_json_session.dart @@ -674,6 +674,11 @@ class StreamJsonSession { 'request_id': 'set-perm-${_localSeq++}', 'request': {'subtype': 'set_permission_mode', 'mode': mode}, })); + // Optimistically reflect the change so the badge / status line update + // immediately (T-250) — the control_request emits no status event, and a + // fresh system/init only arrives later. The next init reconciles if the + // process ends up in a different mode. + _mergeStatus(SessionStatus(permissionMode: mode)); } Future dispose() async { diff --git a/test/builtin/claude/stream_json_session_test.dart b/test/builtin/claude/stream_json_session_test.dart index 04461a45..460537ba 100644 --- a/test/builtin/claude/stream_json_session_test.dart +++ b/test/builtin/claude/stream_json_session_test.dart @@ -583,6 +583,18 @@ void main() { expect(id1, isNot(equals(id2))); }); + test('setPermissionMode emits the new mode on statusStream (T-250)', () async { + // The control_request itself emits no status event; without an optimistic + // update the badge stayed stale. Each call must surface the new mode. + session.setPermissionMode('plan'); + await Future.delayed(Duration.zero); + expect(statuses.last.permissionMode, 'plan'); + + session.setPermissionMode('acceptEdits'); + await Future.delayed(Duration.zero); + expect(statuses.last.permissionMode, 'acceptEdits'); + }); + test('busy goes true on send and false on a result event', () async { final busy = []; session.busyStream.listen(busy.add);