From ecad431523a6b00fb00db75907f1009c6bd2245c Mon Sep 17 00:00:00 2001 From: "suhird.singh" Date: Wed, 24 Jun 2026 13:38:25 -0400 Subject: [PATCH 1/6] i18n: add scroll-to-top/bottom shortcut descriptions Add GotoTop and GotoBottom translation entries used by the upcoming vim-style gg/G navigation bindings in the main panel. --- pkg/i18n/english.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index d86b0c84..4d119227 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -63,6 +63,8 @@ type TranslationSet struct { Recreate string PreviousContext string NextContext string + GotoTop string + GotoBottom string Attach string ViewLogs string UpProject string @@ -194,6 +196,8 @@ func englishSet() TranslationSet { Recreate: "recreate", PreviousContext: "previous tab", NextContext: "next tab", + GotoTop: "scroll to top", + GotoBottom: "scroll to bottom", Attach: "attach", ViewLogs: "view logs", UpProject: "up project", From 1a71302f11edeefd484db5d43dd8a5245cb95460 Mon Sep 17 00:00:00 2001 From: "suhird.singh" Date: Wed, 24 Jun 2026 13:39:16 -0400 Subject: [PATCH 2/6] gui: add gotoTopMain/gotoBottomMain navigation handlers Add handlers that scroll the main panel to the top and bottom. gotoTopMain implements the vim-style "gg" double press (gocui has no native key-sequence support) by tracking the last 'g' press timestamp in the gui state, while gotoBottomMain jumps to the final line, honouring the ScrollPastBottom config. The origin math and double-press timing are factored into pure helpers so they can be unit tested. --- pkg/gui/gui.go | 4 ++++ pkg/gui/main_panel.go | 50 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 6ea2ee6c..f3b076c6 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -87,6 +87,10 @@ type guiState struct { // Maintains the state of manual filtering i.e. typing in a substring // to filter on in the current panel. Filter filterState + + // Timestamp of the last 'g' key press in the main panel, used to detect + // the vim-style "gg" double press that jumps to the top. + lastGPressedAt time.Time } type filterState struct { diff --git a/pkg/gui/main_panel.go b/pkg/gui/main_panel.go index 004147be..ebd628b0 100644 --- a/pkg/gui/main_panel.go +++ b/pkg/gui/main_panel.go @@ -2,10 +2,33 @@ package gui import ( "math" + "time" "github.com/jesseduffield/gocui" ) +// gPressTimeout is the window within which two consecutive 'g' presses are +// treated as the vim-style "gg" command to jump to the top of the main panel. +const gPressTimeout = 250 * time.Millisecond + +// isDoublePress reports whether `now` follows `previous` closely enough to be +// considered the second half of a double key press. +func isDoublePress(previous, now time.Time, timeout time.Duration) bool { + return !previous.IsZero() && now.Sub(previous) <= timeout +} + +// gotoBottomOriginY returns the vertical origin that scrolls the main panel to +// its final line. When scrollPastBottom is false the last page of content is +// kept on screen rather than scrolling it off the top. +func gotoBottomOriginY(totalLines, viewHeight int, scrollPastBottom bool) int { + reservedLines := 0 + if !scrollPastBottom { + reservedLines = viewHeight + } + + return int(math.Max(0, float64(totalLines-reservedLines))) +} + func (gui *Gui) scrollUpMain() error { mainView := gui.Views.Main mainView.Autoscroll = false @@ -73,6 +96,33 @@ func (gui *Gui) jumpToTopMain(g *gocui.Gui, v *gocui.View) error { return nil } +// gotoTopMain implements the vim-style "gg" command: the first 'g' arms the +// sequence and the second 'g' pressed within gPressTimeout jumps to the top of +// the main panel. +func (gui *Gui) gotoTopMain(g *gocui.Gui, v *gocui.View) error { + now := time.Now() + if !isDoublePress(gui.State.lastGPressedAt, now, gPressTimeout) { + gui.State.lastGPressedAt = now + return nil + } + + gui.State.lastGPressedAt = time.Time{} + return gui.jumpToTopMain(g, v) +} + +// gotoBottomMain implements the vim-style "G" command, jumping to the bottom of +// the main panel. +func (gui *Gui) gotoBottomMain(g *gocui.Gui, v *gocui.View) error { + mainView := gui.Views.Main + mainView.Autoscroll = false + + _, viewHeight := mainView.Size() + ox, _ := mainView.Origin() + newOy := gotoBottomOriginY(mainView.ViewLinesHeight(), viewHeight, gui.Config.UserConfig.Gui.ScrollPastBottom) + + return mainView.SetOrigin(ox, newOy) +} + func (gui *Gui) onMainTabClick(tabIndex int) error { gui.Log.Warn(tabIndex) From a20fa161cc6d6416da5fd6f38a8512ee06175e42 Mon Sep 17 00:00:00 2001 From: "suhird.singh" Date: Wed, 24 Jun 2026 13:39:42 -0400 Subject: [PATCH 3/6] gui: bind g/G to vim-style top/bottom navigation in the main panel Pressing 'gg' jumps to the top and 'G' jumps to the bottom of the main panel, matching vim navigation. The bindings are scoped to the main view so they apply across all of its tabs (Config, Logs, Env, Stats, Top). --- pkg/gui/keybindings.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go index df706024..1342e4ff 100644 --- a/pkg/gui/keybindings.go +++ b/pkg/gui/keybindings.go @@ -461,6 +461,20 @@ func (gui *Gui) GetInitialKeybindings() []*Binding { Modifier: gocui.ModNone, Handler: gui.scrollRightMain, }, + { + ViewName: "main", + Key: 'g', + Modifier: gocui.ModNone, + Handler: gui.gotoTopMain, + Description: gui.Tr.GotoTop, + }, + { + ViewName: "main", + Key: 'G', + Modifier: gocui.ModNone, + Handler: gui.gotoBottomMain, + Description: gui.Tr.GotoBottom, + }, { ViewName: "filter", Key: gocui.KeyEnter, From 4298b4cfaa8478901876a8fd94360a003e907bf3 Mon Sep 17 00:00:00 2001 From: "suhird.singh" Date: Wed, 24 Jun 2026 13:40:11 -0400 Subject: [PATCH 4/6] gui: show gg/G shortcuts in the options bar for the main panel When the main panel is focused (e.g. the Container Config screen) the options bar now lists the gg/G scroll-to-top/bottom shortcuts alongside the existing scroll and navigation hints. --- pkg/gui/gui.go | 10 ++++++++++ pkg/gui/view_helpers.go | 2 ++ 2 files changed, 12 insertions(+) diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index f3b076c6..266378e9 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -171,6 +171,16 @@ func (gui *Gui) renderGlobalOptions() error { }) } +func (gui *Gui) renderMainOptions() error { + return gui.renderOptionsMap(map[string]string{ + "PgUp/PgDn": gui.Tr.Scroll, + "← → ↑ ↓": gui.Tr.Navigate, + "gg": gui.Tr.GotoTop, + "G": gui.Tr.GotoBottom, + "esc": gui.Tr.Return, + }) +} + func (gui *Gui) goEvery(interval time.Duration, function func() error) { _ = function() // time.Tick doesn't run immediately so we'll do that here // TODO: maybe change go func() { diff --git a/pkg/gui/view_helpers.go b/pkg/gui/view_helpers.go index 6acbb18c..5bb354e7 100644 --- a/pkg/gui/view_helpers.go +++ b/pkg/gui/view_helpers.go @@ -230,6 +230,8 @@ func (gui *Gui) renderPanelOptions() error { return gui.renderMenuOptions() case "confirmation": return gui.renderConfirmationOptions() + case "main": + return gui.renderMainOptions() } return gui.renderGlobalOptions() } From f7a5d519fb8767ba2ea0811349a4f7280c8a7b08 Mon Sep 17 00:00:00 2001 From: "suhird.singh" Date: Wed, 24 Jun 2026 13:41:17 -0400 Subject: [PATCH 5/6] gui: test main panel goto-top/bottom helpers Cover the gotoBottomOriginY origin math (scroll-past-bottom on/off, content shorter than the view, empty content) and the isDoublePress timing used to detect the vim-style "gg" double press. --- pkg/gui/main_panel_test.go | 100 +++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 pkg/gui/main_panel_test.go diff --git a/pkg/gui/main_panel_test.go b/pkg/gui/main_panel_test.go new file mode 100644 index 00000000..0600779e --- /dev/null +++ b/pkg/gui/main_panel_test.go @@ -0,0 +1,100 @@ +package gui + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestGotoBottomOriginY(t *testing.T) { + type scenario struct { + name string + totalLines int + viewHeight int + scrollPastBottom bool + expected int + } + + scenarios := []scenario{ + { + name: "keeps the last page on screen when not scrolling past bottom", + totalLines: 100, + viewHeight: 20, + scrollPastBottom: false, + expected: 80, + }, + { + name: "scrolls all content off the top when scrolling past bottom", + totalLines: 100, + viewHeight: 20, + scrollPastBottom: true, + expected: 100, + }, + { + name: "never returns a negative origin when content fits the view", + totalLines: 5, + viewHeight: 20, + scrollPastBottom: false, + expected: 0, + }, + { + name: "handles empty content", + totalLines: 0, + viewHeight: 20, + scrollPastBottom: true, + expected: 0, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + assert.Equal(t, s.expected, gotoBottomOriginY(s.totalLines, s.viewHeight, s.scrollPastBottom)) + }) + } +} + +func TestIsDoublePress(t *testing.T) { + now := time.Unix(1000, 0) + timeout := 250 * time.Millisecond + + type scenario struct { + name string + previous time.Time + now time.Time + expected bool + } + + scenarios := []scenario{ + { + name: "no previous press is not a double press", + previous: time.Time{}, + now: now, + expected: false, + }, + { + name: "second press within the timeout is a double press", + previous: now.Add(-100 * time.Millisecond), + now: now, + expected: true, + }, + { + name: "second press exactly on the timeout is a double press", + previous: now.Add(-timeout), + now: now, + expected: true, + }, + { + name: "second press after the timeout is not a double press", + previous: now.Add(-timeout - time.Millisecond), + now: now, + expected: false, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + assert.Equal(t, s.expected, isDoublePress(s.previous, s.now, timeout)) + }) + } +} From 5ef52132a997b89ab5c0766ab650b6d8379d179d Mon Sep 17 00:00:00 2001 From: "suhird.singh" Date: Wed, 24 Jun 2026 13:41:41 -0400 Subject: [PATCH 6/6] docs: regenerate keybinding cheatsheets for g/G navigation Auto-generated from the new main-panel scroll-to-top/bottom bindings. --- docs/keybindings/Keybindings_de.md | 2 ++ docs/keybindings/Keybindings_en.md | 2 ++ docs/keybindings/Keybindings_es.md | 2 ++ docs/keybindings/Keybindings_fr.md | 2 ++ docs/keybindings/Keybindings_nl.md | 2 ++ docs/keybindings/Keybindings_pl.md | 2 ++ docs/keybindings/Keybindings_pt.md | 2 ++ docs/keybindings/Keybindings_tr.md | 2 ++ docs/keybindings/Keybindings_zh.md | 2 ++ 9 files changed, 18 insertions(+) diff --git a/docs/keybindings/Keybindings_de.md b/docs/keybindings/Keybindings_de.md index dc9dd08e..f79fe76c 100644 --- a/docs/keybindings/Keybindings_de.md +++ b/docs/keybindings/Keybindings_de.md @@ -98,6 +98,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
   esc: zurück
+  g: scroll to top
+  G: scroll to bottom
 
## Global diff --git a/docs/keybindings/Keybindings_en.md b/docs/keybindings/Keybindings_en.md index ae21c4dc..e8382ca8 100644 --- a/docs/keybindings/Keybindings_en.md +++ b/docs/keybindings/Keybindings_en.md @@ -98,6 +98,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
   esc: return
+  g: scroll to top
+  G: scroll to bottom
 
## Global diff --git a/docs/keybindings/Keybindings_es.md b/docs/keybindings/Keybindings_es.md index 6b4eb921..19e49d44 100644 --- a/docs/keybindings/Keybindings_es.md +++ b/docs/keybindings/Keybindings_es.md @@ -98,6 +98,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
   esc: regresar
+  g: scroll to top
+  G: scroll to bottom
 
## Global diff --git a/docs/keybindings/Keybindings_fr.md b/docs/keybindings/Keybindings_fr.md index 109078b2..921e11c4 100644 --- a/docs/keybindings/Keybindings_fr.md +++ b/docs/keybindings/Keybindings_fr.md @@ -98,6 +98,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
   esc: retour
+  g: scroll to top
+  G: scroll to bottom
 
## Global diff --git a/docs/keybindings/Keybindings_nl.md b/docs/keybindings/Keybindings_nl.md index 24477006..ff51ee8c 100644 --- a/docs/keybindings/Keybindings_nl.md +++ b/docs/keybindings/Keybindings_nl.md @@ -98,6 +98,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
   esc: terug
+  g: scroll to top
+  G: scroll to bottom
 
## Globaal diff --git a/docs/keybindings/Keybindings_pl.md b/docs/keybindings/Keybindings_pl.md index 2f4cb93f..da178f16 100644 --- a/docs/keybindings/Keybindings_pl.md +++ b/docs/keybindings/Keybindings_pl.md @@ -98,6 +98,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
   esc: powrót
+  g: scroll to top
+  G: scroll to bottom
 
## Globalne diff --git a/docs/keybindings/Keybindings_pt.md b/docs/keybindings/Keybindings_pt.md index 32a557b5..990a23ca 100644 --- a/docs/keybindings/Keybindings_pt.md +++ b/docs/keybindings/Keybindings_pt.md @@ -98,6 +98,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
   esc: retornar
+  g: scroll to top
+  G: scroll to bottom
 
## Global diff --git a/docs/keybindings/Keybindings_tr.md b/docs/keybindings/Keybindings_tr.md index 3192a7c2..0fd14dbd 100644 --- a/docs/keybindings/Keybindings_tr.md +++ b/docs/keybindings/Keybindings_tr.md @@ -98,6 +98,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
   esc: dönüş
+  g: scroll to top
+  G: scroll to bottom
 
## Global diff --git a/docs/keybindings/Keybindings_zh.md b/docs/keybindings/Keybindings_zh.md index 0c75fbcb..8b7ed22a 100644 --- a/docs/keybindings/Keybindings_zh.md +++ b/docs/keybindings/Keybindings_zh.md @@ -98,6 +98,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
   esc: 返回
+  g: scroll to top
+  G: scroll to bottom
 
## 全局