perf(tui): debounce window resize rendering

Ayman Bagabas created

This commit introduces window resize debouncing in the TUI application
to reduce tearing and excessive re-rendering during rapid resize events.
A debounce duration of 50 milliseconds has been implemented, ensuring
that the UI only updates after the user has stopped resizing for this
period.

Change summary

go.mod                         |  4 +-
go.sum                         |  8 ++--
internal/tui/page/chat/chat.go |  2 
internal/tui/tui.go            | 63 ++++++++++++++++++++++++------------
4 files changed, 49 insertions(+), 28 deletions(-)

Detailed changes

go.mod 🔗

@@ -6,7 +6,7 @@ require (
 	charm.land/bubbles/v2 v2.0.0-rc.1
 	charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251202162339-5fa38b798f16
 	charm.land/fantasy v0.5.1
-	charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251119143523-0334bb4562ca
+	charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251202164103-ad876c4132d6
 	charm.land/x/vcr v0.1.1
 	github.com/JohannesKaufmann/html-to-markdown v1.6.0
 	github.com/MakeNowJust/heredoc v1.0.0
@@ -21,7 +21,7 @@ require (
 	github.com/charmbracelet/fang v0.4.4
 	github.com/charmbracelet/glamour/v2 v2.0.0-20251106195642-800eb8175930
 	github.com/charmbracelet/log/v2 v2.0.0-20251106192421-eb64aaa963a0
-	github.com/charmbracelet/ultraviolet v0.0.0-20251202162030-ecc8c1ae4b2b
+	github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318
 	github.com/charmbracelet/x/ansi v0.11.2
 	github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
 	github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f

go.sum 🔗

@@ -4,8 +4,8 @@ charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251202162339-5fa38b798f16 h1:9iVAss7WF8A
 charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251202162339-5fa38b798f16/go.mod h1:Vsh7/MLC7LQ2Ab8H63SXm6yD/L6o4HDvhdD/IrIRXrU=
 charm.land/fantasy v0.5.1 h1:Svi/UpI4/DwVjTqNYceDXoJJYn6SVEM5dnLH92UBiEs=
 charm.land/fantasy v0.5.1/go.mod h1:SPOsnIlkBKnhw2Wnasv+wZ82EmCMIGesx0je3tgR6+M=
-charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251119143523-0334bb4562ca h1:6bVc8OFotCS4sS7HKqxTudP7yn8Y0ODR6df2pdlY/+s=
-charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251119143523-0334bb4562ca/go.mod h1:XSJjv7DaH4zd1Y27kZis295RkEj9OFR9zh2WffQQsKQ=
+charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251202164103-ad876c4132d6 h1:7gwwAGBZwCS3c8dX7OVKQJbR/SpL75Ad7JUy2bEhNjg=
+charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251202164103-ad876c4132d6/go.mod h1:0H6aOGRnJHcF0owqZQFIyLZULTVXBxEvLSbouQ7BSb0=
 charm.land/x/vcr v0.1.1 h1:PXCFMUG0rPtyk35rhfzYCJEduOzWXCIbrXTFq4OF/9Q=
 charm.land/x/vcr v0.1.1/go.mod h1:eByq2gqzWvcct/8XE2XO5KznoWEBiXH56+y2gphbltM=
 cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
@@ -98,8 +98,8 @@ github.com/charmbracelet/glamour/v2 v2.0.0-20251106195642-800eb8175930 h1:+47Z2j
 github.com/charmbracelet/glamour/v2 v2.0.0-20251106195642-800eb8175930/go.mod h1:izs11tnkYaT3DTEH2E0V/lCb18VGZ7k9HLYEGuvgXGA=
 github.com/charmbracelet/log/v2 v2.0.0-20251106192421-eb64aaa963a0 h1:lxHzxsHd4P7o7+5D5OcEItYkQ1xY3ovNg8Dc5ftd3rI=
 github.com/charmbracelet/log/v2 v2.0.0-20251106192421-eb64aaa963a0/go.mod h1:Q7oMtlboDPnnrYiJDXNwdWmJblOmuOnycPKczlVju6I=
-github.com/charmbracelet/ultraviolet v0.0.0-20251202162030-ecc8c1ae4b2b h1:jY1J0PcfetoB1uJ+w8rd86gUFSpKpJJI35gnfpKF5hg=
-github.com/charmbracelet/ultraviolet v0.0.0-20251202162030-ecc8c1ae4b2b/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI=
+github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI=
+github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI=
 github.com/charmbracelet/x/ansi v0.11.2 h1:XAG3FSjiVtFvgEgGrNBkCNNYrsucAt8c6bfxHyROLLs=
 github.com/charmbracelet/x/ansi v0.11.2/go.mod h1:9tY2bzX5SiJCU0iWyskjBeI2BRQfvPqI+J760Mjf+Rg=
 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1xwHZg6eMZ9Wv5TE1UGub6ARubyOd1Lo5kPUI/6VL50=

internal/tui/page/chat/chat.go 🔗

@@ -525,7 +525,7 @@ func (p *chatPage) View() string {
 		)
 		layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
 	}
-	canvas := lipgloss.NewCanvas(
+	canvas := lipgloss.NewCompositor(
 		layers...,
 	)
 	return canvas.Render()

internal/tui/tui.go 🔗

@@ -36,6 +36,7 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/page/chat"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
+	uv "github.com/charmbracelet/ultraviolet"
 	"golang.org/x/mod/semver"
 	"golang.org/x/text/cases"
 	"golang.org/x/text/language"
@@ -87,6 +88,10 @@ type appModel struct {
 	// QueryVersion instructs the TUI to query for the terminal version when it
 	// starts.
 	QueryVersion bool
+
+	// store a reusable canvas for rendering and lazy resizing
+	canvas     *uv.ScreenBuffer
+	resizeTime time.Time
 }
 
 // Init initializes the application model and returns initial commands.
@@ -110,6 +115,8 @@ func (a appModel) Init() tea.Cmd {
 	return tea.Batch(cmds...)
 }
 
+type lazyWindowSizeMsg struct{ width, height int }
+
 // Update handles incoming messages and updates the application state.
 func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
@@ -153,9 +160,21 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 		return a, tea.Batch(cmds...)
 	case tea.WindowSizeMsg:
+		const debounceDuration = 50 * time.Millisecond
 		a.wWidth, a.wHeight = msg.Width, msg.Height
+		// Lazily update all components on resize after 150ms
+		// to avoid excessive updates during window resizing.
+		a.resizeTime = time.Now()
+		return a, tea.Tick(debounceDuration, func(t time.Time) tea.Msg {
+			if time.Since(a.resizeTime) >= debounceDuration {
+				return lazyWindowSizeMsg{msg.Width, msg.Height}
+			}
+			return nil
+		})
+
+	case lazyWindowSizeMsg:
 		a.completions.Update(msg)
-		return a, a.handleWindowResize(msg.Width, msg.Height)
+		return a, a.handleWindowResize(msg.width, msg.height)
 
 	case pubsub.Event[mcp.Event]:
 		switch msg.Payload.Type {
@@ -426,6 +445,9 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
 	var cmds []tea.Cmd
 
+	// Resize canvas
+	a.canvas.Resize(width, height)
+
 	// TODO: clean up these magic numbers.
 	if a.showingFullHelp {
 		height -= 5
@@ -593,22 +615,17 @@ func (a *appModel) View() tea.View {
 	view.MouseMode = tea.MouseModeCellMotion
 	view.BackgroundColor = t.BgBase
 	if a.wWidth < 25 || a.wHeight < 15 {
-		view.SetContent(
-			lipgloss.NewCanvas(
-				lipgloss.NewLayer(
-					t.S().Base.Width(a.wWidth).Height(a.wHeight).
-						Align(lipgloss.Center, lipgloss.Center).
-						Render(
-							t.S().Base.
-								Padding(1, 4).
-								Foreground(t.White).
-								BorderStyle(lipgloss.RoundedBorder()).
-								BorderForeground(t.Primary).
-								Render("Window too small!"),
-						),
-				),
-			).Render(),
-		)
+		content := t.S().Base.Width(a.wWidth).Height(a.wHeight).
+			Align(lipgloss.Center, lipgloss.Center).
+			Render(
+				t.S().Base.
+					Padding(1, 4).
+					Foreground(t.White).
+					BorderStyle(lipgloss.RoundedBorder()).
+					BorderForeground(t.Primary).
+					Render("Window too small!"),
+			)
+		view.SetContent(content)
 		return view
 	}
 
@@ -659,11 +676,11 @@ func (a *appModel) View() tea.View {
 		)
 	}
 
-	canvas := lipgloss.NewCanvas(
-		layers...,
-	)
+	a.canvas.Clear()
+	comp := lipgloss.NewCompositor(layers...)
+	comp.Draw(a.canvas, a.canvas.Bounds())
 
-	view.Content = canvas.Render()
+	view.Content = a.canvas.Render()
 	view.Cursor = cursor
 
 	if a.sendProgressBar && a.app != nil && a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
@@ -701,6 +718,8 @@ func New(app *app.App) *appModel {
 	keyMap := DefaultKeyMap()
 	keyMap.pageBindings = chatPage.Bindings()
 
+	canvas := uv.NewScreenBuffer(0, 0)
+
 	model := &appModel{
 		currentPage: chat.ChatPageID,
 		app:         app,
@@ -714,6 +733,8 @@ func New(app *app.App) *appModel {
 
 		dialog:      dialogs.NewDialogCmp(),
 		completions: completions.New(),
+
+		canvas: &canvas,
 	}
 
 	return model