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
@@ -0,0 +1,74 @@
package app
import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
)
// inSessionModel builds an in-session model with deliberately long fields (model name,
// session name, workspace) that would overflow a slim status bar if not truncated.
func inSessionModel(w, h int) Model {
m := NewModel(nil)
m.width, m.height = w, h
m.connected = true
m.currentModel = "llama-cpp:a-very-long-local-model-identifier-here"
m.sessions = []Session{{
ID: "abcdef123456", Status: "FAILED", WorkflowID: "healthcheck",
Name: "a-rather-long-session-name", CurrentStage: "write_script",
WorkspaceRoot: "/home/kami/Programs/correx/some/deep/path",
LastEventAt: nowMillis(), Active: true,
}}
m.selectedID = "abcdef123456"
m.sessionEntered = true
m.routerMessages["abcdef123456"] = []RouterEntry{{Role: "router", Content: strings.Repeat("word ", 60)}}
return m
}
func assertNoOverflow(t *testing.T, out string, w int) {
t.Helper()
for i, ln := range strings.Split(out, "\n") {
if vw := lipgloss.Width(ln); vw > w {
t.Errorf("line %d visible width %d > terminal width %d: %q", i, vw, w, ln)
}
}
}
// The whole frame must fit the terminal at every width — no status/footer/panel line
// may overflow (the bug: justify never truncated, so slim terminals smeared).
func TestViewNeverOverflowsWidth(t *testing.T) {
for _, w := range []int{40, 50, 60, 80, 95, 96, 110, 160} {
m := inSessionModel(w, 30)
assertNoOverflow(t, m.View(), w)
}
}
// The stats overlay must also stay within the terminal at narrow widths.
func TestStatsOverlayNeverOverflowsWidth(t *testing.T) {
for _, w := range []int{50, 64, 80, 120} {
m := inSessionModel(w, 40)
m.overlay = OverlayStats
m.statsFor = m.selectedID
m.stats = sampleStats()
assertNoOverflow(t, m.View(), w)
}
}
// Below the threshold the main area collapses to a single column (vertically stacked
// in-session); above it the two-column split returns.
func TestNarrowCollapsesMainColumns(t *testing.T) {
narrow := inSessionModel(70, 30)
if !narrow.narrow() {
t.Fatalf("width 70 should be narrow")
}
wide := inSessionModel(120, 30)
if wide.narrow() {
t.Fatalf("width 120 should not be narrow")
}
// In-session narrow main stacks output over events: both titles present, one per row.
out := narrow.renderMainNarrow(20)
if !strings.Contains(out, "OUTPUT") || !strings.Contains(out, "EVENTS") {
t.Errorf("narrow in-session main should stack OUTPUT and EVENTS, got:\n%s", out)
}
}
+50 -20
View File
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
)
// center composites a modal over a scrim-filled screen. Immediate-mode: the modal
@@ -19,6 +20,9 @@ func (m Model) center(modal string) string {
func (m Model) modalWidth() int {
w := m.width * 70 / 100
if m.narrow() {
w = m.width - 4 // slim terminals: give modals nearly the whole screen
}
if w > 92 {
w = 92
}
@@ -28,6 +32,15 @@ func (m Model) modalWidth() int {
return w
}
// clip truncates a (possibly ANSI-styled) line to w visible columns so modal content
// never wraps mid-word on a slim terminal. ANSI-aware — won't slice escape codes.
func clip(s string, w int) string {
if w < 1 {
w = 1
}
return ansi.Truncate(s, w, "…")
}
func (m Model) renderOverlay(base string) string {
switch m.overlay {
case OverlayPalette:
@@ -183,17 +196,24 @@ func (m Model) approvalActions() string {
return lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render(k) +
t.span(" "+l, t.P.Faint)
}
// Tighten the inter-hint gap on a slim terminal so the action row isn't clipped —
// the approval card's decision keys must stay fully visible.
gapStr := " "
if m.narrow() {
gapStr = " "
}
gap := t.span(gapStr, t.P.Faint)
if m.steering {
return strings.Join([]string{
hint("enter", "approve + send note"), hint("esc", "keep note, decide below"),
}, t.span(" ", t.P.Faint))
}, gap)
}
parts := []string{hint("a", "approve"), hint("A", "auto"), hint("s", "steer"), hint("r", "reject")}
if s := m.session(m.selectedID); s != nil && s.Pending != nil && isUnifiedDiff(s.Pending.Preview) {
parts = append(parts, hint("^x", "fullscreen"))
}
parts = append(parts, hint("esc", "later"))
return strings.Join(parts, t.span(" ", t.P.Faint))
return strings.Join(parts, gap)
}
// diffBodyHeight is the number of diff lines visible in the diff modal body.
@@ -594,65 +614,75 @@ func (m Model) statsModal() string {
}
s := m.stats
cw := w - 4 // modal inner content width (borders + padding)
section := func(label string) string {
return lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(label)
}
// put clips each data line to the inner width so it never wraps on a slim modal.
put := func(styled string) { b.WriteString(clip(styled, cw) + "\n") }
line := func(s string) string { return mbg(t, s, t.P.Fg) }
faint := func(s string) string { return mbg(t, s, t.P.Faint) }
// nameW shrinks the breakdown name column on slim terminals.
nameW := 20
if cw < 52 {
nameW = 12
}
// header row: duration + event count
b.WriteString(faint(" duration ") + line(humanDurMs(s.SessionDurationMs)) +
faint(" events ") + line(itoa(int(s.EventCount))) + "\n\n")
put(faint(" duration ") + line(humanDurMs(s.SessionDurationMs)) +
faint(" events ") + line(itoa(int(s.EventCount))))
b.WriteString("\n")
// inference
b.WriteString(section("Inference") + "\n")
b.WriteString(line(fmt.Sprintf(" %d calls · %d prompt + %d completion = %d tok · %.1f tok/s",
s.InferenceCount, s.PromptTokens, s.CompletionTokens, s.PromptTokens+s.CompletionTokens, s.TokensPerSecond)) + "\n")
put(line(fmt.Sprintf(" %d calls · %d+%d tok · %.1f tok/s",
s.InferenceCount, s.PromptTokens, s.CompletionTokens, s.TokensPerSecond)))
for i, p := range s.PerProvider {
if i >= statsBreakdownRows {
b.WriteString(faint(fmt.Sprintf(" … +%d more", len(s.PerProvider)-statsBreakdownRows)) + "\n")
put(faint(fmt.Sprintf(" … +%d more", len(s.PerProvider)-statsBreakdownRows)))
break
}
b.WriteString(faint(fmt.Sprintf(" %-20s %3d calls %6d tok %7d ms %.1f tok/s",
padRaw(p.Provider, 20), p.CompletedCount, p.PromptTokens+p.CompletionTokens, p.TotalLatencyMs, p.TokensPerSecond)) + "\n")
put(faint(fmt.Sprintf(" %-*s %d× %d tok %.1f t/s",
nameW, padRaw(p.Provider, nameW), p.CompletedCount, p.PromptTokens+p.CompletionTokens, p.TokensPerSecond)))
}
b.WriteString("\n")
// tools
b.WriteString(section("Tools") + "\n")
b.WriteString(line(fmt.Sprintf(" %d calls · %d ms", s.ToolCount, s.ToolMs)) + "\n")
put(line(fmt.Sprintf(" %d calls · %d ms", s.ToolCount, s.ToolMs)))
for i, tl := range s.PerTool {
if i >= statsBreakdownRows {
b.WriteString(faint(fmt.Sprintf(" … +%d more", len(s.PerTool)-statsBreakdownRows)) + "\n")
put(faint(fmt.Sprintf(" … +%d more", len(s.PerTool)-statsBreakdownRows)))
break
}
b.WriteString(faint(fmt.Sprintf(" %-24s %3d %7d ms", padRaw(tl.ToolName, 24), tl.CompletedCount, tl.TotalDurationMs)) + "\n")
put(faint(fmt.Sprintf(" %-*s %d× %d ms", nameW, padRaw(tl.ToolName, nameW), tl.CompletedCount, tl.TotalDurationMs)))
}
b.WriteString("\n")
// approvals
b.WriteString(section("Approvals") + "\n")
b.WriteString(line(fmt.Sprintf(" %d req · %d resolved · %d pending · avg wait %d ms",
s.ApprovalsRequested, s.ApprovalsResolved, s.ApprovalsPending, s.AvgApprovalWaitMs)) + "\n")
put(line(fmt.Sprintf(" %d req · %d res · %d pending · avg %d ms",
s.ApprovalsRequested, s.ApprovalsResolved, s.ApprovalsPending, s.AvgApprovalWaitMs)))
for _, tr := range s.PerTier {
b.WriteString(faint(fmt.Sprintf(" %-4s req %d res %d avg %d ms", tr.Tier, tr.RequestedCount, tr.ResolvedCount, tr.AvgWaitMs)) + "\n")
put(faint(fmt.Sprintf(" %-3s req %d res %d avg %d ms", tr.Tier, tr.RequestedCount, tr.ResolvedCount, tr.AvgWaitMs)))
}
b.WriteString("\n")
// failures
f := s.Failures
b.WriteString(section("Failures") + "\n")
b.WriteString(line(fmt.Sprintf(" inference %d · timeouts %d · tool %d · rejected %d · stage %d · workflow %d",
f.InferenceFailures, f.InferenceTimeouts, f.ToolFailures, f.ToolRejections, f.StageFailures, f.WorkflowFailures)) + "\n\n")
put(line(fmt.Sprintf(" inf %d · t/o %d · tool %d · rej %d · stg %d · wf %d",
f.InferenceFailures, f.InferenceTimeouts, f.ToolFailures, f.ToolRejections, f.StageFailures, f.WorkflowFailures)))
b.WriteString("\n")
// time accounting
other := 100.0 - s.InferencePct - s.ToolPct - s.ApprovalWaitPct
if other < 0 {
other = 0
}
b.WriteString(section("Time") + " " +
line(fmt.Sprintf("inference %.1f%% · tools %.1f%% · approval-wait %.1f%% · other %.1f%%",
s.InferencePct, s.ToolPct, s.ApprovalWaitPct, other)) + "\n")
b.WriteString(section("Time") + "\n")
put(line(fmt.Sprintf(" inf %.1f%% · tools %.1f%% · wait %.1f%% · other %.1f%%",
s.InferencePct, s.ToolPct, s.ApprovalWaitPct, other)))
b.WriteString("\n" + modalHints(t, [][2]string{{"S/esc", "close"}}))
return t.Overlay.Width(w).Render(b.String())
+100 -9
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"
}
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))
}
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")
}
if g := m.gaugeText(); g != "" {
right = append(right, span(label, clockFg))
}
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:
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
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
}