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()) } 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) } func (m Model) renderApproval(base string) string { t := m.theme s := m.session(m.selectedID) if s == nil || s.Pending == nil { return base } a := s.Pending w := m.modalWidth() textW := w - 6 riskColor := t.P.Warn switch strings.ToUpper(a.Risk) { case "HIGH", "CRITICAL": riskColor = t.P.Bad case "LOW": 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") } } 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"}, {"enter", "approve"}, {"^x", "diff"}, {"esc", "later"}})) modal := t.Overlay.Width(w).Render(b.String()) return m.center(modal) } func (m Model) diffModal() string { t := m.theme w := m.modalWidth() h := m.height * 70 / 100 if h < 8 { h = 8 } bodyH := h - 6 diff := m.currentDiff() lines := strings.Split(strings.TrimRight(diff, "\n"), "\n") off := m.diffScrollOffset if off > len(lines)-1 { off = len(lines) - 1 } 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("\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 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 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 } r := []rune(s) if w < 1 { return "" } return string(r[:w-1]) + "…" }