diff --git a/apps/tui-go/internal/app/diff.go b/apps/tui-go/internal/app/diff.go new file mode 100644 index 00000000..2e0ae136 --- /dev/null +++ b/apps/tui-go/internal/app/diff.go @@ -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 +} diff --git a/apps/tui-go/internal/app/diff_test.go b/apps/tui-go/internal/app/diff_test.go new file mode 100644 index 00000000..37c105e6 --- /dev/null +++ b/apps/tui-go/internal/app/diff_test.go @@ -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) + } +} diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index 6cecf2a9..e3af420b 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -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 diff --git a/apps/tui-go/internal/app/theme.go b/apps/tui-go/internal/app/theme.go index 3f996728..914d26bd 100644 --- a/apps/tui-go/internal/app/theme.go +++ b/apps/tui-go/internal/app/theme.go @@ -5,23 +5,26 @@ import "github.com/charmbracelet/lipgloss" // Palette is the "Soft" chrome from docs/visual/ref with the blue accent: // rounded borders, opaque dark panels, generous spacing. type Palette struct { - Bg lipgloss.Color // panel/background fill - BgDeep lipgloss.Color // outermost backdrop (slightly darker) - BgPanel lipgloss.Color // inside-panel fill - Sel lipgloss.Color // selection row background - Border lipgloss.Color // idle panel border - BorderHi lipgloss.Color // active panel border - Fg lipgloss.Color // primary text - FgStrong lipgloss.Color // headings / emphasis - Dim lipgloss.Color // labels, secondary text - Faint lipgloss.Color // tertiary, hints - Accent lipgloss.Color // blue accent - Accent2 lipgloss.Color // secondary accent + Bg lipgloss.Color // panel/background fill + BgDeep lipgloss.Color // outermost backdrop (slightly darker) + BgPanel lipgloss.Color // inside-panel fill + Sel lipgloss.Color // selection row background + Border lipgloss.Color // idle panel border + BorderHi lipgloss.Color // active panel border + Fg lipgloss.Color // primary text + FgStrong lipgloss.Color // headings / emphasis + Dim lipgloss.Color // labels, secondary text + Faint lipgloss.Color // tertiary, hints + Accent lipgloss.Color // blue accent + Accent2 lipgloss.Color // secondary accent OK lipgloss.Color Warn lipgloss.Color Bad lipgloss.Color + DiffAdd lipgloss.Color // added-line cell background + DiffDel lipgloss.Color // removed-line cell background + CatLifecycle lipgloss.Color CatContext lipgloss.Color CatInference lipgloss.Color @@ -50,6 +53,9 @@ var SoftBlue = Palette{ Warn: lipgloss.Color("#e8c06a"), Bad: lipgloss.Color("#e87f7f"), + DiffAdd: lipgloss.Color("#1b2a1d"), + DiffDel: lipgloss.Color("#2c1c1f"), + CatLifecycle: lipgloss.Color("#c98fd9"), CatContext: lipgloss.Color("#b9c0c9"), CatInference: lipgloss.Color("#56c8d8"), @@ -69,17 +75,17 @@ type Theme struct { PanelTitle lipgloss.Style // uppercase dim header label TitleHi lipgloss.Style // active header label - Text lipgloss.Style - Strong lipgloss.Style - Dim lipgloss.Style - Faint lipgloss.Style - Accent lipgloss.Style - SelRow lipgloss.Style - Status lipgloss.Style // top bar - Footer lipgloss.Style // bottom hints - Input lipgloss.Style // input bar panel - Overlay lipgloss.Style // modal box - Scrim lipgloss.Style // dim backdrop behind modals + Text lipgloss.Style + Strong lipgloss.Style + Dim lipgloss.Style + Faint lipgloss.Style + Accent lipgloss.Style + SelRow lipgloss.Style + Status lipgloss.Style // top bar + Footer lipgloss.Style // bottom hints + Input lipgloss.Style // input bar panel + Overlay lipgloss.Style // modal box + Scrim lipgloss.Style // dim backdrop behind modals } // NewTheme builds the style set for a palette. diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index b2f04b9f..45359efe 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -195,11 +195,18 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.client.Send(protocol.CancelSession(m.selectedID)) } case "a": + if ds == StateApproval { + return m.decide("APPROVE") + } if ds == StateInSession { if s := m.session(m.selectedID); s != nil && s.Pending != nil { m.approvalDismissed = false } } + case "r": + if ds == StateApproval { + return m.decide("REJECT") + } case "A": if ds == StateApproval { return m.autoApprove() diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index c9f23241..6d2dd45e 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -22,7 +22,14 @@ func (m Model) View() string { 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 { mainH = 3 } @@ -30,16 +37,13 @@ func (m Model) View() string { base := lipgloss.JoinVertical(lipgloss.Left, m.renderStatus(), m.renderMain(mainH), - m.renderInput(), + bottom, m.renderFooter(), ) if m.overlay != OverlayNone { return m.renderOverlay(base) } - if m.displayState() == StateApproval { - return m.renderApproval(base) - } return base }