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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions