vendor/github.com/charmbracelet/bubbletea/v2/.golangci.yml 🔗
@@ -7,7 +7,6 @@ linters:
     - exhaustive
     - goconst
     - godot
-    - godox
     - gomoddirectives
     - goprintffuncname
     - gosec
  Ayman Bagabas created
vendor/github.com/charmbracelet/bubbletea/v2/.golangci.yml             |  1 
vendor/github.com/charmbracelet/bubbletea/v2/README.md                 |  1 
vendor/github.com/charmbracelet/bubbletea/v2/cursed_renderer.go        | 15 
vendor/github.com/charmbracelet/bubbletea/v2/exec.go                   |  4 
vendor/github.com/charmbracelet/bubbletea/v2/nil_renderer.go           |  3 
vendor/github.com/charmbracelet/bubbletea/v2/renderer.go               |  3 
vendor/github.com/charmbracelet/bubbletea/v2/standard_renderer.go      |  3 
vendor/github.com/charmbracelet/bubbletea/v2/tea.go                    | 21 
vendor/github.com/charmbracelet/ultraviolet/event.go                   | 36 
vendor/github.com/charmbracelet/ultraviolet/parse.go                   |  8 
vendor/github.com/charmbracelet/ultraviolet/terminal_reader.go         | 43 
vendor/github.com/charmbracelet/ultraviolet/terminal_reader_windows.go | 84 
vendor/modules.txt                                                     | 10 
13 files changed, 172 insertions(+), 60 deletions(-)
@@ -7,7 +7,6 @@ linters:
     - exhaustive
     - goconst
     - godot
-    - godox
     - gomoddirectives
     - goprintffuncname
     - gosec
  @@ -10,7 +10,6 @@
     <a href="https://github.com/charmbracelet/bubbletea/releases"><img src="https://img.shields.io/github/release/charmbracelet/bubbletea.svg" alt="Latest Release"></a>
     <a href="https://pkg.go.dev/github.com/charmbracelet/bubbletea?tab=doc"><img src="https://godoc.org/github.com/charmbracelet/bubbletea?status.svg" alt="GoDoc"></a>
     <a href="https://github.com/charmbracelet/bubbletea/actions"><img src="https://github.com/charmbracelet/bubbletea/actions/workflows/build.yml/badge.svg" alt="Build Status"></a>
-    <a href="https://www.phorm.ai/query?projectId=a0e324b6-b706-4546-b951-6671ea60c13f"><img src="https://stuff.charm.sh/misc/phorm-badge.svg" alt="phorm.ai"></a>
 </p>
 
 The fun, functional and stateful way to build terminal apps. A Go framework
  @@ -104,6 +104,21 @@ func (s *cursedRenderer) writeString(str string) (int, error) {
 	return s.scr.WriteString(str)
 }
 
+// resetLinesRendered implements renderer.
+func (s *cursedRenderer) resetLinesRendered() {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	if !s.altScreen {
+		var frameHeight int
+		if s.lastFrame != nil {
+			frameHeight = strings.Count(*s.lastFrame, "\n") + 1
+		}
+
+		io.WriteString(s.w, strings.Repeat("\n", max(0, frameHeight-1))) //nolint:errcheck,gosec
+	}
+}
+
 // flush implements renderer.
 func (s *cursedRenderer) flush(p *Program) error {
 	s.mu.Lock()
  @@ -114,6 +114,7 @@ func (p *Program) exec(c ExecCommand, fn ExecCallback) {
 
 	// Execute system command.
 	if err := c.Run(); err != nil {
+		p.renderer.resetLinesRendered()
 		_ = p.RestoreTerminal() // also try to restore the terminal.
 		if fn != nil {
 			go p.Send(fn(err))
@@ -121,6 +122,9 @@ func (p *Program) exec(c ExecCommand, fn ExecCallback) {
 		return
 	}
 
+	// Maintain the existing output from the command
+	p.renderer.resetLinesRendered()
+
 	// Have the program re-capture input.
 	err := p.RestoreTerminal()
 	if fn != nil {
  @@ -67,4 +67,5 @@ func (n nilRenderer) setBackgroundColor(color.Color) {}
 func (n nilRenderer) setWindowTitle(string) {}
 
 // hit implements the Renderer interface.
-func (n nilRenderer) hit(MouseMsg) []Msg { return nil }
+func (n nilRenderer) hit(MouseMsg) []Msg  { return nil }
+func (n nilRenderer) resetLinesRendered() {}
  @@ -71,6 +71,9 @@ type renderer interface {
 	repaint()
 
 	writeString(string) (int, error)
+
+	// resetLinesRendered ensures exec output remains on screen on exit
+	resetLinesRendered()
 }
 
 // repaintMsg forces a full repaint.
  @@ -382,3 +382,6 @@ func (r *standardRenderer) setForegroundColor(c color.Color) {}
 func (r *standardRenderer) setBackgroundColor(c color.Color) {}
 func (r *standardRenderer) setWindowTitle(s string)          {}
 func (r *standardRenderer) hit(MouseMsg) []Msg               { return nil }
+func (r *standardRenderer) resetLinesRendered() {
+	r.linesRendered = 0
+}
  @@ -1220,13 +1220,22 @@ func (p *Program) recoverFromPanic(r any) {
 	default:
 	}
 	p.cancel() // Just in case a previous shutdown has failed.
-	s := strings.ReplaceAll(
-		fmt.Sprintf("Caught panic:\n\n%s\n\nRestoring terminal...\n\n", r),
-		"\n", "\r\n")
-	fmt.Fprintln(os.Stderr, s)
-	stack := strings.ReplaceAll(fmt.Sprintf("%s", debug.Stack()), "\n", "\r\n")
-	fmt.Fprintln(os.Stderr, stack)
 	p.shutdown(true)
+	// We use "\r\n" to ensure the output is formatted even when restoring the
+	// terminal does not work or when raw mode is still active.
+	rec := strings.ReplaceAll(fmt.Sprintf("%s", r), "\n", "\r\n")
+	fmt.Fprintf(os.Stderr, "Caught panic:\r\n\r\n%s\r\n\r\nRestoring terminal...\r\n\r\n", rec)
+	stack := strings.ReplaceAll(fmt.Sprintf("%s\n", debug.Stack()), "\n", "\r\n")
+	fmt.Fprint(os.Stderr, stack)
+	if v, err := strconv.ParseBool(os.Getenv("TEA_DEBUG")); err == nil && v {
+		f, err := os.Create(fmt.Sprintf("bubbletea-panic-%d.log", time.Now().Unix()))
+		if err == nil {
+			defer f.Close()        //nolint:errcheck
+			fmt.Fprintln(f, rec)   //nolint:errcheck
+			fmt.Fprintln(f)        //nolint:errcheck
+			fmt.Fprintln(f, stack) //nolint:errcheck
+		}
+	}
 }
 
 // ReleaseTerminal restores the original terminal state and cancels the input
  @@ -131,6 +131,24 @@ func (k KeyPressEvent) String() string {
 	return Key(k).String()
 }
 
+// Keystroke returns the keystroke representation of the [Key]. While less type
+// safe than looking at the individual fields, it will usually be more
+// convenient and readable to use this method when matching against keys.
+//
+// Note that modifier keys are always printed in the following order:
+//   - ctrl
+//   - alt
+//   - shift
+//   - meta
+//   - hyper
+//   - super
+//
+// For example, you'll always see "ctrl+shift+alt+a" and never
+// "shift+ctrl+alt+a".
+func (k KeyPressEvent) Keystroke() string {
+	return Key(k).Keystroke()
+}
+
 // Key returns the underlying key event. This is a syntactic sugar for casting
 // the key event to a [Key].
 func (k KeyPressEvent) Key() Key {
@@ -163,6 +181,24 @@ func (k KeyReleaseEvent) String() string {
 	return Key(k).String()
 }
 
+// Keystroke returns the keystroke representation of the [Key]. While less type
+// safe than looking at the individual fields, it will usually be more
+// convenient and readable to use this method when matching against keys.
+//
+// Note that modifier keys are always printed in the following order:
+//   - ctrl
+//   - alt
+//   - shift
+//   - meta
+//   - hyper
+//   - super
+//
+// For example, you'll always see "ctrl+shift+alt+a" and never
+// "shift+ctrl+alt+a".
+func (k KeyReleaseEvent) Keystroke() string {
+	return Key(k).Keystroke()
+}
+
 // Key returns the underlying key event. This is a convenience method and
 // syntactic sugar to satisfy the [KeyEvent] interface, and cast the key event to
 // [Key].
  @@ -748,10 +748,12 @@ func (p *SequenceParser) parseSs3(b []byte) (int, Event) {
 }
 
 func (p *SequenceParser) parseOsc(b []byte) (int, Event) {
-	defaultKey := KeyPressEvent{Code: rune(b[1]), Mod: ModAlt}
+	defaultKey := func() KeyPressEvent {
+		return KeyPressEvent{Code: rune(b[1]), Mod: ModAlt}
+	}
 	if len(b) == 2 && b[0] == ansi.ESC {
 		// short cut if this is an alt+] key
-		return 2, defaultKey
+		return 2, defaultKey()
 	}
 
 	var i int
@@ -802,7 +804,7 @@ func (p *SequenceParser) parseOsc(b []byte) (int, Event) {
 	case ansi.ESC:
 		if i >= len(b) || b[i] != '\\' {
 			if cmd == -1 || (start == 0 && end == 2) {
-				return 2, defaultKey
+				return 2, defaultKey()
 			}
 
 			// If we don't have a valid ST terminator, then this is a
  @@ -55,14 +55,15 @@ type TerminalReader struct {
 	// Windows Console API.
 	MouseMode *MouseMode
 
-	// Timeout is the escape character timeout duration. Most escape sequences
-	// start with an escape character [ansi.ESC] and are followed by one or
-	// more characters. If the next character is not received within this
-	// timeout, the reader will assume that the escape sequence is complete and
-	// will process the received characters as a complete escape sequence.
+	// EscTimeout is the escape character timeout duration. Most escape
+	// sequences start with an escape character [ansi.ESC] and are followed by
+	// one or more characters. If the next character is not received within
+	// this timeout, the reader will assume that the escape sequence is
+	// complete and will process the received characters as a complete escape
+	// sequence.
 	//
 	// By default, this is set to [DefaultEscTimeout] (50 milliseconds).
-	Timeout time.Duration
+	EscTimeout time.Duration
 
 	r     io.Reader
 	rd    cancelreader.CancelReader
@@ -85,6 +86,7 @@ type TerminalReader struct {
 	// prevent	multiple calls to the Close() method.
 	closed    bool
 	started   bool          // started indicates whether the reader has been started.
+	runOnce   sync.Once     // runOnce is used to ensure that the reader is only started once.
 	close     chan struct{} // close is a channel used to signal the reader to close.
 	closeOnce sync.Once
 	notify    chan []byte // notify is a channel used to notify the reader of new input events.
@@ -113,10 +115,10 @@ type TerminalReader struct {
 //	}
 func NewTerminalReader(r io.Reader, termType string) *TerminalReader {
 	return &TerminalReader{
-		Timeout: DefaultEscTimeout,
-		r:       r,
-		term:    termType,
-		lookup:  true, // Use lookup table by default.
+		EscTimeout: DefaultEscTimeout,
+		r:          r,
+		term:       termType,
+		lookup:     true, // Use lookup table by default.
 	}
 }
 
@@ -131,22 +133,20 @@ func (d *TerminalReader) SetLogger(logger Logger) {
 // lookup table for key sequences if it is not already set. This function
 // should be called before reading input events.
 func (d *TerminalReader) Start() (err error) {
-	if d.rd == nil {
-		d.rd, err = newCancelreader(d.r)
-		if err != nil {
-			return err
-		}
+	d.rd, err = newCancelreader(d.r)
+	if err != nil {
+		return err
 	}
 	if d.table == nil {
 		d.table = buildKeysTable(d.Legacy, d.term, d.UseTerminfo)
 	}
 	d.started = true
 	d.esc.Store(false)
-	d.timeout = time.NewTimer(d.Timeout)
+	d.timeout = time.NewTimer(d.EscTimeout)
 	d.notify = make(chan []byte)
 	d.close = make(chan struct{}, 1)
 	d.closeOnce = sync.Once{}
-	go d.run()
+	d.runOnce = sync.Once{}
 	return nil
 }
 
@@ -194,6 +194,11 @@ func (d *TerminalReader) receiveEvents(ctx context.Context, events chan<- Event)
 		return ErrReaderNotStarted
 	}
 
+	// Start the reader loop if it hasn't been started yet.
+	d.runOnce.Do(func() {
+		go d.run()
+	})
+
 	closingFunc := func() error {
 		// If we're closing, make sure to send any remaining events even if
 		// they are incomplete.
@@ -255,7 +260,7 @@ func (d *TerminalReader) run() {
 		esc := n > 0 && n <= 2 && readBuf[0] == ansi.ESC
 		if esc {
 			d.esc.Store(true)
-			d.timeout.Reset(d.Timeout)
+			d.timeout.Reset(d.EscTimeout)
 		}
 
 		d.notify <- readBuf[:n]
@@ -305,7 +310,7 @@ LOOP:
 						ansi.ESC, ansi.CSI, ansi.OSC, ansi.DCS, ansi.APC, ansi.SOS, ansi.PM,
 					}, d.buf[0]) {
 						d.esc.Store(true)
-						d.timeout.Reset(d.Timeout)
+						d.timeout.Reset(d.EscTimeout)
 					}
 				}
 				// If this is the entire buffer, we can break and assume this
  @@ -8,6 +8,7 @@ import (
 	"errors"
 	"fmt"
 	"strings"
+	"time"
 	"unicode"
 	"unicode/utf16"
 	"unicode/utf8"
@@ -22,7 +23,7 @@ import (
 // given event channel.
 func (d *TerminalReader) ReceiveEvents(ctx context.Context, events chan<- Event) error {
 	for {
-		evs, err := d.handleConInput(readConsoleInput)
+		evs, err := d.handleConInput()
 		if errors.Is(err, errNotConInputReader) {
 			return d.receiveEvents(ctx, events)
 		}
@@ -41,31 +42,45 @@ func (d *TerminalReader) ReceiveEvents(ctx context.Context, events chan<- Event)
 
 var errNotConInputReader = fmt.Errorf("handleConInput: not a conInputReader")
 
-func (d *TerminalReader) handleConInput(
-	finput func(windows.Handle, []xwindows.InputRecord) (uint32, error),
-) ([]Event, error) {
+func (d *TerminalReader) handleConInput() ([]Event, error) {
 	cc, ok := d.rd.(*conInputReader)
 	if !ok {
 		return nil, errNotConInputReader
 	}
 
-	// read up to 256 events, this is to allow for sequences events reported as
-	// key events.
-	var events [256]xwindows.InputRecord
-	_, err := finput(cc.conin, events[:])
-	if err != nil {
+	var (
+		events []xwindows.InputRecord
+		err    error
+	)
+	for {
+		// Peek up to 256 events, this is to allow for sequences events reported as
+		// key events.
+		events, err = peekNConsoleInputs(cc.conin, 256)
 		if cc.isCanceled() {
 			return nil, cancelreader.ErrCanceled
 		}
+		if err != nil {
+			return nil, fmt.Errorf("peek coninput events: %w", err)
+		}
+		if len(events) > 0 {
+			break
+		}
+
+		// Sleep for a bit to avoid busy waiting.
+		time.Sleep(10 * time.Millisecond)
+	}
+
+	events, err = readNConsoleInputs(cc.conin, uint32(len(events)))
+	if cc.isCanceled() {
+		return nil, cancelreader.ErrCanceled
+	}
+	if err != nil {
 		return nil, fmt.Errorf("read coninput events: %w", err)
 	}
 
 	var evs []Event
 	for _, event := range events {
 		if e := d.SequenceParser.parseConInputEvent(event, &d.keyState, d.MouseMode, d.logger); e != nil {
-			if e == nil {
-				continue
-			}
 			if multi, ok := e.(MultiEvent); ok {
 				if d.logger != nil {
 					for _, ev := range multi {
@@ -223,6 +238,16 @@ func highWord(data uint32) uint16 {
 	return uint16((data & 0xFFFF0000) >> 16) //nolint:gosec
 }
 
+func readNConsoleInputs(console windows.Handle, maxEvents uint32) ([]xwindows.InputRecord, error) {
+	if maxEvents == 0 {
+		return nil, fmt.Errorf("maxEvents cannot be zero")
+	}
+
+	records := make([]xwindows.InputRecord, maxEvents)
+	n, err := readConsoleInput(console, records)
+	return records[:n], err
+}
+
 func readConsoleInput(console windows.Handle, inputRecords []xwindows.InputRecord) (uint32, error) {
 	if len(inputRecords) == 0 {
 		return 0, fmt.Errorf("size of input record buffer cannot be zero")
@@ -235,7 +260,6 @@ func readConsoleInput(console windows.Handle, inputRecords []xwindows.InputRecor
 	return read, err //nolint:wrapcheck
 }
 
-//nolint:unused
 func peekConsoleInput(console windows.Handle, inputRecords []xwindows.InputRecord) (uint32, error) {
 	if len(inputRecords) == 0 {
 		return 0, fmt.Errorf("size of input record buffer cannot be zero")
@@ -248,6 +272,16 @@ func peekConsoleInput(console windows.Handle, inputRecords []xwindows.InputRecor
 	return read, err //nolint:wrapcheck
 }
 
+func peekNConsoleInputs(console windows.Handle, maxEvents uint32) ([]xwindows.InputRecord, error) {
+	if maxEvents == 0 {
+		return nil, fmt.Errorf("maxEvents cannot be zero")
+	}
+
+	records := make([]xwindows.InputRecord, maxEvents)
+	n, err := peekConsoleInput(console, records)
+	return records[:n], err
+}
+
 // parseWin32InputKeyEvent parses a single key event from either the Windows
 // Console API or win32-input-mode events. When state is nil, it means this is
 // an event from win32-input-mode. Otherwise, it's a key event from the Windows
@@ -506,7 +540,9 @@ func (p *SequenceParser) parseWin32InputKeyEvent(state *win32InputState, vkc uin
 
 	var text string
 	keyCode := baseCode
-	if !unicode.IsControl(r) {
+	if unicode.IsControl(r) {
+		return p.parseControl(byte(r))
+	} else {
 		rw := utf8.EncodeRune(utf8Buf[:], r)
 		keyCode, _ = utf8.DecodeRune(utf8Buf[:rw])
 		if unicode.IsPrint(keyCode) && (cks == 0 ||
@@ -517,18 +553,18 @@ func (p *SequenceParser) parseWin32InputKeyEvent(state *win32InputState, vkc uin
 			// then the key event is a printable event i.e. [text] is not empty.
 			text = string(keyCode)
 		}
-	}
 
-	key.Code = keyCode
-	key.Text = text
-	key.Mod = translateControlKeyState(cks)
-	key.BaseCode = baseCode
-	key = ensureKeyCase(key, cks)
-	if keyDown {
-		return KeyPressEvent(key)
-	}
+		key.Code = keyCode
+		key.Text = text
+		key.Mod = translateControlKeyState(cks)
+		key.BaseCode = baseCode
+		key = ensureKeyCase(key, cks)
+		if keyDown {
+			return KeyPressEvent(key)
+		}
 
-	return KeyReleaseEvent(key)
+		return KeyReleaseEvent(key)
+	}
 }
 
 // ensureKeyCase ensures that the key's text is in the correct case based on the
  @@ -254,7 +254,7 @@ github.com/charmbracelet/bubbles/v2/spinner
 github.com/charmbracelet/bubbles/v2/textarea
 github.com/charmbracelet/bubbles/v2/textinput
 github.com/charmbracelet/bubbles/v2/viewport
-# github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.1 => github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250703182356-a42fb608faaf
+# github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.1 => github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250708145940-f4b2ad3636f9
 ## explicit; go 1.24.3
 github.com/charmbracelet/bubbletea/v2
 # github.com/charmbracelet/colorprofile v0.3.1
@@ -269,7 +269,7 @@ github.com/charmbracelet/glamour/v2
 github.com/charmbracelet/glamour/v2/ansi
 github.com/charmbracelet/glamour/v2/internal/autolink
 github.com/charmbracelet/glamour/v2/styles
-# github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2.0.20250703152125-8e1c474f8a71 => github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250703152138-ff346e83e819
+# github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2.0.20250703152125-8e1c474f8a71 => github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250708150236-b6de769f3a51
 ## explicit; go 1.24.2
 github.com/charmbracelet/lipgloss/v2
 github.com/charmbracelet/lipgloss/v2/table
@@ -277,7 +277,7 @@ github.com/charmbracelet/lipgloss/v2/tree
 # github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706
 ## explicit; go 1.19
 github.com/charmbracelet/log/v2
-# github.com/charmbracelet/ultraviolet v0.0.0-20250707134318-0fdaa64b8c5e
+# github.com/charmbracelet/ultraviolet v0.0.0-20250708144633-4a8e4329a1a0
 ## explicit; go 1.24.0
 github.com/charmbracelet/ultraviolet
 # github.com/charmbracelet/x/ansi v0.9.3
@@ -838,5 +838,5 @@ mvdan.cc/sh/v3/fileutil
 mvdan.cc/sh/v3/interp
 mvdan.cc/sh/v3/pattern
 mvdan.cc/sh/v3/syntax
-# github.com/charmbracelet/bubbletea/v2 => github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250703182356-a42fb608faaf
-# github.com/charmbracelet/lipgloss/v2 => github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250703152138-ff346e83e819
+# github.com/charmbracelet/bubbletea/v2 => github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250708145940-f4b2ad3636f9
+# github.com/charmbracelet/lipgloss/v2 => github.com/charmbracelet/lipgloss-internal/v2 v2.0.0-20250708150236-b6de769f3a51