detached 108af61

fix(sessions): address picker review findings

commit
108af6152a4e959061bc09f046afbb4d6cdcc3e3
author
brice · · 2026-08-19 07:08 UTC
parent
1bc97e8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +92 -58
M packages/agent/cli.go +18 -2
@@ -2034,8 +2034,11 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
20342034
20352035 // loadSession replaces the current session with the one at path and
20362036 // hands its messages to the agent. Used by the /sessions picker.
2037- loadSession := func(path string) (loadErr error) {
2038- selectedMeta, err := core.ManagedSessionMeta(context.Background(), agentSessionsRoot(ZutHome(), args), path)
2037+ loadSessionContext := func(loadCtx context.Context, path string) (loadErr error) {
2038+ if err := loadCtx.Err(); err != nil {
2039+ return err
2040+ }
2041+ selectedMeta, err := core.ManagedSessionMeta(loadCtx, agentSessionsRoot(ZutHome(), args), path)
20392042 if err != nil {
20402043 return fmt.Errorf("validate selected session: %w", err)
20412044 }
@@ -2049,8 +2052,14 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
20492052 // Persistence callbacks take the read side, so an active session
20502053 // cannot be snapshotted before its lazy writes land or overwritten by
20512054 // a callback while the candidate is being installed.
2055+ if err := loadCtx.Err(); err != nil {
2056+ return err
2057+ }
20522058 sessionTransitionMu.Lock()
20532059 defer sessionTransitionMu.Unlock()
2060+ if err := loadCtx.Err(); err != nil {
2061+ return err
2062+ }
20542063 workspaceChanged := filepath.Clean(selectedMeta.CWD) != filepath.Clean(args.CWD)
20552064
20562065 currentAg := liveInteractiveAgent(iv, ag)
@@ -2150,6 +2159,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
21502159 // agent is reused and only its transcript/usage are refreshed.
21512160 wireAgentPersist(candidate.agent)
21522161
2162+ if err := loadCtx.Err(); err != nil {
2163+ return err
2164+ }
21532165 persistMu.Lock()
21542166 // A provider rebuild ran outside persistMu. Refuse to install it if
21552167 // the live agent, session, selection, transcript, or cost changed in
@@ -2221,6 +2233,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
22212233 }
22222234 return nil
22232235 }
2236+ loadSession := func(path string) error {
2237+ return loadSessionContext(context.Background(), path)
2238+ }
22242239
22252240 // changeCWD switches the running session to a new working directory.
22262241 // Wired into InteractiveConfig.ChangeCWD and invoked by the hidden
@@ -2646,6 +2661,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
26462661 return out
26472662 },
26482663 LoadSession: loadSession,
2664+ LoadSessionContext: loadSessionContext,
26492665 ChangeCWD: changeCWD,
26502666 PersistCompactHandoff: persistCompactHandoff,
26512667 CurrentCompactHandoff: currentCompactHandoff,
@@ -2034,8 +2034,11 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2034 2034
2035 // loadSession replaces the current session with the one at path and2035 // loadSession replaces the current session with the one at path and
2036 // hands its messages to the agent. Used by the /sessions picker.2036 // hands its messages to the agent. Used by the /sessions picker.
2037- loadSession := func(path string) (loadErr error) {2037+ loadSessionContext := func(loadCtx context.Context, path string) (loadErr error) {
2038- selectedMeta, err := core.ManagedSessionMeta(context.Background(), agentSessionsRoot(ZutHome(), args), path)2038+ if err := loadCtx.Err(); err != nil {
2039+ return err
2040+ }
2041+ selectedMeta, err := core.ManagedSessionMeta(loadCtx, agentSessionsRoot(ZutHome(), args), path)
2039 if err != nil {2042 if err != nil {
2040 return fmt.Errorf("validate selected session: %w", err)2043 return fmt.Errorf("validate selected session: %w", err)
2041 }2044 }
@@ -2049,8 +2052,14 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2049 // Persistence callbacks take the read side, so an active session2052 // Persistence callbacks take the read side, so an active session
2050 // cannot be snapshotted before its lazy writes land or overwritten by2053 // cannot be snapshotted before its lazy writes land or overwritten by
2051 // a callback while the candidate is being installed.2054 // a callback while the candidate is being installed.
2055+ if err := loadCtx.Err(); err != nil {
2056+ return err
2057+ }
2052 sessionTransitionMu.Lock()2058 sessionTransitionMu.Lock()
2053 defer sessionTransitionMu.Unlock()2059 defer sessionTransitionMu.Unlock()
2060+ if err := loadCtx.Err(); err != nil {
2061+ return err
2062+ }
2054 workspaceChanged := filepath.Clean(selectedMeta.CWD) != filepath.Clean(args.CWD)2063 workspaceChanged := filepath.Clean(selectedMeta.CWD) != filepath.Clean(args.CWD)
2055 2064
2056 currentAg := liveInteractiveAgent(iv, ag)2065 currentAg := liveInteractiveAgent(iv, ag)
@@ -2150,6 +2159,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2150 // agent is reused and only its transcript/usage are refreshed.2159 // agent is reused and only its transcript/usage are refreshed.
2151 wireAgentPersist(candidate.agent)2160 wireAgentPersist(candidate.agent)
2152 2161
2162+ if err := loadCtx.Err(); err != nil {
2163+ return err
2164+ }
2153 persistMu.Lock()2165 persistMu.Lock()
2154 // A provider rebuild ran outside persistMu. Refuse to install it if2166 // A provider rebuild ran outside persistMu. Refuse to install it if
2155 // the live agent, session, selection, transcript, or cost changed in2167 // the live agent, session, selection, transcript, or cost changed in
@@ -2221,6 +2233,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2221 }2233 }
2222 return nil2234 return nil
2223 }2235 }
2236+ loadSession := func(path string) error {
2237+ return loadSessionContext(context.Background(), path)
2238+ }
2224 2239
2225 // changeCWD switches the running session to a new working directory.2240 // changeCWD switches the running session to a new working directory.
2226 // Wired into InteractiveConfig.ChangeCWD and invoked by the hidden2241 // Wired into InteractiveConfig.ChangeCWD and invoked by the hidden
@@ -2646,6 +2661,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2646 return out2661 return out
2647 },2662 },
2648 LoadSession: loadSession,2663 LoadSession: loadSession,
2664+ LoadSessionContext: loadSessionContext,
2649 ChangeCWD: changeCWD,2665 ChangeCWD: changeCWD,
2650 PersistCompactHandoff: persistCompactHandoff,2666 PersistCompactHandoff: persistCompactHandoff,
2651 CurrentCompactHandoff: currentCompactHandoff,2667 CurrentCompactHandoff: currentCompactHandoff,
M packages/agent/modes/interactive.go +3 -0
@@ -299,6 +299,9 @@ type InteractiveConfig struct {
299299 // LoadSession swaps the current session for the one at path. The
300300 // callback returns the new agent message slice so the TUI can invalidate.
301301 LoadSession func(path string) error
302+ // LoadSessionContext is the cancellation-aware picker path. Embedders that
303+ // only implement LoadSession retain the existing synchronous contract.
304+ LoadSessionContext func(context.Context, string) error
302305
303306 // ChangeCWD switches the running zut session's working directory
304307 // to path. The host closes the current session, rebuilds the
@@ -299,6 +299,9 @@ type InteractiveConfig struct {
299 // LoadSession swaps the current session for the one at path. The299 // LoadSession swaps the current session for the one at path. The
300 // callback returns the new agent message slice so the TUI can invalidate.300 // callback returns the new agent message slice so the TUI can invalidate.
301 LoadSession func(path string) error301 LoadSession func(path string) error
302+ // LoadSessionContext is the cancellation-aware picker path. Embedders that
303+ // only implement LoadSession retain the existing synchronous contract.
304+ LoadSessionContext func(context.Context, string) error
302 305
303 // ChangeCWD switches the running zut session's working directory306 // ChangeCWD switches the running zut session's working directory
304 // to path. The host closes the current session, rebuilds the307 // to path. The host closes the current session, rebuilds the
M packages/agent/modes/interactive_commands.go +59 -44
@@ -1021,58 +1021,73 @@ func (i *Interactive) applySessionSelection(path string) {
10211021 i.markSessionTitleSwitching()
10221022
10231023 go func() {
1024- err := i.cfg.LoadSession(path)
1025- if err != nil {
1026- i.restoreFailedSessionTitle()
1027- i.mu.Lock()
1028- i.sessionLoading = false
1029- i.statusErr = err.Error()
1030- i.statusOK = ""
1031- i.mu.Unlock()
1032- i.invalidate()
1024+ load := i.cfg.LoadSession
1025+ if i.cfg.LoadSessionContext != nil {
1026+ ctx := i.runCtx
1027+ if ctx == nil {
1028+ ctx = context.Background()
1029+ }
1030+ err := i.cfg.LoadSessionContext(ctx, path)
1031+ i.finishSessionSelection(path, err)
10331032 return
10341033 }
1035- i.restoreLoadedSessionTitle()
1036- state := decodeCompactHandoff(i.currentCompactHandoff())
1034+ err := load(path)
1035+ i.finishSessionSelection(path, err)
1036+ }()
1037+}
1038+
1039+func (i *Interactive) finishSessionSelection(path string, err error) {
1040+ if err != nil {
1041+ i.restoreFailedSessionTitle()
10371042 i.mu.Lock()
1038- i.compactContinuation = state
10391043 i.sessionLoading = false
1040- i.statusOK = "resumed session: " + path
1041- i.statusErr = ""
1042- i.parkedTurn = 0
1043- i.parkedTotal = 0
1044- i.scrollOffset = 0
1045- // Fresh transcript swapped in: drop the auto-follow baseline so
1046- // the next render's follow guard doesn't see the wholesale
1047- // length change as a delta and jump the viewport.
1048- i.prevChatLen = 0
1049- i.prevChatCols = 0
1050- i.extNotes = nil
1051- i.view.InvalidateRenderCache()
1052- if i.agent != nil {
1053- i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
1054- i.cumUsage = i.agent.Cost()
1055- if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
1056- i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
1057- } else {
1058- i.lastCtxInput = 0
1059- }
1060- // Snap to the tail again — the swap brought in a fresh
1061- // transcript whose markdown / chroma cost we don't want
1062- // blocking the redraw.
1063- if len(i.view.Messages) > initialResumeTailLimit {
1064- i.view.TailLimit = initialResumeTailLimit
1065- } else {
1066- i.view.TailLimit = 0
1067- }
1068- }
1044+ i.statusErr = err.Error()
1045+ i.statusOK = ""
10691046 i.mu.Unlock()
10701047 i.invalidate()
1071- if state.reason != compactContinuationNone {
1072- i.startRestoredCompactHandoff(i.runCtx)
1048+ return
1049+ }
1050+ i.restoreLoadedSessionTitle()
1051+ state := decodeCompactHandoff(i.currentCompactHandoff())
1052+ i.mu.Lock()
1053+ i.compactContinuation = state
1054+ i.sessionLoading = false
1055+ i.statusOK = "resumed session: " + path
1056+ i.statusErr = ""
1057+ i.parkedTurn = 0
1058+ i.parkedTotal = 0
1059+ i.scrollOffset = 0
1060+ // Fresh transcript swapped in: drop the auto-follow baseline so
1061+ // the next render's follow guard doesn't see the wholesale
1062+ // length change as a delta and jump the viewport.
1063+ i.prevChatLen = 0
1064+ i.prevChatCols = 0
1065+ i.extNotes = nil
1066+ i.view.InvalidateRenderCache()
1067+ if i.agent != nil {
1068+ i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
1069+ i.cumUsage = i.agent.Cost()
1070+ if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
1071+ i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
1072+ } else {
1073+ i.lastCtxInput = 0
10731074 }
1074- }()
1075+ // Snap to the tail again — the swap brought in a fresh
1076+ // transcript whose markdown / chroma cost we don't want
1077+ // blocking the redraw.
1078+ if len(i.view.Messages) > initialResumeTailLimit {
1079+ i.view.TailLimit = initialResumeTailLimit
1080+ } else {
1081+ i.view.TailLimit = 0
1082+ }
1083+ }
1084+ i.mu.Unlock()
1085+ i.invalidate()
1086+ if state.reason != compactContinuationNone {
1087+ i.startRestoredCompactHandoff(i.runCtx)
1088+ }
10751089 }
1090+
10761091 func (i *Interactive) applyModelSelection(prov, model string) {
10771092 i.swapModel(prov, model, i.cfg.BuildAgentFor, false)
10781093 }
@@ -1021,58 +1021,73 @@ func (i *Interactive) applySessionSelection(path string) {
1021 i.markSessionTitleSwitching()1021 i.markSessionTitleSwitching()
1022 1022
1023 go func() {1023 go func() {
1024- err := i.cfg.LoadSession(path)1024+ load := i.cfg.LoadSession
1025- if err != nil {1025+ if i.cfg.LoadSessionContext != nil {
1026- i.restoreFailedSessionTitle()1026+ ctx := i.runCtx
1027- i.mu.Lock()1027+ if ctx == nil {
1028- i.sessionLoading = false1028+ ctx = context.Background()
1029- i.statusErr = err.Error()1029+ }
1030- i.statusOK = ""1030+ err := i.cfg.LoadSessionContext(ctx, path)
1031- i.mu.Unlock()1031+ i.finishSessionSelection(path, err)
1032- i.invalidate()
1033 return1032 return
1034 }1033 }
1035- i.restoreLoadedSessionTitle()1034+ err := load(path)
1036- state := decodeCompactHandoff(i.currentCompactHandoff())1035+ i.finishSessionSelection(path, err)
1036+ }()
1037+}
1038+
1039+func (i *Interactive) finishSessionSelection(path string, err error) {
1040+ if err != nil {
1041+ i.restoreFailedSessionTitle()
1037 i.mu.Lock()1042 i.mu.Lock()
1038- i.compactContinuation = state
1039 i.sessionLoading = false1043 i.sessionLoading = false
1040- i.statusOK = "resumed session: " + path1044+ i.statusErr = err.Error()
1041- i.statusErr = ""1045+ i.statusOK = ""
1042- i.parkedTurn = 0
1043- i.parkedTotal = 0
1044- i.scrollOffset = 0
1045- // Fresh transcript swapped in: drop the auto-follow baseline so
1046- // the next render's follow guard doesn't see the wholesale
1047- // length change as a delta and jump the viewport.
1048- i.prevChatLen = 0
1049- i.prevChatCols = 0
1050- i.extNotes = nil
1051- i.view.InvalidateRenderCache()
1052- if i.agent != nil {
1053- i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
1054- i.cumUsage = i.agent.Cost()
1055- if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
1056- i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
1057- } else {
1058- i.lastCtxInput = 0
1059- }
1060- // Snap to the tail again — the swap brought in a fresh
1061- // transcript whose markdown / chroma cost we don't want
1062- // blocking the redraw.
1063- if len(i.view.Messages) > initialResumeTailLimit {
1064- i.view.TailLimit = initialResumeTailLimit
1065- } else {
1066- i.view.TailLimit = 0
1067- }
1068- }
1069 i.mu.Unlock()1046 i.mu.Unlock()
1070 i.invalidate()1047 i.invalidate()
1071- if state.reason != compactContinuationNone {1048+ return
1072- i.startRestoredCompactHandoff(i.runCtx)1049+ }
1050+ i.restoreLoadedSessionTitle()
1051+ state := decodeCompactHandoff(i.currentCompactHandoff())
1052+ i.mu.Lock()
1053+ i.compactContinuation = state
1054+ i.sessionLoading = false
1055+ i.statusOK = "resumed session: " + path
1056+ i.statusErr = ""
1057+ i.parkedTurn = 0
1058+ i.parkedTotal = 0
1059+ i.scrollOffset = 0
1060+ // Fresh transcript swapped in: drop the auto-follow baseline so
1061+ // the next render's follow guard doesn't see the wholesale
1062+ // length change as a delta and jump the viewport.
1063+ i.prevChatLen = 0
1064+ i.prevChatCols = 0
1065+ i.extNotes = nil
1066+ i.view.InvalidateRenderCache()
1067+ if i.agent != nil {
1068+ i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
1069+ i.cumUsage = i.agent.Cost()
1070+ if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
1071+ i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
1072+ } else {
1073+ i.lastCtxInput = 0
1073 }1074 }
1074- }()1075+ // Snap to the tail again — the swap brought in a fresh
1076+ // transcript whose markdown / chroma cost we don't want
1077+ // blocking the redraw.
1078+ if len(i.view.Messages) > initialResumeTailLimit {
1079+ i.view.TailLimit = initialResumeTailLimit
1080+ } else {
1081+ i.view.TailLimit = 0
1082+ }
1083+ }
1084+ i.mu.Unlock()
1085+ i.invalidate()
1086+ if state.reason != compactContinuationNone {
1087+ i.startRestoredCompactHandoff(i.runCtx)
1088+ }
1075 }1089 }
1090+
1076 func (i *Interactive) applyModelSelection(prov, model string) {1091 func (i *Interactive) applyModelSelection(prov, model string) {
1077 i.swapModel(prov, model, i.cfg.BuildAgentFor, false)1092 i.swapModel(prov, model, i.cfg.BuildAgentFor, false)
1078 }1093 }
M packages/agent/modes/interactive_host.go +2 -0
@@ -141,6 +141,8 @@ func (i *Interactive) ApplySessionAgentWithCWD(ag *core.Agent, providerName, mod
141141 i.cfg.CWD = cwd
142142 i.cfg.SubagentsSystemAddendum = subagents.SystemPromptAddendum(profiles)
143143 i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg, i.autoSubagentsEnabledLocked())
144+ i.cfg.StartupContextPaths = nil
145+ i.view.StartupContextPaths = nil
144146 if i.cfg.Terminal != nil {
145147 if seq := tui.ReportCWD(cwd); seq != "" {
146148 _, _ = i.cfg.Terminal.Write([]byte(seq))
@@ -141,6 +141,8 @@ func (i *Interactive) ApplySessionAgentWithCWD(ag *core.Agent, providerName, mod
141 i.cfg.CWD = cwd141 i.cfg.CWD = cwd
142 i.cfg.SubagentsSystemAddendum = subagents.SystemPromptAddendum(profiles)142 i.cfg.SubagentsSystemAddendum = subagents.SystemPromptAddendum(profiles)
143 i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg, i.autoSubagentsEnabledLocked())143 i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg, i.autoSubagentsEnabledLocked())
144+ i.cfg.StartupContextPaths = nil
145+ i.view.StartupContextPaths = nil
144 if i.cfg.Terminal != nil {146 if i.cfg.Terminal != nil {
145 if seq := tui.ReportCWD(cwd); seq != "" {147 if seq := tui.ReportCWD(cwd); seq != "" {
146 _, _ = i.cfg.Terminal.Write([]byte(seq))148 _, _ = i.cfg.Terminal.Write([]byte(seq))
M packages/agent/modes/interactive_host_test.go +5 -1
@@ -20,11 +20,15 @@ func TestApplySessionAgentWithCWDRebindsInteractiveWorkspace(t *testing.T) {
2020 current := core.NewAgent(nil, "model", "", nil)
2121 selected := core.NewAgent(nil, "model", "", nil)
2222 selected.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "selected"}}}})
23- i := NewInteractive(InteractiveConfig{Agent: current, CWD: oldCWD})
23+ i := NewInteractive(InteractiveConfig{Agent: current, CWD: oldCWD, StartupContextPaths: []string{filepath.Join(oldCWD, "AGENTS.md")}})
24+ i.view.StartupContextPaths = []string{filepath.Join(oldCWD, "AGENTS.md")}
2425 i.ApplySessionAgentWithCWD(selected, "provider", "model", newCWD, nil)
2526 if i.cfg.CWD != newCWD || i.agent != selected {
2627 t.Fatalf("interactive workspace = %q agent=%p, want cwd=%q agent=%p", i.cfg.CWD, i.agent, newCWD, selected)
2728 }
29+ if len(i.cfg.StartupContextPaths) != 0 || len(i.view.StartupContextPaths) != 0 {
30+ t.Fatalf("old workspace paths remained: cfg=%q view=%q", i.cfg.StartupContextPaths, i.view.StartupContextPaths)
31+ }
2832 text, _ := i.view.Messages[0].Content[0].(provider.TextBlock)
2933 if text.Text != "selected" {
3034 t.Fatalf("resumed view text = %q", text.Text)
@@ -20,11 +20,15 @@ func TestApplySessionAgentWithCWDRebindsInteractiveWorkspace(t *testing.T) {
20 current := core.NewAgent(nil, "model", "", nil)20 current := core.NewAgent(nil, "model", "", nil)
21 selected := core.NewAgent(nil, "model", "", nil)21 selected := core.NewAgent(nil, "model", "", nil)
22 selected.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "selected"}}}})22 selected.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "selected"}}}})
23- i := NewInteractive(InteractiveConfig{Agent: current, CWD: oldCWD})23+ i := NewInteractive(InteractiveConfig{Agent: current, CWD: oldCWD, StartupContextPaths: []string{filepath.Join(oldCWD, "AGENTS.md")}})
24+ i.view.StartupContextPaths = []string{filepath.Join(oldCWD, "AGENTS.md")}
24 i.ApplySessionAgentWithCWD(selected, "provider", "model", newCWD, nil)25 i.ApplySessionAgentWithCWD(selected, "provider", "model", newCWD, nil)
25 if i.cfg.CWD != newCWD || i.agent != selected {26 if i.cfg.CWD != newCWD || i.agent != selected {
26 t.Fatalf("interactive workspace = %q agent=%p, want cwd=%q agent=%p", i.cfg.CWD, i.agent, newCWD, selected)27 t.Fatalf("interactive workspace = %q agent=%p, want cwd=%q agent=%p", i.cfg.CWD, i.agent, newCWD, selected)
27 }28 }
29+ if len(i.cfg.StartupContextPaths) != 0 || len(i.view.StartupContextPaths) != 0 {
30+ t.Fatalf("old workspace paths remained: cfg=%q view=%q", i.cfg.StartupContextPaths, i.view.StartupContextPaths)
31+ }
28 text, _ := i.view.Messages[0].Content[0].(provider.TextBlock)32 text, _ := i.view.Messages[0].Content[0].(provider.TextBlock)
29 if text.Text != "selected" {33 if text.Text != "selected" {
30 t.Fatalf("resumed view text = %q", text.Text)34 t.Fatalf("resumed view text = %q", text.Text)
M packages/agent/modes/session_dialog.go +3 -3
@@ -432,6 +432,8 @@ func (d *sessionDialog) Close() {
432432 d.loadSlots = nil
433433 d.loadReadyThrough = 0
434434 d.searchEvents = nil
435+ d.searchSegments = nil
436+ d.searchMatches = nil
435437 d.active = false
436438 }
437439
@@ -522,7 +524,7 @@ func (d *sessionDialog) scheduleSearchMatch() {
522524 d.matchCancel()
523525 }
524526 query := d.query
525- segments := append([]core.SessionSearchSegment(nil), d.searchSegments...)
527+ segments := d.searchSegments
526528 generation := d.searchGeneration
527529 events := d.searchEvents
528530 ctx, cancel := context.WithCancel(d.searchContext)
@@ -944,7 +946,6 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
944946 if len(runes) > 0 {
945947 d.query = string(runes[:len(runes)-1])
946948 }
947- d.searchMatches = nil
948949 d.applySearchFilter()
949950 d.scheduleSearchMatch()
950951 return sessionDialogAction{}
@@ -962,7 +963,6 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
962963 if len([]rune(d.query)) > 256 {
963964 d.query = string([]rune(d.query)[:256])
964965 }
965- d.searchMatches = nil
966966 d.applySearchFilter()
967967 d.scheduleSearchMatch()
968968 return sessionDialogAction{}
@@ -432,6 +432,8 @@ func (d *sessionDialog) Close() {
432 d.loadSlots = nil432 d.loadSlots = nil
433 d.loadReadyThrough = 0433 d.loadReadyThrough = 0
434 d.searchEvents = nil434 d.searchEvents = nil
435+ d.searchSegments = nil
436+ d.searchMatches = nil
435 d.active = false437 d.active = false
436 }438 }
437 439
@@ -522,7 +524,7 @@ func (d *sessionDialog) scheduleSearchMatch() {
522 d.matchCancel()524 d.matchCancel()
523 }525 }
524 query := d.query526 query := d.query
525- segments := append([]core.SessionSearchSegment(nil), d.searchSegments...)527+ segments := d.searchSegments
526 generation := d.searchGeneration528 generation := d.searchGeneration
527 events := d.searchEvents529 events := d.searchEvents
528 ctx, cancel := context.WithCancel(d.searchContext)530 ctx, cancel := context.WithCancel(d.searchContext)
@@ -944,7 +946,6 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
944 if len(runes) > 0 {946 if len(runes) > 0 {
945 d.query = string(runes[:len(runes)-1])947 d.query = string(runes[:len(runes)-1])
946 }948 }
947- d.searchMatches = nil
948 d.applySearchFilter()949 d.applySearchFilter()
949 d.scheduleSearchMatch()950 d.scheduleSearchMatch()
950 return sessionDialogAction{}951 return sessionDialogAction{}
@@ -962,7 +963,6 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
962 if len([]rune(d.query)) > 256 {963 if len([]rune(d.query)) > 256 {
963 d.query = string([]rune(d.query)[:256])964 d.query = string([]rune(d.query)[:256])
964 }965 }
965- d.searchMatches = nil
966 d.applySearchFilter()966 d.applySearchFilter()
967 d.scheduleSearchMatch()967 d.scheduleSearchMatch()
968 return sessionDialogAction{}968 return sessionDialogAction{}
M packages/core/session_picker.go +2 -8
@@ -181,15 +181,9 @@ func ReadSessionSearchSegments(ctx context.Context, path string) ([]SessionSearc
181181 }
182182 if len(line) > 0 {
183183 var head sessionLineHead
184- if jsonErr := json.Unmarshal(line, &head); jsonErr != nil {
185- return nil, fmt.Errorf("read session search text: invalid JSON: %w", jsonErr)
186- }
187- if head.Type == "message" {
184+ if jsonErr := json.Unmarshal(line, &head); jsonErr == nil && head.Type == "message" {
188185 message, messageErr := readSearchMessage(line)
189- if messageErr != nil {
190- return nil, fmt.Errorf("read session search text: %w", messageErr)
191- }
192- if message.Role == provider.RoleUser || message.Role == provider.RoleAssistant {
186+ if messageErr == nil && (message.Role == provider.RoleUser || message.Role == provider.RoleAssistant) {
193187 for _, rawText := range message.Texts {
194188 for _, text := range splitSessionSearchText(rawText) {
195189 segments = append(segments, SessionSearchSegment{Path: path, Text: text, Normalized: NormalizeSessionSearchText(text), Role: message.Role, Time: message.Time, Order: order})
@@ -181,15 +181,9 @@ func ReadSessionSearchSegments(ctx context.Context, path string) ([]SessionSearc
181 }181 }
182 if len(line) > 0 {182 if len(line) > 0 {
183 var head sessionLineHead183 var head sessionLineHead
184- if jsonErr := json.Unmarshal(line, &head); jsonErr != nil {184+ if jsonErr := json.Unmarshal(line, &head); jsonErr == nil && head.Type == "message" {
185- return nil, fmt.Errorf("read session search text: invalid JSON: %w", jsonErr)
186- }
187- if head.Type == "message" {
188 message, messageErr := readSearchMessage(line)185 message, messageErr := readSearchMessage(line)
189- if messageErr != nil {186+ if messageErr == nil && (message.Role == provider.RoleUser || message.Role == provider.RoleAssistant) {
190- return nil, fmt.Errorf("read session search text: %w", messageErr)
191- }
192- if message.Role == provider.RoleUser || message.Role == provider.RoleAssistant {
193 for _, rawText := range message.Texts {187 for _, rawText := range message.Texts {
194 for _, text := range splitSessionSearchText(rawText) {188 for _, text := range splitSessionSearchText(rawText) {
195 segments = append(segments, SessionSearchSegment{Path: path, Text: text, Normalized: NormalizeSessionSearchText(text), Role: message.Role, Time: message.Time, Order: order})189 segments = append(segments, SessionSearchSegment{Path: path, Text: text, Normalized: NormalizeSessionSearchText(text), Role: message.Role, Time: message.Time, Order: order})

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close