fix(tui): collapsible, zoomable sidebar panels and a sidebar toggle

Every sidebar panel gets a header with a click-to-collapse chevron and a
click-to-zoom glyph; a one-row chip on the viewer line hides the whole
sidebar and brings it back from a narrow rail. Tab skips collapsed and
hidden panels, scrollbar hit zones follow the rendered bars, and
fillBackground also repaints after the bare ESC[m reset so no cell shows
the terminal background.
This commit is contained in:
Ahmed Allam 2026-10-04 00:57:10 +00:00 • committed by Ahmed Allam
parent e1d7f5c433
commit 701c22b8a9
10 changed files with 690 additions and 189 deletions

View file

@ -209,8 +209,7 @@ func (m *Model) ensureAgentVisible() {
m.agentOffset = 0
return
}
_, _, _, agentHeight := m.sidebarHeights()
rows := max(1, agentHeight-4)
rows := m.agentPageSize()
row := selectedAgentRow(entries, m.selectedAgent)
if row < m.agentOffset {
m.agentOffset = row
@ -222,7 +221,7 @@ func (m *Model) ensureAgentVisible() {
func (m Model) agentPageSize() int {
_, _, _, agentHeight := m.sidebarHeights()
return max(1, agentHeight-4)
return max(1, agentHeight-3)
}
func (m *Model) keepAgentSelectionInWindow() {

View file

@ -27,11 +27,14 @@ func mcpModel(t *testing.T) Model {
func TestMcpPanelShowsHealthyAndOffline(t *testing.T) {
m := mcpModel(t)
out := ansi.Strip(m.mcpConnectionsView(40, 6))
for _, want := range []string{"MCP Connections (2)", "supabase", "3 tools", "vercel", "offline"} {
for _, want := range []string{"supabase", "3 tools", "vercel", "offline"} {
if !strings.Contains(out, want) {
t.Fatalf("panel missing %q:\n%s", want, out)
}
}
if sidebar := ansi.Strip(m.sidebarView(26, 40)); !strings.Contains(sidebar, "MCP (2)") {
t.Fatalf("sidebar header did not carry the connection count:\n%s", sidebar)
}
}
// A roster longer than the panel height shows a window of rows rather than every
@ -46,9 +49,8 @@ func TestMcpPanelWindowsLargeRosterAndCountsAll(t *testing.T) {
}
m.snapshot.Connections = conns
// rows = 6 → one header line + five roster rows.
out := ansi.Strip(m.mcpConnectionsView(40, 6))
if !strings.Contains(out, "MCP Connections (12)") {
if !strings.Contains(ansi.Strip(m.sidebarView(26, 40)), "MCP (12)") {
t.Fatalf("header did not carry the full connection count:\n%s", out)
}
if !strings.Contains(out, "conn-00") {
@ -58,14 +60,13 @@ func TestMcpPanelWindowsLargeRosterAndCountsAll(t *testing.T) {
t.Fatalf("a roster past the panel height should be windowed, not fully drawn:\n%s", out)
}
if got := strings.Count(out, "\n") + 1; got != 6 {
t.Fatalf("panel rendered %d lines, want 6 (header + five rows)", got)
t.Fatalf("panel rendered %d lines, want 6", got)
}
// Scrolling the roster brings the tail into view while the header count holds.
m.mcpOffset = 7
m.mcpOffset = 6
scrolled := ansi.Strip(m.mcpConnectionsView(40, 6))
if !strings.Contains(scrolled, "conn-11") || !strings.Contains(scrolled, "MCP Connections (12)") {
t.Fatalf("scrolled window did not reveal the tail with the count intact:\n%s", scrolled)
if !strings.Contains(scrolled, "conn-11") {
t.Fatalf("scrolled window did not reveal the tail:\n%s", scrolled)
}
}

View file

@ -100,6 +100,9 @@ type Model struct {
filtered []string
cursor int
collapsedAgents map[string]bool
collapsedPanels map[sidebarPanel]bool
zoomedPanel sidebarPanel
sidebarHidden bool
expandedEvents map[string]bool
blockCache map[string]renderedBlock
eventSpans []eventSpan
@ -278,7 +281,7 @@ func New(client *Client) Model {
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,
collapsedAgents: map[string]bool{}, collapsedPanels: map[sidebarPanel]bool{}, zoomedPanel: panelNone, 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{},
}

View file

@ -959,6 +959,17 @@ func TestFillBackgroundRestoresBaseForegroundAfterReset(t *testing.T) {
}
}
func TestFillBackgroundRestoresBaseColorsAfterBareReset(t *testing.T) {
view := "\x1b[38;2;115;115;115mModel\x1b[m padding\x1b[0m"
filled := fillBackground(view)
if !strings.Contains(filled, "\x1b[m"+baseFrameColors+" padding") {
t.Fatalf("base colors not restored after bare reset: %q", filled)
}
if strings.Contains(filled, "\x1b[m ") {
t.Fatalf("cells after a bare reset show the terminal background: %q", filled)
}
}
func TestMainTraceTreeAndFindingsRenderScrollbars(t *testing.T) {
model := New(nil)
model.width, model.height = 150, 35

View file

@ -0,0 +1,274 @@
package app
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
type sidebarPanel int
const (
panelNone sidebarPanel = iota - 1
panelAgents
panelFindings
panelMcp
panelStats
)
const (
panelOpenGlyph = "▾"
panelCollapsedGlyph = "▸"
panelZoomGlyph = "⤢"
panelUnzoomGlyph = "⤡"
sidebarHideGlyph = "»"
sidebarShowGlyph = "«"
panelGlyphZone = 3
)
type panelRect struct {
panel sidebarPanel
top int
height int
}
func (m Model) panelShrunk(panel sidebarPanel) bool {
return m.collapsedPanels[panel] || (m.zoomedPanel != panelNone && m.zoomedPanel != panel)
}
func (m Model) panelHeight(panel sidebarPanel, open int) int {
if m.panelShrunk(panel) {
return 1
}
return open
}
func panelFocus(panel sidebarPanel) (focusMode, bool) {
switch panel {
case panelAgents:
return focusAgents, true
case panelFindings:
return focusVulnerabilities, true
case panelMcp:
return focusMcp, true
default:
return focusInput, false
}
}
func (m Model) sidebarPanels() []panelRect {
statsHeight, vulnHeight, mcpHeight, agentHeight := m.sidebarHeights()
top := m.viewerHeight()
rects := []panelRect{{panelAgents, top, agentHeight}}
top += agentHeight
if vulnHeight > 0 {
rects = append(rects, panelRect{panelFindings, top, vulnHeight})
top += vulnHeight
}
if mcpHeight > 0 {
rects = append(rects, panelRect{panelMcp, top, mcpHeight})
top += mcpHeight
}
return append(rects, panelRect{panelStats, top, statsHeight})
}
func (m Model) panelAt(y int) (panelRect, bool) {
for _, rect := range m.sidebarPanels() {
if y >= rect.top && y < rect.top+rect.height {
return rect, true
}
}
return panelRect{}, false
}
func (m Model) panelTop(panel sidebarPanel) int {
for _, rect := range m.sidebarPanels() {
if rect.panel == panel {
return rect.top
}
}
return 0
}
func (m Model) panelTitle(panel sidebarPanel) string {
switch panel {
case panelAgents:
return fmt.Sprintf("Agents (%d)", len(m.snapshot.Agents))
case panelFindings:
return fmt.Sprintf("Findings (%d)", len(m.snapshot.Vulnerabilities))
case panelMcp:
return fmt.Sprintf("MCP (%d)", len(m.snapshot.Connections))
default:
return "Model"
}
}
func (m Model) panelHeader(panel sidebarPanel, width int) string {
style := lipgloss.NewStyle().Foreground(dim)
glyph := panelZoomGlyph
if m.zoomedPanel == panel {
glyph = panelUnzoomGlyph
}
label := truncate(style.Render(panelOpenGlyph+" "+m.panelTitle(panel)), max(1, width-2))
gap := max(1, width-lipgloss.Width(label)-1)
return label + strings.Repeat(" ", gap) + style.Render(glyph)
}
func (m Model) collapsedPanelRow(panel sidebarPanel, width int) string {
label := lipgloss.NewStyle().Foreground(dim).Render(panelCollapsedGlyph + " " + m.panelTitle(panel))
return " " + truncate(label, max(1, width-1))
}
func (m Model) panelBox(panel sidebarPanel, body string, width, height int, focused bool) string {
if height <= 1 {
return m.collapsedPanelRow(panel, width)
}
border := dark
if focused {
border = green
}
content := m.panelHeader(panel, width-4)
if body != "" {
content += "\n" + body
}
return lipgloss.NewStyle().Width(width-2).Height(height-2).Border(lipgloss.RoundedBorder()).
BorderForeground(border).Padding(0, 1).Render(content)
}
const (
toggleButtonWidth = 3
sidebarRailWidth = toggleButtonWidth + 1
)
var toggleButtonFill = lipgloss.Color("#262626")
func sidebarToggleButton(glyph string) string {
return lipgloss.NewStyle().
Background(toggleButtonFill).
Foreground(brightWhite).
Bold(true).
Width(toggleButtonWidth).
Align(lipgloss.Center).
Render(glyph)
}
func (m Model) railVisible() bool {
return m.sidebarHidden && m.width >= 120
}
func (m Model) sidebarRail(height int) string {
return lipgloss.NewStyle().
Width(sidebarRailWidth).
Height(height).
Align(lipgloss.Right).
Render(sidebarToggleButton(sidebarShowGlyph))
}
func (m Model) toggleButtonHit(x, y int) bool {
if m.railVisible() {
return y == 0 && x >= m.width-toggleButtonWidth
}
return y == 1 && x >= m.width-2-toggleButtonWidth && x < m.width-2
}
func (m Model) viewerBox(width int) string {
textWidth := max(1, width-toggleButtonWidth-1)
text := m.viewerView(textWidth)
rows := strings.Count(text, "\n") + 1
return lipgloss.JoinHorizontal(
lipgloss.Top,
fixedPanelBody(text, textWidth, rows),
" ",
sidebarToggleButton(sidebarHideGlyph),
)
}
func (m *Model) toggleSidebar() {
m.sidebarHidden = !m.sidebarHidden
m.resizeViewport()
m.panelsChanged()
}
func (m *Model) togglePanelCollapsed(panel sidebarPanel) {
if m.collapsedPanels[panel] {
delete(m.collapsedPanels, panel)
} else {
m.collapsedPanels[panel] = true
if m.zoomedPanel == panel {
m.zoomedPanel = panelNone
}
}
m.panelsChanged()
}
func (m *Model) togglePanelZoom(panel sidebarPanel) {
if m.zoomedPanel == panel {
m.zoomedPanel = panelNone
} else {
m.zoomedPanel = panel
delete(m.collapsedPanels, panel)
}
m.panelsChanged()
}
func (m *Model) revealPanel(panel sidebarPanel) {
delete(m.collapsedPanels, panel)
m.zoomedPanel = panelNone
m.panelsChanged()
}
func (m *Model) clickPanel(rect panelRect, x, y int) bool {
switch {
case rect.height <= 1:
m.revealPanel(rect.panel)
case y-rect.top != 1:
return false
case x >= m.width-2-panelGlyphZone:
m.togglePanelZoom(rect.panel)
default:
m.togglePanelCollapsed(rect.panel)
}
if focus, ok := panelFocus(rect.panel); ok && !m.panelShrunk(rect.panel) {
m.focus = focus
m.input.Blur()
}
return true
}
func (m *Model) panelsChanged() {
showSidebar, _, _, _ := m.layout()
for _, rect := range m.sidebarPanels() {
if focus, ok := panelFocus(rect.panel); ok && m.focus == focus && (!showSidebar || rect.height <= 1) {
m.focus = focusInput
m.input.Focus()
}
}
m.ensureAgentVisible()
totalRows, _ := m.vulnerabilityScrollRows()
m.vulnOffset = clampVulnerabilityOffset(m.vulnOffset, totalRows, m.vulnerabilityPageSize())
m.mcpOffset = m.clampMcpOffset(m.mcpOffset)
}
func (m *Model) scrollPanel(panel sidebarPanel, delta int) {
switch panel {
case panelAgents:
m.focus = focusAgents
m.input.Blur()
rows := m.agentPageSize()
total := len(agentTreeEntries(m.snapshot.Agents, m.collapsedAgents))
m.agentOffset = min(max(0, total-rows), max(0, m.agentOffset+delta))
m.keepAgentSelectionInWindow()
m.refreshViewport()
case panelFindings:
m.focus = focusVulnerabilities
m.input.Blur()
totalRows, _ := m.vulnerabilityScrollRows()
m.vulnOffset = min(max(0, totalRows-m.vulnerabilityPageSize()), max(0, m.vulnOffset+delta))
m.keepVulnerabilitySelectionInWindow()
case panelMcp:
m.focus = focusMcp
m.input.Blur()
m.mcpOffset = m.clampMcpOffset(m.mcpOffset + delta)
}
}

View file

@ -0,0 +1,232 @@
package app
import (
"fmt"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/usestrix/strix/tui/internal/protocol"
)
func panelsModel(t *testing.T) Model {
t.Helper()
m := New(nil)
m.width, m.height = 130, 40
m.showSplash = false
m.ready = true
root := "root"
agents := []protocol.Agent{{ID: root, Name: "Root Agent", Status: "running"}}
for i := 0; i < 30; i++ {
name := fmt.Sprintf("Worker %02d", i)
agents = append(agents, protocol.Agent{ID: name, Name: name, ParentID: &root, Status: "running"})
}
conns := make([]protocol.Connection, 0, 12)
for i := 0; i < 12; i++ {
conns = append(conns, protocol.Connection{Name: fmt.Sprintf("conn-%02d", i), ToolCount: 2})
}
url := "http://127.0.0.1:57388/?token=abc"
m.snapshot = protocol.Snapshot{
ScanStarted: true, ScanState: "running", Agents: agents, Connections: conns,
ViewerStatus: "running", ViewerURL: &url,
Vulnerabilities: []map[string]any{{"id": "v1", "title": "Finding one", "severity": "high"}},
}
m.resizeViewport()
return m
}
func click(t *testing.T, m Model, x, y int) Model {
t.Helper()
updated, _ := m.updateMouse(tea.MouseMsg{X: x, Y: y, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress})
return updated.(Model)
}
func panelRectOf(t *testing.T, m Model, panel sidebarPanel) panelRect {
t.Helper()
for _, rect := range m.sidebarPanels() {
if rect.panel == panel {
return rect
}
}
t.Fatalf("panel %d not in sidebar", panel)
return panelRect{}
}
func TestSidebarPanelsRenderHeadersWithControls(t *testing.T) {
m := panelsModel(t)
_, sidebarWidth, _, _ := m.layout()
view := ansi.Strip(m.sidebarView(sidebarWidth, m.height))
for _, want := range []string{"▾ Agents (31)", "▾ Findings (1)", "▾ MCP (12)", "▾ Model", "⤢"} {
if !strings.Contains(view, want) {
t.Fatalf("sidebar missing %q:\n%s", want, view)
}
}
if strings.Contains(view, "⤡") {
t.Fatalf("nothing is zoomed, yet a restore glyph is drawn:\n%s", view)
}
}
func TestClickingPanelHeaderCollapsesAndExpands(t *testing.T) {
m := panelsModel(t)
_, _, chatWidth, _ := m.layout()
rect := panelRectOf(t, m, panelFindings)
before := panelRectOf(t, m, panelAgents).height
m = click(t, m, chatWidth+4, rect.top+1)
collapsed := panelRectOf(t, m, panelFindings)
if collapsed.height != 1 || !m.collapsedPanels[panelFindings] {
t.Fatalf("header click did not collapse the panel: %+v", collapsed)
}
if got := panelRectOf(t, m, panelAgents).height; got <= before {
t.Fatalf("agent panel did not grow into the freed rows: %d -> %d", before, got)
}
_, sidebarWidth, _, _ := m.layout()
view := ansi.Strip(m.sidebarView(sidebarWidth, m.height))
if !strings.Contains(view, "▸ Findings (1)") || strings.Contains(view, "Finding one") {
t.Fatalf("collapsed panel should be a one-line header without its rows:\n%s", view)
}
m = click(t, m, chatWidth+4, collapsed.top)
if panelRectOf(t, m, panelFindings).height == 1 || m.collapsedPanels[panelFindings] {
t.Fatalf("clicking the collapsed row did not expand the panel")
}
if m.focus != focusVulnerabilities {
t.Fatalf("expanded panel did not take focus: %v", m.focus)
}
}
func TestClickingZoomGlyphGivesPanelTheSidebar(t *testing.T) {
m := panelsModel(t)
_, sidebarWidth, chatWidth, _ := m.layout()
rect := panelRectOf(t, m, panelMcp)
m = click(t, m, m.width-3, rect.top+1)
if m.zoomedPanel != panelMcp {
t.Fatalf("zoom glyph click did not zoom the panel: %d", m.zoomedPanel)
}
for _, other := range []sidebarPanel{panelAgents, panelFindings, panelStats} {
if got := panelRectOf(t, m, other).height; got != 1 {
t.Fatalf("panel %d should shrink to its header while another is zoomed, got %d", other, got)
}
}
zoomed := panelRectOf(t, m, panelMcp)
rects := m.sidebarPanels()
if last := rects[len(rects)-1]; last.top+last.height != m.height || zoomed.height < m.height-10 {
t.Fatalf("zoomed panel does not fill the sidebar: %+v screen=%d", rects, m.height)
}
view := ansi.Strip(m.sidebarView(sidebarWidth, m.height))
if !strings.Contains(view, "⤡") || !strings.Contains(view, "conn-11") {
t.Fatalf("zoomed roster should show every connection and the restore glyph:\n%s", view)
}
if m.focus != focusMcp {
t.Fatalf("zoomed panel did not take focus: %v", m.focus)
}
m = click(t, m, m.width-3, zoomed.top+1)
if m.zoomedPanel != panelNone || panelRectOf(t, m, panelAgents).height == 1 {
t.Fatalf("restore glyph did not unzoom")
}
m = click(t, m, m.width-3, panelRectOf(t, m, panelAgents).top+1)
m = click(t, m, chatWidth+4, panelRectOf(t, m, panelFindings).top)
if m.zoomedPanel != panelNone || m.collapsedPanels[panelFindings] {
t.Fatalf("clicking a shrunk row should restore the whole sidebar")
}
}
func TestToggleButtonsHideAndShowSidebar(t *testing.T) {
m := panelsModel(t)
m.focus = focusAgents
showSidebar, sidebarWidth, chatWidth, _ := m.layout()
if !showSidebar {
t.Fatalf("precondition: sidebar visible")
}
rows := strings.Split(ansi.Strip(m.sidebarView(sidebarWidth, m.height)), "\n")
if !strings.HasSuffix(rows[1], " » │") || strings.Contains(rows[2], "»") {
t.Fatalf("viewer panel lacks the one-row hide button:\n%s", strings.Join(rows[:3], "\n"))
}
if strings.Contains(ansi.Strip(m.View()), "«") || strings.Contains(ansi.Strip(m.statusView(chatWidth)), "sidebar") {
t.Fatalf("show button or text hint drawn while the sidebar is visible")
}
m = click(t, m, m.width-4, 1)
if showSidebar, _, width, _ := m.layout(); showSidebar || width != m.width-sidebarRailWidth-1 {
t.Fatalf("hide button click did not hide the sidebar: show=%v chatWidth=%d", showSidebar, width)
}
if m.focus != focusInput {
t.Fatalf("focus stayed on a hidden panel: %v", m.focus)
}
frame := ansi.Strip(m.View())
rows = strings.Split(frame, "\n")
if !strings.HasSuffix(rows[0], " « ") || strings.Contains(rows[1], "«") || lipgloss.Width(rows[0]) != m.width {
t.Fatalf("hidden sidebar should leave a one-row show button in the rail:\n%s", strings.Join(rows[:2], "\n"))
}
if strings.Contains(frame, "»") {
t.Fatalf("hide button drawn while the sidebar is hidden")
}
m = click(t, m, m.width-3, 0)
if showSidebar, _, _, _ := m.layout(); !showSidebar {
t.Fatalf("show button click did not bring the sidebar back")
}
}
func TestFocusCyclingSkipsShrunkPanels(t *testing.T) {
m := panelsModel(t)
m.collapsedPanels[panelFindings] = true
seen := map[focusMode]bool{}
for range 6 {
m.cycleFocus(1)
seen[m.focus] = true
}
if seen[focusVulnerabilities] || !seen[focusAgents] || !seen[focusMcp] {
t.Fatalf("tab order wrong with findings collapsed: %v", seen)
}
m.zoomedPanel = panelAgents
seen = map[focusMode]bool{}
for range 6 {
m.cycleFocus(1)
seen[m.focus] = true
}
if seen[focusMcp] || !seen[focusAgents] {
t.Fatalf("tab order wrong with agents zoomed: %v", seen)
}
m.sidebarHidden = true
seen = map[focusMode]bool{}
for range 4 {
m.cycleFocus(1)
seen[m.focus] = true
}
if seen[focusAgents] || len(seen) != 2 {
t.Fatalf("tab order should stay in the chat column with the sidebar hidden: %v", seen)
}
}
func TestScrollbarHitTestFollowsPanelState(t *testing.T) {
m := panelsModel(t)
showSidebar, _, chatWidth, chatHeight := m.layout()
agents := panelRectOf(t, m, panelAgents)
at := func(y int) scrollbarTarget {
return m.scrollbarAt(tea.MouseMsg{X: m.width - 3, Y: y}, showSidebar, chatWidth, chatHeight)
}
if at(agents.top+2) != scrollbarAgents {
t.Fatalf("overflowing agent tree should expose its scrollbar")
}
if at(agents.top+1) != scrollbarNone {
t.Fatalf("the header row is a control, not a scrollbar")
}
findings := panelRectOf(t, m, panelFindings)
if at(findings.top+2) != scrollbarNone {
t.Fatalf("a findings list that fits has no scrollbar to grab")
}
m.collapsedPanels[panelAgents] = true
agents = panelRectOf(t, m, panelAgents)
if agents.height != 1 || at(agents.top) != scrollbarNone {
t.Fatalf("collapsed panel still reports a scrollbar")
}
}

View file

@ -126,11 +126,14 @@ func (m Model) statusVisible() bool {
}
func (m Model) layout() (showSidebar bool, sidebarWidth, chatWidth, chatHeight int) {
showSidebar = m.width >= 120
if showSidebar {
showSidebar = m.width >= 120 && !m.sidebarHidden
switch {
case showSidebar:
sidebarWidth = max(24, m.width/5)
chatWidth = m.width - sidebarWidth - 1
} else {
case m.railVisible():
chatWidth = m.width - sidebarRailWidth - 1
default:
chatWidth = m.width
}
statusH := 0

View file

@ -162,12 +162,8 @@ func (m Model) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
return m.updateSetupMouse(msg)
}
showSidebar, _, chatWidth, chatHeight := m.layout()
viewerHeight := m.viewerHeight()
_, vulnHeight, mcpHeight, agentHeight := m.sidebarHeights()
x, y := msg.X, msg.Y
if m.updateMainScrollbarMouse(
msg, showSidebar, chatWidth, chatHeight, viewerHeight, agentHeight, vulnHeight, mcpHeight,
) {
if m.updateMainScrollbarMouse(msg, showSidebar, chatWidth, chatHeight) {
return m, nil
}
if m.selection.dragging {
@ -198,24 +194,8 @@ func (m Model) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
switch msg.Button {
case tea.MouseButtonWheelUp:
if showSidebar && x >= chatWidth+1 {
switch {
case y < viewerHeight:
return m, nil
case y < viewerHeight+agentHeight:
m.focus = focusAgents
m.input.Blur()
m.agentOffset = max(0, m.agentOffset-3)
m.keepAgentSelectionInWindow()
m.refreshViewport()
case vulnHeight > 0 && y < viewerHeight+agentHeight+vulnHeight:
m.focus = focusVulnerabilities
m.input.Blur()
m.vulnOffset = max(0, m.vulnOffset-3)
m.keepVulnerabilitySelectionInWindow()
case mcpHeight > 0 && y < viewerHeight+agentHeight+vulnHeight+mcpHeight:
m.focus = focusMcp
m.input.Blur()
m.mcpOffset = m.clampMcpOffset(m.mcpOffset - 3)
if rect, ok := m.panelAt(y); ok && rect.height > 1 {
m.scrollPanel(rect.panel, -3)
}
return m, nil
}
@ -226,26 +206,8 @@ func (m Model) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
return m, nil
case tea.MouseButtonWheelDown:
if showSidebar && x >= chatWidth+1 {
switch {
case y < viewerHeight:
return m, nil
case y < viewerHeight+agentHeight:
m.focus = focusAgents
m.input.Blur()
rows := m.agentPageSize()
m.agentOffset = min(max(0, len(agentTreeEntries(m.snapshot.Agents, m.collapsedAgents))-rows), m.agentOffset+3)
m.keepAgentSelectionInWindow()
m.refreshViewport()
case vulnHeight > 0 && y < viewerHeight+agentHeight+vulnHeight:
m.focus = focusVulnerabilities
m.input.Blur()
totalRows, _ := m.vulnerabilityScrollRows()
m.vulnOffset = min(max(0, totalRows-m.vulnerabilityPageSize()), m.vulnOffset+3)
m.keepVulnerabilitySelectionInWindow()
case mcpHeight > 0 && y < viewerHeight+agentHeight+vulnHeight+mcpHeight:
m.focus = focusMcp
m.input.Blur()
m.mcpOffset = m.clampMcpOffset(m.mcpOffset + 3)
if rect, ok := m.panelAt(y); ok && rect.height > 1 {
m.scrollPanel(rect.panel, 3)
}
return m, nil
}
@ -288,21 +250,32 @@ func (m Model) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
return m, nil
}
if !showSidebar || x < chatWidth+1 {
if x < chatWidth+1 {
return m, nil
}
// Sidebar: viewer, agents, vulnerabilities, then stats.
switch {
case y < viewerHeight:
if m.toggleButtonHit(x, y) {
m.selection.active = false
m.toggleSidebar()
return m, nil
}
if !showSidebar {
return m, nil
}
if y < m.viewerHeight() {
return m, send(m.client, "viewer.open", map[string]any{})
case y < viewerHeight+agentHeight:
}
rect, ok := m.panelAt(y)
if !ok || m.clickPanel(rect, x, y) {
return m, nil
}
localY := y - rect.top
switch rect.panel {
case panelAgents:
m.focus = focusAgents
m.input.Blur()
// Content starts after the top border (1) and vertical padding (1).
entries := agentTreeEntries(m.snapshot.Agents, m.collapsedAgents)
start := windowStart(m.agentOffset, len(entries), max(1, agentHeight-4))
localY := y - viewerHeight
if row := start + localY - 2; localY >= 2 && localY < agentHeight-2 && row < len(entries) {
start := windowStart(m.agentOffset, len(entries), m.agentPageSize())
if row := start + localY - 2; localY >= 2 && localY < rect.height-1 && row < len(entries) {
m.selectedAgent = entries[row].index
agentID := m.snapshot.Agents[m.selectedAgent].ID
if hasAgentChildren(agentID, m.snapshot.Agents) {
@ -311,24 +284,22 @@ func (m Model) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
}
m.refreshViewport()
}
case vulnHeight > 0 && y < viewerHeight+agentHeight+vulnHeight:
case panelFindings:
m.focus = focusVulnerabilities
m.input.Blur()
// Content starts after the top border (1); clicking a row opens its detail.
row := y - viewerHeight - agentHeight - 1
if idx := m.vulnerabilityIndexAtRow(row); row >= 0 && row < vulnHeight-2 && idx >= 0 {
row := localY - 2
if idx := m.vulnerabilityIndexAtRow(row); row >= 0 && row < rect.height-3 && idx >= 0 {
m.selectedVuln = idx
m.openModal(modalVulnerability)
}
case panelMcp:
m.focus = focusMcp
m.input.Blur()
}
return m, nil
}
func (m *Model) updateMainScrollbarMouse(
msg tea.MouseMsg,
showSidebar bool,
chatWidth, chatHeight, viewerHeight, agentHeight, vulnHeight, mcpHeight int,
) bool {
func (m *Model) updateMainScrollbarMouse(msg tea.MouseMsg, showSidebar bool, chatWidth, chatHeight int) bool {
if msg.Action == tea.MouseActionRelease {
if m.draggingScrollbar == scrollbarNone {
return false
@ -337,18 +308,18 @@ func (m *Model) updateMainScrollbarMouse(
return true
}
if msg.Action == tea.MouseActionMotion && m.draggingScrollbar != scrollbarNone {
m.scrollFromMouse(m.draggingScrollbar, msg.Y, chatHeight, viewerHeight, agentHeight, vulnHeight)
m.scrollFromMouse(m.draggingScrollbar, msg.Y, chatHeight)
return true
}
if msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft {
return false
}
target := m.scrollbarAt(msg, showSidebar, chatWidth, chatHeight, viewerHeight, agentHeight, vulnHeight, mcpHeight)
target := m.scrollbarAt(msg, showSidebar, chatWidth, chatHeight)
if target == scrollbarNone {
return false
}
m.draggingScrollbar = target
m.scrollFromMouse(target, msg.Y, chatHeight, viewerHeight, agentHeight, vulnHeight)
m.scrollFromMouse(target, msg.Y, chatHeight)
return true
}
@ -361,32 +332,28 @@ func nearColumn(x, column int) bool {
return x >= column-scrollbarGrab && x <= column+scrollbarGrab
}
// scrollbarAt reports which scrollbar, if any, the pointer is over.
func (m Model) scrollbarAt(
msg tea.MouseMsg,
showSidebar bool,
chatWidth, chatHeight, viewerHeight, agentHeight, vulnHeight, mcpHeight int,
) scrollbarTarget {
mcpTop := viewerHeight + agentHeight + vulnHeight
switch {
case nearColumn(msg.X, chatWidth-2) && msg.Y >= 1 && msg.Y < chatHeight-1 &&
m.viewport.TotalLineCount() > m.viewport.VisibleLineCount():
func (m Model) scrollbarAt(msg tea.MouseMsg, showSidebar bool, chatWidth, chatHeight int) scrollbarTarget {
if nearColumn(msg.X, chatWidth-2) && msg.Y >= 1 && msg.Y < chatHeight-1 &&
m.viewport.TotalLineCount() > m.viewport.VisibleLineCount() {
return scrollbarTrace
case showSidebar && nearColumn(msg.X, m.width-3) && msg.Y >= viewerHeight+2 &&
msg.Y < viewerHeight+agentHeight-2 &&
len(agentTreeEntries(m.snapshot.Agents, m.collapsedAgents)) > m.agentPageSize():
return scrollbarAgents
case showSidebar && vulnHeight > 0 && nearColumn(msg.X, m.width-3) &&
msg.Y >= viewerHeight+agentHeight+1 &&
msg.Y < viewerHeight+agentHeight+vulnHeight-1:
totalRows, _ := m.vulnerabilityScrollRows()
if totalRows > m.vulnerabilityPageSize() {
}
if !showSidebar || !nearColumn(msg.X, m.width-3) {
return scrollbarNone
}
rect, ok := m.panelAt(msg.Y)
if !ok || rect.height <= 1 || msg.Y < rect.top+2 || msg.Y >= rect.top+rect.height-1 {
return scrollbarNone
}
switch rect.panel {
case panelAgents:
if len(agentTreeEntries(m.snapshot.Agents, m.collapsedAgents)) > m.agentPageSize() {
return scrollbarAgents
}
case panelFindings:
if totalRows, _ := m.vulnerabilityScrollRows(); totalRows > m.vulnerabilityPageSize() {
return scrollbarFindings
}
// The roster scrolls below a fixed header, so its bar starts two rows into
// the panel (border then header) rather than one.
case showSidebar && mcpHeight > 0 && nearColumn(msg.X, m.width-3) &&
msg.Y >= mcpTop+2 && msg.Y < mcpTop+mcpHeight-1:
case panelMcp:
if len(m.snapshot.Connections) > m.mcpPageSize() {
return scrollbarMcp
}
@ -394,10 +361,7 @@ func (m Model) scrollbarAt(
return scrollbarNone
}
func (m *Model) scrollFromMouse(
target scrollbarTarget,
y, chatHeight, viewerHeight, agentHeight, vulnHeight int,
) {
func (m *Model) scrollFromMouse(target scrollbarTarget, y, chatHeight int) {
switch target {
case scrollbarTrace:
height := max(1, chatHeight-2)
@ -411,7 +375,7 @@ func (m *Model) scrollFromMouse(
total := len(agentTreeEntries(m.snapshot.Agents, m.collapsedAgents))
m.focus = focusAgents
m.input.Blur()
m.agentOffset = scrollbarOffset(y-viewerHeight-2, height, total, height)
m.agentOffset = scrollbarOffset(y-m.panelTop(panelAgents)-2, height, total, height)
m.keepAgentSelectionInWindow()
m.refreshViewport()
case scrollbarFindings:
@ -420,15 +384,14 @@ func (m *Model) scrollFromMouse(
m.focus = focusVulnerabilities
m.input.Blur()
// The offset is a row, so dragging moves the list continuously.
m.vulnOffset = scrollbarOffset(y-viewerHeight-agentHeight-1, height, totalRows, height)
m.vulnOffset = scrollbarOffset(y-m.panelTop(panelFindings)-2, height, totalRows, height)
m.keepVulnerabilitySelectionInWindow()
case scrollbarMcp:
height := m.mcpPageSize()
total := len(m.snapshot.Connections)
m.focus = focusMcp
m.input.Blur()
// The bar starts two rows into the panel (border then the fixed header).
m.mcpOffset = scrollbarOffset(y-viewerHeight-agentHeight-vulnHeight-2, height, total, height)
m.mcpOffset = scrollbarOffset(y-m.panelTop(panelMcp)-2, height, total, height)
}
}
@ -579,13 +542,11 @@ func labelHitAt(panel, label string, left, top, x, y int) bool {
func (m *Model) cycleFocus(delta int) {
available := []focusMode{focusInput, focusChat}
if m.width >= 120 {
available = append(available, focusAgents)
if len(m.snapshot.Vulnerabilities) > 0 {
available = append(available, focusVulnerabilities)
}
if len(m.snapshot.Connections) > 0 {
available = append(available, focusMcp)
if showSidebar, _, _, _ := m.layout(); showSidebar {
for _, rect := range m.sidebarPanels() {
if focus, ok := panelFocus(rect.panel); ok && rect.height > 1 {
available = append(available, focus)
}
}
}
idx := 0

View file

@ -387,10 +387,11 @@ const (
// 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 clears both foreground and
// background. Reasserting only black made uncolored and faint text inherit the
// terminal profile's foreground; light profiles therefore rendered that text
// color shows the terminal's default background. lipgloss emits a reset
// (\x1b[0m, or the bare \x1b[m that x/ansi uses) at the end of every styled
// span, which clears both foreground and background. Reasserting only black
// made uncolored and faint text inherit the terminal profile's foreground;
// light profiles therefore rendered that text
// black-on-black. Reapply both base colors after each reset (and at the start).
// Spans that set their own colors — inline code, selected rows, buttons — keep
// them, because their color is emitted after the base style.
@ -398,9 +399,14 @@ func fillBackground(view string) string {
if view == "" {
return view
}
return baseFrameColors + strings.ReplaceAll(view, "\x1b[0m", "\x1b[0m"+baseFrameColors)
return baseFrameColors + baseColorRestorer.Replace(view)
}
var baseColorRestorer = strings.NewReplacer(
"\x1b[0m", "\x1b[0m"+baseFrameColors,
"\x1b[m", "\x1b[m"+baseFrameColors,
)
func (m Model) splashView() string {
shine := "Starting Strix Agent"
chars := []rune(shine)
@ -516,6 +522,8 @@ func (m Model) mainView() string {
body := leftColumn
if showSidebar {
body = lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, " ", m.sidebarView(sidebarWidth, m.height))
} else if m.railVisible() {
body = lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, " ", m.sidebarRail(m.height))
}
return lipgloss.NewStyle().Background(black).Foreground(textColor).Render(body)
}
@ -526,75 +534,87 @@ func (m Model) mainView() string {
// 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, mcpHeight, 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),
parts := []string{lipgloss.NewStyle().
Width(width-2).
Height(m.viewerHeight()-2).
Border(lipgloss.RoundedBorder()).
BorderForeground(dark).
Padding(0, 1).
Render(m.viewerBox(width - 4))}
agents := ""
if agentHeight > 1 {
agentRows := max(1, agentHeight-3)
agents = withVerticalScrollbar(
m.agentsView(max(1, width-5), agentRows),
width-4,
vulnRows,
totalRows,
vulnRows,
offsetRows,
m.scrollbarThumb(scrollbarFindings),
agentRows,
len(agentTreeEntries(m.snapshot.Agents, m.collapsedAgents)),
agentRows,
m.agentOffset,
m.scrollbarThumb(scrollbarAgents),
)
parts = append(parts, lipgloss.NewStyle().Width(width-2).Height(vulnRows).Border(lipgloss.RoundedBorder()).BorderForeground(vulnBorder).Padding(0, 1).Render(findings))
}
parts = append(parts, m.panelBox(panelAgents, agents, width, agentHeight, m.focus == focusAgents))
if vulnHeight > 0 {
findings := ""
if vulnHeight > 1 {
vulnRows := max(1, vulnHeight-3)
totalRows, offsetRows := m.vulnerabilityScrollRows()
findings = withVerticalScrollbar(
m.vulnerabilitiesView(m.vulnerabilityListWidth(), vulnRows),
width-4,
vulnRows,
totalRows,
vulnRows,
offsetRows,
m.scrollbarThumb(scrollbarFindings),
)
}
parts = append(parts, m.panelBox(panelFindings, findings, width, vulnHeight, m.focus == focusVulnerabilities))
}
if mcpHeight > 0 {
mcpBorder := dark
if m.focus == focusMcp {
mcpBorder = green
roster := ""
if mcpHeight > 1 {
roster = m.mcpConnectionsView(width-4, max(1, mcpHeight-3))
}
mcpRows := max(1, mcpHeight-2)
parts = append(parts, lipgloss.NewStyle().Width(width-2).Height(mcpRows).Border(lipgloss.RoundedBorder()).BorderForeground(mcpBorder).Padding(0, 1).Render(m.mcpConnectionsView(width-4, mcpRows)))
parts = append(parts, m.panelBox(panelMcp, roster, width, mcpHeight, m.focus == focusMcp))
}
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")
stats := ""
if statsHeight > 1 {
stats = m.statsView()
}
parts = append(parts, m.panelBox(panelStats, stats, width, statsHeight, false))
return lipgloss.JoinVertical(lipgloss.Left, parts...)
}
func (m Model) sidebarHeights() (statsHeight, vulnHeight, mcpHeight, 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)
statsRows := lipgloss.Height(lipgloss.NewStyle().Width(m.sidebarInnerWidth()).Render(m.statsView()))
statsHeight = m.panelHeight(panelStats, min(15, statsRows+3))
if len(m.snapshot.Vulnerabilities) > 0 {
vulnHeight = min(12, len(m.vulnerabilityRows(m.vulnerabilityListWidth()))+2)
vulnHeight = m.panelHeight(panelFindings, min(12, len(m.vulnerabilityRows(m.vulnerabilityListWidth()))+3))
}
// One header line + one line per connection + the box border (2). Capped so a
// Header line + one line per connection + the box border (2). Capped so a
// long roster cannot crowd out the agent tree; a roster past the cap scrolls
// inside the panel. Absent entirely when the run has no MCP connections.
if len(m.snapshot.Connections) > 0 {
mcpHeight = min(9, len(m.snapshot.Connections)+3)
mcpHeight = m.panelHeight(panelMcp, min(9, len(m.snapshot.Connections)+3))
}
agentHeight = m.panelHeight(panelAgents, 3)
spare := max(0, m.height-m.viewerHeight()-statsHeight-vulnHeight-mcpHeight-agentHeight)
switch {
case m.zoomedPanel == panelFindings && vulnHeight > 1:
vulnHeight += spare
case m.zoomedPanel == panelMcp && mcpHeight > 1:
mcpHeight += spare
case m.zoomedPanel == panelStats && statsHeight > 1:
statsHeight += spare
case agentHeight > 1:
agentHeight += spare
}
agentHeight = max(3, m.height-m.viewerHeight()-statsHeight-vulnHeight-mcpHeight)
return
}
@ -602,7 +622,7 @@ func (m Model) viewerHeight() int {
return strings.Count(m.viewerView(m.viewerContentWidth()), "\n") + 3
}
func (m Model) viewerContentWidth() int {
func (m Model) sidebarInnerWidth() int {
_, sidebarWidth, _, _ := m.layout()
if sidebarWidth == 0 {
sidebarWidth = 24
@ -610,6 +630,10 @@ func (m Model) viewerContentWidth() int {
return max(1, sidebarWidth-4)
}
func (m Model) viewerContentWidth() int {
return max(1, m.sidebarInnerWidth()-toggleButtonWidth-1)
}
func (m Model) viewerView(width int) string {
switch m.snapshot.ViewerStatus {
case "running":
@ -662,29 +686,22 @@ func (m Model) statsView() string {
return b.String()
}
// mcpConnectionsView renders the sidebar MCP panel: a header carrying the total
// connection count, then one row per connection with a status glyph and its tool
// count (or "offline").
// mcpConnectionsView renders the sidebar MCP roster: one row per connection with
// a status glyph and its tool count (or "offline").
// - a solid green dot marks an attached, idle connection;
// - a green cycling quarter-circle (◐ ◓ ◑ ◒) marks a call running against it;
// - a red dot plus "offline" marks a connection whose live session has died.
//
// The header stays fixed while the roster below it scrolls: when there are more
// connections than the panel can show, the visible window is chosen by
// m.mcpOffset and withVerticalScrollbar draws a thumb in the reserved last
// column, exactly as the agent tree and findings list scroll.
// When there are more connections than the panel can show, the visible window
// is chosen by m.mcpOffset and withVerticalScrollbar draws a thumb in the
// reserved last column, exactly as the agent tree and findings list scroll.
//
// "In use" is derived from the connection-tagged tool-call events in the stream,
// not carried on the connection roster, so a call in flight shows motion without
// any extra backend signal. The quarter-circle rides the shared sweepFrame tick.
func (m Model) mcpConnectionsView(width, rows int) string {
conns := m.snapshot.Connections
header := truncate(lipgloss.NewStyle().Foreground(dim).Render(
fmt.Sprintf("MCP Connections (%d)", len(conns))), width)
bodyRows := max(0, rows-1)
if bodyRows == 0 {
return header
}
bodyRows := max(1, rows)
inUse := m.mcpInUse()
frames := []rune{'◐', '◓', '◑', '◒'}
// Reserve the scrollbar column whether or not the bar is showing, so the
@ -721,7 +738,7 @@ func (m Model) mcpConnectionsView(width, rows int) string {
m.mcpOffset,
m.scrollbarThumb(scrollbarMcp),
)
return header + "\n" + roster
return roster
}
// mcpPageSize is how many connection rows the roster shows at once, below its

View file

@ -135,7 +135,7 @@ func clampVulnerabilityOffset(offset, total, height int) int {
func (m Model) vulnerabilityPageSize() int {
_, vulnHeight, _, _ := m.sidebarHeights()
return max(1, vulnHeight-2)
return max(1, vulnHeight-3)
}
// vulnerabilityPageItems is how many findings a page step should move by: the
@ -192,7 +192,7 @@ 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")
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\nClick ▾/⤢ Collapse / zoom panel\nClick »/« Hide / show sidebar")
content := title + "\n\n" + body
return lipgloss.NewStyle().Width(38).Border(lipgloss.RoundedBorder()).BorderForeground(green).Background(black).Padding(1, 2).Render(content)
case modalQuit: