@@ -1,6 +1,8 @@
1package dialog
1package dialog
4 "charm.land/bubbles/v2/key"
6 "charm.land/bubbles/v2/key"
5 tea "charm.land/bubbletea/v2"
7 tea "charm.land/bubbletea/v2"
6 "charm.land/lipgloss/v2"
8 "charm.land/lipgloss/v2"
@@ -47,9 +49,26 @@ type LoadingDialog interface {
47 StopLoading()
49 StopLoading()
52+// Grace period constants for dialogs that open asynchronously and may
53+// receive in-flight keystrokes from a previously focused component.
55+ // graceQuietPeriod is how long input must be quiet before the dialog
56+ // arms. Each absorbed keystroke resets this timer.
57+ graceQuietPeriod = 200 * time.Millisecond
58+ // graceMaxDelay is the absolute ceiling: the dialog always arms after
59+ // this duration regardless of input activity. Prevents auto-repeat
60+ // from keeping the dialog disarmed indefinitely.
61+ graceMaxDelay = 1500 * time.Millisecond
50// Overlay manages multiple dialogs as an overlay.
64// Overlay manages multiple dialogs as an overlay.
51type Overlay struct {
65type Overlay struct {
52 dialogs []Dialog
66 dialogs []Dialog
68+ // Grace period state for the front dialog. Only active when the
69+ // dialog was opened via OpenDialogWithGrace.
70+ graceOpenedAt time.Time
71+ graceLastInputAt time.Time
55// NewOverlay creates a new [Overlay] instance.
74// NewOverlay creates a new [Overlay] instance.
@@ -77,6 +96,36 @@ func (d *Overlay) ContainsDialog(dialogID string) bool {
77// OpenDialog opens a new dialog to the stack.
96// OpenDialog opens a new dialog to the stack.
78func (d *Overlay) OpenDialog(dialog Dialog) {
97func (d *Overlay) OpenDialog(dialog Dialog) {
79 d.dialogs = append(d.dialogs, dialog)
98 d.dialogs = append(d.dialogs, dialog)
99+ d.graceOpenedAt = time.Time{}
100+ d.graceLastInputAt = time.Time{}
103+// OpenDialogWithGrace opens a dialog with an input grace period. All
104+// keystrokes are absorbed until either the input has been quiet for
105+// graceQuietPeriod or graceMaxDelay has elapsed since opening, whichever
106+// comes first. Use this for dialogs that open asynchronously (e.g.
107+// permission prompts) where in-flight keystrokes from a previously
108+// focused component could act on the dialog before the user sees it.
109+func (d *Overlay) OpenDialogWithGrace(dialog Dialog) {
111+ d.dialogs = append(d.dialogs, dialog)
112+ d.graceOpenedAt = now
113+ d.graceLastInputAt = now
116+// inGracePeriod reports whether the front dialog is still within its
117+// input grace period. Returns false if no grace period is active.
118+func (d *Overlay) inGracePeriod() bool {
119+ if d.graceOpenedAt.IsZero() {
122+ if time.Since(d.graceOpenedAt) >= graceMaxDelay {
125+ if time.Since(d.graceLastInputAt) >= graceQuietPeriod {
82// CloseDialog closes the dialog with the specified ID from the stack.
131// CloseDialog closes the dialog with the specified ID from the stack.
@@ -97,6 +146,15 @@ func (d *Overlay) CloseFrontDialog() {
97 d.removeDialog(len(d.dialogs) - 1)
146 d.removeDialog(len(d.dialogs) - 1)
149+func (d *Overlay) removeDialog(idx int) {
150+ d.dialogs = append(d.dialogs[:idx], d.dialogs[idx+1:]...)
151+ // Clear grace state when the front dialog changes.
152+ if idx == len(d.dialogs) {
153+ d.graceOpenedAt = time.Time{}
154+ d.graceLastInputAt = time.Time{}
100// Dialog returns the dialog with the specified ID, or nil if not found.
158// Dialog returns the dialog with the specified ID, or nil if not found.
101func (d *Overlay) Dialog(dialogID string) Dialog {
159func (d *Overlay) Dialog(dialogID string) Dialog {
102 for _, dialog := range d.dialogs {
160 for _, dialog := range d.dialogs {
@@ -133,6 +191,12 @@ func (d *Overlay) Update(msg tea.Msg) tea.Msg {
133 return nil
191 return nil
194+ // Absorb keystrokes during the grace period for async dialogs.
195+ if _, ok := msg.(tea.KeyPressMsg); ok && d.inGracePeriod() {
196+ d.graceLastInputAt = time.Now()
136 idx := len(d.dialogs) - 1 // active dialog is the last one
200 idx := len(d.dialogs) - 1 // active dialog is the last one
137 dialog := d.dialogs[idx]
201 dialog := d.dialogs[idx]
138 if dialog == nil {
202 if dialog == nil {
@@ -203,11 +267,3 @@ func (d *Overlay) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
204 return cur
268 return cur
207−// removeDialog removes a dialog from the stack.
208−func (d *Overlay) removeDialog(idx int) {
209− if idx < 0 || idx >= len(d.dialogs) {
212− d.dialogs = append(d.dialogs[:idx], d.dialogs[idx+1:]...)