Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 4 additions & 18 deletions cmd/mecatui/ui/agents_inventory.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,19 +125,8 @@ func (m Model) onAgentsInvKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bool) {
if m.agentsInv.viewport == nil {
return m, nil, true
}
switch {
case key.Matches(msg, m.keys.Down):
m.agentsInv.viewport.Move(bounded.LineDown, len(rows))
case key.Matches(msg, m.keys.Up):
m.agentsInv.viewport.Move(bounded.LineUp, len(rows))
case key.Matches(msg, m.keys.ScrollD):
m.agentsInv.viewport.Move(bounded.PageDown, len(rows))
case key.Matches(msg, m.keys.ScrollU):
m.agentsInv.viewport.Move(bounded.PageUp, len(rows))
case key.Matches(msg, m.keys.ScrollBottom):
m.agentsInv.viewport.Move(bounded.End, len(rows))
case key.Matches(msg, m.keys.ScrollTop):
m.agentsInv.viewport.Move(bounded.Top, len(rows))
if move, ok := boundedNavigationMove(msg, m.keys, bounded.LineDown, bounded.LineUp, bounded.PageDown, bounded.PageUp, bounded.End, bounded.Top); ok {
m.agentsInv.viewport.Move(move, len(rows))
}
return m, nil, true
}
Expand All @@ -153,11 +142,8 @@ func (m Model) onAgentsInvWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) {
if m.agentsInv.viewport == nil {
return m, nil
}
switch msg.Button {
case tea.MouseWheelUp:
m.agentsInv.viewport.Move(bounded.LineUp, len(rows))
case tea.MouseWheelDown:
m.agentsInv.viewport.Move(bounded.LineDown, len(rows))
if move, ok := boundedWheelMove(msg); ok {
m.agentsInv.viewport.Move(move, len(rows))
}
return m, nil
}
Expand Down
44 changes: 32 additions & 12 deletions cmd/mecatui/ui/bounded_navigation.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,40 @@ import (
"github.com/stacklok/mecatl/cmd/mecatui/ui/internal/bounded"
)

func boundedNavigationMove(msg tea.KeyPressMsg, keys keyMap) (bounded.Move, bool) {
switch {
case key.Matches(msg, keys.Up):
// The optional order preserves each caller's supported moves and binding precedence.
func boundedNavigationMove(msg tea.KeyPressMsg, keys keyMap, order ...bounded.Move) (bounded.Move, bool) {
bindings := [...]key.Binding{
bounded.LineUp: keys.Up,
bounded.LineDown: keys.Down,
bounded.PageUp: keys.ScrollU,
bounded.PageDown: keys.ScrollD,
bounded.Top: keys.ScrollTop,
bounded.End: keys.ScrollBottom,
}
if len(order) == 0 {
order = []bounded.Move{bounded.LineUp, bounded.LineDown, bounded.PageUp, bounded.PageDown, bounded.Top, bounded.End}
}
for _, move := range order {
if key.Matches(msg, bindings[move]) {
return move, true
}
}
return 0, false
}

// Filterable pickers reserve printable Up/Down bindings for text input.
func boundedFilterNavigationMove(msg tea.KeyPressMsg, keys keyMap, order ...bounded.Move) (bounded.Move, bool) {
keys.Up = key.NewBinding(key.WithKeys("up"))
keys.Down = key.NewBinding(key.WithKeys("down"))
return boundedNavigationMove(msg, keys, order...)
}

func boundedWheelMove(msg tea.MouseWheelMsg) (bounded.Move, bool) {
switch msg.Button {
case tea.MouseWheelUp:
return bounded.LineUp, true
case key.Matches(msg, keys.Down):
case tea.MouseWheelDown:
return bounded.LineDown, true
case key.Matches(msg, keys.ScrollU):
return bounded.PageUp, true
case key.Matches(msg, keys.ScrollD):
return bounded.PageDown, true
case key.Matches(msg, keys.ScrollTop):
return bounded.Top, true
case key.Matches(msg, keys.ScrollBottom):
return bounded.End, true
default:
return 0, false
}
Expand Down
178 changes: 178 additions & 0 deletions cmd/mecatui/ui/bounded_navigation_precedence_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,189 @@ package ui
import (
"testing"

"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"

"github.com/stacklok/mecatl/cmd/mecatui/client"
"github.com/stacklok/mecatl/cmd/mecatui/ui/internal/bounded"
)

func TestBoundedNavigationReaderHistoricalPrecedence(t *testing.T) {
for _, tc := range []struct {
name string
actions []string
help, soul, dream int
}{
{"lines", []string{"Up", "Down"}, 11, 11, 11},
{"page before line only in help", []string{"Down", "ScrollD"}, 15, 11, 11},
{"pages", []string{"ScrollU", "ScrollD"}, 15, 15, 15},
{"ends", []string{"ScrollTop", "ScrollBottom"}, 95, 95, 10},
} {
t.Run(tc.name, func(t *testing.T) {
overrides := map[string][]string{}
for _, action := range tc.actions {
overrides[action] = []string{"z"}
}
deps := surfaceDeps{keys: applyKeyOverrides(defaultKeys(), overrides)}
viewport := func() *bounded.Viewport {
v := new(bounded.Viewport)
v.SetGeometry(40, 5, 0, bounded.Clip)
v.Move(bounded.PageDown, 100)
v.Move(bounded.PageDown, 100)
return v
}
h := &helpState{deps: deps, viewport: *viewport(), total: 100}
s := &soulState{deps: deps, viewport: viewport(), total: 100}
d := &dreamState{deps: deps, view: dreamReview, viewport: viewport(), readerRows: 100}
for _, reader := range []struct {
name string
handle func(tea.KeyPressMsg) (tea.Cmd, bool, bool)
v *bounded.Viewport
want int
}{
{"help", h.HandleKey, &h.viewport, tc.help},
{"soul", s.HandleKey, s.viewport, tc.soul},
{"dream", d.HandleKey, d.viewport, tc.dream},
} {
reader.handle(navigationTestKey("z"))
if got := reader.v.Offset(); got != reader.want {
t.Errorf("%s offset = %d, want %d", reader.name, got, reader.want)
}
}
})
}
}

func TestBoundedNavigationReaderCloseAndHelpPrecedeMovement(t *testing.T) {
deps := surfaceDeps{keys: applyKeyOverrides(defaultKeys(), map[string][]string{"Close": {"q"}, "Down": {"q"}, "Help": {"z"}, "ScrollD": {"z"}})}
v := new(bounded.Viewport)
v.SetGeometry(40, 5, 0, bounded.Clip)
h := &helpState{deps: deps, viewport: *v, total: 100}
s := &soulState{deps: deps, viewport: v, total: 100}
d := &dreamState{deps: deps, view: dreamReview, viewport: v, readerRows: 100}
if _, _, closed := h.HandleKey(navigationTestKey("z")); !closed || h.viewport.Offset() != 0 {
t.Fatal("Help must close before overlapping page movement")
}
if _, _, closed := h.HandleKey(navigationTestKey("q")); !closed || h.viewport.Offset() != 0 {
t.Fatal("Help Close must precede Down")
}
if _, _, closed := s.HandleKey(navigationTestKey("q")); !closed || v.Offset() != 0 {
t.Fatal("Soul Close must precede Down")
}
d.HandleKey(navigationTestKey("q"))
if d.view != dreamTargets || v.Offset() != 0 {
t.Fatal("Dream back action must precede Down")
}
}

func TestBoundedNavigationToolcallsMovementBeforeChoose(t *testing.T) {
m := addToolcallsForTest(t, newToolcallsInspectorModel(t), 30)
m = openToolcallsForTest(t, m)
s := toolcallsForTest(t, m)
s.Render(80, 12)
s.list.SetCursor(10)
s.selected = 10
s.deps.keys = applyKeyOverrides(s.deps.keys, map[string][]string{"Up": {"z"}, "Down": {"z"}, "Choose": {"z"}})
s.HandleKey(navigationTestKey("z"))
if s.selected != 11 || s.detail {
t.Fatal("Toolcalls Down must precede Up and Choose")
}
s.deps.keys.Close = key.NewBinding(key.WithKeys("z"))
if _, _, closed := s.HandleKey(navigationTestKey("z")); !closed || s.selected != 11 {
t.Fatal("Toolcalls Close must precede navigation and Choose")
}
}

func TestBoundedNavigationInventoryHistoricalPrecedence(t *testing.T) {
m := newAgentsInvModel(t, scrollAgents(60), client.Capabilities{Agents: true})
opened, cmd := m.openAgentsInv()
m = feedCmd(t, opened.(Model), cmd)
m.keys = applyKeyOverrides(m.keys, map[string][]string{"Up": {"z"}, "Down": {"z"}, "ScrollD": {"z"}})
updated, _, _ := m.onAgentsInvKey(navigationTestKey("z"))
m = updated.(Model)
if m.agentsInv.viewport.Offset() != 1 {
t.Fatal("inventory Down must precede Up and PageDown")
}
m.keys.Close = key.NewBinding(key.WithKeys("z"))
updated, _, _ = m.onAgentsInvKey(navigationTestKey("z"))
if updated.(Model).agentsInv.view != agentsInvNone {
t.Fatal("inventory Close must precede movement")
}
}

func TestBoundedReaderWheelClassification(t *testing.T) {
h := &helpState{total: 100}
s := &soulState{viewport: new(bounded.Viewport), total: 100}
d := &dreamState{view: dreamReview, viewport: new(bounded.Viewport), readerRows: 100}
u := &userModelState{view: userModelDetail, viewport: new(bounded.Viewport), detailLines: make([]string, 100)}
for _, reader := range []struct {
name string
handle func(tea.MouseWheelMsg) (tea.Cmd, bool)
v *bounded.Viewport
}{
{"help", h.HandleWheel, &h.viewport},
{"soul", s.HandleWheel, s.viewport},
{"dream", d.HandleWheel, d.viewport},
{"memory", u.HandleWheel, u.viewport},
} {
t.Run(reader.name, func(t *testing.T) {
reader.v.SetGeometry(40, 5, 0, bounded.Clip)
for _, step := range []struct {
button tea.MouseButton
want int
}{
{tea.MouseWheelDown, 1}, {tea.MouseWheelLeft, 1}, {tea.MouseWheelRight, 1}, {tea.MouseNone, 1}, {tea.MouseWheelUp, 0},
} {
reader.handle(tea.MouseWheelMsg{Button: step.button})
if got := reader.v.Offset(); got != step.want {
t.Fatalf("button %v offset = %d, want %d", step.button, got, step.want)
}
}
})
}
d.compact, u.compact = true, true
d.HandleWheel(tea.MouseWheelMsg{Button: tea.MouseWheelDown})
u.HandleWheel(tea.MouseWheelMsg{Button: tea.MouseWheelDown})
if d.viewport.Offset() != 0 || u.viewport.Offset() != 0 {
t.Fatal("compact readers accepted wheel movement")
}
d.compact, d.view = false, dreamConfirmApply
d.HandleWheel(tea.MouseWheelMsg{Button: tea.MouseWheelDown})
if d.viewport.Offset() != 0 {
t.Fatal("Dream confirmation accepted reader movement")
}
}

func TestToolcallsWheelRetainsLegacyNonUpMovement(t *testing.T) {
m := addToolcallsForTest(t, newToolcallsInspectorModel(t), 30)
m = openToolcallsForTest(t, m)
s := toolcallsForTest(t, m)
s.selected = 0
s.Render(80, 12)
selected := s.list.CursorID()
for _, detail := range []bool{false, true} {
s.detail = detail
s.window = new(bounded.Viewport)
s.window.SetGeometry(80, 5, 0, bounded.Clip)
s.lines, s.width = 100, 0
for _, step := range []struct {
button tea.MouseButton
want int
}{
{tea.MouseWheelLeft, 1}, {tea.MouseWheelRight, 2}, {tea.MouseNone, 3}, {tea.MouseWheelDown, 4}, {tea.MouseWheelUp, 3},
} {
s.HandleWheel(tea.MouseWheelMsg{Button: step.button})
got := s.list.Offset()
if detail {
got = s.window.Offset()
}
if got != step.want || s.list.CursorID() != selected {
t.Fatalf("detail=%t button=%v offset=%d, want %d and unchanged selection", detail, step.button, got, step.want)
}
}
}
}

func TestBoundedNavigationSessionsLocalActionsPrecedeMovement(t *testing.T) {
m, _ := hitDispatchModel(t)
m.phase = phaseIdle
Expand Down
80 changes: 80 additions & 0 deletions cmd/mecatui/ui/bounded_navigation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
)

func TestBoundedNavigationMove(t *testing.T) {
disabled := defaultKeys()
disabled.Down.SetEnabled(false)
for _, tc := range []struct {
name string
keys keyMap
Expand All @@ -30,6 +32,7 @@ func TestBoundedNavigationMove(t *testing.T) {
{"remapped top", navigationTestKeys(), navigationTestKey("t"), bounded.Top, true},
{"remapped bottom", navigationTestKeys(), navigationTestKey("b"), bounded.End, true},
{"unmatched", defaultKeys(), navigationTestKey("x"), 0, false},
{"disabled", disabled, tea.KeyPressMsg{Code: tea.KeyDown}, 0, false},
{"overlap keeps declared precedence", navigationOverlapTestKeys(), navigationTestKey("x"), bounded.LineUp, true},
} {
t.Run(tc.name, func(t *testing.T) {
Expand All @@ -41,6 +44,83 @@ func TestBoundedNavigationMove(t *testing.T) {
}
}

func TestBoundedNavigationOrderedSubset(t *testing.T) {
keys := navigationOverlapTestKeys()
keys.ScrollD = key.NewBinding(key.WithKeys("x"))
keys.ScrollBottom = key.NewBinding(key.WithKeys("x"))
for _, tc := range []struct {
name string
order []bounded.Move
msg tea.KeyPressMsg
want bounded.Move
ok bool
}{
{"down first", []bounded.Move{bounded.LineDown, bounded.LineUp}, navigationTestKey("x"), bounded.LineDown, true},
{"page first", []bounded.Move{bounded.PageDown, bounded.LineDown}, navigationTestKey("x"), bounded.PageDown, true},
{"end only", []bounded.Move{bounded.End}, navigationTestKey("x"), bounded.End, true},
{"excluded page", []bounded.Move{bounded.LineUp, bounded.LineDown}, navigationTestKey("p"), 0, false},
{"excluded top", []bounded.Move{bounded.PageDown, bounded.PageUp}, navigationTestKey("t"), 0, false},
} {
t.Run(tc.name, func(t *testing.T) {
if got, ok := boundedNavigationMove(tc.msg, keys, tc.order...); got != tc.want || ok != tc.ok {
t.Fatalf("move = (%v, %t), want (%v, %t)", got, ok, tc.want, tc.ok)
}
})
}
}

func TestBoundedFilterNavigationMove(t *testing.T) {
keys := navigationTestKeys()
keys.Up.SetEnabled(false)
keys.Down.SetEnabled(false)
keys.ScrollTop.SetEnabled(false)
for _, tc := range []struct {
name string
msg tea.KeyPressMsg
want bounded.Move
ok bool
}{
{"fixed up", tea.KeyPressMsg{Code: tea.KeyUp}, bounded.LineUp, true},
{"fixed down", tea.KeyPressMsg{Code: tea.KeyDown}, bounded.LineDown, true},
{"remapped up is text", navigationTestKey("u"), 0, false},
{"remapped down is text", navigationTestKey("d"), 0, false},
{"j is text", navigationTestKey("j"), 0, false},
{"k is text", navigationTestKey("k"), 0, false},
{"page up", navigationTestKey("p"), bounded.PageUp, true},
{"page down", navigationTestKey("n"), bounded.PageDown, true},
{"disabled top", navigationTestKey("t"), 0, false},
{"end", navigationTestKey("b"), bounded.End, true},
{"old page", tea.KeyPressMsg{Code: tea.KeyPgDown}, 0, false},
} {
t.Run(tc.name, func(t *testing.T) {
if got, ok := boundedFilterNavigationMove(tc.msg, keys); got != tc.want || ok != tc.ok {
t.Fatalf("move = (%v, %t), want (%v, %t)", got, ok, tc.want, tc.ok)
}
})
}
if keys.Up.Enabled() || keys.Down.Enabled() || keys.Up.Keys()[0] != "u" || keys.Down.Keys()[0] != "d" {
t.Fatal("filter navigation mutated shared bindings")
}
}

func TestBoundedWheelMove(t *testing.T) {
for _, tc := range []struct {
button tea.MouseButton
want bounded.Move
ok bool
}{
{tea.MouseWheelUp, bounded.LineUp, true},
{tea.MouseWheelDown, bounded.LineDown, true},
{tea.MouseWheelLeft, 0, false},
{tea.MouseWheelRight, 0, false},
{tea.MouseNone, 0, false},
} {
if got, ok := boundedWheelMove(tea.MouseWheelMsg{Button: tc.button}); got != tc.want || ok != tc.ok {
t.Errorf("button %v = (%v, %t), want (%v, %t)", tc.button, got, ok, tc.want, tc.ok)
}
}
}

func navigationTestKeys() keyMap {
return applyKeyOverrides(defaultKeys(), map[string][]string{
"Up": {"u"}, "Down": {"d"}, "ScrollU": {"p"}, "ScrollD": {"n"}, "ScrollTop": {"t"}, "ScrollBottom": {"b"},
Expand Down
6 changes: 4 additions & 2 deletions cmd/mecatui/ui/card_layout_regions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,10 @@ func TestMecatuiCardLayout_Scenario3_InventoryAndMCPFitWidth(t *testing.T) {
t.Run("sessions and worktrees", func(t *testing.T) {
st := sessionsState{filtered: []client.SessionListItem{{ID: "s", Title: long, State: long, ModelID: long, Relationship: client.SessionRelationship{MemberName: long}}}, handles: map[string]string{"s": long}}
assertFits(t, "sessions", renderSessionsPanel(th, st, client.Capabilities{}, hk, width, 20))
wt := worktreesState{view: worktreesPanel, filtered: []client.Worktree{{Label: long, Branch: long}}}
assertFits(t, "worktrees", renderWorktreesPanel(th, wt, client.Capabilities{}, hk, width, 20))
wt := worktreesState{deps: surfaceDeps{theme: th, keys: defaultKeys(), marks: hk}, identities: make(map[client.WorktreeSelector]string), worktrees: []client.Worktree{{Selector: testWorktreeSelector("long"), Label: long, Branch: long}}}
wt.syncFilter()
out, _ := wt.Render(width, 20)
assertFits(t, "worktrees", out)
})

t.Run("agents team and parallel", func(t *testing.T) {
Expand Down
Loading
Loading