From 597a4a71f232799efb7e216d568f4a4d7805f800 Mon Sep 17 00:00:00 2001 From: Gavin Jeong Date: Wed, 22 Apr 2026 16:07:20 +0900 Subject: [PATCH 1/2] fix: reserve g/G for session list navigation Keep sessions grouping on tab/shift+tab so g/G can behave like vim-style navigation again: gg jumps to the top and G jumps to the end. Update the sessions help text accordingly and add tests covering gg/G and tab-based group cycling. --- internal/tui/app.go | 37 +++++++++-- internal/tui/help.go | 4 +- internal/tui/session_keybindings_test.go | 84 ++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 internal/tui/session_keybindings_test.go diff --git a/internal/tui/app.go b/internal/tui/app.go index a784602..39bb74d 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -238,6 +238,7 @@ type App struct { lastMsgLoadTime time.Time liveTail bool // auto-scroll to latest message on tick termFocused bool // terminal has focus (for Kitty image cleanup) + sessPendingG bool // sessions view: first 'g' of a possible gg top-jump // Mouse state dragResizing bool @@ -1375,6 +1376,10 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { sp := &a.sessSplit key := msg.String() + if key != "g" { + a.sessPendingG = false + } + // Help overlay: any key closes it if a.showHelp { a.showHelp = false @@ -1565,10 +1570,6 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return a, nil } return a.openEditMenu(sess) - case km.Session.Group: - a.sessGroupMode = (a.sessGroupMode + 1) % numGroupModes - a.rebuildSessionList() - return a, nil case km.Session.Refresh: cmd := a.doRefresh() a.copiedMsg = "Refreshed" @@ -1602,6 +1603,33 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return a, nil } + // Sessions list vim-style jumps: gg = top, G = end. + // Handle these before TranslateNav so user-configured navigation aliases + // cannot reinterpret the first `g` as Home. + if !sp.Focus { + switch key { + case "g": + if a.sessPendingG { + a.sessPendingG = false + a.sessionList.Select(0) + return a, a.schedulePreviewUpdate() + } + a.sessPendingG = true + return a, nil + case "G", "end": + a.sessPendingG = false + items := a.sessionList.VisibleItems() + if len(items) > 0 { + a.sessionList.Select(len(items) - 1) + } + return a, a.schedulePreviewUpdate() + case "home": + a.sessPendingG = false + a.sessionList.Select(0) + return a, a.schedulePreviewUpdate() + } + } + // Translate navigation aliases (e.g. vim j→down, emacs ctrl+n→down) if nav, navMsg := a.keymap.TranslateNav(key, msg); nav != "" { key = nav @@ -1636,6 +1664,7 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return a, a.schedulePreviewUpdate() } + // Default list update oldIdx := a.sessionList.Index() m, cmd := a.updateSessionList(msg) diff --git a/internal/tui/help.go b/internal/tui/help.go index 9955a52..0be60e8 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -70,7 +70,7 @@ func (a *App) sessHelpLine() string { h = fmtKey(sk.Pick, "pick") + " " + h } if !a.sessSplit.Show { - h += " →:preview tab:group" + h += " g/G:top/end →:preview tab/S-tab:group" } else if a.sessSplit.Focus { switch a.sessPreviewMode { case sessPreviewConversation: @@ -82,7 +82,7 @@ func (a *App) sessHelpLine() string { } h += " " + displayKey(sk.ResizeShrink) + displayKey(sk.ResizeGrow) + ":resize" } else { - h += " tab:group →:focus ←:close " + displayKey(sk.ResizeShrink) + displayKey(sk.ResizeGrow) + ":resize" + h += " g/G:top/end tab/S-tab:group →:focus ←:close " + displayKey(sk.ResizeShrink) + displayKey(sk.ResizeGrow) + ":resize" } if a.config.TmuxEnabled && tmux.InTmux() { h += " " + fmtKey(sk.Live, "live") diff --git a/internal/tui/session_keybindings_test.go b/internal/tui/session_keybindings_test.go new file mode 100644 index 0000000..a2da5a6 --- /dev/null +++ b/internal/tui/session_keybindings_test.go @@ -0,0 +1,84 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func newSessionKeybindingApp() *App { + app := newTestApp(fakeSessions()) + app.sessionsLoading = false + app.sessSplit.Show = false + app.sessSplit.Focus = false + contentH := ContentHeight(app.height) + app.sessionList = newSessionList(app.sessions, app.sessSplit.ListWidth(app.width, app.splitRatio), contentH, app.sessGroupMode, app.selectedSet, app.hiddenBadges, app.config.WorktreeDir) + app.sessionList.ResetFilter() + app.sessSplit.List = &app.sessionList + return app +} + +func TestSessionsGGJumpsToTop(t *testing.T) { + app := newSessionKeybindingApp() + if got := len(app.sessionList.VisibleItems()); got < 3 { + t.Fatalf("expected at least 3 visible items, got %d", got) + } + app.sessionList.Select(2) + if got := app.sessionList.Index(); got != 2 { + t.Fatalf("expected precondition index 2, got %d", got) + } + + m, _ := app.handleSessionKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}}) + app = m.(*App) + if app.sessionList.Index() != 2 { + t.Fatalf("single g should only arm pending jump, got index %d", app.sessionList.Index()) + } + if !app.sessPendingG { + t.Fatal("expected pending g after first g") + } + + m, _ = app.handleSessionKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}}) + app = m.(*App) + if app.sessionList.Index() != 0 { + t.Fatalf("gg should jump to top, got index %d", app.sessionList.Index()) + } + if app.sessPendingG { + t.Fatal("pending g should clear after gg") + } +} + +func TestSessionsGJumpsToEnd(t *testing.T) { + app := newSessionKeybindingApp() + app.sessionList.Select(0) + + m, _ := app.handleSessionKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}}) + app = m.(*App) + want := len(app.sessionList.VisibleItems()) - 1 + if app.sessionList.Index() != want { + t.Fatalf("G should jump to end, got index %d want %d", app.sessionList.Index(), want) + } +} + +func TestSessionsTabStillCyclesGroupMode(t *testing.T) { + app := newSessionKeybindingApp() + start := app.sessGroupMode + + m, _ := app.handleSessionKeys(tea.KeyMsg{Type: tea.KeyTab}) + app = m.(*App) + if app.sessGroupMode == start { + t.Fatalf("tab should still cycle group mode, stayed at %d", start) + } +} + +func TestSessionsHelpShowsNavigationAndTabGrouping(t *testing.T) { + app := newSessionKeybindingApp() + app.sessSplit.Show = false + + help := stripANSI(app.sessHelpLine()) + for _, want := range []string{"g/G:top/end", "tab/S-tab:group"} { + if !strings.Contains(help, want) { + t.Fatalf("expected sessions help to contain %q, got %q", want, help) + } + } +} From 13b3ebed547c3bcb62995ac1a318b860e1877015 Mon Sep 17 00:00:00 2001 From: Gavin Jeong Date: Thu, 23 Apr 2026 00:19:29 +0900 Subject: [PATCH 2/2] fix: restore config test env approval bypass Config test popups regressed because isolated env startup no longer had all of the approval bits Claude expects. We were already seeding trust and project onboarding, but recent Claude builds also gate startup on the CLAUDE.md external include approval flags. Update injectProjectTrust to mark: - hasClaudeMdExternalIncludesApproved - hasClaudeMdExternalIncludesWarningShown alongside trust/onboarding, so config-test popups start directly instead of asking the approval question and exiting. Add a focused test covering the injected project state. --- internal/tmux/isolated.go | 20 +++++++++++++------ internal/tmux/isolated_test.go | 35 ++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 internal/tmux/isolated_test.go diff --git a/internal/tmux/isolated.go b/internal/tmux/isolated.go index 2a91131..64ad819 100644 --- a/internal/tmux/isolated.go +++ b/internal/tmux/isolated.go @@ -209,19 +209,27 @@ func injectProjectTrust(data []byte, projectPath string) []byte { return data } - // Ensure the project entry exists with trust accepted + // Ensure the project entry exists with trust accepted. + // Recent Claude builds also gate isolated test env startup on the + // CLAUDE.md external-includes approval bits; without these, the + // config-test popup immediately exits after showing the approval + // question even though trust/onboarding are already marked done. entry, exists := projects[projectPath] if !exists { entry = map[string]interface{}{ - "allowedTools": []interface{}{}, - "mcpContextUris": []interface{}{}, - "mcpServers": map[string]interface{}{}, - "hasCompletedProjectOnboarding": true, - "projectOnboardingSeenCount": 10, + "allowedTools": []interface{}{}, + "mcpContextUris": []interface{}{}, + "mcpServers": map[string]interface{}{}, + "hasCompletedProjectOnboarding": true, + "projectOnboardingSeenCount": 10, + "hasClaudeMdExternalIncludesApproved": true, + "hasClaudeMdExternalIncludesWarningShown": true, } } entry["hasTrustDialogAccepted"] = true entry["hasCompletedProjectOnboarding"] = true + entry["hasClaudeMdExternalIncludesApproved"] = true + entry["hasClaudeMdExternalIncludesWarningShown"] = true projects[projectPath] = entry // Write back diff --git a/internal/tmux/isolated_test.go b/internal/tmux/isolated_test.go new file mode 100644 index 0000000..17b150d --- /dev/null +++ b/internal/tmux/isolated_test.go @@ -0,0 +1,35 @@ +package tmux + +import ( + "encoding/json" + "testing" +) + +func TestInjectProjectTrustSetsApprovalFlags(t *testing.T) { + input := []byte(`{"projects":{}}`) + out := injectProjectTrust(input, "/tmp/ccx-cfgtest-123") + + var state map[string]json.RawMessage + if err := json.Unmarshal(out, &state); err != nil { + t.Fatalf("unmarshal state: %v", err) + } + var projects map[string]map[string]interface{} + if err := json.Unmarshal(state["projects"], &projects); err != nil { + t.Fatalf("unmarshal projects: %v", err) + } + entry, ok := projects["/tmp/ccx-cfgtest-123"] + if !ok { + t.Fatal("project entry not injected") + } + for _, key := range []string{ + "hasTrustDialogAccepted", + "hasCompletedProjectOnboarding", + "hasClaudeMdExternalIncludesApproved", + "hasClaudeMdExternalIncludesWarningShown", + } { + v, ok := entry[key].(bool) + if !ok || !v { + t.Fatalf("expected %s=true, got %#v", key, entry[key]) + } + } +}