feat: add Go/Bubble Tea TUI rewrite (apps/tui-go)
Reimplement the TUI in Go using Bubble Tea, replacing the Kotlin/Tamboui app. Same WebSocket protocol, soft-rounded layout with blue accent theme.
This commit is contained in:
@@ -0,0 +1,548 @@
|
||||
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")
|
||||
}
|
||||
|
||||
mainH := m.height - statusH - footerH - inputH
|
||||
if mainH < 3 {
|
||||
mainH = 3
|
||||
}
|
||||
|
||||
base := lipgloss.JoinVertical(lipgloss.Left,
|
||||
m.renderStatus(),
|
||||
m.renderMain(mainH),
|
||||
m.renderInput(),
|
||||
m.renderFooter(),
|
||||
)
|
||||
|
||||
if m.overlay != OverlayNone {
|
||||
return m.renderOverlay(base)
|
||||
}
|
||||
if m.displayState() == StateApproval {
|
||||
return m.renderApproval(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 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)
|
||||
}
|
||||
|
||||
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("s", "mode"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
|
||||
}
|
||||
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> · <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) +
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user