fix(sessions): address picker review findings
- commit
- 108af6152a4e959061bc09f046afbb4d6cdcc3e3
- author
- brice · · 2026-08-19 07:08 UTC
- parent
- 1bc97e8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 2034 | 2034 | ||
| 2035 | 2035 | // loadSession replaces the current session with the one at path and | |
| 2036 | 2036 | // 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) | |
| 2039 | 2042 | if err != nil { | |
| 2040 | 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 | 2052 | // Persistence callbacks take the read side, so an active session | |
| 2050 | 2053 | // cannot be snapshotted before its lazy writes land or overwritten by | |
| 2051 | 2054 | // a callback while the candidate is being installed. | |
| 2055 | + | if err := loadCtx.Err(); err != nil { | |
| 2056 | + | return err | |
| 2057 | + | } | |
| 2052 | 2058 | sessionTransitionMu.Lock() | |
| 2053 | 2059 | defer sessionTransitionMu.Unlock() | |
| 2060 | + | if err := loadCtx.Err(); err != nil { | |
| 2061 | + | return err | |
| 2062 | + | } | |
| 2054 | 2063 | workspaceChanged := filepath.Clean(selectedMeta.CWD) != filepath.Clean(args.CWD) | |
| 2055 | 2064 | ||
| 2056 | 2065 | currentAg := liveInteractiveAgent(iv, ag) | |
| @@ -2150,6 +2159,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2150 | 2159 | // agent is reused and only its transcript/usage are refreshed. | |
| 2151 | 2160 | wireAgentPersist(candidate.agent) | |
| 2152 | 2161 | ||
| 2162 | + | if err := loadCtx.Err(); err != nil { | |
| 2163 | + | return err | |
| 2164 | + | } | |
| 2153 | 2165 | persistMu.Lock() | |
| 2154 | 2166 | // A provider rebuild ran outside persistMu. Refuse to install it if | |
| 2155 | 2167 | // 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 | 2234 | return nil | |
| 2223 | 2235 | } | |
| 2236 | + | loadSession := func(path string) error { | |
| 2237 | + | return loadSessionContext(context.Background(), path) | |
| 2238 | + | } | |
| 2224 | 2239 | ||
| 2225 | 2240 | // changeCWD switches the running session to a new working directory. | |
| 2226 | 2241 | // 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 | 2661 | return out | |
| 2647 | 2662 | }, | |
| 2648 | 2663 | LoadSession: loadSession, | |
| 2664 | + | LoadSessionContext: loadSessionContext, | |
| 2649 | 2665 | ChangeCWD: changeCWD, | |
| 2650 | 2666 | PersistCompactHandoff: persistCompactHandoff, | |
| 2651 | 2667 | 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 and | 2035 | // 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 session | 2052 | // Persistence callbacks take the read side, so an active session | ||
| 2050 | // cannot be snapshotted before its lazy writes land or overwritten by | 2053 | // 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 if | 2166 | // A provider rebuild ran outside persistMu. Refuse to install it if | ||
| 2155 | // the live agent, session, selection, transcript, or cost changed in | 2167 | // 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 nil | 2234 | 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 hidden | 2241 | // 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 out | 2661 | 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 { | |||
| 299 | 299 | // LoadSession swaps the current session for the one at path. The | |
| 300 | 300 | // callback returns the new agent message slice so the TUI can invalidate. | |
| 301 | 301 | 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 | 306 | // ChangeCWD switches the running zut session's working directory | |
| 304 | 307 | // 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. The | 299 | // 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) error | 301 | 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 directory | 306 | // ChangeCWD switches the running zut session's working directory | ||
| 304 | // to path. The host closes the current session, rebuilds the | 307 | // 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) { | |||
| 1021 | 1021 | i.markSessionTitleSwitching() | |
| 1022 | 1022 | ||
| 1023 | 1023 | 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) | |
| 1033 | 1032 | return | |
| 1034 | 1033 | } | |
| 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() | |
| 1037 | 1042 | i.mu.Lock() | |
| 1038 | - | i.compactContinuation = state | |
| 1039 | 1043 | 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 = "" | |
| 1069 | 1046 | i.mu.Unlock() | |
| 1070 | 1047 | 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 | |
| 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 | 1091 | func (i *Interactive) applyModelSelection(prov, model string) { | |
| 1077 | 1092 | i.swapModel(prov, model, i.cfg.BuildAgentFor, false) | |
| 1078 | 1093 | } | |
| @@ -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 = false | 1028 | + | 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 | return | 1032 | 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 = false | 1043 | i.sessionLoading = false | ||
| 1040 | - | i.statusOK = "resumed session: " + path | 1044 | + | 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 | |||
| 141 | 141 | i.cfg.CWD = cwd | |
| 142 | 142 | i.cfg.SubagentsSystemAddendum = subagents.SystemPromptAddendum(profiles) | |
| 143 | 143 | i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg, i.autoSubagentsEnabledLocked()) | |
| 144 | + | i.cfg.StartupContextPaths = nil | |
| 145 | + | i.view.StartupContextPaths = nil | |
| 144 | 146 | if i.cfg.Terminal != nil { | |
| 145 | 147 | if seq := tui.ReportCWD(cwd); seq != "" { | |
| 146 | 148 | _, _ = i.cfg.Terminal.Write([]byte(seq)) | |
| @@ -141,6 +141,8 @@ func (i *Interactive) ApplySessionAgentWithCWD(ag *core.Agent, providerName, mod | |||||
| 141 | i.cfg.CWD = cwd | 141 | 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) { | |||
| 20 | 20 | current := core.NewAgent(nil, "model", "", nil) | |
| 21 | 21 | selected := core.NewAgent(nil, "model", "", nil) | |
| 22 | 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 | 25 | i.ApplySessionAgentWithCWD(selected, "provider", "model", newCWD, nil) | |
| 25 | 26 | if i.cfg.CWD != newCWD || i.agent != selected { | |
| 26 | 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 | 32 | text, _ := i.view.Messages[0].Content[0].(provider.TextBlock) | |
| 29 | 33 | if text.Text != "selected" { | |
| 30 | 34 | 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() { | |||
| 432 | 432 | d.loadSlots = nil | |
| 433 | 433 | d.loadReadyThrough = 0 | |
| 434 | 434 | d.searchEvents = nil | |
| 435 | + | d.searchSegments = nil | |
| 436 | + | d.searchMatches = nil | |
| 435 | 437 | d.active = false | |
| 436 | 438 | } | |
| 437 | 439 | ||
| @@ -522,7 +524,7 @@ func (d *sessionDialog) scheduleSearchMatch() { | |||
| 522 | 524 | d.matchCancel() | |
| 523 | 525 | } | |
| 524 | 526 | query := d.query | |
| 525 | - | segments := append([]core.SessionSearchSegment(nil), d.searchSegments...) | |
| 527 | + | segments := d.searchSegments | |
| 526 | 528 | generation := d.searchGeneration | |
| 527 | 529 | events := d.searchEvents | |
| 528 | 530 | ctx, cancel := context.WithCancel(d.searchContext) | |
| @@ -944,7 +946,6 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||
| 944 | 946 | if len(runes) > 0 { | |
| 945 | 947 | d.query = string(runes[:len(runes)-1]) | |
| 946 | 948 | } | |
| 947 | - | d.searchMatches = nil | |
| 948 | 949 | d.applySearchFilter() | |
| 949 | 950 | d.scheduleSearchMatch() | |
| 950 | 951 | return sessionDialogAction{} | |
| @@ -962,7 +963,6 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||
| 962 | 963 | if len([]rune(d.query)) > 256 { | |
| 963 | 964 | d.query = string([]rune(d.query)[:256]) | |
| 964 | 965 | } | |
| 965 | - | d.searchMatches = nil | |
| 966 | 966 | d.applySearchFilter() | |
| 967 | 967 | d.scheduleSearchMatch() | |
| 968 | 968 | return sessionDialogAction{} | |
| @@ -432,6 +432,8 @@ func (d *sessionDialog) Close() { | |||||
| 432 | d.loadSlots = nil | 432 | d.loadSlots = nil | ||
| 433 | d.loadReadyThrough = 0 | 433 | d.loadReadyThrough = 0 | ||
| 434 | d.searchEvents = nil | 434 | d.searchEvents = nil | ||
| 435 | + | d.searchSegments = nil | |||
| 436 | + | d.searchMatches = nil | |||
| 435 | d.active = false | 437 | 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.query | 526 | query := d.query | ||
| 525 | - | segments := append([]core.SessionSearchSegment(nil), d.searchSegments...) | 527 | + | segments := d.searchSegments |
| 526 | generation := d.searchGeneration | 528 | generation := d.searchGeneration | ||
| 527 | events := d.searchEvents | 529 | 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 | |||
| 181 | 181 | } | |
| 182 | 182 | if len(line) > 0 { | |
| 183 | 183 | 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" { | |
| 188 | 185 | 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) { | |
| 193 | 187 | for _, rawText := range message.Texts { | |
| 194 | 188 | for _, text := range splitSessionSearchText(rawText) { | |
| 195 | 189 | 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 sessionLineHead | 183 | 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}) | ||