c82a86477d
Artifact content wider than the modal was silently cut off. Lines are now hard-wrapped at rune boundaries to the modal content width, and scroll clamping uses the wrapped line count so the last page stays reachable.
623 lines
18 KiB
Go
623 lines
18 KiB
Go
package app
|
||
|
||
import (
|
||
"sort"
|
||
"strings"
|
||
|
||
"github.com/charmbracelet/lipgloss"
|
||
)
|
||
|
||
// center composites a modal over a scrim-filled screen. Immediate-mode: the modal
|
||
// is recomputed from state every frame, so there is no separate show/restore path
|
||
// to desync (the "diff won't come back" bug class can't occur here).
|
||
func (m Model) center(modal string) string {
|
||
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal,
|
||
lipgloss.WithWhitespaceBackground(m.theme.P.BgDeep),
|
||
lipgloss.WithWhitespaceForeground(m.theme.P.BgDeep))
|
||
}
|
||
|
||
func (m Model) modalWidth() int {
|
||
w := m.width * 70 / 100
|
||
if w > 92 {
|
||
w = 92
|
||
}
|
||
if w < 24 {
|
||
w = 24
|
||
}
|
||
return w
|
||
}
|
||
|
||
func (m Model) renderOverlay(base string) string {
|
||
switch m.overlay {
|
||
case OverlayPalette:
|
||
return m.center(m.paletteModal())
|
||
case OverlayEventInspector:
|
||
return m.center(m.eventInspectorModal())
|
||
case OverlayDiff:
|
||
return m.center(m.diffModal())
|
||
case OverlayToolPalette:
|
||
return m.center(m.toolPaletteModal())
|
||
case OverlayModels:
|
||
return m.center(m.modelsModal())
|
||
case OverlayArtifacts:
|
||
return m.center(m.artifactsModal())
|
||
case OverlayConfig:
|
||
return m.center(m.configModal())
|
||
}
|
||
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 = previewRowCount(s.Pending.Preview)
|
||
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
|
||
if isUnifiedDiff(a.Preview) {
|
||
content = m.renderSplitDiff(parseUnifiedDiff(a.Preview), textW, diffH, 0)
|
||
} else {
|
||
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)
|
||
}
|
||
if m.steering {
|
||
return strings.Join([]string{
|
||
hint("enter", "approve + send note"), hint("esc", "keep note, decide below"),
|
||
}, t.span(" ", t.P.Faint))
|
||
}
|
||
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) {
|
||
parts = append(parts, hint("^x", "fullscreen"))
|
||
}
|
||
parts = append(parts, hint("esc", "later"))
|
||
return strings.Join(parts, t.span(" ", t.P.Faint))
|
||
}
|
||
|
||
// diffBodyHeight is the number of diff lines visible in the diff modal body.
|
||
func (m Model) diffBodyHeight() int {
|
||
h := m.height * 70 / 100
|
||
if h < 8 {
|
||
h = 8
|
||
}
|
||
return h - 6
|
||
}
|
||
|
||
// diffMaxScroll is the largest scroll offset that still fills the body with
|
||
// diff rows, keeping the last page anchored to the bottom of the modal.
|
||
func (m Model) diffMaxScroll() int {
|
||
max := previewRowCount(m.currentDiff()) - m.diffBodyHeight()
|
||
if max < 0 {
|
||
max = 0
|
||
}
|
||
return max
|
||
}
|
||
|
||
func (m Model) diffModal() string {
|
||
t := m.theme
|
||
w := m.modalWidth()
|
||
bodyH := m.diffBodyHeight()
|
||
diff := m.currentDiff()
|
||
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)
|
||
}
|
||
|
||
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())
|
||
}
|
||
|
||
// 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
|
||
}
|