summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorTom <[email protected]>2025-06-24 18:31:02 +0700
committerGitHub <[email protected]>2025-06-24 06:31:02 -0500
commit6bc61cbc2dab6e363b2f333c14772983dd4cd223 (patch)
tree3658e3583fbd431a8646dc988928723fd0920746
parent01d351bebeb0e7ad9b97e63bbebdc7478339018f (diff)
downloadopencode-6bc61cbc2dab6e363b2f333c14772983dd4cd223.tar.gz
opencode-6bc61cbc2dab6e363b2f333c14772983dd4cd223.zip
feat(tui): add debounce logic to escape key interrupt (#169)
Co-authored-by: opencode <[email protected]> Co-authored-by: adamdottv <[email protected]>
-rw-r--r--packages/tui/internal/components/chat/editor.go52
-rw-r--r--packages/tui/internal/tui/tui.go45
2 files changed, 80 insertions, 17 deletions
diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go
index f48dcea1b..d67a226fe 100644
--- a/packages/tui/internal/components/chat/editor.go
+++ b/packages/tui/internal/components/chat/editor.go
@@ -32,17 +32,19 @@ type EditorComponent interface {
Newline() (tea.Model, tea.Cmd)
Previous() (tea.Model, tea.Cmd)
Next() (tea.Model, tea.Cmd)
+ SetInterruptKeyInDebounce(inDebounce bool)
}
type editorComponent struct {
- app *app.App
- width, height int
- textarea textarea.Model
- attachments []app.Attachment
- history []string
- historyIndex int
- currentMessage string
- spinner spinner.Model
+ app *app.App
+ width, height int
+ textarea textarea.Model
+ attachments []app.Attachment
+ history []string
+ historyIndex int
+ currentMessage string
+ spinner spinner.Model
+ interruptKeyInDebounce bool
}
func (m *editorComponent) Init() tea.Cmd {
@@ -115,9 +117,14 @@ func (m *editorComponent) Content() string {
Background(t.BackgroundElement()).
Render(textarea)
- hint := base("enter") + muted(" send ")
+ hint := base(m.getSubmitKeyText()) + muted(" send ")
if m.app.IsBusy() {
- hint = muted("working") + m.spinner.View() + muted(" ") + base("esc") + muted(" interrupt")
+ keyText := m.getInterruptKeyText()
+ if m.interruptKeyInDebounce {
+ hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText+" again") + muted(" interrupt")
+ } else {
+ hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt")
+ }
}
model := ""
@@ -263,6 +270,18 @@ func (m *editorComponent) Next() (tea.Model, tea.Cmd) {
return m, nil
}
+func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
+ m.interruptKeyInDebounce = inDebounce
+}
+
+func (m *editorComponent) getInterruptKeyText() string {
+ return m.app.Commands[commands.SessionInterruptCommand].Keys()[0]
+}
+
+func (m *editorComponent) getSubmitKeyText() string {
+ return m.app.Commands[commands.InputSubmitCommand].Keys()[0]
+}
+
func createTextArea(existing *textarea.Model) textarea.Model {
t := theme.CurrentTheme()
bgColor := t.BackgroundElement()
@@ -311,11 +330,12 @@ func NewEditorComponent(app *app.App) EditorComponent {
ta := createTextArea(nil)
return &editorComponent{
- app: app,
- textarea: ta,
- history: []string{},
- historyIndex: 0,
- currentMessage: "",
- spinner: s,
+ app: app,
+ textarea: ta,
+ history: []string{},
+ historyIndex: 0,
+ currentMessage: "",
+ spinner: s,
+ interruptKeyInDebounce: false,
}
}
diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go
index 654cd2a52..70b8ff605 100644
--- a/packages/tui/internal/tui/tui.go
+++ b/packages/tui/internal/tui/tui.go
@@ -6,6 +6,7 @@ import (
"os"
"os/exec"
"strings"
+ "time"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
@@ -25,6 +26,19 @@ import (
"github.com/sst/opencode/pkg/client"
)
+// InterruptDebounceTimeoutMsg is sent when the interrupt key debounce timeout expires
+type InterruptDebounceTimeoutMsg struct{}
+
+// InterruptKeyState tracks the state of interrupt key presses for debouncing
+type InterruptKeyState int
+
+const (
+ InterruptKeyIdle InterruptKeyState = iota
+ InterruptKeyFirstPress
+)
+
+const interruptDebounceTimeout = 1 * time.Second
+
type appModel struct {
width, height int
app *app.App
@@ -40,6 +54,7 @@ type appModel struct {
leaderBinding *key.Binding
isLeaderSequence bool
toastManager *toast.ToastManager
+ interruptKeyState InterruptKeyState
}
func (a appModel) Init() tea.Cmd {
@@ -171,9 +186,32 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, nil
}
- // 6. Check again for commands that don't require leader
+ // 6. Handle interrupt key debounce for session interrupt
+ interruptCommand := a.app.Commands[commands.SessionInterruptCommand]
+ if interruptCommand.Matches(msg, a.isLeaderSequence) && a.app.IsBusy() {
+ switch a.interruptKeyState {
+ case InterruptKeyIdle:
+ // First interrupt key press - start debounce timer
+ a.interruptKeyState = InterruptKeyFirstPress
+ a.editor.SetInterruptKeyInDebounce(true)
+ return a, tea.Tick(interruptDebounceTimeout, func(t time.Time) tea.Msg {
+ return InterruptDebounceTimeoutMsg{}
+ })
+ case InterruptKeyFirstPress:
+ // Second interrupt key press within timeout - actually interrupt
+ a.interruptKeyState = InterruptKeyIdle
+ a.editor.SetInterruptKeyInDebounce(false)
+ return a, util.CmdHandler(commands.ExecuteCommandMsg(interruptCommand))
+ }
+ }
+
+ // 7. Check again for commands that don't require leader (excluding interrupt when busy)
matches := a.app.Commands.Matches(msg, a.isLeaderSequence)
if len(matches) > 0 {
+ // Skip interrupt key if we're in debounce mode and app is busy
+ if interruptCommand.Matches(msg, a.isLeaderSequence) && a.app.IsBusy() && a.interruptKeyState != InterruptKeyIdle {
+ return a, nil
+ }
return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
}
@@ -305,6 +343,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
tm, cmd := a.toastManager.Update(msg)
a.toastManager = tm
cmds = append(cmds, cmd)
+ case InterruptDebounceTimeoutMsg:
+ // Reset interrupt key state after timeout
+ a.interruptKeyState = InterruptKeyIdle
+ a.editor.SetInterruptKeyInDebounce(false)
}
// update status bar
@@ -597,6 +639,7 @@ func NewModel(app *app.App) tea.Model {
showCompletionDialog: false,
editorContainer: editorContainer,
toastManager: toast.NewToastManager(),
+ interruptKeyState: InterruptKeyIdle,
layout: layout.NewFlexLayout(
[]tea.ViewModel{messagesContainer, editorContainer},
layout.WithDirection(layout.FlexDirectionVertical),