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:
@@ -0,0 +1,184 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
type diffKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
diffBlank diffKind = iota // no line on this side
|
||||||
|
diffContext
|
||||||
|
diffDel
|
||||||
|
diffAdd
|
||||||
|
)
|
||||||
|
|
||||||
|
// diffRow is one aligned row of a split (side-by-side) diff: the old line on the
|
||||||
|
// left, the new line on the right. Either side may be blank (pure add/remove).
|
||||||
|
type diffRow struct {
|
||||||
|
oldNum, newNum int // 0 = blank cell
|
||||||
|
oldText, newText string
|
||||||
|
oldKind, newKind diffKind
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseUnifiedDiff turns a unified diff into aligned old|new rows. File headers
|
||||||
|
// (---, +++, diff, index) are dropped; @@ markers seed the line numbers.
|
||||||
|
// Consecutive removals and additions are zipped so an edit lines up; a pure
|
||||||
|
// create yields blank left cells (and vice-versa for a delete).
|
||||||
|
func parseUnifiedDiff(diff string) []diffRow {
|
||||||
|
var rows []diffRow
|
||||||
|
oldN, newN := 1, 1
|
||||||
|
var dels, adds []string
|
||||||
|
flush := func() {
|
||||||
|
n := len(dels)
|
||||||
|
if len(adds) > n {
|
||||||
|
n = len(adds)
|
||||||
|
}
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
r := diffRow{}
|
||||||
|
if i < len(dels) {
|
||||||
|
r.oldNum, r.oldText, r.oldKind = oldN, dels[i], diffDel
|
||||||
|
oldN++
|
||||||
|
}
|
||||||
|
if i < len(adds) {
|
||||||
|
r.newNum, r.newText, r.newKind = newN, adds[i], diffAdd
|
||||||
|
newN++
|
||||||
|
}
|
||||||
|
rows = append(rows, r)
|
||||||
|
}
|
||||||
|
dels, adds = dels[:0], adds[:0]
|
||||||
|
}
|
||||||
|
for _, ln := range strings.Split(strings.TrimRight(diff, "\n"), "\n") {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(ln, "+++"), strings.HasPrefix(ln, "---"),
|
||||||
|
strings.HasPrefix(ln, "diff "), strings.HasPrefix(ln, "index "):
|
||||||
|
continue
|
||||||
|
case strings.HasPrefix(ln, "@@"):
|
||||||
|
flush()
|
||||||
|
oldN, newN = hunkStarts(ln)
|
||||||
|
case strings.HasPrefix(ln, "-"):
|
||||||
|
dels = append(dels, ln[1:])
|
||||||
|
case strings.HasPrefix(ln, "+"):
|
||||||
|
adds = append(adds, ln[1:])
|
||||||
|
default: // context (leading space) or stray blank line
|
||||||
|
flush()
|
||||||
|
text := strings.TrimPrefix(ln, " ")
|
||||||
|
rows = append(rows, diffRow{
|
||||||
|
oldNum: oldN, newNum: newN, oldText: text, newText: text,
|
||||||
|
oldKind: diffContext, newKind: diffContext,
|
||||||
|
})
|
||||||
|
oldN++
|
||||||
|
newN++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// hunkStarts reads the starting old/new line numbers from an @@ -a,b +c,d @@ marker.
|
||||||
|
func hunkStarts(h string) (int, int) {
|
||||||
|
old, newer := 1, 1
|
||||||
|
for _, f := range strings.Fields(h) {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(f, "-"):
|
||||||
|
old = atoiComma(f[1:])
|
||||||
|
case strings.HasPrefix(f, "+"):
|
||||||
|
newer = atoiComma(f[1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return old, newer
|
||||||
|
}
|
||||||
|
|
||||||
|
func atoiComma(s string) int {
|
||||||
|
if i := strings.IndexByte(s, ','); i >= 0 {
|
||||||
|
s = s[:i]
|
||||||
|
}
|
||||||
|
n, _ := strconv.Atoi(s)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// diffTarget returns the path the diff writes to (the +++ side), stripped of the
|
||||||
|
// b/ prefix. Empty if the diff carries no header.
|
||||||
|
func diffTarget(diff string) string {
|
||||||
|
for _, ln := range strings.Split(diff, "\n") {
|
||||||
|
if strings.HasPrefix(ln, "+++ ") {
|
||||||
|
p := strings.Fields(strings.TrimPrefix(ln, "+++ "))[0]
|
||||||
|
p = strings.TrimPrefix(p, "b/")
|
||||||
|
if p != "/dev/null" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffGutter = 4 // line-number column width
|
||||||
|
|
||||||
|
// renderSplitDiff renders aligned rows as a two-column old|new view exactly
|
||||||
|
// `width` cells wide, returning up to maxRows lines starting at offset off.
|
||||||
|
func (m Model) renderSplitDiff(rows []diffRow, width, maxRows, off int) []string {
|
||||||
|
colW := (width - 1) / 2
|
||||||
|
if colW < diffGutter+4 {
|
||||||
|
colW = diffGutter + 4
|
||||||
|
}
|
||||||
|
if off < 0 {
|
||||||
|
off = 0
|
||||||
|
}
|
||||||
|
if off > len(rows) {
|
||||||
|
off = len(rows)
|
||||||
|
}
|
||||||
|
end := off + maxRows
|
||||||
|
if end > len(rows) {
|
||||||
|
end = len(rows)
|
||||||
|
}
|
||||||
|
sep := lipgloss.NewStyle().Foreground(m.theme.P.Border).Background(m.theme.P.Bg).Render("│")
|
||||||
|
out := make([]string, 0, end-off)
|
||||||
|
for _, r := range rows[off:end] {
|
||||||
|
out = append(out,
|
||||||
|
m.diffCell(r.oldNum, r.oldText, r.oldKind, colW)+sep+
|
||||||
|
m.diffCell(r.newNum, r.newText, r.newKind, colW))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// diffCell renders one side of a diff row (gutter number, +/- sign, text) padded
|
||||||
|
// to width w with the kind's background tint.
|
||||||
|
func (m Model) diffCell(num int, text string, kind diffKind, w int) string {
|
||||||
|
t := m.theme
|
||||||
|
if kind == diffBlank {
|
||||||
|
return lipgloss.NewStyle().Background(t.P.Bg).Render(strings.Repeat(" ", w))
|
||||||
|
}
|
||||||
|
var bg, textFg, signFg lipgloss.Color
|
||||||
|
sign := " "
|
||||||
|
switch kind {
|
||||||
|
case diffAdd:
|
||||||
|
bg, textFg, signFg, sign = t.P.DiffAdd, t.P.FgStrong, t.P.OK, "+"
|
||||||
|
case diffDel:
|
||||||
|
bg, textFg, signFg, sign = t.P.DiffDel, t.P.FgStrong, t.P.Bad, "-"
|
||||||
|
default: // context
|
||||||
|
bg, textFg, signFg = t.P.Bg, t.P.Dim, t.P.Faint
|
||||||
|
}
|
||||||
|
numStr := ""
|
||||||
|
if num > 0 {
|
||||||
|
numStr = itoa(num)
|
||||||
|
}
|
||||||
|
numCell := lipgloss.NewStyle().Foreground(t.P.Faint).Background(bg).Render(padLeft(numStr, diffGutter) + " ")
|
||||||
|
signCell := lipgloss.NewStyle().Foreground(signFg).Background(bg).Bold(true).Render(sign + " ")
|
||||||
|
textW := w - diffGutter - 3
|
||||||
|
if textW < 1 {
|
||||||
|
textW = 1
|
||||||
|
}
|
||||||
|
body := lipgloss.NewStyle().Foreground(textFg).Background(bg).Render(truncate(text, textW))
|
||||||
|
return padTo(numCell+signCell+body, w, bg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// padLeft right-aligns s in a field of width w (space-padded on the left).
|
||||||
|
func padLeft(s string, w int) string {
|
||||||
|
if len(s) >= w {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return strings.Repeat(" ", w-len(s)) + s
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseUnifiedDiff_Create(t *testing.T) {
|
||||||
|
diff := "--- /dev/null\n+++ b/hello.sh\n@@ -0,0 +1,2 @@\n+#!/usr/bin/env bash\n+echo hi\n"
|
||||||
|
rows := parseUnifiedDiff(diff)
|
||||||
|
if len(rows) != 2 {
|
||||||
|
t.Fatalf("want 2 rows, got %d", len(rows))
|
||||||
|
}
|
||||||
|
for i, r := range rows {
|
||||||
|
if r.oldKind != diffBlank || r.oldNum != 0 || r.oldText != "" {
|
||||||
|
t.Errorf("row %d: create should have a blank left cell, got %+v", i, r)
|
||||||
|
}
|
||||||
|
if r.newKind != diffAdd {
|
||||||
|
t.Errorf("row %d: right cell should be an addition, got %+v", i, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rows[0].newNum != 1 || rows[1].newNum != 2 {
|
||||||
|
t.Errorf("new line numbers should start at 1: %d, %d", rows[0].newNum, rows[1].newNum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseUnifiedDiff_EditAlignsAndNumbers(t *testing.T) {
|
||||||
|
diff := "--- a/x\n+++ b/x\n@@ -1,3 +1,3 @@\n ctx\n-old\n+new\n more\n"
|
||||||
|
rows := parseUnifiedDiff(diff)
|
||||||
|
if len(rows) != 3 {
|
||||||
|
t.Fatalf("want 3 rows (context, change, context), got %d", len(rows))
|
||||||
|
}
|
||||||
|
// context row: both sides present, same text, line 1
|
||||||
|
if rows[0].oldNum != 1 || rows[0].newNum != 1 || rows[0].oldText != "ctx" || rows[0].newText != "ctx" {
|
||||||
|
t.Errorf("context row mis-numbered/aligned: %+v", rows[0])
|
||||||
|
}
|
||||||
|
// change row: old "old" on left, new "new" on right, both line 2
|
||||||
|
c := rows[1]
|
||||||
|
if c.oldKind != diffDel || c.newKind != diffAdd || c.oldText != "old" || c.newText != "new" {
|
||||||
|
t.Errorf("change row should pair del|add: %+v", c)
|
||||||
|
}
|
||||||
|
if c.oldNum != 2 || c.newNum != 2 {
|
||||||
|
t.Errorf("change row line numbers should be 2/2: %d/%d", c.oldNum, c.newNum)
|
||||||
|
}
|
||||||
|
// trailing context advances to line 3 on both sides
|
||||||
|
if rows[2].oldNum != 3 || rows[2].newNum != 3 {
|
||||||
|
t.Errorf("trailing context should be 3/3: %d/%d", rows[2].oldNum, rows[2].newNum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiffTarget(t *testing.T) {
|
||||||
|
got := diffTarget("--- a/old.sh\n+++ b/dir/new.sh\n@@ -0,0 +1 @@\n+x\n")
|
||||||
|
if got != "dir/new.sh" {
|
||||||
|
t.Errorf("diffTarget = %q, want dir/new.sh", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
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
|
t := m.theme
|
||||||
s := m.session(m.selectedID)
|
s := m.session(m.selectedID)
|
||||||
if s == nil || s.Pending == nil {
|
if s == nil || s.Pending == nil {
|
||||||
return base
|
return m.renderInput()
|
||||||
}
|
}
|
||||||
a := s.Pending
|
a := s.Pending
|
||||||
w := m.modalWidth()
|
textW := m.width - 4 // box borders (2) + side padding (2)
|
||||||
textW := w - 6
|
|
||||||
|
|
||||||
riskColor := t.P.Warn
|
riskColor := t.P.Warn
|
||||||
switch strings.ToUpper(a.Risk) {
|
switch strings.ToUpper(a.Risk) {
|
||||||
@@ -70,28 +93,51 @@ func (m Model) renderApproval(base string) string {
|
|||||||
riskColor = t.P.OK
|
riskColor = t.P.OK
|
||||||
}
|
}
|
||||||
|
|
||||||
var b strings.Builder
|
leftHdr := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("→ ") +
|
||||||
b.WriteString(m.titleLine("approval required") + "\n\n")
|
t.span(a.ToolName, t.P.FgStrong)
|
||||||
b.WriteString(mbg(t, "tool ", t.P.Faint) + mbg(t, a.ToolName, t.P.FgStrong) + "\n")
|
if tgt := diffTarget(a.Preview); tgt != "" {
|
||||||
b.WriteString(mbg(t, "tier ", t.P.Faint) + mbg(t, a.Tier, t.P.Accent2) + "\n")
|
leftHdr += t.span(" "+tgt, t.P.Dim)
|
||||||
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))
|
rightHdr := t.span("tier ", t.P.Faint) + t.span(a.Tier, t.P.Accent2) +
|
||||||
if m.steerBuffer == "" {
|
t.span(" · risk ", t.P.Faint) +
|
||||||
b.WriteString(mbg(t, "(optional note)", t.P.Faint))
|
lipgloss.NewStyle().Foreground(riskColor).Background(t.P.Bg).Bold(true).Render(strings.ToUpper(a.Risk))
|
||||||
} 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())
|
innerH := h - 2
|
||||||
return m.center(modal)
|
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.
|
// 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
|
// 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 {
|
func (m Model) diffMaxScroll() int {
|
||||||
lines := strings.Split(strings.TrimRight(m.currentDiff(), "\n"), "\n")
|
rows := parseUnifiedDiff(m.currentDiff())
|
||||||
max := len(lines) - m.diffBodyHeight()
|
max := len(rows) - m.diffBodyHeight()
|
||||||
if max < 0 {
|
if max < 0 {
|
||||||
max = 0
|
max = 0
|
||||||
}
|
}
|
||||||
@@ -119,7 +165,7 @@ func (m Model) diffModal() string {
|
|||||||
w := m.modalWidth()
|
w := m.modalWidth()
|
||||||
bodyH := m.diffBodyHeight()
|
bodyH := m.diffBodyHeight()
|
||||||
diff := m.currentDiff()
|
diff := m.currentDiff()
|
||||||
lines := strings.Split(strings.TrimRight(diff, "\n"), "\n")
|
rows := parseUnifiedDiff(diff)
|
||||||
|
|
||||||
off := m.diffScrollOffset
|
off := m.diffScrollOffset
|
||||||
if off > m.diffMaxScroll() {
|
if off > m.diffMaxScroll() {
|
||||||
@@ -128,24 +174,15 @@ func (m Model) diffModal() string {
|
|||||||
if off < 0 {
|
if off < 0 {
|
||||||
off = 0
|
off = 0
|
||||||
}
|
}
|
||||||
end := off + bodyH
|
|
||||||
if end > len(lines) {
|
|
||||||
end = len(lines)
|
|
||||||
}
|
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(m.titleLine("diff") + mbg(t, " ("+itoa(len(lines))+" lines)", t.P.Faint) + "\n\n")
|
b.WriteString(m.titleLine("diff"))
|
||||||
for _, ln := range lines[off:end] {
|
if tgt := diffTarget(diff); tgt != "" {
|
||||||
fg := t.P.Dim
|
b.WriteString(mbg(t, " "+tgt, t.P.Dim))
|
||||||
switch {
|
}
|
||||||
case strings.HasPrefix(ln, "+"):
|
b.WriteString(mbg(t, " ("+itoa(len(rows))+" rows)", t.P.Faint) + "\n\n")
|
||||||
fg = t.P.OK
|
for _, ln := range m.renderSplitDiff(rows, w-6, bodyH, off) {
|
||||||
case strings.HasPrefix(ln, "-"):
|
b.WriteString(ln + "\n")
|
||||||
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"}}))
|
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))
|
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 {
|
func truncate(s string, w int) string {
|
||||||
if len([]rune(s)) <= w {
|
if len([]rune(s)) <= w {
|
||||||
return s
|
return s
|
||||||
|
|||||||
@@ -5,23 +5,26 @@ import "github.com/charmbracelet/lipgloss"
|
|||||||
// Palette is the "Soft" chrome from docs/visual/ref with the blue accent:
|
// Palette is the "Soft" chrome from docs/visual/ref with the blue accent:
|
||||||
// rounded borders, opaque dark panels, generous spacing.
|
// rounded borders, opaque dark panels, generous spacing.
|
||||||
type Palette struct {
|
type Palette struct {
|
||||||
Bg lipgloss.Color // panel/background fill
|
Bg lipgloss.Color // panel/background fill
|
||||||
BgDeep lipgloss.Color // outermost backdrop (slightly darker)
|
BgDeep lipgloss.Color // outermost backdrop (slightly darker)
|
||||||
BgPanel lipgloss.Color // inside-panel fill
|
BgPanel lipgloss.Color // inside-panel fill
|
||||||
Sel lipgloss.Color // selection row background
|
Sel lipgloss.Color // selection row background
|
||||||
Border lipgloss.Color // idle panel border
|
Border lipgloss.Color // idle panel border
|
||||||
BorderHi lipgloss.Color // active panel border
|
BorderHi lipgloss.Color // active panel border
|
||||||
Fg lipgloss.Color // primary text
|
Fg lipgloss.Color // primary text
|
||||||
FgStrong lipgloss.Color // headings / emphasis
|
FgStrong lipgloss.Color // headings / emphasis
|
||||||
Dim lipgloss.Color // labels, secondary text
|
Dim lipgloss.Color // labels, secondary text
|
||||||
Faint lipgloss.Color // tertiary, hints
|
Faint lipgloss.Color // tertiary, hints
|
||||||
Accent lipgloss.Color // blue accent
|
Accent lipgloss.Color // blue accent
|
||||||
Accent2 lipgloss.Color // secondary accent
|
Accent2 lipgloss.Color // secondary accent
|
||||||
|
|
||||||
OK lipgloss.Color
|
OK lipgloss.Color
|
||||||
Warn lipgloss.Color
|
Warn lipgloss.Color
|
||||||
Bad lipgloss.Color
|
Bad lipgloss.Color
|
||||||
|
|
||||||
|
DiffAdd lipgloss.Color // added-line cell background
|
||||||
|
DiffDel lipgloss.Color // removed-line cell background
|
||||||
|
|
||||||
CatLifecycle lipgloss.Color
|
CatLifecycle lipgloss.Color
|
||||||
CatContext lipgloss.Color
|
CatContext lipgloss.Color
|
||||||
CatInference lipgloss.Color
|
CatInference lipgloss.Color
|
||||||
@@ -50,6 +53,9 @@ var SoftBlue = Palette{
|
|||||||
Warn: lipgloss.Color("#e8c06a"),
|
Warn: lipgloss.Color("#e8c06a"),
|
||||||
Bad: lipgloss.Color("#e87f7f"),
|
Bad: lipgloss.Color("#e87f7f"),
|
||||||
|
|
||||||
|
DiffAdd: lipgloss.Color("#1b2a1d"),
|
||||||
|
DiffDel: lipgloss.Color("#2c1c1f"),
|
||||||
|
|
||||||
CatLifecycle: lipgloss.Color("#c98fd9"),
|
CatLifecycle: lipgloss.Color("#c98fd9"),
|
||||||
CatContext: lipgloss.Color("#b9c0c9"),
|
CatContext: lipgloss.Color("#b9c0c9"),
|
||||||
CatInference: lipgloss.Color("#56c8d8"),
|
CatInference: lipgloss.Color("#56c8d8"),
|
||||||
@@ -69,17 +75,17 @@ type Theme struct {
|
|||||||
PanelTitle lipgloss.Style // uppercase dim header label
|
PanelTitle lipgloss.Style // uppercase dim header label
|
||||||
TitleHi lipgloss.Style // active header label
|
TitleHi lipgloss.Style // active header label
|
||||||
|
|
||||||
Text lipgloss.Style
|
Text lipgloss.Style
|
||||||
Strong lipgloss.Style
|
Strong lipgloss.Style
|
||||||
Dim lipgloss.Style
|
Dim lipgloss.Style
|
||||||
Faint lipgloss.Style
|
Faint lipgloss.Style
|
||||||
Accent lipgloss.Style
|
Accent lipgloss.Style
|
||||||
SelRow lipgloss.Style
|
SelRow lipgloss.Style
|
||||||
Status lipgloss.Style // top bar
|
Status lipgloss.Style // top bar
|
||||||
Footer lipgloss.Style // bottom hints
|
Footer lipgloss.Style // bottom hints
|
||||||
Input lipgloss.Style // input bar panel
|
Input lipgloss.Style // input bar panel
|
||||||
Overlay lipgloss.Style // modal box
|
Overlay lipgloss.Style // modal box
|
||||||
Scrim lipgloss.Style // dim backdrop behind modals
|
Scrim lipgloss.Style // dim backdrop behind modals
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTheme builds the style set for a palette.
|
// NewTheme builds the style set for a palette.
|
||||||
|
|||||||
@@ -195,11 +195,18 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.client.Send(protocol.CancelSession(m.selectedID))
|
m.client.Send(protocol.CancelSession(m.selectedID))
|
||||||
}
|
}
|
||||||
case "a":
|
case "a":
|
||||||
|
if ds == StateApproval {
|
||||||
|
return m.decide("APPROVE")
|
||||||
|
}
|
||||||
if ds == StateInSession {
|
if ds == StateInSession {
|
||||||
if s := m.session(m.selectedID); s != nil && s.Pending != nil {
|
if s := m.session(m.selectedID); s != nil && s.Pending != nil {
|
||||||
m.approvalDismissed = false
|
m.approvalDismissed = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case "r":
|
||||||
|
if ds == StateApproval {
|
||||||
|
return m.decide("REJECT")
|
||||||
|
}
|
||||||
case "A":
|
case "A":
|
||||||
if ds == StateApproval {
|
if ds == StateApproval {
|
||||||
return m.autoApprove()
|
return m.autoApprove()
|
||||||
|
|||||||
@@ -22,7 +22,14 @@ func (m Model) View() string {
|
|||||||
return m.theme.Screen.Render("correx — terminal too small")
|
return m.theme.Screen.Render("correx — terminal too small")
|
||||||
}
|
}
|
||||||
|
|
||||||
mainH := m.height - statusH - footerH - inputH
|
bottom := m.renderInput()
|
||||||
|
bottomH := inputH
|
||||||
|
if m.displayState() == StateApproval {
|
||||||
|
bottomH = m.approvalBandHeight()
|
||||||
|
bottom = m.renderApprovalBand(bottomH)
|
||||||
|
}
|
||||||
|
|
||||||
|
mainH := m.height - statusH - footerH - bottomH
|
||||||
if mainH < 3 {
|
if mainH < 3 {
|
||||||
mainH = 3
|
mainH = 3
|
||||||
}
|
}
|
||||||
@@ -30,16 +37,13 @@ func (m Model) View() string {
|
|||||||
base := lipgloss.JoinVertical(lipgloss.Left,
|
base := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
m.renderStatus(),
|
m.renderStatus(),
|
||||||
m.renderMain(mainH),
|
m.renderMain(mainH),
|
||||||
m.renderInput(),
|
bottom,
|
||||||
m.renderFooter(),
|
m.renderFooter(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if m.overlay != OverlayNone {
|
if m.overlay != OverlayNone {
|
||||||
return m.renderOverlay(base)
|
return m.renderOverlay(base)
|
||||||
}
|
}
|
||||||
if m.displayState() == StateApproval {
|
|
||||||
return m.renderApproval(base)
|
|
||||||
}
|
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user