a92b5a3531
Two operator features over the existing WebSocket protocol, plus a tui-go nav fix. Config editor (g / palette "config"): - core:config gains a thin registry stack: OrchestrationKnobs ([orchestration] block), CorrexConfigWriter (round-trip TOML serializer; parseToml(write(c))==c), ConfigHolder (AtomicReference live config), and EditableConfig (allowlist of editable fields + patch applier; security fields are absent so they can never be patched). - ConfigService validates -> persists (atomic temp+move) -> swaps the holder -> rebuilds config-derived services. Live-apply is scoped to safe seams: stage timeout, compaction threshold (now a supplier), router knobs (routerFacade rebuild), and personalization/project toggles all take effect on the next session/turn; in-flight sessions keep the config they started with. server.host/port are persisted + badged "restart required", not hot-swapped. - Protocol: GetConfig / UpdateConfig / ConfigSnapshot; TUI overlay with type-aware editing (bool toggle, enum cycle, inline int/string edit) and save. Artifact viewer (v / palette "artifacts"): - Server assembles a session's artifacts from the event log and resolves bytes from the CAS store (StreamQueries.listArtifacts) — a replay-neutral snapshot read. TUI overlay lists artifacts and shows scrollable content. tui-go fix: workflow failure no longer clears selectedID (which yanked the user to the list); listNav lands on the first session when nothing is selected instead of wrapping to a random one. Config is not event-sourced (it lives in TOML); editing stays outside the log.
585 lines
17 KiB
Go
585 lines
17 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.
|
||
func (m Model) artifactContentLines() []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)"}
|
||
}
|
||
return strings.Split(strings.ReplaceAll(*c, "\r\n", "\n"), "\n")
|
||
}
|
||
|
||
// artifactContentMaxScroll keeps the last page of content anchored to the body bottom.
|
||
func (m Model) artifactContentMaxScroll() int {
|
||
max := len(m.artifactContentLines()) - 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.
|
||
lines := m.artifactContentLines()
|
||
bodyH := m.artifactContentBodyH()
|
||
coff := m.artifactScroll
|
||
if coff > m.artifactContentMaxScroll() {
|
||
coff = m.artifactContentMaxScroll()
|
||
}
|
||
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, " "+truncate(lines[i], w-6), 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]) + "…"
|
||
}
|