f7fc10ddf5
- Events were hard-capped to the last 7 per session in addEvent, so the e-inspector and EVENTS panel could never show more. Raise to 1000 (effectively all, bounded); the EVENTS panel now shows the latest that fit (tail), and the inspector scrolls a window around the selection with a position indicator. - The TUI had no approval.resolved handling at all — the decision frame was ignored and pending only cleared on session.resumed. Add the constant + case: clear the gate and record an ApprovalResolved event (APPROVED/REJECTED/AUTO_APPROVED + reason) into the stream. - Status bar now shows the current stage and session status alongside the name. Relabel the cryptic "N bg" badge to "N elsewhere".
423 lines
12 KiB
Go
423 lines
12 KiB
Go
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, rat := 0, 0
|
||
if s := m.session(m.selectedID); s != nil && s.Pending != nil {
|
||
rows = previewRowCount(s.Pending.Preview)
|
||
if n := len(s.Pending.Rationale); n > 0 {
|
||
rat = n + 1 // rationale lines + trailing blank
|
||
}
|
||
}
|
||
h := 2 + 1 + 1 + rat + 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
|
||
ratBlock := 0
|
||
if len(a.Rationale) > 0 {
|
||
ratBlock = len(a.Rationale) + 1 // rationale lines + trailing blank
|
||
}
|
||
diffH := innerH - 4 - ratBlock // header, blank, blank, action row, + rationale block
|
||
if diffH < 1 {
|
||
diffH = 1
|
||
}
|
||
body := make([]string, 0, innerH)
|
||
body = append(body, m.justify(leftHdr, rightHdr, textW, t.P.Bg), "")
|
||
for _, r := range a.Rationale {
|
||
marker := lipgloss.NewStyle().Foreground(t.P.Warn).Background(t.P.Bg).Render("▲ ")
|
||
body = append(body, marker+t.span(truncate(r, textW-2), t.P.Dim))
|
||
}
|
||
if len(a.Rationale) > 0 {
|
||
body = append(body, "")
|
||
}
|
||
var content []string
|
||
if isUnifiedDiff(a.Preview) {
|
||
content = m.renderSplitDiff(parseUnifiedDiff(a.Preview), textW, diffH, 0)
|
||
} else {
|
||
content = m.renderPreviewLines(a.Preview, textW, diffH, 0)
|
||
}
|
||
diffStart := len(body)
|
||
body = append(body, content...)
|
||
for len(body) < diffStart+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")}
|
||
if s := m.session(m.selectedID); s != nil && s.Pending != nil && isUnifiedDiff(s.Pending.Preview) {
|
||
parts = append(parts, hint("^x", "fullscreen"))
|
||
}
|
||
parts = append(parts, 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 {
|
||
max := previewRowCount(m.currentDiff()) - 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()
|
||
isDiff := isUnifiedDiff(diff)
|
||
|
||
off := m.diffScrollOffset
|
||
if off > m.diffMaxScroll() {
|
||
off = m.diffMaxScroll()
|
||
}
|
||
if off < 0 {
|
||
off = 0
|
||
}
|
||
|
||
var b strings.Builder
|
||
title := "preview"
|
||
if isDiff {
|
||
title = "diff"
|
||
}
|
||
b.WriteString(m.titleLine(title))
|
||
if tgt := diffTarget(diff); tgt != "" {
|
||
b.WriteString(mbg(t, " "+tgt, t.P.Dim))
|
||
}
|
||
b.WriteString(mbg(t, " ("+itoa(previewRowCount(diff))+" rows)", t.P.Faint) + "\n\n")
|
||
var lines []string
|
||
if isDiff {
|
||
lines = m.renderSplitDiff(parseUnifiedDiff(diff), w-6, bodyH, off)
|
||
} else {
|
||
lines = m.renderPreviewLines(diff, w-6, bodyH, off)
|
||
}
|
||
for _, ln := range lines {
|
||
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"))
|
||
if len(evs) > 0 {
|
||
b.WriteString(mbg(t, " ("+itoa(m.overlayEventIdx+1)+"/"+itoa(len(evs))+")", t.P.Faint))
|
||
}
|
||
b.WriteString("\n\n")
|
||
if len(evs) == 0 {
|
||
b.WriteString(mbg(t, "no events", t.P.Faint))
|
||
return m.center(t.Overlay.Width(w).Render(b.String()))
|
||
}
|
||
// 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
|
||
}
|
||
// Window the (potentially long) list to a visible height, keeping the
|
||
// selected row in view — the modal must not grow past the screen.
|
||
bodyH := m.height*70/100 - 4
|
||
if bodyH < 3 {
|
||
bodyH = 3
|
||
}
|
||
off := 0
|
||
if len(evs) > bodyH {
|
||
off = m.overlayEventIdx - bodyH/2
|
||
if off < 0 {
|
||
off = 0
|
||
}
|
||
if off > len(evs)-bodyH {
|
||
off = len(evs) - bodyH
|
||
}
|
||
}
|
||
end := off + bodyH
|
||
if end > len(evs) {
|
||
end = len(evs)
|
||
}
|
||
for i := off; i < end; i++ {
|
||
e := evs[i]
|
||
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]) + "…"
|
||
}
|