Files
correx/apps/tui-go/internal/app/overlays.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

366 lines
11 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 (
"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())
case OverlayModels:
return m.center(m.modelsModal())
}
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)
}
// approvalBandHeight sizes the docked approval band to its diff, capped so it
// never crowds out the session above (longer diffs spill to the ^x fullscreen
// view). Layout: 2 borders + header + blank + diff rows + blank + action row.
func (m Model) approvalBandHeight() int {
rows := 0
if s := m.session(m.selectedID); s != nil && s.Pending != nil {
rows = len(parseUnifiedDiff(s.Pending.Preview))
}
h := 2 + 1 + 1 + rows + 1 + 1
if maxH := m.height * 55 / 100; h > maxH {
h = maxH
}
if h < 10 {
h = 10
}
if hardMax := m.height - statusH - footerH - 3; hardMax > 4 && h > hardMax {
h = hardMax
}
return h
}
// renderApprovalBand draws the approval gate as a full-width band (in place of
// the input bar): tool/tier/risk header, a two-column old|new diff, and the
// action row. It never floats over the rest of the UI.
func (m Model) renderApprovalBand(h int) string {
t := m.theme
s := m.session(m.selectedID)
if s == nil || s.Pending == nil {
return m.renderInput()
}
a := s.Pending
textW := m.width - 4 // box borders (2) + side padding (2)
riskColor := t.P.Warn
switch strings.ToUpper(a.Risk) {
case "HIGH", "CRITICAL":
riskColor = t.P.Bad
case "LOW":
riskColor = t.P.OK
}
leftHdr := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("→ ") +
t.span(a.ToolName, t.P.FgStrong)
if tgt := diffTarget(a.Preview); tgt != "" {
leftHdr += t.span(" "+tgt, t.P.Dim)
}
rightHdr := t.span("tier ", t.P.Faint) + t.span(a.Tier, t.P.Accent2) +
t.span(" · risk ", t.P.Faint) +
lipgloss.NewStyle().Foreground(riskColor).Background(t.P.Bg).Bold(true).Render(strings.ToUpper(a.Risk))
innerH := h - 2
diffH := innerH - 4 // header, blank, blank, action row
if diffH < 1 {
diffH = 1
}
rows := parseUnifiedDiff(a.Preview)
body := make([]string, 0, innerH)
body = append(body, m.justify(leftHdr, rightHdr, textW, t.P.Bg), "")
body = append(body, m.renderSplitDiff(rows, textW, diffH, 0)...)
for len(body) < 2+diffH {
body = append(body, "")
}
body = append(body, "", m.approvalActions())
return t.box("permission required", body, m.width, h, true)
}
// approvalActions renders the action row of the approval band, or the live steer
// note when the operator is composing one.
func (m Model) approvalActions() string {
t := m.theme
if m.steering {
note, fg := m.steerBuffer, t.P.FgStrong
if note == "" {
note, fg = "(steer note — enter to send, esc to cancel)", t.P.Faint
}
return t.span("steer ", t.P.Faint) + t.span(note, fg) +
lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏")
}
hint := func(k, l string) string {
return lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render(k) +
t.span(" "+l, t.P.Faint)
}
parts := []string{hint("a", "approve"), hint("A", "auto"), hint("s", "steer"),
hint("r", "reject"), hint("^x", "fullscreen"), hint("esc", "later")}
return strings.Join(parts, t.span(" ", t.P.Faint))
}
// diffBodyHeight is the number of diff lines visible in the diff modal body.
func (m Model) diffBodyHeight() int {
h := m.height * 70 / 100
if h < 8 {
h = 8
}
return h - 6
}
// diffMaxScroll is the largest scroll offset that still fills the body with
// diff rows, keeping the last page anchored to the bottom of the modal.
func (m Model) diffMaxScroll() int {
rows := parseUnifiedDiff(m.currentDiff())
max := len(rows) - m.diffBodyHeight()
if max < 0 {
max = 0
}
return max
}
func (m Model) diffModal() string {
t := m.theme
w := m.modalWidth()
bodyH := m.diffBodyHeight()
diff := m.currentDiff()
rows := parseUnifiedDiff(diff)
off := m.diffScrollOffset
if off > m.diffMaxScroll() {
off = m.diffMaxScroll()
}
if off < 0 {
off = 0
}
var b strings.Builder
b.WriteString(m.titleLine("diff"))
if tgt := diffTarget(diff); tgt != "" {
b.WriteString(mbg(t, " "+tgt, t.P.Dim))
}
b.WriteString(mbg(t, " ("+itoa(len(rows))+" rows)", t.P.Faint) + "\n\n")
for _, ln := range m.renderSplitDiff(rows, w-6, bodyH, off) {
b.WriteString(ln + "\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 (m Model) modelsModal() string {
t := m.theme
w := m.modalWidth()
var b strings.Builder
b.WriteString(m.titleLine("models") + "\n\n")
if g := m.gaugeText(); g != "" {
b.WriteString(mbg(t, g, t.P.Faint) + "\n\n")
}
if len(m.availableModels) == 0 {
b.WriteString(mbg(t, " model management not enabled", t.P.Faint) + "\n")
}
for i, id := range m.availableModels {
marker := mbg(t, " ", t.P.BgPanel)
nameFg := t.P.Fg
if i == m.modelsIndex {
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
nameFg = t.P.FgStrong
}
line := marker + lipgloss.NewStyle().Foreground(nameFg).Background(t.P.BgPanel).Render(padRaw(id, 28))
if id == m.currentModel {
line += lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Render(" ◂ resident")
}
b.WriteString(line + "\n")
}
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"enter", "swap"}, {"c", "clear pin"}, {"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 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]) + "…"
}