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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Windows added to a session (multi-window builds and `mox new -w`) now
target the session by exact name. tmux fell back to prefix matching, so
a missing session could resolve to a longer-named one and the window
would land there.
- Fast typing in the hub no longer runs keys typed after a quit: `qi`
quit and then fell through to the import handoff. The hub and editor now
share one key-repeat replay that stops at the key that quits.
- `mox validate` lists layouts in name order instead of a different order
on every run.

## [0.6.2] — 2026-07-23

### Added
Expand Down
58 changes: 28 additions & 30 deletions internal/cli/editor_ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ type editorModel struct {
// config. nil when tmux is unavailable (and in most tests).
startSession func(cfg *config.Config, name string) error

// leaving is set once the editor quits or suspends for $EDITOR, so a
// batched rune replay stops there.
leaving bool

width, height int
}

Expand Down Expand Up @@ -231,6 +235,7 @@ func (m editorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case editorReturnMsg:
m.leaving = false // back from $EDITOR
if msg.err != nil {
m.status = "editor: " + msg.err.Error()
m.statusErr = true
Expand All @@ -255,23 +260,7 @@ func (m editorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case tea.KeyMsg:
if msg.Type == tea.KeyRunes && len(msg.Runes) > 1 {
// Key repeat and fast typing batch runes into one message;
// hotkeys match single runes, so replay the batch one rune at
// a time. A mid-batch mode change or command (quit, save)
// takes effect immediately and the rest replays against it.
cur := m
for _, r := range msg.Runes {
nm, cmd := cur.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
em, ok := nm.(editorModel)
if !ok {
return nm, cmd
}
cur = em
if cmd != nil {
return cur, cmd
}
}
return cur, nil
return replayRunes(m, msg.Runes)
}
switch m.mode {
case modeBrowse:
Expand Down Expand Up @@ -302,7 +291,7 @@ func (m editorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m editorModel) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyCtrlC:
return m, tea.Quit
return m.quit()
case tea.KeyTab:
if m.pane == paneList {
m.pane = paneForm
Expand Down Expand Up @@ -442,6 +431,7 @@ func (m editorModel) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
parts := strings.Fields(editor)
ed := exec.Command(parts[0], append(parts[1:], m.st.path)...) //nolint:gosec // the user's own $EDITOR choice
m.leaving = true
return m, tea.ExecProcess(ed, func(err error) tea.Msg { return editorReturnMsg{err: err} })
}
return m, nil
Expand All @@ -450,7 +440,7 @@ func (m editorModel) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m editorModel) updateFilter(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyCtrlC:
return m, tea.Quit
return m.quit()
case tea.KeyEsc:
m.filter = nil
m.mode = modeBrowse
Expand Down Expand Up @@ -498,6 +488,14 @@ func (m editorModel) moveCursor(delta int) (tea.Model, tea.Cmd) {
return m, nil
}

// quit ends the editor, marking it so a batched rune replay stops here.
func (m editorModel) quit() (tea.Model, tea.Cmd) {
m.leaving = true
return m, tea.Quit
}

func (m editorModel) isLeaving() bool { return m.leaving }

// selectIndex moves the list selection and resets the draft. The guard
// interposition is done by requestSelect.
func (m editorModel) selectIndex(idx int) (tea.Model, tea.Cmd) {
Expand Down Expand Up @@ -554,7 +552,7 @@ func (m editorModel) cycleField() (tea.Model, tea.Cmd) {
func (m editorModel) updateFieldEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyCtrlC:
return m, tea.Quit
return m.quit()
case tea.KeyEsc:
m.mode = modeBrowse
m.inputErr = ""
Expand Down Expand Up @@ -612,7 +610,7 @@ func (m *editorModel) validateSessionName(name, allowSelf string) error {
func (m editorModel) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyCtrlC:
return m, tea.Quit
return m.quit()
case tea.KeyEsc:
m.mode = modeBrowse
m.inputErr = ""
Expand Down Expand Up @@ -680,7 +678,7 @@ func (m editorModel) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {

func (m editorModel) updateConfirmDelete(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if msg.Type == tea.KeyCtrlC {
return m, tea.Quit
return m.quit()
}
if msg.Type == tea.KeyEsc {
m.mode = modeBrowse
Expand Down Expand Up @@ -718,7 +716,7 @@ func (m editorModel) startSave() (tea.Model, tea.Cmd) {
func (m editorModel) updateDiff(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyCtrlC:
return m, tea.Quit
return m.quit()
case tea.KeyEsc:
m.mode = modeBrowse
return m, nil
Expand All @@ -745,7 +743,7 @@ func (m editorModel) updateDiff(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m editorModel) updateStale(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyCtrlC:
return m, tea.Quit
return m.quit()
case tea.KeyEsc:
m.mode = modeBrowse
return m, nil
Expand Down Expand Up @@ -791,15 +789,15 @@ func (m editorModel) requestQuit() (tea.Model, tea.Cmd) {
m.pending = pendingAction{kind: pendingQuit}
return m, nil
}
return m, tea.Quit
return m.quit()
}

// updateGuard resolves the save/discard/stay prompt, then continues the
// pending action. Guard-save skips the diff preview — choosing "save" is
// the confirmation.
func (m editorModel) updateGuard(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if msg.Type == tea.KeyCtrlC {
return m, tea.Quit
return m.quit()
}
if msg.Type == tea.KeyEsc {
m.mode = modeBrowse
Expand Down Expand Up @@ -832,7 +830,7 @@ func (m editorModel) continuePending() (tea.Model, tea.Cmd) {
m.mode = modeBrowse
switch p.kind {
case pendingQuit:
return m, tea.Quit
return m.quit()
case pendingSelect:
idx := p.target
if idx > len(m.visible)-1 {
Expand All @@ -855,7 +853,7 @@ func (m editorModel) updateWizard(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if msg.Type == tea.KeyCtrlC {
// Hard-quit like every other sub-mode — the wizard's own ctrl+c
// handling would only cancel back into the editor.
return m, tea.Quit
return m.quit()
}
nm, _ := m.wizard.Update(msg) // swallow the wizard's tea.Quit
aw, ok := nm.(addModel)
Expand Down Expand Up @@ -1382,7 +1380,7 @@ func (m editorModel) updateListEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if le.editing {
switch msg.Type {
case tea.KeyCtrlC:
return m, tea.Quit
return m.quit()
case tea.KeyEsc:
le.editing, le.input, le.errMsg = false, nil, ""
return m, nil
Expand Down Expand Up @@ -1416,7 +1414,7 @@ func (m editorModel) updateListEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
items := m.listItems()
switch msg.Type {
case tea.KeyCtrlC:
return m, tea.Quit
return m.quit()
case tea.KeyEsc:
m.mode = modeBrowse
return m, nil
Expand Down
14 changes: 14 additions & 0 deletions internal/cli/editor_ui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1253,3 +1253,17 @@ func TestEditorFilterHint(t *testing.T) {
t.Fatal("active filter did not show the live prompt")
}
}

// TestEditorBatchedKeysStopAtQuit pins that runes batched after a quit are
// dropped rather than replayed against the exiting editor.
func TestEditorBatchedKeysStopAtQuit(t *testing.T) {
m := testEditorModel(t)
nm, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("qj")})
m = nm.(editorModel)
if !isQuit(cmd) {
t.Fatal("batched qj did not quit")
}
if m.sel != 0 {
t.Fatalf("sel = %d after batched qj, want 0 (j after quit must be dropped)", m.sel)
}
}
48 changes: 20 additions & 28 deletions internal/cli/hub_ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,9 @@ type hubModel struct {
statusErr bool
statusOK bool // success feedback renders green

action hubAction
choice string
action hubAction
choice string
leaving bool // set once the hub has asked to quit

width, height int
}
Expand Down Expand Up @@ -361,24 +362,7 @@ func (m hubModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

case tea.KeyMsg:
if msg.Type == tea.KeyRunes && len(msg.Runes) > 1 {
// Key repeat batches runes; replay them one at a time.
cur := m
var cmds []tea.Cmd
for _, r := range msg.Runes {
nm, cmd := cur.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
hm, ok := nm.(hubModel)
if !ok {
return nm, cmd
}
cur = hm
if cmd != nil {
cmds = append(cmds, cmd)
}
}
if len(cmds) == 0 {
return cur, nil
}
return cur, tea.Batch(cmds...)
return replayRunes(m, msg.Runes)
}
switch m.mode {
case hubBrowse:
Expand All @@ -395,7 +379,7 @@ func (m hubModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m hubModel) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyCtrlC:
return m, tea.Quit
return m.quit()
case tea.KeyUp, tea.KeyCtrlP, tea.KeyCtrlK:
return m.move(-1)
case tea.KeyDown, tea.KeyCtrlN, tea.KeyCtrlJ:
Expand All @@ -404,15 +388,15 @@ func (m hubModel) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if c, ok := m.selected(); ok && m.pending == "" {
m.choice = c.Name
m.action = hubAttach
return m, tea.Quit
return m.quit()
}
return m, nil
case tea.KeyCtrlE:
if c, ok := m.selected(); ok && m.pending == "" {
if _, managed := m.sessions[c.Name]; managed {
m.choice = c.Name
m.action = hubEdit
return m, tea.Quit
return m.quit()
}
// Same feedback as S: a silent no-op reads as a dead key.
m.status = c.Name + " is not in the config"
Expand All @@ -429,14 +413,14 @@ func (m hubModel) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.pending != "" {
return m, nil // let the in-flight action finish (ctrl+c overrides)
}
return m, tea.Quit
return m.quit()
}
switch string(msg.Runes) {
case "q":
if m.pending != "" {
return m, nil
}
return m, tea.Quit
return m.quit()
case "j":
return m.move(1)
case "k":
Expand Down Expand Up @@ -491,15 +475,15 @@ func (m hubModel) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
m.choice = c.Name
m.action = hubImport
return m, tea.Quit
return m.quit()
}
return m, nil
}

func (m hubModel) updateFilter(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyCtrlC:
return m, tea.Quit
return m.quit()
case tea.KeyEsc:
m.filter = nil
m.mode = hubBrowse
Expand Down Expand Up @@ -529,7 +513,7 @@ func (m hubModel) updateFilter(msg tea.KeyMsg) (tea.Model, tea.Cmd) {

func (m hubModel) updateConfirmKill(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if msg.Type == tea.KeyCtrlC {
return m, tea.Quit
return m.quit()
}
if msg.Type == tea.KeyEsc {
m.mode = hubBrowse
Expand All @@ -548,6 +532,14 @@ func (m hubModel) updateConfirmKill(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}

// quit ends the hub, marking it so a batched rune replay stops here.
func (m hubModel) quit() (tea.Model, tea.Cmd) {
m.leaving = true
return m, tea.Quit
}

func (m hubModel) isLeaving() bool { return m.leaving }

// move shifts the selection and restarts the preview cycle.
func (m hubModel) move(delta int) (tea.Model, tea.Cmd) {
if len(m.visible) == 0 {
Expand Down
16 changes: 16 additions & 0 deletions internal/cli/hub_ui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -618,3 +618,19 @@ func TestHubTabbyPreviewKeepsFooter(t *testing.T) {
t.Fatal("footer (shortcuts bar) missing from the rendered view")
}
}

// TestHubBatchedKeysStopAtQuit pins that a batched rune run stops at the
// rune that quits: fast-typed "qi" on an unmanaged session must quit, not
// fall through to the import handoff.
func TestHubBatchedKeysStopAtQuit(t *testing.T) {
m := testHubModel(t, nil)
m, _ = hubRunes(t, m, "jj") // scratch: running, unmanaged
nm, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("qi")})
m = nm.(hubModel)
if !isQuit(cmd) {
t.Fatal("batched qi did not quit")
}
if m.action != hubQuit || m.choice != "" {
t.Fatalf("batched qi: action=%v choice=%q, want a plain quit", m.action, m.choice)
}
}
Loading
Loading