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,286 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// center composites a modal over a scrim-filled screen. Immediate-mode: the modal
|
||||
// is recomputed from state every frame, so there is no separate show/restore path
|
||||
// to desync (the "diff won't come back" bug class can't occur here).
|
||||
func (m Model) center(modal string) string {
|
||||
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal,
|
||||
lipgloss.WithWhitespaceBackground(m.theme.P.BgDeep),
|
||||
lipgloss.WithWhitespaceForeground(m.theme.P.BgDeep))
|
||||
}
|
||||
|
||||
func (m Model) modalWidth() int {
|
||||
w := m.width * 70 / 100
|
||||
if w > 92 {
|
||||
w = 92
|
||||
}
|
||||
if w < 24 {
|
||||
w = 24
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func (m Model) renderOverlay(base string) string {
|
||||
switch m.overlay {
|
||||
case OverlayPalette:
|
||||
return m.center(m.paletteModal())
|
||||
case OverlayEventInspector:
|
||||
return m.center(m.eventInspectorModal())
|
||||
case OverlayDiff:
|
||||
return m.center(m.diffModal())
|
||||
case OverlayToolPalette:
|
||||
return m.center(m.toolPaletteModal())
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func (m Model) titleLine(text string) string {
|
||||
t := m.theme
|
||||
return lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Bold(true).Render(text)
|
||||
}
|
||||
|
||||
func mbg(t Theme, s string, fg lipgloss.Color) string {
|
||||
return lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(s)
|
||||
}
|
||||
|
||||
func (m Model) renderApproval(base string) string {
|
||||
t := m.theme
|
||||
s := m.session(m.selectedID)
|
||||
if s == nil || s.Pending == nil {
|
||||
return base
|
||||
}
|
||||
a := s.Pending
|
||||
w := m.modalWidth()
|
||||
textW := w - 6
|
||||
|
||||
riskColor := t.P.Warn
|
||||
switch strings.ToUpper(a.Risk) {
|
||||
case "HIGH", "CRITICAL":
|
||||
riskColor = t.P.Bad
|
||||
case "LOW":
|
||||
riskColor = t.P.OK
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(m.titleLine("approval required") + "\n\n")
|
||||
b.WriteString(mbg(t, "tool ", t.P.Faint) + mbg(t, a.ToolName, t.P.FgStrong) + "\n")
|
||||
b.WriteString(mbg(t, "tier ", t.P.Faint) + mbg(t, a.Tier, t.P.Accent2) + "\n")
|
||||
b.WriteString(mbg(t, "risk ", t.P.Faint) + lipgloss.NewStyle().Foreground(riskColor).Background(t.P.BgPanel).Bold(true).Render(strings.ToUpper(a.Risk)) + "\n")
|
||||
if a.Preview != "" {
|
||||
b.WriteString("\n" + mbg(t, "preview", t.P.Faint) + "\n")
|
||||
for _, ln := range limitLines(a.Preview, 6) {
|
||||
b.WriteString(mbg(t, " "+truncate(ln, textW), t.P.Dim) + "\n")
|
||||
}
|
||||
}
|
||||
b.WriteString("\n" + mbg(t, "steer ", t.P.Faint))
|
||||
if m.steerBuffer == "" {
|
||||
b.WriteString(mbg(t, "(optional note)", t.P.Faint))
|
||||
} else {
|
||||
b.WriteString(mbg(t, m.steerBuffer, t.P.FgStrong))
|
||||
}
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏") + "\n\n")
|
||||
b.WriteString(modalHints(t, [][2]string{{"^a", "approve"}, {"^r", "reject"}, {"enter", "approve"}, {"^x", "diff"}, {"esc", "later"}}))
|
||||
|
||||
modal := t.Overlay.Width(w).Render(b.String())
|
||||
return m.center(modal)
|
||||
}
|
||||
|
||||
func (m Model) diffModal() string {
|
||||
t := m.theme
|
||||
w := m.modalWidth()
|
||||
h := m.height * 70 / 100
|
||||
if h < 8 {
|
||||
h = 8
|
||||
}
|
||||
bodyH := h - 6
|
||||
diff := m.currentDiff()
|
||||
lines := strings.Split(strings.TrimRight(diff, "\n"), "\n")
|
||||
|
||||
off := m.diffScrollOffset
|
||||
if off > len(lines)-1 {
|
||||
off = len(lines) - 1
|
||||
}
|
||||
if off < 0 {
|
||||
off = 0
|
||||
}
|
||||
end := off + bodyH
|
||||
if end > len(lines) {
|
||||
end = len(lines)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(m.titleLine("diff") + mbg(t, " ("+itoa(len(lines))+" lines)", t.P.Faint) + "\n\n")
|
||||
for _, ln := range lines[off:end] {
|
||||
fg := t.P.Dim
|
||||
switch {
|
||||
case strings.HasPrefix(ln, "+"):
|
||||
fg = t.P.OK
|
||||
case strings.HasPrefix(ln, "-"):
|
||||
fg = t.P.Bad
|
||||
case strings.HasPrefix(ln, "@@"):
|
||||
fg = t.P.Accent2
|
||||
}
|
||||
b.WriteString(mbg(t, truncate(ln, w-6), fg) + "\n")
|
||||
}
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "scroll"}, {"^x/esc", "close"}}))
|
||||
|
||||
modal := t.Overlay.Width(w).Render(b.String())
|
||||
return m.center(modal)
|
||||
}
|
||||
|
||||
func (m Model) eventInspectorModal() string {
|
||||
t := m.theme
|
||||
w := m.modalWidth()
|
||||
evs := m.currentEvents()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(m.titleLine("event inspector") + "\n\n")
|
||||
if len(evs) == 0 {
|
||||
b.WriteString(mbg(t, "no events", t.P.Faint))
|
||||
}
|
||||
// Budget the plain detail so the styled row never needs ANSI-aware truncation
|
||||
// (truncating a styled string would cut through escape codes and corrupt it).
|
||||
detailBudget := (w - 6) - (2 + 9 + 11 + 1 + 18 + 1)
|
||||
if detailBudget < 4 {
|
||||
detailBudget = 4
|
||||
}
|
||||
for i, e := range evs {
|
||||
cat := inferCategory(e.Type)
|
||||
marker := mbg(t, " ", t.P.BgPanel)
|
||||
fg := t.P.Fg
|
||||
if i == m.overlayEventIdx {
|
||||
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ")
|
||||
fg = t.P.FgStrong
|
||||
}
|
||||
row := marker + mbg(t, padRaw(e.Time, 8)+" ", t.P.Faint) +
|
||||
lipgloss.NewStyle().Foreground(t.categoryColor(cat)).Background(t.P.BgPanel).Render(padRaw("["+cat+"]", 11)) + " " +
|
||||
lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(padRaw(e.Type, 18)) +
|
||||
mbg(t, " "+truncate(e.Detail, detailBudget), t.P.Dim)
|
||||
b.WriteString(row + "\n")
|
||||
}
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"esc", "close"}}))
|
||||
|
||||
modal := t.Overlay.Width(w).Render(b.String())
|
||||
return m.center(modal)
|
||||
}
|
||||
|
||||
func (m Model) paletteModal() string {
|
||||
t := m.theme
|
||||
w := m.modalWidth()
|
||||
cmds := m.filteredPalette()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(m.titleLine("command palette") + "\n\n")
|
||||
|
||||
// filter input line
|
||||
filter := m.paletteFilter
|
||||
caret := mbg(t, " ", t.P.BgPanel)
|
||||
if m.caretVisible() {
|
||||
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏")
|
||||
}
|
||||
prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("› ")
|
||||
if filter == "" {
|
||||
b.WriteString(prompt + caret + mbg(t, "type to filter…", t.P.Faint) + "\n\n")
|
||||
} else {
|
||||
b.WriteString(prompt + mbg(t, filter, t.P.FgStrong) + caret + "\n\n")
|
||||
}
|
||||
|
||||
if len(cmds) == 0 {
|
||||
b.WriteString(mbg(t, " no matching commands", t.P.Faint) + "\n")
|
||||
}
|
||||
for i, c := range cmds {
|
||||
marker := mbg(t, " ", t.P.BgPanel)
|
||||
titleFg := t.P.Fg
|
||||
if i == m.paletteIndex {
|
||||
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
|
||||
titleFg = t.P.FgStrong
|
||||
}
|
||||
b.WriteString(marker +
|
||||
lipgloss.NewStyle().Foreground(titleFg).Background(t.P.BgPanel).Render(padRaw(c.title, 18)) +
|
||||
mbg(t, " "+c.hint, t.P.Faint) + "\n")
|
||||
}
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"enter", "run"}, {"esc", "close"}}))
|
||||
modal := t.Overlay.Width(w).Render(b.String())
|
||||
return m.center(modal)
|
||||
}
|
||||
|
||||
func (m Model) toolPaletteModal() string {
|
||||
t := m.theme
|
||||
w := m.modalWidth()
|
||||
s := m.session(m.selectedID)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(m.titleLine("tool palette") + "\n\n")
|
||||
if s == nil || len(s.ToolsByStage) == 0 {
|
||||
b.WriteString(mbg(t, "no tool manifest for this session", t.P.Faint) + "\n")
|
||||
} else {
|
||||
stages := make([]string, 0, len(s.ToolsByStage))
|
||||
for st := range s.ToolsByStage {
|
||||
stages = append(stages, st)
|
||||
}
|
||||
sort.Strings(stages)
|
||||
for _, st := range stages {
|
||||
label := mbg(t, st, t.P.Accent2)
|
||||
if st == s.CurrentStage {
|
||||
label += lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render(" ◂ current")
|
||||
}
|
||||
b.WriteString(label + "\n")
|
||||
for _, tool := range s.ToolsByStage[st] {
|
||||
tier := lipgloss.NewStyle().Foreground(tierColor(t, tool.Tier)).Background(t.P.BgPanel).Render("T" + itoa(tool.Tier))
|
||||
b.WriteString(mbg(t, " "+padRaw(tool.Name, 22), t.P.Fg) + tier + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"t/esc", "close"}}))
|
||||
modal := t.Overlay.Width(w).Render(b.String())
|
||||
return m.center(modal)
|
||||
}
|
||||
|
||||
func tierColor(t Theme, tier int) lipgloss.Color {
|
||||
switch {
|
||||
case tier >= 3:
|
||||
return t.P.Bad
|
||||
case tier == 2:
|
||||
return t.P.Warn
|
||||
case tier == 1:
|
||||
return t.P.Accent2
|
||||
default:
|
||||
return t.P.OK
|
||||
}
|
||||
}
|
||||
|
||||
func modalHints(t Theme, pairs [][2]string) string {
|
||||
parts := make([]string, 0, len(pairs))
|
||||
for _, p := range pairs {
|
||||
parts = append(parts,
|
||||
lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Bold(true).Render(p[0])+
|
||||
mbg(t, " "+p[1], t.P.Faint))
|
||||
}
|
||||
return strings.Join(parts, mbg(t, " ", t.P.Faint))
|
||||
}
|
||||
|
||||
func limitLines(s string, n int) []string {
|
||||
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
||||
if len(lines) > n {
|
||||
lines = lines[:n]
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func truncate(s string, w int) string {
|
||||
if len([]rune(s)) <= w {
|
||||
return s
|
||||
}
|
||||
r := []rune(s)
|
||||
if w < 1 {
|
||||
return ""
|
||||
}
|
||||
return string(r[:w-1]) + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user