From 419a1391376fedf4f114dca5c1bd5d66fa4600ba Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Wed, 18 Mar 2026 14:52:43 +0300 Subject: [PATCH] fix: make TUI responsive for terminal resizing at any dimension Prevent layout corruption and visual breakage when the terminal is resized to narrow or short dimensions: - Status bar: progressively drops middle/right sections instead of wrapping to multiple lines (broke height calculation) - Autocomplete popup: guard against negative widths, truncate names before rendering to prevent text wrapping inside fixed-width columns, adapt to name-only mode at very narrow widths - Tree selector: use full height minus chrome instead of halved height, guard against negative width in node truncation - Model selector: rune-aware name truncation preserving provider tags, width-adaptive entry rendering - Overlay dialog: clamp dimensions to terminal bounds instead of using fixed minimums that could exceed the terminal - Input hint, popup footer, and all help text: tiered adaptive variants for different terminal widths - Queued messages: measure actual rendered height instead of fixed 5-line-per-message estimate --- internal/ui/input.go | 74 +++++++++++++++++++++++++++++------ internal/ui/model.go | 48 ++++++++++++++++++++--- internal/ui/model_selector.go | 56 +++++++++++++++++++++++--- internal/ui/overlay.go | 52 ++++++++++++------------ internal/ui/tree_selector.go | 25 ++++++++---- 5 files changed, 197 insertions(+), 58 deletions(-) diff --git a/internal/ui/input.go b/internal/ui/input.go index 1e9af0da..add10633 100644 --- a/internal/ui/input.go +++ b/internal/ui/input.go @@ -419,7 +419,18 @@ func (s *InputComponent) View() tea.View { MarginTop(1). PaddingLeft(3) - hint := "enter submit • ctrl+j / shift+enter new line • ctrl+v paste image" + // Adapt hint text to available width (accounting for left padding of 3). + var hint string + availableHintWidth := s.width - 3 + if availableHintWidth >= 67 { + hint = "enter submit • ctrl+j / shift+enter new line • ctrl+v paste image" + } else if availableHintWidth >= 40 { + hint = "↵ submit • ctrl+j newline • ctrl+v image" + } else if availableHintWidth >= 20 { + hint = "↵ submit • ctrl+j" + } else { + hint = "↵ submit" + } view.WriteString("\n") view.WriteString(helpStyle.Render(hint)) } @@ -429,13 +440,17 @@ func (s *InputComponent) View() tea.View { // renderPopup renders the autocomplete popup for slash command suggestions. func (s *InputComponent) renderPopup() string { + popupWidth := max(s.width-4, 20) popupStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("236")). Padding(1, 2). - Width(s.width - 4). + Width(popupWidth). MarginLeft(0) + // Inner content width: popup minus border (2) and horizontal padding (4). + innerWidth := max(popupWidth-6, 10) + var items []string visibleItems := min(len(s.filtered), s.popupHeight) @@ -466,28 +481,51 @@ func (s *InputComponent) renderPopup() string { if s.fileMode { // File mode: use full width for the path, show description // (e.g. "directory") inline after a gap. - maxNameLen := s.width - 24 + maxNameLen := max(innerWidth-16, 8) displayName := sc.Name if len(displayName) > maxNameLen && maxNameLen > 3 { displayName = displayName[:maxNameLen-3] + "..." } name := nameStyle.Render(displayName) - if sc.Description != "" { + if sc.Description != "" && innerWidth > 30 { items = append(items, indicator+name+" "+descStyle.Render(sc.Description)) } else { items = append(items, indicator+name) } } else { - nameWidth := 15 - name := nameStyle.Width(nameWidth - 2).Render(sc.Name) + // Line layout: indicator(2) + name(nameWidth-2 visual) + desc. + if innerWidth < 20 { + // Very narrow: show truncated name only, no fixed column. + displayName := sc.Name + maxName := max(innerWidth-2, 3) + if len(displayName) > maxName { + displayName = displayName[:maxName-1] + "…" + } + items = append(items, indicator+nameStyle.Render(displayName)) + } else { + nameWidth := 15 + if innerWidth < 25 { + nameWidth = max(innerWidth*2/5+1, 8) + } + maxNameChars := nameWidth - 2 + displayName := sc.Name + if len(displayName) > maxNameChars { + displayName = displayName[:maxNameChars-1] + "…" + } + name := nameStyle.Width(maxNameChars).Render(displayName) - desc := sc.Description - maxDescLen := s.width - nameWidth - 14 - if len(desc) > maxDescLen && maxDescLen > 3 { - desc = desc[:maxDescLen-3] + "..." + // Description gets remaining space. + maxDescLen := max(innerWidth-nameWidth, 0) + desc := sc.Description + if maxDescLen < 4 { + items = append(items, indicator+name) + } else { + if len(desc) > maxDescLen { + desc = desc[:maxDescLen-3] + "..." + } + items = append(items, indicator+name+descStyle.Render(desc)) + } } - - items = append(items, indicator+name+descStyle.Render(desc)) } } @@ -499,8 +537,18 @@ func (s *InputComponent) renderPopup() string { } content := strings.Join(items, "\n") + + // Adapt footer text to available width. + var footerText string + if innerWidth >= 50 { + footerText = "↑↓ navigate • tab complete • ↵ select • esc dismiss" + } else if innerWidth >= 30 { + footerText = "↑↓ nav • tab • ↵ select • esc" + } else { + footerText = "↑↓ tab ↵ esc" + } footer := lipgloss.NewStyle().Foreground(lipgloss.Color("238")).Italic(true). - Render("↑↓ navigate • tab complete • ↵ select • esc dismiss") + Render(footerText) return popupStyle.Render(content + "\n\n" + footer) } diff --git a/internal/ui/model.go b/internal/ui/model.go index 12ddb3a9..87811e12 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -1606,10 +1606,31 @@ func (m *AppModel) renderStatusBar() string { rightSide := strings.Join(rightParts, " ") - // Fill the gap between left+middle and right with spaces. - usedWidth := lipgloss.Width(leftSide) + lipgloss.Width(middleSide) + lipgloss.Width(rightSide) - gap := max(m.width-usedWidth, 1) + // Progressive truncation to keep the status bar on one line. + // When content exceeds terminal width, drop sections in order: + // middle (extensions/thinking) → usage stats → model label → right side. + leftW := lipgloss.Width(leftSide) + middleW := lipgloss.Width(middleSide) + rightW := lipgloss.Width(rightSide) + // Need at least 1 space gap between left+middle and right. + if leftW+middleW+rightW+1 > m.width { + // Drop middle section first (extensions/thinking status). + middleSide = "" + middleW = 0 + } + if leftW+rightW+1 > m.width && len(rightParts) > 1 { + // Drop usage stats, keep model label. + rightSide = rightParts[0] + rightW = lipgloss.Width(rightSide) + } + if leftW+rightW+1 > m.width { + // Drop right side entirely. + rightSide = "" + rightW = 0 + } + + gap := max(m.width-leftW-middleW-rightW, 1) return leftSide + middleSide + strings.Repeat(" ", gap) + rightSide } @@ -2194,7 +2215,7 @@ func (m *AppModel) drainScrollback() tea.Cmd { // stream region = total - header - separator(1) - widgets - queued(N*5) - input(measured) - widgets - statusBar(1) - footer // separator = 1 line // above widgets = measured dynamically -// queued msgs = ~5 lines per message (padding + text + badge + padding) +// queued msgs = measured dynamically via lipgloss.Height() // input region = measured dynamically via lipgloss.Height() // below widgets = measured dynamically // status bar = 1 line (always present) @@ -2210,8 +2231,12 @@ func (m *AppModel) distributeHeight() { if vis.HideStatusBar { statusBarLines = 0 } - const linesPerQueuedMsg = 5 - queuedLines := len(m.queuedMessages) * linesPerQueuedMsg + // Measure actual queued message height instead of using a fixed estimate, + // since text wrapping at different widths changes the rendered line count. + var queuedLines int + if queuedView := m.renderQueuedMessages(); queuedView != "" { + queuedLines = lipgloss.Height(queuedView) + } // Propagate hint visibility before measuring input height. if ic, ok := m.input.(*InputComponent); ok { @@ -2258,6 +2283,17 @@ func (m *AppModel) distributeHeight() { } } +// clamp constrains v to the range [lo, hi]. +func clamp(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + // repeatRune returns a string consisting of n repetitions of r. func repeatRune(r rune, n int) string { if n <= 0 { diff --git a/internal/ui/model_selector.go b/internal/ui/model_selector.go index 78cf6dd9..9303e22e 100644 --- a/internal/ui/model_selector.go +++ b/internal/ui/model_selector.go @@ -208,9 +208,20 @@ func (ms *ModelSelectorComponent) View() tea.View { // Header. b.WriteString(headerStyle.Render("Model Selector")) b.WriteString("\n") - b.WriteString(helpStyle.Render("↑/↓: move enter: select esc: cancel type to filter")) + // Adapt help text to terminal width. + if ms.width >= 56 { + b.WriteString(helpStyle.Render("↑/↓: move enter: select esc: cancel type to filter")) + } else if ms.width >= 35 { + b.WriteString(helpStyle.Render("↑↓ move ↵ select esc type")) + } else { + b.WriteString(helpStyle.Render("↑↓ ↵ esc")) + } b.WriteString("\n") - b.WriteString(infoStyle.Render("Only showing models with configured API keys")) + if ms.width >= 48 { + b.WriteString(infoStyle.Render("Only showing models with configured API keys")) + } else { + b.WriteString(infoStyle.Render("Models with API keys")) + } b.WriteString("\n") // Search input. @@ -281,9 +292,9 @@ func (ms *ModelSelectorComponent) IsActive() bool { // --- Internal helpers --- func (ms *ModelSelectorComponent) visibleHeight() int { - // Reserve: header(1) + help(1) + info(1) + search(1) + separator(1) + footer(2) = 7 - h := max(ms.height-7, 5) - return h + // Reserve: header(1) + help(1) + info(1) + search(1) + separator(1) + footer(2) = 7. + // Minimum 3 entries so the selector is still usable on short terminals. + return max(ms.height-7, 3) } func (ms *ModelSelectorComponent) rebuildFiltered() { @@ -396,8 +407,37 @@ func (ms *ModelSelectorComponent) renderEntry(entry ModelEntry, isCursor bool) s // Active model checkmark. var active string + activeWidth := 0 if entry.Provider+"/"+entry.ModelID == ms.currentModel { active = lipgloss.NewStyle().Foreground(theme.Success).Render(" \u2713") + activeWidth = 2 // " ✓" + } + + // Truncate model ID and provider tag to fit terminal width. + // Layout: cursor(3) + model + " " + provider + active. + // Use rune length for display-width accuracy (the "…" suffix is 1 rune / 1 column). + const cursorWidth = 3 + available := max(ms.width-cursorWidth-activeWidth-1, 10) // 1 for space between model and provider + provDisplayLen := len([]rune(providerStr)) + modelDisplayLen := len([]rune(modelStr)) + + if modelDisplayLen+1+provDisplayLen > available { + // Prioritize model name — truncate it, but keep provider visible. + maxModel := max(available-provDisplayLen-1, 6) + if maxModel < modelDisplayLen { + if maxModel > 3 { + runes := []rune(modelStr) + modelStr = string(runes[:maxModel-1]) + "…" + } else { + runes := []rune(modelStr) + modelStr = string(runes[:maxModel]) + } + } + // If provider itself is too long, drop it. + modelDisplayLen = len([]rune(modelStr)) + if modelDisplayLen+1+provDisplayLen > available { + providerStr = "" + } } // Style the model ID. @@ -409,5 +449,9 @@ func (ms *ModelSelectorComponent) renderEntry(entry ModelEntry, isCursor bool) s // Style the provider tag. providerStyle := lipgloss.NewStyle().Foreground(theme.Muted) - return cursor + modelStyle.Render(modelStr) + " " + providerStyle.Render(providerStr) + active + result := cursor + modelStyle.Render(modelStr) + if providerStr != "" { + result += " " + providerStyle.Render(providerStr) + } + return result + active } diff --git a/internal/ui/overlay.go b/internal/ui/overlay.go index 994bb055..9cce0cf2 100644 --- a/internal/ui/overlay.go +++ b/internal/ui/overlay.go @@ -135,31 +135,24 @@ func (o *overlayDialog) handleKey(msg tea.KeyPressMsg) (*overlayResult, tea.Cmd) func (o *overlayDialog) Render() string { theme := GetTheme() - // Calculate dialog dimensions. + // Calculate dialog dimensions, clamped to terminal bounds. + termW := max(o.width, 10) + termH := max(o.height, 5) + dw := o.dialogWidth if dw == 0 { - dw = o.width * 60 / 100 - } - if dw < 30 { - dw = 30 - } - if dw > o.width-4 { - dw = o.width - 4 + dw = termW * 60 / 100 } + dw = clamp(dw, min(24, termW), termW-2) mh := o.maxHeight if mh == 0 { - mh = o.height * 80 / 100 - } - if mh < 8 { - mh = 8 - } - if mh > o.height-2 { - mh = o.height - 2 + mh = termH * 80 / 100 } + mh = clamp(mh, min(6, termH), termH) // Inner width accounts for border (2) + horizontal padding (2 left + 1 right). - innerWidth := max(dw-5, 10) + innerWidth := max(dw-5, 6) // Render body text (potentially as markdown). bodyText := o.content @@ -268,18 +261,27 @@ func (o *overlayDialog) Render() string { dialog := dialogStyle.Render(innerContent) - // Key hints below the dialog. + // Key hints below the dialog, adapted to width. var hints []string - if scrollable { - hints = append(hints, "↑/↓ scroll") - } - if len(o.actions) > 0 { - hints = append(hints, "←/→ switch") - hints = append(hints, "Enter select") + if termW >= 50 { + if scrollable { + hints = append(hints, "↑/↓ scroll") + } + if len(o.actions) > 0 { + hints = append(hints, "←/→ switch") + hints = append(hints, "Enter select") + } else { + hints = append(hints, "Enter dismiss") + } + hints = append(hints, "Esc cancel") } else { - hints = append(hints, "Enter dismiss") + if len(o.actions) > 0 { + hints = append(hints, "↵ select") + } else { + hints = append(hints, "↵ ok") + } + hints = append(hints, "esc") } - hints = append(hints, "Esc cancel") hintText := lipgloss.NewStyle(). Foreground(theme.Muted). Render(" " + strings.Join(hints, " ")) diff --git a/internal/ui/tree_selector.go b/internal/ui/tree_selector.go index 5bbb49b1..4f398512 100644 --- a/internal/ui/tree_selector.go +++ b/internal/ui/tree_selector.go @@ -217,7 +217,14 @@ func (ts *TreeSelectorComponent) View() tea.View { // Header. b.WriteString(headerStyle.Render("Session Tree")) b.WriteString("\n") - b.WriteString(helpStyle.Render("↑/↓: move ←/→: page enter: select esc: cancel ^O: cycle filter")) + // Adapt help text to terminal width. + if ts.width >= 70 { + b.WriteString(helpStyle.Render("↑/↓: move ←/→: page enter: select esc: cancel ^O: cycle filter")) + } else if ts.width >= 45 { + b.WriteString(helpStyle.Render("↑↓ move ↵ select esc cancel ^O filter")) + } else { + b.WriteString(helpStyle.Render("↑↓ ↵ esc ^O")) + } b.WriteString("\n") if ts.search != "" { @@ -269,9 +276,10 @@ func (ts *TreeSelectorComponent) IsActive() bool { // --- Internal helpers --- func (ts *TreeSelectorComponent) visibleHeight() int { - // Reserve lines for header(3) + search(1) + separator(1) + footer(2). - h := max(ts.height/2-7, 5) - return h + // Chrome: header(1) + help(1) + separator(1) + entries + separator(1) + footer(1) = 5 fixed. + // Optional search line adds 1 more. Use 7 as a safe estimate. + const chromeLines = 7 + return max(ts.height-chromeLines, 3) } func (ts *TreeSelectorComponent) rebuildFlatList() { @@ -389,7 +397,7 @@ func (ts *TreeSelectorComponent) passesFilter(node *session.TreeNode) bool { func (ts *TreeSelectorComponent) renderNode(node FlatNode, isCursor, isLeaf bool) string { theme := GetTheme() - maxWidth := ts.width - 4 + maxWidth := max(ts.width-4, 10) // Cursor indicator. var cursor string @@ -401,9 +409,10 @@ func (ts *TreeSelectorComponent) renderNode(node FlatNode, isCursor, isLeaf bool // Role-colored content. text := ts.entryDisplayText(node.Entry) - if len(text) > maxWidth-len(node.Prefix)-10 { - trimLen := maxWidth - len(node.Prefix) - 13 - if trimLen > 0 && trimLen < len(text) { + available := maxWidth - len(node.Prefix) - 10 + if available > 3 && len(text) > available { + trimLen := max(available-3, 1) + if trimLen < len(text) { text = text[:trimLen] + "..." } }