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]) + } + } +} 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) + } + } +}