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