Files
correx/apps/tui-go/internal/app/view.go
T
kami 85121cec9d fix(tui-go): event-stream badge tracks session status, not just the socket
The EVENTS panel showed "● live" off m.connected alone, so a completed/failed/
paused session kept reading "live" as long as the websocket was up — stale, since
a terminal session emits no more events. Derive the badge from the selected
session's Status (the same vocabulary the status bar uses): ✓ done, ✗ failed,
‖ paused, ● live while active, ○ idle when disconnected. event_badge_test.go
covers all five.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 09:52:37 +00:00

864 lines
27 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"fmt"
"os"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
)
const (
statusH = 1
footerH = 1
inputH = 4 // rounded box: 2 borders + 2 content lines
// 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 {
return ""
}
if m.width < 20 || m.height < 8 {
return m.theme.Screen.Render("correx — terminal too small")
}
bottom := m.renderInput()
bottomH := inputH
if m.displayState() == StateApproval {
bottomH = m.approvalBandHeight()
bottom = m.renderApprovalBand(bottomH)
}
mainH := m.height - statusH - footerH - bottomH
if mainH < 3 {
mainH = 3
}
base := lipgloss.JoinVertical(lipgloss.Left,
m.renderStatus(),
m.renderMain(mainH),
bottom,
m.renderFooter(),
)
// Stash the base so center() can composite a modal over a dimmed copy of it.
m.lastBase = base
if m.displayState() == StateClarification {
return m.center(m.clarificationModal())
}
if m.displayState() == StateWorkflowPropose {
return m.center(m.proposeModal())
}
if m.overlay != OverlayNone {
return m.renderOverlay(base)
}
return base
}
// --- top status bar ---
func (m Model) renderStatus() string {
t := m.theme
bg := t.P.BgPanel
span := func(s string, fg lipgloss.Color) string {
return lipgloss.NewStyle().Foreground(fg).Background(bg).Render(s)
}
nrw := m.narrow()
sepStr := " · "
if nrw {
sepStr = " · "
}
sep := span(sepStr, t.P.Faint)
parts := []string{span("correx", t.P.Dim)}
if m.connected {
parts = append(parts, span("●", t.P.OK)+span(" connected", t.P.Dim))
} else if m.reconnecting {
parts = append(parts, span("◌", t.P.Warn)+span(" reconnecting", t.P.Warn))
} else {
parts = append(parts, span("○", t.P.Bad)+span(" disconnected", t.P.Bad))
}
if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle {
parts = append(parts, span(s.Name, t.P.FgStrong))
if s.CurrentStage != "" && m.sbShow("stage") {
parts = append(parts, span("⟐ "+s.CurrentStage, t.P.Accent2))
}
if s.Status != "" && m.sbShow("status") {
parts = append(parts, lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(bg).Render(statusLabel(s.Status)))
}
if s.WorkspaceRoot != "" && !nrw && m.sbShow("workspace") {
parts = append(parts, span("⌂ "+shortPath(s.WorkspaceRoot), t.P.Faint))
}
}
if m.sbShow("model") {
model := m.currentModel
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
// Last-event clock (§2): always visible in-session, so a silent agent is never
// mistaken for a healthy one. Updates every frame tick; turns warn-colored when an
// active session has gone quiet past the stale threshold.
if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle && m.sbShow("clock") {
gap := nowMillis() - s.LastEventAt
clockFg := t.P.Faint
if s.Active && gap > staleEventThresholdMs {
clockFg = t.P.Warn
}
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 != "" && !nrw && m.sbShow("gauge") {
right = append(right, span(g, t.P.Faint))
}
if s := m.session(m.selectedID); s != nil && s.Active {
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 {
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, sep), m.width, bg)
}
// gaugeText renders the live VRAM/GPU/RAM gauge, omitting any unavailable field.
func (m Model) gaugeText() string {
var parts []string
if m.gpuUsedMB != nil && m.gpuTotalMB != nil {
parts = append(parts, "VRAM "+itoa(int(*m.gpuUsedMB))+"/"+itoa(int(*m.gpuTotalMB))+"M")
}
if m.gpuUtil != nil {
parts = append(parts, "GPU "+itoa(*m.gpuUtil)+"%")
}
if m.sysRamUsedMB != nil && m.sysRamTotalMB != nil {
parts = append(parts, "RAM "+itoa(int(*m.sysRamUsedMB))+"/"+itoa(int(*m.sysRamTotalMB))+"M")
}
if m.ramMB != nil {
parts = append(parts, "proc "+itoa(int(*m.ramMB))+"M")
}
return strings.Join(parts, " ")
}
// agoLabel renders a millisecond age as a compact "Ns ago" / "Nm ago" / "Nh ago" /
// "Nd ago". Drives the last-event clock (§2) — the live signal that distinguishes a
// thinking agent from a hung one.
func agoLabel(deltaMs int64) string {
if deltaMs < 0 {
deltaMs = 0
}
s := deltaMs / 1000
switch {
case s < 60:
return itoa(int(s)) + "s ago"
case s < 3600:
return itoa(int(s/60)) + "m ago"
case s < 86400:
return itoa(int(s/3600)) + "h ago"
default:
return itoa(int(s/86400)) + "d ago"
}
}
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
func (m Model) spinner() string { return spinnerFrames[m.frame%len(spinnerFrames)] }
// caretVisible blinks the insert caret roughly twice a second.
func (m Model) caretVisible() bool { return (m.frame/4)%2 == 0 }
// --- bottom footer hints ---
func (m Model) renderFooter() string {
t := m.theme
bg := t.P.BgDeep
hint := func(key, label string) string {
k := lipgloss.NewStyle().Foreground(t.P.Accent).Background(bg).Bold(true).Render(key)
l := lipgloss.NewStyle().Foreground(t.P.Faint).Background(bg).Render(" " + label)
return k + l
}
gap := lipgloss.NewStyle().Background(bg).Render(" ")
nrw := m.narrow()
var hints []string
switch {
case m.editMode == ModeInsert:
hints = []string{hint("enter", "send"), hint("esc", "normal"), hint("↑↓", "history")}
// The `/` command menu only triggers on an empty chat line (mid-line `/` is literal).
if m.inputMode == ModeRouter && m.inputBuffer == "" {
hints = append(hints, hint("/", "cmds"))
}
if m.inputMode == ModeRouter {
hints = append(hints, hint("@", "files"))
}
case m.displayState() == StateApproval:
// 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() == StateClarification:
// The question form owns its keys; the footer mirrors the essentials.
hints = []string{hint("←→", "option"), hint("↑↓", "question"), hint("spc", "select"), hint("enter", "submit"), hint("esc", "later")}
case m.displayState() == StateWorkflowPropose:
// The picker owns its keys; the footer mirrors the essentials.
hints = []string{hint("↑↓", "choose"), hint("enter", "launch"), hint("e", "custom"), hint("esc", "later")}
case m.displayState() == StateIdle:
if nrw {
hints = []string{hint("i", "name"), hint("/", "filter"), hint("w", "wf"), hint("R", "resume"), hint("p", "cmds"), hint("q", "quit")}
} else {
hints = []string{hint("i", "name"), hint("/", "filter"), hint("enter", "open"), hint("jk", "move"), hint("w", "workflows"), hint("R", "resume"), hint("p", "cmds"), hint("q", "quit")}
}
default: // StateInSession
if nrw {
hints = []string{hint("i", "msg"), hint("^↑↓", "msgs"), hint("y", "copy"), hint("e", "events"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
} else {
// Trimmed to fit ~100 cols: tools/stats/model are all reachable via `p cmds`,
// so the footer keeps the high-traffic keys + the new transcript nav/copy.
hints = []string{hint("i", "message"), hint("^↑↓", "msgs"), hint("y", "copy"), hint("e", "events"), hint("t", "tools"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
}
if m.copiedFlash != 0 && m.frame-m.copiedFlash < copiedFlashFrames {
hints = append(hints, lipgloss.NewStyle().Foreground(t.P.OK).Background(bg).Bold(true).Render("✓ copied"))
}
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")
return m.justify(left, right, m.width, bg)
}
// --- 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
var leftTitle, rightTitle string
var leftBody, rightBody []string
leftActive, rightActive := false, false
switch m.displayState() {
case StateIdle:
leftTitle = "sessions"
if m.wfVisible {
leftTitle = "workflows"
leftBody = m.workflowRows(leftW - 4)
} else {
leftBody = m.sessionRows(leftW - 4)
}
leftActive = true
rightTitle = "welcome"
rightBody = m.welcomeRows(rightW - 4)
case StateInSession, StateApproval:
leftTitle = "output"
leftBody = m.routerRows(leftW-4, h-2)
leftActive = true
rightTitle = "events"
rightBody = m.eventRows(rightW-4, h)
}
left := m.theme.box(leftTitle, leftBody, leftW, h, leftActive)
right := m.theme.box(rightTitle, rightBody, rightW, h, rightActive)
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)
// to a single visible line so the fixed-height input box can't overflow and smear stale cells
// (F-006). The underlying inputBuffer keeps its real characters for submission.
func flattenForDisplay(s string) string {
repl := strings.NewReplacer("\r\n", "↵ ", "\r", "↵ ", "\n", "↵ ", "\t", " ")
return repl.Replace(s)
}
func (m Model) renderInput() string {
t := m.theme
placeholder := "Ask anything…"
switch {
case m.inputMode == ModeIntent:
placeholder = "Describe the task for " + m.wfPendingName + " (Enter to start, empty = none)…"
case m.inputMode == ModeFilter:
placeholder = "Filter sessions…"
case m.displayState() == StateIdle:
placeholder = "Session name…"
}
insert := m.editMode == ModeInsert
caret := t.span(" ", t.P.Bg)
if insert && m.caretVisible() {
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏")
}
var line string
switch {
case m.inputBuffer == "" && !insert:
line = t.span(placeholder, t.P.Faint)
case m.inputBuffer == "":
line = caret + t.span(placeholder, t.P.Faint)
default:
line = t.span(flattenForDisplay(m.inputBuffer), t.P.FgStrong) + caret
}
// status sub-line: <session/none> · <uuid> · <mode>
name := "(no session)"
if s := m.session(m.selectedID); s != nil {
name = s.Name
}
mode := "chat"
if m.chatMode == ChatModeSteering {
mode = "steering"
}
if m.inputMode == ModeFilter {
mode = "filter"
}
if m.inputMode == ModeIntent {
mode = "task"
}
sub := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(name)
if m.selectedID != "" {
sub += t.span(" · ", t.P.Faint) +
t.span(m.selectedID, t.P.Faint)
}
sub += t.span(" · ", t.P.Faint) +
t.span(mode, t.P.Dim)
body := []string{line, sub}
return t.box("", body, m.width, inputH, insert)
}
// --- body row builders ---
func (m Model) sessionRows(w int) []string {
t := m.theme
list := m.filteredSessions()
if len(list) == 0 {
return []string{t.span("no sessions yet", t.P.Faint), "", t.span("type a name below to begin", t.P.Faint)}
}
rows := make([]string, 0, len(list))
for _, s := range list {
sel := s.ID == m.selectedID && !m.wfVisible
short := s.ID
if len(short) > 6 {
short = short[:6]
}
nameFg := t.P.Fg
bar := t.span(" ", t.P.Bg)
if sel {
bar = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▌ ")
nameFg = t.P.FgStrong
}
idCell := t.span("["+short+"] ", t.P.Faint)
statusCell := lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(t.P.Bg).Bold(true).
Render(padRaw(statusLabel(s.Status), 9))
nameCell := lipgloss.NewStyle().Foreground(nameFg).Background(t.P.Bg).Render(" " + s.Name)
stage := ""
if s.CurrentStage != "" {
stage = t.span(" ["+s.CurrentStage+"]", t.P.Dim)
}
rows = append(rows, bar+idCell+statusCell+nameCell+stage)
}
return rows
}
func (m Model) workflowRows(w int) []string {
t := m.theme
if len(m.workflows) == 0 {
return []string{t.span("no workflows advertised", t.P.Faint)}
}
rows := make([]string, 0, len(m.workflows))
for i, wf := range m.workflows {
marker := t.span(" ", t.P.Bg)
fg := t.P.Fg
if i == m.wfIndex {
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▸ ")
fg = t.P.FgStrong
}
rows = append(rows, marker+lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(wf.ID)+t.span(" "+wf.Description, t.P.Faint))
}
return rows
}
func (m Model) welcomeRows(w int) []string {
t := m.theme
title := lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.Bg).Bold(true).Render("Corre") +
lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("x") +
lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.Bg).Bold(true).Render(" Agent Harness")
var status string
if m.connected {
status = lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("Connected") +
t.span(" · ", t.P.Faint) + t.span(plural(len(m.sessions), "session"), t.P.Dim)
} else {
status = lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Render("Disconnected")
}
rows := []string{title, status, "",
t.span("Select a session from the left", t.P.Dim),
t.span("or type a name to start a new one.", t.P.Dim), ""}
if n := len(m.sessions); n > 0 {
rows = append(rows, t.span("recent:", t.P.Faint))
start := 0
if n > 5 {
start = n - 5
}
for _, s := range m.sessions[start:] {
mark := statusGlyph(t, s.Status)
rows = append(rows, t.span(" "+s.Name+" ", t.P.Fg)+t.span(formatTime(s.LastEventAt), t.P.Faint)+" "+mark)
}
}
return rows
}
func (m Model) routerRows(w, h int) []string {
t := m.theme
if len(m.routerMessages[m.selectedID]) == 0 {
return []string{t.span("awaiting router response…", t.P.Faint)}
}
rows, msgStart := m.buildTranscriptRows(w)
// With a selection, scroll so the selected message is visible (top-aligned, clamped to the
// end); otherwise honour the free-scroll offset (0 = tail-follow the newest output).
if m.transcriptSel >= 0 && m.transcriptSel < len(msgStart) && len(rows) > h {
start := msgStart[m.transcriptSel]
if start > len(rows)-h {
start = len(rows) - h
}
if start < 0 {
start = 0
}
return rows[start : start+h]
}
if len(rows) > h {
off := m.outputScroll
if max := len(rows) - h; off > max {
off = max
}
if off < 0 {
off = 0
}
end := len(rows) - off
return rows[end-h : end]
}
return rows
}
// buildTranscriptRows renders the selected session's transcript to display rows (and records each
// message's first row index for scroll-to-selection). Split out so the scroll handler can measure
// the full height against the same content the renderer windows.
func (m Model) buildTranscriptRows(w int) ([]string, []int) {
t := m.theme
msgs := m.routerMessages[m.selectedID]
var rows []string
msgStart := make([]int, len(msgs)) // first row index of each message, for scroll-to-selection
for mi, e := range msgs {
msgStart[mi] = len(rows)
switch e.Role {
case "user":
if mi == m.transcriptSel {
// Selected message: an inverted tag + a highlighted background bar so it's
// unmistakable which one ctrl+↑/↓ landed on (and what `y` will copy).
tag := lipgloss.NewStyle().Foreground(t.P.BgDeep).Background(t.P.Accent).Bold(true).Render(" ")
rows = append(rows, tag+" "+lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.Sel).Render(e.Content))
break
}
tag := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("")
rows = append(rows, tag+" "+t.span(e.Content, t.P.FgStrong))
case "router":
// The router turn is model output: render it as markdown (bold,
// lists, headings, code) wrapped to the panel width. glamour applies
// its own inline foreground colors; we keep the opaque panel by
// fixing each line's background. On any failure renderMarkdown hands
// back the plain content, which still flows through this path fine.
rendered := renderMarkdown(e.Content, w)
for _, ln := range strings.Split(rendered, "\n") {
rows = append(rows, lipgloss.NewStyle().Background(t.P.Bg).Render(ln))
}
if s := metricsSuffix(e.Metrics); s != "" {
rows = append(rows, t.span(s, t.P.Faint))
}
case "tool":
rows = append(rows, t.span("· tool output ("+itoaLen(e.Content)+" chars) — ^x to view", t.P.Dim))
case "narration_llm":
diamond := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("◆")
lines := wrap(e.Content, w-2)
for i, ln := range lines {
if i == 0 {
rows = append(rows, diamond+" "+t.span(ln, t.P.FgStrong))
} else {
rows = append(rows, " "+t.span(ln, t.P.FgStrong))
}
}
if s := metricsSuffix(e.Metrics); s != "" {
rows = append(rows, t.span(s, t.P.Faint))
}
case "narration", "stage":
rows = append(rows, t.span(e.Content, t.P.Dim))
case "action":
// Inline "external feedback" row: a side-effecting action (tool call, write/edit,
// approval, grant) surfaced in the conversation flow. Muted via actionsHidden.
if m.actionsHidden {
break
}
icon := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(e.Icon)
rows = append(rows, " "+icon+" "+t.span(e.Content, t.P.Dim))
}
}
return rows, msgStart
}
func (m Model) eventRows(w, h int) []string {
t := m.theme
header := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("event stream") +
t.span(" ", t.P.Bg) + m.eventStreamBadge()
s := m.session(m.selectedID)
if s == nil || len(s.Events) == 0 {
return []string{header, "", t.span("no events yet", t.P.Faint)}
}
evRows := make([]string, 0, len(s.Events))
for _, e := range s.Events {
cat := inferCategory(e.Type)
catCell := lipgloss.NewStyle().Foreground(t.categoryColor(cat)).Background(t.P.BgPanel).
Render(" " + padRaw(cat, 9) + " ")
evRows = append(evRows,
t.span(e.Time+" ", t.P.Faint)+catCell+" "+
t.span(padRaw(e.Type, 18), t.P.Fg)+t.span(e.Detail, t.P.Dim))
}
// Show the latest events that fit under the 2-line header (box inner = h-2).
if avail := h - 4; avail >= 1 && len(evRows) > avail {
evRows = evRows[len(evRows)-avail:]
}
return append([]string{header, ""}, evRows...)
}
// eventStreamBadge reflects whether the *selected session's* stream is still live, not
// just whether the socket is connected: a completed / failed / paused session emits no
// more events, so the old connection-only "● live" went stale the moment a session ended.
func (m Model) eventStreamBadge() string {
t := m.theme
if !m.connected {
return t.span("○ idle", t.P.Faint)
}
if s := m.session(m.selectedID); s != nil {
badge := func(fg lipgloss.Color, text string) string {
return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(text)
}
switch u := strings.ToUpper(s.Status); {
case strings.Contains(u, "FAIL"):
return badge(t.P.Bad, "✗ failed")
case strings.Contains(u, "COMPLETE"):
return badge(t.P.OK, "✓ done")
case strings.Contains(u, "PAUSE"):
return badge(t.P.Warn, "‖ paused")
}
}
return lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("● live")
}
// --- width helpers ---
// shortPath abbreviates the user's home dir to ~ for a compact cwd in the status bar.
func shortPath(p string) string {
if home := os.Getenv("HOME"); home != "" && strings.HasPrefix(p, home) {
return "~" + p[len(home):]
}
return p
}
// fill left-justifies a styled line and pads with bg to width w.
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, 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)
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
}
pad := lipgloss.NewStyle().Background(bg).Render(strings.Repeat(" ", mid))
return " " + left + pad + right + lipgloss.NewStyle().Background(bg).Render(" ")
}
// padTo pads (or truncates) a possibly-styled string to visible width w, filling
// with the given background so the panel stays opaque.
func padTo(s string, w int, bg lipgloss.Color) string {
vw := lipgloss.Width(s)
if vw == w {
return s
}
if vw > w {
return ansi.Truncate(s, w, "")
}
return s + lipgloss.NewStyle().Background(bg).Render(strings.Repeat(" ", w-vw))
}
// padRaw pads a plain (unstyled) string to width w with spaces, truncating with
// an ellipsis only when it genuinely overflows.
func padRaw(s string, w int) string {
n := len([]rune(s))
if n == w {
return s
}
if n > w {
if w <= 1 {
return string([]rune(s)[:w])
}
return string([]rune(s)[:w-1]) + "…"
}
return s + strings.Repeat(" ", w-n)
}
func statusColor(t Theme, status string) lipgloss.Color {
u := strings.ToUpper(status)
switch {
case strings.Contains(u, "FAIL"):
return t.P.Bad
case strings.Contains(u, "COMPLETE"):
return t.P.Dim
case strings.Contains(u, "PAUSE"), strings.Contains(u, "STARTING"):
return t.P.Warn
case strings.Contains(u, "CHAT"), strings.Contains(u, "ACTIVE"):
return t.P.Accent
default:
return t.P.Dim
}
}
func statusLabel(status string) string {
u := strings.ToUpper(status)
if i := strings.Index(u, " "); i > 0 {
return u[:i]
}
return u
}
func statusGlyph(t Theme, status string) string {
u := strings.ToUpper(status)
switch {
case strings.Contains(u, "FAIL"):
return lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Render("✗")
case strings.Contains(u, "COMPLETE"):
return lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("✓")
default:
return t.span("·", t.P.Faint)
}
}
// span renders text with foreground fg over the panel background.
func (t Theme) span(s string, fg lipgloss.Color) string {
return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(s)
}
// box draws a rounded, opaque panel with the title embedded in the top border.
func (t Theme) box(title string, body []string, w, h int, active bool) string {
if w < 4 {
w = 4
}
if h < 2 {
h = 2
}
inner := w - 2
textW := inner - 2
bc := t.P.Border
if active {
bc = t.P.BorderHi
}
bs := lipgloss.NewStyle().Foreground(bc).Background(t.P.Bg)
cell := lipgloss.NewStyle().Background(t.P.Bg)
var top string
if title == "" {
top = bs.Render("╭" + strings.Repeat("─", inner) + "╮")
} else {
ts := t.PanelTitle
if active {
ts = t.TitleHi
}
label := strings.ToUpper(title)
fill := inner - (2 + lipgloss.Width(label) + 1)
if fill < 0 {
fill = 0
}
top = bs.Render("╭─ ") + ts.Render(label) + bs.Render(" "+strings.Repeat("─", fill)) + bs.Render("╮")
}
lines := make([]string, 0, h)
lines = append(lines, top)
for i := 0; i < h-2; i++ {
content := ""
if i < len(body) {
content = body[i]
}
padded := padTo(content, textW, t.P.Bg)
lines = append(lines, bs.Render("│")+cell.Render(" ")+padded+cell.Render(" ")+bs.Render("│"))
}
lines = append(lines, bs.Render("╰"+strings.Repeat("─", inner)+"╯"))
return strings.Join(lines, "\n")
}
func plural(n int, word string) string {
s := itoa(n) + " " + word
if n != 1 {
s += "s"
}
return s
}
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
b[i] = '-'
}
return string(b[i:])
}
func itoaLen(s string) string { return itoa(len(s)) }
// metricsSuffix formats the faint latency+token annotation for a ROUTER turn.
func metricsSuffix(mtr *TurnMetrics) string {
if mtr == nil {
return ""
}
return fmt.Sprintf(" %.1fs · %d tokens", float64(mtr.LatencyMs)/1000.0, mtr.TotalTokens)
}
// wrap splits s into lines no wider than w (rune-based, whitespace-greedy).
func wrap(s string, w int) []string {
if w < 1 {
w = 1
}
words := strings.Fields(s)
if len(words) == 0 {
return []string{""}
}
var lines []string
cur := ""
for _, word := range words {
if cur == "" {
cur = word
} else if len(cur)+1+len(word) <= w {
cur += " " + word
} else {
lines = append(lines, cur)
cur = word
}
}
if cur != "" {
lines = append(lines, cur)
}
return lines
}