From ae9f17eed2915cbde5b26982edb4f00db8c5f20a Mon Sep 17 00:00:00 2001 From: Don Brown Date: Wed, 15 Apr 2026 15:21:09 -0600 Subject: [PATCH] Fix terminal reset, bulk flash, closed PRs - Give tea a duped stdout FD so autoupdater's FD-1 /dev/null redirect can't swallow alt-screen exit - Gate bulk-approve transition on all repo list fetches completing to stop mid-startup flicker - Hide CLOSED (non-merged) PRs unless they have new activity Closes #18 --- cmd/prx/main.go | 8 ++++++- internal/autoupdate/stdout_unix.go | 31 +++++++++++++++++++++++++++ internal/autoupdate/stdout_windows.go | 7 ++++++ internal/tui/model_helpers.go | 11 ++++++++++ 4 files changed, 56 insertions(+), 1 deletion(-) diff --git a/cmd/prx/main.go b/cmd/prx/main.go index 4541eff..eaa5dc6 100644 --- a/cmd/prx/main.go +++ b/cmd/prx/main.go @@ -65,8 +65,14 @@ func main() { return err } + // Hand tea a duped stdout and point FD 1 at /dev/null so the + // background autoupdater (which writes directly to FD 1) can't + // clobber tea's terminal-control sequences and leave the terminal + // in alt-screen mode after exit. + teaOut := autoupdate.RedirectStdoutToDevNull() + m := tui.New(a) - p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) + p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion(), tea.WithOutput(teaOut)) go func() { p.Send(tui.SetProgramMsg{Program: p}) }() diff --git a/internal/autoupdate/stdout_unix.go b/internal/autoupdate/stdout_unix.go index a440f78..fbb1540 100644 --- a/internal/autoupdate/stdout_unix.go +++ b/internal/autoupdate/stdout_unix.go @@ -37,3 +37,34 @@ func suppressStdout() func() { _ = syscall.Close(origStdout) } } + +// RedirectStdoutToDevNull dups the current stdout into a new *os.File and +// points file descriptor 1 at /dev/null. The returned file is a handle to the +// real terminal for callers (e.g. Bubble Tea) that need to keep writing to it; +// anything written directly to FD 1 afterward (including by third-party +// libraries like go-selfupdate) is discarded. +// +// Callers should pass the returned file to tea.WithOutput so the TUI's +// terminal-control sequences reach the real terminal even while the +// autoupdater is running. +func RedirectStdoutToDevNull() *os.File { + origFD, err := syscall.Dup(syscall.Stdout) + if err != nil { + return os.Stdout + } + + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + _ = syscall.Close(origFD) + return os.Stdout + } + + if err := unix.Dup2(int(devNull.Fd()), syscall.Stdout); err != nil { + _ = devNull.Close() + _ = syscall.Close(origFD) + return os.Stdout + } + _ = devNull.Close() + + return os.NewFile(uintptr(origFD), "/dev/stdout") +} diff --git a/internal/autoupdate/stdout_windows.go b/internal/autoupdate/stdout_windows.go index 0df6654..7bf6926 100644 --- a/internal/autoupdate/stdout_windows.go +++ b/internal/autoupdate/stdout_windows.go @@ -2,7 +2,14 @@ package autoupdate +import "os" + // suppressStdout returns a no-op on Windows. func suppressStdout() func() { return func() {} } + +// RedirectStdoutToDevNull is a no-op on Windows and returns os.Stdout. +func RedirectStdoutToDevNull() *os.File { + return os.Stdout +} diff --git a/internal/tui/model_helpers.go b/internal/tui/model_helpers.go index 9b2c411..dbc83ee 100644 --- a/internal/tui/model_helpers.go +++ b/internal/tui/model_helpers.go @@ -55,6 +55,13 @@ func (m *Model) tryStartupTransition() { } } if !hasVisible { + // Don't transition to bulk approve until every repo's list fetches have + // returned — otherwise a fast-returning repo with no visible cards can + // trigger the bulk/fireworks scene before slower repos' cards arrive. + nRepos := len(m.app.Repos) + if m.openListsDone < nRepos || m.mergedListsDone < nRepos || m.trackedListsDone < nRepos { + return + } if len(m.cards) > 0 { // Cards exist but all are reviewed — show bulk approve (fireworks). logger.Info("tryStartupTransition: %d cards but none visible, entering bulk approve", len(m.cards)) @@ -111,6 +118,10 @@ func (m Model) isCardVisible(card *PRCard) bool { if card.HasNewContent { return true // new comments since last review — keep visible } + // Closed-without-merge PRs have nothing actionable; only surface on new activity. + if card.PR != nil && card.PR.State == "CLOSED" { + return false + } return !card.UserHasReviewed && !card.UserHasReacted }