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 ) // 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(), ) 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) } sep := span(" · ", 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 != "" { parts = append(parts, span("⟐ "+s.CurrentStage, t.P.Accent2)) } if s.Status != "" { parts = append(parts, lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(bg).Render(statusLabel(s.Status))) } if s.WorkspaceRoot != "" { parts = append(parts, span("⌂ "+shortPath(s.WorkspaceRoot), t.P.Faint)) } } model := m.currentModel if model == "" { model = "no model" } 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 if g := m.gaugeText(); g != "" { 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)) } if m.bgUpdates > 0 { right = append(right, span(itoa(m.bgUpdates)+" elsewhere", 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) } // 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, " ") } 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(" ") var hints []string switch { case m.editMode == ModeInsert: hints = []string{hint("enter", "send"), hint("esc", "normal"), hint("↑↓", "history")} 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() == StateIdle: 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("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) 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 { 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) } // --- 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: · · 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 msgs := m.routerMessages[m.selectedID] if len(msgs) == 0 { return []string{t.span("awaiting router response…", t.P.Faint)} } var rows []string for _, e := range msgs { switch e.Role { case "user": 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": for _, ln := range wrap(e.Content, w) { rows = append(rows, t.span(ln, t.P.Fg)) } 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)) } } // keep last h rows if len(rows) > h { rows = rows[len(rows)-h:] } return rows } 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) if m.connected { header += lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("● live") } else { header += t.span("○ idle", t.P.Faint) } 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...) } // --- 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. func (m Model) justify(left, right string, w int, bg lipgloss.Color) string { lw := lipgloss.Width(left) rw := lipgloss.Width(right) mid := w - lw - rw - 2 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 }