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
20 changes: 14 additions & 6 deletions internal/tmux/isolated.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions internal/tmux/isolated_test.go
Original file line number Diff line number Diff line change
@@ -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])
}
}
}
37 changes: 33 additions & 4 deletions internal/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions internal/tui/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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")
Expand Down
84 changes: 84 additions & 0 deletions internal/tui/session_keybindings_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading