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
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v1.6.0
v1.6.1
13 changes: 12 additions & 1 deletion cmd/book/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions internal/model/shelf_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
48 changes: 48 additions & 0 deletions internal/model/shelf_model_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
})
}
11 changes: 11 additions & 0 deletions internal/model/tea.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading