on main help sign in
feat(tui): auto close permission prompt when another client responds

When two clients share a session and one of them answers a permission prompt, the other client now sees its modal close automatically as the resolution arrives, instead of being left holding a stale dialog over an already decided request. The initial pending notification is ignored so the modal it just opened is not immediately dismissed. Co-Authored-By: Charm Crush <crush@charm.land>

commit8f9a697c0373e5975521d1b163ae4475a0719132 authorChristian Rocha <christian@rocha.is> dateTue 19 May 2026, 23:20 (5 months ago) parentd9acf86
3 files changed+119−8
internal/ui/dialog/permissions.go+6 internal/ui/model/permission_test.go+96 internal/ui/model/ui.go+17−8
internal/ui/dialog/permissions.go+6
@@ -224,6 +224,12 @@ func (*Permissions) ID() string {
224224 return PermissionsID
225225}
226226
227+// ToolCallID returns the tool call ID associated with this dialog's
228+// permission request.
229+func (p *Permissions) ToolCallID() string {
230+ return p.permission.ToolCallID
231+}
232+
227233// HandleMsg implements [Dialog].
228234func (p *Permissions) HandleMsg(msg tea.Msg) Action {
229235 switch msg := msg.(type) {
internal/ui/model/permission_test.gonew file+96
@@ -0,0 +1,96 @@
1+package model
2+
3+import (
4+ "testing"
5+
6+ "github.com/charmbracelet/crush/internal/permission"
7+ "github.com/charmbracelet/crush/internal/ui/dialog"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+// newTestUIForPermissions builds a UI with a chat, dialog overlay, and
12+// common context sufficient to exercise handlePermissionNotification.
13+func newTestUIForPermissions() *UI {
14+ u := newTestUI()
15+ u.dialog = dialog.NewOverlay()
16+ return u
17+}
18+
19+func TestHandlePermissionNotification_RemoteGrantClosesDialog(t *testing.T) {
20+ t.Parallel()
21+
22+ u := newTestUIForPermissions()
23+ perm := permission.PermissionRequest{
24+ ID: "perm-1",
25+ ToolCallID: "tool-call-X",
26+ ToolName: "bash",
27+ }
28+ u.dialog.OpenDialog(dialog.NewPermissions(u.com, perm))
29+ require.True(t, u.dialog.ContainsDialog(dialog.PermissionsID))
30+
31+ u.handlePermissionNotification(permission.PermissionNotification{
32+ ToolCallID: "tool-call-X",
33+ Granted: true,
34+ })
35+
36+ require.False(t, u.dialog.ContainsDialog(dialog.PermissionsID),
37+ "granted notification should close matching permissions dialog")
38+}
39+
40+func TestHandlePermissionNotification_RemoteDenyClosesDialog(t *testing.T) {
41+ t.Parallel()
42+
43+ u := newTestUIForPermissions()
44+ perm := permission.PermissionRequest{
45+ ID: "perm-2",
46+ ToolCallID: "tool-call-Y",
47+ }
48+ u.dialog.OpenDialog(dialog.NewPermissions(u.com, perm))
49+
50+ u.handlePermissionNotification(permission.PermissionNotification{
51+ ToolCallID: "tool-call-Y",
52+ Denied: true,
53+ })
54+
55+ require.False(t, u.dialog.ContainsDialog(dialog.PermissionsID),
56+ "denied notification should close matching permissions dialog")
57+}
58+
59+func TestHandlePermissionNotification_InitialPendingDoesNotClose(t *testing.T) {
60+ t.Parallel()
61+
62+ u := newTestUIForPermissions()
63+ perm := permission.PermissionRequest{
64+ ID: "perm-3",
65+ ToolCallID: "tool-call-Z",
66+ }
67+ u.dialog.OpenDialog(dialog.NewPermissions(u.com, perm))
68+
69+ // The initial notification published by permission.Request is
70+ // neither granted nor denied; it must not dismiss the dialog.
71+ u.handlePermissionNotification(permission.PermissionNotification{
72+ ToolCallID: "tool-call-Z",
73+ })
74+
75+ require.True(t, u.dialog.ContainsDialog(dialog.PermissionsID),
76+ "initial pending notification must not close the dialog")
77+}
78+
79+func TestHandlePermissionNotification_DifferentToolCallIDDoesNotClose(t *testing.T) {
80+ t.Parallel()
81+
82+ u := newTestUIForPermissions()
83+ perm := permission.PermissionRequest{
84+ ID: "perm-4",
85+ ToolCallID: "tool-call-A",
86+ }
87+ u.dialog.OpenDialog(dialog.NewPermissions(u.com, perm))
88+
89+ u.handlePermissionNotification(permission.PermissionNotification{
90+ ToolCallID: "tool-call-B",
91+ Granted: true,
92+ })
93+
94+ require.True(t, u.dialog.ContainsDialog(dialog.PermissionsID),
95+ "notification for unrelated tool call must not close the dialog")
96+}
internal/ui/model/ui.go+17−8
@@ -3392,16 +3392,25 @@ func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
33923392
33933393// handlePermissionNotification updates tool items when permission state changes.
33943394func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
3395− toolItem := m.chat.MessageItem(notification.ToolCallID)
3396− if toolItem == nil {
3397− return
3395+ if toolItem := m.chat.MessageItem(notification.ToolCallID); toolItem != nil {
3396+ if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
3397+ if notification.Granted {
3398+ permItem.SetStatus(chat.ToolStatusRunning)
3399+ } else {
3400+ permItem.SetStatus(chat.ToolStatusAwaitingPermission)
3401+ }
3402+ }
33983403 }
33993404
3400− if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
3401− if notification.Granted {
3402− permItem.SetStatus(chat.ToolStatusRunning)
3403− } else {
3404− permItem.SetStatus(chat.ToolStatusAwaitingPermission)
3405+ // If this notification reflects a final resolution (granted or denied),
3406+ // dismiss any open permissions dialog whose tool call ID matches. This
3407+ // covers the case where another client resolved the request remotely.
3408+ if !notification.Granted && !notification.Denied {
3409+ return
3410+ }
3411+ if d := m.dialog.Dialog(dialog.PermissionsID); d != nil {
3412+ if perm, ok := d.(*dialog.Permissions); ok && perm.ToolCallID() == notification.ToolCallID {
3413+ m.dialog.CloseDialog(dialog.PermissionsID)
34053414 }
34063415 }
34073416}