From 701c22b8a9da6a49f27ca9c72df011834cb494c7 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Sun, 4 Oct 2026 00:57:10 +0000 Subject: [PATCH] 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. --- strix/interface/tui/internal/app/agents.go | 5 +- strix/interface/tui/internal/app/mcp_test.go | 17 +- strix/interface/tui/internal/app/model.go | 5 +- .../interface/tui/internal/app/model_test.go | 11 + strix/interface/tui/internal/app/panels.go | 274 ++++++++++++++++++ .../interface/tui/internal/app/panels_test.go | 232 +++++++++++++++ strix/interface/tui/internal/app/setup.go | 9 +- strix/interface/tui/internal/app/update.go | 165 ++++------- strix/interface/tui/internal/app/view.go | 157 +++++----- .../tui/internal/app/vulnerabilities.go | 4 +- 10 files changed, 690 insertions(+), 189 deletions(-) create mode 100644 strix/interface/tui/internal/app/panels.go create mode 100644 strix/interface/tui/internal/app/panels_test.go diff --git a/strix/interface/tui/internal/app/agents.go b/strix/interface/tui/internal/app/agents.go index c8df9a42..1b86f86c 100644 --- a/strix/interface/tui/internal/app/agents.go +++ b/strix/interface/tui/internal/app/agents.go @@ -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() { diff --git a/strix/interface/tui/internal/app/mcp_test.go b/strix/interface/tui/internal/app/mcp_test.go index 593701d9..fa52f3c3 100644 --- a/strix/interface/tui/internal/app/mcp_test.go +++ b/strix/interface/tui/internal/app/mcp_test.go @@ -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) } } diff --git a/strix/interface/tui/internal/app/model.go b/strix/interface/tui/internal/app/model.go index 771b4165..ee1e9100 100644 --- a/strix/interface/tui/internal/app/model.go +++ b/strix/interface/tui/internal/app/model.go @@ -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{}, } diff --git a/strix/interface/tui/internal/app/model_test.go b/strix/interface/tui/internal/app/model_test.go index 40363b39..c79c35b7 100644 --- a/strix/interface/tui/internal/app/model_test.go +++ b/strix/interface/tui/internal/app/model_test.go @@ -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 diff --git a/strix/interface/tui/internal/app/panels.go b/strix/interface/tui/internal/app/panels.go new file mode 100644 index 00000000..0d1b1ec7 --- /dev/null +++ b/strix/interface/tui/internal/app/panels.go @@ -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) + } +} diff --git a/strix/interface/tui/internal/app/panels_test.go b/strix/interface/tui/internal/app/panels_test.go new file mode 100644 index 00000000..141408f4 --- /dev/null +++ b/strix/interface/tui/internal/app/panels_test.go @@ -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") + } +} diff --git a/strix/interface/tui/internal/app/setup.go b/strix/interface/tui/internal/app/setup.go index 7a02cf34..89e5cb24 100644 --- a/strix/interface/tui/internal/app/setup.go +++ b/strix/interface/tui/internal/app/setup.go @@ -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 diff --git a/strix/interface/tui/internal/app/update.go b/strix/interface/tui/internal/app/update.go index 3b962495..a963a49d 100644 --- a/strix/interface/tui/internal/app/update.go +++ b/strix/interface/tui/internal/app/update.go @@ -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 diff --git a/strix/interface/tui/internal/app/view.go b/strix/interface/tui/internal/app/view.go index 495b2568..80435c2c 100644 --- a/strix/interface/tui/internal/app/view.go +++ b/strix/interface/tui/internal/app/view.go @@ -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 diff --git a/strix/interface/tui/internal/app/vulnerabilities.go b/strix/interface/tui/internal/app/vulnerabilities.go index 6a59a5aa..a4e1c6b8 100644 --- a/strix/interface/tui/internal/app/vulnerabilities.go +++ b/strix/interface/tui/internal/app/vulnerabilities.go @@ -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: