package app import ( "fmt" "image/color" "sort" "strings" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" ) // 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 { if m.lastBase == "" { return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal, lipgloss.WithWhitespaceStyle(lipgloss.NewStyle(). Background(m.theme.P.BgDeep).Foreground(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 { 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()) case OverlaySessions: return m.center(m.sessionsModal()) case OverlayTasks: return m.center(m.tasksModal()) case OverlayFiles: return m.center(m.filesModal()) case OverlayStatusbar: return m.center(m.statusbarModal()) case OverlayGrants: return m.center(m.grantsModal()) case OverlayGrantScope: return m.center(m.grantScopeModal()) case OverlayHelp: return m.center(m.helpModal()) } 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 color.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)) // When more than one gate is queued, show "approval i/n" so the operator knows // there are others behind this one (↑/↓ to walk them) — never stack modals. if n := len(s.PendingQueue); n > 1 { rightHdr = t.span("approval ", t.P.Faint) + t.span(itoa(s.PendingIdx+1)+"/"+itoa(n), t.P.Accent) + t.span(" · ", t.P.Faint) + rightHdr } 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) } s := m.session(m.selectedID) // Armed T3+ approve: a single decisive line so an accidental keystroke can't slip // a destructive action through — the confirm key must be pressed again. if m.approvalArmed && s != nil && s.Pending != nil { warn := lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Bold(true). Render("press y again to confirm " + s.Pending.Tier + " approval") return warn + gap + hint("any other key", "cancel") } parts := []string{hint("y", "approve"), hint("n", "reject"), hint("e", "steer"), hint("A", "auto")} if s != nil && len(s.PendingQueue) > 1 { parts = append(parts, hint("↑↓", "queue")) } if 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 } // scrollDiff moves the diff/preview/command modal offset by delta rows, clamped to // [0, diffMaxScroll] so paging past either end lands exactly on the edge (no dead // presses unwinding an overshoot — same contract as the OUTPUT free-scroll). func (m *Model) scrollDiff(delta int) { off := m.diffScrollOffset + delta if mx := m.diffMaxScroll(); off > mx { off = mx } if off < 0 { off = 0 } m.diffScrollOffset = off } 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{{"↑↓", "line"}, {"PgUp/Dn", "page"}, {"g/G", "ends"}, {"^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{{"↑↓", "line"}, {"PgUp/Dn", "page"}, {"g/G", "ends"}, {"^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") // Filter line (shown while editing or when a query is set): a `/` prompt over the query. if m.eventFilterTyping || m.eventFilter != "" { caret := mbg(t, "", t.P.BgPanel) if m.eventFilterTyping && 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 m.eventFilter == "" { b.WriteString(prompt + caret + mbg(t, "type to filter events…", t.P.Faint) + "\n\n") } else { b.WriteString(prompt + mbg(t, m.eventFilter, t.P.FgStrong) + caret + "\n\n") } } if len(evs) == 0 { msg := "no events" if m.eventFilter != "" { msg = "no events match \"" + m.eventFilter + "\"" } b.WriteString(mbg(t, msg, 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"}, {"/", "filter"}, {"c", "clear"}, {"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 } // keybind column: the bare-key shortcut, so the palette teaches the shortcuts. keyCell := mbg(t, padRaw("", 4), t.P.BgPanel) if c.key != "" { keyCell = lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(padRaw(c.key, 4)) } b.WriteString(marker + keyCell + lipgloss.NewStyle().Foreground(titleFg).Background(t.P.BgPanel).Render(padRaw(c.title, 16)) + 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) } // filesModal is the `@` file-reference picker: a filter line over the session workspace's // paths, windowed around the selection; enter inserts `@path` into the chat input. func (m Model) filesModal() string { t := m.theme w := m.modalWidth() files := m.filteredFiles() var b strings.Builder b.WriteString(m.titleLine("@ file reference") + "\n\n") 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 m.fileFilter == "" { b.WriteString(prompt + caret + mbg(t, "type to filter files…", t.P.Faint) + "\n\n") } else { b.WriteString(prompt + mbg(t, m.fileFilter, t.P.FgStrong) + caret + "\n\n") } switch { case m.filesLoading: b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n") case len(m.files) == 0: b.WriteString(mbg(t, " no workspace files (is a workspace bound?)", t.P.Faint) + "\n") case len(files) == 0: b.WriteString(mbg(t, " no matching files", t.P.Faint) + "\n") default: const maxRows = 12 start := 0 if m.fileIndex >= maxRows { start = m.fileIndex - maxRows + 1 } end := start + maxRows if end > len(files) { end = len(files) } for i := start; i < end; i++ { marker := mbg(t, " ", t.P.BgPanel) fg := t.P.Fg if i == m.fileIndex { marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ") fg = t.P.FgStrong } b.WriteString(marker + lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(clip(files[i], w-6)) + "\n") } if len(files) > maxRows { b.WriteString(mbg(t, " "+itoa(len(files))+" matches", t.P.Faint) + "\n") } } b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"enter", "insert"}, {"esc", "close"}})) modal := t.Overlay.Width(w).Render(b.String()) return m.center(modal) } // statusbarModal toggles which status-bar segments are shown. Choices persist to the local // tui-prefs.json so the bar stays as configured across launches. func (m Model) statusbarModal() string { t := m.theme w := m.modalWidth() var b strings.Builder b.WriteString(m.titleLine("status bar") + "\n\n") b.WriteString(mbg(t, " show / hide segments (correx, connection, session name stay)", t.P.Faint) + "\n\n") for i, seg := range statusSegments { marker := mbg(t, " ", t.P.BgPanel) labelFg := t.P.Fg if i == m.sbIndex { marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ") labelFg = t.P.FgStrong } box := lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.BgPanel).Render("[✓] ") if !m.sbShow(seg.id) { box = mbg(t, "[ ] ", t.P.Faint) } b.WriteString(marker + box + lipgloss.NewStyle().Foreground(labelFg).Background(t.P.BgPanel).Render(seg.label) + "\n") } b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "move"}, {"space", "toggle"}, {"esc", "close"}})) modal := t.Overlay.Width(w).Render(b.String()) return m.center(modal) } // grantsModal lists the active standing (PROJECT/GLOBAL) grants and lets the operator revoke one. // These are cross-session auto-approvals, so the board opens from anywhere. func (m Model) grantsModal() string { t := m.theme w := m.modalWidth() var b strings.Builder b.WriteString(m.titleLine("standing grants") + "\n\n") b.WriteString(mbg(t, " cross-session auto-approvals in force (tool-bound)", t.P.Faint) + "\n\n") switch { case m.grantsLoading: b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n") case len(m.grants) == 0: b.WriteString(mbg(t, " none — press A on an approval to grant project / global", t.P.Faint) + "\n") default: for i, g := range m.grants { marker := mbg(t, " ", t.P.BgPanel) fg := t.P.Fg if i == m.grantIndex { marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ") fg = t.P.FgStrong } scope := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Render(padRaw(g.Scope, 8)) tool := lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(padRaw(g.ToolName, 16)) tiers := mbg(t, "≤"+strings.Join(g.Tiers, ","), t.P.Faint) b.WriteString(marker + scope + tool + tiers) if g.Scope == "PROJECT" && g.ProjectID != "" { b.WriteString(mbg(t, " "+clip(g.ProjectID, w-52), t.P.Faint)) } b.WriteString("\n") } } b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"x", "revoke"}, {"G/esc", "close"}})) modal := t.Overlay.Width(w).Render(b.String()) return m.center(modal) } // grantScopeModal is the A (approve-always) breadth picker: choose how widely to stop being asked // for the pending tool — this session, this project, or everywhere. func (m Model) grantScopeModal() string { t := m.theme w := m.modalWidth() tool := "" if m.grantFor != nil { tool = m.grantFor.ToolName } var b strings.Builder b.WriteString(m.titleLine("approve always — choose scope") + "\n\n") b.WriteString(mbg(t, " stop asking for ", t.P.Faint) + lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.BgPanel).Render(tool) + "\n\n") for i, opt := range grantScopeOptions() { marker := mbg(t, " ", t.P.BgPanel) labelFg := t.P.Fg if i == m.grantScopeIndex { marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ") labelFg = t.P.FgStrong } key := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Render(padRaw(opt.key, 3)) title := lipgloss.NewStyle().Foreground(labelFg).Background(t.P.BgPanel).Render(padRaw(opt.title, 16)) b.WriteString(marker + key + title + mbg(t, opt.hint, t.P.Faint) + "\n") } b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "move"}, {"enter", "grant"}, {"esc", "cancel"}})) modal := t.Overlay.Width(w).Render(b.String()) return m.center(modal) } // helpModal is the keybinding cheat-sheet (?), grouped by context. Any key dismisses it. // modalBodyRows is how many body lines a scrollable fixed-content modal shows: the // screen height minus the modal chrome (border 2 + padding 2 + title block 2 + hint // block 2). Sized so a modal that fits shows in full and a taller one scrolls rather // than clipping its bottom off-screen (the old compositeOverlay behaviour). func (m Model) modalBodyRows() int { if r := m.height - 8; r > 3 { return r } return 3 } // scrollableModalMax is the largest scroll offset for a body of n lines, anchoring the // last page to the bottom. func (m Model) scrollableModalMax(n int) int { if max := n - m.modalBodyRows(); max > 0 { return max } return 0 } // activeModalBody returns the body lines of whichever scrollable fixed-content modal is // open, so the key handler can clamp m.modalScroll against the same content it renders. func (m Model) activeModalBody() []string { switch m.overlay { case OverlayHelp: return m.helpBody() case OverlayStats: if m.statsLoading || m.stats == nil || m.statsFor != m.selectedID { return nil } return m.statsBody() } return nil } // scrollModal moves the active fixed-content modal's body offset by delta lines, clamped // to [0, scrollableModalMax] — same contract as the diff/output scrollers. func (m *Model) scrollModal(delta int) { max := m.scrollableModalMax(len(m.activeModalBody())) off := m.modalScroll + delta if off > max { off = max } if off < 0 { off = 0 } m.modalScroll = off } // renderScrollModal lays out a title + scrollable body + pinned hint inside the modal box, // windowing body by m.modalScroll so the box never exceeds the screen. A position indicator // appears in the title row whenever the body is taller than the viewport. func (m Model) renderScrollModal(title, titleSuffix string, body []string, hint [][2]string) string { t := m.theme w := m.modalWidth() rows := m.modalBodyRows() off := m.modalScroll if mx := m.scrollableModalMax(len(body)); off > mx { off = mx } if off < 0 { off = 0 } end := off + rows if end > len(body) { end = len(body) } var b strings.Builder b.WriteString(m.titleLine(title)) if titleSuffix != "" { b.WriteString(mbg(t, titleSuffix, t.P.Faint)) } if len(body) > rows { b.WriteString(mbg(t, " ("+itoa(off+1)+"-"+itoa(end)+"/"+itoa(len(body))+")", t.P.Faint)) } b.WriteString("\n\n") for i := off; i < end; i++ { b.WriteString(body[i] + "\n") } b.WriteString("\n" + modalHints(t, hint)) return t.Overlay.Width(w).Render(b.String()) } // helpBody is the keybinding cheat-sheet as discrete lines (so renderScrollModal can window it). func (m Model) helpBody() []string { t := m.theme type kb struct{ key, desc string } var out []string section := func(title string, rows []kb) { out = append(out, lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(title)) for _, r := range rows { key := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render(padRaw(r.key, 16)) out = append(out, " "+key+mbg(t, r.desc, t.P.Fg)) } out = append(out, "") } section("navigate", []kb{ {"↑↓ / j k", "move the session / list selection"}, {"enter", "open the selected session"}, {"Tab / w", "idle: cycle launch target (chat / workflow)"}, {"/", "filter the session list"}, {"^↑ / ^↓", "jump to your previous / next message"}, {"PgUp / PgDn", "scroll output (^u / ^d half-page)"}, {"esc", "drop selection / scroll → follow newest"}, {"l", "back to the session list"}, }) section("session", []kb{ {"i", "compose a message"}, {"d", "right panel: events / changes / off"}, {"s", "toggle chat / steering mode"}, {"w", "show workflows (idle screen)"}, {"y", "copy the selected message (OSC52)"}, {"^x", "open the latest diff / preview"}, {"c", "cancel the running session"}, {"a", "re-open a dismissed approval gate"}, {"q", "quit"}, }) section("overlays", []kb{ {"p", "command palette (all shortcuts)"}, {"?", "this keybindings help"}, {"e", "event inspector (/ to filter)"}, {"t / v", "tools / artifacts"}, {"S / R", "session stats / resume past sessions"}, {"T", "task board (across projects)"}, {"m / g", "swap model / edit config"}, {"G / I", "grants / idea board"}, }) section("approval band", []kb{ {"y / a / enter", "approve once (T3+ asks again to confirm)"}, {"n / r", "reject"}, {"e / s", "steer (add a note)"}, {"A", "approve-always — choose scope"}, {"↑ / ↓", "walk the pending queue"}, {"^x", "fullscreen the diff / command preview"}, {"esc", "dismiss for now (a re-opens)"}, }) section("diff / preview viewer (^x)", []kb{ {"↑ / ↓", "scroll a line"}, {"PgUp / PgDn", "scroll a page (^u / ^d half)"}, {"g / G", "jump to top / end"}, {"^x / esc", "close"}, }) return out } func (m Model) helpModal() string { return m.renderScrollModal("keybindings", "", m.helpBody(), [][2]string{{"↑↓", "scroll"}, {"esc", "close"}}) } 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 } // scrollArtifact moves the selected artifact's content offset by delta lines, clamped to // the scrollable range (same contract as the diff/output scrollers). func (m *Model) scrollArtifact(delta int) { max := m.artifactContentMaxScroll(m.modalWidth() - 6) off := m.artifactScroll + delta if off > max { off = max } if off < 0 { off = 0 } m.artifactScroll = off } 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", "page"}, {"g/G", "ends"}, {"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() // Loading / empty: a small fixed modal, no scroll needed. if m.statsLoading || m.stats == nil || m.statsFor != m.selectedID { 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") 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()) } suffix := "" if m.selectedID != "" { suffix = " (" + shortID(m.selectedID) + ")" } return m.renderScrollModal("session stats", suffix, m.statsBody(), [][2]string{{"↑↓", "scroll"}, {"S/esc", "close"}}) } // statsBody is the session-stats report as discrete lines (so renderScrollModal can window it). // Callers must ensure m.stats is loaded for the selected session. func (m Model) statsBody() []string { t := m.theme s := m.stats cw := m.modalWidth() - 4 // modal inner content width (borders + padding) var out []string 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) { out = append(out, clip(styled, cw)) } line := func(s string) string { return mbg(t, s, t.P.Fg) } faint := func(s string) string { return mbg(t, s, t.P.Faint) } blank := func() { out = append(out, "") } // 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)))) blank() // inference out = append(out, section("Inference")) 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))) } blank() // tools out = append(out, section("Tools")) 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))) } blank() // approvals out = append(out, section("Approvals")) 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))) } blank() // failures f := s.Failures out = append(out, section("Failures")) 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))) blank() // time accounting other := 100.0 - s.InferencePct - s.ToolPct - s.ApprovalWaitPct if other < 0 { other = 0 } out = append(out, section("Time")) put(line(fmt.Sprintf(" inf %.1f%% · tools %.1f%% · wait %.1f%% · other %.1f%%", s.InferencePct, s.ToolPct, s.ApprovalWaitPct, other))) return out } 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) color.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 }