Repository: charmbracelet/bubbletea (go)

Issue:
Cursor visible in `cmd.exe` on Windows when the altscreen is active
I've tested a bubbletea program on windows 11 and it seems that the cursor always stays visible in the left corner of the terminal. ~~In the windows terminal as well as in powershell.~~ It seems that newer versions of windows terminal have fixed this. Affected is only cmd.exe / powershell.

Pull request description:
fix: maintain cursor visibility across altscreen state switch
Based on #462 and #452 by @londek, but fixes maintaining the current visibility state across altscreen state changes.

This makes the behavior consistent across terminals, some of which keep separate state for altscreen and regular buffer.

Fixes #190.

Patch:
diff --git a/commands.go b/commands.go
index 08f1a2e..10444e0 100644
--- a/commands.go
+++ b/commands.go
@@ -24,13 +24,13 @@ func Batch(cmds ...Cmd) Cmd {
 		return nil
 	}
 	return func() Msg {
-		return batchMsg(validCmds)
+		return BatchMsg(validCmds)
 	}
 }
 
-// batchMsg is the internal message used to perform a bunch of commands. You
-// can send a batchMsg with Batch.
-type batchMsg []Cmd
+// BatchMsg is a message used to perform a bunch of commands concurrently with
+// no ordering guarantees. You can send a BatchMsg with Batch.
+type BatchMsg []Cmd
 
 // Sequence runs the given commands one at a time, in order. Contrast this with
 // Batch, which runs commands concurrently.
diff --git a/signals_unix.go b/signals_unix.go
index ef9ed9a..826f58b 100644
--- a/signals_unix.go
+++ b/signals_unix.go
@@ -4,18 +4,15 @@
 package tea
 
 import (
-	"context"
 	"os"
 	"os/signal"
 	"syscall"
-
-	"golang.org/x/term"
 )
 
 // listenForResize sends messages (or errors) when the terminal resizes.
 // Argument output should be the file descriptor for the terminal; usually
 // os.Stdout.
-func listenForResize(ctx context.Context, output *os.File, msgs chan Msg, errs chan error, done chan struct{}) {
+func (p *Program) listenForResize(done chan struct{}) {
 	sig := make(chan os.Signal, 1)
 	signal.Notify(sig, syscall.SIGWINCH)
 
@@ -26,20 +23,11 @@ func listenForResize(ctx context.Context, output *os.File, msgs chan Msg, errs c
 
 	for {
 		select {
-		case <-ctx.Done():
+		case <-p.ctx.Done():
 			return
 		case <-sig:
 		}
 
-		w, h, err := term.GetSize(int(output.Fd()))
-		if err != nil {
-			errs <- err
-		}
-
-		select {
-		case <-ctx.Done():
-			return
-		case msgs <- WindowSizeMsg{w, h}:
-		}
+		p.checkResize()
 	}
 }
diff --git a/signals_windows.go b/signals_windows.go
index c60ca57..2fc6f8a 100644
--- a/signals_windows.go
+++ b/signals_windows.go
@@ -3,15 +3,8 @@
 
 package tea
 
-import (
-	"context"
-	"os"
-)
-
 // listenForResize is not available on windows because windows does not
 // implement syscall.SIGWINCH.
-func listenForResize(ctx context.Context, output *os.File, msgs chan Msg,
-	errs chan error, done chan struct{},
-) {
+func (p *Program) listenForResize(done chan struct{}) {
 	close(done)
 }
diff --git a/standard_renderer.go b/standard_renderer.go
index 7d68b41..17b44e2 100644
--- a/standard_renderer.go
+++ b/standard_renderer.go
@@ -38,6 +38,9 @@ type standardRenderer struct {
 	useANSICompressor  bool
 	once               sync.Once
 
+	// cursor visibility state
+	cursorHidden bool
+
 	// essentially whether or not we're using the full size of the terminal
 	altScreenActive bool
 
@@ -136,6 +139,15 @@ func (r *standardRenderer) flush() {
 	out := termenv.NewOutput(buf)
 
 	newLines := strings.Split(r.buf.String(), "\n")
+
+	// If we know the output's height, we can use it to determine how many
+	// lines we can render. We drop lines from the top of the render buffer if
+	// necessary, as we can't navigate the cursor into the terminal's scrollback
+	// buffer.
+	if r.height > 0 && len(newLines) > r.height {
+		newLines = newLines[len(newLines)-r.height:]
+	}
+
 	numLinesThisFlush := len(newLines)
 	oldLines := strings.Split(r.lastRender, "\n")
 	skipLines := make(map[int]struct{})
@@ -290,6 +302,15 @@ func (r *standardRenderer) enterAltScreen() {
 	r.out.ClearScreen()
 	r.out.MoveCursor(1, 1)
 
+	// cmd.exe and other terminals keep separate cursor states for the AltScreen
+	// and the main buffer. We have to explicitly reset the cursor visibility
+	// whenever we enter AltScreen.
+	if r.cursorHidden {
+		r.out.HideCursor()
+	} else {
+		r.out.ShowCursor()
+	}
+
 	r.repaint()
 }
 
@@ -304,6 +325,15 @@ func (r *standardRenderer) exitAltScreen() {
 	r.altScreenActive = false
 	r.out.ExitAltScreen()
 
+	// cmd.exe and other terminals keep separate cursor states for the AltScreen
+	// and the main buffer. We have to explicitly reset the cursor visibility
+	// whenever we exit AltScreen.
+	if r.cursorHidden {
+		r.out.HideCursor()
+	} else {
+		r.out.ShowCursor()
+	}
+
 	r.repaint()
 }
 
@@ -311,6 +341,7 @@ func (r *standardRenderer) showCursor() {
 	r.mtx.Lock()
 	defer r.mtx.Unlock()
 
+	r.cursorHidden = false
 	r.out.ShowCursor()
 }
 
@@ -318,6 +349,7 @@ func (r *standardRenderer) hideCursor() {
 	r.mtx.Lock()
 	defer r.mtx.Unlock()
 
+	r.cursorHidden = true
 	r.out.HideCursor()
 }
 
diff --git a/tea.go b/tea.go
index 1519ade..d2c19ff 100644
--- a/tea.go
+++ b/tea.go
@@ -24,7 +24,6 @@ import (
 	isatty "github.com/mattn/go-isatty"
 	"github.com/muesli/cancelreader"
 	"github.com/muesli/termenv"
-	"golang.org/x/term"
 )
 
 // ErrProgramKilled is returned by [Program.Run] when the program got killed.
@@ -142,6 +141,9 @@ func NewProgram(model Model, opts ...ProgramOption) *Program {
 		msgs:         make(chan Msg),
 	}
 
+	// Initialize context and teardown channel.
+	p.ctx, p.cancel = context.WithCancel(context.Background())
+
 	// Apply all options to the program.
 	for _, opt := range opts {
 		opt(p)
@@ -202,20 +204,10 @@ func (p *Program) handleResize() chan struct{} {
 
 	if f, ok := p.output.TTY().(*os.File); ok && isatty.IsTerminal(f.Fd()) {
 		// Get the initial terminal size and send it to the program.
-		go func() {
-			w, h, err := term.GetSize(int(f.Fd()))
-			if err != nil {
-				p.errs <- err
-			}
-
-			select {
-			case <-p.ctx.Done():
-			case p.msgs <- WindowSizeMsg{w, h}:
-			}
-		}()
+		go p.checkResize()
 
 		// Listen for window resizes.
-		go listenForResize(p.ctx, f, p.msgs, p.errs, ch)
+		go p.listenForResize(ch)
 	} else {
 		close(ch)
 	}
@@ -247,10 +239,8 @@ func (p *Program) handleCommands(cmds chan Cmd) chan struct{} {
 				// possible to cancel them so we'll have to leak the goroutine
 				// until Cmd returns.
 				go func() {
-					select {
-					case p.msgs <- cmd():
-					case <-p.ctx.Done():
-					}
+					msg := cmd() // this can be long.
+					p.Send(msg)
 				}()
 			}
 		}
@@ -305,7 +295,7 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
 				// NB: this blocks.
 				p.exec(msg.cmd, msg.fn)
 
-			case batchMsg:
+			case BatchMsg:
 				for _, cmd := range msg {
 					cmds <- cmd
 				}
@@ -315,10 +305,7 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
 				go func() {
 					// Execute commands one at a time, in order.
 					for _, cmd := range msg {
-						select {
-						case p.msgs <- cmd():
-						case <-p.ctx.Done():
-						}
+						p.Send(cmd())
 					}
 				}()
 			}
@@ -344,7 +331,6 @@ func (p *Program) Run() (Model, error) {
 	cmds := make(chan Cmd)
 	p.errs = make(chan error)
 
-	p.ctx, p.cancel = context.WithCancel(context.Background())
 	defer p.cancel()
 
 	switch {
@@ -445,7 +431,6 @@ func (p *Program) Run() (Model, error) {
 		if err := p.initCancelReader(); err != nil {
 			return model, err
 		}
-		defer p.cancelReader.Close() //nolint:errcheck
 	}
 
 	// Handle resize events.
@@ -471,6 +456,7 @@ func (p *Program) Run() (Model, error) {
 	if p.cancelReader.Cancel() {
 		p.waitForReadLoop()
 	}
+	_ = p.cancelReader.Close()
 
 	// Wait for all handlers to finish.
 	handlers.shutdown()
@@ -504,10 +490,14 @@ func (p *Program) Start() error {
 // messages to be injected from outside the program for interoperability
 // purposes.
 //
-// If the program is not running this will be a no-op, so it's safe to
-// send messages if the program is unstarted, or has exited.
+// If the program hasn't started yet this will be a blocking operation.
+// If the program has already been terminated this will be a no-op, so it's safe
+// to send messages after the program has exited.
 func (p *Program) Send(msg Msg) {
-	p.msgs <- msg
+	select {
+	case <-p.ctx.Done():
+	case p.msgs <- msg:
+	}
 }
 
 // Quit is a convenience function for quitting Bubble Tea programs. Use it
@@ -576,6 +566,12 @@ func (p *Program) RestoreTerminal() error {
 		go p.Send(repaintMsg{})
 	}
 
+	// If the output is a terminal, it may have been resized while another
+	// process was at the foreground, in which case we may not have received
+	// SIGWINCH. Detect any size change now and propagate the new size as
+	// needed.
+	go p.checkResize()
+
 	return nil
 }
 
diff --git a/tty.go b/tty.go
index 4f33d86..3ab6639 100644
--- a/tty.go
+++ b/tty.go
@@ -3,9 +3,12 @@ package tea
 import (
 	"errors"
 	"io"
+	"os"
 	"time"
 
+	isatty "github.com/mattn/go-isatty"
 	"github.com/muesli/cancelreader"
+	"golang.org/x/term"
 )
 
 func (p *Program) initTerminal() error {
@@ -76,7 +79,10 @@ func (p *Program) readLoop() {
 		msgs, err := readInputs(p.cancelReader)
 		if err != nil {
 			if !errors.Is(err, io.EOF) && !errors.Is(err, cancelreader.ErrCanceled) {
-				p.errs <- err
+				select {
+				case <-p.ctx.Done():
+				case p.errs <- err:
+				}
 			}
 
 			return
@@ -98,3 +104,28 @@ func (p *Program) waitForReadLoop() {
 		// though it was not able to cancel the read.
 	}
 }
+
+// checkResize detects the current size of the output and informs the program
+// via a WindowSizeMsg.
+func (p *Program) checkResize() {
+	f, ok := p.output.TTY().(*os.File)
+	if !ok || !isatty.IsTerminal(f.Fd()) {
+		// can't query window size
+		return
+	}
+
+	w, h, err := term.GetSize(int(f.Fd()))
+	if err != nil {
+		select {
+		case <-p.ctx.Done():
+		case p.errs <- err:
+		}
+
+		return
+	}
+
+	p.Send(WindowSizeMsg{
+		Width:  w,
+		Height: h,
+	})
+}


Test patch:
diff --git a/commands_test.go b/commands_test.go
index 9caf18e..0ec1419 100644
--- a/commands_test.go
+++ b/commands_test.go
@@ -94,13 +94,13 @@ func TestBatch(t *testing.T) {
 	})
 	t.Run("single cmd", func(t *testing.T) {
 		b := Batch(Quit)()
-		if l := len(b.(batchMsg)); l != 1 {
+		if l := len(b.(BatchMsg)); l != 1 {
 			t.Fatalf("expected a []Cmd with len 1, got %d", l)
 		}
 	})
 	t.Run("mixed nil cmds", func(t *testing.T) {
 		b := Batch(nil, Quit, nil, Quit, nil, nil)()
-		if l := len(b.(batchMsg)); l != 2 {
+		if l := len(b.(BatchMsg)); l != 2 {
 			t.Fatalf("expected a []Cmd with len 2, got %d", l)
 		}
 	})
diff --git a/screen_test.go b/screen_test.go
index 33b5169..2f305e3 100644
--- a/screen_test.go
+++ b/screen_test.go
@@ -19,12 +19,12 @@ func TestClearMsg(t *testing.T) {
 		{
 			name:     "altscreen",
 			cmds:     []Cmd{EnterAltScreen, ExitAltScreen},
-			expected: "\x1b[?25l\x1b[?1049h\x1b[2J\x1b[1;1H\x1b[1;1H\x1b[?1049lsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l",
+			expected: "\x1b[?25l\x1b[?1049h\x1b[2J\x1b[1;1H\x1b[1;1H\x1b[?25l\x1b[?1049l\x1b[?25lsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l",
 		},
 		{
 			name:     "altscreen_autoexit",
 			cmds:     []Cmd{EnterAltScreen},
-			expected: "\x1b[?25l\x1b[?1049h\x1b[2J\x1b[1;1H\x1b[1;1Hsuccess\r\n\x1b[2;0H\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1049l",
+			expected: "\x1b[?25l\x1b[?1049h\x1b[2J\x1b[1;1H\x1b[1;1H\x1b[?25lsuccess\r\n\x1b[2;0H\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1049l\x1b[?25h",
 		},
 		{
 			name:     "mouse_cellmotion",
diff --git a/tea_test.go b/tea_test.go
index c61c518..6140677 100644
--- a/tea_test.go
+++ b/tea_test.go
@@ -108,7 +108,7 @@ func TestTeaBatchMsg(t *testing.T) {
 	m := &testModel{}
 	p := NewProgram(m, WithInput(&in), WithOutput(&buf))
 	go func() {
-		p.Send(batchMsg{inc, inc})
+		p.Send(BatchMsg{inc, inc})
 
 		for {
 			time.Sleep(time.Millisecond)
@@ -149,3 +149,29 @@ func TestTeaSequenceMsg(t *testing.T) {
 		t.Fatalf("counter should be 2, got %d", m.counter.Load())
 	}
 }
+
+func TestTeaSend(t *testing.T) {
+	var buf bytes.Buffer
+	var in bytes.Buffer
+
+	m := &testModel{}
+	p := NewProgram(m, WithInput(&in), WithOutput(&buf))
+
+	// sending before the program is started is a blocking operation
+	go p.Send(Quit())
+
+	if _, err := p.Run(); err != nil {
+		t.Fatal(err)
+	}
+
+	// sending a message after program has quit is a no-op
+	p.Send(Quit())
+}
+
+func TestTeaNoRun(t *testing.T) {
+	var buf bytes.Buffer
+	var in bytes.Buffer
+
+	m := &testModel{}
+	NewProgram(m, WithInput(&in), WithOutput(&buf))
+}