Skip to content

Commit a4ae83a

Browse files
monotykamaryopencode
andcommitted
feat(tui): add debounce logic to escape key interrupt to prevent accidental cancellations
Implements a two-stage escape key mechanism where the first press shows "esc again interrupt" and requires a second press within 1 second to actually cancel the operation. This prevents users from accidentally interrupting long-running chat operations while maintaining responsive cancellation when intentional. 🤖 Generated with [opencode](https://opencode.ai) Co-Authored-By: opencode <[email protected]>
1 parent 5d6514a commit a4ae83a

File tree

2 files changed

+69
-16
lines changed

2 files changed

+69
-16
lines changed

packages/tui/internal/components/chat/editor.go

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,19 @@ type EditorComponent interface {
3232
Newline() (tea.Model, tea.Cmd)
3333
Previous() (tea.Model, tea.Cmd)
3434
Next() (tea.Model, tea.Cmd)
35+
SetEscapeKeyInDebounce(inDebounce bool)
3536
}
3637

3738
type editorComponent struct {
38-
app *app.App
39-
width, height int
40-
textarea textarea.Model
41-
attachments []app.Attachment
42-
history []string
43-
historyIndex int
44-
currentMessage string
45-
spinner spinner.Model
39+
app *app.App
40+
width, height int
41+
textarea textarea.Model
42+
attachments []app.Attachment
43+
history []string
44+
historyIndex int
45+
currentMessage string
46+
spinner spinner.Model
47+
escapeKeyInDebounce bool
4648
}
4749

4850
func (m *editorComponent) Init() tea.Cmd {
@@ -117,7 +119,11 @@ func (m *editorComponent) Content() string {
117119

118120
hint := base("enter") + muted(" send ")
119121
if m.app.IsBusy() {
120-
hint = muted("working") + m.spinner.View() + muted(" ") + base("esc") + muted(" interrupt")
122+
if m.escapeKeyInDebounce {
123+
hint = muted("working") + m.spinner.View() + muted(" ") + base("esc again") + muted(" interrupt")
124+
} else {
125+
hint = muted("working") + m.spinner.View() + muted(" ") + base("esc") + muted(" interrupt")
126+
}
121127
}
122128

123129
model := ""
@@ -263,6 +269,10 @@ func (m *editorComponent) Next() (tea.Model, tea.Cmd) {
263269
return m, nil
264270
}
265271

272+
func (m *editorComponent) SetEscapeKeyInDebounce(inDebounce bool) {
273+
m.escapeKeyInDebounce = inDebounce
274+
}
275+
266276
func createTextArea(existing *textarea.Model) textarea.Model {
267277
t := theme.CurrentTheme()
268278
bgColor := t.BackgroundElement()
@@ -311,11 +321,12 @@ func NewEditorComponent(app *app.App) EditorComponent {
311321
ta := createTextArea(nil)
312322

313323
return &editorComponent{
314-
app: app,
315-
textarea: ta,
316-
history: []string{},
317-
historyIndex: 0,
318-
currentMessage: "",
319-
spinner: s,
324+
app: app,
325+
textarea: ta,
326+
history: []string{},
327+
historyIndex: 0,
328+
currentMessage: "",
329+
spinner: s,
330+
escapeKeyInDebounce: false,
320331
}
321332
}

packages/tui/internal/tui/tui.go

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"os"
77
"os/exec"
88
"strings"
9+
"time"
910

1011
"github.com/charmbracelet/bubbles/v2/key"
1112
tea "github.com/charmbracelet/bubbletea/v2"
@@ -25,6 +26,19 @@ import (
2526
"github.com/sst/opencode/pkg/client"
2627
)
2728

29+
// EscapeDebounceTimeoutMsg is sent when the escape key debounce timeout expires
30+
type EscapeDebounceTimeoutMsg struct{}
31+
32+
// EscapeKeyState tracks the state of escape key presses for debouncing
33+
type EscapeKeyState int
34+
35+
const (
36+
EscapeKeyIdle EscapeKeyState = iota
37+
EscapeKeyFirstPress
38+
)
39+
40+
const escapeDebounceTimeout = 1 * time.Second
41+
2842
type appModel struct {
2943
width, height int
3044
app *app.App
@@ -40,6 +54,7 @@ type appModel struct {
4054
leaderBinding *key.Binding
4155
isLeaderSequence bool
4256
toastManager *toast.ToastManager
57+
escapeKeyState EscapeKeyState
4358
}
4459

4560
func (a appModel) Init() tea.Cmd {
@@ -171,9 +186,31 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
171186
return a, nil
172187
}
173188

174-
// 6. Check again for commands that don't require leader
189+
// 6. Handle escape key debounce for session interrupt
190+
if keyString == "esc" && a.app.IsBusy() {
191+
switch a.escapeKeyState {
192+
case EscapeKeyIdle:
193+
// First escape press - start debounce timer
194+
a.escapeKeyState = EscapeKeyFirstPress
195+
a.editor.SetEscapeKeyInDebounce(true)
196+
return a, tea.Tick(escapeDebounceTimeout, func(t time.Time) tea.Msg {
197+
return EscapeDebounceTimeoutMsg{}
198+
})
199+
case EscapeKeyFirstPress:
200+
// Second escape press within timeout - actually interrupt
201+
a.escapeKeyState = EscapeKeyIdle
202+
a.editor.SetEscapeKeyInDebounce(false)
203+
return a, util.CmdHandler(commands.ExecuteCommandMsg(a.app.Commands[commands.SessionInterruptCommand]))
204+
}
205+
}
206+
207+
// 7. Check again for commands that don't require leader (excluding escape when busy)
175208
matches := a.app.Commands.Matches(msg, a.isLeaderSequence)
176209
if len(matches) > 0 {
210+
// Skip escape key interrupt if we're in debounce mode and app is busy
211+
if keyString == "esc" && a.app.IsBusy() && a.escapeKeyState != EscapeKeyIdle {
212+
return a, nil
213+
}
177214
return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
178215
}
179216

@@ -283,6 +320,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
283320
tm, cmd := a.toastManager.Update(msg)
284321
a.toastManager = tm
285322
cmds = append(cmds, cmd)
323+
case EscapeDebounceTimeoutMsg:
324+
// Reset escape key state after timeout
325+
a.escapeKeyState = EscapeKeyIdle
326+
a.editor.SetEscapeKeyInDebounce(false)
286327
}
287328

288329
// update status bar
@@ -575,6 +616,7 @@ func NewModel(app *app.App) tea.Model {
575616
showCompletionDialog: false,
576617
editorContainer: editorContainer,
577618
toastManager: toast.NewToastManager(),
619+
escapeKeyState: EscapeKeyIdle,
578620
layout: layout.NewFlexLayout(
579621
[]tea.ViewModel{messagesContainer, editorContainer},
580622
layout.WithDirection(layout.FlexDirectionVertical),

0 commit comments

Comments
 (0)