Files
correx/apps/tui-go/internal/app/overlays.go
T
kami 15248cae8a feat(recovery,context): tier-2 intent-holder arbiter + remaining-delta pinning + surfaced signal events
- recovery: two-tier repair ladder — owner then arbiter (recovery stage recast
  as intent-holder, holds initial intent, reconciles cross-file contract disputes)
  with independent INTENT_ROUTE_BUDGET; RecoveryRoutingTest coverage
- context: remaining-delta checklist pinning (RemainingDelta{Pinning,Entry}Test)
- surface write-only events (session name, quality signals) into stats/browse
- parseToolArguments lenient-Json fallback for bare-key drift
- tools: ChildProcess seam
2026-07-11 23:56:52 +04:00

1370 lines
43 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 OverlayProjectProfile:
return m.center(m.projectProfileModal())
case OverlayOperatorProfile:
return m.center(m.operatorProfileModal())
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 {
textW := m.width - 4 - 2 // box borders/padding + rationale marker
for _, r := range s.Pending.Rationale {
rat += len(wrapLine(r, textW))
}
rat++ // 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 {
for _, r := range a.Rationale {
ratBlock += len(wrapLine(r, textW-2))
}
ratBlock++ // 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("▲ ")
for i, ln := range wrapLine(r, textW-2) {
prefix := marker
if i > 0 {
prefix = " "
}
body = append(body, prefix+t.span(ln, 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)) + mbg(t, " ", t.P.Fg) +
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
}