fix(tui): responsive layout for slim terminals

The TUI overflowed and became unreadable on narrow terminals: the status bar and
footer spilled far past the edge (justify never truncated), the two-column main
split shrank the events panel to ~20 cols, and the stats overlay wrapped mid-word.

- justify is now width-safe: shrinks the left segment first (the right holds the
  high-signal connection clock / active spinner), clamps the right only as a last
  resort, ANSI-aware so it never slices escape codes. No line can exceed the width.
- Main area collapses below narrowWidth (96): single full-width session list when
  idle; output stacked over the live event strip in-session (the strip is too
  valuable to drop — the constraint is width, not height).
- Compact chrome when slim: status drops workspace/gauge/provider-suffix and uses a
  short clock ("◷ 3s") + bare spinner; footer uses a reduced hint set and drops the
  "Soft·blue" badge; approval action row tightens its gaps so decision keys stay
  visible.
- Overlays get near-full-width on slim terminals; stats pane uses compact formats +
  per-line clipping so it never wraps.

Tests: no rendered line exceeds the terminal width at 40–160 cols (incl. the stats
overlay); narrow in-session main stacks output+events.
This commit is contained in:
2026-06-13 11:01:16 +04:00
parent 8b896b519a
commit b4ca17c85f
3 changed files with 230 additions and 35 deletions
+106 -15
View File
@@ -17,8 +17,16 @@ const (
// staleEventThresholdMs: past this gap since the last event, an *active* session's
// last-event clock turns warn-colored — the "thinking vs hung" tell (§2).
staleEventThresholdMs = 30_000
// narrowWidth: below this the side-by-side panels become unreadable, so the main
// area collapses (single column / vertical stack) and the chrome goes compact.
narrowWidth = 96
)
// narrow reports whether the terminal is too slim for the full side-by-side layout
// and full-width chrome.
func (m Model) narrow() bool { return m.width < narrowWidth }
// View renders the whole frame purely from the model (immediate-mode).
func (m Model) View() string {
if m.quitting {
@@ -61,7 +69,12 @@ func (m Model) renderStatus() string {
span := func(s string, fg lipgloss.Color) string {
return lipgloss.NewStyle().Foreground(fg).Background(bg).Render(s)
}
sep := span(" · ", t.P.Faint)
nrw := m.narrow()
sepStr := " · "
if nrw {
sepStr = " · "
}
sep := span(sepStr, t.P.Faint)
parts := []string{span("correx", t.P.Dim)}
if m.connected {
@@ -80,7 +93,7 @@ func (m Model) renderStatus() string {
if s.Status != "" {
parts = append(parts, lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(bg).Render(statusLabel(s.Status)))
}
if s.WorkspaceRoot != "" {
if s.WorkspaceRoot != "" && !nrw {
parts = append(parts, span("⌂ "+shortPath(s.WorkspaceRoot), t.P.Faint))
}
}
@@ -89,11 +102,15 @@ func (m Model) renderStatus() string {
if model == "" {
model = "no model"
}
loc := "local"
if m.providerType == "REMOTE" {
loc = "remote"
if nrw {
parts = append(parts, span(model, t.P.Fg))
} else {
loc := "local"
if m.providerType == "REMOTE" {
loc = "remote"
}
parts = append(parts, span(model, t.P.Fg)+span(" ("+loc+")", t.P.Faint))
}
parts = append(parts, span(model, t.P.Fg)+span(" ("+loc+")", t.P.Faint))
left := strings.Join(parts, sep)
var right []string
@@ -106,21 +123,34 @@ func (m Model) renderStatus() string {
if s.Active && gap > staleEventThresholdMs {
clockFg = t.P.Warn
}
right = append(right, span("last event "+agoLabel(gap), clockFg))
label := "last event " + agoLabel(gap)
if nrw { // compact: "◷ 3s" instead of "last event 3s ago"
label = "◷ " + strings.TrimSuffix(agoLabel(gap), " ago")
}
right = append(right, span(label, clockFg))
}
if g := m.gaugeText(); g != "" {
if g := m.gaugeText(); g != "" && !nrw {
right = append(right, span(g, t.P.Faint))
}
if s := m.session(m.selectedID); s != nil && s.Active {
right = append(right, lipgloss.NewStyle().Foreground(t.P.Accent).Background(bg).Render(m.spinner())+span(" active", t.P.Accent2))
spin := lipgloss.NewStyle().Foreground(t.P.Accent).Background(bg).Render(m.spinner())
if nrw {
right = append(right, spin)
} else {
right = append(right, spin+span(" active", t.P.Accent2))
}
}
if m.bgUpdates > 0 {
right = append(right, span(itoa(m.bgUpdates)+" elsewhere", t.P.Warn))
label := itoa(m.bgUpdates) + " elsewhere"
if nrw {
label = itoa(m.bgUpdates) + "↗"
}
right = append(right, span(label, t.P.Warn))
}
if len(right) == 0 {
return m.fill(left, m.width, bg)
}
return m.justify(left, strings.Join(right, span(" · ", t.P.Faint)), m.width, bg)
return m.justify(left, strings.Join(right, sep), m.width, bg)
}
// gaugeText renders the live VRAM/GPU/RAM gauge, omitting any unavailable field.
@@ -180,6 +210,7 @@ func (m Model) renderFooter() string {
}
gap := lipgloss.NewStyle().Background(bg).Render(" ")
nrw := m.narrow()
var hints []string
switch {
case m.editMode == ModeInsert:
@@ -188,15 +219,27 @@ func (m Model) renderFooter() string {
// Approval actions live in the band itself; the footer offers navigation only.
hints = []string{hint("l", "back"), hint("e", "events"), hint("q", "quit")}
case m.displayState() == StateIdle:
hints = []string{hint("i", "name"), hint("/", "filter"), hint("enter", "open"), hint("jk", "move"), hint("w", "workflows"), hint("p", "cmds"), hint("q", "quit")}
if nrw {
hints = []string{hint("i", "name"), hint("/", "filter"), hint("w", "wf"), hint("p", "cmds"), hint("q", "quit")}
} else {
hints = []string{hint("i", "name"), hint("/", "filter"), hint("enter", "open"), hint("jk", "move"), hint("w", "workflows"), hint("p", "cmds"), hint("q", "quit")}
}
default: // StateInSession
hints = []string{hint("i", "message"), hint("e", "events"), hint("t", "tools"), hint("S", "stats"), hint("m", "model"), hint("s", "mode"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
if nrw {
hints = []string{hint("i", "msg"), hint("e", "events"), hint("S", "stats"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
} else {
hints = []string{hint("i", "message"), hint("e", "events"), hint("t", "tools"), hint("S", "stats"), hint("m", "model"), hint("s", "mode"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
}
if s := m.session(m.selectedID); s != nil && s.Pending != nil {
hints = append(hints, hint("a", "approval (pending)"))
}
}
left := strings.Join(hints, gap)
// The "Soft · blue" badge is decoration — drop it when slim so the hints get the room.
if nrw {
return m.fill(left, m.width, bg)
}
right := lipgloss.NewStyle().Foreground(t.P.Dim).Background(bg).Render("Soft") +
lipgloss.NewStyle().Foreground(t.P.Faint).Background(bg).Render(" · ") +
lipgloss.NewStyle().Foreground(t.P.Accent).Background(bg).Render("blue")
@@ -207,6 +250,9 @@ func (m Model) renderFooter() string {
// --- main split ---
func (m Model) renderMain(h int) string {
if m.narrow() {
return m.renderMainNarrow(h)
}
leftW := m.width * 63 / 100
rightW := m.width - leftW
@@ -239,6 +285,35 @@ func (m Model) renderMain(h int) string {
return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
}
// renderMainNarrow gives the main area a single full-width column when the terminal
// is too slim for side-by-side panels: the session list when idle, and the output
// transcript stacked over the live event strip when in-session (so both still fit
// — the strip is too valuable to drop, the constraint is width not height).
func (m Model) renderMainNarrow(h int) string {
w := m.width
if m.displayState() == StateIdle {
title := "sessions"
body := m.sessionRows(w - 4)
if m.wfVisible {
title, body = "workflows", m.workflowRows(w-4)
}
return m.theme.box(title, body, w, h, true)
}
// in-session / approval: stack output over events.
outH := h * 55 / 100
if outH < 3 {
outH = 3
}
evH := h - outH
if evH < 3 {
evH = 3
outH = h - evH
}
out := m.theme.box("output", m.routerRows(w-4, outH-2), w, outH, true)
ev := m.theme.box("events", m.eventRows(w-4, evH), w, evH, false)
return lipgloss.JoinVertical(lipgloss.Left, out, ev)
}
// --- input bar ---
// flattenForDisplay collapses control characters (newlines from a multi-line paste, tabs)
@@ -478,11 +553,27 @@ func (m Model) fill(s string, w int, bg lipgloss.Color) string {
return " " + padTo(s, w-1, bg)
}
// justify places left and right segments on a bg-filled line of width w.
// justify places left and right segments on a bg-filled line of width w, truncating
// so the line never overflows w. The left segment is shrunk first (the right holds
// the high-signal status — connection clock / active spinner); the right is clamped
// only as a last resort. ANSI-aware so truncation can't slice through escape codes.
func (m Model) justify(left, right string, w int, bg lipgloss.Color) string {
avail := w - 2 // leading + trailing space
lw := lipgloss.Width(left)
rw := lipgloss.Width(right)
mid := w - lw - rw - 2
if lw+rw+1 > avail {
maxLeft := avail - rw - 1
if maxLeft < 0 {
maxLeft = 0
}
left = ansi.Truncate(left, maxLeft, "…")
lw = lipgloss.Width(left)
if lw+rw+1 > avail {
right = ansi.Truncate(right, max(0, avail-lw-1), "…")
rw = lipgloss.Width(right)
}
}
mid := avail - lw - rw
if mid < 1 {
mid = 1
}