mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 09:26:39 +02:00
825 lines
28 KiB
Go
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, "")
|
|
}
|