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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user