mirror of
https://github.com/usestrix/strix.git
synced 2026-11-09 05:19:39 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bba8f7748 | ||
|
|
82fbb6c2d3 | ||
|
|
a0f10f05ef | ||
|
|
2e21cd06d0 | ||
|
|
652e30089d |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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{},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
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) panelControls() bool {
|
||||
return len(m.snapshot.Vulnerabilities) > 0 || len(m.snapshot.Connections) > 0
|
||||
}
|
||||
|
||||
func (m Model) panelShrunk(panel sidebarPanel) bool {
|
||||
if panel == panelStats || !m.panelControls() {
|
||||
return false
|
||||
}
|
||||
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
|
||||
}
|
||||
top += m.sidebarGap()
|
||||
return append(rects, panelRect{panelStats, top, statsHeight})
|
||||
}
|
||||
|
||||
func (m Model) sidebarGap() int {
|
||||
statsHeight, vulnHeight, mcpHeight, agentHeight := m.sidebarHeights()
|
||||
return max(0, m.height-m.viewerHeight()-agentHeight-vulnHeight-mcpHeight-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 m.snapshot.Model
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) panelHeader(panel sidebarPanel, width int) string {
|
||||
style := lipgloss.NewStyle().Foreground(dim)
|
||||
if !m.panelControls() {
|
||||
return truncate(style.Render(m.panelTitle(panel)), max(1, width))
|
||||
}
|
||||
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 {
|
||||
title := m.panelTitle(panel)
|
||||
if panel != panelStats {
|
||||
title = panelCollapsedGlyph + " " + title
|
||||
}
|
||||
label := lipgloss.NewStyle().Foreground(dim).Render(title)
|
||||
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 := body
|
||||
if panel != panelStats {
|
||||
content = m.panelHeader(panel, width-4)
|
||||
if body != "" {
|
||||
content += "\n\n" + body
|
||||
}
|
||||
}
|
||||
return lipgloss.NewStyle().Width(width-2).Height(height-2).Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(border).Padding(0, 1).Render(content)
|
||||
}
|
||||
|
||||
const (
|
||||
toggleButtonWidth = 3
|
||||
railButtonWidth = 5
|
||||
railButtonHeight = 3
|
||||
sidebarRailWidth = railButtonWidth + 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 {
|
||||
button := lipgloss.NewStyle().
|
||||
Background(toggleButtonFill).
|
||||
Foreground(brightWhite).
|
||||
Bold(true).
|
||||
Width(railButtonWidth).
|
||||
Height(railButtonHeight).
|
||||
Align(lipgloss.Center, lipgloss.Center).
|
||||
Render(sidebarShowGlyph)
|
||||
return lipgloss.NewStyle().
|
||||
Width(sidebarRailWidth).
|
||||
Height(height).
|
||||
Align(lipgloss.Right).
|
||||
Render(button)
|
||||
}
|
||||
|
||||
func (m Model) toggleButtonHit(x, y int) bool {
|
||||
if m.railVisible() {
|
||||
return y < railButtonHeight && x >= m.width-railButtonWidth
|
||||
}
|
||||
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.panel == panelStats || !m.panelControls():
|
||||
return false
|
||||
case rect.height <= 1 && !m.panelShrunk(rect.panel):
|
||||
m.togglePanelZoom(rect.panel)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
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, Model: "openrouter/z-ai/glm-5.3",
|
||||
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)", "⤢"} {
|
||||
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} {
|
||||
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-12 {
|
||||
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[1], " « ") || strings.Contains(rows[0], "«") || strings.Contains(rows[3], "«") || lipgloss.Width(rows[0]) != m.width {
|
||||
t.Fatalf("hidden sidebar should leave a %d-row show button in the rail:\n%s", railButtonHeight, strings.Join(rows[:4], "\n"))
|
||||
}
|
||||
if !m.toggleButtonHit(m.width-railButtonWidth, railButtonHeight-1) || m.toggleButtonHit(m.width-railButtonWidth-1, 0) || m.toggleButtonHit(m.width-1, railButtonHeight) {
|
||||
t.Fatalf("show button hit zone does not match the drawn button")
|
||||
}
|
||||
if strings.Contains(frame, "»") {
|
||||
t.Fatalf("hide button drawn while the sidebar is hidden")
|
||||
}
|
||||
|
||||
m = click(t, m, m.width-3, 2)
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSidebarFitsShortTerminal(t *testing.T) {
|
||||
m := panelsModel(t)
|
||||
m.height = 19
|
||||
m.snapshot.Connections = m.snapshot.Connections[:1]
|
||||
m.resizeViewport()
|
||||
_, sidebarWidth, _, _ := m.layout()
|
||||
rects := m.sidebarPanels()
|
||||
last := rects[len(rects)-1]
|
||||
if last.top+last.height > m.height {
|
||||
t.Fatalf("panels run past the screen: %+v (height %d)", rects, m.height)
|
||||
}
|
||||
if got := lipgloss.Height(m.sidebarView(sidebarWidth, m.height)); got > m.height {
|
||||
t.Fatalf("sidebar renders %d rows on a %d-row terminal", got, m.height)
|
||||
}
|
||||
squeezed := panelRectOf(t, m, panelMcp)
|
||||
if squeezed.height != 1 {
|
||||
t.Fatalf("expected the MCP panel squeezed to its header, got %+v", squeezed)
|
||||
}
|
||||
if last.panel != panelStats || last.height < 3 {
|
||||
t.Fatalf("Model panel must keep its box on short terminals: %+v", last)
|
||||
}
|
||||
m = click(t, m, m.width-6, squeezed.top)
|
||||
if m.zoomedPanel != panelMcp {
|
||||
t.Fatalf("clicking a squeezed header should zoom it, zoomed=%v", m.zoomedPanel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelPanelHasNoControlsAndNeverShrinks(t *testing.T) {
|
||||
m := panelsModel(t)
|
||||
_, sidebarWidth, _, _ := m.layout()
|
||||
stats := panelRectOf(t, m, panelStats)
|
||||
rows := strings.Split(ansi.Strip(m.sidebarView(sidebarWidth, m.height)), "\n")
|
||||
if first := rows[stats.top+1]; strings.ContainsAny(first, "▾▸⤢⤡") || strings.Contains(first, "Model") || !strings.Contains(first, m.snapshot.Model[:8]) {
|
||||
t.Fatalf("Model panel should start with the model name and carry no controls: %q", first)
|
||||
}
|
||||
before := m.sidebarPanels()
|
||||
m = click(t, m, m.width-3, stats.top+1)
|
||||
m = click(t, m, m.width-10, stats.top+1)
|
||||
if m.zoomedPanel != panelNone || len(m.collapsedPanels) != 0 {
|
||||
t.Fatalf("clicks on the Model header changed panel state: zoom=%v collapsed=%v", m.zoomedPanel, m.collapsedPanels)
|
||||
}
|
||||
m = click(t, m, m.width-3, panelRectOf(t, m, panelAgents).top+1)
|
||||
if m.zoomedPanel != panelAgents {
|
||||
t.Fatalf("precondition: agents zoomed")
|
||||
}
|
||||
after := panelRectOf(t, m, panelStats)
|
||||
if after.height != before[len(before)-1].height {
|
||||
t.Fatalf("Model panel shrank under zoom: %+v -> %+v", before[len(before)-1], after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelPanelSticksToBottom(t *testing.T) {
|
||||
m := panelsModel(t)
|
||||
m = click(t, m, m.width-10, panelRectOf(t, m, panelAgents).top+1)
|
||||
if !m.collapsedPanels[panelAgents] {
|
||||
t.Fatalf("precondition: agents collapsed")
|
||||
}
|
||||
stats := panelRectOf(t, m, panelStats)
|
||||
if stats.top+stats.height != m.height {
|
||||
t.Fatalf("Model panel not at the bottom: %+v (height %d)", stats, m.height)
|
||||
}
|
||||
_, sidebarWidth, _, _ := m.layout()
|
||||
rows := strings.Split(ansi.Strip(m.sidebarView(sidebarWidth, m.height)), "\n")
|
||||
if len(rows) != m.height {
|
||||
t.Fatalf("sidebar renders %d rows, want %d", len(rows), m.height)
|
||||
}
|
||||
if !strings.Contains(rows[stats.top+1], m.snapshot.Model[:8]) {
|
||||
t.Fatalf("Model panel not where its rect says:\n%s", strings.Join(rows[stats.top-2:], "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatsPanelKeepsAssignedHeight(t *testing.T) {
|
||||
m := panelsModel(t)
|
||||
m.snapshot.Model = strings.Repeat("openrouter/some-vendor/a-very-long-model-name ", 12)
|
||||
m.resizeViewport()
|
||||
_, sidebarWidth, _, _ := m.layout()
|
||||
statsHeight, _, _, _ := m.sidebarHeights()
|
||||
if statsHeight != 15 {
|
||||
t.Fatalf("stats panel should hit its cap, got %d", statsHeight)
|
||||
}
|
||||
if got := lipgloss.Height(m.sidebarView(sidebarWidth, m.height)); got != m.height {
|
||||
t.Fatalf("sidebar renders %d rows, want %d", got, m.height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadersAreFollowedByABlankRow(t *testing.T) {
|
||||
m := panelsModel(t)
|
||||
_, sidebarWidth, _, _ := m.layout()
|
||||
rows := strings.Split(ansi.Strip(m.sidebarView(sidebarWidth, m.height)), "\n")
|
||||
for _, panel := range []sidebarPanel{panelAgents, panelFindings, panelMcp} {
|
||||
rect := panelRectOf(t, m, panel)
|
||||
header, blank, first := rows[rect.top+1], rows[rect.top+2], rows[rect.top+3]
|
||||
if !strings.Contains(header, m.panelTitle(panel)) || strings.TrimSpace(strings.Trim(blank, "│")) != "" || strings.TrimSpace(strings.Trim(first, "│")) == "" {
|
||||
t.Fatalf("panel %d should render header, blank row, content:\n%s", panel, strings.Join(rows[rect.top:rect.top+4], "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLonePanelHasNoControls(t *testing.T) {
|
||||
m := panelsModel(t)
|
||||
m.snapshot.Vulnerabilities = nil
|
||||
m.snapshot.Connections = nil
|
||||
_, sidebarWidth, _, _ := m.layout()
|
||||
view := ansi.Strip(m.sidebarView(sidebarWidth, m.height))
|
||||
if strings.ContainsAny(view, "▾▸⤢⤡") || !strings.Contains(view, "Agents (31)") {
|
||||
t.Fatalf("a lone Agents panel should carry no collapse or zoom controls:\n%s", view)
|
||||
}
|
||||
rect := panelRectOf(t, m, panelAgents)
|
||||
m = click(t, m, m.width-3, rect.top+1)
|
||||
m = click(t, m, m.width-10, rect.top+1)
|
||||
if m.zoomedPanel != panelNone || len(m.collapsedPanels) != 0 || panelRectOf(t, m, panelAgents) != rect {
|
||||
t.Fatalf("header clicks changed a lone panel: zoom=%v collapsed=%v", m.zoomedPanel, m.collapsedPanels)
|
||||
}
|
||||
if m.sidebarGap() != 0 {
|
||||
t.Fatalf("lone Agents panel should take the spare rows, gap=%d", m.sidebarGap())
|
||||
}
|
||||
m.snapshot.Connections = panelsModel(t).snapshot.Connections
|
||||
if !strings.Contains(ansi.Strip(m.sidebarView(sidebarWidth, m.height)), "▾ Agents (31)") {
|
||||
t.Fatalf("controls should return once a second panel exists")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,108 @@ 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-4)
|
||||
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-4)
|
||||
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-4))
|
||||
}
|
||||
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")
|
||||
if gap := m.sidebarGap(); gap > 0 {
|
||||
parts = append(parts, lipgloss.NewStyle().Width(width).Height(gap).Render(""))
|
||||
}
|
||||
stats := ""
|
||||
if statsHeight > 1 {
|
||||
stats = fixedPanelBody(lipgloss.NewStyle().Width(width-4).Render(m.statsView()), width-4, statsHeight-2)
|
||||
}
|
||||
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+2))
|
||||
if len(m.snapshot.Vulnerabilities) > 0 {
|
||||
vulnHeight = min(12, len(m.vulnerabilityRows(m.vulnerabilityListWidth()))+2)
|
||||
vulnHeight = m.panelHeight(panelFindings, min(13, len(m.vulnerabilityRows(m.vulnerabilityListWidth()))+4))
|
||||
}
|
||||
// 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(10, len(m.snapshot.Connections)+4))
|
||||
}
|
||||
agentHeight = m.panelHeight(panelAgents, 5)
|
||||
for _, p := range []struct {
|
||||
h *int
|
||||
floor int
|
||||
}{{&statsHeight, 4}, {&mcpHeight, 5}, {&vulnHeight, 5}} {
|
||||
over := m.viewerHeight() + statsHeight + vulnHeight + mcpHeight + agentHeight - m.height
|
||||
if over <= 0 {
|
||||
break
|
||||
}
|
||||
if *p.h > p.floor {
|
||||
*p.h -= min(over, *p.h-p.floor)
|
||||
}
|
||||
}
|
||||
for _, h := range []*int{&mcpHeight, &vulnHeight, &statsHeight} {
|
||||
if m.viewerHeight()+statsHeight+vulnHeight+mcpHeight+agentHeight <= m.height {
|
||||
break
|
||||
}
|
||||
if *h > 1 {
|
||||
*h = 1
|
||||
}
|
||||
}
|
||||
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 agentHeight > 1:
|
||||
agentHeight += spare
|
||||
}
|
||||
agentHeight = max(3, m.height-m.viewerHeight()-statsHeight-vulnHeight-mcpHeight)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -602,7 +643,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 +651,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 +707,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 +759,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
|
||||
|
||||
@@ -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-4)
|
||||
}
|
||||
|
||||
// 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:
|
||||
|
||||
Reference in new issue
Block a user