diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 95a8faf1..aa2895da 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1,4 +1,4 @@ -import type { CoreMessage, LanguageModelV1Prompt } from "ai" +import type { LanguageModelV1Prompt } from "ai" import { unique } from "remeda" export namespace ProviderTransform { diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index f48dcea1..ac23eb0b 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 { @@ -117,7 +119,12 @@ func (m *editorComponent) Content() string { hint := base("enter") + 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,14 @@ 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 createTextArea(existing *textarea.Model) textarea.Model { t := theme.CurrentTheme() bgColor := t.BackgroundElement() @@ -311,11 +326,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 a3c83976..9e23805d 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)) } @@ -283,6 +321,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 @@ -575,6 +617,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),