summaryrefslogtreecommitdiffhomepage
path: root/packages/tui
diff options
context:
space:
mode:
authorYihui Khuu <[email protected]>2025-07-09 09:53:38 +1000
committerGitHub <[email protected]>2025-07-08 18:53:38 -0500
commit7893b8461495e9d1074c46d788b5007b29449736 (patch)
treed56dead7618f6c8ebac63f50f9acd73f76d54ff9 /packages/tui
parentcfc715bd48be5460faf33d49a26abafc89c25c42 (diff)
downloadopencode-7893b8461495e9d1074c46d788b5007b29449736.tar.gz
opencode-7893b8461495e9d1074c46d788b5007b29449736.zip
Add debounce before exit when using non-leader exit command (#759)
Diffstat (limited to 'packages/tui')
-rw-r--r--packages/tui/internal/components/chat/editor.go15
-rw-r--r--packages/tui/internal/tui/tui.go41
2 files changed, 53 insertions, 3 deletions
diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go
index 82b3bf71e..f4e6ab2f9 100644
--- a/packages/tui/internal/components/chat/editor.go
+++ b/packages/tui/internal/components/chat/editor.go
@@ -38,6 +38,7 @@ type EditorComponent interface {
Paste() (tea.Model, tea.Cmd)
Newline() (tea.Model, tea.Cmd)
SetInterruptKeyInDebounce(inDebounce bool)
+ SetExitKeyInDebounce(inDebounce bool)
}
type editorComponent struct {
@@ -45,6 +46,7 @@ type editorComponent struct {
textarea textarea.Model
spinner spinner.Model
interruptKeyInDebounce bool
+ exitKeyInDebounce bool
}
func (m *editorComponent) Init() tea.Cmd {
@@ -224,7 +226,10 @@ func (m *editorComponent) Content(width int) string {
Render(textarea)
hint := base(m.getSubmitKeyText()) + muted(" send ")
- if m.app.IsBusy() {
+ if m.exitKeyInDebounce {
+ keyText := m.getExitKeyText()
+ hint = base(keyText+" again") + muted(" to exit")
+ } else if m.app.IsBusy() {
keyText := m.getInterruptKeyText()
if m.interruptKeyInDebounce {
hint = muted(
@@ -365,6 +370,10 @@ func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
m.interruptKeyInDebounce = inDebounce
}
+func (m *editorComponent) SetExitKeyInDebounce(inDebounce bool) {
+ m.exitKeyInDebounce = inDebounce
+}
+
func (m *editorComponent) getInterruptKeyText() string {
return m.app.Commands[commands.SessionInterruptCommand].Keys()[0]
}
@@ -373,6 +382,10 @@ func (m *editorComponent) getSubmitKeyText() string {
return m.app.Commands[commands.InputSubmitCommand].Keys()[0]
}
+func (m *editorComponent) getExitKeyText() string {
+ return m.app.Commands[commands.AppExitCommand].Keys()[0]
+}
+
func (m *editorComponent) resetTextareaStyles() textarea.Model {
t := theme.CurrentTheme()
bgColor := t.BackgroundElement()
diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go
index d25fc1395..c0d7ca1b0 100644
--- a/packages/tui/internal/tui/tui.go
+++ b/packages/tui/internal/tui/tui.go
@@ -32,15 +32,27 @@ import (
// InterruptDebounceTimeoutMsg is sent when the interrupt key debounce timeout expires
type InterruptDebounceTimeoutMsg struct{}
+// ExitDebounceTimeoutMsg is sent when the exit key debounce timeout expires
+type ExitDebounceTimeoutMsg struct{}
+
// InterruptKeyState tracks the state of interrupt key presses for debouncing
type InterruptKeyState int
+// ExitKeyState tracks the state of exit key presses for debouncing
+type ExitKeyState int
+
const (
InterruptKeyIdle InterruptKeyState = iota
InterruptKeyFirstPress
)
+const (
+ ExitKeyIdle ExitKeyState = iota
+ ExitKeyFirstPress
+)
+
const interruptDebounceTimeout = 1 * time.Second
+const exitDebounceTimeout = 1 * time.Second
const fileViewerFullWidthCutoff = 160
type appModel struct {
@@ -59,6 +71,7 @@ type appModel struct {
isLeaderSequence bool
toastManager *toast.ToastManager
interruptKeyState InterruptKeyState
+ exitKeyState ExitKeyState
lastScroll time.Time
messagesRight bool
fileViewer fileviewer.Model
@@ -271,7 +284,26 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
- // 7. Check again for commands that don't require leader (excluding interrupt when busy)
+ // 7. Handle exit key debounce for app exit when using non-leader command
+ exitCommand := a.app.Commands[commands.AppExitCommand]
+ if exitCommand.Matches(msg, a.isLeaderSequence) {
+ switch a.exitKeyState {
+ case ExitKeyIdle:
+ // First exit key press - start debounce timer
+ a.exitKeyState = ExitKeyFirstPress
+ a.editor.SetExitKeyInDebounce(true)
+ return a, tea.Tick(exitDebounceTimeout, func(t time.Time) tea.Msg {
+ return ExitDebounceTimeoutMsg{}
+ })
+ case ExitKeyFirstPress:
+ // Second exit key press within timeout - actually exit
+ a.exitKeyState = ExitKeyIdle
+ a.editor.SetExitKeyInDebounce(false)
+ return a, util.CmdHandler(commands.ExecuteCommandMsg(exitCommand))
+ }
+ }
+
+ // 8. Check again for commands that don't require leader (excluding interrupt when busy and exit when in debounce)
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
@@ -281,7 +313,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
}
- // 7. Fallback to editor. This is for other characters
+ // 9. Fallback to editor. This is for other characters
// like backspace, tab, etc.
updatedEditor, cmd := a.editor.Update(msg)
a.editor = updatedEditor.(chat.EditorComponent)
@@ -499,6 +531,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Reset interrupt key state after timeout
a.interruptKeyState = InterruptKeyIdle
a.editor.SetInterruptKeyInDebounce(false)
+ case ExitDebounceTimeoutMsg:
+ // Reset exit key state after timeout
+ a.exitKeyState = ExitKeyIdle
+ a.editor.SetExitKeyInDebounce(false)
case dialog.FindSelectedMsg:
return a.openFile(msg.FilePath)
}
@@ -1015,6 +1051,7 @@ func NewModel(app *app.App) tea.Model {
fileCompletionActive: false,
toastManager: toast.NewToastManager(),
interruptKeyState: InterruptKeyIdle,
+ exitKeyState: ExitKeyIdle,
fileViewer: fileviewer.New(app),
messagesRight: app.State.MessagesRight,
}