Files
correx/apps/tui-go/internal/app/view.go
T
kami 03ccac76c7 feat(tui): dock approval gate + two-column diff viewer
Replace the floating approval modal (which blanked the whole UI) with an
opencode-style band that takes the input bar's slot while a gate is
pending: tool/tier/risk header above a side-by-side old|new diff, with
the session output and event stream still visible above it. The band
height adapts to the diff length.

Add a unified-diff parser that aligns removals/additions into split rows
(blank left cell for a create, blank right for a delete) and a
two-column renderer shared by the band preview and the ^x fullscreen
view. Wire plain a/r to approve/reject to match the advertised keys.
2026-06-03 00:17:08 +04:00

578 lines
16 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 (
"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))
}
}
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)+" bg", 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:
hints = []string{hint("a", "approve"), hint("A", "auto"), hint("s", "steer"), hint("r", "reject"), hint("x", "diff"), hint("esc", "later")}
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)
}
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 == 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"
}
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))
}
case "tool":
rows = append(rows, t.span("· tool output ("+itoaLen(e.Content)+" chars) — ^x to view", t.P.Dim))
}
}
// keep last h rows
if len(rows) > h {
rows = rows[len(rows)-h:]
}
return rows
}
func (m Model) eventRows(w 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)
}
rows := []string{header, ""}
s := m.session(m.selectedID)
if s == nil || len(s.Events) == 0 {
rows = append(rows, t.span("no events yet", t.P.Faint))
return rows
}
for _, e := range s.Events {
cat := inferCategory(e.Type)
catCell := lipgloss.NewStyle().Foreground(t.categoryColor(cat)).Background(t.P.BgPanel).
Render(" " + padRaw(cat, 9) + " ")
rows = append(rows,
t.span(e.Time+" ", t.P.Faint)+catCell+" "+
t.span(padRaw(e.Type, 18), t.P.Fg)+t.span(e.Detail, t.P.Dim))
}
return rows
}
// --- width helpers ---
// 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)) }
// 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
}