diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 870c72d8..edb7bd73 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -311,6 +311,11 @@ type Model struct { snapshotPhase bool pendingEvents []protocol.ServerMessage + // lastBase is the full-screen render behind the active modal, stashed by View each + // frame so center() can composite a modal over a dimmed copy of it (transparent + // backdrop) instead of an opaque scrim. Transient render scratch — not real state. + lastBase string + // approval steering input buffer steerBuffer string steering bool diff --git a/apps/tui-go/internal/app/overlay_composite_test.go b/apps/tui-go/internal/app/overlay_composite_test.go new file mode 100644 index 00000000..6b9a687a --- /dev/null +++ b/apps/tui-go/internal/app/overlay_composite_test.go @@ -0,0 +1,52 @@ +package app + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +func fullScreen(ch string, w, h int) string { + row := strings.Repeat(ch, w) + rows := make([]string, h) + for i := range rows { + rows[i] = row + } + return strings.Join(rows, "\n") +} + +// A composited modal must still fill the whole screen exactly (h lines, each w cols), +// so the alt-screen never shows a torn/short region around a "transparent" modal. +func TestCompositeOverlayGeometry(t *testing.T) { + m := NewModel(nil) + m.width, m.height = 40, 12 + m.lastBase = fullScreen("x", 40, 12) + + modal := lipgloss.NewStyle().Width(10).Border(lipgloss.RoundedBorder()).Render("hi\nthere") + out := m.center(modal) + + lines := strings.Split(out, "\n") + if len(lines) != 12 { + t.Fatalf("height = %d, want 12", len(lines)) + } + for i, ln := range lines { + if w := ansi.StringWidth(ln); w != 40 { + t.Fatalf("line %d visible width = %d, want 40", i, w) + } + } +} + +// The idempotence guard: a modal that is already full-screen (a builder that centered +// internally, then got re-centered) must pass through unchanged — not get re-dimmed. +func TestCenterIdempotentOnFullScreen(t *testing.T) { + m := NewModel(nil) + m.width, m.height = 30, 8 + m.lastBase = fullScreen("y", 30, 8) + + full := fullScreen("M", 30, 8) + if got := m.center(full); got != full { + t.Fatal("center should pass a full-screen modal through unchanged") + } +} diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index 3b52600b..fe5370d4 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -9,13 +9,74 @@ import ( "github.com/charmbracelet/x/ansi" ) -// 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). +// center draws a modal centered over a *dimmed* copy of the screen behind it, so the +// transcript stays faintly visible around the modal (a transparent backdrop) rather +// than being hidden by an opaque scrim. Immediate-mode: recomputed from state every +// frame, so there is no show/restore path to desync. +// +// Idempotence guard: some modal builders call center() internally and then get centered +// again by renderOverlay. The inner call already produced a full-screen composite, so a +// second call (input already width×height) returns it unchanged instead of dimming the +// modal itself. Falls back to an opaque scrim when no base was stashed (defensive). 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)) + if m.lastBase == "" { + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal, + lipgloss.WithWhitespaceBackground(m.theme.P.BgDeep), + lipgloss.WithWhitespaceForeground(m.theme.P.BgDeep)) + } + if lipgloss.Height(modal) >= m.height && lipgloss.Width(modal) >= m.width { + return modal // already a full-screen composite; don't re-dim it + } + return m.compositeOverlay(m.lastBase, modal) +} + +// compositeOverlay strips + dims base into a faint scrim, then splices the modal box +// over it centered. ANSI-aware so the side margins keep the (dimmed) content behind. +// base and the result are both m.width × m.height. +func (m Model) compositeOverlay(base, modal string) string { + w, h := m.width, m.height + dim := lipgloss.NewStyle().Foreground(m.theme.P.Faint).Background(m.theme.P.BgDeep) + baseLines := strings.Split(base, "\n") + scrim := make([]string, h) + for i := 0; i < h; i++ { + raw := "" + if i < len(baseLines) { + raw = ansi.Strip(baseLines[i]) + } + scrim[i] = dim.Render(padRightRaw(raw, w)) + } + modalLines := strings.Split(modal, "\n") + mw := lipgloss.Width(modal) + top := (h - len(modalLines)) / 2 + left := (w - mw) / 2 + if top < 0 { + top = 0 + } + if left < 0 { + left = 0 + } + for r, ml := range modalLines { + row := top + r + if row < 0 || row >= h { + continue + } + leftPart := ansi.Truncate(scrim[row], left, "") + rightPart := ansi.TruncateLeft(scrim[row], left+mw, "") + scrim[row] = leftPart + ml + rightPart + } + return strings.Join(scrim, "\n") +} + +// padRightRaw pads an unstyled string with spaces to exactly w columns (or truncates). +func padRightRaw(s string, w int) string { + switch sw := ansi.StringWidth(s); { + case sw > w: + return ansi.Truncate(s, w, "") + case sw < w: + return s + strings.Repeat(" ", w-sw) + default: + return s + } } func (m Model) modalWidth() int { diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 1c4b4451..acbd5b9d 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -54,6 +54,8 @@ func (m Model) View() string { bottom, m.renderFooter(), ) + // Stash the base so center() can composite a modal over a dimmed copy of it. + m.lastBase = base if m.displayState() == StateClarification { return m.center(m.clarificationModal())