package app import ( "fmt" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" "github.com/usestrix/strix/tui/internal/render" ) var panelSeverityColors = map[string]lipgloss.Color{ "critical": render.SevCrit, "high": render.SevHigh, "medium": render.SevMed, "low": green, "info": blue, } // vulnerabilityRow is one rendered line of the findings list. The list scrolls by // row rather than by finding, so a long title does not make the panel jump a // whole entry at a time. type vulnerabilityRow struct { index int // the finding this line belongs to text string // one wrapped line of its title first bool // the line that carries the number and the severity dot } // vulnerabilityRows lays every finding out as the lines it will occupy. func (m Model) vulnerabilityRows(width int) []vulnerabilityRow { // Wrapped lines sit under the title rather than under the severity dot. body := max(1, width-2) rows := make([]vulnerabilityRow, 0, len(m.snapshot.Vulnerabilities)) for i := range m.snapshot.Vulnerabilities { for line, text := range strings.Split(wrapBlock(m.vulnerabilityTitle(i), body), "\n") { rows = append(rows, vulnerabilityRow{index: i, text: text, first: line == 0}) } } return rows } func (m Model) vulnerabilitiesView(width, height int) string { rows := m.vulnerabilityRows(width) start := min(max(0, m.vulnOffset), max(0, len(rows)-1)) end := min(len(rows), start+height) lines := make([]string, 0, max(0, end-start)) for _, row := range rows[start:end] { style := lipgloss.NewStyle().Foreground(textColor) if row.index == m.selectedVuln { style = style.Bold(true).Foreground(white) } prefix := " " if row.first { severity := strings.ToLower(render.StringValue(m.snapshot.Vulnerabilities[row.index]["severity"])) color, ok := panelSeverityColors[severity] if !ok { color = blue // matches SEVERITY_COLORS.get(severity, "#3b82f6") } prefix = lipgloss.NewStyle().Foreground(color).Render("ā— ") } lines = append(lines, prefix+style.Render(row.text)) } return strings.Join(lines, "\n") } // vulnerabilityListWidth is the one width the findings list is laid out at, for // rendering and for every interaction alike. Wrapping a title at two widths a // column apart gives two different row counts, and then a click resolves to the // wrong finding and the scrollbar reports the wrong length. // // The panel is sidebarWidth-2 wide with a column of padding either side, and the // scrollbar takes one more. That last column is reserved whether or not the bar // is showing, so the layout does not shift as the list grows past the panel. func (m Model) vulnerabilityListWidth() int { _, sidebarWidth, _, _ := m.layout() return max(1, sidebarWidth-5) } func (m Model) vulnerabilityTitle(index int) string { title := render.StringValue(m.snapshot.Vulnerabilities[index]["title"]) if title == "" { title = "Unknown Vulnerability" } return title } // vulnerabilityScrollRows reports the list length and position in rows, which is // what the scrollbar needs to move continuously. func (m Model) vulnerabilityScrollRows() (total, offset int) { return len(m.vulnerabilityRows(m.vulnerabilityListWidth())), m.vulnOffset } // vulnerabilityIndexAtRow maps a click on a visible row back to its finding. func (m Model) vulnerabilityIndexAtRow(row int) int { rows := m.vulnerabilityRows(m.vulnerabilityListWidth()) target := m.vulnOffset + row if target < 0 || target >= len(rows) { return -1 } return rows[target].index } // ensureVulnerabilityVisible scrolls the least it can to bring the selected // finding into view, keeping the whole entry visible where it fits. func (m *Model) ensureVulnerabilityVisible() { rows := m.vulnerabilityRows(m.vulnerabilityListWidth()) if len(rows) == 0 { m.vulnOffset = 0 return } height := m.vulnerabilityPageSize() firstRow, lastRow := -1, -1 for row, entry := range rows { if entry.index != m.selectedVuln { continue } if firstRow < 0 { firstRow = row } lastRow = row } if firstRow < 0 { m.vulnOffset = clampVulnerabilityOffset(m.vulnOffset, len(rows), height) return } if firstRow < m.vulnOffset { m.vulnOffset = firstRow } else if lastRow >= m.vulnOffset+height { // Prefer showing the whole entry, but never scroll its start out of view. m.vulnOffset = min(firstRow, lastRow-height+1) } m.vulnOffset = clampVulnerabilityOffset(m.vulnOffset, len(rows), height) } func clampVulnerabilityOffset(offset, total, height int) int { return min(max(0, offset), max(0, total-height)) } func (m Model) vulnerabilityPageSize() int { _, vulnHeight, _ := m.sidebarHeights() return max(1, vulnHeight-2) } // vulnerabilityPageItems is how many findings a page step should move by: the // number of distinct entries currently on screen. func (m Model) vulnerabilityPageItems() int { rows := m.vulnerabilityRows(m.vulnerabilityListWidth()) height := m.vulnerabilityPageSize() start := min(max(0, m.vulnOffset), max(0, len(rows))) end := min(len(rows), start+height) seen := 0 previous := -1 for _, row := range rows[start:end] { if row.index != previous { seen++ previous = row.index } } return max(1, seen) } func (m *Model) moveVulnerabilitySelection(delta int) { m.selectedVuln = max(0, min(len(m.snapshot.Vulnerabilities)-1, m.selectedVuln+delta)) } // keepVulnerabilitySelectionInWindow pulls the selection to the nearest finding // still on screen after the list has been scrolled directly. func (m *Model) keepVulnerabilitySelectionInWindow() { rows := m.vulnerabilityRows(m.vulnerabilityListWidth()) if len(rows) == 0 { return } height := m.vulnerabilityPageSize() start := min(max(0, m.vulnOffset), max(0, len(rows)-1)) end := min(len(rows), start+height) visible := rows[start:end] if len(visible) == 0 { return } for _, row := range visible { if row.index == m.selectedVuln { return } } if m.selectedVuln < visible[0].index { m.selectedVuln = visible[0].index return } m.selectedVuln = visible[len(visible)-1].index } // statsView ports build_tui_stats_text + the version line appended in // _update_stats_display: model, token/cost line, optional Caido URL, version. func (m Model) modalView() string { switch m.modal { case modalHelp: title := lipgloss.NewStyle().Bold(true).Foreground(green).Width(34).Align(lipgloss.Center).Render("Strix Help") body := lipgloss.NewStyle().Foreground(textColor).Render("F1 Help\nCtrl+O Open viewer\nCtrl+Q/C Quit\nESC Stop Agent\nEnter Send / expand node\nCtrl+J Newline in message\nTab Switch panels\n↑/↓ Navigate tree\nDrag Select & copy text\nClick Expand/collapse tool") content := title + "\n\n" + body return lipgloss.NewStyle().Width(38).Border(lipgloss.RoundedBorder()).BorderForeground(green).Background(black).Padding(1, 2).Render(content) case modalQuit: // #quit_dialog: width 24, border round #333333, title #d4d4d4. return m.confirmView("Quit Strix?", 24, dark, textColor) case modalStop: name := "agent" if len(m.snapshot.Agents) > 0 { name = m.snapshot.Agents[m.selectedAgent].Name } // #stop_agent_dialog: width 30, border round #a3a3a3, title #a3a3a3. return m.confirmView("šŸ›‘ Stop '"+name+"'?", 30, mid, mid) case modalConfirmMount: return m.mountConfirmView() case modalVulnerability: if len(m.snapshot.Vulnerabilities) == 0 { return "" } return m.vulnerabilityDetail() } return "" } func (m Model) confirmView(title string, width int, border, titleColor lipgloss.Color) string { return m.confirmDialog(title, "", width, border, titleColor, red, "Yes", "No") } // mountConfirmView asks before a target-less scan mounts the working directory. // It is a compact prompt docked in the corner of the live view: nothing is // prepared until it is answered, and the directory is a workspace rather than a // target, so the prompt is what the scan follows. func (m Model) mountConfirmView() string { width := min(52, max(20, m.width-4)) dir := strings.TrimSpace(m.snapshot.PendingMount) if dir == "" { dir = "the current directory" } title := render.Bold(amber).Render("ā–³ Mount working directory?") body := render.Col(white).Render(truncatePath(dir, width-4)) + "\n" + render.Dim().Render("writable in the sandbox") return m.cornerPrompt(title, body, width, "Confirm", "Cancel") } // truncatePath keeps the tail of a path visible, which is the part that // identifies the directory. func truncatePath(path string, width int) string { if width <= 1 || lipgloss.Width(path) <= width { return path } return "…" + ansi.TruncateLeft(path, lipgloss.Width(path)-width+1, "") } // cornerPrompt renders a compact two-button prompt for the corner of the live // view, sized to its content rather than centered like the modal dialogs. func (m Model) cornerPrompt(title, body string, width int, confirmLabel, cancelLabel string) string { // Each label keeps its padding whether or not it is focused, so moving the // choice repaints a background instead of shifting the pair sideways. button := func(label string, focused bool, fill lipgloss.Color) string { style := lipgloss.NewStyle().Bold(true) if focused { return style.Background(fill).Foreground(brightWhite).Render(" " + label + " ") } return style.Foreground(fill).Render(" " + label + " ") } yes := button(confirmLabel, m.modalChoice == 0, amber) no := button(cancelLabel, m.modalChoice != 0, dim) if m.modalChoice != 0 { no = button(cancelLabel, true, lipgloss.Color("#3e3e3e")) } inner := lipgloss.NewStyle().Width(width - 4) content := inner.Render(title) + "\n" + inner.Render(body) + "\n" + inner.Align(lipgloss.Right).Render(yes+" "+no) return lipgloss.NewStyle().Width(width-2).Border(lipgloss.RoundedBorder()). BorderForeground(amber).Background(black).Padding(0, 1).Render(content) } // confirmDialog renders a two-button prompt. The focused button fills its // background; body is optional detail shown between the title and the buttons. func (m Model) confirmDialog( title, body string, width int, border, titleColor, confirmColor lipgloss.Color, confirmLabel, cancelLabel string, ) string { // Two equal columns with a one-cell gutter. The buttons keep their columns // whichever one is focused, so moving the choice repaints a background // instead of shifting the row. contentWidth := width - 4 inner := lipgloss.NewStyle().Width(contentWidth) // Two columns share the content width with a one-cell gutter; the label // carries a space on each side before it is centered in its column. leftColumn := (contentWidth - 1) / 2 rightColumn := contentWidth - 1 - leftColumn button := func(label string, column int, focused bool, fill lipgloss.Color) string { style := lipgloss.NewStyle().Width(column).Align(lipgloss.Center).Bold(true) if focused { return style.Background(fill).Foreground(brightWhite).Render(" " + label + " ") } return style.Foreground(fill).Render(" " + label + " ") } yes := button(confirmLabel, leftColumn, m.modalChoice == 0, confirmColor) no := button(cancelLabel, rightColumn, false, dim) if m.modalChoice != 0 { no = button(cancelLabel, rightColumn, true, lipgloss.Color("#3e3e3e")) } content := inner.Bold(true).Foreground(titleColor).Align(lipgloss.Center).Render(title) if body != "" { content += "\n\n" + inner.Render(body) } content += "\n\n" + inner.Align(lipgloss.Center).Render(yes+" "+no) // Width() sets the content box, so the border's two columns come off it to // keep the dialog the width the design calls for. return lipgloss.NewStyle().Width(width - 2).Border(lipgloss.RoundedBorder()).BorderForeground(border).Background(black).Padding(1).Render(content) } // vulnerabilityBody ports VulnerabilityDetailScreen._render_vulnerability: // the exact field order, labels, colors, and dict keys. func vulnerabilityBody(v map[string]any) string { fieldStyle := render.Bold(render.Field) var b strings.Builder b.WriteString("šŸž " + render.Bold(render.ReportHdr).Render("Vulnerability Report")) field := func(label, value string) { if value != "" { b.WriteString("\n\n" + fieldStyle.Render(label+": ") + value) } } field("Agent", render.StringValue(v["agent_name"])) field("Title", render.StringValue(v["title"])) if sev := render.StringValue(v["severity"]); sev != "" { b.WriteString("\n\n" + fieldStyle.Render("Severity: ") + lipgloss.NewStyle().Bold(true).Foreground(render.SeverityColor(sev)).Render(strings.ToUpper(sev))) } if score, ok := render.NumericValue(v["cvss"]); ok { b.WriteString("\n\n" + fieldStyle.Render("CVSS Score: ") + lipgloss.NewStyle().Bold(true).Foreground(render.CVSSColor(score)).Render(render.StringValue(v["cvss"]))) } field("Target", render.StringValue(v["target"])) if dep, ok := v["dependency_metadata"].(map[string]any); ok { field("Package", render.StringValue(dep["package_name"])) field("Ecosystem", render.StringValue(dep["package_ecosystem"])) field("Installed Version", render.StringValue(dep["installed_version"])) field("Fixed Version", render.StringValue(dep["fixed_version"])) field("Introduced By", render.StringValue(dep["introduced_by"])) field("Dependency Chain", render.StringValue(dep["dependency_path"])) } field("Endpoint", render.StringValue(v["endpoint"])) field("Method", render.StringValue(v["method"])) field("CVE", render.StringValue(v["cve"])) field("CWE", render.StringValue(v["cwe"])) if fe := render.StringValue(v["fix_effort"]); fe != "" { field("Fix Effort", titleCase(fe)) } if bd, ok := v["cvss_breakdown"].(map[string]any); ok && len(bd) > 0 { if parts := render.CVSSVectorParts(bd); len(parts) > 0 { b.WriteString("\n\n" + fieldStyle.Render("CVSS Vector: ") + render.Dim().Render(strings.Join(parts, "/"))) } } section := func(label, value string) { if value != "" { b.WriteString("\n\n" + fieldStyle.Render(label) + "\n" + value) } } section("Description", render.StringValue(v["description"])) section("Impact", render.StringValue(v["impact"])) section("Technical Analysis", render.StringValue(v["technical_analysis"])) section("Evidence", render.StringValue(v["evidence"])) section("PoC Description", render.StringValue(v["poc_description"])) if poc := render.StringValue(v["poc_script_code"]); poc != "" { pocLang, pocCode := render.ParseFencedCode(poc) b.WriteString("\n\n" + fieldStyle.Render("PoC Code") + "\n" + render.HighlightCode(pocCode, pocLang)) } section("Remediation", render.StringValue(v["remediation_steps"])) section("Assumptions", render.StringValue(v["assumptions"])) return b.String() } func (m Model) vulnerabilityDialogSize() (width, height int) { return min(m.width, min(110, max(40, m.width*85/100))), min(m.height, min(45, max(10, m.height*85/100))) } func (m *Model) resizeVulnerabilityViewport() { if m.modal != modalVulnerability || len(m.snapshot.Vulnerabilities) == 0 { return } width, height := m.vulnerabilityDialogSize() innerWidth := max(1, width-8) // border plus three cells of horizontal padding m.vulnViewport.Width = max(1, innerWidth-2) // right padding and one-cell scrollbar m.vulnViewport.Height = max(1, height-9) // padding, one-row grid gutter, and two-row footer m.vulnViewport.SetContent(wrapBlock(vulnerabilityBody(m.snapshot.Vulnerabilities[m.selectedVuln]), m.vulnViewport.Width)) m.vulnViewport.SetYOffset(m.vulnViewport.YOffset) } func (m Model) vulnerabilityScrollView() string { view := m.vulnViewport.View() if m.vulnViewport.TotalLineCount() <= m.vulnViewport.VisibleLineCount() { return view + " " } height := m.vulnViewport.Height thumbHeight := max(1, height*m.vulnViewport.VisibleLineCount()/m.vulnViewport.TotalLineCount()) thumbStart := int(m.vulnViewport.ScrollPercent() * float64(height-thumbHeight)) bar := make([]string, height) for row := range bar { cell := " " if row >= thumbStart && row < thumbStart+thumbHeight { cell = lipgloss.NewStyle().Foreground(lipgloss.Color("#404040")).Render("ā–ˆ") } bar[row] = cell } return lipgloss.JoinHorizontal(lipgloss.Top, view, " ", strings.Join(bar, "\n")) } func (m Model) vulnerabilityDetail() string { width, height := m.vulnerabilityDialogSize() inner := max(1, width-8) // Button row: right-aligned Copy / Done above a top rule (#vuln_detail_buttons). rule := lipgloss.NewStyle().Foreground(lipgloss.Color("#1a1a1a")).Render(strings.Repeat("─", max(1, inner))) focused := m.focusedReportButton() var stepping, acting []string for _, button := range m.reportButtons() { rendered := m.reportButton(button, button == focused) if button == reportPrev || button == reportNext { stepping = append(stepping, rendered) continue } acting = append(acting, rendered) } // Stepping sits on the left behind the position, acting on the right. right := strings.Join(acting, " ") left := strings.Join(stepping, " ") if total := len(m.snapshot.Vulnerabilities); total > 1 { left = render.Dim().Render(fmt.Sprintf("%d/%d", m.selectedVuln+1, total)) + " " + left } room := max(0, inner-lipgloss.Width(right)) buttonRow := rule + "\n" + lipgloss.NewStyle().Width(room).Render(truncate(left, room)) + right content := m.vulnerabilityScrollView() + "\n" + buttonRow return lipgloss.NewStyle().Width(width-2).Height(height-2).Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("#262626")).Background(lipgloss.Color("#0a0a0a")).Padding(2, 3).Render(content) } // showVulnerability moves the open report to another finding, keeping the list // behind it in step and starting the new report at its top. func (m *Model) showVulnerability(index int) { if index < 0 || index >= len(m.snapshot.Vulnerabilities) || index == m.selectedVuln { return } m.selectedVuln = index m.ensureVulnerabilityVisible() // The copy state belongs to the report that was on screen, not this one. m.vulnerabilityCopied = false m.vulnerabilityCopyError = "" m.resizeVulnerabilityViewport() m.vulnViewport.GotoTop() } // The report's buttons. Prev and Next carry their arrows so a click test cannot // be fooled by the same word appearing in the body of a finding. const ( reportPrev = "‹ Prev" reportNext = "Next ›" reportCopy = "Copy" reportDone = "Done" ) // reportButtons is the row as it stands, left to right. Stepping is offered only // in the directions that have a report. func (m Model) reportButtons() []string { previous, next := m.vulnerabilityNeighbors() buttons := make([]string, 0, 4) if previous { buttons = append(buttons, reportPrev) } if next { buttons = append(buttons, reportNext) } return append(buttons, reportCopy, reportDone) } // focusedReportButton is the button Enter would press. It falls back to Done when // the focused one has gone, which happens when stepping to either end drops a // direction from the row. func (m Model) focusedReportButton() string { for _, button := range m.reportButtons() { if button == m.reportFocus { return button } } return reportDone } // stepReportFocus moves along the row, wrapping at its ends. func (m *Model) stepReportFocus(delta int) { buttons := m.reportButtons() current := 0 for i, button := range buttons { if button == m.focusedReportButton() { current = i } } m.reportFocus = buttons[clampCycle(current+delta, len(buttons))] } // vulnerabilityNeighbors reports which way the open report can be stepped. The // ends are not wrapped: a report is one of an ordered list, and rolling from the // last to the first hides that you reached the end. func (m Model) vulnerabilityNeighbors() (previous, next bool) { return m.selectedVuln > 0, m.selectedVuln < len(m.snapshot.Vulnerabilities)-1 } // reportButton renders one button of the report row. Copy reports the outcome of // the last attempt in its own label. func (m Model) reportButton(label string, focused bool) string { if label == reportCopy { switch { case m.vulnerabilityCopied: label = "Copied!" case m.vulnerabilityCopyError != "": label = "Copy failed" } } if focused { return lipgloss.NewStyle().Background(lipgloss.Color("#363636")). Foreground(brightWhite).Bold(true).Padding(0, 1).Render(label) } return lipgloss.NewStyle().Foreground(lipgloss.Color("#525252")).Render(label) } func (m *Model) startVulnerabilityCopy() tea.Cmd { m.vulnerabilityCopied = false m.vulnerabilityCopyError = "" if m.selectedVuln < 0 || m.selectedVuln >= len(m.snapshot.Vulnerabilities) { return nil } report := vulnerabilityMarkdownReport(m.snapshot.Vulnerabilities[m.selectedVuln]) return func() tea.Msg { return vulnerabilityCopiedMsg{err: writeClipboard(report)} } } // titleCase upper-cases the first letter of each word (Python str.title()).