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