-
Notifications
You must be signed in to change notification settings - Fork 1.1k
[v2] Hard-tab cursor optimization silently breaks column alignment in View() output #1614
Description
Note
Human speaking here! Apologies for the full-blown AI-generated bug report, but this was sufficiently complex for me to track down and debug what was going on that I needed to get an LLM involved and help me write up the issue.
Describe the bug
bubbletea v2's cursedRenderer auto-detects the terminal's TAB0 flag via termios and replaces runs of plain space cells with \t characters as a cursor-movement optimization. This silently corrupts any application output that relies on precise space-based column alignment. The application's View() returns properly padded strings with spaces, but the renderer replaces those spaces with tab characters before writing to the terminal. There is no opt-out mechanism.
Setup
- OS: macOS v25.3.0 -
TAB0is the defaulttermiosoutput flag - Shell: fish
- Terminal Emulator: iTerm2 (but should reproduce in other terms too)
- Terminal Multiplexer: N/A
- bubbletea: v2.0.1
To Reproduce
- Create a bubbletea v2 app that renders space-padded columns in
View()(e.g. a table withstrings.Repeat(" ", n)for alignment) - Run it on macOS (or any terminal where
termioshasTAB0set - the macOS default) - Observe that columns are misaligned - spaces have been replaced with tab characters
Source Code
Minimal reproduction - a two-column aligned table:
package main
import (
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
)
type model struct{}
func (m model) Init() tea.Cmd { return nil }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg, ok := msg.(tea.KeyMsg); ok && msg.String() == "q" {
return m, tea.Quit
}
return m, nil
}
func (m model) View() tea.View {
// Two columns, padded with spaces to align.
rows := []struct{ left, right string }{
{"short", "value1"},
{"much longer name", "value2"},
{"mid", "value3"},
}
maxLeft := 0
for _, r := range rows {
if len(r.left) > maxLeft {
maxLeft = len(r.left)
}
}
var b strings.Builder
for _, r := range rows {
pad := strings.Repeat(" ", maxLeft-len(r.left)+2)
b.WriteString(r.left + pad + r.right + "\n")
}
b.WriteString("\npress q to quit\n")
return tea.NewView(b.String())
}
func main() {
if _, err := tea.NewProgram(model{}).Run(); err != nil {
fmt.Println("Error:", err)
}
}Expected behavior
Columns should be aligned exactly as the View() output specifies - spaces should remain as spaces.
short value1
much longer name value2
mid value3
Actual behavior
Spaces are replaced with tab characters, breaking column alignment:
short value1
much longer name value2
mid value3
Additional context
The root cause is in cursed_renderer.go where hardTabs is set from termios:
// termios_bsd.go
p.useHardTabs = s.Oflag&unix.TABDLY == unix.TAB0The cellbuf relativeCursorMove() in screen.go then replaces runs of "clear" cells with \t. A cell is "clear" when Cell.Clear() returns true - any space with no foreground/background/underline and only Bold/Faint/Italic/Blink attributes.
This affects any bubbletea v2 application doing column-aligned output on terminals with TAB0 set (the macOS default), including huh multi-select fields.
Workaround: Wrap padding spaces in SGR 8 (conceal): \x1b[8m + spaces + \x1b[28m. The conceal attribute makes Style.Clear() return false, preventing tab compression, while being visually invisible on space characters.
Suggested fix: One or more of:
- Add
tea.WithHardTabs(false)option to let applications disable the optimization - Only apply tab compression to renderer-produced cells, not cells from application
View()output - Disable by default - the optimization saves minimal bandwidth but breaks a very common pattern