mirror of
https://github.com/usestrix/strix.git
synced 2026-10-04 02:33:47 +00:00
fix(tui): pin the Model panel to the sidebar bottom and enlarge the show-sidebar button
This commit is contained in:
parent
1a680f4aa3
commit
faca0d4d1d
3 changed files with 97 additions and 23 deletions
|
|
@ -34,6 +34,9 @@ type panelRect struct {
|
|||
}
|
||||
|
||||
func (m Model) panelShrunk(panel sidebarPanel) bool {
|
||||
if panel == panelStats {
|
||||
return false
|
||||
}
|
||||
return m.collapsedPanels[panel] || (m.zoomedPanel != panelNone && m.zoomedPanel != panel)
|
||||
}
|
||||
|
||||
|
|
@ -70,9 +73,15 @@ func (m Model) sidebarPanels() []panelRect {
|
|||
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 {
|
||||
|
|
@ -106,6 +115,9 @@ func (m Model) panelTitle(panel sidebarPanel) string {
|
|||
|
||||
func (m Model) panelHeader(panel sidebarPanel, width int) string {
|
||||
style := lipgloss.NewStyle().Foreground(dim)
|
||||
if panel == panelStats {
|
||||
return truncate(style.Render(m.panelTitle(panel)), max(1, width))
|
||||
}
|
||||
glyph := panelZoomGlyph
|
||||
if m.zoomedPanel == panel {
|
||||
glyph = panelUnzoomGlyph
|
||||
|
|
@ -116,7 +128,11 @@ func (m Model) panelHeader(panel sidebarPanel, width int) string {
|
|||
}
|
||||
|
||||
func (m Model) collapsedPanelRow(panel sidebarPanel, width int) string {
|
||||
label := lipgloss.NewStyle().Foreground(dim).Render(panelCollapsedGlyph + " " + m.panelTitle(panel))
|
||||
title := m.panelTitle(panel)
|
||||
if panel != panelStats {
|
||||
title = panelCollapsedGlyph + " " + title
|
||||
}
|
||||
label := lipgloss.NewStyle().Foreground(dim).Render(title)
|
||||
return " " + truncate(label, max(1, width-1))
|
||||
}
|
||||
|
||||
|
|
@ -138,7 +154,9 @@ func (m Model) panelBox(panel sidebarPanel, body string, width, height int, focu
|
|||
|
||||
const (
|
||||
toggleButtonWidth = 3
|
||||
sidebarRailWidth = toggleButtonWidth + 1
|
||||
railButtonWidth = 5
|
||||
railButtonHeight = 3
|
||||
sidebarRailWidth = railButtonWidth + 1
|
||||
)
|
||||
|
||||
var toggleButtonFill = lipgloss.Color("#262626")
|
||||
|
|
@ -158,16 +176,24 @@ func (m Model) railVisible() bool {
|
|||
}
|
||||
|
||||
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(sidebarToggleButton(sidebarShowGlyph))
|
||||
Render(button)
|
||||
}
|
||||
|
||||
func (m Model) toggleButtonHit(x, y int) bool {
|
||||
if m.railVisible() {
|
||||
return y == 0 && x >= m.width-toggleButtonWidth
|
||||
return y < railButtonHeight && x >= m.width-railButtonWidth
|
||||
}
|
||||
return y == 1 && x >= m.width-2-toggleButtonWidth && x < m.width-2
|
||||
}
|
||||
|
|
@ -220,6 +246,8 @@ func (m *Model) revealPanel(panel sidebarPanel) {
|
|||
|
||||
func (m *Model) clickPanel(rect panelRect, x, y int) bool {
|
||||
switch {
|
||||
case rect.panel == panelStats:
|
||||
return false
|
||||
case rect.height <= 1 && !m.panelShrunk(rect.panel):
|
||||
m.togglePanelZoom(rect.panel)
|
||||
case rect.height <= 1:
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ 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", "⤢"} {
|
||||
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)
|
||||
}
|
||||
|
|
@ -106,14 +106,14 @@ func TestClickingZoomGlyphGivesPanelTheSidebar(t *testing.T) {
|
|||
if m.zoomedPanel != panelMcp {
|
||||
t.Fatalf("zoom glyph click did not zoom the panel: %d", m.zoomedPanel)
|
||||
}
|
||||
for _, other := range []sidebarPanel{panelAgents, panelFindings, panelStats} {
|
||||
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-10 {
|
||||
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))
|
||||
|
|
@ -160,14 +160,17 @@ func TestToggleButtonsHideAndShowSidebar(t *testing.T) {
|
|||
}
|
||||
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.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, 0)
|
||||
m = click(t, m, m.width-3, 2)
|
||||
if showSidebar, _, _, _ := m.layout(); !showSidebar {
|
||||
t.Fatalf("show button click did not bring the sidebar back")
|
||||
}
|
||||
|
|
@ -245,17 +248,60 @@ func TestSidebarFitsShortTerminal(t *testing.T) {
|
|||
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, ok := m.panelAt(last.top)
|
||||
if !ok || squeezed.panel != panelStats || squeezed.height != 1 {
|
||||
t.Fatalf("expected the stats panel squeezed to its header, got %+v", squeezed)
|
||||
squeezed := panelRectOf(t, m, panelMcp)
|
||||
if squeezed.height != 1 {
|
||||
t.Fatalf("expected the MCP panel squeezed to its header, got %+v", squeezed)
|
||||
}
|
||||
m = click(t, m, m.width-6, last.top)
|
||||
if m.zoomedPanel != panelStats {
|
||||
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)
|
||||
}
|
||||
statsHeight, _, _, _ := m.sidebarHeights()
|
||||
if statsHeight < 4 {
|
||||
t.Fatalf("zoomed stats panel still has no room: %d", statsHeight)
|
||||
}
|
||||
|
||||
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 header := rows[stats.top+1]; strings.ContainsAny(header, "▾▸⤢⤡") || !strings.Contains(header, "Model") {
|
||||
t.Fatalf("Model header should carry no controls: %q", header)
|
||||
}
|
||||
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], "Model") {
|
||||
t.Fatalf("Model header not where its rect says:\n%s", strings.Join(rows[stats.top-2:], "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -580,6 +580,9 @@ func (m Model) sidebarView(width, height int) string {
|
|||
}
|
||||
parts = append(parts, m.panelBox(panelMcp, roster, width, mcpHeight, m.focus == focusMcp))
|
||||
}
|
||||
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-3)
|
||||
|
|
@ -604,8 +607,7 @@ func (m Model) sidebarHeights() (statsHeight, vulnHeight, mcpHeight, agentHeight
|
|||
mcpHeight = m.panelHeight(panelMcp, min(9, len(m.snapshot.Connections)+3))
|
||||
}
|
||||
agentHeight = m.panelHeight(panelAgents, 4)
|
||||
squeezable := []*int{&statsHeight, &mcpHeight, &vulnHeight}
|
||||
for _, h := range squeezable {
|
||||
for _, h := range []*int{&statsHeight, &mcpHeight, &vulnHeight} {
|
||||
over := m.viewerHeight() + statsHeight + vulnHeight + mcpHeight + agentHeight - m.height
|
||||
if over <= 0 {
|
||||
break
|
||||
|
|
@ -614,7 +616,7 @@ func (m Model) sidebarHeights() (statsHeight, vulnHeight, mcpHeight, agentHeight
|
|||
*h -= min(over, *h-4)
|
||||
}
|
||||
}
|
||||
for _, h := range squeezable {
|
||||
for _, h := range []*int{&mcpHeight, &vulnHeight, &statsHeight} {
|
||||
if m.viewerHeight()+statsHeight+vulnHeight+mcpHeight+agentHeight <= m.height {
|
||||
break
|
||||
}
|
||||
|
|
@ -628,8 +630,6 @@ func (m Model) sidebarHeights() (statsHeight, vulnHeight, mcpHeight, agentHeight
|
|||
vulnHeight += spare
|
||||
case m.zoomedPanel == panelMcp && mcpHeight > 1:
|
||||
mcpHeight += spare
|
||||
case m.zoomedPanel == panelStats && statsHeight > 1:
|
||||
statsHeight += spare
|
||||
case agentHeight > 1:
|
||||
agentHeight += spare
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue