3600ec6897
The plane-2 tool-call assessor records verified preconditions
("[PATH_OUTSIDE_WORKSPACE] …") and the server already ships them in
RiskSummaryDto.rationale, but the Go TUI's RiskSummaryDto had no
Rationale field — so the justification was silently dropped at decode
and the gate showed only an opaque tier.
Decode the rationale, carry it on Approval, and render it under the
header in the approval band (warn-marked, above the diff). This closes
the last deferred item of the plane-2 slice-1 plan: the assessment
surfaced to the approval UX. Golden test pins the rationale decode.
381 lines
11 KiB
Go
381 lines
11 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 = len(parseUnifiedDiff(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
|
||
}
|
||
rows := parseUnifiedDiff(a.Preview)
|
||
|
||
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, "")
|
||
}
|
||
diffStart := len(body)
|
||
body = append(body, m.renderSplitDiff(rows, textW, diffH, 0)...)
|
||
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"), 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]) + "…"
|
||
}
|