Files
correx/apps/tui-go/internal/app/overlays.go
T
kami da3f6c84a3 feat(tui): QA fixes + approval Ctrl keys, workflow focus, session UUID
- gate session-list nav on idle so in-session arrows/jk don't move the list (5a)
- approval dismiss on esc + in-session 'a' reopens a pending approval (5b)
- auto-focus a newly started workflow session (was running invisibly)
- approval actions on Ctrl chords (^a/^r/^x) matching the modal; bare/Alt letters no longer approve; diff closes on ^x/esc
- steer hint in the approval modal; session UUID in the input bar
- env-gated debug logging (CORREX_TUI_LOG) with k.Alt in the key trace
2026-06-01 23:23:29 +04:00

321 lines
9.3 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)
}
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"}, {"s", "steer"}, {"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 (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 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]) + "…"
}