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]) + "…" }