package app import ( "fmt" "sort" "strings" "github.com/charmbracelet/lipgloss" "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). 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 m.narrow() { w = m.width - 4 // slim terminals: give modals nearly the whole screen } if w > 92 { w = 92 } if w < 24 { w = 24 } return w } // clip truncates a (possibly ANSI-styled) line to w visible columns so modal content // never wraps mid-word on a slim terminal. ANSI-aware — won't slice escape codes. func clip(s string, w int) string { if w < 1 { w = 1 } return ansi.Truncate(s, 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()) case OverlayArtifacts: return m.center(m.artifactsModal()) case OverlayConfig: return m.center(m.configModal()) case OverlayStats: return m.center(m.statsModal()) case OverlayIdeas: return m.center(m.ideasModal()) case OverlayHealth: return m.center(m.healthModal()) } 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 = m.approvalContentRows(s.Pending) if n := len(s.Pending.Rationale); n > 0 { rat = n + 1 // rationale lines + trailing blank } } steer := 0 if m.steering || m.steerBuffer != "" { steer = 1 } h := 2 + 1 + 1 + rat + rows + 1 + steer + 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 } steerBlock := 0 if m.steering || m.steerBuffer != "" { steerBlock = 1 } diffH := innerH - 4 - ratBlock - steerBlock // header, blank, blank, action row, + rationale/steer if diffH < 1 { diffH = 1 } 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, "") } var content []string switch { case isCommandApproval(a): content = m.commandCardLines(a, textW) if len(content) > diffH { content = content[:diffH] } case isUnifiedDiff(a.Preview): content = m.renderSplitDiff(parseUnifiedDiff(a.Preview), textW, diffH, 0) default: content = m.renderPreviewLines(a.Preview, textW, diffH, 0) } diffStart := len(body) body = append(body, content...) for len(body) < diffStart+diffH { body = append(body, "") } body = append(body, "") if steerBlock == 1 { body = append(body, m.steerLine()) } body = append(body, m.approvalActions()) return t.box("permission required", body, m.width, h, true) } // steerLine renders the steering note inside the band: editable (with a caret) // while the operator is typing it, or as a persistent reminder once set — so it // stays visible after editing and it's clear it rides along with approve/reject. func (m Model) steerLine() string { t := m.theme label := t.span("steer ", t.P.Faint) caret := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏") if m.steering { if m.steerBuffer == "" { return label + caret + t.span(" type a note…", t.P.Faint) } return label + t.span(m.steerBuffer, t.P.FgStrong) + caret } return label + t.span(m.steerBuffer, t.P.Accent2) + t.span(" (sent with your decision · s to edit)", t.P.Faint) } // approvalActions renders the action row of the approval band. While the operator // is composing a steer note the keys are different (typing is captured), so the // hints switch to the editing controls. func (m Model) approvalActions() string { t := m.theme 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) } // Tighten the inter-hint gap on a slim terminal so the action row isn't clipped — // the approval card's decision keys must stay fully visible. gapStr := " " if m.narrow() { gapStr = " " } gap := t.span(gapStr, t.P.Faint) if m.steering { return strings.Join([]string{ hint("enter", "approve + send note"), hint("esc", "keep note, decide below"), }, gap) } parts := []string{hint("a", "approve"), hint("A", "auto"), hint("s", "steer"), hint("r", "reject")} if s := m.session(m.selectedID); s != nil && s.Pending != nil && (isUnifiedDiff(s.Pending.Preview) || isCommandApproval(s.Pending)) { parts = append(parts, hint("^x", "fullscreen")) } parts = append(parts, hint("esc", "later")) return strings.Join(parts, gap) } // approvalContentRows is the number of body rows the pending approval's content // occupies — command-card lines for a command, diff/preview rows otherwise. Drives // band sizing so the height matches what renderApprovalBand will draw. func (m Model) approvalContentRows(a *Approval) int { if isCommandApproval(a) { w := m.width - 4 if w < 8 { w = 8 } return len(m.commandCardLines(a, w)) } return previewRowCount(a.Preview) } // 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 := previewRowCount(m.currentDiff()) if cmd := m.pendingCommand(); cmd != nil { rows = len(m.commandCardLines(cmd, m.modalWidth()-6)) } max := rows - m.diffBodyHeight() if max < 0 { max = 0 } return max } func (m Model) diffModal() string { if cmd := m.pendingCommand(); cmd != nil { return m.commandModal(cmd) } t := m.theme w := m.modalWidth() bodyH := m.diffBodyHeight() diff := m.currentDiff() isDiff := isUnifiedDiff(diff) off := m.diffScrollOffset if off > m.diffMaxScroll() { off = m.diffMaxScroll() } if off < 0 { off = 0 } var b strings.Builder title := "preview" if isDiff { title = "diff" } b.WriteString(m.titleLine(title)) if tgt := diffTarget(diff); tgt != "" { b.WriteString(mbg(t, " "+tgt, t.P.Dim)) } b.WriteString(mbg(t, " ("+itoa(previewRowCount(diff))+" rows)", t.P.Faint) + "\n\n") var lines []string if isDiff { lines = m.renderSplitDiff(parseUnifiedDiff(diff), w-6, bodyH, off) } else { lines = m.renderPreviewLines(diff, w-6, bodyH, off) } for _, ln := range lines { 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) } // commandModal is the ^x fullscreen view of a command approval: the same // deterministic card as the band, scrollable, so a long command is fully readable // without ever being truncated. func (m Model) commandModal(a *Approval) string { t := m.theme w := m.modalWidth() bodyH := m.diffBodyHeight() lines := m.commandCardLines(a, w-6) off := m.diffScrollOffset if mx := m.diffMaxScroll(); off > mx { off = mx } if off < 0 { off = 0 } var b strings.Builder b.WriteString(m.titleLine("command")) b.WriteString(mbg(t, " "+a.ToolName, t.P.Dim)) b.WriteString(mbg(t, " ("+itoa(len(lines))+" rows)", t.P.Faint) + "\n\n") end := off + bodyH if end > len(lines) { end = len(lines) } for i := off; i < end; i++ { b.WriteString(lines[i] + "\n") } b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "scroll"}, {"^x/esc", "close"}})) return m.center(t.Overlay.Width(w).Render(b.String())) } func (m Model) eventInspectorModal() string { t := m.theme w := m.modalWidth() evs := m.currentEvents() var b strings.Builder b.WriteString(m.titleLine("event inspector")) if len(evs) > 0 { b.WriteString(mbg(t, " ("+itoa(m.overlayEventIdx+1)+"/"+itoa(len(evs))+")", t.P.Faint)) } b.WriteString("\n\n") if len(evs) == 0 { b.WriteString(mbg(t, "no events", t.P.Faint)) return m.center(t.Overlay.Width(w).Render(b.String())) } // 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 } // Window the (potentially long) list to a visible height, keeping the // selected row in view — the modal must not grow past the screen. bodyH := m.height*70/100 - 4 if bodyH < 3 { bodyH = 3 } off := 0 if len(evs) > bodyH { off = m.overlayEventIdx - bodyH/2 if off < 0 { off = 0 } if off > len(evs)-bodyH { off = len(evs) - bodyH } } end := off + bodyH if end > len(evs) { end = len(evs) } for i := off; i < end; i++ { e := evs[i] 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) } // artifactListRows is the number of artifact entries shown in the list portion of // the viewer (the rest of the modal height goes to the selected artifact's content). func (m Model) artifactListRows() int { n := len(m.artifacts) if n > 6 { n = 6 } if n < 1 { n = 1 } return n } // artifactContentBodyH is the number of content lines visible for the selected artifact. func (m Model) artifactContentBodyH() int { h := m.height*70/100 - m.artifactListRows() - 7 // title, blanks, content header, hints if h < 3 { h = 3 } return h } // artifactContentLines splits the selected artifact's content into display lines, // wrapping each line to the specified width. func (m Model) artifactContentLines(w int) []string { if m.artifactsIndex < 0 || m.artifactsIndex >= len(m.artifacts) { return nil } c := m.artifacts[m.artifactsIndex].Content if c == nil { return []string{"(no content stored)"} } lines := strings.Split(strings.ReplaceAll(*c, "\r\n", "\n"), "\n") return wrapLines(lines, w) } // artifactContentMaxScroll keeps the last page of content anchored to the body bottom. func (m Model) artifactContentMaxScroll(w int) int { max := len(m.artifactContentLines(w)) - m.artifactContentBodyH() if max < 0 { max = 0 } return max } func (m Model) artifactsModal() string { t := m.theme w := m.modalWidth() var b strings.Builder b.WriteString(m.titleLine("artifacts")) if len(m.artifacts) > 0 { b.WriteString(mbg(t, " ("+itoa(m.artifactsIndex+1)+"/"+itoa(len(m.artifacts))+")", t.P.Faint)) } b.WriteString("\n\n") if m.artifactsLoading { b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n") b.WriteString("\n" + modalHints(t, [][2]string{{"esc", "close"}})) return t.Overlay.Width(w).Render(b.String()) } if len(m.artifacts) == 0 { b.WriteString(mbg(t, " no artifacts for this session", t.P.Faint) + "\n") b.WriteString("\n" + modalHints(t, [][2]string{{"esc", "close"}})) return t.Overlay.Width(w).Render(b.String()) } // Windowed artifact list, keeping the selected row in view. rows := m.artifactListRows() off := 0 if len(m.artifacts) > rows { off = m.artifactsIndex - rows/2 if off < 0 { off = 0 } if off > len(m.artifacts)-rows { off = len(m.artifacts) - rows } } end := off + rows if end > len(m.artifacts) { end = len(m.artifacts) } for i := off; i < end; i++ { a := m.artifacts[i] marker := mbg(t, " ", t.P.BgPanel) idFg := t.P.Fg if i == m.artifactsIndex { marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ") idFg = t.P.FgStrong } row := marker + lipgloss.NewStyle().Foreground(idFg).Background(t.P.BgPanel).Render(padRaw(shortID(a.ArtifactID), 14)) + " " + mbg(t, padRaw(a.StageID, 18), t.P.Accent2) + " " + mbg(t, a.Phase, t.P.Faint) b.WriteString(row + "\n") } // Selected artifact content, scrollable with PgUp/PgDn. // Width available for content: w - 2 (left padding) - 2 (box borders) - 2 (right padding) = w - 6 contentW := w - 6 lines := m.artifactContentLines(contentW) bodyH := m.artifactContentBodyH() coff := m.artifactScroll if coff > m.artifactContentMaxScroll(contentW) { coff = m.artifactContentMaxScroll(contentW) } if coff < 0 { coff = 0 } b.WriteString(mbg(t, " ── content", t.P.Faint)) if len(lines) > bodyH { b.WriteString(mbg(t, " ("+itoa(coff+1)+"-"+itoa(min(coff+bodyH, len(lines)))+"/"+itoa(len(lines))+")", t.P.Faint)) } b.WriteString("\n") cend := coff + bodyH if cend > len(lines) { cend = len(lines) } for i := coff; i < cend; i++ { b.WriteString(mbg(t, " "+lines[i], t.P.Fg) + "\n") } b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"PgUp/Dn", "scroll"}, {"v/esc", "close"}})) return t.Overlay.Width(w).Render(b.String()) } // statsBreakdownRows caps how many per-provider / per-tool / per-tier rows the pane // shows; the server already sorts each list by descending cost, so the cap keeps the // heaviest contributors and the modal bounded. const statsBreakdownRows = 6 // humanDurMs renders a millisecond span as a compact "1h 2m 3s" string. func humanDurMs(ms int64) string { if ms <= 0 { return "0s" } total := ms / 1000 h := total / 3600 mn := (total / 60) % 60 s := total % 60 out := "" if h > 0 { out += itoa(int(h)) + "h " } if h > 0 || mn > 0 { out += itoa(int(mn)) + "m " } return out + itoa(int(s)) + "s" } func (m Model) statsModal() string { t := m.theme w := m.modalWidth() var b strings.Builder b.WriteString(m.titleLine("session stats")) if m.selectedID != "" { b.WriteString(mbg(t, " ("+shortID(m.selectedID)+")", t.P.Faint)) } b.WriteString("\n\n") if m.statsLoading || m.stats == nil || m.statsFor != m.selectedID { msg := "loading…" if !m.statsLoading && m.stats == nil { msg = "no stats for this session" } b.WriteString(mbg(t, " "+msg, t.P.Faint) + "\n") b.WriteString("\n" + modalHints(t, [][2]string{{"S/esc", "close"}})) return t.Overlay.Width(w).Render(b.String()) } s := m.stats cw := w - 4 // modal inner content width (borders + padding) section := func(label string) string { return lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(label) } // put clips each data line to the inner width so it never wraps on a slim modal. put := func(styled string) { b.WriteString(clip(styled, cw) + "\n") } line := func(s string) string { return mbg(t, s, t.P.Fg) } faint := func(s string) string { return mbg(t, s, t.P.Faint) } // nameW shrinks the breakdown name column on slim terminals. nameW := 20 if cw < 52 { nameW = 12 } // header row: duration + event count put(faint(" duration ") + line(humanDurMs(s.SessionDurationMs)) + faint(" events ") + line(itoa(int(s.EventCount)))) b.WriteString("\n") // inference b.WriteString(section("Inference") + "\n") put(line(fmt.Sprintf(" %d calls · %d+%d tok · %.1f tok/s", s.InferenceCount, s.PromptTokens, s.CompletionTokens, s.TokensPerSecond))) for i, p := range s.PerProvider { if i >= statsBreakdownRows { put(faint(fmt.Sprintf(" … +%d more", len(s.PerProvider)-statsBreakdownRows))) break } put(faint(fmt.Sprintf(" %-*s %d× %d tok %.1f t/s", nameW, padRaw(p.Provider, nameW), p.CompletedCount, p.PromptTokens+p.CompletionTokens, p.TokensPerSecond))) } b.WriteString("\n") // tools b.WriteString(section("Tools") + "\n") put(line(fmt.Sprintf(" %d calls · %d ms", s.ToolCount, s.ToolMs))) for i, tl := range s.PerTool { if i >= statsBreakdownRows { put(faint(fmt.Sprintf(" … +%d more", len(s.PerTool)-statsBreakdownRows))) break } put(faint(fmt.Sprintf(" %-*s %d× %d ms", nameW, padRaw(tl.ToolName, nameW), tl.CompletedCount, tl.TotalDurationMs))) } b.WriteString("\n") // approvals b.WriteString(section("Approvals") + "\n") put(line(fmt.Sprintf(" %d req · %d res · %d pending · avg %d ms", s.ApprovalsRequested, s.ApprovalsResolved, s.ApprovalsPending, s.AvgApprovalWaitMs))) for _, tr := range s.PerTier { put(faint(fmt.Sprintf(" %-3s req %d res %d avg %d ms", tr.Tier, tr.RequestedCount, tr.ResolvedCount, tr.AvgWaitMs))) } b.WriteString("\n") // failures f := s.Failures b.WriteString(section("Failures") + "\n") put(line(fmt.Sprintf(" inf %d · t/o %d · tool %d · rej %d · stg %d · wf %d", f.InferenceFailures, f.InferenceTimeouts, f.ToolFailures, f.ToolRejections, f.StageFailures, f.WorkflowFailures))) b.WriteString("\n") // time accounting other := 100.0 - s.InferencePct - s.ToolPct - s.ApprovalWaitPct if other < 0 { other = 0 } b.WriteString(section("Time") + "\n") put(line(fmt.Sprintf(" inf %.1f%% · tools %.1f%% · wait %.1f%% · other %.1f%%", s.InferencePct, s.ToolPct, s.ApprovalWaitPct, other))) b.WriteString("\n" + modalHints(t, [][2]string{{"S/esc", "close"}})) return t.Overlay.Width(w).Render(b.String()) } func (m Model) healthModal() string { t := m.theme w := m.modalWidth() var b strings.Builder b.WriteString(m.titleLine("health checks")) b.WriteString("\n\n") if m.healthLoading || m.health == nil { msg := "loading…" if !m.healthLoading && m.health == nil { msg = "no health report available" } b.WriteString(mbg(t, " "+msg, t.P.Faint) + "\n") b.WriteString("\n" + modalHints(t, [][2]string{{"H/esc", "close"}})) return t.Overlay.Width(w).Render(b.String()) } h := m.health cw := w - 4 // overall status header overallFg := t.P.OK if h.Overall == "DEGRADED" { overallFg = t.P.Bad } overall := lipgloss.NewStyle().Foreground(overallFg).Background(t.P.BgPanel).Bold(true).Render(h.Overall) b.WriteString(clip(mbg(t, " overall ", t.P.Faint)+overall+mbg(t, " checked "+h.CheckedAt, t.P.Faint), cw) + "\n\n") if len(h.Subjects) == 0 { b.WriteString(mbg(t, " no health probes recorded — health monitoring may be disabled", t.P.Faint) + "\n") b.WriteString("\n" + modalHints(t, [][2]string{{"H/esc", "close"}})) return t.Overlay.Width(w).Render(b.String()) } section := func(label string) string { return lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(label) } put := func(styled string) { b.WriteString(clip(styled, cw) + "\n") } faint := func(s string) string { return mbg(t, s, t.P.Faint) } for _, sub := range h.Subjects { statusFg := t.P.OK if sub.Status == "DEGRADED" { statusFg = t.P.Bad } statusLabel := lipgloss.NewStyle().Foreground(statusFg).Background(t.P.BgPanel).Bold(sub.Status == "DEGRADED").Render(sub.Status) b.WriteString(section(sub.Subject) + mbg(t, " ", t.P.BgPanel) + statusLabel + "\n") put(faint(fmt.Sprintf(" metric %-18s value %d", sub.Metric, sub.ObservedValue))) if sub.Detail != "" { put(faint(" " + truncate(sub.Detail, cw-4))) } put(faint(" since " + sub.Since)) b.WriteString("\n") } b.WriteString(modalHints(t, [][2]string{{"H/esc", "close"}})) return t.Overlay.Width(w).Render(b.String()) } // shortID trims a long artifact id for the list column while staying identifiable. func shortID(id string) string { if len(id) <= 14 { return id } return id[:13] + "…" } 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]) + "…" } // wrapLines wraps each line in the input slice to the specified width using hard rune-boundary wrapping. func wrapLines(lines []string, w int) []string { if w < 1 { w = 1 } var result []string for _, line := range lines { wrapped := wrapLine(line, w) result = append(result, wrapped...) } return result } // wrapLine wraps a single line to the specified width using hard rune-boundary wrapping. func wrapLine(s string, w int) []string { if w < 1 { w = 1 } runes := []rune(s) if len(runes) <= w { return []string{s} } var lines []string for len(runes) > 0 { if len(runes) <= w { lines = append(lines, string(runes)) break } lines = append(lines, string(runes[:w])) runes = runes[w:] } return lines }