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.
This commit is contained in:
@@ -52,15 +52,38 @@ 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 {
|
||||
// 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 base
|
||||
return m.renderInput()
|
||||
}
|
||||
a := s.Pending
|
||||
w := m.modalWidth()
|
||||
textW := w - 6
|
||||
textW := m.width - 4 // box borders (2) + side padding (2)
|
||||
|
||||
riskColor := t.P.Warn
|
||||
switch strings.ToUpper(a.Risk) {
|
||||
@@ -70,28 +93,51 @@ func (m Model) renderApproval(base string) string {
|
||||
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")
|
||||
}
|
||||
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)
|
||||
}
|
||||
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"}}))
|
||||
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))
|
||||
|
||||
modal := t.Overlay.Width(w).Render(b.String())
|
||||
return m.center(modal)
|
||||
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.
|
||||
@@ -104,10 +150,10 @@ func (m Model) diffBodyHeight() int {
|
||||
}
|
||||
|
||||
// diffMaxScroll is the largest scroll offset that still fills the body with
|
||||
// diff lines, keeping the last page anchored to the bottom of the modal.
|
||||
// diff rows, keeping the last page anchored to the bottom of the modal.
|
||||
func (m Model) diffMaxScroll() int {
|
||||
lines := strings.Split(strings.TrimRight(m.currentDiff(), "\n"), "\n")
|
||||
max := len(lines) - m.diffBodyHeight()
|
||||
rows := parseUnifiedDiff(m.currentDiff())
|
||||
max := len(rows) - m.diffBodyHeight()
|
||||
if max < 0 {
|
||||
max = 0
|
||||
}
|
||||
@@ -119,7 +165,7 @@ func (m Model) diffModal() string {
|
||||
w := m.modalWidth()
|
||||
bodyH := m.diffBodyHeight()
|
||||
diff := m.currentDiff()
|
||||
lines := strings.Split(strings.TrimRight(diff, "\n"), "\n")
|
||||
rows := parseUnifiedDiff(diff)
|
||||
|
||||
off := m.diffScrollOffset
|
||||
if off > m.diffMaxScroll() {
|
||||
@@ -128,24 +174,15 @@ func (m Model) diffModal() string {
|
||||
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(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"}}))
|
||||
|
||||
@@ -316,14 +353,6 @@ func modalHints(t Theme, pairs [][2]string) string {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user