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

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 }