diff --git a/internal/tui/app.go b/internal/tui/app.go index f933969..1105f64 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -327,7 +327,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil } return a, nil - case "j", "k": + case "j", "k", "g", "G": switch a.activeTab { case 0: a.analysisView.Update(msg) diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index 97bac25..1e48721 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -595,3 +595,165 @@ func TestApp_YankKeyIgnoredWhenFilterActive(t *testing.T) { t.Error("expected 'y' to be forwarded to filter, not trigger copy") } } + +// TestApp_SessionsGGJumpsToStart verifies that pressing 'g' twice on the Sessions tab +// routes through app.Update() and jumps the selection to the first item. +// This complements TestSessionsView_GGJumpsToStart in sessions_test.go, which tests +// the view's key handling in isolation. This test catches routing bugs in app.Update() +// where keys may not be forwarded to the view at all. +func TestApp_SessionsGGJumpsToStart(t *testing.T) { + s := store.New() + now := time.Now() + for i := range 5 { + s.Add(&model.SessionMeta{ + UUID: fmt.Sprintf("u%d", i), + Slug: fmt.Sprintf("session-%d", i), + ProjectPath: "-p", + StartTime: now.Add(time.Duration(i) * time.Hour), + Models: map[string]int{}, + ToolUsage: map[string]int{}, + SkillsUsed: map[string]int{}, + CommandsUsed: map[string]int{}, + FileOps: map[string]int{}, + }) + } + + app := NewApp(s, "") + updated, _ := app.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + app = updated.(App) + app.activeTab = 2 + app.View() // populate rows + + // Navigate down + updated, _ = app.Update(tea.KeyMsg{Type: tea.KeyDown}) + app = updated.(App) + updated, _ = app.Update(tea.KeyMsg{Type: tea.KeyDown}) + app = updated.(App) + + // Press gg + updated, _ = app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}}) + app = updated.(App) + updated, _ = app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}}) + app = updated.(App) + + if app.sessionsView.Selected() != 0 { + t.Errorf("expected selected=0 after gg, got %d", app.sessionsView.Selected()) + } +} + +// TestApp_SessionsGJumpsToEnd verifies that pressing 'G' on the Sessions tab +// routes through app.Update() and jumps the selection to the last item. +// This complements TestSessionsView_GJumpsToEnd in sessions_test.go, which tests +// the view's key handling in isolation. This test catches routing bugs in app.Update() +// where keys may not be forwarded to the view at all. +func TestApp_SessionsGJumpsToEnd(t *testing.T) { + s := store.New() + now := time.Now() + for i := range 5 { + s.Add(&model.SessionMeta{ + UUID: fmt.Sprintf("u%d", i), + Slug: fmt.Sprintf("session-%d", i), + ProjectPath: "-p", + StartTime: now.Add(time.Duration(i) * time.Hour), + Models: map[string]int{}, + ToolUsage: map[string]int{}, + SkillsUsed: map[string]int{}, + CommandsUsed: map[string]int{}, + FileOps: map[string]int{}, + }) + } + + app := NewApp(s, "") + updated, _ := app.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + app = updated.(App) + app.activeTab = 2 + app.View() // populate rows + + updated, _ = app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}}) + app = updated.(App) + + if app.sessionsView.Selected() != 4 { + t.Errorf("expected selected=4 after G, got %d", app.sessionsView.Selected()) + } +} + +// TestApp_ProjectsGGJumpsToStart verifies that pressing 'g' twice on the Projects tab +// routes through app.Update() and jumps the selection to the first item. +// This complements TestProjectsView_GGJumpsToStart in projects_test.go, which tests +// the view's key handling in isolation. This test catches routing bugs in app.Update() +// where keys may not be forwarded to the view at all. +func TestApp_ProjectsGGJumpsToStart(t *testing.T) { + s := store.New() + now := time.Now() + for i := range 5 { + s.Add(&model.SessionMeta{ + UUID: fmt.Sprintf("u%d", i), + Slug: fmt.Sprintf("s%d", i), + ProjectPath: fmt.Sprintf("-p%d", i), + StartTime: now.Add(time.Duration(i) * time.Hour), + Models: map[string]int{}, + ToolUsage: map[string]int{}, + SkillsUsed: map[string]int{}, + CommandsUsed: map[string]int{}, + FileOps: map[string]int{}, + }) + } + + app := NewApp(s, "") + updated, _ := app.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + app = updated.(App) + app.activeTab = 1 + app.View() // populate lastRows + + // Navigate down + updated, _ = app.Update(tea.KeyMsg{Type: tea.KeyDown}) + app = updated.(App) + updated, _ = app.Update(tea.KeyMsg{Type: tea.KeyDown}) + app = updated.(App) + + // Press gg + updated, _ = app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}}) + app = updated.(App) + updated, _ = app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}}) + app = updated.(App) + + if app.projectsView.Selected() != 0 { + t.Errorf("expected selected=0 after gg, got %d", app.projectsView.Selected()) + } +} + +// TestApp_ProjectsGJumpsToEnd verifies that pressing 'G' on the Projects tab +// routes through app.Update() and jumps the selection to the last item. +// This complements TestProjectsView_GJumpsToEnd in projects_test.go, which tests +// the view's key handling in isolation. This test catches routing bugs in app.Update() +// where keys may not be forwarded to the view at all. +func TestApp_ProjectsGJumpsToEnd(t *testing.T) { + s := store.New() + now := time.Now() + for i := range 5 { + s.Add(&model.SessionMeta{ + UUID: fmt.Sprintf("u%d", i), + Slug: fmt.Sprintf("s%d", i), + ProjectPath: fmt.Sprintf("-p%d", i), + StartTime: now.Add(time.Duration(i) * time.Hour), + Models: map[string]int{}, + ToolUsage: map[string]int{}, + SkillsUsed: map[string]int{}, + CommandsUsed: map[string]int{}, + FileOps: map[string]int{}, + }) + } + + app := NewApp(s, "") + updated, _ := app.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + app = updated.(App) + app.activeTab = 1 + app.View() // populate lastRows + + updated, _ = app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}}) + app = updated.(App) + + if app.projectsView.Selected() != 4 { + t.Errorf("expected selected=4 after G, got %d", app.projectsView.Selected()) + } +} diff --git a/internal/tui/views/projects.go b/internal/tui/views/projects.go index 8f8fa17..646f16e 100644 --- a/internal/tui/views/projects.go +++ b/internal/tui/views/projects.go @@ -24,6 +24,7 @@ type ProjectsView struct { selected int filter *components.Filter lastRows []ProjectRow + lastKey string // track last key for gg detection } // NewProjectsView creates a new ProjectsView. @@ -36,9 +37,11 @@ func (v *ProjectsView) Update(msg tea.KeyMsg) { // Forward to filter first if v.filter.Update(msg) { v.selected = 0 + v.lastKey = "" return } + maxIdx := len(v.lastRows) - 1 switch msg.Type { case tea.KeyUp: if v.selected > 0 { @@ -54,8 +57,20 @@ func (v *ProjectsView) Update(msg tea.KeyMsg) { } case "j": v.selected++ + case "g": + if v.lastKey == "g" { + v.selected = 0 + } else { + v.lastKey = "g" + return + } + case "G": + if maxIdx >= 0 { + v.selected = maxIdx + } } } + v.lastKey = "" } // FilterActive returns true if the filter input is active. @@ -63,6 +78,11 @@ func (v *ProjectsView) FilterActive() bool { return v.filter.Active } +// Selected returns the current selected index. +func (v *ProjectsView) Selected() int { + return v.selected +} + // SelectedProject returns the Path of the currently selected project, // or "" if no projects are available. func (v *ProjectsView) SelectedProject() string { diff --git a/internal/tui/views/projects_test.go b/internal/tui/views/projects_test.go index 6bc58e1..74a59b6 100644 --- a/internal/tui/views/projects_test.go +++ b/internal/tui/views/projects_test.go @@ -1,6 +1,7 @@ package views import ( + "fmt" "strings" "testing" "time" @@ -165,3 +166,116 @@ func TestProjectsView_Empty(t *testing.T) { t.Error("expected non-empty view even when empty") } } + +// TestProjectsView_GGJumpsToStart verifies that pressing 'g' twice (vim gg) +// jumps the selection to the first item in the list, regardless of current position. +func TestProjectsView_GGJumpsToStart(t *testing.T) { + s := store.New() + now := time.Now() + for i := range 5 { + s.Add(&model.SessionMeta{ + UUID: fmt.Sprintf("u%d", i), + Slug: fmt.Sprintf("s%d", i), + ProjectPath: fmt.Sprintf("-p%d", i), + StartTime: now.Add(time.Duration(i) * time.Hour), + EndTime: now.Add(time.Duration(i)*time.Hour + time.Minute), + Models: map[string]int{}, + ToolUsage: map[string]int{}, + SkillsUsed: map[string]int{}, + CommandsUsed: map[string]int{}, + FileOps: map[string]int{}, + MessageCount: 1, + }) + } + + v := NewProjectsView(s) + v.View(80, 24) // populate lastRows + + // Navigate down + v.Update(tea.KeyMsg{Type: tea.KeyDown}) + v.Update(tea.KeyMsg{Type: tea.KeyDown}) + if v.selected != 2 { + t.Fatalf("expected selected=2 after navigation, got %d", v.selected) + } + + // Press 'g' twice to jump to start + v.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}}) + v.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}}) + + if v.selected != 0 { + t.Errorf("expected selected=0 after gg, got %d", v.selected) + } +} + +// TestProjectsView_GJumpsToEnd verifies that pressing 'G' (vim G) +// jumps the selection to the last item in the list. +func TestProjectsView_GJumpsToEnd(t *testing.T) { + s := store.New() + now := time.Now() + for i := range 5 { + s.Add(&model.SessionMeta{ + UUID: fmt.Sprintf("u%d", i), + Slug: fmt.Sprintf("s%d", i), + ProjectPath: fmt.Sprintf("-p%d", i), + StartTime: now.Add(time.Duration(i) * time.Hour), + EndTime: now.Add(time.Duration(i)*time.Hour + time.Minute), + Models: map[string]int{}, + ToolUsage: map[string]int{}, + SkillsUsed: map[string]int{}, + CommandsUsed: map[string]int{}, + FileOps: map[string]int{}, + MessageCount: 1, + }) + } + + v := NewProjectsView(s) + v.View(80, 24) // populate lastRows + + // Press 'G' to jump to end + v.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}}) + + if v.selected != 4 { + t.Errorf("expected selected=4 after G, got %d", v.selected) + } +} + +// TestProjectsView_GGAfterNavigation verifies that gg works correctly +// after navigating with other keys, resetting position to start. +func TestProjectsView_GGAfterNavigation(t *testing.T) { + s := store.New() + now := time.Now() + for i := range 5 { + s.Add(&model.SessionMeta{ + UUID: fmt.Sprintf("u%d", i), + Slug: fmt.Sprintf("s%d", i), + ProjectPath: fmt.Sprintf("-p%d", i), + StartTime: now.Add(time.Duration(i) * time.Hour), + EndTime: now.Add(time.Duration(i)*time.Hour + time.Minute), + Models: map[string]int{}, + ToolUsage: map[string]int{}, + SkillsUsed: map[string]int{}, + CommandsUsed: map[string]int{}, + FileOps: map[string]int{}, + MessageCount: 1, + }) + } + + v := NewProjectsView(s) + v.View(80, 24) + + // Navigate down with j + v.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + v.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + v.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + if v.selected != 3 { + t.Fatalf("expected selected=3 after j navigation, got %d", v.selected) + } + + // Press gg to jump back to start + v.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}}) + v.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}}) + + if v.selected != 0 { + t.Errorf("expected selected=0 after gg, got %d", v.selected) + } +} diff --git a/internal/tui/views/sessions.go b/internal/tui/views/sessions.go index 24b5587..f632b03 100644 --- a/internal/tui/views/sessions.go +++ b/internal/tui/views/sessions.go @@ -18,6 +18,7 @@ type SessionsView struct { selected int rows []*model.SessionMeta // cached sorted list filter *components.Filter + lastKey string // track last key for gg detection } // NewSessionsView creates a new SessionsView. @@ -50,17 +51,19 @@ func (v *SessionsView) Update(msg tea.KeyMsg) { // Forward to filter first if v.filter.Update(msg) { v.selected = 0 + v.lastKey = "" return } v.refreshRows() + maxIdx := len(v.rows) - 1 switch msg.Type { case tea.KeyUp: if v.selected > 0 { v.selected-- } case tea.KeyDown: - if v.selected < len(v.rows)-1 { + if v.selected < maxIdx { v.selected++ } case tea.KeyRunes: @@ -70,11 +73,23 @@ func (v *SessionsView) Update(msg tea.KeyMsg) { v.selected-- } case "j": - if v.selected < len(v.rows)-1 { + if v.selected < maxIdx { v.selected++ } + case "g": + if v.lastKey == "g" { + v.selected = 0 + } else { + v.lastKey = "g" + return + } + case "G": + if maxIdx >= 0 { + v.selected = maxIdx + } } } + v.lastKey = "" } // FilterActive returns true if the filter input is active. @@ -82,6 +97,11 @@ func (v *SessionsView) FilterActive() bool { return v.filter.Active } +// Selected returns the current selected index. +func (v *SessionsView) Selected() int { + return v.selected +} + // SelectedSession returns the currently selected session, or nil. func (v *SessionsView) SelectedSession() *model.SessionMeta { v.refreshRows() diff --git a/internal/tui/views/sessions_test.go b/internal/tui/views/sessions_test.go index 9235707..037617b 100644 --- a/internal/tui/views/sessions_test.go +++ b/internal/tui/views/sessions_test.go @@ -1,6 +1,7 @@ package views import ( + "fmt" "strings" "testing" "time" @@ -253,3 +254,110 @@ func TestVisibleSessions(t *testing.T) { t.Errorf("expected first visible session 'beta', got %q", rows[0].Slug) } } + +// TestSessionsView_GGJumpsToStart verifies that pressing 'g' twice (vim gg) +// jumps the selection to the first item in the list, regardless of current position. +func TestSessionsView_GGJumpsToStart(t *testing.T) { + s := store.New() + now := time.Now() + for i := range 5 { + s.Add(&model.SessionMeta{ + UUID: fmt.Sprintf("u%d", i), + Slug: fmt.Sprintf("session-%d", i), + ProjectPath: "-p", + StartTime: now.Add(time.Duration(i) * time.Hour), + Models: map[string]int{}, + ToolUsage: map[string]int{}, + SkillsUsed: map[string]int{}, + CommandsUsed: map[string]int{}, + FileOps: map[string]int{}, + }) + } + + v := NewSessionsView(s) + v.View(100, 24) // populate rows + + // Navigate to the end + v.Update(tea.KeyMsg{Type: tea.KeyDown}) + v.Update(tea.KeyMsg{Type: tea.KeyDown}) + if v.selected != 2 { + t.Fatalf("expected selected=2 after navigation, got %d", v.selected) + } + + // Press 'g' twice to jump to start + v.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}}) + v.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}}) + + if v.selected != 0 { + t.Errorf("expected selected=0 after gg, got %d", v.selected) + } +} + +// TestSessionsView_GJumpsToEnd verifies that pressing 'G' (vim G) +// jumps the selection to the last item in the list. +func TestSessionsView_GJumpsToEnd(t *testing.T) { + s := store.New() + now := time.Now() + for i := range 5 { + s.Add(&model.SessionMeta{ + UUID: fmt.Sprintf("u%d", i), + Slug: fmt.Sprintf("session-%d", i), + ProjectPath: "-p", + StartTime: now.Add(time.Duration(i) * time.Hour), + Models: map[string]int{}, + ToolUsage: map[string]int{}, + SkillsUsed: map[string]int{}, + CommandsUsed: map[string]int{}, + FileOps: map[string]int{}, + }) + } + + v := NewSessionsView(s) + v.View(100, 24) // populate rows + + // Press 'G' to jump to end + v.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}}) + + if v.selected != 4 { + t.Errorf("expected selected=4 after G, got %d", v.selected) + } +} + +// TestSessionsView_GGAfterNavigation verifies that gg works correctly +// after navigating with other keys, resetting position to start. +func TestSessionsView_GGAfterNavigation(t *testing.T) { + s := store.New() + now := time.Now() + for i := range 5 { + s.Add(&model.SessionMeta{ + UUID: fmt.Sprintf("u%d", i), + Slug: fmt.Sprintf("session-%d", i), + ProjectPath: "-p", + StartTime: now.Add(time.Duration(i) * time.Hour), + Models: map[string]int{}, + ToolUsage: map[string]int{}, + SkillsUsed: map[string]int{}, + CommandsUsed: map[string]int{}, + FileOps: map[string]int{}, + }) + } + + v := NewSessionsView(s) + v.View(100, 24) + + // Navigate down with j + v.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + v.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + v.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + if v.selected != 3 { + t.Fatalf("expected selected=3 after j navigation, got %d", v.selected) + } + + // Press gg to jump back to start + v.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}}) + v.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}}) + + if v.selected != 0 { + t.Errorf("expected selected=0 after gg, got %d", v.selected) + } +}