Files
correx/apps/tui-go/internal/app/view.go
T

629 lines
18 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
)
// 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.ramMB != nil {
parts = append(parts, "RAM "+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 ---
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(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
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
}