diff --git a/VERSION b/VERSION index b7c0a9b..0236045 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.6.0 +v1.6.1 diff --git a/cmd/book/main.go b/cmd/book/main.go index 7a85953..b0dea75 100644 --- a/cmd/book/main.go +++ b/cmd/book/main.go @@ -700,12 +700,23 @@ func Main() { // runProgram runs a RootScreen TUI and prints its completion output after the // program exits. The interactive form renders in the alternate screen buffer // and is discarded on exit, so the caller prints the result below the banner -// instead of leaving selector fragments behind. +// instead of leaving selector fragments behind. Screens that need no input +// (model.NonInteractive) skip the program entirely: starting and quitting a +// tea program instantly races the terminal's capability replies into the +// shell prompt (charmbracelet/bubbletea#1590). func runProgram(screen model.RootScreen) error { + if ni, ok := screen.Model.(model.NonInteractive); ok && ni.SkipProgram() { + return printResult(screen.Model) + } m, err := tea.NewProgram(screen).Run() if err != nil { return err } + return printResult(m) +} + +// printResult surfaces a model's terminal error and completion output. +func printResult(m tea.Model) error { if ep, ok := m.(model.ErrorProvider); ok { if err := ep.Error(); err != nil { return err diff --git a/internal/model/shelf_model.go b/internal/model/shelf_model.go index dd99df6..105c027 100644 --- a/internal/model/shelf_model.go +++ b/internal/model/shelf_model.go @@ -98,6 +98,13 @@ func (m getShelfModel) View() tea.View { return altScreenView(s.Base.Render(header + "\n" + body + "\n\n" + footer)) } +// SkipProgram implements NonInteractive. The list action hides every form +// group, so the screen has nothing to ask and running it through a tea +// program would quit instantly, leaking terminal capability replies. +func (m getShelfModel) SkipProgram() bool { + return m.action == "list" +} + // ResultView returns the completion output for the caller to print after the // program exits. func (m getShelfModel) ResultView() string { @@ -162,6 +169,12 @@ func GetShelfForm(bs *book.BookShelves, config *theme.UIConfig, action string) g WithShowErrors(false). WithTheme(config.Theme.HuhTheme(config.Interactive)) + if action == "list" { + // Every group is hidden for list, so the form is complete by + // construction and never runs; see getShelfModel.SkipProgram. + m.book.form.State = huh.StateCompleted + } + return getShelfModel{ get: m, action: action, diff --git a/internal/model/shelf_model_test.go b/internal/model/shelf_model_test.go new file mode 100644 index 0000000..2a02ebd --- /dev/null +++ b/internal/model/shelf_model_test.go @@ -0,0 +1,48 @@ +package model + +import ( + "maps" + "strings" + "testing" + + "github.com/polymorcodeus/book/internal/theme" + "github.com/polymorcodeus/book/pkg/book" +) + +func testUIConfig() *theme.UIConfig { + cfg := &theme.UIConfig{ + Config: &book.Config{Interactive: true}, + Theme: theme.NewTheme(nil), + Templates: make(map[string]book.ViewTemplate), + } + maps.Copy(cfg.Templates, book.DefaultViewTemplates) + return cfg +} + +func TestGetShelfFormSkipProgram(t *testing.T) { + shelf, err := book.NewShelf("archive", "") + if err != nil { + t.Fatalf("NewShelf: %v", err) + } + var bs book.BookShelves + bs.AddShelf(*shelf) + + t.Run("list skips the program and renders immediately", func(t *testing.T) { + m := GetShelfForm(&bs, testUIConfig(), "list") + if !m.SkipProgram() { + t.Fatal("list action should skip the tea program") + } + if got := m.ResultView(); !strings.Contains(got, "archive") { + t.Errorf("ResultView missing shelf name, got %q", got) + } + }) + + t.Run("other actions keep the program", func(t *testing.T) { + for _, action := range []string{"add", "edit", "get"} { + m := GetShelfForm(&bs, testUIConfig(), action) + if m.SkipProgram() { + t.Errorf("action %q should run the tea program", action) + } + } + }) +} diff --git a/internal/model/tea.go b/internal/model/tea.go index 8484f03..730199f 100644 --- a/internal/model/tea.go +++ b/internal/model/tea.go @@ -162,6 +162,17 @@ type ErrorProvider interface { Error() error } +// NonInteractive is implemented by screens whose form has no visible groups +// for their action (e.g. shelf list). Such a form completes on the first +// update, so the program starts and quits within milliseconds. Quitting that +// fast races the terminal's capability-query replies: they land in the tty +// buffer after the input reader stops and leak into the shell prompt +// (charmbracelet/bubbletea#1590). runProgram skips the tea program for these +// screens and prints the result directly. +type NonInteractive interface { + SkipProgram() bool +} + // altScreenView returns a view rendered in the alternate screen buffer. The // interactive form is drawn there and discarded on exit, leaving the main // screen clean for the caller to print the result.