Skip to content

[v2] Hard-tab cursor optimization silently breaks column alignment in View() output #1614

@zx8

Description

@zx8

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 - TAB0 is the default termios output flag
  • Shell: fish
  • Terminal Emulator: iTerm2 (but should reproduce in other terms too)
  • Terminal Multiplexer: N/A
  • bubbletea: v2.0.1

To Reproduce

  1. Create a bubbletea v2 app that renders space-padded columns in View() (e.g. a table with strings.Repeat(" ", n) for alignment)
  2. Run it on macOS (or any terminal where termios has TAB0 set - the macOS default)
  3. 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.TAB0

The 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:

  1. Add tea.WithHardTabs(false) option to let applications disable the optimization
  2. Only apply tab compression to renderer-produced cells, not cells from application View() output
  3. Disable by default - the optimization saves minimal bandwidth but breaks a very common pattern

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions