diff --git a/cmd/dbc/drain_unix.go b/cmd/dbc/drain_unix.go new file mode 100644 index 00000000..8872fe66 --- /dev/null +++ b/cmd/dbc/drain_unix.go @@ -0,0 +1,75 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows + +package main + +import ( + "os" + "syscall" + "time" + + "github.com/charmbracelet/x/term" +) + +// Work around https://github.com/columnar-tech/dbc/issues/351 +// +// suppressTerminalProbeResponses prevents BubbleTea v2's capability probe +// responses from appearing as garbled output in the shell. +// +// BubbleTea v2 queries terminal capabilities on startup (DECRQM for mode 2026 +// = synchronized output and mode 2027 = unicode core). For fast-completing +// operations like local package installs, the program can exit before the +// terminal's responses arrive in the tty buffer. When the terminal is restored +// to cooked mode (echo on) after the renderer exits, those response bytes get +// echoed to the screen, producing garbled output like "^[[?2026;2$y". +// +// We put the terminal back into raw mode (no echo) immediately after the +// renderer exits so that any in-flight responses are not echoed. Then we sleep +// briefly to let those responses arrive, and drain the buffer with a +// non-blocking syscall.Read loop. +// +// syscall.Read is used directly rather than os.Stdin.Read because Go's file +// wrapper retries EAGAIN through the runtime poller, defeating the +// non-blocking intent. +func suppressTerminalProbeResponses() { + fd := uintptr(os.Stdin.Fd()) + + // Put the terminal back into raw mode so that any in-flight probe + // responses arriving during the sleep below are not echoed to the screen. + // If stdin is not a terminal (e.g. piped input), MakeRaw returns an error + // and we bail. + state, err := term.MakeRaw(fd) + if err != nil { + return + } + defer term.Restore(fd, state) //nolint:errcheck + + // Sleep briefly to give the terminal time to deliver its responses. + // The local terminal round-trip is typically <5ms; 50ms gives headroom. + time.Sleep(50 * time.Millisecond) + + // Drain whatever arrived in the buffer. + if err := syscall.SetNonblock(int(fd), true); err != nil { + return + } + defer syscall.SetNonblock(int(fd), false) //nolint:errcheck + var buf [256]byte + for { + if _, err := syscall.Read(int(fd), buf[:]); err != nil { + return + } + } +} diff --git a/cmd/dbc/drain_windows.go b/cmd/dbc/drain_windows.go new file mode 100644 index 00000000..a1812037 --- /dev/null +++ b/cmd/dbc/drain_windows.go @@ -0,0 +1,21 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package main + +// no-op on Windows since I haven't reproduced this issue on a Windows terminal. +// See drain_unix.go for a description of the issue and the fix. +func suppressTerminalProbeResponses() {} diff --git a/cmd/dbc/install.go b/cmd/dbc/install.go index 576e75ac..592a21a2 100644 --- a/cmd/dbc/install.go +++ b/cmd/dbc/install.go @@ -66,14 +66,21 @@ func (InstallCmd) Description() string { func (c InstallCmd) GetModelCustom(baseModel baseModel) tea.Model { s := spinner.New() s.Spinner = spinner.MiniDot + isLocal := strings.HasSuffix(c.Driver, ".tar.gz") || strings.HasSuffix(c.Driver, ".tgz") + localPackagePath := "" + if isLocal { + localPackagePath = c.Driver + } return progressiveInstallModel{ - Driver: c.Driver, - NoVerify: c.NoVerify, - jsonOutput: c.Json, - Pre: c.Pre, - spinner: s, - cfg: getConfig(c.Level), - baseModel: baseModel, + Driver: c.Driver, + NoVerify: c.NoVerify, + jsonOutput: c.Json, + Pre: c.Pre, + spinner: s, + cfg: getConfig(c.Level), + baseModel: baseModel, + isLocal: isLocal, + localPackagePath: localPackagePath, p: dbc.NewFileProgress( progress.WithDefaultBlend(), progress.WithWidth(20), @@ -174,8 +181,9 @@ type progressiveInstallModel struct { spinner spinner.Model p dbc.FileProgressModel - width, height int - isLocal bool + width, height int + isLocal bool + localPackagePath string // original path for display; only set when isLocal=true registryErrors error // Store registry errors for better error messages } @@ -203,6 +211,13 @@ func (m progressiveInstallModel) Init() tea.Cmd { }) } +func (m progressiveInstallModel) Preamble() string { + if m.isLocal { + return "Installing from local package: " + m.localPackagePath + "\n\n" + } + return "" +} + func (m progressiveInstallModel) FinalOutput() string { if m.conflictingInfo.ID != "" && m.conflictingInfo.Version != nil { if m.conflictingInfo.Version.Equal(m.DriverPackage.Version) { @@ -371,15 +386,16 @@ func (m progressiveInstallModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.searchForDriver(msg) case localInstallMsg: m.isLocal = true - return m, tea.Sequence( - tea.Printf("Installing from local package: %s\n", m.Driver), - func() tea.Msg { - localDrv, err := os.Open(m.Driver) - if err != nil { - return err - } - return localDrv - }) + if m.localPackagePath == "" { + m.localPackagePath = m.Driver + } + return m, func() tea.Msg { + localDrv, err := os.Open(m.Driver) + if err != nil { + return err + } + return localDrv + } case dbc.PkgInfo: m.DriverPackage = msg di, err := config.GetDriver(m.cfg, m.Driver) diff --git a/cmd/dbc/main.go b/cmd/dbc/main.go index 27830d66..60ab8637 100644 --- a/cmd/dbc/main.go +++ b/cmd/dbc/main.go @@ -57,6 +57,15 @@ type HasFinalOutput interface { FinalOutput() string } +// HasPreamble is implemented by models that want to print text to stdout before +// the TUI renderer starts. This is a workaround for a regression in bubbletea +// v2 where tea.Println calls inside a model's Update() destroys scrollback. It +// might be this issue: https://github.com/charmbracelet/bubbletea/issues/1571 +// or https://github.com/charmbracelet/bubbletea/issues/1613. +type HasPreamble interface { + Preamble() string +} + type HasStatus interface { Status() int Err() error @@ -259,6 +268,8 @@ func main() { // defer f.Close() _, needsRenderer := m.(NeedsRenderer) + // Work around https://github.com/columnar-tech/dbc/issues/351 + usedRenderer := false if !isatty.IsTerminal(os.Stdout.Fd()) || !needsRenderer { prog = tea.NewProgram(m, tea.WithoutRenderer(), tea.WithInput(nil)) } else if args.Quiet { @@ -266,6 +277,15 @@ func main() { prog = tea.NewProgram(m, tea.WithoutRenderer(), tea.WithInput(nil), tea.WithOutput(os.Stderr)) } else { prog = tea.NewProgram(m) + usedRenderer = true + } + + if !args.Quiet { + if hp, ok := m.(HasPreamble); ok { + if preamble := hp.Preamble(); preamble != "" { + lipgloss.Print(preamble) + } + } } if m, err = prog.Run(); err != nil { @@ -273,6 +293,11 @@ func main() { os.Exit(1) } + // Work around https://github.com/columnar-tech/dbc/issues/351 + if usedRenderer { + suppressTerminalProbeResponses() + } + if !args.Quiet { if fo, ok := m.(HasFinalOutput); ok { if output := fo.FinalOutput(); output != "" {