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 }