Просмотр исходного кода

feat(tui): add debounce logic to escape key interrupt (#169)

Co-authored-by: opencode <[email protected]>
Co-authored-by: adamdottv <[email protected]>
Tom 8 месяцев назад
Родитель
Сommit
6bc61cbc2d
2 измененных файлов с 80 добавлено и 17 удалено
  1. 36 16
      packages/tui/internal/components/chat/editor.go
  2. 44 1
      packages/tui/internal/tui/tui.go

+ 36 - 16
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,
 	}
 }

+ 44 - 1
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),