Skip to content
2 changes: 1 addition & 1 deletion packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { CoreMessage, LanguageModelV1Prompt } from "ai"
import type { LanguageModelV1Prompt } from "ai"
import { unique } from "remeda"

export namespace ProviderTransform {
Expand Down
52 changes: 36 additions & 16 deletions packages/tui/internal/components/chat/editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 := ""
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
}
}
45 changes: 44 additions & 1 deletion packages/tui/internal/tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"os/exec"
"strings"
"time"

"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
Expand All @@ -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
Expand All @@ -40,6 +54,7 @@ type appModel struct {
leaderBinding *key.Binding
isLeaderSequence bool
toastManager *toast.ToastManager
interruptKeyState InterruptKeyState
}

func (a appModel) Init() tea.Cmd {
Expand Down Expand Up @@ -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))
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down