Files
strix/strix/interface/tui/internal/app/view.go
T

825 lines
28 KiB
Go

package app
import (
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/usestrix/strix/tui/internal/protocol"
"github.com/usestrix/strix/tui/internal/render"
)
// eventSpan records which content lines of the chat trace belong to an
// expandable tool event, so clicks can toggle its collapsed state.
type eventSpan struct {
start, end int
eventID string
}
// renderedBlock is a chat block kept across frames: rendering (syntax
// highlighting, image placements, wrapping) is expensive and only changes
// when the event, the chat width, or its expanded state changes.
type renderedBlock struct {
version int
width int
expanded bool
wrapped string
expandable bool
height int
}
func (m *Model) renderEvent(event protocol.Event, width int) renderedBlock {
expanded := m.expandedEvents[event.ID]
if cached, ok := m.blockCache[event.ID]; ok &&
cached.version == event.Version && cached.width == width && cached.expanded == expanded {
return cached
}
var block string
expandable := false
switch event.Type {
case "chat":
block = render.Chat(event.Data)
case "tool":
name := render.StringValue(event.Data["tool_name"])
block, expandable = render.CollapseTool(render.Tool(event.Data), name, expanded)
}
entry := renderedBlock{version: event.Version, width: width, expanded: expanded, expandable: expandable}
if block != "" {
entry.wrapped = wrapBlock(block, width)
entry.height = strings.Count(entry.wrapped, "\n") + 1
}
if m.blockCache == nil {
m.blockCache = map[string]renderedBlock{}
}
m.blockCache[event.ID] = entry
return entry
}
func (m *Model) chatContent() string {
if len(m.snapshot.Agents) == 0 {
switch m.snapshot.ScanState {
case "failed":
message := "Scan failed"
if m.snapshot.Error != nil && strings.TrimSpace(*m.snapshot.Error) != "" {
detail := strings.ReplaceAll(strings.TrimSpace(*m.snapshot.Error), "\n", " ")
message += "\n\n" + ansi.Truncate(detail, max(1, m.viewport.Width-4), "...")
}
return centeredPlaceholder(message, m.viewport.Width, m.viewport.Height)
case "stopped":
return centeredPlaceholder("Scan stopped", m.viewport.Width, m.viewport.Height)
case "completed":
return centeredPlaceholder("Scan completed", m.viewport.Width, m.viewport.Height)
case "preparing":
return centeredPlaceholder("Preparing scan...", m.viewport.Width, m.viewport.Height)
default:
return centeredPlaceholder("Loading...", m.viewport.Width, m.viewport.Height)
}
}
if m.selectedAgent >= len(m.snapshot.Agents) {
return ""
}
agentID := m.snapshot.Agents[m.selectedAgent].ID
events := append([]protocol.Event(nil), m.snapshot.Events...)
// Match _gather_agent_events: sort by (timestamp, id).
sort.SliceStable(events, func(i, j int) bool {
if events[i].Timestamp != events[j].Timestamp {
return events[i].Timestamp < events[j].Timestamp
}
return events[i].ID < events[j].ID
})
// .chat-content has padding: 0 1 — one column of horizontal padding, so wrap
// to width-2 and indent every line by one cell.
contentWidth := max(1, m.viewport.Width-2)
render.SetImageWidth(contentWidth - 2)
var blocks []string
var spans []eventSpan
line := 0
for _, event := range events {
if event.AgentID != agentID {
continue
}
entry := m.renderEvent(event, contentWidth)
if entry.wrapped == "" {
continue
}
if len(blocks) > 0 {
line++ // blank separator line between blocks
}
if entry.expandable {
spans = append(spans, eventSpan{start: line, end: line + entry.height - 1, eventID: event.ID})
}
line += entry.height
blocks = append(blocks, entry.wrapped)
}
m.eventSpans = spans
if len(blocks) == 0 {
return centeredPlaceholder("Starting agent...", m.viewport.Width, m.viewport.Height)
}
return indentLines(strings.Join(blocks, "\n\n"), " ")
}
// indentLines prefixes every line with the given pad (chat-content padding-left).
func indentLines(s, pad string) string {
lines := strings.Split(s, "\n")
for i, line := range lines {
lines[i] = pad + line
}
return strings.Join(lines, "\n")
}
func centeredPlaceholder(text string, width, height int) string {
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, lipgloss.NewStyle().Foreground(dim).Italic(true).Render(text))
}
// truncate clips to a display-cell width, honoring wide runes and ANSI styling.
func truncate(value string, limit int) string {
if limit <= 0 {
return ""
}
if ansi.StringWidth(value) <= limit {
return value
}
return ansi.Truncate(value, limit, "…")
}
// wrapBlock hard-wraps each line of a rendered block to the given cell width so
// content never spills past the chat border, matching Textual's word wrapping.
func wrapBlock(value string, width int) string {
if width <= 0 {
return value
}
var out []string
for _, line := range strings.Split(value, "\n") {
if ansi.StringWidth(line) <= width {
out = append(out, line)
continue
}
out = append(out, strings.Split(ansi.Wrap(line, width, " -"), "\n")...)
}
return strings.Join(out, "\n")
}
// scrollbarThumb brightens the bar being dragged so the grab reads as taking
// hold of it.
func (m Model) scrollbarThumb(target scrollbarTarget) lipgloss.Color {
if m.draggingScrollbar == target {
return thumbActive
}
return thumbResting
}
func verticalScrollbar(height, total, visible, offset int, thumb lipgloss.Color) string {
if height <= 0 || total <= visible {
return ""
}
visible = min(max(1, visible), max(1, total))
total = max(visible, total)
thumbHeight := height
thumbStart := 0
if total > visible {
thumbHeight = max(1, height*visible/total)
maxOffset := total - visible
thumbStart = (height - thumbHeight) * min(max(0, offset), maxOffset) / maxOffset
}
thumbStyle := lipgloss.NewStyle().Foreground(thumb)
bar := make([]string, height)
for row := range bar {
bar[row] = " "
if row >= thumbStart && row < thumbStart+thumbHeight {
bar[row] = thumbStyle.Render("█")
}
}
return strings.Join(bar, "\n")
}
// withVerticalScrollbar reserves a single column for the bar, and only while the
// panel actually overflows.
func withVerticalScrollbar(
content string,
width, height, total, visible, offset int,
thumb lipgloss.Color,
) string {
if total <= visible {
return fixedPanelBody(content, width, height)
}
body := fixedPanelBody(content, max(1, width-1), height)
bar := verticalScrollbar(height, total, visible, offset, thumb)
return lipgloss.JoinHorizontal(lipgloss.Top, body, bar)
}
func visibleContent(content string, offset, height int) string {
if height <= 0 || content == "" {
return ""
}
lines := strings.Split(content, "\n")
start := min(max(0, offset), len(lines))
end := min(len(lines), start+height)
return strings.Join(lines[start:end], "\n")
}
func fixedPanelBody(content string, width, height int) string {
lines := strings.Split(content, "\n")
body := make([]string, max(0, height))
for row := range body {
line := ""
if row < len(lines) {
line = ansi.Truncate(lines[row], max(1, width), "")
}
padding := strings.Repeat(" ", max(0, width-ansi.StringWidth(line)))
// End every source style before padding; otherwise inline-code and tool
// backgrounds can paint the empty space through to the panel border.
body[row] = line + "\x1b[0m" + blackBG + padding
}
return strings.Join(body, "\n")
}
func (m Model) View() string {
view := fillBackground(m.viewInner())
// Kitty graphics transmissions ride out of band: they carry no visible
// cells, so writing them directly keeps the Bubble Tea frame diff clean.
for _, seq := range render.DrainImageTransmissions() {
_, _ = os.Stdout.WriteString(seq)
}
return view
}
func (m Model) viewInner() string {
if m.showSplash {
return m.splashView()
}
if !m.ready {
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, lipgloss.NewStyle().Foreground(dim).Render("Connecting to Strix…"), lipgloss.WithWhitespaceBackground(black))
}
main := m.mainView()
if m.snapshot.SetupMode {
main = m.setupView()
}
if m.modal == modalConfirmMount {
// A corner prompt, not a dialog: it sits out of the way in the live view
// while the scan waits on the answer.
main = m.cornerOverlay(main, m.modalView())
} else if m.modal != modalNone {
// Only the vulnerability detail dims its backdrop (#000000 80%); Help,
// Quit and Stop are transparent.
main = m.overlay(main, m.modalView(), m.modal == modalVulnerability)
}
return m.toastOverlay(main)
}
// cornerOverlay splices a panel in directly above the composer, right-aligned
// with it, leaving the rest of the view visible behind it.
func (m Model) cornerOverlay(view, panel string) string {
if panel == "" {
return view
}
fg := strings.Split(panel, "\n")
bg := strings.Split(view, "\n")
panelWidth := lipgloss.Width(panel)
// Right edge of the chat column, so it lines up with the composer rather
// than covering the sidebar.
_, _, chatWidth, _ := m.layout()
left := max(0, min(chatWidth, m.width)-panelWidth)
// Bottom row sits just above the composer, clearing the status line so the
// scan state and quit hint stay readable.
statusH := 0
if m.statusVisible() {
statusH = 1
}
top := max(0, m.inputTop()-statusH-len(fg))
for row := top; row < min(len(bg), top+len(fg)); row++ {
fgLine := ansi.Truncate(fg[row-top], max(0, m.width-left), "")
rightStart := left + lipgloss.Width(fgLine)
leftPart := padToWidth(ansi.Truncate(bg[row], left, ""), left)
rightPart := ""
if lipgloss.Width(bg[row]) > rightStart {
rightPart = ansi.TruncateLeft(bg[row], rightStart, "")
}
bg[row] = leftPart + fgLine + rightPart
}
return strings.Join(bg, "\n")
}
// toastOverlay splices a transient notification into the bottom-right corner,
// where Textual's notify() toasts appeared.
func (m Model) toastOverlay(view string) string {
if m.toast == "" {
return view
}
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(green).
Background(black).
Foreground(textColor).
Padding(0, 1).
Render(m.toast)
fg := strings.Split(box, "\n")
bg := strings.Split(view, "\n")
boxWidth := lipgloss.Width(box)
left := max(0, m.width-boxWidth-2)
top := max(0, m.height-len(fg)-1)
for row := top; row < min(len(bg), top+len(fg)); row++ {
fgLine := fg[row-top]
rightStart := left + boxWidth
leftPart := padToWidth(ansi.Truncate(bg[row], left, ""), left)
rightPart := ""
if lipgloss.Width(bg[row]) > rightStart {
rightPart = ansi.TruncateLeft(bg[row], rightStart, "")
}
bg[row] = leftPart + fgLine + rightPart
}
return strings.Join(bg, "\n")
}
// blackBG is the SGR that selects a solid black background.
const blackBG = "\x1b[48;2;0;0;0m"
// fillBackground paints the whole frame black like Textual's Screen background.
// Bubble Tea has no screen compositor, so any cell the view does not explicitly
// color shows the terminal's default background. lipgloss emits a full reset
// (\x1b[0m) at the end of every styled span, which also clears the background, so
// we reassert black after each reset (and at the start). Spans that set their own
// background — inline code, selected rows, buttons — keep it, because their color
// is emitted before the reset.
func fillBackground(view string) string {
if view == "" {
return view
}
return blackBG + strings.ReplaceAll(view, "\x1b[0m", "\x1b[0m"+blackBG)
}
func (m Model) splashView() string {
shine := "Starting Strix Agent"
chars := []rune(shine)
pos := m.splashFrame % (len(chars) + 8)
var start strings.Builder
for i, char := range chars {
distance := i - pos
if distance < 0 {
distance = -distance
}
// Tiers match SplashScreen._build_start_line_text:
// bright_white / white / #a3a3a3 / #525252.
color := lipgloss.Color("#525252")
bold := false
switch {
case distance <= 1:
color, bold = brightWhite, true
case distance <= 3:
color, bold = white, true
case distance <= 5:
color = lipgloss.Color("#a3a3a3")
}
start.WriteString(lipgloss.NewStyle().Foreground(color).Bold(bold).Render(string(char)))
}
welcome := lipgloss.NewStyle().Bold(true).Foreground(white).Render("Welcome to ") +
lipgloss.NewStyle().Bold(true).Foreground(green).Render("Strix") +
lipgloss.NewStyle().Bold(true).Foreground(white).Render("!")
version := lipgloss.NewStyle().Foreground(white).Faint(true).Render("v" + appVersion)
tagline := lipgloss.NewStyle().Foreground(white).Faint(true).Render("Open-source AI hackers for your apps")
url := lipgloss.NewStyle().Bold(true).Foreground(green).Render("strix.ai")
// The wordmark is shared with the launch screen so the two read as one moment.
content := wordmark() + "\n\n" +
welcome + "\n" + version + "\n" + tagline + "\n\n" +
start.String() + "\n\n" + url
if warn := m.snapshot.ModelWarning; warn != "" {
content += "\n\n" + splashModelWarning(warn)
}
panel := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(green).Padding(1, 6).Align(lipgloss.Center).Render(content)
// #splash_screen background is solid black.
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, panel,
lipgloss.WithWhitespaceBackground(black))
}
// splashModelWarning ports SplashScreen._build_model_warning_text.
func splashModelWarning(model string) string {
yellow := lipgloss.Color("#eab308")
return lipgloss.NewStyle().Bold(true).Foreground(yellow).Render("⚠ ") +
lipgloss.NewStyle().Bold(true).Foreground(render.Cyan).Render(model) +
lipgloss.NewStyle().Foreground(yellow).Render(" is not a recommended frontier model - pentest quality could be degraded")
}
// chatPaneKey identifies everything the bordered trace depends on.
type chatPaneKey struct {
offset int
width, height int
border lipgloss.Color
selection selectionState
}
// chatPane memoizes the bordered trace: slicing, scrollbar padding and border
// styling all re-measure every visible cell, which is costly when inline image
// placeholders (a base rune plus two combining marks per cell) fill the pane,
// and the trace is unchanged across most frames.
var chatPane struct {
key chatPaneKey
content string
out string
}
func (m Model) renderChatPane(width, height int, border lipgloss.Color) string {
key := chatPaneKey{offset: m.viewport.YOffset, width: width, height: height, border: border, selection: m.selection}
if chatPane.out != "" && chatPane.key == key && chatPane.content == m.viewportContent {
return chatPane.out
}
trace := withVerticalScrollbar(
m.highlightSelection(visibleContent(m.viewportContent, m.viewport.YOffset, height), m.viewport.YOffset),
width,
height,
m.viewport.TotalLineCount(),
m.viewport.VisibleLineCount(),
m.viewport.YOffset,
m.scrollbarThumb(scrollbarTrace),
)
out := lipgloss.NewStyle().Width(width).Height(height).
Border(lipgloss.RoundedBorder()).BorderForeground(border).Render(trace)
chatPane.key, chatPane.content, chatPane.out = key, m.viewportContent, out
return out
}
func (m Model) mainView() string {
showSidebar, sidebarWidth, chatWidth, chatHeight := m.layout()
// Matches tui_styles.tcss: #chat_history border is near-black when idle and
// green on focus.
chatBorder := lipgloss.Color("#0a0a0a")
if m.focus == focusChat {
chatBorder = green
}
traceHeight := chatHeight - 2
chat := m.renderChatPane(chatWidth-2, traceHeight, chatBorder)
inputBorder := dark
if m.focus == focusInput {
inputBorder = green
}
input := lipgloss.NewStyle().Width(chatWidth - 2).Height(m.input.Height()).
Border(lipgloss.RoundedBorder()).BorderForeground(inputBorder).PaddingLeft(1).
Render(m.highlightInputSelection(m.input.View()))
// Chat column: chat history, optional status row, then input — all chat-width.
leftParts := []string{chat}
if m.statusVisible() {
leftParts = append(leftParts, m.statusView(chatWidth))
}
leftParts = append(leftParts, input)
leftColumn := strings.Join(leftParts, "\n")
body := leftColumn
if showSidebar {
body = lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, " ", m.sidebarView(sidebarWidth, m.height))
}
return lipgloss.NewStyle().Background(black).Foreground(textColor).Render(body)
}
// Every panel that Tab can reach shows focus the way the chat and the composer
// do, with a green border. The stylesheet asked for near-black on the tree
// instead, through a Tree:focus rule that lost to the #agents_tree id selector
// and so never applied - honoring it made the outline vanish on the one panel
// that had just become active.
func (m Model) sidebarView(width, height int) string {
// Stats box height fits its content (auto, max 15); vulns panel max-height 12.
statsBody := m.statsView()
statsHeight, vulnHeight, agentHeight := m.sidebarHeights()
agentBorder := dark
if m.focus == focusAgents {
agentBorder = green
}
// #agents_tree padding: 1 (all sides); interior lines = box - border - v.padding.
agentRows := max(1, agentHeight-4)
agentEntries := agentTreeEntries(m.snapshot.Agents, m.collapsedAgents)
agents := withVerticalScrollbar(
m.agentsView(max(1, width-5), agentRows),
width-4,
agentRows,
len(agentEntries),
agentRows,
m.agentOffset,
m.scrollbarThumb(scrollbarAgents),
)
parts := []string{
lipgloss.NewStyle().Width(width-2).Height(m.viewerHeight()-2).Border(lipgloss.RoundedBorder()).BorderForeground(dark).Padding(0, 1).Render(m.viewerView(width - 4)),
lipgloss.NewStyle().Width(width-2).Height(agentHeight-2).Border(lipgloss.RoundedBorder()).BorderForeground(agentBorder).Padding(1, 1).Render(agents),
}
if vulnHeight > 0 {
vulnBorder := dark
if m.focus == focusVulnerabilities {
vulnBorder = green
}
vulnRows := max(1, vulnHeight-2)
totalRows, offsetRows := m.vulnerabilityScrollRows()
findings := withVerticalScrollbar(
m.vulnerabilitiesView(m.vulnerabilityListWidth(), vulnRows),
width-4,
vulnRows,
totalRows,
vulnRows,
offsetRows,
m.scrollbarThumb(scrollbarFindings),
)
parts = append(parts, lipgloss.NewStyle().Width(width-2).Height(vulnRows).Border(lipgloss.RoundedBorder()).BorderForeground(vulnBorder).Padding(0, 1).Render(findings))
}
parts = append(parts, lipgloss.NewStyle().Width(width-2).Height(statsHeight-2).Border(lipgloss.RoundedBorder()).BorderForeground(dark).Padding(0, 1).Render(statsBody))
return strings.Join(parts, "\n")
}
func (m Model) sidebarHeights() (statsHeight, vulnHeight, agentHeight int) {
// Measure the stats panel the way its box will render it: a long model name
// wraps inside the sidebar, and counting only its newlines would size the
// box short and push the whole frame past the bottom of the terminal.
statsRows := lipgloss.Height(lipgloss.NewStyle().Width(m.viewerContentWidth()).Render(m.statsView()))
statsHeight = min(15, statsRows+2)
if len(m.snapshot.Vulnerabilities) > 0 {
vulnHeight = min(12, len(m.vulnerabilityRows(m.vulnerabilityListWidth()))+2)
}
agentHeight = max(3, m.height-m.viewerHeight()-statsHeight-vulnHeight)
return
}
func (m Model) viewerHeight() int {
return strings.Count(m.viewerView(m.viewerContentWidth()), "\n") + 3
}
func (m Model) viewerContentWidth() int {
_, sidebarWidth, _, _ := m.layout()
if sidebarWidth == 0 {
sidebarWidth = 24
}
return max(1, sidebarWidth-4)
}
func (m Model) viewerView(width int) string {
switch m.snapshot.ViewerStatus {
case "running":
status := lipgloss.NewStyle().Foreground(green).Render("● Viewer running")
if m.snapshot.ViewerURL != nil && strings.TrimSpace(*m.snapshot.ViewerURL) != "" {
url := wrapBlock(strings.TrimSpace(*m.snapshot.ViewerURL), width)
return status + "\n" + lipgloss.NewStyle().Foreground(dim).Render(url)
}
return status
case "unavailable":
return truncate(lipgloss.NewStyle().Foreground(amber).Render("Viewer UI not built"), width)
case "failed":
return truncate(lipgloss.NewStyle().Foreground(red).Render("Viewer failed to start"), width)
default:
return truncate(lipgloss.NewStyle().Foreground(textColor).Render("▶ Watch live in browser"), width)
}
}
func (m Model) statsView() string {
w := lipgloss.NewStyle().Foreground(white)
var b strings.Builder
if model := m.snapshot.Model; model != "" {
b.WriteString(w.Render(model))
}
if m.snapshot.Subscription {
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(lipgloss.NewStyle().Foreground(green).Render("ChatGPT subscription"))
}
total := numberValue(m.snapshot.Usage["total_tokens"])
if total > 0 {
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(w.Render(fmt.Sprintf("%s tokens", formatCount(total))))
if cost := floatValue(m.snapshot.Usage["cost"]); !m.snapshot.Subscription && cost > 0 {
b.WriteString(w.Render(fmt.Sprintf(" · $%.2f", cost)))
}
}
if caido := m.snapshot.CaidoURL; caido != "" {
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(lipgloss.NewStyle().Bold(true).Foreground(white).Render("Caido: ") + w.Render(caido))
}
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(w.Render("v" + appVersion))
return b.String()
}
func numberValue(value any) int64 {
switch v := value.(type) {
case float64:
return int64(v)
case int64:
return v
case int:
return int64(v)
case json.Number:
n, _ := v.Int64()
return n
}
return 0
}
func floatValue(value any) float64 {
switch v := value.(type) {
case float64:
return v
case int:
return float64(v)
case string:
n, _ := strconv.ParseFloat(v, 64)
return n
}
return 0
}
func formatCount(value int64) string {
if value >= 1_000_000 {
return fmt.Sprintf("%.1fM", float64(value)/1_000_000)
}
if value >= 1_000 {
return fmt.Sprintf("%.1fK", float64(value)/1_000)
}
return strconv.FormatInt(value, 10)
}
func (m Model) statusView(width int) string {
// Status text color mirrors #status_text (#a3a3a3); keymap hints use white
// keys and dim actions (keymap_styled). See _get_status_display_content.
left, right := "", ""
if len(m.snapshot.Agents) > 0 && !m.snapshot.SetupMode {
agent := m.snapshot.Agents[m.selectedAgent]
quitHint := lipgloss.NewStyle().Foreground(white).Render("ctrl-q") + lipgloss.NewStyle().Foreground(dim).Render(" ") + lipgloss.NewStyle().Foreground(dim).Render("quit")
switch agent.Status {
case "running":
if m.agentHasEvents(agent.ID) {
left = m.sweepView() + lipgloss.NewStyle().Foreground(white).Render("esc") + lipgloss.NewStyle().Foreground(dim).Render(" ") + lipgloss.NewStyle().Foreground(dim).Render("stop")
} else {
left = m.sweepView() + lipgloss.NewStyle().Foreground(white).Render("Initializing")
}
right = quitHint
case "waiting":
left = lipgloss.NewStyle().Foreground(dim).Render("Send message to resume")
if msg := agent.ErrorMessage; msg != "" {
left = statusMessage(msg, red, " · Send message to resume", width)
}
case "budget_paused":
left = lipgloss.NewStyle().Foreground(amber).Render("Budget limit reached") +
lipgloss.NewStyle().Foreground(dim).Render(" · Send a message to continue")
right = quitHint
case "completed":
left = lipgloss.NewStyle().Foreground(mid).Render("Agent completed")
case "stopped":
left = lipgloss.NewStyle().Foreground(mid).Render("Agent stopped")
case "failed", "crashed":
msg := agent.ErrorMessage
if msg == "" {
msg = "Agent failed"
}
left = statusMessage(msg, red, " · Send message to resume", width)
}
}
if m.errorText != "" {
left = statusMessage(m.errorText, red, "", width-lipgloss.Width(right))
}
return composeStatusRow(left, right, width)
}
// composeStatusRow lays the status text and the corner hint on one row exactly
// width columns wide. A wider row would widen the whole chat column, because
// JoinHorizontal pads every row of a block to its widest, which pushes the
// sidebar off screen and wraps the frame.
func composeStatusRow(left, right string, width int) string {
if width <= 0 {
return ""
}
const leading = 1 // the row is indented one column, like the panels above it
// A terminal can be narrower than the hint itself. Drop the hint rather than
// keep it at the cost of the status, which is the part carrying information;
// ctrl-q works whether or not the row has room to say so.
if lipgloss.Width(right) > 0 && width < lipgloss.Width(right)+leading+2 {
right = ""
}
separator := 0
if lipgloss.Width(right) > 0 {
separator = 1
}
left = truncate(left, max(0, width-leading-lipgloss.Width(right)-separator))
padding := max(0, width-leading-lipgloss.Width(left)-lipgloss.Width(right))
return " " + left + strings.Repeat(" ", padding) + right
}
// statusMessage fits a message and its trailing hint on the one status row. A
// model or backend error can be a wrapped exception several lines long, so it is
// flattened to a single line and clipped, leaving the hint readable.
func statusMessage(message string, color lipgloss.Color, hint string, width int) string {
styledHint := lipgloss.NewStyle().Foreground(dim).Render(hint)
room := max(1, width-2-lipgloss.Width(styledHint))
flat := truncate(flattenStatus(message), room)
return lipgloss.NewStyle().Foreground(color).Render(flat) + styledHint
}
// flattenStatus turns a multi-line message into one line, collapsing the runs of
// whitespace that joining its lines leaves behind.
func flattenStatus(message string) string {
message = strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ", "\t", " ").Replace(message)
return strings.Join(strings.Fields(message), " ")
}
func (m Model) sweepView() string {
palette := []lipgloss.Color{
black, lipgloss.Color("#031a09"), lipgloss.Color("#052e16"), lipgloss.Color("#0d4a2a"),
lipgloss.Color("#15803d"), green, brightGreen, lipgloss.Color("#86efac"),
}
const numSquares = 6
numColors := len(palette)
offset := numColors - 1
maxPos := (numSquares - 1) + offset
totalRange := maxPos + offset
cycleLength := totalRange * 2
frameInCycle := m.sweepFrame % cycleLength
wavePos := totalRange - abs(totalRange-frameInCycle)
sweepPos := wavePos - offset
dotColor := lipgloss.Color("#0a3d1f")
var b strings.Builder
for i := 0; i < numSquares; i++ {
dist := abs(i - sweepPos)
colorIdx := numColors - 1 - dist
if colorIdx <= 0 {
b.WriteString(lipgloss.NewStyle().Foreground(dotColor).Render("·"))
} else {
b.WriteString(lipgloss.NewStyle().Foreground(palette[colorIdx]).Render("▪"))
}
}
b.WriteString(" ")
return b.String()
}
func abs(x int) int {
if x < 0 {
return -x
}
return x
}
func titleCase(s string) string {
return strings.Title(strings.ToLower(s))
}
// overlay composites a centered dialog on top of the live main view. When
// dimmed is true (vulnerability detail, background: #000000 80%) the backdrop is
// recolored to a dark grey; otherwise it is left untouched to match Textual's
// transparent modal backdrop (background: $background 0%).
func (m Model) overlay(background, foreground string, dimmed bool) string {
bg := strings.Split(background, "\n")
fg := strings.Split(foreground, "\n")
dialogHeight := len(fg)
dialogWidth := lipgloss.Width(foreground)
top := max(0, (m.height-dialogHeight)/2)
left := max(0, (m.width-dialogWidth)/2)
dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#3f3f46"))
for row := 0; row < len(bg); row++ {
if row < top || row >= top+dialogHeight {
if dimmed {
bg[row] = dimStyle.Render(ansi.Strip(bg[row]))
}
continue
}
fgLine := fg[row-top]
rightStart := left + dialogWidth
var leftPart, rightPart string
if dimmed {
bgLine := ansi.Strip(bg[row])
leftPart = dimStyle.Render(truncateToWidth(bgLine, left))
if lipgloss.Width(bgLine) > rightStart {
rightPart = dimStyle.Render(ansi.TruncateLeft(bgLine, rightStart, ""))
}
} else {
// Preserve the original styling of the visible backdrop segments.
leftPart = padToWidth(ansi.Truncate(bg[row], left, ""), left)
if lipgloss.Width(bg[row]) > rightStart {
rightPart = ansi.TruncateLeft(bg[row], rightStart, "")
}
}
bg[row] = leftPart + fgLine + rightPart
}
return strings.Join(bg, "\n")
}
// padToWidth right-pads an ANSI string to an exact display width.
func padToWidth(value string, width int) string {
w := lipgloss.Width(value)
if w >= width {
return value
}
return value + strings.Repeat(" ", width-w)
}
func truncateToWidth(value string, width int) string {
if width <= 0 {
return ""
}
if lipgloss.Width(value) <= width {
return value + strings.Repeat(" ", width-lipgloss.Width(value))
}
return ansi.Truncate(value, width, "")
}