diff --git a/apps/tui-go/internal/app/adaptive_layout_test.go b/apps/tui-go/internal/app/adaptive_layout_test.go new file mode 100644 index 00000000..c1a27f9a --- /dev/null +++ b/apps/tui-go/internal/app/adaptive_layout_test.go @@ -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) + } +} diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index 7e68903d..c6bc9cbe 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -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()) diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 1f1b4e01..9f666ab5 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -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 }