From 22a52f22e4cf950dfaf4956bc99a31c292677903 Mon Sep 17 00:00:00 2001 From: merefield Date: Wed, 4 Nov 2026 22:32:17 +0000 Subject: [PATCH] FIX: keep command Escape bursts inside session detail --- README.md | 7 ++ internal/ui/monitor_commands.go | 14 ++- internal/ui/monitor_commands_escape.go | 36 ++++++++ internal/ui/monitor_commands_inline.go | 2 +- internal/ui/monitor_goal_test.go | 119 +++++++++++++++++++++++++ 5 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 internal/ui/monitor_commands_escape.go diff --git a/README.md b/README.md index 2ba6b3a..aefb8ce 100644 --- a/README.md +++ b/README.md @@ -1898,6 +1898,13 @@ cannot activate a hidden control. The composer and conversation remain in place all levels, including in a wide session row. `/commands` also opens the root list, including local commands without a shared daemon. +In terminal command menus, **Escape** dismisses the command layer without leaving +the selected session detail or discarding its composer draft. Recognised held-key +repeats and rapid Escape bursts immediately after dismissal are absorbed instead +of reaching the composer’s interrupt action or session-list navigation. Terminals +without key-release reporting use a brief quiet period; another key, a click, or +an observed Escape release restores ordinary navigation immediately. + Codex does not expose a general slash-command discovery/execution API. These are explicit adapters to supported app-server catalogues, not a reproduction of every CLI command. Live options and help are discovered from the server and diff --git a/internal/ui/monitor_commands.go b/internal/ui/monitor_commands.go index d13db91..8572ddb 100644 --- a/internal/ui/monitor_commands.go +++ b/internal/ui/monitor_commands.go @@ -38,6 +38,7 @@ type monitorCommandsState struct { restore *monitorCommandLevel inline bool prefix string + escapeUntil time.Time } type monitorCommandsResult struct { session string @@ -184,6 +185,12 @@ func (m *Model) cancelMonitorCommands() tea.Cmd { return m.monitorPrompt.input.Focus() } +func (m *Model) escapeMonitorCommands() tea.Cmd { + cmd := m.cancelMonitorCommands() + m.monitorCommands.escapeUntil = time.Now().Add(monitorCommandEscapeQuietPeriod) + return cmd +} + func (m Model) openMonitorCommands(path string) (Model, tea.Cmd, bool) { path = strings.TrimPrefix(strings.TrimSpace(path), "/") if path == "commands" { @@ -648,6 +655,9 @@ func (m Model) renderMonitorCommands(width, height int, colors palette) string { func (m Model) updateMonitorCommands(msg tea.Msg) (Model, tea.Cmd, bool) { p := &m.monitorCommands + if m.consumeMonitorCommandEscape(msg) { + return m, nil, true + } if r, ok := msg.(monitorCommandsResult); ok { if p.open && p.session == r.session && p.request == r.request { if m.meterView != viewMonitor || m.monitorContextTarget() != r.session { @@ -804,7 +814,7 @@ func (m Model) updateMonitorCommands(msg tea.Msg) (Model, tea.Cmd, bool) { if key, ok := msg.(tea.KeyPressMsg); ok { switch key.String() { case "esc": - cmd := m.cancelMonitorCommands() + cmd := m.escapeMonitorCommands() return m, cmd, true case "enter": return m.reviewMonitorCommandInput() @@ -850,7 +860,7 @@ func (m Model) updateMonitorCommands(msg tea.Msg) (Model, tea.Cmd, bool) { } switch key.String() { case "esc": - cmd := m.cancelMonitorCommands() + cmd := m.escapeMonitorCommands() return m, cmd, true case "q", "ctrl+c", "tab", "shift+tab": m.closeMonitorCommands() diff --git a/internal/ui/monitor_commands_escape.go b/internal/ui/monitor_commands_escape.go new file mode 100644 index 0000000..89ce56b --- /dev/null +++ b/internal/ui/monitor_commands_escape.go @@ -0,0 +1,36 @@ +package ui + +import ( + "time" + + tea "charm.land/bubbletea/v2" +) + +const monitorCommandEscapeQuietPeriod = 250 * time.Millisecond + +// Dismissing a command layer must not let the same Escape burst dismiss the +// composer/session underneath it. Modern terminals identify repeats/releases; +// legacy terminals need a short quiet period after the last Escape. Other +// deliberate input immediately restores ordinary navigation. +func (m *Model) consumeMonitorCommandEscape(msg tea.Msg) bool { + p := &m.monitorCommands + switch key := msg.(type) { + case tea.KeyReleaseMsg: + if key.String() == "esc" { + p.escapeUntil = time.Time{} + } + case tea.MouseClickMsg, tea.PasteMsg: + p.escapeUntil = time.Time{} + case tea.KeyPressMsg: + if key.String() != "esc" || m.meterView != viewMonitor || p.session != m.monitorContextTarget() { + p.escapeUntil = time.Time{} + return false + } + if key.IsRepeat && (p.open || !p.escapeUntil.IsZero()) || time.Now().Before(p.escapeUntil) { + p.escapeUntil = time.Now().Add(monitorCommandEscapeQuietPeriod) + return true + } + p.escapeUntil = time.Time{} + } + return false +} diff --git a/internal/ui/monitor_commands_inline.go b/internal/ui/monitor_commands_inline.go index c86be5c..686dc53 100644 --- a/internal/ui/monitor_commands_inline.go +++ b/internal/ui/monitor_commands_inline.go @@ -90,7 +90,7 @@ func (m Model) updateInlineCommands(msg tea.Msg) (Model, tea.Cmd, bool) { if key, ok := msg.(tea.KeyPressMsg); ok { switch key.String() { case "esc": - cmd := m.cancelMonitorCommands() + cmd := m.escapeMonitorCommands() return m, cmd, true case "alt+left": cmd := m.backMonitorCommands() diff --git a/internal/ui/monitor_goal_test.go b/internal/ui/monitor_goal_test.go index b43a1b6..d64d0a8 100644 --- a/internal/ui/monitor_goal_test.go +++ b/internal/ui/monitor_goal_test.go @@ -121,6 +121,125 @@ func TestMonitorGoalInputReviewRetainsNestedParent(t *testing.T) { } } +func TestMonitorGoalEscapeBurstStaysInSessionDetail(t *testing.T) { + for _, inline := range []bool{false, true} { + for _, path := range []string{"goal", "goal/edit", "goal/budget", "goal/edit/New%20objective"} { + for _, state := range []string{"ready", "loading", "error"} { + for _, repeat := range []bool{false, true} { + m, base := suggestionTestModel(t) + m.fetcher = &goalUIClient{base.commandTestClient} + m.monitorPrompt.input.SetValue("/goal") + if !inline { + m.monitorPrompt.input.Blur() + } + m, cmd, _ := m.openMonitorCommands(path) + if state != "loading" { + next, _ := m.Update(cmd()) + m = next.(Model) + } + if state == "error" { + m.monitorCommands.notice = "Command unavailable." + } + draft := m.monitorPrompt.input.Value() + for i := range 4 { + next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape, IsRepeat: repeat && i > 0}) + m = next.(Model) + if m.monitorContextDetail != "root-one" { + t.Fatalf("Escape burst left detail: path=%s inline=%t state=%s repeat=%t press=%d", path, inline, state, repeat, i+1) + } + } + if state == "loading" { + next, _ := m.Update(cmd()) + m = next.(Model) + } + if base.calls != 0 || m.monitorCommands.open || m.monitorPrompt.input.Value() != draft { + t.Fatal("Escape submitted/reopened a goal action or lost the draft") + } + } + } + } + } +} + +func TestMonitorCommandEscapeRestoresOrdinaryNavigation(t *testing.T) { + for _, resume := range []string{"release", "timeout", "typing", "paste", "click"} { + m, base := suggestionTestModel(t) + m.fetcher = &goalUIClient{base.commandTestClient} + m.monitorPrompt.input.SetValue("/goal") + m, cmd, _ := m.openMonitorCommands("goal/edit") + next, _ := m.Update(cmd()) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + m = next.(Model) + switch resume { + case "release": + next, _ = m.Update(tea.KeyReleaseMsg{Code: tea.KeyEscape}) + m = next.(Model) + case "timeout": + m.monitorCommands.escapeUntil = time.Now().Add(-time.Second) + default: + var message tea.Msg = key('x') + if resume == "paste" { + message = tea.PasteMsg{Content: "new text"} + } else if resume == "click" { + message = tea.MouseClickMsg{X: -1, Y: -1, Button: tea.MouseLeft} + } + m.consumeMonitorCommandEscape(message) + } + m.monitorPrompt.input.Blur() + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + m = next.(Model) + if m.monitorContextDetail != "" { + t.Fatal("ordinary Escape navigation remained blocked after", resume) + } + } +} + +func TestMonitorGoalEscapeWithoutComposer(t *testing.T) { + m, base := suggestionTestModel(t) + m.fetcher = &goalUIClient{base.commandTestClient} + m.monitorPrompt.input.SetValue("/goal") + m.monitorPrompt.input.Blur() + m, cmd, _ := m.openMonitorCommands("goal/edit") + next, _ := m.Update(cmd()) + m = next.(Model) + m.monitorPrompt = monitorPromptState{} + for range 3 { + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + m = next.(Model) + if m.monitorContextDetail != "root-one" { + t.Fatal("Escape dismissed detail when no composer could take focus") + } + } +} + +func TestMonitorGoalEscapeDoesNotInterruptWorkingTurn(t *testing.T) { + m, c := turnTestModel() + m.fetcher = &goalUIClient{&commandTestClient{promptTestClient: c.promptTestClient}} + m.monitorPrompt.input.SetValue("/goal") + m, cmd, _ := m.openMonitorCommands("goal/edit") + next, _ := m.Update(cmd()) + m = next.(Model) + m.fetcher = c + for i := range 3 { + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEscape, IsRepeat: i > 0}) + m = next.(Model) + if m.monitorPrompt.busy || m.monitorContextDetail != "root-one" { + t.Fatal("dismissing a goal interrupted work or navigated away") + } + } + next, _ = m.Update(tea.KeyReleaseMsg{Code: tea.KeyEscape}) + m = next.(Model) + _, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if cmd == nil { + t.Fatal("deliberate working-composer Escape lost its interrupt action") + } + cmd() + if c.action != "interrupt" { + t.Fatal("fresh Escape did not restore the ordinary working-composer action", c.action) + } +} + func TestMonitorGoalFollowsConfiguredFields(t *testing.T) { m := completedCopyModel() m.monitorStatusLine = []string{"model-with-reasoning", "used-tokens"}