From 2e3de493c3d86e79c2e2a7eb4420de5f0e7575dc Mon Sep 17 00:00:00 2001 From: Anand Biju Date: Sun, 4 Oct 2026 08:59:27 +0530 Subject: [PATCH] fix(renderer): restore DECST8C tab stops after resize Hard-tab cursor moves use terminal tab stops that were only set at startup. After a widen, columns beyond the old width had no stops, so tabs landed on the right margin and corrupted the row. When hard tabs are enabled, resize now refreshes SetTabStops for the new width and re-emits DECST8C, matching the startup path. Fixes #1838 --- cursed_renderer.go | 6 +++++ cursed_renderer_test.go | 55 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/cursed_renderer.go b/cursed_renderer.go index 0ad3e602b2..4b680bd52c 100644 --- a/cursed_renderer.go +++ b/cursed_renderer.go @@ -675,6 +675,12 @@ func (s *cursedRenderer) resize(w, h int) { s.scr.Erase() s.width, s.height = w, h s.scr.Resize(s.width, s.height) + // Restore tab stops across the new width. The terminal keeps the stops + // from the previous size unless we send DECST8C again. + if s.hardTabs { + s.scr.SetTabStops(s.width) + _, _ = s.scr.WriteString(ansi.SetTabEvery8Columns) + } s.pendingErase = true s.mu.Unlock() } diff --git a/cursed_renderer_test.go b/cursed_renderer_test.go index 8b2efa8d4f..93bf1abc1c 100644 --- a/cursed_renderer_test.go +++ b/cursed_renderer_test.go @@ -201,3 +201,58 @@ func TestCursedRenderer_updatesKittyKeyboardFlagsInPlace(t *testing.T) { t.Fatalf("expected kitty keyboard protocol to be pushed once, got %d pushes in %q", n, got) } } + +// Fixes: https://github.com/charmbracelet/bubbletea/issues/1838 +func TestCursedRenderer_restoresTabStopsOnResize(t *testing.T) { + t.Parallel() + + render := func(t *testing.T, r *cursedRenderer, v View) { + t.Helper() + r.render(v) + if err := r.flush(false); err != nil { + t.Fatal(err) + } + } + + t.Run("hard tabs", func(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + r := newCursedRenderer(&out, []string{"TERM=xterm-256color"}, 80, 24) + r.setOptimizations(true, false, false) + r.start() + render(t, r, NewView("hello")) + + got := out.String() + if n := strings.Count(got, ansi.SetTabEvery8Columns); n != 1 { + t.Fatalf("expected initial %q once, got %d in %q", ansi.SetTabEvery8Columns, n, got) + } + + // Widen, then flush. resize queues DECST8C (and refreshes tab stops); + // the following flush is what writes it to the terminal. + r.resize(160, 24) + render(t, r, NewView("hello")) + + got = out.String() + if n := strings.Count(got, ansi.SetTabEvery8Columns); n != 2 { + t.Fatalf("expected %q again after widen, got %d in %q", ansi.SetTabEvery8Columns, n, got) + } + assertInOrder(t, got, ansi.SetTabEvery8Columns, ansi.SetTabEvery8Columns) + }) + + t.Run("no hard tabs", func(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + r := newCursedRenderer(&out, []string{"TERM=xterm-256color"}, 80, 24) + r.setOptimizations(false, false, false) + r.start() + render(t, r, NewView("hello")) + r.resize(160, 24) + render(t, r, NewView("hello")) + + if strings.Contains(out.String(), ansi.SetTabEvery8Columns) { + t.Fatalf("expected no %q without hard tabs, got %q", ansi.SetTabEvery8Columns, out.String()) + } + }) +}