mirror of
https://github.com/usestrix/strix.git
synced 2026-08-19 18:13:34 +02:00
411 lines
12 KiB
Go
411 lines
12 KiB
Go
package app
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/atotto/clipboard"
|
|
"github.com/charmbracelet/bubbles/key"
|
|
"github.com/charmbracelet/bubbles/textarea"
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/charmbracelet/x/ansi"
|
|
"github.com/usestrix/strix/tui/internal/protocol"
|
|
)
|
|
|
|
type wireMsg protocol.Envelope
|
|
type wireErrMsg struct{ err error }
|
|
type sentMsg struct {
|
|
requestID string
|
|
command string
|
|
collection string
|
|
err error
|
|
}
|
|
type splashTickMsg time.Time
|
|
type sweepTickMsg time.Time
|
|
type vulnerabilityCopiedMsg struct{ err error }
|
|
|
|
var writeClipboard = clipboard.WriteAll
|
|
|
|
type collectionAssembly struct {
|
|
kind string
|
|
revision int
|
|
baseRevision int
|
|
cursor int
|
|
agents []protocol.Agent
|
|
events []protocol.Event
|
|
findings []map[string]any
|
|
operations []protocol.CollectionOperation
|
|
ids map[string]bool
|
|
}
|
|
|
|
// appVersion is the package version string shown on the splash and stats panel.
|
|
// It is set by main from the STRIX_VERSION env var (see go_tui.py), matching
|
|
// Python's get_package_version() which reads the installed "strix-agent" version
|
|
// and falls back to "dev".
|
|
var appVersion = "dev"
|
|
|
|
// SetVersion overrides the displayed version; empty values are ignored so the
|
|
// "dev" fallback survives when the launcher does not provide one.
|
|
func SetVersion(v string) {
|
|
if strings.TrimSpace(v) != "" {
|
|
appVersion = strings.TrimSpace(v)
|
|
}
|
|
}
|
|
|
|
type modalMode int
|
|
|
|
const (
|
|
modalNone modalMode = iota
|
|
modalHelp
|
|
modalQuit
|
|
modalStop
|
|
modalConfirmMount
|
|
modalVulnerability
|
|
)
|
|
|
|
type focusMode int
|
|
|
|
const (
|
|
focusInput focusMode = iota
|
|
focusChat
|
|
focusAgents
|
|
focusVulnerabilities
|
|
)
|
|
|
|
type scrollbarTarget int
|
|
|
|
const (
|
|
scrollbarNone scrollbarTarget = iota
|
|
scrollbarTrace
|
|
scrollbarAgents
|
|
scrollbarFindings
|
|
)
|
|
|
|
type Model struct {
|
|
client *Client
|
|
width, height int
|
|
snapshot protocol.Snapshot
|
|
input textarea.Model
|
|
viewport viewport.Model
|
|
viewportContent string
|
|
vulnViewport viewport.Model
|
|
modal modalMode
|
|
focus focusMode
|
|
options []string
|
|
filtered []string
|
|
cursor int
|
|
collapsedAgents map[string]bool
|
|
expandedEvents map[string]bool
|
|
blockCache map[string]renderedBlock
|
|
eventSpans []eventSpan
|
|
setupLog []string
|
|
pendingPrompt string
|
|
errorText string
|
|
fatalError error
|
|
selectedAgent int
|
|
selectedVuln int
|
|
agentOffset int
|
|
vulnOffset int
|
|
modalChoice int
|
|
reportFocus string
|
|
ready bool
|
|
quitting bool
|
|
showSplash bool
|
|
splashStarted time.Time
|
|
splashFrame int
|
|
sweepFrame int
|
|
budgetPauseNotified bool
|
|
followOutput bool
|
|
selection selectionState
|
|
toast string
|
|
toastID int
|
|
draggingScrollbar scrollbarTarget
|
|
stateRevision int
|
|
collectionRevisions map[string]int
|
|
collectionAssemblies map[string]*collectionAssembly
|
|
resyncRequested map[string]bool
|
|
resyncRequests map[string]string
|
|
seenMessages map[string]bool
|
|
vulnerabilityCopied bool
|
|
vulnerabilityCopyError string
|
|
}
|
|
|
|
var (
|
|
green = lipgloss.Color("#22c55e")
|
|
brightGreen = lipgloss.Color("#4ade80")
|
|
blue = lipgloss.Color("#3b82f6")
|
|
lightBlue = lipgloss.Color("#60a5fa")
|
|
red = lipgloss.Color("#ef4444")
|
|
orange = lipgloss.Color("#ea580c")
|
|
amber = lipgloss.Color("#d97706")
|
|
white = lipgloss.Color("#fafaf9")
|
|
brightWhite = lipgloss.Color("#ffffff")
|
|
textColor = lipgloss.Color("#d4d4d4")
|
|
dim = lipgloss.Color("#737373")
|
|
mid = lipgloss.Color("#a3a3a3")
|
|
dark = lipgloss.Color("#333333")
|
|
black = lipgloss.Color("#000000")
|
|
)
|
|
|
|
// Agent tree colors: a uniform label, dim guides, and a filled block cursor.
|
|
const (
|
|
treeLabel = lipgloss.Color("#e7e5e4")
|
|
treeGuide = lipgloss.Color("#4f4f4f")
|
|
treeCursorFg = lipgloss.Color("#ddedf9")
|
|
treeCursorBg = lipgloss.Color("#0178d4")
|
|
)
|
|
|
|
// Scrollbar thumbs. The track stays blank so a scrollable panel does not gain a
|
|
// visible rule down its edge, and the thumb brightens while it is dragged, which
|
|
// is the feedback Textual gave through scrollbar-color-active.
|
|
//
|
|
// One resting color for every panel, rather than the three the stylesheet named.
|
|
// The chat pane's was #1a1a1a on black, which is invisible - the bar could not be
|
|
// found, let alone grabbed (#1005).
|
|
const (
|
|
thumbResting = lipgloss.Color("#3f3f46")
|
|
thumbActive = lipgloss.Color("#9ca3af")
|
|
)
|
|
|
|
// Composer placeholders. The launch screen falls back to the short prompt when
|
|
// the column is too narrow to show the full one without clipping it.
|
|
const (
|
|
setupPlaceholder = "Describe what to test, or name a target"
|
|
setupPlaceholderShort = "What should Strix test?"
|
|
chatPlaceholder = "Send a message"
|
|
)
|
|
|
|
// The composer opens at minInputLines rows for breathing room and grows with
|
|
// its content up to maxInputLines.
|
|
const (
|
|
minInputLines = 3
|
|
maxInputLines = 8
|
|
)
|
|
|
|
// newChatInput builds the multi-line chat composer. Enter submits (handled by
|
|
// the update loop before the textarea sees it); Shift/Alt+Enter and Ctrl+J
|
|
// insert a newline.
|
|
func newChatInput() textarea.Model {
|
|
input := textarea.New()
|
|
input.ShowLineNumbers = false
|
|
input.CharLimit = 4096
|
|
input.MaxHeight = maxInputLines
|
|
input.SetHeight(1)
|
|
input.KeyMap.InsertNewline = key.NewBinding(
|
|
key.WithKeys("shift+enter", "alt+enter", "ctrl+j"),
|
|
key.WithHelp("shift+enter", "insert newline"),
|
|
)
|
|
plain := lipgloss.NewStyle()
|
|
text := lipgloss.NewStyle().Foreground(textColor)
|
|
placeholder := lipgloss.NewStyle().Foreground(lipgloss.Color("#525252"))
|
|
for _, style := range []*textarea.Style{&input.FocusedStyle, &input.BlurredStyle} {
|
|
style.Base = plain
|
|
style.CursorLine = text
|
|
style.EndOfBuffer = plain
|
|
style.Placeholder = placeholder
|
|
style.Text = text
|
|
}
|
|
input.FocusedStyle.Prompt = lipgloss.NewStyle().Bold(true).Foreground(green)
|
|
input.BlurredStyle.Prompt = lipgloss.NewStyle().Foreground(dim)
|
|
input.SetPromptFunc(2, func(lineIdx int) string {
|
|
if lineIdx == 0 {
|
|
return "> "
|
|
}
|
|
return " "
|
|
})
|
|
input.Cursor.Style = lipgloss.NewStyle().Foreground(green)
|
|
return input
|
|
}
|
|
|
|
// composerBounds returns the floor and ceiling row counts for the composer at
|
|
// the current terminal height. A short terminal shrinks the ceiling so a long
|
|
// prompt cannot crowd out everything above it.
|
|
//
|
|
// Only the launch screen opens taller than a single row: there the composer is
|
|
// the whole screen and wants breathing room, while during a scan it sits under
|
|
// the trace and stays out of the way until there is something to show.
|
|
func (m Model) composerBounds() (floor, ceiling int) {
|
|
ceiling = maxInputLines
|
|
if m.height > 0 {
|
|
ceiling = max(minInputLines, min(maxInputLines, m.height/3))
|
|
}
|
|
floor = 1
|
|
if m.snapshot.SetupMode {
|
|
floor = min(minInputLines, ceiling)
|
|
}
|
|
return floor, ceiling
|
|
}
|
|
|
|
// syncInputHeight grows or shrinks the composer with its content, between the
|
|
// floor and ceiling.
|
|
func (m *Model) syncInputHeight() {
|
|
floor, ceiling := m.composerBounds()
|
|
m.input.SetHeight(max(floor, min(composerHeight(m.input), ceiling)))
|
|
}
|
|
|
|
// composerHeight is how many rows the composer needs to show all of its
|
|
// content, capped at maxInputLines. Soft-wrapped rows count: a single long
|
|
// line still grows the box. LineCount only counts hard newlines, and the
|
|
// wrapped height the textarea does report covers just the line the cursor is
|
|
// on, so a scratch copy measures each line with the composer's own wrapping.
|
|
func composerHeight(input textarea.Model) int {
|
|
probe, rows := input, 0
|
|
for _, line := range strings.Split(input.Value(), "\n") {
|
|
// A line narrower than the text column cannot wrap, which is the case
|
|
// for nearly every keystroke; only measure the ones that might.
|
|
if ansi.StringWidth(line) < input.Width() {
|
|
rows++
|
|
} else {
|
|
probe.SetValue(line)
|
|
rows += probe.LineInfo().Height
|
|
}
|
|
if rows >= maxInputLines {
|
|
return maxInputLines
|
|
}
|
|
}
|
|
return max(1, rows)
|
|
}
|
|
|
|
func New(client *Client) Model {
|
|
input := newChatInput()
|
|
input.Placeholder = setupPlaceholder
|
|
input.Focus()
|
|
return Model{
|
|
client: client, input: input, viewport: viewport.New(80, 20), vulnViewport: viewport.New(80, 20),
|
|
collapsedAgents: map[string]bool{}, expandedEvents: map[string]bool{}, blockCache: map[string]renderedBlock{}, showSplash: true, splashStarted: time.Now(), followOutput: true,
|
|
collectionRevisions: map[string]int{}, collectionAssemblies: map[string]*collectionAssembly{}, resyncRequested: map[string]bool{}, resyncRequests: map[string]string{},
|
|
seenMessages: map[string]bool{},
|
|
}
|
|
}
|
|
|
|
func (m Model) Init() tea.Cmd { return tea.Batch(readWire(m.client), splashTick(), sweepTick()) }
|
|
|
|
// splashTick drives the splash "Starting Strix Agent" shimmer at Python's 0.1s cadence.
|
|
func splashTick() tea.Cmd {
|
|
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { return splashTickMsg(t) })
|
|
}
|
|
|
|
// sweepTick drives the running-status sweep animation at Python's 0.06s cadence.
|
|
func sweepTick() tea.Cmd {
|
|
return tea.Tick(60*time.Millisecond, func(t time.Time) tea.Msg { return sweepTickMsg(t) })
|
|
}
|
|
|
|
func readWire(client *Client) tea.Cmd {
|
|
return func() tea.Msg {
|
|
envelope, err := client.Read()
|
|
if err != nil {
|
|
return wireErrMsg{err}
|
|
}
|
|
return wireMsg(envelope)
|
|
}
|
|
}
|
|
|
|
func send(client *Client, command string, payload any) tea.Cmd {
|
|
return func() tea.Msg {
|
|
requestID, err := client.Send(command, payload)
|
|
collection := ""
|
|
if values, ok := payload.(map[string]any); ok {
|
|
collection, _ = values["collection"].(string)
|
|
}
|
|
return sentMsg{requestID: requestID, command: command, collection: collection, err: err}
|
|
}
|
|
}
|
|
|
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
var cmds []tea.Cmd
|
|
switch msg := msg.(type) {
|
|
case splashTickMsg:
|
|
m.splashFrame++
|
|
if m.showSplash && time.Since(m.splashStarted) >= 4500*time.Millisecond {
|
|
m.showSplash = false
|
|
}
|
|
return m, splashTick()
|
|
case sweepTickMsg:
|
|
m.sweepFrame++
|
|
return m, sweepTick()
|
|
case tea.WindowSizeMsg:
|
|
m.width, m.height = msg.Width, msg.Height
|
|
m.resizeViewport()
|
|
m.resizeVulnerabilityViewport()
|
|
m.ensureAgentVisible()
|
|
m.ensureVulnerabilityVisible()
|
|
case wireErrMsg:
|
|
if !m.quitting {
|
|
m.errorText = "Backend disconnected: " + msg.err.Error()
|
|
m.fatalError = fmt.Errorf("backend disconnected: %w", msg.err)
|
|
}
|
|
return m, tea.Quit
|
|
case wireMsg:
|
|
envelope := protocol.Envelope(msg)
|
|
if envelope.Version != protocol.Version {
|
|
m.errorText = fmt.Sprintf("Protocol mismatch: backend=%d client=%d", envelope.Version, protocol.Version)
|
|
m.fatalError = fmt.Errorf("protocol mismatch: backend=%d client=%d", envelope.Version, protocol.Version)
|
|
return m, tea.Quit
|
|
}
|
|
if cmd := m.handleEnvelope(envelope); cmd != nil {
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
cmds = append(cmds, readWire(m.client))
|
|
case sentMsg:
|
|
if msg.err != nil {
|
|
m.errorText = msg.err.Error()
|
|
if msg.command == "collection.resync" && msg.collection != "" {
|
|
m.resyncRequested[msg.collection] = false
|
|
}
|
|
} else if msg.command == "collection.resync" && msg.requestID != "" && msg.collection != "" {
|
|
m.resyncRequests[msg.requestID] = msg.collection
|
|
}
|
|
case selectionCopiedMsg:
|
|
text := "Copied to clipboard"
|
|
if msg.err != nil {
|
|
text = "Copy failed: " + msg.err.Error()
|
|
}
|
|
return m, m.showToast(text)
|
|
case toastExpiredMsg:
|
|
if msg.id == m.toastID {
|
|
m.toast = ""
|
|
if !m.selection.dragging {
|
|
m.selection.active = false
|
|
}
|
|
}
|
|
return m, nil
|
|
case vulnerabilityCopiedMsg:
|
|
m.vulnerabilityCopied = msg.err == nil
|
|
m.vulnerabilityCopyError = ""
|
|
if msg.err != nil {
|
|
m.vulnerabilityCopyError = msg.err.Error()
|
|
}
|
|
return m, nil
|
|
case tea.KeyMsg:
|
|
if m.showSplash {
|
|
switch msg.String() {
|
|
case "ctrl+c", "ctrl+q", "q", "esc":
|
|
m.quitting = true
|
|
return m, tea.Batch(send(m.client, "app.quit", map[string]any{}), tea.Quit)
|
|
}
|
|
m.showSplash = false
|
|
return m, nil
|
|
}
|
|
if m.modal != modalNone {
|
|
return m.updateModal(msg)
|
|
}
|
|
return m.updateMain(msg)
|
|
case tea.MouseMsg:
|
|
if m.showSplash || !m.ready {
|
|
return m, nil
|
|
}
|
|
return m.updateMouse(msg)
|
|
}
|
|
var cmd tea.Cmd
|
|
if m.modal == modalNone {
|
|
m.input, cmd = m.input.Update(msg)
|
|
}
|
|
cmds = append(cmds, cmd)
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
func (m Model) FatalError() error { return m.fatalError }
|