mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
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
This commit is contained in:
+61
-13
@@ -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)
|
||||
}
|
||||
|
||||
+42
-6
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+27
-25
@@ -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, " "))
|
||||
|
||||
@@ -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] + "..."
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user