Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions internal/ui/monitor_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type monitorCommandsState struct {
restore *monitorCommandLevel
inline bool
prefix string
escapeUntil time.Time
}
type monitorCommandsResult struct {
session string
Expand Down Expand Up @@ -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" {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
36 changes: 36 additions & 0 deletions internal/ui/monitor_commands_escape.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 1 addition & 1 deletion internal/ui/monitor_commands_inline.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
119 changes: 119 additions & 0 deletions internal/ui/monitor_goal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
Loading