on main help sign in
feat(dialog): track last closed dialog in open with grace
commitf6a841eca9e9d0ec45536e292cd6f70ce8c1bf02 authorKieran Klukas <kieran@dunkirk.sh> dateMon 15 Jun 2026, 17:44 (4 months ago) parent70ef707
2 files changed+85
internal/ui/dialog/dialog.go+27 internal/ui/dialog/overlay_test.go+58
internal/ui/dialog/dialog.go+27
@@ -69,6 +69,13 @@ type Overlay struct {
6969 // dialog was opened via OpenDialogWithGrace.
7070 graceOpenedAt time.Time
7171 graceLastInputAt time.Time
72+
73+ // Track recently closed dialog IDs so that reopening the same
74+ // dialog type can skip the grace period. This prevents rapid
75+ // successive dialogs (e.g. multiple permission prompts) from
76+ // each eating a keystroke.
77+ lastClosedID string
78+ lastClosedAt time.Time
7279}
7380
7481// NewOverlay creates a new [Overlay] instance.
@@ -106,9 +113,21 @@ func (d *Overlay) OpenDialog(dialog Dialog) {
106113// comes first. Use this for dialogs that open asynchronously (e.g.
107114// permission prompts) where in-flight keystrokes from a previously
108115// focused component could act on the dialog before the user sees it.
116+//
117+// If the same dialog ID was just closed (within reopenGraceWindow),
118+// the grace period is skipped — the user is already focused on this
119+// dialog type and rapid successive prompts should not eat keystrokes.
109120func (d *Overlay) OpenDialogWithGrace(dialog Dialog) {
110121 now := time.Now()
111122 d.dialogs = append(d.dialogs, dialog)
123+
124+ // Skip grace when reopening the same dialog type immediately.
125+ if dialog.ID() == d.lastClosedID && now.Sub(d.lastClosedAt) < reopenGraceWindow {
126+ d.graceOpenedAt = time.Time{}
127+ d.graceLastInputAt = time.Time{}
128+ return
129+ }
130+
112131 d.graceOpenedAt = now
113132 d.graceLastInputAt = now
114133}
@@ -146,7 +165,15 @@ func (d *Overlay) CloseFrontDialog() {
146165 d.removeDialog(len(d.dialogs) - 1)
147166}
148167
168+// reopenGraceWindow is how long after closing a dialog we consider
169+// a reopen of the same dialog ID to be "immediate" and skip grace.
170+const reopenGraceWindow = 500 * time.Millisecond
171+
149172func (d *Overlay) removeDialog(idx int) {
173+ if idx == len(d.dialogs)-1 {
174+ d.lastClosedID = d.dialogs[idx].ID()
175+ d.lastClosedAt = time.Now()
176+ }
150177 d.dialogs = append(d.dialogs[:idx], d.dialogs[idx+1:]...)
151178 // Clear grace state when the front dialog changes.
152179 if idx == len(d.dialogs) {
internal/ui/dialog/overlay_test.go+58
@@ -129,3 +129,61 @@ func TestOverlay_NonKeyMessagesPassDuringGrace(t *testing.T) {
129129 o.Update(tea.MouseWheelMsg{})
130130 require.Len(t, d.received, 1, "non-key messages should pass through during grace")
131131}
132+
133+// TestOverlay_ReopenSameDialogSkipsGrace verifies that when a dialog is
134+// closed and immediately reopened with the same ID (e.g. successive
135+// permission prompts), the grace period is skipped so the user can
136+// continue interacting without lost keystrokes.
137+func TestOverlay_ReopenSameDialogSkipsGrace(t *testing.T) {
138+ t.Parallel()
139+
140+ d1 := &stubDialog{id: "permissions"}
141+ o := NewOverlay()
142+ o.OpenDialogWithGrace(d1)
143+
144+ // Close and immediately reopen with the same ID.
145+ o.CloseDialog("permissions")
146+ d2 := &stubDialog{id: "permissions"}
147+ o.OpenDialogWithGrace(d2)
148+
149+ // Keys should reach the new dialog immediately — no grace.
150+ o.Update(keyMsg('a'))
151+ require.Len(t, d2.received, 1, "reopened dialog with same ID should skip grace")
152+}
153+
154+// TestOverlay_ReopenDifferentDialogKeepsGrace verifies that reopening a
155+// different dialog ID still gets the normal grace period.
156+func TestOverlay_ReopenDifferentDialogKeepsGrace(t *testing.T) {
157+ t.Parallel()
158+
159+ d1 := &stubDialog{id: "permissions"}
160+ o := NewOverlay()
161+ o.OpenDialogWithGrace(d1)
162+ o.CloseDialog("permissions")
163+
164+ d2 := &stubDialog{id: "other"}
165+ o.OpenDialogWithGrace(d2)
166+
167+ o.Update(keyMsg('a'))
168+ require.Empty(t, d2.received, "different dialog ID should still have grace period")
169+}
170+
171+// TestOverlay_ReopenAfterWindowExpiresKeepsGrace verifies that if enough
172+// time passes between close and reopen, the grace period applies normally.
173+func TestOverlay_ReopenAfterWindowExpiresKeepsGrace(t *testing.T) {
174+ t.Parallel()
175+
176+ d1 := &stubDialog{id: "permissions"}
177+ o := NewOverlay()
178+ o.OpenDialogWithGrace(d1)
179+ o.CloseDialog("permissions")
180+
181+ // Backdate the close time so it falls outside the reopen window.
182+ o.lastClosedAt = time.Now().Add(-reopenGraceWindow - time.Millisecond)
183+
184+ d2 := &stubDialog{id: "permissions"}
185+ o.OpenDialogWithGrace(d2)
186+
187+ o.Update(keyMsg('a'))
188+ require.Empty(t, d2.received, "reopened dialog after window expires should have grace")
189+}