diff --git a/CHANGELOG.md b/CHANGELOG.md index af7b744..c962afd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/internal/cli/editor_ui.go b/internal/cli/editor_ui.go index c826c42..47e425c 100644 --- a/internal/cli/editor_ui.go +++ b/internal/cli/editor_ui.go @@ -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 } @@ -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 @@ -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: @@ -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 @@ -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 @@ -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 @@ -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) { @@ -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 = "" @@ -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 = "" @@ -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 @@ -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 @@ -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 @@ -791,7 +789,7 @@ 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 @@ -799,7 +797,7 @@ func (m editorModel) requestQuit() (tea.Model, tea.Cmd) { // 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 @@ -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 { @@ -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) @@ -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 @@ -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 diff --git a/internal/cli/editor_ui_test.go b/internal/cli/editor_ui_test.go index 84ae246..d39984a 100644 --- a/internal/cli/editor_ui_test.go +++ b/internal/cli/editor_ui_test.go @@ -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) + } +} diff --git a/internal/cli/hub_ui.go b/internal/cli/hub_ui.go index 34c0609..d5b291d 100644 --- a/internal/cli/hub_ui.go +++ b/internal/cli/hub_ui.go @@ -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 } @@ -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: @@ -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: @@ -404,7 +388,7 @@ 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: @@ -412,7 +396,7 @@ func (m hubModel) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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" @@ -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": @@ -491,7 +475,7 @@ 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 } @@ -499,7 +483,7 @@ func (m hubModel) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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 @@ -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 @@ -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 { diff --git a/internal/cli/hub_ui_test.go b/internal/cli/hub_ui_test.go index c1c3fef..054a270 100644 --- a/internal/cli/hub_ui_test.go +++ b/internal/cli/hub_ui_test.go @@ -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) + } +} diff --git a/internal/cli/picker_ui.go b/internal/cli/picker_ui.go index 4643cf1..257756b 100644 --- a/internal/cli/picker_ui.go +++ b/internal/cli/picker_ui.go @@ -3,6 +3,7 @@ package cli import ( "strings" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" @@ -145,3 +146,33 @@ func panel(title, footer string, content []string, w, h int) string { rows = append(rows, bottomLine) return strings.Join(rows, "\n") } + +// leaver is a full-screen model that can report it has handed the terminal +// off: quit, or suspended for $EDITOR. +type leaver interface { + tea.Model + isLeaving() bool +} + +// replayRunes feeds a batched KeyRunes message (key repeat, fast typing) +// back through the model one rune at a time, since hotkeys match single +// runes. Commands from every rune are batched together. Replay stops at the +// rune that hands the terminal off, so keys typed after a quit never act on +// a model that is already leaving. +func replayRunes[M leaver](m M, runes []rune) (tea.Model, tea.Cmd) { + cur := m + var cmds []tea.Cmd + for _, r := range runes { + nm, cmd := cur.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + next, ok := nm.(M) + if !ok { + return nm, tea.Batch(append(cmds, cmd)...) + } + cur = next + cmds = append(cmds, cmd) + if cur.isLeaving() { + break + } + } + return cur, tea.Batch(cmds...) +} diff --git a/internal/cli/validate.go b/internal/cli/validate.go index d21b5f6..13406ef 100644 --- a/internal/cli/validate.go +++ b/internal/cli/validate.go @@ -23,30 +23,31 @@ func runValidate(cmd *cobra.Command, args []string) error { if err != nil { return err } + out := cmd.OutOrStdout() - fmt.Println("OK: configuration is valid") - fmt.Println() - fmt.Printf("Sessions: %d\n", len(cfg.Sessions)) - fmt.Printf("Layouts: %d\n", len(cfg.Layouts)) + fmt.Fprintln(out, "OK: configuration is valid") + fmt.Fprintln(out) + fmt.Fprintf(out, "Sessions: %d\n", len(cfg.Sessions)) + fmt.Fprintf(out, "Layouts: %d\n", len(cfg.Layouts)) if len(cfg.Sessions) > 0 { - fmt.Println() - fmt.Println("Sessions:") + fmt.Fprintln(out) + fmt.Fprintln(out, "Sessions:") for _, name := range cfg.ListSessionNames() { s := cfg.Sessions[name] if s.IsSimple() { - fmt.Printf(" - %s (simple: %d hosts)\n", name, len(s.Hosts)) + fmt.Fprintf(out, " - %s (simple: %d hosts)\n", name, len(s.Hosts)) } else { - fmt.Printf(" - %s (complex: %d windows)\n", name, len(s.Windows)) + fmt.Fprintf(out, " - %s (complex: %d windows)\n", name, len(s.Windows)) } } } if len(cfg.Layouts) > 0 { - fmt.Println() - fmt.Println("Layouts:") - for name, layout := range cfg.Layouts { - fmt.Printf(" - %s (%d panes)\n", name, len(layout.Panes)) + fmt.Fprintln(out) + fmt.Fprintln(out, "Layouts:") + for _, name := range cfg.ListLayoutNames() { + fmt.Fprintf(out, " - %s (%d panes)\n", name, len(cfg.Layouts[name].Panes)) } } return nil diff --git a/internal/cli/validate_test.go b/internal/cli/validate_test.go new file mode 100644 index 0000000..8739037 --- /dev/null +++ b/internal/cli/validate_test.go @@ -0,0 +1,37 @@ +package cli + +import ( + "bytes" + "strings" + "testing" +) + +// TestValidateListsLayoutsSorted pins that mox validate prints layouts in +// name order, not Go's randomized map order. +func TestValidateListsLayoutsSorted(t *testing.T) { + path := writeTempConfig(t, `layouts: + zeta: + panes: [{split: root}] + alpha: + panes: [{split: root}] + mid: + panes: [{split: root}] +sessions: + dev: + hosts: [a] +`) + for i := 0; i < 5; i++ { // map order varies run to run; one pass could pass by luck + var out bytes.Buffer + cmd := NewRootCommand() + cmd.SetArgs([]string{"validate", "-c", path}) + cmd.SetOut(&out) + if err := cmd.Execute(); err != nil { + t.Fatalf("validate: %v", err) + } + got := out.String() + a, m, z := strings.Index(got, "- alpha"), strings.Index(got, "- mid"), strings.Index(got, "- zeta") + if a < 0 || m < 0 || z < 0 || a > m || m > z { + t.Fatalf("layouts not sorted:\n%s", got) + } + } +} diff --git a/internal/config/loader.go b/internal/config/loader.go index 03d36c1..c3ac68a 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -123,3 +123,13 @@ func (c *Config) ListSessionNames() []string { slices.Sort(names) return names } + +// ListLayoutNames returns all layout names sorted alphabetically. +func (c *Config) ListLayoutNames() []string { + names := make([]string, 0, len(c.Layouts)) + for name := range c.Layouts { + names = append(names, name) + } + slices.Sort(names) + return names +} diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 7870160..a9e5f43 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -149,3 +149,12 @@ func TestEffectivePath_LocalDirIgnored(t *testing.T) { t.Error("a directory named .mox.yml should not count as a local config") } } + +func TestListLayoutNamesSorted(t *testing.T) { + cfg := &Config{Layouts: map[string]*Layout{"zeta": {}, "alpha": {}, "mid": {}}} + got := cfg.ListLayoutNames() + want := []string{"alpha", "mid", "zeta"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("ListLayoutNames() = %v, want %v", got, want) + } +} diff --git a/internal/tmux/operations.go b/internal/tmux/operations.go index eb2df6f..7a79ed5 100644 --- a/internal/tmux/operations.go +++ b/internal/tmux/operations.go @@ -131,7 +131,7 @@ func (c *Client) AttachSession(name string) error { // CreateWindow creates a new window in a session and returns the new window's id. func (c *Client) CreateWindow(session, name, startDir string) (string, error) { - args := []string{"new-window", "-t", session + ":", "-P", "-F", "#{window_id}", "-n", name} + args := []string{"new-window", "-t", "=" + session + ":", "-P", "-F", "#{window_id}", "-n", name} if startDir != "" { args = append(args, "-c", startDir) } diff --git a/internal/tmux/operations_test.go b/internal/tmux/operations_test.go index 195282c..955ef95 100644 --- a/internal/tmux/operations_test.go +++ b/internal/tmux/operations_test.go @@ -127,3 +127,32 @@ func TestSessionExistsNoServer(t *testing.T) { t.Errorf("SessionExists returned true for non-existent session") } } + +// TestCreateWindowExactSessionMatch pins that CreateWindow targets its +// session by exact name. Without the "=" prefix tmux falls back to prefix +// matching, so a missing session would quietly resolve to a longer-named +// one and the window would land there. +func TestCreateWindowExactSessionMatch(t *testing.T) { + client, err := NewClient() + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + const longer = "mox-test-cwexact-longer" + _ = client.KillSession(longer) + if err := client.CreateSession(longer, "", "main"); err != nil { + t.Fatalf("CreateSession() error = %v", err) + } + t.Cleanup(func() { _ = client.KillSession(longer) }) + + if _, err := client.CreateWindow("mox-test-cwexact", "stray", ""); err == nil { + t.Fatal("CreateWindow on a missing session succeeded (prefix-matched the longer name)") + } + out, err := client.Run("list-windows", "-t", "="+longer, "-F", "#{window_name}") + if err != nil { + t.Fatalf("list-windows: %v", err) + } + if strings.Contains(out, "stray") { + t.Fatalf("stray window landed in %q: %q", longer, out) + } +}