From 5d6514a798f91f3b7611456a929be082d8f89f09 Mon Sep 17 00:00:00 2001 From: Tom X Nguyen Date: Sat, 21 Jun 2025 13:28:16 +0700 Subject: [PATCH 1/8] fix: remove unused CoreMessage --- packages/opencode/src/provider/transform.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 95a8faf18164..aa2895da36e8 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 { From a4ae83af7ce532ccfb8b34fa1ac3dac607a16b06 Mon Sep 17 00:00:00 2001 From: Tom X Nguyen Date: Sat, 21 Jun 2025 13:51:25 +0700 Subject: [PATCH 2/8] feat(tui): add debounce logic to escape key interrupt to prevent accidental cancellations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a two-stage escape key mechanism where the first press shows "esc again interrupt" and requires a second press within 1 second to actually cancel the operation. This prevents users from accidentally interrupting long-running chat operations while maintaining responsive cancellation when intentional. 🤖 Generated with [opencode](https://opencode.ai) Co-Authored-By: opencode --- .../tui/internal/components/chat/editor.go | 41 ++++++++++------- packages/tui/internal/tui/tui.go | 44 ++++++++++++++++++- 2 files changed, 69 insertions(+), 16 deletions(-) diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index f48dcea1b1d1..cef44cb3613f 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) + SetEscapeKeyInDebounce(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 + escapeKeyInDebounce bool } func (m *editorComponent) Init() tea.Cmd { @@ -117,7 +119,11 @@ 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") + if m.escapeKeyInDebounce { + hint = muted("working") + m.spinner.View() + muted(" ") + base("esc again") + muted(" interrupt") + } else { + hint = muted("working") + m.spinner.View() + muted(" ") + base("esc") + muted(" interrupt") + } } model := "" @@ -263,6 +269,10 @@ func (m *editorComponent) Next() (tea.Model, tea.Cmd) { return m, nil } +func (m *editorComponent) SetEscapeKeyInDebounce(inDebounce bool) { + m.escapeKeyInDebounce = inDebounce +} + func createTextArea(existing *textarea.Model) textarea.Model { t := theme.CurrentTheme() bgColor := t.BackgroundElement() @@ -311,11 +321,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, + escapeKeyInDebounce: false, } } diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index a3c83976b544..e28697b10934 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" ) +// EscapeDebounceTimeoutMsg is sent when the escape key debounce timeout expires +type EscapeDebounceTimeoutMsg struct{} + +// EscapeKeyState tracks the state of escape key presses for debouncing +type EscapeKeyState int + +const ( + EscapeKeyIdle EscapeKeyState = iota + EscapeKeyFirstPress +) + +const escapeDebounceTimeout = 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 + escapeKeyState EscapeKeyState } func (a appModel) Init() tea.Cmd { @@ -171,9 +186,31 @@ 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 escape key debounce for session interrupt + if keyString == "esc" && a.app.IsBusy() { + switch a.escapeKeyState { + case EscapeKeyIdle: + // First escape press - start debounce timer + a.escapeKeyState = EscapeKeyFirstPress + a.editor.SetEscapeKeyInDebounce(true) + return a, tea.Tick(escapeDebounceTimeout, func(t time.Time) tea.Msg { + return EscapeDebounceTimeoutMsg{} + }) + case EscapeKeyFirstPress: + // Second escape press within timeout - actually interrupt + a.escapeKeyState = EscapeKeyIdle + a.editor.SetEscapeKeyInDebounce(false) + return a, util.CmdHandler(commands.ExecuteCommandMsg(a.app.Commands[commands.SessionInterruptCommand])) + } + } + + // 7. Check again for commands that don't require leader (excluding escape when busy) matches := a.app.Commands.Matches(msg, a.isLeaderSequence) if len(matches) > 0 { + // Skip escape key interrupt if we're in debounce mode and app is busy + if keyString == "esc" && a.app.IsBusy() && a.escapeKeyState != EscapeKeyIdle { + return a, nil + } return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches)) } @@ -283,6 +320,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 EscapeDebounceTimeoutMsg: + // Reset escape key state after timeout + a.escapeKeyState = EscapeKeyIdle + a.editor.SetEscapeKeyInDebounce(false) } // update status bar @@ -575,6 +616,7 @@ func NewModel(app *app.App) tea.Model { showCompletionDialog: false, editorContainer: editorContainer, toastManager: toast.NewToastManager(), + escapeKeyState: EscapeKeyIdle, layout: layout.NewFlexLayout( []tea.ViewModel{messagesContainer, editorContainer}, layout.WithDirection(layout.FlexDirectionVertical), From d9b693f6c482c19c0d5dafaa25a18b4cd55300ac Mon Sep 17 00:00:00 2001 From: Tom X Nguyen Date: Sat, 21 Jun 2025 22:38:42 +0700 Subject: [PATCH 3/8] feat(tui): add configurable interrupt key debounce to prevent accidental cancellations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a two-stage interrupt key mechanism that works with any user-configured interrupt keybind (not just "esc"). The first press shows "esc again interrupt" and requires a second press within 1 second to actually cancel the operation. This prevents users from accidentally interrupting long-running chat operations while maintaining responsive cancellation when intentional. - Uses dynamic keybind matching instead of hardcoded "esc" key - Works with leader key sequences and custom interrupt keybinds - 1 second debounce timeout for optimal user experience - Real-time UI feedback with hint text updates 🤖 Generated with [opencode](https://opencode.ai) Co-Authored-By: opencode --- .../tui/internal/components/chat/editor.go | 12 ++-- packages/tui/internal/tui/tui.go | 61 ++++++++++--------- 2 files changed, 37 insertions(+), 36 deletions(-) diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index cef44cb3613f..c2c15483e142 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -32,7 +32,7 @@ type EditorComponent interface { Newline() (tea.Model, tea.Cmd) Previous() (tea.Model, tea.Cmd) Next() (tea.Model, tea.Cmd) - SetEscapeKeyInDebounce(inDebounce bool) + SetInterruptKeyInDebounce(inDebounce bool) } type editorComponent struct { @@ -44,7 +44,7 @@ type editorComponent struct { historyIndex int currentMessage string spinner spinner.Model - escapeKeyInDebounce bool + interruptKeyInDebounce bool } func (m *editorComponent) Init() tea.Cmd { @@ -119,7 +119,7 @@ func (m *editorComponent) Content() string { hint := base("enter") + muted(" send ") if m.app.IsBusy() { - if m.escapeKeyInDebounce { + if m.interruptKeyInDebounce { hint = muted("working") + m.spinner.View() + muted(" ") + base("esc again") + muted(" interrupt") } else { hint = muted("working") + m.spinner.View() + muted(" ") + base("esc") + muted(" interrupt") @@ -269,8 +269,8 @@ func (m *editorComponent) Next() (tea.Model, tea.Cmd) { return m, nil } -func (m *editorComponent) SetEscapeKeyInDebounce(inDebounce bool) { - m.escapeKeyInDebounce = inDebounce +func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) { + m.interruptKeyInDebounce = inDebounce } func createTextArea(existing *textarea.Model) textarea.Model { @@ -327,6 +327,6 @@ func NewEditorComponent(app *app.App) EditorComponent { historyIndex: 0, currentMessage: "", spinner: s, - escapeKeyInDebounce: false, + interruptKeyInDebounce: false, } } diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index e28697b10934..9e23805d1ce9 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -26,18 +26,18 @@ import ( "github.com/sst/opencode/pkg/client" ) -// EscapeDebounceTimeoutMsg is sent when the escape key debounce timeout expires -type EscapeDebounceTimeoutMsg struct{} +// InterruptDebounceTimeoutMsg is sent when the interrupt key debounce timeout expires +type InterruptDebounceTimeoutMsg struct{} -// EscapeKeyState tracks the state of escape key presses for debouncing -type EscapeKeyState int +// InterruptKeyState tracks the state of interrupt key presses for debouncing +type InterruptKeyState int const ( - EscapeKeyIdle EscapeKeyState = iota - EscapeKeyFirstPress + InterruptKeyIdle InterruptKeyState = iota + InterruptKeyFirstPress ) -const escapeDebounceTimeout = 1 * time.Second +const interruptDebounceTimeout = 1 * time.Second type appModel struct { width, height int @@ -54,7 +54,7 @@ type appModel struct { leaderBinding *key.Binding isLeaderSequence bool toastManager *toast.ToastManager - escapeKeyState EscapeKeyState + interruptKeyState InterruptKeyState } func (a appModel) Init() tea.Cmd { @@ -186,29 +186,30 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil } - // 6. Handle escape key debounce for session interrupt - if keyString == "esc" && a.app.IsBusy() { - switch a.escapeKeyState { - case EscapeKeyIdle: - // First escape press - start debounce timer - a.escapeKeyState = EscapeKeyFirstPress - a.editor.SetEscapeKeyInDebounce(true) - return a, tea.Tick(escapeDebounceTimeout, func(t time.Time) tea.Msg { - return EscapeDebounceTimeoutMsg{} + // 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 EscapeKeyFirstPress: - // Second escape press within timeout - actually interrupt - a.escapeKeyState = EscapeKeyIdle - a.editor.SetEscapeKeyInDebounce(false) - return a, util.CmdHandler(commands.ExecuteCommandMsg(a.app.Commands[commands.SessionInterruptCommand])) + 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 escape when busy) + // 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 escape key interrupt if we're in debounce mode and app is busy - if keyString == "esc" && a.app.IsBusy() && a.escapeKeyState != EscapeKeyIdle { + // 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)) @@ -320,10 +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 EscapeDebounceTimeoutMsg: - // Reset escape key state after timeout - a.escapeKeyState = EscapeKeyIdle - a.editor.SetEscapeKeyInDebounce(false) + case InterruptDebounceTimeoutMsg: + // Reset interrupt key state after timeout + a.interruptKeyState = InterruptKeyIdle + a.editor.SetInterruptKeyInDebounce(false) } // update status bar @@ -616,7 +617,7 @@ func NewModel(app *app.App) tea.Model { showCompletionDialog: false, editorContainer: editorContainer, toastManager: toast.NewToastManager(), - escapeKeyState: EscapeKeyIdle, + interruptKeyState: InterruptKeyIdle, layout: layout.NewFlexLayout( []tea.ViewModel{messagesContainer, editorContainer}, layout.WithDirection(layout.FlexDirectionVertical), From fc028bb35221c45a3263f91538e7a9f0dcec483c Mon Sep 17 00:00:00 2001 From: Tom X Nguyen Date: Sat, 21 Jun 2025 22:45:40 +0700 Subject: [PATCH 4/8] feat(tui): add dynamic interrupt key text display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the interrupt hint text to show the user's actual configured interrupt key instead of hardcoded "esc". Now displays the correct key text for any configured interrupt keybind (esc, ctrl+c, q, leader+key, etc.). - Gets interrupt key text from command registry during initialization - Updates hint display dynamically: "ctrl+c interrupt" → "ctrl+c again interrupt" - Removes unnecessary fallbacks for cleaner, more confident code - Handles first render correctly with proper initialization 🤖 Generated with [opencode](https://opencode.ai) Co-Authored-By: opencode --- packages/tui/internal/components/chat/editor.go | 15 +++++++++++---- packages/tui/internal/tui/tui.go | 12 +++++++++--- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index c2c15483e142..115ef323b7f0 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -32,7 +32,7 @@ type EditorComponent interface { Newline() (tea.Model, tea.Cmd) Previous() (tea.Model, tea.Cmd) Next() (tea.Model, tea.Cmd) - SetInterruptKeyInDebounce(inDebounce bool) + SetInterruptKeyInDebounce(inDebounce bool, keyText string) } type editorComponent struct { @@ -45,6 +45,7 @@ type editorComponent struct { currentMessage string spinner spinner.Model interruptKeyInDebounce bool + interruptKeyText string } func (m *editorComponent) Init() tea.Cmd { @@ -120,9 +121,9 @@ func (m *editorComponent) Content() string { hint := base("enter") + muted(" send ") if m.app.IsBusy() { if m.interruptKeyInDebounce { - hint = muted("working") + m.spinner.View() + muted(" ") + base("esc again") + muted(" interrupt") + hint = muted("working") + m.spinner.View() + muted(" ") + base(m.interruptKeyText+" again") + muted(" interrupt") } else { - hint = muted("working") + m.spinner.View() + muted(" ") + base("esc") + muted(" interrupt") + hint = muted("working") + m.spinner.View() + muted(" ") + base(m.interruptKeyText) + muted(" interrupt") } } @@ -269,8 +270,9 @@ func (m *editorComponent) Next() (tea.Model, tea.Cmd) { return m, nil } -func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) { +func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool, keyText string) { m.interruptKeyInDebounce = inDebounce + m.interruptKeyText = keyText } func createTextArea(existing *textarea.Model) textarea.Model { @@ -320,6 +322,10 @@ func NewEditorComponent(app *app.App) EditorComponent { s := createSpinner() ta := createTextArea(nil) + // Get the configured interrupt key text for display + interruptCommand := app.Commands[commands.SessionInterruptCommand] + interruptKeyText := interruptCommand.Keys()[0] + return &editorComponent{ app: app, textarea: ta, @@ -328,5 +334,6 @@ func NewEditorComponent(app *app.App) EditorComponent { currentMessage: "", spinner: s, interruptKeyInDebounce: false, + interruptKeyText: interruptKeyText, } } diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 9e23805d1ce9..2bedebbcf7dd 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -189,18 +189,21 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // 6. Handle interrupt key debounce for session interrupt interruptCommand := a.app.Commands[commands.SessionInterruptCommand] if interruptCommand.Matches(msg, a.isLeaderSequence) && a.app.IsBusy() { + // Get the configured key text for display + keyText := interruptCommand.Keys()[0] + switch a.interruptKeyState { case InterruptKeyIdle: // First interrupt key press - start debounce timer a.interruptKeyState = InterruptKeyFirstPress - a.editor.SetInterruptKeyInDebounce(true) + a.editor.SetInterruptKeyInDebounce(true, keyText) 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) + a.editor.SetInterruptKeyInDebounce(false, keyText) return a, util.CmdHandler(commands.ExecuteCommandMsg(interruptCommand)) } } @@ -324,7 +327,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case InterruptDebounceTimeoutMsg: // Reset interrupt key state after timeout a.interruptKeyState = InterruptKeyIdle - a.editor.SetInterruptKeyInDebounce(false) + // Get the interrupt key text for display + interruptCommand := a.app.Commands[commands.SessionInterruptCommand] + keyText := interruptCommand.Keys()[0] + a.editor.SetInterruptKeyInDebounce(false, keyText) } // update status bar From 61c489592a754f6b44d6f21e09d76025f4fed66a Mon Sep 17 00:00:00 2001 From: Tom X Nguyen Date: Sat, 21 Jun 2025 22:49:09 +0700 Subject: [PATCH 5/8] chore: go fmt --- .../tui/internal/components/chat/editor.go | 28 +++++++++---------- packages/tui/internal/tui/tui.go | 2 +- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index 115ef323b7f0..88c519f92d4b 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -36,14 +36,14 @@ type EditorComponent interface { } 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 interruptKeyText string } @@ -327,12 +327,12 @@ func NewEditorComponent(app *app.App) EditorComponent { interruptKeyText := interruptCommand.Keys()[0] 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, interruptKeyText: interruptKeyText, } diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 2bedebbcf7dd..a605cbcc937f 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -191,7 +191,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if interruptCommand.Matches(msg, a.isLeaderSequence) && a.app.IsBusy() { // Get the configured key text for display keyText := interruptCommand.Keys()[0] - + switch a.interruptKeyState { case InterruptKeyIdle: // First interrupt key press - start debounce timer From 3718847269be8c6b6aa719864f81961ee61d5043 Mon Sep 17 00:00:00 2001 From: Tom X Nguyen Date: Sat, 21 Jun 2025 23:01:25 +0700 Subject: [PATCH 6/8] refactor: remove redundant interrupt key text storage Instead of storing the interrupt key text, dynamically retrieve it from the app commands when needed. This reduces state duplication and ensures the displayed key always matches the configured command. --- .../tui/internal/components/chat/editor.go | 20 +++++++++---------- packages/tui/internal/tui/tui.go | 12 ++++------- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index 88c519f92d4b..ac23eb0b783a 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -32,7 +32,7 @@ type EditorComponent interface { Newline() (tea.Model, tea.Cmd) Previous() (tea.Model, tea.Cmd) Next() (tea.Model, tea.Cmd) - SetInterruptKeyInDebounce(inDebounce bool, keyText string) + SetInterruptKeyInDebounce(inDebounce bool) } type editorComponent struct { @@ -45,7 +45,6 @@ type editorComponent struct { currentMessage string spinner spinner.Model interruptKeyInDebounce bool - interruptKeyText string } func (m *editorComponent) Init() tea.Cmd { @@ -120,10 +119,11 @@ func (m *editorComponent) Content() string { hint := base("enter") + muted(" send ") if m.app.IsBusy() { + keyText := m.getInterruptKeyText() if m.interruptKeyInDebounce { - hint = muted("working") + m.spinner.View() + muted(" ") + base(m.interruptKeyText+" again") + muted(" interrupt") + hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText+" again") + muted(" interrupt") } else { - hint = muted("working") + m.spinner.View() + muted(" ") + base(m.interruptKeyText) + muted(" interrupt") + hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt") } } @@ -270,9 +270,12 @@ func (m *editorComponent) Next() (tea.Model, tea.Cmd) { return m, nil } -func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool, keyText string) { +func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) { m.interruptKeyInDebounce = inDebounce - m.interruptKeyText = keyText +} + +func (m *editorComponent) getInterruptKeyText() string { + return m.app.Commands[commands.SessionInterruptCommand].Keys()[0] } func createTextArea(existing *textarea.Model) textarea.Model { @@ -322,10 +325,6 @@ func NewEditorComponent(app *app.App) EditorComponent { s := createSpinner() ta := createTextArea(nil) - // Get the configured interrupt key text for display - interruptCommand := app.Commands[commands.SessionInterruptCommand] - interruptKeyText := interruptCommand.Keys()[0] - return &editorComponent{ app: app, textarea: ta, @@ -334,6 +333,5 @@ func NewEditorComponent(app *app.App) EditorComponent { currentMessage: "", spinner: s, interruptKeyInDebounce: false, - interruptKeyText: interruptKeyText, } } diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index a605cbcc937f..01ce3cf21d40 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -189,21 +189,20 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // 6. Handle interrupt key debounce for session interrupt interruptCommand := a.app.Commands[commands.SessionInterruptCommand] if interruptCommand.Matches(msg, a.isLeaderSequence) && a.app.IsBusy() { - // Get the configured key text for display - keyText := interruptCommand.Keys()[0] + switch a.interruptKeyState { case InterruptKeyIdle: // First interrupt key press - start debounce timer a.interruptKeyState = InterruptKeyFirstPress - a.editor.SetInterruptKeyInDebounce(true, keyText) + 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, keyText) + a.editor.SetInterruptKeyInDebounce(false) return a, util.CmdHandler(commands.ExecuteCommandMsg(interruptCommand)) } } @@ -327,10 +326,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case InterruptDebounceTimeoutMsg: // Reset interrupt key state after timeout a.interruptKeyState = InterruptKeyIdle - // Get the interrupt key text for display - interruptCommand := a.app.Commands[commands.SessionInterruptCommand] - keyText := interruptCommand.Keys()[0] - a.editor.SetInterruptKeyInDebounce(false, keyText) + a.editor.SetInterruptKeyInDebounce(false) } // update status bar From a02668ca2573c01bc407656e7a6e2513ccefee7e Mon Sep 17 00:00:00 2001 From: Tom X Nguyen Date: Sat, 21 Jun 2025 23:02:34 +0700 Subject: [PATCH 7/8] chore: go fmt --- packages/tui/internal/tui/tui.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 01ce3cf21d40..9e23805d1ce9 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -189,8 +189,6 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // 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 From 43f2bedebee9ced5eb21da3750bda6992aad88c9 Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Tue, 24 Jun 2025 06:26:31 -0500 Subject: [PATCH 8/8] fix(tui): use keybind for submit hint --- packages/tui/internal/components/chat/editor.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index ac23eb0b783a..d67a226fec5f 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -117,7 +117,7 @@ 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() { keyText := m.getInterruptKeyText() if m.interruptKeyInDebounce { @@ -278,6 +278,10 @@ 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()