Merge pull request #149 from bnema/feat/session-picker-fuzzy-search
feat(sessions): add scoped fuzzy session picker
14 files · +1270 -70
M README.md +2 -2
| @@ -316,7 +316,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||
| 316 | 316 | | `/orchestrator` | Toggle automatic subagent orchestration. When disabled, subagent tools remain available for explicit delegation. | | |
| 317 | 317 | | `/goal <objective>` | Start an autonomous interactive-session mission. Use `/goal` for status, `/goal history` for prior goals, and `/goal pause`, `resume`, or `clear` for control. | | |
| 318 | 318 | | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. | | |
| 319 | - | | `/sessions` | Resume a previous session for this directory. The picker omits branches created only for tree navigation. | | |
| 319 | + | | `/sessions` | Resume a previous session. The picker opens in this-directory scope; press `Tab` for all sessions in the active namespace, or `/` to search user/assistant text. It omits branches created only for tree navigation. | | |
| 320 | 320 | | `/fork` | Pick a previous user message and fork the current session after that turn. The selected turn becomes branch context and no provider turn starts during the checkout. | | |
| 321 | 321 | | `/session` | Four ops on the current session: `export` to a portable `.zutsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. `Esc` `Esc` from an idle, empty editor is a shortcut for `/session tree` when the terminal supplies two parsed bare Escape events. | | |
| 322 | 322 | | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). | | |
| @@ -359,7 +359,7 @@ Type `!` followed by a command to run it directly without going through the mode | |||
| 359 | 359 | ||
| 360 | 360 | ### `/sessions` | |
| 361 | 361 | ||
| 362 | - | Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the session title. Session entries load in the background and are revealed incrementally in newest-first order; an older result may wait briefly for entries ahead of it so the list never reorders, while the picker keeps showing progress for the remaining entries. Once an entry appears, `up`/`down` and `enter` can select it immediately; `esc` cancels and closes the picker. zut swaps the current session file for the selected one and replays the full transcript (including tool calls) into the agent. Sessions remember their stored provider and model: resuming rebuilds the live agent when necessary instead of silently using a different model; if that rebuild fails, the current session is left unchanged. Branches created for tree navigation remain available to `/session tree` but are hidden from this flat picker. A fresh interactive session asks the active model for a concise title after the first real prompt and restores that title when resumed. | |
| 362 | + | Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the session title. Press `Tab` to rotate between **this directory** and **all sessions** in the active normal or Zutfile-agent namespace; other agent namespaces never appear. Press `/` to search an ephemeral in-memory corpus of persisted user and assistant text. Tool calls, tool results, images, developer context, and compaction payloads are excluded. The picker displays each matching session's count and a highlighted best excerpt; changing a query reuses the corpus rather than reopening transcripts. Session entries and search corpus load in the background. Once an entry appears, `up`/`down` and `enter` can select it immediately; `esc` clears a search or cancels and closes the picker. Selecting a session from another working directory switches the live workspace to that session's recorded directory before the transcript resumes, so tools, instructions, sandbox state, and file suggestions do not retain the old directory. Sessions remember their stored provider and model: resuming rebuilds the live agent when necessary instead of silently using a different model; if a staged switch fails, the current session is left unchanged. Branches created for tree navigation remain available to `/session tree` but are hidden from this flat picker. `/sessions` is unavailable with `--no-session`. A fresh interactive session asks the active model for a concise title after the first real prompt and restores that title when resumed. | |
| 363 | 363 | ||
| 364 | 364 | ### `/session` | |
| 365 | 365 | ||
| @@ -316,7 +316,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||||
| 316 | | `/orchestrator` | Toggle automatic subagent orchestration. When disabled, subagent tools remain available for explicit delegation. | | 316 | | `/orchestrator` | Toggle automatic subagent orchestration. When disabled, subagent tools remain available for explicit delegation. | | ||
| 317 | | `/goal <objective>` | Start an autonomous interactive-session mission. Use `/goal` for status, `/goal history` for prior goals, and `/goal pause`, `resume`, or `clear` for control. | | 317 | | `/goal <objective>` | Start an autonomous interactive-session mission. Use `/goal` for status, `/goal history` for prior goals, and `/goal pause`, `resume`, or `clear` for control. | | ||
| 318 | | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. | | 318 | | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. | | ||
| 319 | - | | `/sessions` | Resume a previous session for this directory. The picker omits branches created only for tree navigation. | | 319 | + | | `/sessions` | Resume a previous session. The picker opens in this-directory scope; press `Tab` for all sessions in the active namespace, or `/` to search user/assistant text. It omits branches created only for tree navigation. | |
| 320 | | `/fork` | Pick a previous user message and fork the current session after that turn. The selected turn becomes branch context and no provider turn starts during the checkout. | | 320 | | `/fork` | Pick a previous user message and fork the current session after that turn. The selected turn becomes branch context and no provider turn starts during the checkout. | | ||
| 321 | | `/session` | Four ops on the current session: `export` to a portable `.zutsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. `Esc` `Esc` from an idle, empty editor is a shortcut for `/session tree` when the terminal supplies two parsed bare Escape events. | | 321 | | `/session` | Four ops on the current session: `export` to a portable `.zutsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. `Esc` `Esc` from an idle, empty editor is a shortcut for `/session tree` when the terminal supplies two parsed bare Escape events. | | ||
| 322 | | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). | | 322 | | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). | | ||
| @@ -359,7 +359,7 @@ Type `!` followed by a command to run it directly without going through the mode | |||||
| 359 | 359 | ||||
| 360 | ### `/sessions` | 360 | ### `/sessions` | ||
| 361 | 361 | ||||
| 362 | - | Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the session title. Session entries load in the background and are revealed incrementally in newest-first order; an older result may wait briefly for entries ahead of it so the list never reorders, while the picker keeps showing progress for the remaining entries. Once an entry appears, `up`/`down` and `enter` can select it immediately; `esc` cancels and closes the picker. zut swaps the current session file for the selected one and replays the full transcript (including tool calls) into the agent. Sessions remember their stored provider and model: resuming rebuilds the live agent when necessary instead of silently using a different model; if that rebuild fails, the current session is left unchanged. Branches created for tree navigation remain available to `/session tree` but are hidden from this flat picker. A fresh interactive session asks the active model for a concise title after the first real prompt and restores that title when resumed. | 362 | + | Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the session title. Press `Tab` to rotate between **this directory** and **all sessions** in the active normal or Zutfile-agent namespace; other agent namespaces never appear. Press `/` to search an ephemeral in-memory corpus of persisted user and assistant text. Tool calls, tool results, images, developer context, and compaction payloads are excluded. The picker displays each matching session's count and a highlighted best excerpt; changing a query reuses the corpus rather than reopening transcripts. Session entries and search corpus load in the background. Once an entry appears, `up`/`down` and `enter` can select it immediately; `esc` clears a search or cancels and closes the picker. Selecting a session from another working directory switches the live workspace to that session's recorded directory before the transcript resumes, so tools, instructions, sandbox state, and file suggestions do not retain the old directory. Sessions remember their stored provider and model: resuming rebuilds the live agent when necessary instead of silently using a different model; if a staged switch fails, the current session is left unchanged. Branches created for tree navigation remain available to `/session tree` but are hidden from this flat picker. `/sessions` is unavailable with `--no-session`. A fresh interactive session asks the active model for a concise title after the first real prompt and restores that title when resumed. |
| 363 | 363 | ||||
| 364 | ### `/session` | 364 | ### `/session` | ||
| 365 | 365 | ||||
M packages/agent/cli.go +94 -7
| @@ -436,13 +436,20 @@ type sessionResumeCandidate struct { | |||
| 436 | 436 | // error this function closes the candidate session and leaves the current | |
| 437 | 437 | // agent untouched. | |
| 438 | 438 | func prepareSessionResume(path string, current *core.Agent, currentProvider, currentModel string, buildAgentFor func(string, string) (*core.Agent, string, string, error)) (sessionResumeCandidate, error) { | |
| 439 | - | return prepareSessionResumeWithOptions(path, current, currentProvider, currentModel, false, false, buildAgentFor) | |
| 439 | + | return prepareSessionResumeWithOptions(path, current, currentProvider, currentModel, false, false, false, buildAgentFor) | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | // prepareSessionResumeForWorkspace always rebuilds the agent. A selected | |
| 443 | + | // transcript from another cwd must not retain tools, instructions, or sandbox | |
| 444 | + | // state assembled for the previous workspace even when provider/model match. | |
| 445 | + | func prepareSessionResumeForWorkspace(path string, current *core.Agent, currentProvider, currentModel string, buildAgentFor func(string, string) (*core.Agent, string, string, error)) (sessionResumeCandidate, error) { | |
| 446 | + | return prepareSessionResumeWithOptions(path, current, currentProvider, currentModel, false, false, true, buildAgentFor) | |
| 440 | 447 | } | |
| 441 | 448 | ||
| 442 | 449 | // prepareSessionResumeWithOptions is the provider/model-aware implementation. | |
| 443 | 450 | // An explicit CLI provider or model keeps that field authoritative; an empty | |
| 444 | 451 | // field falls back to the selected session's metadata when it is complete. | |
| 445 | - | func prepareSessionResumeWithOptions(path string, current *core.Agent, currentProvider, currentModel string, explicitProvider, explicitModel bool, buildAgentFor func(string, string) (*core.Agent, string, string, error)) (candidate sessionResumeCandidate, err error) { | |
| 452 | + | func prepareSessionResumeWithOptions(path string, current *core.Agent, currentProvider, currentModel string, explicitProvider, explicitModel, forceRebuild bool, buildAgentFor func(string, string) (*core.Agent, string, string, error)) (candidate sessionResumeCandidate, err error) { | |
| 446 | 453 | if current == nil { | |
| 447 | 454 | return candidate, fmt.Errorf("no agent running; log in first") | |
| 448 | 455 | } | |
| @@ -496,7 +503,7 @@ func prepareSessionResumeWithOptions(path string, current *core.Agent, currentPr | |||
| 496 | 503 | if explicitModel { | |
| 497 | 504 | resumeModel = currentModel | |
| 498 | 505 | } | |
| 499 | - | if resumeProvider == currentProvider && resumeModel == currentModel { | |
| 506 | + | if !forceRebuild && resumeProvider == currentProvider && resumeModel == currentModel { | |
| 500 | 507 | keepSession = true | |
| 501 | 508 | return candidate, nil | |
| 502 | 509 | } | |
| @@ -581,7 +588,7 @@ func applySessionResume(sess *core.Session, ag *core.Agent, currentProvider, cur | |||
| 581 | 588 | strings.TrimSpace(sess.Meta.Model) == strings.TrimSpace(currentModel) { | |
| 582 | 589 | return candidate, nil | |
| 583 | 590 | } | |
| 584 | - | candidate, err := prepareSessionResumeWithOptions(sess.Path, ag, currentProvider, currentModel, explicitProvider, explicitModel, buildAgentFor) | |
| 591 | + | candidate, err := prepareSessionResumeWithOptions(sess.Path, ag, currentProvider, currentModel, explicitProvider, explicitModel, false, buildAgentFor) | |
| 585 | 592 | if err != nil { | |
| 586 | 593 | return sessionResumeCandidate{}, joinSessionCloseError(err, sess) | |
| 587 | 594 | } | |
| @@ -2030,13 +2037,33 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2030 | 2037 | ||
| 2031 | 2038 | // loadSession replaces the current session with the one at path and | |
| 2032 | 2039 | // hands its messages to the agent. Used by the /sessions picker. | |
| 2033 | - | loadSession := func(path string) (loadErr error) { | |
| 2040 | + | loadSessionContext := func(loadCtx context.Context, path string) (loadErr error) { | |
| 2041 | + | if err := loadCtx.Err(); err != nil { | |
| 2042 | + | return err | |
| 2043 | + | } | |
| 2044 | + | selectedMeta, err := core.ManagedSessionMeta(loadCtx, agentSessionsRoot(ZutHome(), args), path) | |
| 2045 | + | if err != nil { | |
| 2046 | + | return fmt.Errorf("validate selected session: %w", err) | |
| 2047 | + | } | |
| 2048 | + | if info, statErr := os.Stat(selectedMeta.CWD); statErr != nil || !info.IsDir() { | |
| 2049 | + | if statErr != nil { | |
| 2050 | + | return fmt.Errorf("selected session cwd: %w", statErr) | |
| 2051 | + | } | |
| 2052 | + | return fmt.Errorf("selected session cwd is not a directory: %s", selectedMeta.CWD) | |
| 2053 | + | } | |
| 2034 | 2054 | // Hold the transition lock from the pre-flush through the commit. | |
| 2035 | 2055 | // Persistence callbacks take the read side, so an active session | |
| 2036 | 2056 | // cannot be snapshotted before its lazy writes land or overwritten by | |
| 2037 | 2057 | // a callback while the candidate is being installed. | |
| 2058 | + | if err := loadCtx.Err(); err != nil { | |
| 2059 | + | return err | |
| 2060 | + | } | |
| 2038 | 2061 | sessionTransitionMu.Lock() | |
| 2039 | 2062 | defer sessionTransitionMu.Unlock() | |
| 2063 | + | if err := loadCtx.Err(); err != nil { | |
| 2064 | + | return err | |
| 2065 | + | } | |
| 2066 | + | workspaceChanged := filepath.Clean(selectedMeta.CWD) != filepath.Clean(args.CWD) | |
| 2040 | 2067 | ||
| 2041 | 2068 | currentAg := liveInteractiveAgent(iv, ag) | |
| 2042 | 2069 | if currentAg == nil { | |
| @@ -2067,15 +2094,63 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2067 | 2094 | // provider settings on the shared runtime, so restore those settings on | |
| 2068 | 2095 | // every failed path, including errors returned during preparation. | |
| 2069 | 2096 | runtimeConfig := runtime.snapshotConfiguration() | |
| 2097 | + | oldCWD := args.CWD | |
| 2098 | + | oldResolvedCWD := r.CWD | |
| 2099 | + | oldPermissionSet := args.PermissionSet | |
| 2100 | + | oldSandboxRoot := "" | |
| 2101 | + | oldSandboxLocked := false | |
| 2102 | + | if sharedSandbox != nil { | |
| 2103 | + | oldSandboxRoot = sharedSandbox.Root | |
| 2104 | + | oldSandboxLocked = sharedSandbox.Locked() | |
| 2105 | + | } | |
| 2106 | + | rollbackWorkspace := func() { | |
| 2107 | + | if !workspaceChanged { | |
| 2108 | + | return | |
| 2109 | + | } | |
| 2110 | + | args.CWD = oldCWD | |
| 2111 | + | r.CWD = oldResolvedCWD | |
| 2112 | + | args.PermissionSet = oldPermissionSet | |
| 2113 | + | runtime.SetRepoRoot(oldResolvedCWD) | |
| 2114 | + | if sharedSandbox != nil { | |
| 2115 | + | sharedSandbox.Root = oldSandboxRoot | |
| 2116 | + | if oldSandboxLocked { | |
| 2117 | + | sharedSandbox.Lock() | |
| 2118 | + | } else { | |
| 2119 | + | sharedSandbox.Unlock() | |
| 2120 | + | } | |
| 2121 | + | } | |
| 2122 | + | } | |
| 2123 | + | if workspaceChanged { | |
| 2124 | + | args.CWD = selectedMeta.CWD | |
| 2125 | + | r.CWD = selectedMeta.CWD | |
| 2126 | + | runtime.SetRepoRoot(selectedMeta.CWD) | |
| 2127 | + | if args.PermissionSet != nil { | |
| 2128 | + | expanded := args.PermissionSet.Expand(selectedMeta.CWD, args.AgentDataDir) | |
| 2129 | + | args.PermissionSet = &expanded | |
| 2130 | + | } | |
| 2131 | + | if sharedSandbox != nil { | |
| 2132 | + | sharedSandbox.Root = selectedMeta.CWD | |
| 2133 | + | if oldSandboxLocked { | |
| 2134 | + | sharedSandbox.Lock() | |
| 2135 | + | } else { | |
| 2136 | + | sharedSandbox.Unlock() | |
| 2137 | + | } | |
| 2138 | + | } | |
| 2139 | + | } | |
| 2070 | 2140 | committed := false | |
| 2071 | 2141 | var candidate sessionResumeCandidate | |
| 2072 | 2142 | defer func() { | |
| 2073 | 2143 | if !committed { | |
| 2144 | + | rollbackWorkspace() | |
| 2074 | 2145 | runtime.restoreConfiguration(runtimeConfig) | |
| 2075 | 2146 | loadErr = joinSessionCloseError(loadErr, candidate.session) | |
| 2076 | 2147 | } | |
| 2077 | 2148 | }() | |
| 2078 | - | candidate, err := prepareSessionResume(path, currentAg, currentProvider, currentModel, baseBuildAgentFor) | |
| 2149 | + | if workspaceChanged { | |
| 2150 | + | candidate, err = prepareSessionResumeForWorkspace(path, currentAg, currentProvider, currentModel, baseBuildAgentFor) | |
| 2151 | + | } else { | |
| 2152 | + | candidate, err = prepareSessionResume(path, currentAg, currentProvider, currentModel, baseBuildAgentFor) | |
| 2153 | + | } | |
| 2079 | 2154 | if err != nil { | |
| 2080 | 2155 | // prepareSessionResume closes its append handle on failure; | |
| 2081 | 2156 | // no live agent or session has been changed. | |
| @@ -2087,6 +2162,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2087 | 2162 | // agent is reused and only its transcript/usage are refreshed. | |
| 2088 | 2163 | wireAgentPersist(candidate.agent) | |
| 2089 | 2164 | ||
| 2165 | + | if err := loadCtx.Err(); err != nil { | |
| 2166 | + | return err | |
| 2167 | + | } | |
| 2090 | 2168 | persistMu.Lock() | |
| 2091 | 2169 | // A provider rebuild ran outside persistMu. Refuse to install it if | |
| 2092 | 2170 | // the live agent, session, selection, transcript, or cost changed in | |
| @@ -2121,7 +2199,11 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2121 | 2199 | // writer, so no new message can land on the old file between the | |
| 2122 | 2200 | // two state changes. | |
| 2123 | 2201 | if iv != nil { | |
| 2124 | - | iv.ApplySessionAgentWithCompactHandoff(candidate.agent, candidate.provider, candidate.model, candidate.session.Meta.CompactHandoff) | |
| 2202 | + | if workspaceChanged { | |
| 2203 | + | iv.ApplySessionAgentWithCWD(candidate.agent, candidate.provider, candidate.model, selectedMeta.CWD, candidate.session.Meta.CompactHandoff) | |
| 2204 | + | } else { | |
| 2205 | + | iv.ApplySessionAgentWithCompactHandoff(candidate.agent, candidate.provider, candidate.model, candidate.session.Meta.CompactHandoff) | |
| 2206 | + | } | |
| 2125 | 2207 | } | |
| 2126 | 2208 | ag = candidate.agent | |
| 2127 | 2209 | } else { | |
| @@ -2154,6 +2236,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2154 | 2236 | } | |
| 2155 | 2237 | return nil | |
| 2156 | 2238 | } | |
| 2239 | + | loadSession := func(path string) error { | |
| 2240 | + | return loadSessionContext(context.Background(), path) | |
| 2241 | + | } | |
| 2157 | 2242 | ||
| 2158 | 2243 | // changeCWD switches the running session to a new working directory. | |
| 2159 | 2244 | // Wired into InteractiveConfig.ChangeCWD and invoked by the hidden | |
| @@ -2499,6 +2584,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2499 | 2584 | ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup, | |
| 2500 | 2585 | ZutHome: ZutHome(), | |
| 2501 | 2586 | SessionsRoot: agentSessionsRoot(ZutHome(), args), | |
| 2587 | + | SessionsDisabled: args.NoSess, | |
| 2502 | 2588 | Version: version, | |
| 2503 | 2589 | UpdateInfoChan: updateCh, | |
| 2504 | 2590 | Sandbox: sharedSandbox, | |
| @@ -2578,6 +2664,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2578 | 2664 | return out | |
| 2579 | 2665 | }, | |
| 2580 | 2666 | LoadSession: loadSession, | |
| 2667 | + | LoadSessionContext: loadSessionContext, | |
| 2581 | 2668 | ChangeCWD: changeCWD, | |
| 2582 | 2669 | PersistCompactHandoff: persistCompactHandoff, | |
| 2583 | 2670 | CurrentCompactHandoff: currentCompactHandoff, | |
| @@ -436,13 +436,20 @@ type sessionResumeCandidate struct { | |||||
| 436 | // error this function closes the candidate session and leaves the current | 436 | // error this function closes the candidate session and leaves the current | ||
| 437 | // agent untouched. | 437 | // agent untouched. | ||
| 438 | func prepareSessionResume(path string, current *core.Agent, currentProvider, currentModel string, buildAgentFor func(string, string) (*core.Agent, string, string, error)) (sessionResumeCandidate, error) { | 438 | func prepareSessionResume(path string, current *core.Agent, currentProvider, currentModel string, buildAgentFor func(string, string) (*core.Agent, string, string, error)) (sessionResumeCandidate, error) { | ||
| 439 | - | return prepareSessionResumeWithOptions(path, current, currentProvider, currentModel, false, false, buildAgentFor) | 439 | + | return prepareSessionResumeWithOptions(path, current, currentProvider, currentModel, false, false, false, buildAgentFor) |
| 440 | + | } | |||
| 441 | + | ||||
| 442 | + | // prepareSessionResumeForWorkspace always rebuilds the agent. A selected | |||
| 443 | + | // transcript from another cwd must not retain tools, instructions, or sandbox | |||
| 444 | + | // state assembled for the previous workspace even when provider/model match. | |||
| 445 | + | func prepareSessionResumeForWorkspace(path string, current *core.Agent, currentProvider, currentModel string, buildAgentFor func(string, string) (*core.Agent, string, string, error)) (sessionResumeCandidate, error) { | |||
| 446 | + | return prepareSessionResumeWithOptions(path, current, currentProvider, currentModel, false, false, true, buildAgentFor) | |||
| 440 | } | 447 | } | ||
| 441 | 448 | ||||
| 442 | // prepareSessionResumeWithOptions is the provider/model-aware implementation. | 449 | // prepareSessionResumeWithOptions is the provider/model-aware implementation. | ||
| 443 | // An explicit CLI provider or model keeps that field authoritative; an empty | 450 | // An explicit CLI provider or model keeps that field authoritative; an empty | ||
| 444 | // field falls back to the selected session's metadata when it is complete. | 451 | // field falls back to the selected session's metadata when it is complete. | ||
| 445 | - | func prepareSessionResumeWithOptions(path string, current *core.Agent, currentProvider, currentModel string, explicitProvider, explicitModel bool, buildAgentFor func(string, string) (*core.Agent, string, string, error)) (candidate sessionResumeCandidate, err error) { | 452 | + | func prepareSessionResumeWithOptions(path string, current *core.Agent, currentProvider, currentModel string, explicitProvider, explicitModel, forceRebuild bool, buildAgentFor func(string, string) (*core.Agent, string, string, error)) (candidate sessionResumeCandidate, err error) { |
| 446 | if current == nil { | 453 | if current == nil { | ||
| 447 | return candidate, fmt.Errorf("no agent running; log in first") | 454 | return candidate, fmt.Errorf("no agent running; log in first") | ||
| 448 | } | 455 | } | ||
| @@ -496,7 +503,7 @@ func prepareSessionResumeWithOptions(path string, current *core.Agent, currentPr | |||||
| 496 | if explicitModel { | 503 | if explicitModel { | ||
| 497 | resumeModel = currentModel | 504 | resumeModel = currentModel | ||
| 498 | } | 505 | } | ||
| 499 | - | if resumeProvider == currentProvider && resumeModel == currentModel { | 506 | + | if !forceRebuild && resumeProvider == currentProvider && resumeModel == currentModel { |
| 500 | keepSession = true | 507 | keepSession = true | ||
| 501 | return candidate, nil | 508 | return candidate, nil | ||
| 502 | } | 509 | } | ||
| @@ -581,7 +588,7 @@ func applySessionResume(sess *core.Session, ag *core.Agent, currentProvider, cur | |||||
| 581 | strings.TrimSpace(sess.Meta.Model) == strings.TrimSpace(currentModel) { | 588 | strings.TrimSpace(sess.Meta.Model) == strings.TrimSpace(currentModel) { | ||
| 582 | return candidate, nil | 589 | return candidate, nil | ||
| 583 | } | 590 | } | ||
| 584 | - | candidate, err := prepareSessionResumeWithOptions(sess.Path, ag, currentProvider, currentModel, explicitProvider, explicitModel, buildAgentFor) | 591 | + | candidate, err := prepareSessionResumeWithOptions(sess.Path, ag, currentProvider, currentModel, explicitProvider, explicitModel, false, buildAgentFor) |
| 585 | if err != nil { | 592 | if err != nil { | ||
| 586 | return sessionResumeCandidate{}, joinSessionCloseError(err, sess) | 593 | return sessionResumeCandidate{}, joinSessionCloseError(err, sess) | ||
| 587 | } | 594 | } | ||
| @@ -2030,13 +2037,33 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2030 | 2037 | ||||
| 2031 | // loadSession replaces the current session with the one at path and | 2038 | // loadSession replaces the current session with the one at path and | ||
| 2032 | // hands its messages to the agent. Used by the /sessions picker. | 2039 | // hands its messages to the agent. Used by the /sessions picker. | ||
| 2033 | - | loadSession := func(path string) (loadErr error) { | 2040 | + | loadSessionContext := func(loadCtx context.Context, path string) (loadErr error) { |
| 2041 | + | if err := loadCtx.Err(); err != nil { | |||
| 2042 | + | return err | |||
| 2043 | + | } | |||
| 2044 | + | selectedMeta, err := core.ManagedSessionMeta(loadCtx, agentSessionsRoot(ZutHome(), args), path) | |||
| 2045 | + | if err != nil { | |||
| 2046 | + | return fmt.Errorf("validate selected session: %w", err) | |||
| 2047 | + | } | |||
| 2048 | + | if info, statErr := os.Stat(selectedMeta.CWD); statErr != nil || !info.IsDir() { | |||
| 2049 | + | if statErr != nil { | |||
| 2050 | + | return fmt.Errorf("selected session cwd: %w", statErr) | |||
| 2051 | + | } | |||
| 2052 | + | return fmt.Errorf("selected session cwd is not a directory: %s", selectedMeta.CWD) | |||
| 2053 | + | } | |||
| 2034 | // Hold the transition lock from the pre-flush through the commit. | 2054 | // Hold the transition lock from the pre-flush through the commit. | ||
| 2035 | // Persistence callbacks take the read side, so an active session | 2055 | // Persistence callbacks take the read side, so an active session | ||
| 2036 | // cannot be snapshotted before its lazy writes land or overwritten by | 2056 | // cannot be snapshotted before its lazy writes land or overwritten by | ||
| 2037 | // a callback while the candidate is being installed. | 2057 | // a callback while the candidate is being installed. | ||
| 2058 | + | if err := loadCtx.Err(); err != nil { | |||
| 2059 | + | return err | |||
| 2060 | + | } | |||
| 2038 | sessionTransitionMu.Lock() | 2061 | sessionTransitionMu.Lock() | ||
| 2039 | defer sessionTransitionMu.Unlock() | 2062 | defer sessionTransitionMu.Unlock() | ||
| 2063 | + | if err := loadCtx.Err(); err != nil { | |||
| 2064 | + | return err | |||
| 2065 | + | } | |||
| 2066 | + | workspaceChanged := filepath.Clean(selectedMeta.CWD) != filepath.Clean(args.CWD) | |||
| 2040 | 2067 | ||||
| 2041 | currentAg := liveInteractiveAgent(iv, ag) | 2068 | currentAg := liveInteractiveAgent(iv, ag) | ||
| 2042 | if currentAg == nil { | 2069 | if currentAg == nil { | ||
| @@ -2067,15 +2094,63 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2067 | // provider settings on the shared runtime, so restore those settings on | 2094 | // provider settings on the shared runtime, so restore those settings on | ||
| 2068 | // every failed path, including errors returned during preparation. | 2095 | // every failed path, including errors returned during preparation. | ||
| 2069 | runtimeConfig := runtime.snapshotConfiguration() | 2096 | runtimeConfig := runtime.snapshotConfiguration() | ||
| 2097 | + | oldCWD := args.CWD | |||
| 2098 | + | oldResolvedCWD := r.CWD | |||
| 2099 | + | oldPermissionSet := args.PermissionSet | |||
| 2100 | + | oldSandboxRoot := "" | |||
| 2101 | + | oldSandboxLocked := false | |||
| 2102 | + | if sharedSandbox != nil { | |||
| 2103 | + | oldSandboxRoot = sharedSandbox.Root | |||
| 2104 | + | oldSandboxLocked = sharedSandbox.Locked() | |||
| 2105 | + | } | |||
| 2106 | + | rollbackWorkspace := func() { | |||
| 2107 | + | if !workspaceChanged { | |||
| 2108 | + | return | |||
| 2109 | + | } | |||
| 2110 | + | args.CWD = oldCWD | |||
| 2111 | + | r.CWD = oldResolvedCWD | |||
| 2112 | + | args.PermissionSet = oldPermissionSet | |||
| 2113 | + | runtime.SetRepoRoot(oldResolvedCWD) | |||
| 2114 | + | if sharedSandbox != nil { | |||
| 2115 | + | sharedSandbox.Root = oldSandboxRoot | |||
| 2116 | + | if oldSandboxLocked { | |||
| 2117 | + | sharedSandbox.Lock() | |||
| 2118 | + | } else { | |||
| 2119 | + | sharedSandbox.Unlock() | |||
| 2120 | + | } | |||
| 2121 | + | } | |||
| 2122 | + | } | |||
| 2123 | + | if workspaceChanged { | |||
| 2124 | + | args.CWD = selectedMeta.CWD | |||
| 2125 | + | r.CWD = selectedMeta.CWD | |||
| 2126 | + | runtime.SetRepoRoot(selectedMeta.CWD) | |||
| 2127 | + | if args.PermissionSet != nil { | |||
| 2128 | + | expanded := args.PermissionSet.Expand(selectedMeta.CWD, args.AgentDataDir) | |||
| 2129 | + | args.PermissionSet = &expanded | |||
| 2130 | + | } | |||
| 2131 | + | if sharedSandbox != nil { | |||
| 2132 | + | sharedSandbox.Root = selectedMeta.CWD | |||
| 2133 | + | if oldSandboxLocked { | |||
| 2134 | + | sharedSandbox.Lock() | |||
| 2135 | + | } else { | |||
| 2136 | + | sharedSandbox.Unlock() | |||
| 2137 | + | } | |||
| 2138 | + | } | |||
| 2139 | + | } | |||
| 2070 | committed := false | 2140 | committed := false | ||
| 2071 | var candidate sessionResumeCandidate | 2141 | var candidate sessionResumeCandidate | ||
| 2072 | defer func() { | 2142 | defer func() { | ||
| 2073 | if !committed { | 2143 | if !committed { | ||
| 2144 | + | rollbackWorkspace() | |||
| 2074 | runtime.restoreConfiguration(runtimeConfig) | 2145 | runtime.restoreConfiguration(runtimeConfig) | ||
| 2075 | loadErr = joinSessionCloseError(loadErr, candidate.session) | 2146 | loadErr = joinSessionCloseError(loadErr, candidate.session) | ||
| 2076 | } | 2147 | } | ||
| 2077 | }() | 2148 | }() | ||
| 2078 | - | candidate, err := prepareSessionResume(path, currentAg, currentProvider, currentModel, baseBuildAgentFor) | 2149 | + | if workspaceChanged { |
| 2150 | + | candidate, err = prepareSessionResumeForWorkspace(path, currentAg, currentProvider, currentModel, baseBuildAgentFor) | |||
| 2151 | + | } else { | |||
| 2152 | + | candidate, err = prepareSessionResume(path, currentAg, currentProvider, currentModel, baseBuildAgentFor) | |||
| 2153 | + | } | |||
| 2079 | if err != nil { | 2154 | if err != nil { | ||
| 2080 | // prepareSessionResume closes its append handle on failure; | 2155 | // prepareSessionResume closes its append handle on failure; | ||
| 2081 | // no live agent or session has been changed. | 2156 | // no live agent or session has been changed. | ||
| @@ -2087,6 +2162,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2087 | // agent is reused and only its transcript/usage are refreshed. | 2162 | // agent is reused and only its transcript/usage are refreshed. | ||
| 2088 | wireAgentPersist(candidate.agent) | 2163 | wireAgentPersist(candidate.agent) | ||
| 2089 | 2164 | ||||
| 2165 | + | if err := loadCtx.Err(); err != nil { | |||
| 2166 | + | return err | |||
| 2167 | + | } | |||
| 2090 | persistMu.Lock() | 2168 | persistMu.Lock() | ||
| 2091 | // A provider rebuild ran outside persistMu. Refuse to install it if | 2169 | // A provider rebuild ran outside persistMu. Refuse to install it if | ||
| 2092 | // the live agent, session, selection, transcript, or cost changed in | 2170 | // the live agent, session, selection, transcript, or cost changed in | ||
| @@ -2121,7 +2199,11 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2121 | // writer, so no new message can land on the old file between the | 2199 | // writer, so no new message can land on the old file between the | ||
| 2122 | // two state changes. | 2200 | // two state changes. | ||
| 2123 | if iv != nil { | 2201 | if iv != nil { | ||
| 2124 | - | iv.ApplySessionAgentWithCompactHandoff(candidate.agent, candidate.provider, candidate.model, candidate.session.Meta.CompactHandoff) | 2202 | + | if workspaceChanged { |
| 2203 | + | iv.ApplySessionAgentWithCWD(candidate.agent, candidate.provider, candidate.model, selectedMeta.CWD, candidate.session.Meta.CompactHandoff) | |||
| 2204 | + | } else { | |||
| 2205 | + | iv.ApplySessionAgentWithCompactHandoff(candidate.agent, candidate.provider, candidate.model, candidate.session.Meta.CompactHandoff) | |||
| 2206 | + | } | |||
| 2125 | } | 2207 | } | ||
| 2126 | ag = candidate.agent | 2208 | ag = candidate.agent | ||
| 2127 | } else { | 2209 | } else { | ||
| @@ -2154,6 +2236,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2154 | } | 2236 | } | ||
| 2155 | return nil | 2237 | return nil | ||
| 2156 | } | 2238 | } | ||
| 2239 | + | loadSession := func(path string) error { | |||
| 2240 | + | return loadSessionContext(context.Background(), path) | |||
| 2241 | + | } | |||
| 2157 | 2242 | ||||
| 2158 | // changeCWD switches the running session to a new working directory. | 2243 | // changeCWD switches the running session to a new working directory. | ||
| 2159 | // Wired into InteractiveConfig.ChangeCWD and invoked by the hidden | 2244 | // Wired into InteractiveConfig.ChangeCWD and invoked by the hidden | ||
| @@ -2499,6 +2584,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2499 | ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup, | 2584 | ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup, | ||
| 2500 | ZutHome: ZutHome(), | 2585 | ZutHome: ZutHome(), | ||
| 2501 | SessionsRoot: agentSessionsRoot(ZutHome(), args), | 2586 | SessionsRoot: agentSessionsRoot(ZutHome(), args), | ||
| 2587 | + | SessionsDisabled: args.NoSess, | |||
| 2502 | Version: version, | 2588 | Version: version, | ||
| 2503 | UpdateInfoChan: updateCh, | 2589 | UpdateInfoChan: updateCh, | ||
| 2504 | Sandbox: sharedSandbox, | 2590 | Sandbox: sharedSandbox, | ||
| @@ -2578,6 +2664,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2578 | return out | 2664 | return out | ||
| 2579 | }, | 2665 | }, | ||
| 2580 | LoadSession: loadSession, | 2666 | LoadSession: loadSession, | ||
| 2667 | + | LoadSessionContext: loadSessionContext, | |||
| 2581 | ChangeCWD: changeCWD, | 2668 | ChangeCWD: changeCWD, | ||
| 2582 | PersistCompactHandoff: persistCompactHandoff, | 2669 | PersistCompactHandoff: persistCompactHandoff, | ||
| 2583 | CurrentCompactHandoff: currentCompactHandoff, | 2670 | CurrentCompactHandoff: currentCompactHandoff, | ||
M packages/agent/cli_session_test.go +18 -1
| @@ -885,6 +885,23 @@ func TestApplySessionResumeBindsRebuiltAgentToDurableSessionID(t *testing.T) { | |||
| 885 | 885 | } | |
| 886 | 886 | } | |
| 887 | 887 | ||
| 888 | + | func TestPrepareSessionResumeForWorkspaceRebuildsMatchingProviderAndModel(t *testing.T) { | |
| 889 | + | path := syntheticSession(t, "provider", "model", provider.Usage{}) | |
| 890 | + | current := core.NewAgent(nil, "model", "", nil) | |
| 891 | + | builds := 0 | |
| 892 | + | candidate, err := prepareSessionResumeForWorkspace(path, current, "provider", "model", func(providerName, model string) (*core.Agent, string, string, error) { | |
| 893 | + | builds++ | |
| 894 | + | return core.NewAgent(nil, model, "", nil), providerName, model, nil | |
| 895 | + | }) | |
| 896 | + | if err != nil { | |
| 897 | + | t.Fatal(err) | |
| 898 | + | } | |
| 899 | + | defer candidate.session.Close() | |
| 900 | + | if builds != 1 || !candidate.rebuilt || candidate.agent == current { | |
| 901 | + | t.Fatalf("workspace resume = builds %d rebuilt %v agent=%p current=%p", builds, candidate.rebuilt, candidate.agent, current) | |
| 902 | + | } | |
| 903 | + | } | |
| 904 | + | ||
| 888 | 905 | func TestPrepareSessionResumePreservesLegacyMissingMetadata(t *testing.T) { | |
| 889 | 906 | old := core.NewAgent(nil, "old-model", "", nil) | |
| 890 | 907 | old.SetMessages([]provider.Message{{ | |
| @@ -987,7 +1004,7 @@ func TestPrepareSessionResumeHonorsExplicitProviderModelFields(t *testing.T) { | |||
| 987 | 1004 | old := core.NewAgent(nil, "old-model", "", nil) | |
| 988 | 1005 | path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{}) | |
| 989 | 1006 | var gotProvider, gotModel string | |
| 990 | - | candidate, err := prepareSessionResumeWithOptions(path, old, "current-provider", "current-model", tc.explicitProvider, tc.explicitModel, func(providerName, model string) (*core.Agent, string, string, error) { | |
| 1007 | + | candidate, err := prepareSessionResumeWithOptions(path, old, "current-provider", "current-model", tc.explicitProvider, tc.explicitModel, false, func(providerName, model string) (*core.Agent, string, string, error) { | |
| 991 | 1008 | gotProvider, gotModel = providerName, model | |
| 992 | 1009 | return core.NewAgent(nil, model, "", nil), providerName, model, nil | |
| 993 | 1010 | }) | |
| @@ -885,6 +885,23 @@ func TestApplySessionResumeBindsRebuiltAgentToDurableSessionID(t *testing.T) { | |||||
| 885 | } | 885 | } | ||
| 886 | } | 886 | } | ||
| 887 | 887 | ||||
| 888 | + | func TestPrepareSessionResumeForWorkspaceRebuildsMatchingProviderAndModel(t *testing.T) { | |||
| 889 | + | path := syntheticSession(t, "provider", "model", provider.Usage{}) | |||
| 890 | + | current := core.NewAgent(nil, "model", "", nil) | |||
| 891 | + | builds := 0 | |||
| 892 | + | candidate, err := prepareSessionResumeForWorkspace(path, current, "provider", "model", func(providerName, model string) (*core.Agent, string, string, error) { | |||
| 893 | + | builds++ | |||
| 894 | + | return core.NewAgent(nil, model, "", nil), providerName, model, nil | |||
| 895 | + | }) | |||
| 896 | + | if err != nil { | |||
| 897 | + | t.Fatal(err) | |||
| 898 | + | } | |||
| 899 | + | defer candidate.session.Close() | |||
| 900 | + | if builds != 1 || !candidate.rebuilt || candidate.agent == current { | |||
| 901 | + | t.Fatalf("workspace resume = builds %d rebuilt %v agent=%p current=%p", builds, candidate.rebuilt, candidate.agent, current) | |||
| 902 | + | } | |||
| 903 | + | } | |||
| 904 | + | ||||
| 888 | func TestPrepareSessionResumePreservesLegacyMissingMetadata(t *testing.T) { | 905 | func TestPrepareSessionResumePreservesLegacyMissingMetadata(t *testing.T) { | ||
| 889 | old := core.NewAgent(nil, "old-model", "", nil) | 906 | old := core.NewAgent(nil, "old-model", "", nil) | ||
| 890 | old.SetMessages([]provider.Message{{ | 907 | old.SetMessages([]provider.Message{{ | ||
| @@ -987,7 +1004,7 @@ func TestPrepareSessionResumeHonorsExplicitProviderModelFields(t *testing.T) { | |||||
| 987 | old := core.NewAgent(nil, "old-model", "", nil) | 1004 | old := core.NewAgent(nil, "old-model", "", nil) | ||
| 988 | path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{}) | 1005 | path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{}) | ||
| 989 | var gotProvider, gotModel string | 1006 | var gotProvider, gotModel string | ||
| 990 | - | candidate, err := prepareSessionResumeWithOptions(path, old, "current-provider", "current-model", tc.explicitProvider, tc.explicitModel, func(providerName, model string) (*core.Agent, string, string, error) { | 1007 | + | candidate, err := prepareSessionResumeWithOptions(path, old, "current-provider", "current-model", tc.explicitProvider, tc.explicitModel, false, func(providerName, model string) (*core.Agent, string, string, error) { |
| 991 | gotProvider, gotModel = providerName, model | 1008 | gotProvider, gotModel = providerName, model | ||
| 992 | return core.NewAgent(nil, model, "", nil), providerName, model, nil | 1009 | return core.NewAgent(nil, model, "", nil), providerName, model, nil | ||
| 993 | }) | 1010 | }) | ||
M packages/agent/modes/interactive.go +19 -0
| @@ -277,6 +277,9 @@ type InteractiveConfig struct { | |||
| 277 | 277 | // from ZutHome for Zutfile agents, whose sessions are isolated by agent | |
| 278 | 278 | // name. Empty falls back to ZutHome for embedders and tests. | |
| 279 | 279 | SessionsRoot string | |
| 280 | + | // SessionsDisabled is true for --no-session invocations. The picker must | |
| 281 | + | // fail closed instead of offering stale or non-persisted state. | |
| 282 | + | SessionsDisabled bool | |
| 280 | 283 | ||
| 281 | 284 | // Version is the binary's current version (from main.version). | |
| 282 | 285 | // Used only for display; the update check itself is done outside | |
| @@ -296,6 +299,9 @@ type InteractiveConfig struct { | |||
| 296 | 299 | // LoadSession swaps the current session for the one at path. The | |
| 297 | 300 | // callback returns the new agent message slice so the TUI can invalidate. | |
| 298 | 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 | |
| 299 | 305 | ||
| 300 | 306 | // ChangeCWD switches the running zut session's working directory | |
| 301 | 307 | // to path. The host closes the current session, rebuilds the | |
| @@ -895,6 +901,9 @@ type Interactive struct { | |||
| 895 | 901 | // picker owns the load lifecycle; the interactive loop applies results so | |
| 896 | 902 | // rendering never races with background file reads. | |
| 897 | 903 | sessionLoads <-chan sessionLoadEvent | |
| 904 | + | // sessionSearches carries the one-time corpus read and query-match results. | |
| 905 | + | // It stays separate from summary loading so a query edit never reopens JSONL. | |
| 906 | + | sessionSearches <-chan sessionSearchEvent | |
| 898 | 907 | ||
| 899 | 908 | // sessionTreeLoads delivers asynchronously-read tree rows. Like the flat | |
| 900 | 909 | // picker, the main loop is the sole owner of dialog mutation and rendering. | |
| @@ -1365,6 +1374,16 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1365 | 1374 | i.sessionDialog.ApplyLoad(event) | |
| 1366 | 1375 | i.mu.Unlock() | |
| 1367 | 1376 | i.invalidate() | |
| 1377 | + | case event, ok := <-i.sessionSearches: | |
| 1378 | + | i.mu.Lock() | |
| 1379 | + | if !ok { | |
| 1380 | + | i.sessionSearches = nil | |
| 1381 | + | i.mu.Unlock() | |
| 1382 | + | break | |
| 1383 | + | } | |
| 1384 | + | i.sessionDialog.ApplySearch(event) | |
| 1385 | + | i.mu.Unlock() | |
| 1386 | + | i.invalidate() | |
| 1368 | 1387 | case event, ok := <-i.sessionTreeLoads: | |
| 1369 | 1388 | i.mu.Lock() | |
| 1370 | 1389 | if !ok { | |
| @@ -277,6 +277,9 @@ type InteractiveConfig struct { | |||||
| 277 | // from ZutHome for Zutfile agents, whose sessions are isolated by agent | 277 | // from ZutHome for Zutfile agents, whose sessions are isolated by agent | ||
| 278 | // name. Empty falls back to ZutHome for embedders and tests. | 278 | // name. Empty falls back to ZutHome for embedders and tests. | ||
| 279 | SessionsRoot string | 279 | SessionsRoot string | ||
| 280 | + | // SessionsDisabled is true for --no-session invocations. The picker must | |||
| 281 | + | // fail closed instead of offering stale or non-persisted state. | |||
| 282 | + | SessionsDisabled bool | |||
| 280 | 283 | ||||
| 281 | // Version is the binary's current version (from main.version). | 284 | // Version is the binary's current version (from main.version). | ||
| 282 | // Used only for display; the update check itself is done outside | 285 | // Used only for display; the update check itself is done outside | ||
| @@ -296,6 +299,9 @@ type InteractiveConfig struct { | |||||
| 296 | // LoadSession swaps the current session for the one at path. The | 299 | // LoadSession swaps the current session for the one at path. The | ||
| 297 | // 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. | ||
| 298 | 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 | |||
| 299 | 305 | ||||
| 300 | // ChangeCWD switches the running zut session's working directory | 306 | // ChangeCWD switches the running zut session's working directory | ||
| 301 | // to path. The host closes the current session, rebuilds the | 307 | // to path. The host closes the current session, rebuilds the | ||
| @@ -895,6 +901,9 @@ type Interactive struct { | |||||
| 895 | // picker owns the load lifecycle; the interactive loop applies results so | 901 | // picker owns the load lifecycle; the interactive loop applies results so | ||
| 896 | // rendering never races with background file reads. | 902 | // rendering never races with background file reads. | ||
| 897 | sessionLoads <-chan sessionLoadEvent | 903 | sessionLoads <-chan sessionLoadEvent | ||
| 904 | + | // sessionSearches carries the one-time corpus read and query-match results. | |||
| 905 | + | // It stays separate from summary loading so a query edit never reopens JSONL. | |||
| 906 | + | sessionSearches <-chan sessionSearchEvent | |||
| 898 | 907 | ||||
| 899 | // sessionTreeLoads delivers asynchronously-read tree rows. Like the flat | 908 | // sessionTreeLoads delivers asynchronously-read tree rows. Like the flat | ||
| 900 | // picker, the main loop is the sole owner of dialog mutation and rendering. | 909 | // picker, the main loop is the sole owner of dialog mutation and rendering. | ||
| @@ -1365,6 +1374,16 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 1365 | i.sessionDialog.ApplyLoad(event) | 1374 | i.sessionDialog.ApplyLoad(event) | ||
| 1366 | i.mu.Unlock() | 1375 | i.mu.Unlock() | ||
| 1367 | i.invalidate() | 1376 | i.invalidate() | ||
| 1377 | + | case event, ok := <-i.sessionSearches: | |||
| 1378 | + | i.mu.Lock() | |||
| 1379 | + | if !ok { | |||
| 1380 | + | i.sessionSearches = nil | |||
| 1381 | + | i.mu.Unlock() | |||
| 1382 | + | break | |||
| 1383 | + | } | |||
| 1384 | + | i.sessionDialog.ApplySearch(event) | |||
| 1385 | + | i.mu.Unlock() | |||
| 1386 | + | i.invalidate() | |||
| 1368 | case event, ok := <-i.sessionTreeLoads: | 1387 | case event, ok := <-i.sessionTreeLoads: | ||
| 1369 | i.mu.Lock() | 1388 | i.mu.Lock() | ||
| 1370 | if !ok { | 1389 | if !ok { | ||
M packages/agent/modes/interactive_commands.go +80 -44
| @@ -310,8 +310,16 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 310 | 310 | case "/settings": | |
| 311 | 311 | i.openSettingsDialog() | |
| 312 | 312 | case "/sessions": | |
| 313 | + | if i.cfg.SessionsDisabled { | |
| 314 | + | i.mu.Lock() | |
| 315 | + | i.statusErr = "sessions are disabled by --no-session" | |
| 316 | + | i.statusOK = "" | |
| 317 | + | i.mu.Unlock() | |
| 318 | + | break | |
| 319 | + | } | |
| 313 | 320 | i.mu.Lock() | |
| 314 | 321 | i.sessionLoads = i.sessionDialog.Open(ctx, i.sessionsRoot(), i.cfg.CWD) | |
| 322 | + | i.sessionSearches = nil | |
| 315 | 323 | i.mu.Unlock() | |
| 316 | 324 | case "/fork": | |
| 317 | 325 | i.doSessionFork() | |
| @@ -978,6 +986,19 @@ func totalTurnsLocked(msgs []provider.Message) int { | |||
| 978 | 986 | } | |
| 979 | 987 | return n | |
| 980 | 988 | } | |
| 989 | + | func (i *Interactive) canResumeSessionSelection() bool { | |
| 990 | + | if i == nil || i.cfg.LoadSession == nil { | |
| 991 | + | return false | |
| 992 | + | } | |
| 993 | + | i.mu.Lock() | |
| 994 | + | busy := i.busy || i.streamOn || i.streamFlushPending || len(i.streamPending) != 0 || | |
| 995 | + | i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing | |
| 996 | + | queued := len(i.queued) != 0 | |
| 997 | + | ag := i.agent | |
| 998 | + | i.mu.Unlock() | |
| 999 | + | return !busy && !queued && ag != nil && ag.QueuedMessageCount() == 0 | |
| 1000 | + | } | |
| 1001 | + | ||
| 981 | 1002 | func (i *Interactive) applySessionSelection(path string) { | |
| 982 | 1003 | if i.cfg.LoadSession == nil { | |
| 983 | 1004 | i.mu.Lock() | |
| @@ -1000,58 +1021,73 @@ func (i *Interactive) applySessionSelection(path string) { | |||
| 1000 | 1021 | i.markSessionTitleSwitching() | |
| 1001 | 1022 | ||
| 1002 | 1023 | go func() { | |
| 1003 | - | err := i.cfg.LoadSession(path) | |
| 1004 | - | if err != nil { | |
| 1005 | - | i.restoreFailedSessionTitle() | |
| 1006 | - | i.mu.Lock() | |
| 1007 | - | i.sessionLoading = false | |
| 1008 | - | i.statusErr = err.Error() | |
| 1009 | - | i.statusOK = "" | |
| 1010 | - | i.mu.Unlock() | |
| 1011 | - | 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) | |
| 1012 | 1032 | return | |
| 1013 | 1033 | } | |
| 1014 | - | i.restoreLoadedSessionTitle() | |
| 1015 | - | 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() | |
| 1016 | 1042 | i.mu.Lock() | |
| 1017 | - | i.compactContinuation = state | |
| 1018 | 1043 | i.sessionLoading = false | |
| 1019 | - | i.statusOK = "resumed session: " + path | |
| 1020 | - | i.statusErr = "" | |
| 1021 | - | i.parkedTurn = 0 | |
| 1022 | - | i.parkedTotal = 0 | |
| 1023 | - | i.scrollOffset = 0 | |
| 1024 | - | // Fresh transcript swapped in: drop the auto-follow baseline so | |
| 1025 | - | // the next render's follow guard doesn't see the wholesale | |
| 1026 | - | // length change as a delta and jump the viewport. | |
| 1027 | - | i.prevChatLen = 0 | |
| 1028 | - | i.prevChatCols = 0 | |
| 1029 | - | i.extNotes = nil | |
| 1030 | - | i.view.InvalidateRenderCache() | |
| 1031 | - | if i.agent != nil { | |
| 1032 | - | i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages()) | |
| 1033 | - | i.cumUsage = i.agent.Cost() | |
| 1034 | - | if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 { | |
| 1035 | - | i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens | |
| 1036 | - | } else { | |
| 1037 | - | i.lastCtxInput = 0 | |
| 1038 | - | } | |
| 1039 | - | // Snap to the tail again — the swap brought in a fresh | |
| 1040 | - | // transcript whose markdown / chroma cost we don't want | |
| 1041 | - | // blocking the redraw. | |
| 1042 | - | if len(i.view.Messages) > initialResumeTailLimit { | |
| 1043 | - | i.view.TailLimit = initialResumeTailLimit | |
| 1044 | - | } else { | |
| 1045 | - | i.view.TailLimit = 0 | |
| 1046 | - | } | |
| 1047 | - | } | |
| 1044 | + | i.statusErr = err.Error() | |
| 1045 | + | i.statusOK = "" | |
| 1048 | 1046 | i.mu.Unlock() | |
| 1049 | 1047 | i.invalidate() | |
| 1050 | - | if state.reason != compactContinuationNone { | |
| 1051 | - | 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 | |
| 1052 | 1074 | } | |
| 1053 | - | }() | |
| 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 | + | } | |
| 1054 | 1089 | } | |
| 1090 | + | ||
| 1055 | 1091 | func (i *Interactive) applyModelSelection(prov, model string) { | |
| 1056 | 1092 | i.swapModel(prov, model, i.cfg.BuildAgentFor, false) | |
| 1057 | 1093 | } | |
| @@ -310,8 +310,16 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 310 | case "/settings": | 310 | case "/settings": | ||
| 311 | i.openSettingsDialog() | 311 | i.openSettingsDialog() | ||
| 312 | case "/sessions": | 312 | case "/sessions": | ||
| 313 | + | if i.cfg.SessionsDisabled { | |||
| 314 | + | i.mu.Lock() | |||
| 315 | + | i.statusErr = "sessions are disabled by --no-session" | |||
| 316 | + | i.statusOK = "" | |||
| 317 | + | i.mu.Unlock() | |||
| 318 | + | break | |||
| 319 | + | } | |||
| 313 | i.mu.Lock() | 320 | i.mu.Lock() | ||
| 314 | i.sessionLoads = i.sessionDialog.Open(ctx, i.sessionsRoot(), i.cfg.CWD) | 321 | i.sessionLoads = i.sessionDialog.Open(ctx, i.sessionsRoot(), i.cfg.CWD) | ||
| 322 | + | i.sessionSearches = nil | |||
| 315 | i.mu.Unlock() | 323 | i.mu.Unlock() | ||
| 316 | case "/fork": | 324 | case "/fork": | ||
| 317 | i.doSessionFork() | 325 | i.doSessionFork() | ||
| @@ -978,6 +986,19 @@ func totalTurnsLocked(msgs []provider.Message) int { | |||||
| 978 | } | 986 | } | ||
| 979 | return n | 987 | return n | ||
| 980 | } | 988 | } | ||
| 989 | + | func (i *Interactive) canResumeSessionSelection() bool { | |||
| 990 | + | if i == nil || i.cfg.LoadSession == nil { | |||
| 991 | + | return false | |||
| 992 | + | } | |||
| 993 | + | i.mu.Lock() | |||
| 994 | + | busy := i.busy || i.streamOn || i.streamFlushPending || len(i.streamPending) != 0 || | |||
| 995 | + | i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing | |||
| 996 | + | queued := len(i.queued) != 0 | |||
| 997 | + | ag := i.agent | |||
| 998 | + | i.mu.Unlock() | |||
| 999 | + | return !busy && !queued && ag != nil && ag.QueuedMessageCount() == 0 | |||
| 1000 | + | } | |||
| 1001 | + | ||||
| 981 | func (i *Interactive) applySessionSelection(path string) { | 1002 | func (i *Interactive) applySessionSelection(path string) { | ||
| 982 | if i.cfg.LoadSession == nil { | 1003 | if i.cfg.LoadSession == nil { | ||
| 983 | i.mu.Lock() | 1004 | i.mu.Lock() | ||
| @@ -1000,58 +1021,73 @@ func (i *Interactive) applySessionSelection(path string) { | |||||
| 1000 | i.markSessionTitleSwitching() | 1021 | i.markSessionTitleSwitching() | ||
| 1001 | 1022 | ||||
| 1002 | go func() { | 1023 | go func() { | ||
| 1003 | - | err := i.cfg.LoadSession(path) | 1024 | + | load := i.cfg.LoadSession |
| 1004 | - | if err != nil { | 1025 | + | if i.cfg.LoadSessionContext != nil { |
| 1005 | - | i.restoreFailedSessionTitle() | 1026 | + | ctx := i.runCtx |
| 1006 | - | i.mu.Lock() | 1027 | + | if ctx == nil { |
| 1007 | - | i.sessionLoading = false | 1028 | + | ctx = context.Background() |
| 1008 | - | i.statusErr = err.Error() | 1029 | + | } |
| 1009 | - | i.statusOK = "" | 1030 | + | err := i.cfg.LoadSessionContext(ctx, path) |
| 1010 | - | i.mu.Unlock() | 1031 | + | i.finishSessionSelection(path, err) |
| 1011 | - | i.invalidate() | |||
| 1012 | return | 1032 | return | ||
| 1013 | } | 1033 | } | ||
| 1014 | - | i.restoreLoadedSessionTitle() | 1034 | + | err := load(path) |
| 1015 | - | 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() | |||
| 1016 | i.mu.Lock() | 1042 | i.mu.Lock() | ||
| 1017 | - | i.compactContinuation = state | |||
| 1018 | i.sessionLoading = false | 1043 | i.sessionLoading = false | ||
| 1019 | - | i.statusOK = "resumed session: " + path | 1044 | + | i.statusErr = err.Error() |
| 1020 | - | i.statusErr = "" | 1045 | + | i.statusOK = "" |
| 1021 | - | i.parkedTurn = 0 | |||
| 1022 | - | i.parkedTotal = 0 | |||
| 1023 | - | i.scrollOffset = 0 | |||
| 1024 | - | // Fresh transcript swapped in: drop the auto-follow baseline so | |||
| 1025 | - | // the next render's follow guard doesn't see the wholesale | |||
| 1026 | - | // length change as a delta and jump the viewport. | |||
| 1027 | - | i.prevChatLen = 0 | |||
| 1028 | - | i.prevChatCols = 0 | |||
| 1029 | - | i.extNotes = nil | |||
| 1030 | - | i.view.InvalidateRenderCache() | |||
| 1031 | - | if i.agent != nil { | |||
| 1032 | - | i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages()) | |||
| 1033 | - | i.cumUsage = i.agent.Cost() | |||
| 1034 | - | if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 { | |||
| 1035 | - | i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens | |||
| 1036 | - | } else { | |||
| 1037 | - | i.lastCtxInput = 0 | |||
| 1038 | - | } | |||
| 1039 | - | // Snap to the tail again — the swap brought in a fresh | |||
| 1040 | - | // transcript whose markdown / chroma cost we don't want | |||
| 1041 | - | // blocking the redraw. | |||
| 1042 | - | if len(i.view.Messages) > initialResumeTailLimit { | |||
| 1043 | - | i.view.TailLimit = initialResumeTailLimit | |||
| 1044 | - | } else { | |||
| 1045 | - | i.view.TailLimit = 0 | |||
| 1046 | - | } | |||
| 1047 | - | } | |||
| 1048 | i.mu.Unlock() | 1046 | i.mu.Unlock() | ||
| 1049 | i.invalidate() | 1047 | i.invalidate() | ||
| 1050 | - | if state.reason != compactContinuationNone { | 1048 | + | return |
| 1051 | - | 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 | |||
| 1052 | } | 1074 | } | ||
| 1053 | - | }() | 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 | + | } | |||
| 1054 | } | 1089 | } | ||
| 1090 | + | ||||
| 1055 | func (i *Interactive) applyModelSelection(prov, model string) { | 1091 | func (i *Interactive) applyModelSelection(prov, model string) { | ||
| 1056 | i.swapModel(prov, model, i.cfg.BuildAgentFor, false) | 1092 | i.swapModel(prov, model, i.cfg.BuildAgentFor, false) | ||
| 1057 | } | 1093 | } | ||
M packages/agent/modes/interactive_host.go +28 -0
| @@ -126,6 +126,34 @@ func (i *Interactive) ApplySessionAgentWithCompactHandoff(ag *core.Agent, provid | |||
| 126 | 126 | i.mu.Unlock() | |
| 127 | 127 | i.invalidate() | |
| 128 | 128 | } | |
| 129 | + | ||
| 130 | + | // ApplySessionAgentWithCWD commits a resumed agent together with its stored | |
| 131 | + | // workspace. Unlike a fresh /cd transition it preserves the session's compact | |
| 132 | + | // handoff and title while rebinding picker and terminal cwd state. | |
| 133 | + | func (i *Interactive) ApplySessionAgentWithCWD(ag *core.Agent, providerName, model, cwd string, compactHandoff json.RawMessage) { | |
| 134 | + | i.ApplySessionAgentWithCompactHandoff(ag, providerName, model, compactHandoff) | |
| 135 | + | if cwd == "" { | |
| 136 | + | return | |
| 137 | + | } | |
| 138 | + | home, _ := os.UserHomeDir() | |
| 139 | + | profiles, _ := subagents.Discover(cwd, home) | |
| 140 | + | i.mu.Lock() | |
| 141 | + | i.cfg.CWD = cwd | |
| 142 | + | i.cfg.SubagentsSystemAddendum = subagents.SystemPromptAddendum(profiles) | |
| 143 | + | i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg, i.autoSubagentsEnabledLocked()) | |
| 144 | + | i.cfg.StartupContextPaths = nil | |
| 145 | + | i.view.StartupContextPaths = nil | |
| 146 | + | if i.cfg.Terminal != nil { | |
| 147 | + | if seq := tui.ReportCWD(cwd); seq != "" { | |
| 148 | + | _, _ = i.cfg.Terminal.Write([]byte(seq)) | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | i.mu.Unlock() | |
| 152 | + | i.fileSuggest.Reset() | |
| 153 | + | i.fileSuggest.SetCWD(cwd) | |
| 154 | + | i.invalidate() | |
| 155 | + | } | |
| 156 | + | ||
| 129 | 157 | func (i *Interactive) SetSubagentSessionScope(_ string) { | |
| 130 | 158 | i.invalidate() | |
| 131 | 159 | } | |
| @@ -126,6 +126,34 @@ func (i *Interactive) ApplySessionAgentWithCompactHandoff(ag *core.Agent, provid | |||||
| 126 | i.mu.Unlock() | 126 | i.mu.Unlock() | ||
| 127 | i.invalidate() | 127 | i.invalidate() | ||
| 128 | } | 128 | } | ||
| 129 | + | ||||
| 130 | + | // ApplySessionAgentWithCWD commits a resumed agent together with its stored | |||
| 131 | + | // workspace. Unlike a fresh /cd transition it preserves the session's compact | |||
| 132 | + | // handoff and title while rebinding picker and terminal cwd state. | |||
| 133 | + | func (i *Interactive) ApplySessionAgentWithCWD(ag *core.Agent, providerName, model, cwd string, compactHandoff json.RawMessage) { | |||
| 134 | + | i.ApplySessionAgentWithCompactHandoff(ag, providerName, model, compactHandoff) | |||
| 135 | + | if cwd == "" { | |||
| 136 | + | return | |||
| 137 | + | } | |||
| 138 | + | home, _ := os.UserHomeDir() | |||
| 139 | + | profiles, _ := subagents.Discover(cwd, home) | |||
| 140 | + | i.mu.Lock() | |||
| 141 | + | i.cfg.CWD = cwd | |||
| 142 | + | i.cfg.SubagentsSystemAddendum = subagents.SystemPromptAddendum(profiles) | |||
| 143 | + | i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg, i.autoSubagentsEnabledLocked()) | |||
| 144 | + | i.cfg.StartupContextPaths = nil | |||
| 145 | + | i.view.StartupContextPaths = nil | |||
| 146 | + | if i.cfg.Terminal != nil { | |||
| 147 | + | if seq := tui.ReportCWD(cwd); seq != "" { | |||
| 148 | + | _, _ = i.cfg.Terminal.Write([]byte(seq)) | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | i.mu.Unlock() | |||
| 152 | + | i.fileSuggest.Reset() | |||
| 153 | + | i.fileSuggest.SetCWD(cwd) | |||
| 154 | + | i.invalidate() | |||
| 155 | + | } | |||
| 156 | + | ||||
| 129 | func (i *Interactive) SetSubagentSessionScope(_ string) { | 157 | func (i *Interactive) SetSubagentSessionScope(_ string) { | ||
| 130 | i.invalidate() | 158 | i.invalidate() | ||
| 131 | } | 159 | } | ||
M packages/agent/modes/interactive_host_test.go +34 -0
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | package modes | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 4 | 6 | "testing" | |
| 5 | 7 | ||
| 6 | 8 | "github.com/bnema/zut/packages/agent/internal/orchestration" | |
| @@ -9,6 +11,38 @@ import ( | |||
| 9 | 11 | "github.com/bnema/zut/packages/provider" | |
| 10 | 12 | ) | |
| 11 | 13 | ||
| 14 | + | func TestApplySessionAgentWithCWDRebindsInteractiveWorkspace(t *testing.T) { | |
| 15 | + | oldCWD := t.TempDir() | |
| 16 | + | newCWD := filepath.Join(t.TempDir(), "selected-workspace") | |
| 17 | + | if err := os.MkdirAll(newCWD, 0o755); err != nil { | |
| 18 | + | t.Fatal(err) | |
| 19 | + | } | |
| 20 | + | current := 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"}}}}) | |
| 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")} | |
| 25 | + | i.ApplySessionAgentWithCWD(selected, "provider", "model", newCWD, nil) | |
| 26 | + | if i.cfg.CWD != newCWD || i.agent != selected { | |
| 27 | + | t.Fatalf("interactive workspace = %q agent=%p, want cwd=%q agent=%p", i.cfg.CWD, i.agent, newCWD, selected) | |
| 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 | + | } | |
| 32 | + | text, _ := i.view.Messages[0].Content[0].(provider.TextBlock) | |
| 33 | + | if text.Text != "selected" { | |
| 34 | + | t.Fatalf("resumed view text = %q", text.Text) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestCanResumeSessionSelectionRejectsBusyTurn(t *testing.T) { | |
| 39 | + | i := NewInteractive(InteractiveConfig{Agent: core.NewAgent(nil, "model", "", nil), LoadSession: func(string) error { return nil }}) | |
| 40 | + | i.busy = true | |
| 41 | + | if i.canResumeSessionSelection() { | |
| 42 | + | t.Fatal("busy turn allowed session selection") | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 12 | 46 | func TestSubmitOrQueuePreservesImagesInHostQueue(t *testing.T) { | |
| 13 | 47 | i := &Interactive{agent: &core.Agent{}, busy: true, compacting: true} | |
| 14 | 48 | image := provider.ImageBlock{MimeType: "image/png", Data: []byte("png-1")} | |
| @@ -1,6 +1,8 @@ | |||||
| 1 | package modes | 1 | package modes | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 4 | "testing" | 6 | "testing" | ||
| 5 | 7 | ||||
| 6 | "github.com/bnema/zut/packages/agent/internal/orchestration" | 8 | "github.com/bnema/zut/packages/agent/internal/orchestration" | ||
| @@ -9,6 +11,38 @@ import ( | |||||
| 9 | "github.com/bnema/zut/packages/provider" | 11 | "github.com/bnema/zut/packages/provider" | ||
| 10 | ) | 12 | ) | ||
| 11 | 13 | ||||
| 14 | + | func TestApplySessionAgentWithCWDRebindsInteractiveWorkspace(t *testing.T) { | |||
| 15 | + | oldCWD := t.TempDir() | |||
| 16 | + | newCWD := filepath.Join(t.TempDir(), "selected-workspace") | |||
| 17 | + | if err := os.MkdirAll(newCWD, 0o755); err != nil { | |||
| 18 | + | t.Fatal(err) | |||
| 19 | + | } | |||
| 20 | + | current := 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"}}}}) | |||
| 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")} | |||
| 25 | + | i.ApplySessionAgentWithCWD(selected, "provider", "model", newCWD, nil) | |||
| 26 | + | if i.cfg.CWD != newCWD || i.agent != selected { | |||
| 27 | + | t.Fatalf("interactive workspace = %q agent=%p, want cwd=%q agent=%p", i.cfg.CWD, i.agent, newCWD, selected) | |||
| 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 | + | } | |||
| 32 | + | text, _ := i.view.Messages[0].Content[0].(provider.TextBlock) | |||
| 33 | + | if text.Text != "selected" { | |||
| 34 | + | t.Fatalf("resumed view text = %q", text.Text) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestCanResumeSessionSelectionRejectsBusyTurn(t *testing.T) { | |||
| 39 | + | i := NewInteractive(InteractiveConfig{Agent: core.NewAgent(nil, "model", "", nil), LoadSession: func(string) error { return nil }}) | |||
| 40 | + | i.busy = true | |||
| 41 | + | if i.canResumeSessionSelection() { | |||
| 42 | + | t.Fatal("busy turn allowed session selection") | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 12 | func TestSubmitOrQueuePreservesImagesInHostQueue(t *testing.T) { | 46 | func TestSubmitOrQueuePreservesImagesInHostQueue(t *testing.T) { | ||
| 13 | i := &Interactive{agent: &core.Agent{}, busy: true, compacting: true} | 47 | i := &Interactive{agent: &core.Agent{}, busy: true, compacting: true} | ||
| 14 | image := provider.ImageBlock{MimeType: "image/png", Data: []byte("png-1")} | 48 | image := provider.ImageBlock{MimeType: "image/png", Data: []byte("png-1")} | ||
M packages/agent/modes/interactive_input_routing.go +22 -1
| @@ -201,16 +201,29 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 201 | 201 | i.mu.Lock() | |
| 202 | 202 | i.sessionDialog.Close() | |
| 203 | 203 | i.sessionLoads = nil | |
| 204 | + | i.sessionSearches = nil | |
| 204 | 205 | i.mu.Unlock() | |
| 205 | 206 | i.invalidate() | |
| 206 | 207 | return false | |
| 207 | 208 | } | |
| 208 | 209 | manualRenamePath := "" | |
| 210 | + | renameActive := false | |
| 211 | + | hasSelection := false | |
| 209 | 212 | i.mu.Lock() | |
| 210 | - | if k.Kind == tui.KeyEnter && i.sessionDialog.renaming && core.NormalizeSessionTitle(i.sessionDialog.rename) != "" && i.sessionDialog.cursor >= 0 && i.sessionDialog.cursor < len(i.sessionDialog.sessions) && i.cfg.CurrentSessionPath != nil { | |
| 213 | + | renameActive = i.sessionDialog.renaming | |
| 214 | + | hasSelection = i.sessionDialog.cursor >= 0 && i.sessionDialog.cursor < len(i.sessionDialog.sessions) | |
| 215 | + | if k.Kind == tui.KeyEnter && i.sessionDialog.renaming && core.NormalizeSessionTitle(i.sessionDialog.rename) != "" && hasSelection && i.cfg.CurrentSessionPath != nil { | |
| 211 | 216 | manualRenamePath = i.sessionDialog.sessions[i.sessionDialog.cursor].Path | |
| 212 | 217 | } | |
| 213 | 218 | i.mu.Unlock() | |
| 219 | + | if k.Kind == tui.KeyEnter && !renameActive && hasSelection && !i.canResumeSessionSelection() { | |
| 220 | + | i.mu.Lock() | |
| 221 | + | i.statusErr = "wait for the active work to finish before resuming a session" | |
| 222 | + | i.statusOK = "" | |
| 223 | + | i.mu.Unlock() | |
| 224 | + | i.invalidate() | |
| 225 | + | return false | |
| 226 | + | } | |
| 214 | 227 | manualRenameCurrent := manualRenamePath != "" && i.cfg.CurrentSessionPath() == manualRenamePath | |
| 215 | 228 | if manualRenameCurrent { | |
| 216 | 229 | i.markSessionTitleSwitching() | |
| @@ -219,6 +232,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 219 | 232 | act := i.sessionDialog.HandleKey(k) | |
| 220 | 233 | if act.Select || act.Close { | |
| 221 | 234 | i.sessionLoads = nil | |
| 235 | + | i.sessionSearches = nil | |
| 236 | + | } | |
| 237 | + | if act.ToggleScope { | |
| 238 | + | i.sessionLoads = i.sessionDialog.Open(ctx, i.sessionsRoot(), i.cfg.CWD, !i.sessionDialog.allScope) | |
| 239 | + | i.sessionSearches = nil | |
| 240 | + | } | |
| 241 | + | if act.StartSearch { | |
| 242 | + | i.sessionSearches = i.sessionDialog.StartSearch(ctx) | |
| 222 | 243 | } | |
| 223 | 244 | i.mu.Unlock() | |
| 224 | 245 | if act.Select { | |
| @@ -201,16 +201,29 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 201 | i.mu.Lock() | 201 | i.mu.Lock() | ||
| 202 | i.sessionDialog.Close() | 202 | i.sessionDialog.Close() | ||
| 203 | i.sessionLoads = nil | 203 | i.sessionLoads = nil | ||
| 204 | + | i.sessionSearches = nil | |||
| 204 | i.mu.Unlock() | 205 | i.mu.Unlock() | ||
| 205 | i.invalidate() | 206 | i.invalidate() | ||
| 206 | return false | 207 | return false | ||
| 207 | } | 208 | } | ||
| 208 | manualRenamePath := "" | 209 | manualRenamePath := "" | ||
| 210 | + | renameActive := false | |||
| 211 | + | hasSelection := false | |||
| 209 | i.mu.Lock() | 212 | i.mu.Lock() | ||
| 210 | - | if k.Kind == tui.KeyEnter && i.sessionDialog.renaming && core.NormalizeSessionTitle(i.sessionDialog.rename) != "" && i.sessionDialog.cursor >= 0 && i.sessionDialog.cursor < len(i.sessionDialog.sessions) && i.cfg.CurrentSessionPath != nil { | 213 | + | renameActive = i.sessionDialog.renaming |
| 214 | + | hasSelection = i.sessionDialog.cursor >= 0 && i.sessionDialog.cursor < len(i.sessionDialog.sessions) | |||
| 215 | + | if k.Kind == tui.KeyEnter && i.sessionDialog.renaming && core.NormalizeSessionTitle(i.sessionDialog.rename) != "" && hasSelection && i.cfg.CurrentSessionPath != nil { | |||
| 211 | manualRenamePath = i.sessionDialog.sessions[i.sessionDialog.cursor].Path | 216 | manualRenamePath = i.sessionDialog.sessions[i.sessionDialog.cursor].Path | ||
| 212 | } | 217 | } | ||
| 213 | i.mu.Unlock() | 218 | i.mu.Unlock() | ||
| 219 | + | if k.Kind == tui.KeyEnter && !renameActive && hasSelection && !i.canResumeSessionSelection() { | |||
| 220 | + | i.mu.Lock() | |||
| 221 | + | i.statusErr = "wait for the active work to finish before resuming a session" | |||
| 222 | + | i.statusOK = "" | |||
| 223 | + | i.mu.Unlock() | |||
| 224 | + | i.invalidate() | |||
| 225 | + | return false | |||
| 226 | + | } | |||
| 214 | manualRenameCurrent := manualRenamePath != "" && i.cfg.CurrentSessionPath() == manualRenamePath | 227 | manualRenameCurrent := manualRenamePath != "" && i.cfg.CurrentSessionPath() == manualRenamePath | ||
| 215 | if manualRenameCurrent { | 228 | if manualRenameCurrent { | ||
| 216 | i.markSessionTitleSwitching() | 229 | i.markSessionTitleSwitching() | ||
| @@ -219,6 +232,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 219 | act := i.sessionDialog.HandleKey(k) | 232 | act := i.sessionDialog.HandleKey(k) | ||
| 220 | if act.Select || act.Close { | 233 | if act.Select || act.Close { | ||
| 221 | i.sessionLoads = nil | 234 | i.sessionLoads = nil | ||
| 235 | + | i.sessionSearches = nil | |||
| 236 | + | } | |||
| 237 | + | if act.ToggleScope { | |||
| 238 | + | i.sessionLoads = i.sessionDialog.Open(ctx, i.sessionsRoot(), i.cfg.CWD, !i.sessionDialog.allScope) | |||
| 239 | + | i.sessionSearches = nil | |||
| 240 | + | } | |||
| 241 | + | if act.StartSearch { | |||
| 242 | + | i.sessionSearches = i.sessionDialog.StartSearch(ctx) | |||
| 222 | } | 243 | } | ||
| 223 | i.mu.Unlock() | 244 | i.mu.Unlock() | ||
| 224 | if act.Select { | 245 | if act.Select { | ||
M packages/agent/modes/session_agent_root_test.go +11 -0
| @@ -46,6 +46,17 @@ func TestSessionsSlashUsesAgentSessionRoot(t *testing.T) { | |||
| 46 | 46 | } | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | + | func TestSessionsSlashRejectsNoSessionMode(t *testing.T) { | |
| 50 | + | i := NewInteractive(InteractiveConfig{SessionsDisabled: true}) | |
| 51 | + | i.runSlash(context.Background(), "/sessions") | |
| 52 | + | if i.sessionDialog.Active() { | |
| 53 | + | t.Fatal("session picker opened while sessions were disabled") | |
| 54 | + | } | |
| 55 | + | if i.statusErr != "sessions are disabled by --no-session" { | |
| 56 | + | t.Fatalf("status error = %q", i.statusErr) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 49 | 60 | func TestSessionsRootDefaultsToZutHome(t *testing.T) { | |
| 50 | 61 | i := &Interactive{cfg: InteractiveConfig{ZutHome: "/zut/home"}} | |
| 51 | 62 | if got := i.sessionsRoot(); got != "/zut/home" { | |
| @@ -46,6 +46,17 @@ func TestSessionsSlashUsesAgentSessionRoot(t *testing.T) { | |||||
| 46 | } | 46 | } | ||
| 47 | } | 47 | } | ||
| 48 | 48 | ||||
| 49 | + | func TestSessionsSlashRejectsNoSessionMode(t *testing.T) { | |||
| 50 | + | i := NewInteractive(InteractiveConfig{SessionsDisabled: true}) | |||
| 51 | + | i.runSlash(context.Background(), "/sessions") | |||
| 52 | + | if i.sessionDialog.Active() { | |||
| 53 | + | t.Fatal("session picker opened while sessions were disabled") | |||
| 54 | + | } | |||
| 55 | + | if i.statusErr != "sessions are disabled by --no-session" { | |||
| 56 | + | t.Fatalf("status error = %q", i.statusErr) | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 49 | func TestSessionsRootDefaultsToZutHome(t *testing.T) { | 60 | func TestSessionsRootDefaultsToZutHome(t *testing.T) { | ||
| 50 | i := &Interactive{cfg: InteractiveConfig{ZutHome: "/zut/home"}} | 61 | i := &Interactive{cfg: InteractiveConfig{ZutHome: "/zut/home"}} | ||
| 51 | if got := i.sessionsRoot(); got != "/zut/home" { | 62 | if got := i.sessionsRoot(); got != "/zut/home" { | ||
M packages/agent/modes/session_dialog.go +441 -15
| @@ -6,9 +6,11 @@ import ( | |||
| 6 | 6 | "strings" | |
| 7 | 7 | "sync" | |
| 8 | 8 | "time" | |
| 9 | + | "unicode/utf8" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/zut/packages/core" | |
| 11 | 12 | "github.com/bnema/zut/packages/tui" | |
| 13 | + | "github.com/sahilm/fuzzy" | |
| 12 | 14 | ) | |
| 13 | 15 | ||
| 14 | 16 | type sessionLoadEventKind uint8 | |
| @@ -19,10 +21,34 @@ const ( | |||
| 19 | 21 | sessionLoadFinished | |
| 20 | 22 | ) | |
| 21 | 23 | ||
| 24 | + | type sessionSearchEventKind uint8 | |
| 25 | + | ||
| 26 | + | const ( | |
| 27 | + | sessionSearchCorpusReady sessionSearchEventKind = iota | |
| 28 | + | sessionSearchMatchesReady | |
| 29 | + | ) | |
| 30 | + | ||
| 31 | + | type sessionSearchEvent struct { | |
| 32 | + | kind sessionSearchEventKind | |
| 33 | + | generation uint64 | |
| 34 | + | query string | |
| 35 | + | segments []core.SessionSearchSegment | |
| 36 | + | matches map[string]sessionSearchMatch | |
| 37 | + | skipped int | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | type sessionSearchMatch struct { | |
| 41 | + | count int | |
| 42 | + | excerpt string | |
| 43 | + | indexes []int | |
| 44 | + | score int | |
| 45 | + | } | |
| 46 | + | ||
| 22 | 47 | type sessionLoadEvent struct { | |
| 23 | 48 | kind sessionLoadEventKind | |
| 24 | 49 | generation uint64 | |
| 25 | 50 | total int | |
| 51 | + | paths []string | |
| 26 | 52 | index int | |
| 27 | 53 | summary core.SessionSummary | |
| 28 | 54 | } | |
| @@ -41,11 +67,29 @@ const maxSessionLoadWorkers = 8 | |||
| 41 | 67 | ||
| 42 | 68 | // sessionDialog is the inline picker shown when the user runs /sessions. | |
| 43 | 69 | type sessionDialog struct { | |
| 44 | - | active bool | |
| 45 | - | sessions []core.SessionSummary | |
| 46 | - | cursor int | |
| 47 | - | renaming bool | |
| 48 | - | rename string | |
| 70 | + | active bool | |
| 71 | + | sessions []core.SessionSummary | |
| 72 | + | baseSessions []core.SessionSummary | |
| 73 | + | cursor int | |
| 74 | + | renaming bool | |
| 75 | + | rename string | |
| 76 | + | ||
| 77 | + | allScope bool | |
| 78 | + | root string | |
| 79 | + | cwd string | |
| 80 | + | paths []string | |
| 81 | + | query string | |
| 82 | + | searching bool | |
| 83 | + | searchPending bool | |
| 84 | + | searchReady bool | |
| 85 | + | searchGeneration uint64 | |
| 86 | + | searchContext context.Context | |
| 87 | + | searchCancel context.CancelFunc | |
| 88 | + | matchCancel context.CancelFunc | |
| 89 | + | searchEvents chan sessionSearchEvent | |
| 90 | + | searchSegments []core.SessionSearchSegment | |
| 91 | + | searchMatches map[string]sessionSearchMatch | |
| 92 | + | searchErr string | |
| 49 | 93 | ||
| 50 | 94 | loading bool | |
| 51 | 95 | loadingDone int | |
| @@ -86,6 +130,8 @@ type sessionDialogAction struct { | |||
| 86 | 130 | Close bool | |
| 87 | 131 | Renamed bool | |
| 88 | 132 | RenameTitle string | |
| 133 | + | ToggleScope bool | |
| 134 | + | StartSearch bool | |
| 89 | 135 | Err error | |
| 90 | 136 | } | |
| 91 | 137 | ||
| @@ -94,10 +140,18 @@ func newSessionDialog() *sessionDialog { return &sessionDialog{} } | |||
| 94 | 140 | // Open shows the dialog immediately and loads session entries on bounded | |
| 95 | 141 | // background workers. Results are delivered to the caller so the UI goroutine | |
| 96 | 142 | // remains responsible for mutating dialog state and rendering it. | |
| 97 | - | func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan sessionLoadEvent { | |
| 143 | + | func (d *sessionDialog) Open(parent context.Context, root, cwd string, allScope ...bool) <-chan sessionLoadEvent { | |
| 98 | 144 | if d.loadCancel != nil { | |
| 99 | 145 | d.loadCancel() | |
| 100 | 146 | } | |
| 147 | + | if d.searchCancel != nil { | |
| 148 | + | d.searchCancel() | |
| 149 | + | d.searchCancel = nil | |
| 150 | + | } | |
| 151 | + | if d.matchCancel != nil { | |
| 152 | + | d.matchCancel() | |
| 153 | + | d.matchCancel = nil | |
| 154 | + | } | |
| 101 | 155 | previousDone := d.loadDone | |
| 102 | 156 | if parent == nil { | |
| 103 | 157 | parent = context.Background() | |
| @@ -109,10 +163,23 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan se | |||
| 109 | 163 | done := make(chan struct{}) | |
| 110 | 164 | d.loadDone = done | |
| 111 | 165 | d.sessions = nil | |
| 166 | + | d.baseSessions = nil | |
| 112 | 167 | d.cursor = 0 | |
| 113 | 168 | d.viewTop = 0 | |
| 114 | 169 | d.renaming = false | |
| 115 | 170 | d.rename = "" | |
| 171 | + | d.root = root | |
| 172 | + | d.cwd = cwd | |
| 173 | + | d.allScope = len(allScope) > 0 && allScope[0] | |
| 174 | + | d.paths = nil | |
| 175 | + | d.query = "" | |
| 176 | + | d.searching = false | |
| 177 | + | d.searchPending = false | |
| 178 | + | d.searchReady = false | |
| 179 | + | d.searchSegments = nil | |
| 180 | + | d.searchMatches = nil | |
| 181 | + | d.searchErr = "" | |
| 182 | + | d.searchEvents = nil | |
| 116 | 183 | d.loading = true | |
| 117 | 184 | d.loadingDone = 0 | |
| 118 | 185 | d.loadingTotal = 0 | |
| @@ -157,11 +224,12 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan se | |||
| 157 | 224 | return | |
| 158 | 225 | default: | |
| 159 | 226 | } | |
| 160 | - | paths := core.ListSessionsContext(ctx, root, cwd) | |
| 227 | + | paths := core.ListSessionPathsContext(ctx, root, cwd, len(allScope) > 0 && allScope[0]) | |
| 161 | 228 | if !send(sessionLoadEvent{ | |
| 162 | 229 | kind: sessionLoadStarted, | |
| 163 | 230 | generation: generation, | |
| 164 | 231 | total: len(paths), | |
| 232 | + | paths: paths, | |
| 165 | 233 | }) { | |
| 166 | 234 | return | |
| 167 | 235 | } | |
| @@ -230,9 +298,15 @@ func (d *sessionDialog) ApplyLoad(event sessionLoadEvent) { | |||
| 230 | 298 | case sessionLoadStarted: | |
| 231 | 299 | d.loadingTotal = event.total | |
| 232 | 300 | d.loadingDone = 0 | |
| 301 | + | d.paths = append([]string(nil), event.paths...) | |
| 302 | + | if d.searchPending { | |
| 303 | + | d.searchPending = false | |
| 304 | + | d.startSearchReader() | |
| 305 | + | } | |
| 233 | 306 | d.loadSlots = make([]sessionLoadSlot, event.total) | |
| 234 | 307 | d.loadReadyThrough = 0 | |
| 235 | 308 | d.sessions = nil | |
| 309 | + | d.baseSessions = nil | |
| 236 | 310 | case sessionLoadEntry: | |
| 237 | 311 | if event.index < 0 || event.index >= len(d.loadSlots) || d.loadSlots[event.index].loaded { | |
| 238 | 312 | return | |
| @@ -296,7 +370,10 @@ func (d *sessionDialog) appendLoadedSessions(start, end int) { | |||
| 296 | 370 | if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 { | |
| 297 | 371 | continue | |
| 298 | 372 | } | |
| 299 | - | d.sessions = append(d.sessions, slot.summary) | |
| 373 | + | d.baseSessions = append(d.baseSessions, slot.summary) | |
| 374 | + | if d.query == "" { | |
| 375 | + | d.sessions = append(d.sessions, slot.summary) | |
| 376 | + | } | |
| 300 | 377 | } | |
| 301 | 378 | } | |
| 302 | 379 | ||
| @@ -311,7 +388,12 @@ func (d *sessionDialog) rebuildLoadedSessions(limit int) { | |||
| 311 | 388 | } | |
| 312 | 389 | filtered = append(filtered, slot.summary) | |
| 313 | 390 | } | |
| 314 | - | d.sessions = filtered | |
| 391 | + | d.baseSessions = filtered | |
| 392 | + | if d.query == "" { | |
| 393 | + | d.sessions = append([]core.SessionSummary(nil), filtered...) | |
| 394 | + | } else { | |
| 395 | + | d.applySearchFilter() | |
| 396 | + | } | |
| 315 | 397 | if d.cursor >= len(d.sessions) { | |
| 316 | 398 | d.cursor = len(d.sessions) - 1 | |
| 317 | 399 | } | |
| @@ -337,12 +419,185 @@ func (d *sessionDialog) Close() { | |||
| 337 | 419 | d.loadCancel() | |
| 338 | 420 | d.loadCancel = nil | |
| 339 | 421 | } | |
| 422 | + | if d.searchCancel != nil { | |
| 423 | + | d.searchCancel() | |
| 424 | + | d.searchCancel = nil | |
| 425 | + | } | |
| 426 | + | if d.matchCancel != nil { | |
| 427 | + | d.matchCancel() | |
| 428 | + | d.matchCancel = nil | |
| 429 | + | } | |
| 340 | 430 | d.loading = false | |
| 431 | + | d.searching = false | |
| 341 | 432 | d.loadSlots = nil | |
| 342 | 433 | d.loadReadyThrough = 0 | |
| 434 | + | d.searchEvents = nil | |
| 435 | + | d.searchSegments = nil | |
| 436 | + | d.searchMatches = nil | |
| 343 | 437 | d.active = false | |
| 344 | 438 | } | |
| 345 | 439 | ||
| 440 | + | // StartSearch starts one background corpus read for this picker opening. Query | |
| 441 | + | // edits reuse the retained corpus and never reopen a session file. | |
| 442 | + | func (d *sessionDialog) StartSearch(parent context.Context) <-chan sessionSearchEvent { | |
| 443 | + | if d.searchEvents != nil { | |
| 444 | + | return d.searchEvents | |
| 445 | + | } | |
| 446 | + | if parent == nil { | |
| 447 | + | parent = context.Background() | |
| 448 | + | } | |
| 449 | + | ctx, cancel := context.WithCancel(parent) | |
| 450 | + | d.searchContext = ctx | |
| 451 | + | d.searchCancel = cancel | |
| 452 | + | d.searching = true | |
| 453 | + | d.searchGeneration++ | |
| 454 | + | events := make(chan sessionSearchEvent, 1) | |
| 455 | + | d.searchEvents = events | |
| 456 | + | if len(d.paths) == 0 && d.loading { | |
| 457 | + | d.searchPending = true | |
| 458 | + | return events | |
| 459 | + | } | |
| 460 | + | d.startSearchReader() | |
| 461 | + | return events | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | func (d *sessionDialog) startSearchReader() { | |
| 465 | + | if d.searchEvents == nil || d.searchCancel == nil { | |
| 466 | + | return | |
| 467 | + | } | |
| 468 | + | ctx := d.searchContext | |
| 469 | + | paths := append([]string(nil), d.paths...) | |
| 470 | + | generation := d.searchGeneration | |
| 471 | + | events := d.searchEvents | |
| 472 | + | go func() { | |
| 473 | + | segments := make([]core.SessionSearchSegment, 0) | |
| 474 | + | skipped := 0 | |
| 475 | + | for _, path := range paths { | |
| 476 | + | if ctx.Err() != nil { | |
| 477 | + | return | |
| 478 | + | } | |
| 479 | + | read, err := core.ReadSessionSearchSegments(ctx, path) | |
| 480 | + | if err != nil { | |
| 481 | + | if ctx.Err() != nil { | |
| 482 | + | return | |
| 483 | + | } | |
| 484 | + | skipped++ | |
| 485 | + | continue | |
| 486 | + | } | |
| 487 | + | segments = append(segments, read...) | |
| 488 | + | } | |
| 489 | + | select { | |
| 490 | + | case events <- sessionSearchEvent{kind: sessionSearchCorpusReady, generation: generation, segments: segments, skipped: skipped}: | |
| 491 | + | case <-ctx.Done(): | |
| 492 | + | } | |
| 493 | + | }() | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | // ApplySearch incorporates corpus and match results on the UI goroutine. | |
| 497 | + | func (d *sessionDialog) ApplySearch(event sessionSearchEvent) { | |
| 498 | + | if !d.active || event.generation != d.searchGeneration { | |
| 499 | + | return | |
| 500 | + | } | |
| 501 | + | switch event.kind { | |
| 502 | + | case sessionSearchCorpusReady: | |
| 503 | + | d.searching = false | |
| 504 | + | d.searchReady = true | |
| 505 | + | if event.skipped > 0 { | |
| 506 | + | d.searchErr = fmt.Sprintf("search skipped %d unreadable session(s)", event.skipped) | |
| 507 | + | } | |
| 508 | + | d.searchSegments = event.segments | |
| 509 | + | d.scheduleSearchMatch() | |
| 510 | + | case sessionSearchMatchesReady: | |
| 511 | + | if event.query != d.query { | |
| 512 | + | return | |
| 513 | + | } | |
| 514 | + | d.searchMatches = event.matches | |
| 515 | + | d.applySearchFilter() | |
| 516 | + | } | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | func (d *sessionDialog) scheduleSearchMatch() { | |
| 520 | + | if !d.searchReady || d.query == "" || d.searchEvents == nil || d.searchCancel == nil { | |
| 521 | + | return | |
| 522 | + | } | |
| 523 | + | if d.matchCancel != nil { | |
| 524 | + | d.matchCancel() | |
| 525 | + | } | |
| 526 | + | query := d.query | |
| 527 | + | segments := d.searchSegments | |
| 528 | + | generation := d.searchGeneration | |
| 529 | + | events := d.searchEvents | |
| 530 | + | ctx, cancel := context.WithCancel(d.searchContext) | |
| 531 | + | d.matchCancel = cancel | |
| 532 | + | go func() { | |
| 533 | + | matches := matchSessionSearchSegments(ctx, query, segments) | |
| 534 | + | if ctx.Err() != nil { | |
| 535 | + | return | |
| 536 | + | } | |
| 537 | + | select { | |
| 538 | + | case events <- sessionSearchEvent{kind: sessionSearchMatchesReady, generation: generation, query: query, matches: matches}: | |
| 539 | + | case <-ctx.Done(): | |
| 540 | + | } | |
| 541 | + | }() | |
| 542 | + | } | |
| 543 | + | ||
| 544 | + | func matchSessionSearchSegments(ctx context.Context, query string, segments []core.SessionSearchSegment) map[string]sessionSearchMatch { | |
| 545 | + | query = core.NormalizeSessionSearchText(query) | |
| 546 | + | if query == "" { | |
| 547 | + | return nil | |
| 548 | + | } | |
| 549 | + | if ctx.Err() != nil { | |
| 550 | + | return nil | |
| 551 | + | } | |
| 552 | + | texts := make([]string, len(segments)) | |
| 553 | + | for index, segment := range segments { | |
| 554 | + | if ctx.Err() != nil { | |
| 555 | + | return nil | |
| 556 | + | } | |
| 557 | + | texts[index] = segment.Normalized | |
| 558 | + | if texts[index] == "" { | |
| 559 | + | texts[index] = core.NormalizeSessionSearchText(segment.Text) | |
| 560 | + | } | |
| 561 | + | } | |
| 562 | + | if ctx.Err() != nil { | |
| 563 | + | return nil | |
| 564 | + | } | |
| 565 | + | ranked := fuzzy.Find(query, texts) | |
| 566 | + | matches := make(map[string]sessionSearchMatch) | |
| 567 | + | for _, match := range ranked { | |
| 568 | + | if ctx.Err() != nil || match.Index < 0 || match.Index >= len(segments) { | |
| 569 | + | return matches | |
| 570 | + | } | |
| 571 | + | segment := segments[match.Index] | |
| 572 | + | current := matches[segment.Path] | |
| 573 | + | current.count++ | |
| 574 | + | if current.excerpt == "" || match.Score > current.score { | |
| 575 | + | current.excerpt = texts[match.Index] | |
| 576 | + | current.indexes = append([]int(nil), match.MatchedIndexes...) | |
| 577 | + | current.score = match.Score | |
| 578 | + | } | |
| 579 | + | matches[segment.Path] = current | |
| 580 | + | } | |
| 581 | + | return matches | |
| 582 | + | } | |
| 583 | + | ||
| 584 | + | func (d *sessionDialog) applySearchFilter() { | |
| 585 | + | if d.query == "" { | |
| 586 | + | d.sessions = append(d.sessions[:0], d.baseSessions...) | |
| 587 | + | } else { | |
| 588 | + | filtered := make([]core.SessionSummary, 0, len(d.baseSessions)) | |
| 589 | + | for _, summary := range d.baseSessions { | |
| 590 | + | if _, ok := d.searchMatches[summary.Path]; ok { | |
| 591 | + | filtered = append(filtered, summary) | |
| 592 | + | } | |
| 593 | + | } | |
| 594 | + | d.sessions = filtered | |
| 595 | + | } | |
| 596 | + | if d.cursor >= len(d.sessions) { | |
| 597 | + | d.cursor = max(0, len(d.sessions)-1) | |
| 598 | + | } | |
| 599 | + | } | |
| 600 | + | ||
| 346 | 601 | // Active reports whether the dialog is visible and consumes input. | |
| 347 | 602 | func (d *sessionDialog) Active() bool { return d != nil && d.active } | |
| 348 | 603 | ||
| @@ -355,7 +610,11 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||
| 355 | 610 | return nil | |
| 356 | 611 | } | |
| 357 | 612 | var lines []string | |
| 358 | - | lines = append(lines, frameHeader(th, "sessions", width)) | |
| 613 | + | title := "sessions · this directory" | |
| 614 | + | if d.allScope { | |
| 615 | + | title = "sessions · all" | |
| 616 | + | } | |
| 617 | + | lines = append(lines, frameHeader(th, title, width)) | |
| 359 | 618 | if d.loading { | |
| 360 | 619 | lines = append(lines, th.FGColor(th.Muted, d.loadingMessage(th))) | |
| 361 | 620 | if len(d.sessions) == 0 { | |
| @@ -364,8 +623,15 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||
| 364 | 623 | return lines | |
| 365 | 624 | } | |
| 366 | 625 | } else if len(d.sessions) == 0 { | |
| 367 | - | lines = append(lines, th.FGColor(th.Muted, "no previous sessions for this directory")) | |
| 368 | - | lines = append(lines, th.FGColor(th.Muted, "press esc to close")) | |
| 626 | + | message := "no previous sessions for this directory" | |
| 627 | + | if d.allScope { | |
| 628 | + | message = "no previous sessions in this namespace" | |
| 629 | + | } | |
| 630 | + | if d.query != "" && d.searchReady { | |
| 631 | + | message = "no matching sessions" | |
| 632 | + | } | |
| 633 | + | lines = append(lines, th.FGColor(th.Muted, message)) | |
| 634 | + | lines = append(lines, th.FGColor(th.Muted, "tab changes scope · esc closes")) | |
| 369 | 635 | lines = append(lines, frameRule(th, width)) | |
| 370 | 636 | return lines | |
| 371 | 637 | } | |
| @@ -381,11 +647,20 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||
| 381 | 647 | lines = append(lines, frameRule(th, width)) | |
| 382 | 648 | return lines | |
| 383 | 649 | } | |
| 384 | - | hint := "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)" | |
| 650 | + | hint := "↑/↓ pick · enter resume · / search · tab scope · r rename · esc cancel" | |
| 651 | + | if d.searchEvents != nil { | |
| 652 | + | hint = "search: " + d.query + " (esc clear · tab scope)" | |
| 653 | + | if d.searching { | |
| 654 | + | hint += " · indexing" | |
| 655 | + | } | |
| 656 | + | } | |
| 385 | 657 | if d.loading { | |
| 386 | - | hint = "pick a session (↑/↓, pgup/pgdn, enter resume, esc cancel; more loading)" | |
| 658 | + | hint += " · loading" | |
| 387 | 659 | } | |
| 388 | 660 | lines = append(lines, th.FGColor(th.Muted, hint)) | |
| 661 | + | if d.searchErr != "" { | |
| 662 | + | lines = append(lines, th.FGColor(th.Warning, d.searchErr)) | |
| 663 | + | } | |
| 389 | 664 | ||
| 390 | 665 | // Viewport: windowed slice of d.sessions around d.cursor so a | |
| 391 | 666 | // list taller than the terminal still scrolls. Caller sets | |
| @@ -410,7 +685,8 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||
| 410 | 685 | } | |
| 411 | 686 | for i := d.viewTop; i < viewBot; i++ { | |
| 412 | 687 | s := d.sessions[i] | |
| 413 | - | plain := " " + formatSessionRowPlain(s, width-2) | |
| 688 | + | match := d.searchMatches[s.Path] | |
| 689 | + | plain := " " + formatSessionSearchRowPlain(s, width-2, match.count) | |
| 414 | 690 | if i == d.cursor { | |
| 415 | 691 | lines = append(lines, th.PadHighlight(plain, width)) | |
| 416 | 692 | } else { | |
| @@ -422,6 +698,15 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||
| 422 | 698 | hidden := total - viewBot | |
| 423 | 699 | lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ↓ %d more below", hidden))) | |
| 424 | 700 | } | |
| 701 | + | if d.allScope && d.cursor >= 0 && d.cursor < len(d.sessions) && d.sessions[d.cursor].CWD != "" { | |
| 702 | + | cwd := sanitizeSessionTreeText(friendlyPath(d.sessions[d.cursor].CWD)) | |
| 703 | + | lines = append(lines, th.FGColor(th.Muted, " cwd: "+cwd)) | |
| 704 | + | } | |
| 705 | + | if d.query != "" && d.cursor >= 0 && d.cursor < len(d.sessions) { | |
| 706 | + | if match := d.searchMatches[d.sessions[d.cursor].Path]; match.excerpt != "" { | |
| 707 | + | lines = append(lines, " "+highlightSessionSearchExcerpt(th, match.excerpt, match.indexes, width-2)) | |
| 708 | + | } | |
| 709 | + | } | |
| 425 | 710 | lines = append(lines, frameRule(th, width)) | |
| 426 | 711 | return lines | |
| 427 | 712 | } | |
| @@ -462,6 +747,96 @@ func clampViewTop(viewTop, cursor, window, total int) int { | |||
| 462 | 747 | // full-row selection highlight. The returned string is guaranteed to | |
| 463 | 748 | // fit within maxWidth visible characters so the terminal never soft- | |
| 464 | 749 | // wraps it into the next row. | |
| 750 | + | func formatSessionSearchRowPlain(s core.SessionSummary, maxWidth, matchCount int) string { | |
| 751 | + | row := formatSessionRowPlain(s, maxWidth) | |
| 752 | + | if matchCount == 0 { | |
| 753 | + | return row | |
| 754 | + | } | |
| 755 | + | count := fmt.Sprintf(" %d matches", matchCount) | |
| 756 | + | if len([]rune(row))+len([]rune(count)) > maxWidth { | |
| 757 | + | return row | |
| 758 | + | } | |
| 759 | + | return row + count | |
| 760 | + | } | |
| 761 | + | ||
| 762 | + | func highlightSessionSearchExcerpt(th tui.Theme, excerpt string, indexes []int, maxWidth int) string { | |
| 763 | + | limit := 240 | |
| 764 | + | if maxWidth > 0 && maxWidth < limit { | |
| 765 | + | limit = maxWidth | |
| 766 | + | } | |
| 767 | + | excerpt, indexes = windowSessionSearchExcerpt(excerpt, indexes, limit) | |
| 768 | + | if len(indexes) == 0 { | |
| 769 | + | return th.FGColor(th.Muted, excerpt) | |
| 770 | + | } | |
| 771 | + | matched := make(map[int]bool, len(indexes)) | |
| 772 | + | for _, index := range indexes { | |
| 773 | + | matched[index] = true | |
| 774 | + | } | |
| 775 | + | var out strings.Builder | |
| 776 | + | for index := 0; index < len(excerpt); { | |
| 777 | + | r, size := utf8.DecodeRuneInString(excerpt[index:]) | |
| 778 | + | part := string(r) | |
| 779 | + | if matched[index] { | |
| 780 | + | out.WriteString(th.FGColor(th.Accent, part)) | |
| 781 | + | } else { | |
| 782 | + | out.WriteString(th.FGColor(th.Muted, part)) | |
| 783 | + | } | |
| 784 | + | index += size | |
| 785 | + | } | |
| 786 | + | return out.String() | |
| 787 | + | } | |
| 788 | + | ||
| 789 | + | func windowSessionSearchExcerpt(text string, indexes []int, limit int) (string, []int) { | |
| 790 | + | runes := []rune(text) | |
| 791 | + | if limit <= 0 || len(runes) <= limit { | |
| 792 | + | return text, indexes | |
| 793 | + | } | |
| 794 | + | matchRune := 0 | |
| 795 | + | if len(indexes) > 0 { | |
| 796 | + | for runeIndex, byteIndex := range runeByteOffsets(text) { | |
| 797 | + | if byteIndex <= indexes[0] { | |
| 798 | + | matchRune = runeIndex | |
| 799 | + | } else { | |
| 800 | + | break | |
| 801 | + | } | |
| 802 | + | } | |
| 803 | + | } | |
| 804 | + | room := limit - 3 | |
| 805 | + | if room < 1 { | |
| 806 | + | return string(runes[:limit]), nil | |
| 807 | + | } | |
| 808 | + | start := max(0, matchRune-room/2) | |
| 809 | + | if start+room > len(runes) { | |
| 810 | + | start = len(runes) - room | |
| 811 | + | } | |
| 812 | + | end := min(len(runes), start+room) | |
| 813 | + | prefix := "" | |
| 814 | + | if start > 0 { | |
| 815 | + | prefix = "..." | |
| 816 | + | } | |
| 817 | + | suffix := "" | |
| 818 | + | if end < len(runes) { | |
| 819 | + | suffix = "..." | |
| 820 | + | } | |
| 821 | + | base := len(string(runes[:start])) | |
| 822 | + | stop := len(string(runes[:end])) | |
| 823 | + | rebased := make([]int, 0, len(indexes)) | |
| 824 | + | for _, index := range indexes { | |
| 825 | + | if index >= base && index < stop { | |
| 826 | + | rebased = append(rebased, len(prefix)+index-base) | |
| 827 | + | } | |
| 828 | + | } | |
| 829 | + | return prefix + string(runes[start:end]) + suffix, rebased | |
| 830 | + | } | |
| 831 | + | ||
| 832 | + | func runeByteOffsets(text string) []int { | |
| 833 | + | offsets := make([]int, 0, len(text)) | |
| 834 | + | for index := range text { | |
| 835 | + | offsets = append(offsets, index) | |
| 836 | + | } | |
| 837 | + | return offsets | |
| 838 | + | } | |
| 839 | + | ||
| 465 | 840 | func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string { | |
| 466 | 841 | when := formatRelative(s.Started) | |
| 467 | 842 | summary := strings.TrimSpace(s.Title) | |
| @@ -556,6 +931,12 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||
| 556 | 931 | renameErr = err | |
| 557 | 932 | } else { | |
| 558 | 933 | d.sessions[d.cursor].Title = title | |
| 934 | + | for index := range d.baseSessions { | |
| 935 | + | if d.baseSessions[index].Path == path { | |
| 936 | + | d.baseSessions[index].Title = title | |
| 937 | + | break | |
| 938 | + | } | |
| 939 | + | } | |
| 559 | 940 | renamed = true | |
| 560 | 941 | } | |
| 561 | 942 | } | |
| @@ -584,6 +965,50 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||
| 584 | 965 | return sessionDialogAction{} | |
| 585 | 966 | } | |
| 586 | 967 | ||
| 968 | + | if k.Kind == tui.KeyTab { | |
| 969 | + | return sessionDialogAction{ToggleScope: true} | |
| 970 | + | } | |
| 971 | + | if k.Kind == tui.KeyRune && k.Rune == '/' && d.query == "" { | |
| 972 | + | return sessionDialogAction{StartSearch: true} | |
| 973 | + | } | |
| 974 | + | if d.query != "" || d.searchEvents != nil { | |
| 975 | + | switch k.Kind { | |
| 976 | + | case tui.KeyEsc: | |
| 977 | + | if d.query == "" { | |
| 978 | + | d.Close() | |
| 979 | + | return sessionDialogAction{Close: true} | |
| 980 | + | } | |
| 981 | + | d.query = "" | |
| 982 | + | d.searchMatches = nil | |
| 983 | + | d.applySearchFilter() | |
| 984 | + | return sessionDialogAction{} | |
| 985 | + | case tui.KeyBackspace: | |
| 986 | + | runes := []rune(d.query) | |
| 987 | + | if len(runes) > 0 { | |
| 988 | + | d.query = string(runes[:len(runes)-1]) | |
| 989 | + | } | |
| 990 | + | d.applySearchFilter() | |
| 991 | + | d.scheduleSearchMatch() | |
| 992 | + | return sessionDialogAction{} | |
| 993 | + | case tui.KeyPaste: | |
| 994 | + | d.query += k.Paste | |
| 995 | + | case tui.KeyRune: | |
| 996 | + | if k.Rune != 0 { | |
| 997 | + | d.query += string(k.Rune) | |
| 998 | + | } | |
| 999 | + | default: | |
| 1000 | + | // Navigation and selection operate on the current filtered rows. | |
| 1001 | + | goto navigate | |
| 1002 | + | } | |
| 1003 | + | d.query = sanitizeSessionTreeText(d.query) | |
| 1004 | + | if len([]rune(d.query)) > 256 { | |
| 1005 | + | d.query = string([]rune(d.query)[:256]) | |
| 1006 | + | } | |
| 1007 | + | d.applySearchFilter() | |
| 1008 | + | d.scheduleSearchMatch() | |
| 1009 | + | return sessionDialogAction{} | |
| 1010 | + | } | |
| 1011 | + | ||
| 587 | 1012 | if d.loading && len(d.sessions) == 0 { | |
| 588 | 1013 | if k.Kind == tui.KeyEsc { | |
| 589 | 1014 | d.Close() | |
| @@ -592,6 +1017,7 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||
| 592 | 1017 | return sessionDialogAction{} | |
| 593 | 1018 | } | |
| 594 | 1019 | ||
| 1020 | + | navigate: | |
| 595 | 1021 | page := d.MaxRows | |
| 596 | 1022 | if page <= 0 { | |
| 597 | 1023 | page = 10 | |
| @@ -6,9 +6,11 @@ import ( | |||||
| 6 | "strings" | 6 | "strings" | ||
| 7 | "sync" | 7 | "sync" | ||
| 8 | "time" | 8 | "time" | ||
| 9 | + | "unicode/utf8" | |||
| 9 | 10 | ||||
| 10 | "github.com/bnema/zut/packages/core" | 11 | "github.com/bnema/zut/packages/core" | ||
| 11 | "github.com/bnema/zut/packages/tui" | 12 | "github.com/bnema/zut/packages/tui" | ||
| 13 | + | "github.com/sahilm/fuzzy" | |||
| 12 | ) | 14 | ) | ||
| 13 | 15 | ||||
| 14 | type sessionLoadEventKind uint8 | 16 | type sessionLoadEventKind uint8 | ||
| @@ -19,10 +21,34 @@ const ( | |||||
| 19 | sessionLoadFinished | 21 | sessionLoadFinished | ||
| 20 | ) | 22 | ) | ||
| 21 | 23 | ||||
| 24 | + | type sessionSearchEventKind uint8 | |||
| 25 | + | ||||
| 26 | + | const ( | |||
| 27 | + | sessionSearchCorpusReady sessionSearchEventKind = iota | |||
| 28 | + | sessionSearchMatchesReady | |||
| 29 | + | ) | |||
| 30 | + | ||||
| 31 | + | type sessionSearchEvent struct { | |||
| 32 | + | kind sessionSearchEventKind | |||
| 33 | + | generation uint64 | |||
| 34 | + | query string | |||
| 35 | + | segments []core.SessionSearchSegment | |||
| 36 | + | matches map[string]sessionSearchMatch | |||
| 37 | + | skipped int | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | type sessionSearchMatch struct { | |||
| 41 | + | count int | |||
| 42 | + | excerpt string | |||
| 43 | + | indexes []int | |||
| 44 | + | score int | |||
| 45 | + | } | |||
| 46 | + | ||||
| 22 | type sessionLoadEvent struct { | 47 | type sessionLoadEvent struct { | ||
| 23 | kind sessionLoadEventKind | 48 | kind sessionLoadEventKind | ||
| 24 | generation uint64 | 49 | generation uint64 | ||
| 25 | total int | 50 | total int | ||
| 51 | + | paths []string | |||
| 26 | index int | 52 | index int | ||
| 27 | summary core.SessionSummary | 53 | summary core.SessionSummary | ||
| 28 | } | 54 | } | ||
| @@ -41,11 +67,29 @@ const maxSessionLoadWorkers = 8 | |||||
| 41 | 67 | ||||
| 42 | // sessionDialog is the inline picker shown when the user runs /sessions. | 68 | // sessionDialog is the inline picker shown when the user runs /sessions. | ||
| 43 | type sessionDialog struct { | 69 | type sessionDialog struct { | ||
| 44 | - | active bool | 70 | + | active bool |
| 45 | - | sessions []core.SessionSummary | 71 | + | sessions []core.SessionSummary |
| 46 | - | cursor int | 72 | + | baseSessions []core.SessionSummary |
| 47 | - | renaming bool | 73 | + | cursor int |
| 48 | - | rename string | 74 | + | renaming bool |
| 75 | + | rename string | |||
| 76 | + | ||||
| 77 | + | allScope bool | |||
| 78 | + | root string | |||
| 79 | + | cwd string | |||
| 80 | + | paths []string | |||
| 81 | + | query string | |||
| 82 | + | searching bool | |||
| 83 | + | searchPending bool | |||
| 84 | + | searchReady bool | |||
| 85 | + | searchGeneration uint64 | |||
| 86 | + | searchContext context.Context | |||
| 87 | + | searchCancel context.CancelFunc | |||
| 88 | + | matchCancel context.CancelFunc | |||
| 89 | + | searchEvents chan sessionSearchEvent | |||
| 90 | + | searchSegments []core.SessionSearchSegment | |||
| 91 | + | searchMatches map[string]sessionSearchMatch | |||
| 92 | + | searchErr string | |||
| 49 | 93 | ||||
| 50 | loading bool | 94 | loading bool | ||
| 51 | loadingDone int | 95 | loadingDone int | ||
| @@ -86,6 +130,8 @@ type sessionDialogAction struct { | |||||
| 86 | Close bool | 130 | Close bool | ||
| 87 | Renamed bool | 131 | Renamed bool | ||
| 88 | RenameTitle string | 132 | RenameTitle string | ||
| 133 | + | ToggleScope bool | |||
| 134 | + | StartSearch bool | |||
| 89 | Err error | 135 | Err error | ||
| 90 | } | 136 | } | ||
| 91 | 137 | ||||
| @@ -94,10 +140,18 @@ func newSessionDialog() *sessionDialog { return &sessionDialog{} } | |||||
| 94 | // Open shows the dialog immediately and loads session entries on bounded | 140 | // Open shows the dialog immediately and loads session entries on bounded | ||
| 95 | // background workers. Results are delivered to the caller so the UI goroutine | 141 | // background workers. Results are delivered to the caller so the UI goroutine | ||
| 96 | // remains responsible for mutating dialog state and rendering it. | 142 | // remains responsible for mutating dialog state and rendering it. | ||
| 97 | - | func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan sessionLoadEvent { | 143 | + | func (d *sessionDialog) Open(parent context.Context, root, cwd string, allScope ...bool) <-chan sessionLoadEvent { |
| 98 | if d.loadCancel != nil { | 144 | if d.loadCancel != nil { | ||
| 99 | d.loadCancel() | 145 | d.loadCancel() | ||
| 100 | } | 146 | } | ||
| 147 | + | if d.searchCancel != nil { | |||
| 148 | + | d.searchCancel() | |||
| 149 | + | d.searchCancel = nil | |||
| 150 | + | } | |||
| 151 | + | if d.matchCancel != nil { | |||
| 152 | + | d.matchCancel() | |||
| 153 | + | d.matchCancel = nil | |||
| 154 | + | } | |||
| 101 | previousDone := d.loadDone | 155 | previousDone := d.loadDone | ||
| 102 | if parent == nil { | 156 | if parent == nil { | ||
| 103 | parent = context.Background() | 157 | parent = context.Background() | ||
| @@ -109,10 +163,23 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan se | |||||
| 109 | done := make(chan struct{}) | 163 | done := make(chan struct{}) | ||
| 110 | d.loadDone = done | 164 | d.loadDone = done | ||
| 111 | d.sessions = nil | 165 | d.sessions = nil | ||
| 166 | + | d.baseSessions = nil | |||
| 112 | d.cursor = 0 | 167 | d.cursor = 0 | ||
| 113 | d.viewTop = 0 | 168 | d.viewTop = 0 | ||
| 114 | d.renaming = false | 169 | d.renaming = false | ||
| 115 | d.rename = "" | 170 | d.rename = "" | ||
| 171 | + | d.root = root | |||
| 172 | + | d.cwd = cwd | |||
| 173 | + | d.allScope = len(allScope) > 0 && allScope[0] | |||
| 174 | + | d.paths = nil | |||
| 175 | + | d.query = "" | |||
| 176 | + | d.searching = false | |||
| 177 | + | d.searchPending = false | |||
| 178 | + | d.searchReady = false | |||
| 179 | + | d.searchSegments = nil | |||
| 180 | + | d.searchMatches = nil | |||
| 181 | + | d.searchErr = "" | |||
| 182 | + | d.searchEvents = nil | |||
| 116 | d.loading = true | 183 | d.loading = true | ||
| 117 | d.loadingDone = 0 | 184 | d.loadingDone = 0 | ||
| 118 | d.loadingTotal = 0 | 185 | d.loadingTotal = 0 | ||
| @@ -157,11 +224,12 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan se | |||||
| 157 | return | 224 | return | ||
| 158 | default: | 225 | default: | ||
| 159 | } | 226 | } | ||
| 160 | - | paths := core.ListSessionsContext(ctx, root, cwd) | 227 | + | paths := core.ListSessionPathsContext(ctx, root, cwd, len(allScope) > 0 && allScope[0]) |
| 161 | if !send(sessionLoadEvent{ | 228 | if !send(sessionLoadEvent{ | ||
| 162 | kind: sessionLoadStarted, | 229 | kind: sessionLoadStarted, | ||
| 163 | generation: generation, | 230 | generation: generation, | ||
| 164 | total: len(paths), | 231 | total: len(paths), | ||
| 232 | + | paths: paths, | |||
| 165 | }) { | 233 | }) { | ||
| 166 | return | 234 | return | ||
| 167 | } | 235 | } | ||
| @@ -230,9 +298,15 @@ func (d *sessionDialog) ApplyLoad(event sessionLoadEvent) { | |||||
| 230 | case sessionLoadStarted: | 298 | case sessionLoadStarted: | ||
| 231 | d.loadingTotal = event.total | 299 | d.loadingTotal = event.total | ||
| 232 | d.loadingDone = 0 | 300 | d.loadingDone = 0 | ||
| 301 | + | d.paths = append([]string(nil), event.paths...) | |||
| 302 | + | if d.searchPending { | |||
| 303 | + | d.searchPending = false | |||
| 304 | + | d.startSearchReader() | |||
| 305 | + | } | |||
| 233 | d.loadSlots = make([]sessionLoadSlot, event.total) | 306 | d.loadSlots = make([]sessionLoadSlot, event.total) | ||
| 234 | d.loadReadyThrough = 0 | 307 | d.loadReadyThrough = 0 | ||
| 235 | d.sessions = nil | 308 | d.sessions = nil | ||
| 309 | + | d.baseSessions = nil | |||
| 236 | case sessionLoadEntry: | 310 | case sessionLoadEntry: | ||
| 237 | if event.index < 0 || event.index >= len(d.loadSlots) || d.loadSlots[event.index].loaded { | 311 | if event.index < 0 || event.index >= len(d.loadSlots) || d.loadSlots[event.index].loaded { | ||
| 238 | return | 312 | return | ||
| @@ -296,7 +370,10 @@ func (d *sessionDialog) appendLoadedSessions(start, end int) { | |||||
| 296 | if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 { | 370 | if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 { | ||
| 297 | continue | 371 | continue | ||
| 298 | } | 372 | } | ||
| 299 | - | d.sessions = append(d.sessions, slot.summary) | 373 | + | d.baseSessions = append(d.baseSessions, slot.summary) |
| 374 | + | if d.query == "" { | |||
| 375 | + | d.sessions = append(d.sessions, slot.summary) | |||
| 376 | + | } | |||
| 300 | } | 377 | } | ||
| 301 | } | 378 | } | ||
| 302 | 379 | ||||
| @@ -311,7 +388,12 @@ func (d *sessionDialog) rebuildLoadedSessions(limit int) { | |||||
| 311 | } | 388 | } | ||
| 312 | filtered = append(filtered, slot.summary) | 389 | filtered = append(filtered, slot.summary) | ||
| 313 | } | 390 | } | ||
| 314 | - | d.sessions = filtered | 391 | + | d.baseSessions = filtered |
| 392 | + | if d.query == "" { | |||
| 393 | + | d.sessions = append([]core.SessionSummary(nil), filtered...) | |||
| 394 | + | } else { | |||
| 395 | + | d.applySearchFilter() | |||
| 396 | + | } | |||
| 315 | if d.cursor >= len(d.sessions) { | 397 | if d.cursor >= len(d.sessions) { | ||
| 316 | d.cursor = len(d.sessions) - 1 | 398 | d.cursor = len(d.sessions) - 1 | ||
| 317 | } | 399 | } | ||
| @@ -337,12 +419,185 @@ func (d *sessionDialog) Close() { | |||||
| 337 | d.loadCancel() | 419 | d.loadCancel() | ||
| 338 | d.loadCancel = nil | 420 | d.loadCancel = nil | ||
| 339 | } | 421 | } | ||
| 422 | + | if d.searchCancel != nil { | |||
| 423 | + | d.searchCancel() | |||
| 424 | + | d.searchCancel = nil | |||
| 425 | + | } | |||
| 426 | + | if d.matchCancel != nil { | |||
| 427 | + | d.matchCancel() | |||
| 428 | + | d.matchCancel = nil | |||
| 429 | + | } | |||
| 340 | d.loading = false | 430 | d.loading = false | ||
| 431 | + | d.searching = false | |||
| 341 | d.loadSlots = nil | 432 | d.loadSlots = nil | ||
| 342 | d.loadReadyThrough = 0 | 433 | d.loadReadyThrough = 0 | ||
| 434 | + | d.searchEvents = nil | |||
| 435 | + | d.searchSegments = nil | |||
| 436 | + | d.searchMatches = nil | |||
| 343 | d.active = false | 437 | d.active = false | ||
| 344 | } | 438 | } | ||
| 345 | 439 | ||||
| 440 | + | // StartSearch starts one background corpus read for this picker opening. Query | |||
| 441 | + | // edits reuse the retained corpus and never reopen a session file. | |||
| 442 | + | func (d *sessionDialog) StartSearch(parent context.Context) <-chan sessionSearchEvent { | |||
| 443 | + | if d.searchEvents != nil { | |||
| 444 | + | return d.searchEvents | |||
| 445 | + | } | |||
| 446 | + | if parent == nil { | |||
| 447 | + | parent = context.Background() | |||
| 448 | + | } | |||
| 449 | + | ctx, cancel := context.WithCancel(parent) | |||
| 450 | + | d.searchContext = ctx | |||
| 451 | + | d.searchCancel = cancel | |||
| 452 | + | d.searching = true | |||
| 453 | + | d.searchGeneration++ | |||
| 454 | + | events := make(chan sessionSearchEvent, 1) | |||
| 455 | + | d.searchEvents = events | |||
| 456 | + | if len(d.paths) == 0 && d.loading { | |||
| 457 | + | d.searchPending = true | |||
| 458 | + | return events | |||
| 459 | + | } | |||
| 460 | + | d.startSearchReader() | |||
| 461 | + | return events | |||
| 462 | + | } | |||
| 463 | + | ||||
| 464 | + | func (d *sessionDialog) startSearchReader() { | |||
| 465 | + | if d.searchEvents == nil || d.searchCancel == nil { | |||
| 466 | + | return | |||
| 467 | + | } | |||
| 468 | + | ctx := d.searchContext | |||
| 469 | + | paths := append([]string(nil), d.paths...) | |||
| 470 | + | generation := d.searchGeneration | |||
| 471 | + | events := d.searchEvents | |||
| 472 | + | go func() { | |||
| 473 | + | segments := make([]core.SessionSearchSegment, 0) | |||
| 474 | + | skipped := 0 | |||
| 475 | + | for _, path := range paths { | |||
| 476 | + | if ctx.Err() != nil { | |||
| 477 | + | return | |||
| 478 | + | } | |||
| 479 | + | read, err := core.ReadSessionSearchSegments(ctx, path) | |||
| 480 | + | if err != nil { | |||
| 481 | + | if ctx.Err() != nil { | |||
| 482 | + | return | |||
| 483 | + | } | |||
| 484 | + | skipped++ | |||
| 485 | + | continue | |||
| 486 | + | } | |||
| 487 | + | segments = append(segments, read...) | |||
| 488 | + | } | |||
| 489 | + | select { | |||
| 490 | + | case events <- sessionSearchEvent{kind: sessionSearchCorpusReady, generation: generation, segments: segments, skipped: skipped}: | |||
| 491 | + | case <-ctx.Done(): | |||
| 492 | + | } | |||
| 493 | + | }() | |||
| 494 | + | } | |||
| 495 | + | ||||
| 496 | + | // ApplySearch incorporates corpus and match results on the UI goroutine. | |||
| 497 | + | func (d *sessionDialog) ApplySearch(event sessionSearchEvent) { | |||
| 498 | + | if !d.active || event.generation != d.searchGeneration { | |||
| 499 | + | return | |||
| 500 | + | } | |||
| 501 | + | switch event.kind { | |||
| 502 | + | case sessionSearchCorpusReady: | |||
| 503 | + | d.searching = false | |||
| 504 | + | d.searchReady = true | |||
| 505 | + | if event.skipped > 0 { | |||
| 506 | + | d.searchErr = fmt.Sprintf("search skipped %d unreadable session(s)", event.skipped) | |||
| 507 | + | } | |||
| 508 | + | d.searchSegments = event.segments | |||
| 509 | + | d.scheduleSearchMatch() | |||
| 510 | + | case sessionSearchMatchesReady: | |||
| 511 | + | if event.query != d.query { | |||
| 512 | + | return | |||
| 513 | + | } | |||
| 514 | + | d.searchMatches = event.matches | |||
| 515 | + | d.applySearchFilter() | |||
| 516 | + | } | |||
| 517 | + | } | |||
| 518 | + | ||||
| 519 | + | func (d *sessionDialog) scheduleSearchMatch() { | |||
| 520 | + | if !d.searchReady || d.query == "" || d.searchEvents == nil || d.searchCancel == nil { | |||
| 521 | + | return | |||
| 522 | + | } | |||
| 523 | + | if d.matchCancel != nil { | |||
| 524 | + | d.matchCancel() | |||
| 525 | + | } | |||
| 526 | + | query := d.query | |||
| 527 | + | segments := d.searchSegments | |||
| 528 | + | generation := d.searchGeneration | |||
| 529 | + | events := d.searchEvents | |||
| 530 | + | ctx, cancel := context.WithCancel(d.searchContext) | |||
| 531 | + | d.matchCancel = cancel | |||
| 532 | + | go func() { | |||
| 533 | + | matches := matchSessionSearchSegments(ctx, query, segments) | |||
| 534 | + | if ctx.Err() != nil { | |||
| 535 | + | return | |||
| 536 | + | } | |||
| 537 | + | select { | |||
| 538 | + | case events <- sessionSearchEvent{kind: sessionSearchMatchesReady, generation: generation, query: query, matches: matches}: | |||
| 539 | + | case <-ctx.Done(): | |||
| 540 | + | } | |||
| 541 | + | }() | |||
| 542 | + | } | |||
| 543 | + | ||||
| 544 | + | func matchSessionSearchSegments(ctx context.Context, query string, segments []core.SessionSearchSegment) map[string]sessionSearchMatch { | |||
| 545 | + | query = core.NormalizeSessionSearchText(query) | |||
| 546 | + | if query == "" { | |||
| 547 | + | return nil | |||
| 548 | + | } | |||
| 549 | + | if ctx.Err() != nil { | |||
| 550 | + | return nil | |||
| 551 | + | } | |||
| 552 | + | texts := make([]string, len(segments)) | |||
| 553 | + | for index, segment := range segments { | |||
| 554 | + | if ctx.Err() != nil { | |||
| 555 | + | return nil | |||
| 556 | + | } | |||
| 557 | + | texts[index] = segment.Normalized | |||
| 558 | + | if texts[index] == "" { | |||
| 559 | + | texts[index] = core.NormalizeSessionSearchText(segment.Text) | |||
| 560 | + | } | |||
| 561 | + | } | |||
| 562 | + | if ctx.Err() != nil { | |||
| 563 | + | return nil | |||
| 564 | + | } | |||
| 565 | + | ranked := fuzzy.Find(query, texts) | |||
| 566 | + | matches := make(map[string]sessionSearchMatch) | |||
| 567 | + | for _, match := range ranked { | |||
| 568 | + | if ctx.Err() != nil || match.Index < 0 || match.Index >= len(segments) { | |||
| 569 | + | return matches | |||
| 570 | + | } | |||
| 571 | + | segment := segments[match.Index] | |||
| 572 | + | current := matches[segment.Path] | |||
| 573 | + | current.count++ | |||
| 574 | + | if current.excerpt == "" || match.Score > current.score { | |||
| 575 | + | current.excerpt = texts[match.Index] | |||
| 576 | + | current.indexes = append([]int(nil), match.MatchedIndexes...) | |||
| 577 | + | current.score = match.Score | |||
| 578 | + | } | |||
| 579 | + | matches[segment.Path] = current | |||
| 580 | + | } | |||
| 581 | + | return matches | |||
| 582 | + | } | |||
| 583 | + | ||||
| 584 | + | func (d *sessionDialog) applySearchFilter() { | |||
| 585 | + | if d.query == "" { | |||
| 586 | + | d.sessions = append(d.sessions[:0], d.baseSessions...) | |||
| 587 | + | } else { | |||
| 588 | + | filtered := make([]core.SessionSummary, 0, len(d.baseSessions)) | |||
| 589 | + | for _, summary := range d.baseSessions { | |||
| 590 | + | if _, ok := d.searchMatches[summary.Path]; ok { | |||
| 591 | + | filtered = append(filtered, summary) | |||
| 592 | + | } | |||
| 593 | + | } | |||
| 594 | + | d.sessions = filtered | |||
| 595 | + | } | |||
| 596 | + | if d.cursor >= len(d.sessions) { | |||
| 597 | + | d.cursor = max(0, len(d.sessions)-1) | |||
| 598 | + | } | |||
| 599 | + | } | |||
| 600 | + | ||||
| 346 | // Active reports whether the dialog is visible and consumes input. | 601 | // Active reports whether the dialog is visible and consumes input. | ||
| 347 | func (d *sessionDialog) Active() bool { return d != nil && d.active } | 602 | func (d *sessionDialog) Active() bool { return d != nil && d.active } | ||
| 348 | 603 | ||||
| @@ -355,7 +610,11 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||||
| 355 | return nil | 610 | return nil | ||
| 356 | } | 611 | } | ||
| 357 | var lines []string | 612 | var lines []string | ||
| 358 | - | lines = append(lines, frameHeader(th, "sessions", width)) | 613 | + | title := "sessions · this directory" |
| 614 | + | if d.allScope { | |||
| 615 | + | title = "sessions · all" | |||
| 616 | + | } | |||
| 617 | + | lines = append(lines, frameHeader(th, title, width)) | |||
| 359 | if d.loading { | 618 | if d.loading { | ||
| 360 | lines = append(lines, th.FGColor(th.Muted, d.loadingMessage(th))) | 619 | lines = append(lines, th.FGColor(th.Muted, d.loadingMessage(th))) | ||
| 361 | if len(d.sessions) == 0 { | 620 | if len(d.sessions) == 0 { | ||
| @@ -364,8 +623,15 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||||
| 364 | return lines | 623 | return lines | ||
| 365 | } | 624 | } | ||
| 366 | } else if len(d.sessions) == 0 { | 625 | } else if len(d.sessions) == 0 { | ||
| 367 | - | lines = append(lines, th.FGColor(th.Muted, "no previous sessions for this directory")) | 626 | + | message := "no previous sessions for this directory" |
| 368 | - | lines = append(lines, th.FGColor(th.Muted, "press esc to close")) | 627 | + | if d.allScope { |
| 628 | + | message = "no previous sessions in this namespace" | |||
| 629 | + | } | |||
| 630 | + | if d.query != "" && d.searchReady { | |||
| 631 | + | message = "no matching sessions" | |||
| 632 | + | } | |||
| 633 | + | lines = append(lines, th.FGColor(th.Muted, message)) | |||
| 634 | + | lines = append(lines, th.FGColor(th.Muted, "tab changes scope · esc closes")) | |||
| 369 | lines = append(lines, frameRule(th, width)) | 635 | lines = append(lines, frameRule(th, width)) | ||
| 370 | return lines | 636 | return lines | ||
| 371 | } | 637 | } | ||
| @@ -381,11 +647,20 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||||
| 381 | lines = append(lines, frameRule(th, width)) | 647 | lines = append(lines, frameRule(th, width)) | ||
| 382 | return lines | 648 | return lines | ||
| 383 | } | 649 | } | ||
| 384 | - | hint := "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)" | 650 | + | hint := "↑/↓ pick · enter resume · / search · tab scope · r rename · esc cancel" |
| 651 | + | if d.searchEvents != nil { | |||
| 652 | + | hint = "search: " + d.query + " (esc clear · tab scope)" | |||
| 653 | + | if d.searching { | |||
| 654 | + | hint += " · indexing" | |||
| 655 | + | } | |||
| 656 | + | } | |||
| 385 | if d.loading { | 657 | if d.loading { | ||
| 386 | - | hint = "pick a session (↑/↓, pgup/pgdn, enter resume, esc cancel; more loading)" | 658 | + | hint += " · loading" |
| 387 | } | 659 | } | ||
| 388 | lines = append(lines, th.FGColor(th.Muted, hint)) | 660 | lines = append(lines, th.FGColor(th.Muted, hint)) | ||
| 661 | + | if d.searchErr != "" { | |||
| 662 | + | lines = append(lines, th.FGColor(th.Warning, d.searchErr)) | |||
| 663 | + | } | |||
| 389 | 664 | ||||
| 390 | // Viewport: windowed slice of d.sessions around d.cursor so a | 665 | // Viewport: windowed slice of d.sessions around d.cursor so a | ||
| 391 | // list taller than the terminal still scrolls. Caller sets | 666 | // list taller than the terminal still scrolls. Caller sets | ||
| @@ -410,7 +685,8 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||||
| 410 | } | 685 | } | ||
| 411 | for i := d.viewTop; i < viewBot; i++ { | 686 | for i := d.viewTop; i < viewBot; i++ { | ||
| 412 | s := d.sessions[i] | 687 | s := d.sessions[i] | ||
| 413 | - | plain := " " + formatSessionRowPlain(s, width-2) | 688 | + | match := d.searchMatches[s.Path] |
| 689 | + | plain := " " + formatSessionSearchRowPlain(s, width-2, match.count) | |||
| 414 | if i == d.cursor { | 690 | if i == d.cursor { | ||
| 415 | lines = append(lines, th.PadHighlight(plain, width)) | 691 | lines = append(lines, th.PadHighlight(plain, width)) | ||
| 416 | } else { | 692 | } else { | ||
| @@ -422,6 +698,15 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||||
| 422 | hidden := total - viewBot | 698 | hidden := total - viewBot | ||
| 423 | lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ↓ %d more below", hidden))) | 699 | lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ↓ %d more below", hidden))) | ||
| 424 | } | 700 | } | ||
| 701 | + | if d.allScope && d.cursor >= 0 && d.cursor < len(d.sessions) && d.sessions[d.cursor].CWD != "" { | |||
| 702 | + | cwd := sanitizeSessionTreeText(friendlyPath(d.sessions[d.cursor].CWD)) | |||
| 703 | + | lines = append(lines, th.FGColor(th.Muted, " cwd: "+cwd)) | |||
| 704 | + | } | |||
| 705 | + | if d.query != "" && d.cursor >= 0 && d.cursor < len(d.sessions) { | |||
| 706 | + | if match := d.searchMatches[d.sessions[d.cursor].Path]; match.excerpt != "" { | |||
| 707 | + | lines = append(lines, " "+highlightSessionSearchExcerpt(th, match.excerpt, match.indexes, width-2)) | |||
| 708 | + | } | |||
| 709 | + | } | |||
| 425 | lines = append(lines, frameRule(th, width)) | 710 | lines = append(lines, frameRule(th, width)) | ||
| 426 | return lines | 711 | return lines | ||
| 427 | } | 712 | } | ||
| @@ -462,6 +747,96 @@ func clampViewTop(viewTop, cursor, window, total int) int { | |||||
| 462 | // full-row selection highlight. The returned string is guaranteed to | 747 | // full-row selection highlight. The returned string is guaranteed to | ||
| 463 | // fit within maxWidth visible characters so the terminal never soft- | 748 | // fit within maxWidth visible characters so the terminal never soft- | ||
| 464 | // wraps it into the next row. | 749 | // wraps it into the next row. | ||
| 750 | + | func formatSessionSearchRowPlain(s core.SessionSummary, maxWidth, matchCount int) string { | |||
| 751 | + | row := formatSessionRowPlain(s, maxWidth) | |||
| 752 | + | if matchCount == 0 { | |||
| 753 | + | return row | |||
| 754 | + | } | |||
| 755 | + | count := fmt.Sprintf(" %d matches", matchCount) | |||
| 756 | + | if len([]rune(row))+len([]rune(count)) > maxWidth { | |||
| 757 | + | return row | |||
| 758 | + | } | |||
| 759 | + | return row + count | |||
| 760 | + | } | |||
| 761 | + | ||||
| 762 | + | func highlightSessionSearchExcerpt(th tui.Theme, excerpt string, indexes []int, maxWidth int) string { | |||
| 763 | + | limit := 240 | |||
| 764 | + | if maxWidth > 0 && maxWidth < limit { | |||
| 765 | + | limit = maxWidth | |||
| 766 | + | } | |||
| 767 | + | excerpt, indexes = windowSessionSearchExcerpt(excerpt, indexes, limit) | |||
| 768 | + | if len(indexes) == 0 { | |||
| 769 | + | return th.FGColor(th.Muted, excerpt) | |||
| 770 | + | } | |||
| 771 | + | matched := make(map[int]bool, len(indexes)) | |||
| 772 | + | for _, index := range indexes { | |||
| 773 | + | matched[index] = true | |||
| 774 | + | } | |||
| 775 | + | var out strings.Builder | |||
| 776 | + | for index := 0; index < len(excerpt); { | |||
| 777 | + | r, size := utf8.DecodeRuneInString(excerpt[index:]) | |||
| 778 | + | part := string(r) | |||
| 779 | + | if matched[index] { | |||
| 780 | + | out.WriteString(th.FGColor(th.Accent, part)) | |||
| 781 | + | } else { | |||
| 782 | + | out.WriteString(th.FGColor(th.Muted, part)) | |||
| 783 | + | } | |||
| 784 | + | index += size | |||
| 785 | + | } | |||
| 786 | + | return out.String() | |||
| 787 | + | } | |||
| 788 | + | ||||
| 789 | + | func windowSessionSearchExcerpt(text string, indexes []int, limit int) (string, []int) { | |||
| 790 | + | runes := []rune(text) | |||
| 791 | + | if limit <= 0 || len(runes) <= limit { | |||
| 792 | + | return text, indexes | |||
| 793 | + | } | |||
| 794 | + | matchRune := 0 | |||
| 795 | + | if len(indexes) > 0 { | |||
| 796 | + | for runeIndex, byteIndex := range runeByteOffsets(text) { | |||
| 797 | + | if byteIndex <= indexes[0] { | |||
| 798 | + | matchRune = runeIndex | |||
| 799 | + | } else { | |||
| 800 | + | break | |||
| 801 | + | } | |||
| 802 | + | } | |||
| 803 | + | } | |||
| 804 | + | room := limit - 3 | |||
| 805 | + | if room < 1 { | |||
| 806 | + | return string(runes[:limit]), nil | |||
| 807 | + | } | |||
| 808 | + | start := max(0, matchRune-room/2) | |||
| 809 | + | if start+room > len(runes) { | |||
| 810 | + | start = len(runes) - room | |||
| 811 | + | } | |||
| 812 | + | end := min(len(runes), start+room) | |||
| 813 | + | prefix := "" | |||
| 814 | + | if start > 0 { | |||
| 815 | + | prefix = "..." | |||
| 816 | + | } | |||
| 817 | + | suffix := "" | |||
| 818 | + | if end < len(runes) { | |||
| 819 | + | suffix = "..." | |||
| 820 | + | } | |||
| 821 | + | base := len(string(runes[:start])) | |||
| 822 | + | stop := len(string(runes[:end])) | |||
| 823 | + | rebased := make([]int, 0, len(indexes)) | |||
| 824 | + | for _, index := range indexes { | |||
| 825 | + | if index >= base && index < stop { | |||
| 826 | + | rebased = append(rebased, len(prefix)+index-base) | |||
| 827 | + | } | |||
| 828 | + | } | |||
| 829 | + | return prefix + string(runes[start:end]) + suffix, rebased | |||
| 830 | + | } | |||
| 831 | + | ||||
| 832 | + | func runeByteOffsets(text string) []int { | |||
| 833 | + | offsets := make([]int, 0, len(text)) | |||
| 834 | + | for index := range text { | |||
| 835 | + | offsets = append(offsets, index) | |||
| 836 | + | } | |||
| 837 | + | return offsets | |||
| 838 | + | } | |||
| 839 | + | ||||
| 465 | func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string { | 840 | func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string { | ||
| 466 | when := formatRelative(s.Started) | 841 | when := formatRelative(s.Started) | ||
| 467 | summary := strings.TrimSpace(s.Title) | 842 | summary := strings.TrimSpace(s.Title) | ||
| @@ -556,6 +931,12 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||||
| 556 | renameErr = err | 931 | renameErr = err | ||
| 557 | } else { | 932 | } else { | ||
| 558 | d.sessions[d.cursor].Title = title | 933 | d.sessions[d.cursor].Title = title | ||
| 934 | + | for index := range d.baseSessions { | |||
| 935 | + | if d.baseSessions[index].Path == path { | |||
| 936 | + | d.baseSessions[index].Title = title | |||
| 937 | + | break | |||
| 938 | + | } | |||
| 939 | + | } | |||
| 559 | renamed = true | 940 | renamed = true | ||
| 560 | } | 941 | } | ||
| 561 | } | 942 | } | ||
| @@ -584,6 +965,50 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||||
| 584 | return sessionDialogAction{} | 965 | return sessionDialogAction{} | ||
| 585 | } | 966 | } | ||
| 586 | 967 | ||||
| 968 | + | if k.Kind == tui.KeyTab { | |||
| 969 | + | return sessionDialogAction{ToggleScope: true} | |||
| 970 | + | } | |||
| 971 | + | if k.Kind == tui.KeyRune && k.Rune == '/' && d.query == "" { | |||
| 972 | + | return sessionDialogAction{StartSearch: true} | |||
| 973 | + | } | |||
| 974 | + | if d.query != "" || d.searchEvents != nil { | |||
| 975 | + | switch k.Kind { | |||
| 976 | + | case tui.KeyEsc: | |||
| 977 | + | if d.query == "" { | |||
| 978 | + | d.Close() | |||
| 979 | + | return sessionDialogAction{Close: true} | |||
| 980 | + | } | |||
| 981 | + | d.query = "" | |||
| 982 | + | d.searchMatches = nil | |||
| 983 | + | d.applySearchFilter() | |||
| 984 | + | return sessionDialogAction{} | |||
| 985 | + | case tui.KeyBackspace: | |||
| 986 | + | runes := []rune(d.query) | |||
| 987 | + | if len(runes) > 0 { | |||
| 988 | + | d.query = string(runes[:len(runes)-1]) | |||
| 989 | + | } | |||
| 990 | + | d.applySearchFilter() | |||
| 991 | + | d.scheduleSearchMatch() | |||
| 992 | + | return sessionDialogAction{} | |||
| 993 | + | case tui.KeyPaste: | |||
| 994 | + | d.query += k.Paste | |||
| 995 | + | case tui.KeyRune: | |||
| 996 | + | if k.Rune != 0 { | |||
| 997 | + | d.query += string(k.Rune) | |||
| 998 | + | } | |||
| 999 | + | default: | |||
| 1000 | + | // Navigation and selection operate on the current filtered rows. | |||
| 1001 | + | goto navigate | |||
| 1002 | + | } | |||
| 1003 | + | d.query = sanitizeSessionTreeText(d.query) | |||
| 1004 | + | if len([]rune(d.query)) > 256 { | |||
| 1005 | + | d.query = string([]rune(d.query)[:256]) | |||
| 1006 | + | } | |||
| 1007 | + | d.applySearchFilter() | |||
| 1008 | + | d.scheduleSearchMatch() | |||
| 1009 | + | return sessionDialogAction{} | |||
| 1010 | + | } | |||
| 1011 | + | ||||
| 587 | if d.loading && len(d.sessions) == 0 { | 1012 | if d.loading && len(d.sessions) == 0 { | ||
| 588 | if k.Kind == tui.KeyEsc { | 1013 | if k.Kind == tui.KeyEsc { | ||
| 589 | d.Close() | 1014 | d.Close() | ||
| @@ -592,6 +1017,7 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||||
| 592 | return sessionDialogAction{} | 1017 | return sessionDialogAction{} | ||
| 593 | } | 1018 | } | ||
| 594 | 1019 | ||||
| 1020 | + | navigate: | |||
| 595 | page := d.MaxRows | 1021 | page := d.MaxRows | ||
| 596 | if page <= 0 { | 1022 | if page <= 0 { | ||
| 597 | page = 10 | 1023 | page = 10 | ||
M packages/agent/modes/session_dialog_test.go +57 -0
| @@ -362,6 +362,63 @@ func TestSessionDialogCanSelectLoadedEntryWhileLoading(t *testing.T) { | |||
| 362 | 362 | } | |
| 363 | 363 | } | |
| 364 | 364 | ||
| 365 | + | func TestSessionDialogAllScopeIncludesOtherCWDBuckets(t *testing.T) { | |
| 366 | + | root := t.TempDir() | |
| 367 | + | cwdA := t.TempDir() | |
| 368 | + | cwdB := t.TempDir() | |
| 369 | + | for _, cwd := range []string{cwdA, cwdB} { | |
| 370 | + | session, err := core.NewSession(root, cwd, "test", "model", "test") | |
| 371 | + | if err != nil { | |
| 372 | + | t.Fatal(err) | |
| 373 | + | } | |
| 374 | + | if err := session.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "scope"}}}); err != nil { | |
| 375 | + | t.Fatal(err) | |
| 376 | + | } | |
| 377 | + | if err := session.Close(); err != nil { | |
| 378 | + | t.Fatal(err) | |
| 379 | + | } | |
| 380 | + | } | |
| 381 | + | d := newSessionDialog() | |
| 382 | + | events := d.Open(context.Background(), root, cwdA, true) | |
| 383 | + | defer d.Close() | |
| 384 | + | for event := range events { | |
| 385 | + | d.ApplyLoad(event) | |
| 386 | + | } | |
| 387 | + | if !d.allScope || len(d.sessions) != 2 { | |
| 388 | + | t.Fatalf("all scope = %v, sessions = %#v", d.allScope, d.sessions) | |
| 389 | + | } | |
| 390 | + | if action := d.HandleKey(tui.Key{Kind: tui.KeyTab}); !action.ToggleScope { | |
| 391 | + | t.Fatalf("tab action = %#v, want scope toggle", action) | |
| 392 | + | } | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | func TestSessionDialogSearchShowsCountsAndExcerpt(t *testing.T) { | |
| 396 | + | d := newSessionDialog() | |
| 397 | + | d.active = true | |
| 398 | + | d.baseSessions = []core.SessionSummary{{Path: "one", MessageCount: 1}, {Path: "two", MessageCount: 1}} | |
| 399 | + | d.sessions = append([]core.SessionSummary(nil), d.baseSessions...) | |
| 400 | + | d.query = "alpha" | |
| 401 | + | d.searchReady = true | |
| 402 | + | d.searchGeneration = 1 | |
| 403 | + | d.searchMatches = matchSessionSearchSegments(context.Background(), d.query, []core.SessionSearchSegment{ | |
| 404 | + | {Path: "one", Text: "alpha implementation details"}, | |
| 405 | + | {Path: "one", Text: "alpha follow-up"}, | |
| 406 | + | {Path: "two", Text: "unrelated text"}, | |
| 407 | + | }) | |
| 408 | + | d.applySearchFilter() | |
| 409 | + | if len(d.sessions) != 1 || d.sessions[0].Path != "one" { | |
| 410 | + | t.Fatalf("search sessions = %#v", d.sessions) | |
| 411 | + | } | |
| 412 | + | match := d.searchMatches["one"] | |
| 413 | + | if match.count != 2 || match.excerpt == "" { | |
| 414 | + | t.Fatalf("search match = %#v", match) | |
| 415 | + | } | |
| 416 | + | rendered := stripANSIBytes(strings.Join(d.Render(tui.Dark, 120), "\n")) | |
| 417 | + | if !strings.Contains(rendered, "2 matches") || !strings.Contains(rendered, "alpha") { | |
| 418 | + | t.Fatalf("search render = %q", rendered) | |
| 419 | + | } | |
| 420 | + | } | |
| 421 | + | ||
| 365 | 422 | func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) { | |
| 366 | 423 | first := newSessionDialog() | |
| 367 | 424 | firstEvents := first.Open(context.Background(), t.TempDir(), t.TempDir()) | |
| @@ -362,6 +362,63 @@ func TestSessionDialogCanSelectLoadedEntryWhileLoading(t *testing.T) { | |||||
| 362 | } | 362 | } | ||
| 363 | } | 363 | } | ||
| 364 | 364 | ||||
| 365 | + | func TestSessionDialogAllScopeIncludesOtherCWDBuckets(t *testing.T) { | |||
| 366 | + | root := t.TempDir() | |||
| 367 | + | cwdA := t.TempDir() | |||
| 368 | + | cwdB := t.TempDir() | |||
| 369 | + | for _, cwd := range []string{cwdA, cwdB} { | |||
| 370 | + | session, err := core.NewSession(root, cwd, "test", "model", "test") | |||
| 371 | + | if err != nil { | |||
| 372 | + | t.Fatal(err) | |||
| 373 | + | } | |||
| 374 | + | if err := session.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "scope"}}}); err != nil { | |||
| 375 | + | t.Fatal(err) | |||
| 376 | + | } | |||
| 377 | + | if err := session.Close(); err != nil { | |||
| 378 | + | t.Fatal(err) | |||
| 379 | + | } | |||
| 380 | + | } | |||
| 381 | + | d := newSessionDialog() | |||
| 382 | + | events := d.Open(context.Background(), root, cwdA, true) | |||
| 383 | + | defer d.Close() | |||
| 384 | + | for event := range events { | |||
| 385 | + | d.ApplyLoad(event) | |||
| 386 | + | } | |||
| 387 | + | if !d.allScope || len(d.sessions) != 2 { | |||
| 388 | + | t.Fatalf("all scope = %v, sessions = %#v", d.allScope, d.sessions) | |||
| 389 | + | } | |||
| 390 | + | if action := d.HandleKey(tui.Key{Kind: tui.KeyTab}); !action.ToggleScope { | |||
| 391 | + | t.Fatalf("tab action = %#v, want scope toggle", action) | |||
| 392 | + | } | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | func TestSessionDialogSearchShowsCountsAndExcerpt(t *testing.T) { | |||
| 396 | + | d := newSessionDialog() | |||
| 397 | + | d.active = true | |||
| 398 | + | d.baseSessions = []core.SessionSummary{{Path: "one", MessageCount: 1}, {Path: "two", MessageCount: 1}} | |||
| 399 | + | d.sessions = append([]core.SessionSummary(nil), d.baseSessions...) | |||
| 400 | + | d.query = "alpha" | |||
| 401 | + | d.searchReady = true | |||
| 402 | + | d.searchGeneration = 1 | |||
| 403 | + | d.searchMatches = matchSessionSearchSegments(context.Background(), d.query, []core.SessionSearchSegment{ | |||
| 404 | + | {Path: "one", Text: "alpha implementation details"}, | |||
| 405 | + | {Path: "one", Text: "alpha follow-up"}, | |||
| 406 | + | {Path: "two", Text: "unrelated text"}, | |||
| 407 | + | }) | |||
| 408 | + | d.applySearchFilter() | |||
| 409 | + | if len(d.sessions) != 1 || d.sessions[0].Path != "one" { | |||
| 410 | + | t.Fatalf("search sessions = %#v", d.sessions) | |||
| 411 | + | } | |||
| 412 | + | match := d.searchMatches["one"] | |||
| 413 | + | if match.count != 2 || match.excerpt == "" { | |||
| 414 | + | t.Fatalf("search match = %#v", match) | |||
| 415 | + | } | |||
| 416 | + | rendered := stripANSIBytes(strings.Join(d.Render(tui.Dark, 120), "\n")) | |||
| 417 | + | if !strings.Contains(rendered, "2 matches") || !strings.Contains(rendered, "alpha") { | |||
| 418 | + | t.Fatalf("search render = %q", rendered) | |||
| 419 | + | } | |||
| 420 | + | } | |||
| 421 | + | ||||
| 365 | func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) { | 422 | func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) { | ||
| 366 | first := newSessionDialog() | 423 | first := newSessionDialog() | ||
| 367 | firstEvents := first.Open(context.Background(), t.TempDir(), t.TempDir()) | 424 | firstEvents := first.Open(context.Background(), t.TempDir(), t.TempDir()) | ||
M packages/core/session.go +2 -0
| @@ -955,6 +955,7 @@ func LatestSession(root, cwd string) string { | |||
| 955 | 955 | // SessionSummary describes one on-disk session at a glance for UI pickers. | |
| 956 | 956 | type SessionSummary struct { | |
| 957 | 957 | Path string | |
| 958 | + | CWD string | |
| 958 | 959 | Started time.Time | |
| 959 | 960 | Model string | |
| 960 | 961 | Provider string | |
| @@ -1065,6 +1066,7 @@ func describeSessionContext(ctx context.Context, path string) SessionSummary { | |||
| 1065 | 1066 | if err != nil { | |
| 1066 | 1067 | return s | |
| 1067 | 1068 | } | |
| 1069 | + | s.CWD = snapshot.Meta.CWD | |
| 1068 | 1070 | s.Started = snapshot.Meta.Started | |
| 1069 | 1071 | s.Model = snapshot.Meta.Model | |
| 1070 | 1072 | s.Provider = snapshot.Meta.Provider | |
| @@ -955,6 +955,7 @@ func LatestSession(root, cwd string) string { | |||||
| 955 | // SessionSummary describes one on-disk session at a glance for UI pickers. | 955 | // SessionSummary describes one on-disk session at a glance for UI pickers. | ||
| 956 | type SessionSummary struct { | 956 | type SessionSummary struct { | ||
| 957 | Path string | 957 | Path string | ||
| 958 | + | CWD string | |||
| 958 | Started time.Time | 959 | Started time.Time | ||
| 959 | Model string | 960 | Model string | ||
| 960 | Provider string | 961 | Provider string | ||
| @@ -1065,6 +1066,7 @@ func describeSessionContext(ctx context.Context, path string) SessionSummary { | |||||
| 1065 | if err != nil { | 1066 | if err != nil { | ||
| 1066 | return s | 1067 | return s | ||
| 1067 | } | 1068 | } | ||
| 1069 | + | s.CWD = snapshot.Meta.CWD | |||
| 1068 | s.Started = snapshot.Meta.Started | 1070 | s.Started = snapshot.Meta.Started | ||
| 1069 | s.Model = snapshot.Meta.Model | 1071 | s.Model = snapshot.Meta.Model | ||
| 1070 | s.Provider = snapshot.Meta.Provider | 1072 | s.Provider = snapshot.Meta.Provider | ||
A packages/core/session_picker.go +324 -0
| @@ -0,0 +1,324 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "bytes" | |
| 6 | + | "context" | |
| 7 | + | "encoding/json" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "io" | |
| 11 | + | "os" | |
| 12 | + | "path/filepath" | |
| 13 | + | "sort" | |
| 14 | + | "strings" | |
| 15 | + | "time" | |
| 16 | + | "unicode" | |
| 17 | + | ||
| 18 | + | "github.com/bnema/zut/packages/provider" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | const ( | |
| 22 | + | maxSessionSearchRowBytes = 1 << 20 | |
| 23 | + | maxSessionSearchRunes = 512 | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | // SessionSearchSegment is one bounded, display-safe user or assistant text | |
| 27 | + | // excerpt from a persisted session. It deliberately never contains tool, | |
| 28 | + | // developer, image, or raw JSON data. | |
| 29 | + | type SessionSearchSegment struct { | |
| 30 | + | Path string | |
| 31 | + | Text string | |
| 32 | + | Normalized string | |
| 33 | + | Role provider.Role | |
| 34 | + | Time time.Time | |
| 35 | + | Order int | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // ListSessionPathsContext lists eligible session files for cwd or, when all is | |
| 39 | + | // true, for every cwd bucket in root's active session namespace. It ignores | |
| 40 | + | // symlinks, malformed buckets, files with invalid metadata, and files whose | |
| 41 | + | // stored cwd does not hash to the directory that contains them. | |
| 42 | + | func ListSessionPathsContext(ctx context.Context, root, cwd string, all bool) []string { | |
| 43 | + | if err := contextErr(ctx); err != nil || strings.TrimSpace(root) == "" { | |
| 44 | + | return nil | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | var dirs []string | |
| 48 | + | if all { | |
| 49 | + | sessionsDir := filepath.Join(root, "sessions") | |
| 50 | + | entries, err := os.ReadDir(sessionsDir) | |
| 51 | + | if err != nil { | |
| 52 | + | return nil | |
| 53 | + | } | |
| 54 | + | for _, entry := range entries { | |
| 55 | + | if err := contextErr(ctx); err != nil { | |
| 56 | + | return nil | |
| 57 | + | } | |
| 58 | + | if entry.Type()&os.ModeSymlink != 0 || !entry.IsDir() || !isSessionBucketName(entry.Name()) { | |
| 59 | + | continue | |
| 60 | + | } | |
| 61 | + | dirs = append(dirs, filepath.Join(sessionsDir, entry.Name())) | |
| 62 | + | } | |
| 63 | + | } else { | |
| 64 | + | dir := SessionsDir(root, cwd) | |
| 65 | + | info, err := os.Lstat(dir) | |
| 66 | + | if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { | |
| 67 | + | return nil | |
| 68 | + | } | |
| 69 | + | dirs = []string{dir} | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | type candidate struct { | |
| 73 | + | path string | |
| 74 | + | mod time.Time | |
| 75 | + | } | |
| 76 | + | var candidates []candidate | |
| 77 | + | for _, dir := range dirs { | |
| 78 | + | if err := contextErr(ctx); err != nil { | |
| 79 | + | return nil | |
| 80 | + | } | |
| 81 | + | entries, err := os.ReadDir(dir) | |
| 82 | + | if err != nil { | |
| 83 | + | continue | |
| 84 | + | } | |
| 85 | + | for _, entry := range entries { | |
| 86 | + | if err := contextErr(ctx); err != nil { | |
| 87 | + | return nil | |
| 88 | + | } | |
| 89 | + | if entry.Type()&os.ModeSymlink != 0 || entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") { | |
| 90 | + | continue | |
| 91 | + | } | |
| 92 | + | info, err := entry.Info() | |
| 93 | + | if err != nil || !info.Mode().IsRegular() { | |
| 94 | + | continue | |
| 95 | + | } | |
| 96 | + | path := filepath.Join(dir, entry.Name()) | |
| 97 | + | meta, err := scanSessionMetaContext(ctx, path) | |
| 98 | + | if err != nil || meta.ID == "" || meta.CWD == "" || meta.HideFromSessions { | |
| 99 | + | continue | |
| 100 | + | } | |
| 101 | + | if filepath.Clean(SessionsDir(root, meta.CWD)) != filepath.Clean(dir) { | |
| 102 | + | continue | |
| 103 | + | } | |
| 104 | + | if !all && meta.CWD != cwd { | |
| 105 | + | continue | |
| 106 | + | } | |
| 107 | + | candidates = append(candidates, candidate{path: path, mod: info.ModTime()}) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | sort.Slice(candidates, func(i, j int) bool { | |
| 111 | + | if !candidates[i].mod.Equal(candidates[j].mod) { | |
| 112 | + | return candidates[i].mod.After(candidates[j].mod) | |
| 113 | + | } | |
| 114 | + | return candidates[i].path > candidates[j].path | |
| 115 | + | }) | |
| 116 | + | paths := make([]string, len(candidates)) | |
| 117 | + | for i, candidate := range candidates { | |
| 118 | + | paths[i] = candidate.path | |
| 119 | + | } | |
| 120 | + | return paths | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // ManagedSessionMeta validates that path is a regular session file directly | |
| 124 | + | // owned by root's active namespace and returns its latest metadata. It is the | |
| 125 | + | // trust boundary for a picker selection before a host changes workspace state. | |
| 126 | + | func ManagedSessionMeta(ctx context.Context, root, path string) (SessionMeta, error) { | |
| 127 | + | if err := contextErr(ctx); err != nil { | |
| 128 | + | return SessionMeta{}, err | |
| 129 | + | } | |
| 130 | + | if strings.TrimSpace(root) == "" || strings.TrimSpace(path) == "" { | |
| 131 | + | return SessionMeta{}, errors.New("managed session: root or path is empty") | |
| 132 | + | } | |
| 133 | + | info, err := os.Lstat(path) | |
| 134 | + | if err != nil { | |
| 135 | + | return SessionMeta{}, fmt.Errorf("managed session: stat: %w", err) | |
| 136 | + | } | |
| 137 | + | if !info.Mode().IsRegular() { | |
| 138 | + | return SessionMeta{}, errors.New("managed session: path is not a regular file") | |
| 139 | + | } | |
| 140 | + | meta, err := scanSessionMetaContext(ctx, path) | |
| 141 | + | if err != nil { | |
| 142 | + | return SessionMeta{}, err | |
| 143 | + | } | |
| 144 | + | if meta.ID == "" || meta.CWD == "" { | |
| 145 | + | return SessionMeta{}, errors.New("managed session: invalid session metadata") | |
| 146 | + | } | |
| 147 | + | wantDir := filepath.Clean(SessionsDir(root, meta.CWD)) | |
| 148 | + | dirInfo, err := os.Lstat(wantDir) | |
| 149 | + | if err != nil || dirInfo.Mode()&os.ModeSymlink != 0 || !dirInfo.IsDir() { | |
| 150 | + | return SessionMeta{}, errors.New("managed session: cwd bucket is not a directory") | |
| 151 | + | } | |
| 152 | + | if filepath.Clean(filepath.Dir(path)) != wantDir { | |
| 153 | + | return SessionMeta{}, errors.New("managed session: cwd bucket does not match metadata") | |
| 154 | + | } | |
| 155 | + | return meta, nil | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // ReadSessionSearchSegments extracts bounded searchable text from persisted | |
| 159 | + | // message rows. It keeps historical user/assistant rows after compaction but | |
| 160 | + | // excludes compaction payloads and every non-text content block. | |
| 161 | + | func ReadSessionSearchSegments(ctx context.Context, path string) ([]SessionSearchSegment, error) { | |
| 162 | + | if err := contextErr(ctx); err != nil { | |
| 163 | + | return nil, err | |
| 164 | + | } | |
| 165 | + | file, err := os.Open(path) | |
| 166 | + | if err != nil { | |
| 167 | + | return nil, fmt.Errorf("read session search text: open: %w", err) | |
| 168 | + | } | |
| 169 | + | defer file.Close() | |
| 170 | + | ||
| 171 | + | reader := bufio.NewReaderSize(file, 64*1024) | |
| 172 | + | var segments []SessionSearchSegment | |
| 173 | + | order := 0 | |
| 174 | + | for { | |
| 175 | + | if err := contextErr(ctx); err != nil { | |
| 176 | + | return nil, err | |
| 177 | + | } | |
| 178 | + | line, err := readBoundedSessionSearchLine(reader) | |
| 179 | + | if err != nil && !errors.Is(err, io.EOF) { | |
| 180 | + | return nil, fmt.Errorf("read session search text: %w", err) | |
| 181 | + | } | |
| 182 | + | if len(line) > 0 { | |
| 183 | + | var head sessionLineHead | |
| 184 | + | if jsonErr := json.Unmarshal(line, &head); jsonErr == nil && head.Type == "message" { | |
| 185 | + | message, messageErr := readSearchMessage(line) | |
| 186 | + | if messageErr == nil && (message.Role == provider.RoleUser || message.Role == provider.RoleAssistant) { | |
| 187 | + | for _, rawText := range message.Texts { | |
| 188 | + | for _, text := range splitSessionSearchText(rawText) { | |
| 189 | + | segments = append(segments, SessionSearchSegment{Path: path, Text: text, Normalized: NormalizeSessionSearchText(text), Role: message.Role, Time: message.Time, Order: order}) | |
| 190 | + | order++ | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | if errors.Is(err, io.EOF) { | |
| 197 | + | break | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | return segments, nil | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | type sessionSearchMessage struct { | |
| 204 | + | Role provider.Role | |
| 205 | + | Time time.Time | |
| 206 | + | Texts []string | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func readSearchMessage(line []byte) (sessionSearchMessage, error) { | |
| 210 | + | var row struct { | |
| 211 | + | Message json.RawMessage `json:"message"` | |
| 212 | + | } | |
| 213 | + | if err := json.Unmarshal(line, &row); err != nil { | |
| 214 | + | return sessionSearchMessage{}, err | |
| 215 | + | } | |
| 216 | + | if len(row.Message) == 0 || bytes.Equal(bytes.TrimSpace(row.Message), []byte("null")) { | |
| 217 | + | return sessionSearchMessage{}, errors.New("message row has no message") | |
| 218 | + | } | |
| 219 | + | var message struct { | |
| 220 | + | Role provider.Role `json:"role"` | |
| 221 | + | Time time.Time `json:"time"` | |
| 222 | + | Content []json.RawMessage `json:"content"` | |
| 223 | + | } | |
| 224 | + | if err := json.Unmarshal(row.Message, &message); err != nil { | |
| 225 | + | return sessionSearchMessage{}, err | |
| 226 | + | } | |
| 227 | + | if message.Role != provider.RoleUser && message.Role != provider.RoleAssistant && message.Role != provider.RoleTool && message.Role != provider.RoleDeveloper { | |
| 228 | + | return sessionSearchMessage{}, fmt.Errorf("message has invalid role %q", message.Role) | |
| 229 | + | } | |
| 230 | + | if len(message.Content) == 0 { | |
| 231 | + | return sessionSearchMessage{}, errors.New("message has no content") | |
| 232 | + | } | |
| 233 | + | result := sessionSearchMessage{Role: message.Role, Time: message.Time} | |
| 234 | + | for _, rawContent := range message.Content { | |
| 235 | + | var textBlock struct { | |
| 236 | + | Text string `json:"text"` | |
| 237 | + | } | |
| 238 | + | if err := json.Unmarshal(rawContent, &textBlock); err != nil { | |
| 239 | + | return sessionSearchMessage{}, err | |
| 240 | + | } | |
| 241 | + | if textBlock.Text != "" { | |
| 242 | + | result.Texts = append(result.Texts, textBlock.Text) | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | return result, nil | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func readBoundedSessionSearchLine(reader *bufio.Reader) ([]byte, error) { | |
| 249 | + | var line []byte | |
| 250 | + | for { | |
| 251 | + | fragment, err := reader.ReadSlice('\n') | |
| 252 | + | line = append(line, fragment...) | |
| 253 | + | if len(line) > maxSessionSearchRowBytes { | |
| 254 | + | return nil, fmt.Errorf("JSONL row exceeds %d bytes", maxSessionSearchRowBytes) | |
| 255 | + | } | |
| 256 | + | switch { | |
| 257 | + | case err == nil: | |
| 258 | + | return bytes.TrimRight(line, "\r\n"), nil | |
| 259 | + | case errors.Is(err, bufio.ErrBufferFull): | |
| 260 | + | continue | |
| 261 | + | case errors.Is(err, io.EOF): | |
| 262 | + | return bytes.TrimRight(line, "\r\n"), io.EOF | |
| 263 | + | default: | |
| 264 | + | return nil, err | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | func splitSessionSearchText(text string) []string { | |
| 270 | + | text = sanitizeSessionSearchText(text) | |
| 271 | + | if text == "" { | |
| 272 | + | return nil | |
| 273 | + | } | |
| 274 | + | runes := []rune(text) | |
| 275 | + | if len(runes) <= maxSessionSearchRunes { | |
| 276 | + | return []string{text} | |
| 277 | + | } | |
| 278 | + | segments := make([]string, 0, len(runes)/maxSessionSearchRunes+1) | |
| 279 | + | for len(runes) > 0 { | |
| 280 | + | end := min(len(runes), maxSessionSearchRunes) | |
| 281 | + | if end < len(runes) { | |
| 282 | + | for split := end - 1; split > end/2; split-- { | |
| 283 | + | if unicode.IsSpace(runes[split]) || unicode.Is(unicode.Sentence_Terminal, runes[split]) { | |
| 284 | + | end = split + 1 | |
| 285 | + | break | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | segment := strings.TrimSpace(string(runes[:end])) | |
| 290 | + | if segment != "" { | |
| 291 | + | segments = append(segments, segment) | |
| 292 | + | } | |
| 293 | + | runes = runes[end:] | |
| 294 | + | } | |
| 295 | + | return segments | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | // NormalizeSessionSearchText returns the display-safe case-folded form used | |
| 299 | + | // by the picker matcher. It removes terminal and bidi controls and collapses | |
| 300 | + | // whitespace without retaining any raw transcript representation. | |
| 301 | + | func NormalizeSessionSearchText(text string) string { | |
| 302 | + | return strings.ToLower(sanitizeSessionSearchText(text)) | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | func sanitizeSessionSearchText(text string) string { | |
| 306 | + | var out strings.Builder | |
| 307 | + | out.Grow(len(text)) | |
| 308 | + | space := true | |
| 309 | + | for _, r := range text { | |
| 310 | + | if unicode.IsControl(r) || unicode.Is(unicode.Bidi_Control, r) || unicode.Is(unicode.Cf, r) { | |
| 311 | + | continue | |
| 312 | + | } | |
| 313 | + | if unicode.IsSpace(r) { | |
| 314 | + | if !space { | |
| 315 | + | out.WriteByte(' ') | |
| 316 | + | space = true | |
| 317 | + | } | |
| 318 | + | continue | |
| 319 | + | } | |
| 320 | + | out.WriteRune(r) | |
| 321 | + | space = false | |
| 322 | + | } | |
| 323 | + | return strings.TrimSpace(out.String()) | |
| 324 | + | } | |
| @@ -0,0 +1,324 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "bytes" | |||
| 6 | + | "context" | |||
| 7 | + | "encoding/json" | |||
| 8 | + | "errors" | |||
| 9 | + | "fmt" | |||
| 10 | + | "io" | |||
| 11 | + | "os" | |||
| 12 | + | "path/filepath" | |||
| 13 | + | "sort" | |||
| 14 | + | "strings" | |||
| 15 | + | "time" | |||
| 16 | + | "unicode" | |||
| 17 | + | ||||
| 18 | + | "github.com/bnema/zut/packages/provider" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | const ( | |||
| 22 | + | maxSessionSearchRowBytes = 1 << 20 | |||
| 23 | + | maxSessionSearchRunes = 512 | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | // SessionSearchSegment is one bounded, display-safe user or assistant text | |||
| 27 | + | // excerpt from a persisted session. It deliberately never contains tool, | |||
| 28 | + | // developer, image, or raw JSON data. | |||
| 29 | + | type SessionSearchSegment struct { | |||
| 30 | + | Path string | |||
| 31 | + | Text string | |||
| 32 | + | Normalized string | |||
| 33 | + | Role provider.Role | |||
| 34 | + | Time time.Time | |||
| 35 | + | Order int | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // ListSessionPathsContext lists eligible session files for cwd or, when all is | |||
| 39 | + | // true, for every cwd bucket in root's active session namespace. It ignores | |||
| 40 | + | // symlinks, malformed buckets, files with invalid metadata, and files whose | |||
| 41 | + | // stored cwd does not hash to the directory that contains them. | |||
| 42 | + | func ListSessionPathsContext(ctx context.Context, root, cwd string, all bool) []string { | |||
| 43 | + | if err := contextErr(ctx); err != nil || strings.TrimSpace(root) == "" { | |||
| 44 | + | return nil | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | var dirs []string | |||
| 48 | + | if all { | |||
| 49 | + | sessionsDir := filepath.Join(root, "sessions") | |||
| 50 | + | entries, err := os.ReadDir(sessionsDir) | |||
| 51 | + | if err != nil { | |||
| 52 | + | return nil | |||
| 53 | + | } | |||
| 54 | + | for _, entry := range entries { | |||
| 55 | + | if err := contextErr(ctx); err != nil { | |||
| 56 | + | return nil | |||
| 57 | + | } | |||
| 58 | + | if entry.Type()&os.ModeSymlink != 0 || !entry.IsDir() || !isSessionBucketName(entry.Name()) { | |||
| 59 | + | continue | |||
| 60 | + | } | |||
| 61 | + | dirs = append(dirs, filepath.Join(sessionsDir, entry.Name())) | |||
| 62 | + | } | |||
| 63 | + | } else { | |||
| 64 | + | dir := SessionsDir(root, cwd) | |||
| 65 | + | info, err := os.Lstat(dir) | |||
| 66 | + | if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { | |||
| 67 | + | return nil | |||
| 68 | + | } | |||
| 69 | + | dirs = []string{dir} | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | type candidate struct { | |||
| 73 | + | path string | |||
| 74 | + | mod time.Time | |||
| 75 | + | } | |||
| 76 | + | var candidates []candidate | |||
| 77 | + | for _, dir := range dirs { | |||
| 78 | + | if err := contextErr(ctx); err != nil { | |||
| 79 | + | return nil | |||
| 80 | + | } | |||
| 81 | + | entries, err := os.ReadDir(dir) | |||
| 82 | + | if err != nil { | |||
| 83 | + | continue | |||
| 84 | + | } | |||
| 85 | + | for _, entry := range entries { | |||
| 86 | + | if err := contextErr(ctx); err != nil { | |||
| 87 | + | return nil | |||
| 88 | + | } | |||
| 89 | + | if entry.Type()&os.ModeSymlink != 0 || entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") { | |||
| 90 | + | continue | |||
| 91 | + | } | |||
| 92 | + | info, err := entry.Info() | |||
| 93 | + | if err != nil || !info.Mode().IsRegular() { | |||
| 94 | + | continue | |||
| 95 | + | } | |||
| 96 | + | path := filepath.Join(dir, entry.Name()) | |||
| 97 | + | meta, err := scanSessionMetaContext(ctx, path) | |||
| 98 | + | if err != nil || meta.ID == "" || meta.CWD == "" || meta.HideFromSessions { | |||
| 99 | + | continue | |||
| 100 | + | } | |||
| 101 | + | if filepath.Clean(SessionsDir(root, meta.CWD)) != filepath.Clean(dir) { | |||
| 102 | + | continue | |||
| 103 | + | } | |||
| 104 | + | if !all && meta.CWD != cwd { | |||
| 105 | + | continue | |||
| 106 | + | } | |||
| 107 | + | candidates = append(candidates, candidate{path: path, mod: info.ModTime()}) | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | sort.Slice(candidates, func(i, j int) bool { | |||
| 111 | + | if !candidates[i].mod.Equal(candidates[j].mod) { | |||
| 112 | + | return candidates[i].mod.After(candidates[j].mod) | |||
| 113 | + | } | |||
| 114 | + | return candidates[i].path > candidates[j].path | |||
| 115 | + | }) | |||
| 116 | + | paths := make([]string, len(candidates)) | |||
| 117 | + | for i, candidate := range candidates { | |||
| 118 | + | paths[i] = candidate.path | |||
| 119 | + | } | |||
| 120 | + | return paths | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // ManagedSessionMeta validates that path is a regular session file directly | |||
| 124 | + | // owned by root's active namespace and returns its latest metadata. It is the | |||
| 125 | + | // trust boundary for a picker selection before a host changes workspace state. | |||
| 126 | + | func ManagedSessionMeta(ctx context.Context, root, path string) (SessionMeta, error) { | |||
| 127 | + | if err := contextErr(ctx); err != nil { | |||
| 128 | + | return SessionMeta{}, err | |||
| 129 | + | } | |||
| 130 | + | if strings.TrimSpace(root) == "" || strings.TrimSpace(path) == "" { | |||
| 131 | + | return SessionMeta{}, errors.New("managed session: root or path is empty") | |||
| 132 | + | } | |||
| 133 | + | info, err := os.Lstat(path) | |||
| 134 | + | if err != nil { | |||
| 135 | + | return SessionMeta{}, fmt.Errorf("managed session: stat: %w", err) | |||
| 136 | + | } | |||
| 137 | + | if !info.Mode().IsRegular() { | |||
| 138 | + | return SessionMeta{}, errors.New("managed session: path is not a regular file") | |||
| 139 | + | } | |||
| 140 | + | meta, err := scanSessionMetaContext(ctx, path) | |||
| 141 | + | if err != nil { | |||
| 142 | + | return SessionMeta{}, err | |||
| 143 | + | } | |||
| 144 | + | if meta.ID == "" || meta.CWD == "" { | |||
| 145 | + | return SessionMeta{}, errors.New("managed session: invalid session metadata") | |||
| 146 | + | } | |||
| 147 | + | wantDir := filepath.Clean(SessionsDir(root, meta.CWD)) | |||
| 148 | + | dirInfo, err := os.Lstat(wantDir) | |||
| 149 | + | if err != nil || dirInfo.Mode()&os.ModeSymlink != 0 || !dirInfo.IsDir() { | |||
| 150 | + | return SessionMeta{}, errors.New("managed session: cwd bucket is not a directory") | |||
| 151 | + | } | |||
| 152 | + | if filepath.Clean(filepath.Dir(path)) != wantDir { | |||
| 153 | + | return SessionMeta{}, errors.New("managed session: cwd bucket does not match metadata") | |||
| 154 | + | } | |||
| 155 | + | return meta, nil | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | // ReadSessionSearchSegments extracts bounded searchable text from persisted | |||
| 159 | + | // message rows. It keeps historical user/assistant rows after compaction but | |||
| 160 | + | // excludes compaction payloads and every non-text content block. | |||
| 161 | + | func ReadSessionSearchSegments(ctx context.Context, path string) ([]SessionSearchSegment, error) { | |||
| 162 | + | if err := contextErr(ctx); err != nil { | |||
| 163 | + | return nil, err | |||
| 164 | + | } | |||
| 165 | + | file, err := os.Open(path) | |||
| 166 | + | if err != nil { | |||
| 167 | + | return nil, fmt.Errorf("read session search text: open: %w", err) | |||
| 168 | + | } | |||
| 169 | + | defer file.Close() | |||
| 170 | + | ||||
| 171 | + | reader := bufio.NewReaderSize(file, 64*1024) | |||
| 172 | + | var segments []SessionSearchSegment | |||
| 173 | + | order := 0 | |||
| 174 | + | for { | |||
| 175 | + | if err := contextErr(ctx); err != nil { | |||
| 176 | + | return nil, err | |||
| 177 | + | } | |||
| 178 | + | line, err := readBoundedSessionSearchLine(reader) | |||
| 179 | + | if err != nil && !errors.Is(err, io.EOF) { | |||
| 180 | + | return nil, fmt.Errorf("read session search text: %w", err) | |||
| 181 | + | } | |||
| 182 | + | if len(line) > 0 { | |||
| 183 | + | var head sessionLineHead | |||
| 184 | + | if jsonErr := json.Unmarshal(line, &head); jsonErr == nil && head.Type == "message" { | |||
| 185 | + | message, messageErr := readSearchMessage(line) | |||
| 186 | + | if messageErr == nil && (message.Role == provider.RoleUser || message.Role == provider.RoleAssistant) { | |||
| 187 | + | for _, rawText := range message.Texts { | |||
| 188 | + | for _, text := range splitSessionSearchText(rawText) { | |||
| 189 | + | segments = append(segments, SessionSearchSegment{Path: path, Text: text, Normalized: NormalizeSessionSearchText(text), Role: message.Role, Time: message.Time, Order: order}) | |||
| 190 | + | order++ | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | } | |||
| 196 | + | if errors.Is(err, io.EOF) { | |||
| 197 | + | break | |||
| 198 | + | } | |||
| 199 | + | } | |||
| 200 | + | return segments, nil | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | type sessionSearchMessage struct { | |||
| 204 | + | Role provider.Role | |||
| 205 | + | Time time.Time | |||
| 206 | + | Texts []string | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func readSearchMessage(line []byte) (sessionSearchMessage, error) { | |||
| 210 | + | var row struct { | |||
| 211 | + | Message json.RawMessage `json:"message"` | |||
| 212 | + | } | |||
| 213 | + | if err := json.Unmarshal(line, &row); err != nil { | |||
| 214 | + | return sessionSearchMessage{}, err | |||
| 215 | + | } | |||
| 216 | + | if len(row.Message) == 0 || bytes.Equal(bytes.TrimSpace(row.Message), []byte("null")) { | |||
| 217 | + | return sessionSearchMessage{}, errors.New("message row has no message") | |||
| 218 | + | } | |||
| 219 | + | var message struct { | |||
| 220 | + | Role provider.Role `json:"role"` | |||
| 221 | + | Time time.Time `json:"time"` | |||
| 222 | + | Content []json.RawMessage `json:"content"` | |||
| 223 | + | } | |||
| 224 | + | if err := json.Unmarshal(row.Message, &message); err != nil { | |||
| 225 | + | return sessionSearchMessage{}, err | |||
| 226 | + | } | |||
| 227 | + | if message.Role != provider.RoleUser && message.Role != provider.RoleAssistant && message.Role != provider.RoleTool && message.Role != provider.RoleDeveloper { | |||
| 228 | + | return sessionSearchMessage{}, fmt.Errorf("message has invalid role %q", message.Role) | |||
| 229 | + | } | |||
| 230 | + | if len(message.Content) == 0 { | |||
| 231 | + | return sessionSearchMessage{}, errors.New("message has no content") | |||
| 232 | + | } | |||
| 233 | + | result := sessionSearchMessage{Role: message.Role, Time: message.Time} | |||
| 234 | + | for _, rawContent := range message.Content { | |||
| 235 | + | var textBlock struct { | |||
| 236 | + | Text string `json:"text"` | |||
| 237 | + | } | |||
| 238 | + | if err := json.Unmarshal(rawContent, &textBlock); err != nil { | |||
| 239 | + | return sessionSearchMessage{}, err | |||
| 240 | + | } | |||
| 241 | + | if textBlock.Text != "" { | |||
| 242 | + | result.Texts = append(result.Texts, textBlock.Text) | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | return result, nil | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | func readBoundedSessionSearchLine(reader *bufio.Reader) ([]byte, error) { | |||
| 249 | + | var line []byte | |||
| 250 | + | for { | |||
| 251 | + | fragment, err := reader.ReadSlice('\n') | |||
| 252 | + | line = append(line, fragment...) | |||
| 253 | + | if len(line) > maxSessionSearchRowBytes { | |||
| 254 | + | return nil, fmt.Errorf("JSONL row exceeds %d bytes", maxSessionSearchRowBytes) | |||
| 255 | + | } | |||
| 256 | + | switch { | |||
| 257 | + | case err == nil: | |||
| 258 | + | return bytes.TrimRight(line, "\r\n"), nil | |||
| 259 | + | case errors.Is(err, bufio.ErrBufferFull): | |||
| 260 | + | continue | |||
| 261 | + | case errors.Is(err, io.EOF): | |||
| 262 | + | return bytes.TrimRight(line, "\r\n"), io.EOF | |||
| 263 | + | default: | |||
| 264 | + | return nil, err | |||
| 265 | + | } | |||
| 266 | + | } | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | func splitSessionSearchText(text string) []string { | |||
| 270 | + | text = sanitizeSessionSearchText(text) | |||
| 271 | + | if text == "" { | |||
| 272 | + | return nil | |||
| 273 | + | } | |||
| 274 | + | runes := []rune(text) | |||
| 275 | + | if len(runes) <= maxSessionSearchRunes { | |||
| 276 | + | return []string{text} | |||
| 277 | + | } | |||
| 278 | + | segments := make([]string, 0, len(runes)/maxSessionSearchRunes+1) | |||
| 279 | + | for len(runes) > 0 { | |||
| 280 | + | end := min(len(runes), maxSessionSearchRunes) | |||
| 281 | + | if end < len(runes) { | |||
| 282 | + | for split := end - 1; split > end/2; split-- { | |||
| 283 | + | if unicode.IsSpace(runes[split]) || unicode.Is(unicode.Sentence_Terminal, runes[split]) { | |||
| 284 | + | end = split + 1 | |||
| 285 | + | break | |||
| 286 | + | } | |||
| 287 | + | } | |||
| 288 | + | } | |||
| 289 | + | segment := strings.TrimSpace(string(runes[:end])) | |||
| 290 | + | if segment != "" { | |||
| 291 | + | segments = append(segments, segment) | |||
| 292 | + | } | |||
| 293 | + | runes = runes[end:] | |||
| 294 | + | } | |||
| 295 | + | return segments | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | // NormalizeSessionSearchText returns the display-safe case-folded form used | |||
| 299 | + | // by the picker matcher. It removes terminal and bidi controls and collapses | |||
| 300 | + | // whitespace without retaining any raw transcript representation. | |||
| 301 | + | func NormalizeSessionSearchText(text string) string { | |||
| 302 | + | return strings.ToLower(sanitizeSessionSearchText(text)) | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | func sanitizeSessionSearchText(text string) string { | |||
| 306 | + | var out strings.Builder | |||
| 307 | + | out.Grow(len(text)) | |||
| 308 | + | space := true | |||
| 309 | + | for _, r := range text { | |||
| 310 | + | if unicode.IsControl(r) || unicode.Is(unicode.Bidi_Control, r) || unicode.Is(unicode.Cf, r) { | |||
| 311 | + | continue | |||
| 312 | + | } | |||
| 313 | + | if unicode.IsSpace(r) { | |||
| 314 | + | if !space { | |||
| 315 | + | out.WriteByte(' ') | |||
| 316 | + | space = true | |||
| 317 | + | } | |||
| 318 | + | continue | |||
| 319 | + | } | |||
| 320 | + | out.WriteRune(r) | |||
| 321 | + | space = false | |||
| 322 | + | } | |||
| 323 | + | return strings.TrimSpace(out.String()) | |||
| 324 | + | } | |||
A packages/core/session_picker_test.go +138 -0
| @@ -0,0 +1,138 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zut/packages/provider" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestListSessionPathsContextScopesAndValidatesBuckets(t *testing.T) { | |
| 13 | + | root := t.TempDir() | |
| 14 | + | cwdA := filepath.Join(t.TempDir(), "workspace-a") | |
| 15 | + | cwdB := filepath.Join(t.TempDir(), "workspace-b") | |
| 16 | + | ||
| 17 | + | newSession := func(cwd, text string) *Session { | |
| 18 | + | t.Helper() | |
| 19 | + | s, err := NewSession(root, cwd, "provider", "model", "test") | |
| 20 | + | if err != nil { | |
| 21 | + | t.Fatal(err) | |
| 22 | + | } | |
| 23 | + | if err := s.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: text}}}); err != nil { | |
| 24 | + | t.Fatal(err) | |
| 25 | + | } | |
| 26 | + | if err := s.Close(); err != nil { | |
| 27 | + | t.Fatal(err) | |
| 28 | + | } | |
| 29 | + | return s | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | a := newSession(cwdA, "from a") | |
| 33 | + | b := newSession(cwdB, "from b") | |
| 34 | + | ||
| 35 | + | if got := ListSessionPathsContext(context.Background(), root, cwdA, false); len(got) != 1 || got[0] != a.Path { | |
| 36 | + | t.Fatalf("current scope = %q, want %q", got, a.Path) | |
| 37 | + | } | |
| 38 | + | got := ListSessionPathsContext(context.Background(), root, cwdA, true) | |
| 39 | + | if len(got) != 2 || !containsPath(got, a.Path) || !containsPath(got, b.Path) { | |
| 40 | + | t.Fatalf("all scope = %q, want both valid sessions", got) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | invalidDir := filepath.Join(root, "sessions", "not-a-cwd-bucket") | |
| 44 | + | if err := os.MkdirAll(invalidDir, 0o755); err != nil { | |
| 45 | + | t.Fatal(err) | |
| 46 | + | } | |
| 47 | + | if err := os.WriteFile(filepath.Join(invalidDir, "invalid.jsonl"), []byte("{}\n"), 0o644); err != nil { | |
| 48 | + | t.Fatal(err) | |
| 49 | + | } | |
| 50 | + | wrongBucket := filepath.Join(root, "sessions", "0123456789abcdef") | |
| 51 | + | if err := os.MkdirAll(wrongBucket, 0o755); err != nil { | |
| 52 | + | t.Fatal(err) | |
| 53 | + | } | |
| 54 | + | contents, err := os.ReadFile(a.Path) | |
| 55 | + | if err != nil { | |
| 56 | + | t.Fatal(err) | |
| 57 | + | } | |
| 58 | + | if err := os.WriteFile(filepath.Join(wrongBucket, "wrong.jsonl"), contents, 0o644); err != nil { | |
| 59 | + | t.Fatal(err) | |
| 60 | + | } | |
| 61 | + | if got := ListSessionPathsContext(context.Background(), root, cwdA, true); len(got) != 2 { | |
| 62 | + | t.Fatalf("all scope included invalid session paths: %q", got) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestManagedSessionMetaRejectsWrongBucket(t *testing.T) { | |
| 67 | + | root := t.TempDir() | |
| 68 | + | cwd := t.TempDir() | |
| 69 | + | session, err := NewSession(root, cwd, "provider", "model", "test") | |
| 70 | + | if err != nil { | |
| 71 | + | t.Fatal(err) | |
| 72 | + | } | |
| 73 | + | if err := session.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "keep this session"}}}); err != nil { | |
| 74 | + | t.Fatal(err) | |
| 75 | + | } | |
| 76 | + | if err := session.Close(); err != nil { | |
| 77 | + | t.Fatal(err) | |
| 78 | + | } | |
| 79 | + | if _, err := ManagedSessionMeta(context.Background(), root, session.Path); err != nil { | |
| 80 | + | t.Fatalf("managed session metadata: %v", err) | |
| 81 | + | } | |
| 82 | + | wrongPath := filepath.Join(root, "sessions", "0123456789abcdef", "moved.jsonl") | |
| 83 | + | if err := os.MkdirAll(filepath.Dir(wrongPath), 0o755); err != nil { | |
| 84 | + | t.Fatal(err) | |
| 85 | + | } | |
| 86 | + | contents, err := os.ReadFile(session.Path) | |
| 87 | + | if err != nil { | |
| 88 | + | t.Fatal(err) | |
| 89 | + | } | |
| 90 | + | if err := os.WriteFile(wrongPath, contents, 0o644); err != nil { | |
| 91 | + | t.Fatal(err) | |
| 92 | + | } | |
| 93 | + | if _, err := ManagedSessionMeta(context.Background(), root, wrongPath); err == nil { | |
| 94 | + | t.Fatal("wrong-bucket session was accepted") | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestReadSessionSearchSegmentsIncludesOnlyUserAssistantText(t *testing.T) { | |
| 99 | + | root := t.TempDir() | |
| 100 | + | cwd := t.TempDir() | |
| 101 | + | s, err := NewSession(root, cwd, "provider", "model", "test") | |
| 102 | + | if err != nil { | |
| 103 | + | t.Fatal(err) | |
| 104 | + | } | |
| 105 | + | for _, msg := range []provider.Message{ | |
| 106 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "find this user sentence"}}}, | |
| 107 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "find this assistant sentence"}, provider.ToolCallBlock{ID: "call", Name: "secret-tool"}}}, | |
| 108 | + | {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{CallID: "call", Content: []provider.Content{provider.TextBlock{Text: "never expose tool result"}}}}}, | |
| 109 | + | {Role: provider.RoleDeveloper, Content: []provider.Content{provider.TextBlock{Text: "never expose developer text"}}}, | |
| 110 | + | } { | |
| 111 | + | if err := s.AppendMessage(msg); err != nil { | |
| 112 | + | t.Fatal(err) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | if err := s.Close(); err != nil { | |
| 116 | + | t.Fatal(err) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | segments, err := ReadSessionSearchSegments(context.Background(), s.Path) | |
| 120 | + | if err != nil { | |
| 121 | + | t.Fatal(err) | |
| 122 | + | } | |
| 123 | + | if len(segments) != 2 { | |
| 124 | + | t.Fatalf("segments = %#v, want exactly user and assistant text", segments) | |
| 125 | + | } | |
| 126 | + | if segments[0].Text != "find this user sentence" || segments[1].Text != "find this assistant sentence" { | |
| 127 | + | t.Fatalf("segments = %#v", segments) | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func containsPath(paths []string, want string) bool { | |
| 132 | + | for _, path := range paths { | |
| 133 | + | if path == want { | |
| 134 | + | return true | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | return false | |
| 138 | + | } | |
| @@ -0,0 +1,138 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zut/packages/provider" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestListSessionPathsContextScopesAndValidatesBuckets(t *testing.T) { | |||
| 13 | + | root := t.TempDir() | |||
| 14 | + | cwdA := filepath.Join(t.TempDir(), "workspace-a") | |||
| 15 | + | cwdB := filepath.Join(t.TempDir(), "workspace-b") | |||
| 16 | + | ||||
| 17 | + | newSession := func(cwd, text string) *Session { | |||
| 18 | + | t.Helper() | |||
| 19 | + | s, err := NewSession(root, cwd, "provider", "model", "test") | |||
| 20 | + | if err != nil { | |||
| 21 | + | t.Fatal(err) | |||
| 22 | + | } | |||
| 23 | + | if err := s.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: text}}}); err != nil { | |||
| 24 | + | t.Fatal(err) | |||
| 25 | + | } | |||
| 26 | + | if err := s.Close(); err != nil { | |||
| 27 | + | t.Fatal(err) | |||
| 28 | + | } | |||
| 29 | + | return s | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | a := newSession(cwdA, "from a") | |||
| 33 | + | b := newSession(cwdB, "from b") | |||
| 34 | + | ||||
| 35 | + | if got := ListSessionPathsContext(context.Background(), root, cwdA, false); len(got) != 1 || got[0] != a.Path { | |||
| 36 | + | t.Fatalf("current scope = %q, want %q", got, a.Path) | |||
| 37 | + | } | |||
| 38 | + | got := ListSessionPathsContext(context.Background(), root, cwdA, true) | |||
| 39 | + | if len(got) != 2 || !containsPath(got, a.Path) || !containsPath(got, b.Path) { | |||
| 40 | + | t.Fatalf("all scope = %q, want both valid sessions", got) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | invalidDir := filepath.Join(root, "sessions", "not-a-cwd-bucket") | |||
| 44 | + | if err := os.MkdirAll(invalidDir, 0o755); err != nil { | |||
| 45 | + | t.Fatal(err) | |||
| 46 | + | } | |||
| 47 | + | if err := os.WriteFile(filepath.Join(invalidDir, "invalid.jsonl"), []byte("{}\n"), 0o644); err != nil { | |||
| 48 | + | t.Fatal(err) | |||
| 49 | + | } | |||
| 50 | + | wrongBucket := filepath.Join(root, "sessions", "0123456789abcdef") | |||
| 51 | + | if err := os.MkdirAll(wrongBucket, 0o755); err != nil { | |||
| 52 | + | t.Fatal(err) | |||
| 53 | + | } | |||
| 54 | + | contents, err := os.ReadFile(a.Path) | |||
| 55 | + | if err != nil { | |||
| 56 | + | t.Fatal(err) | |||
| 57 | + | } | |||
| 58 | + | if err := os.WriteFile(filepath.Join(wrongBucket, "wrong.jsonl"), contents, 0o644); err != nil { | |||
| 59 | + | t.Fatal(err) | |||
| 60 | + | } | |||
| 61 | + | if got := ListSessionPathsContext(context.Background(), root, cwdA, true); len(got) != 2 { | |||
| 62 | + | t.Fatalf("all scope included invalid session paths: %q", got) | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestManagedSessionMetaRejectsWrongBucket(t *testing.T) { | |||
| 67 | + | root := t.TempDir() | |||
| 68 | + | cwd := t.TempDir() | |||
| 69 | + | session, err := NewSession(root, cwd, "provider", "model", "test") | |||
| 70 | + | if err != nil { | |||
| 71 | + | t.Fatal(err) | |||
| 72 | + | } | |||
| 73 | + | if err := session.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "keep this session"}}}); err != nil { | |||
| 74 | + | t.Fatal(err) | |||
| 75 | + | } | |||
| 76 | + | if err := session.Close(); err != nil { | |||
| 77 | + | t.Fatal(err) | |||
| 78 | + | } | |||
| 79 | + | if _, err := ManagedSessionMeta(context.Background(), root, session.Path); err != nil { | |||
| 80 | + | t.Fatalf("managed session metadata: %v", err) | |||
| 81 | + | } | |||
| 82 | + | wrongPath := filepath.Join(root, "sessions", "0123456789abcdef", "moved.jsonl") | |||
| 83 | + | if err := os.MkdirAll(filepath.Dir(wrongPath), 0o755); err != nil { | |||
| 84 | + | t.Fatal(err) | |||
| 85 | + | } | |||
| 86 | + | contents, err := os.ReadFile(session.Path) | |||
| 87 | + | if err != nil { | |||
| 88 | + | t.Fatal(err) | |||
| 89 | + | } | |||
| 90 | + | if err := os.WriteFile(wrongPath, contents, 0o644); err != nil { | |||
| 91 | + | t.Fatal(err) | |||
| 92 | + | } | |||
| 93 | + | if _, err := ManagedSessionMeta(context.Background(), root, wrongPath); err == nil { | |||
| 94 | + | t.Fatal("wrong-bucket session was accepted") | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func TestReadSessionSearchSegmentsIncludesOnlyUserAssistantText(t *testing.T) { | |||
| 99 | + | root := t.TempDir() | |||
| 100 | + | cwd := t.TempDir() | |||
| 101 | + | s, err := NewSession(root, cwd, "provider", "model", "test") | |||
| 102 | + | if err != nil { | |||
| 103 | + | t.Fatal(err) | |||
| 104 | + | } | |||
| 105 | + | for _, msg := range []provider.Message{ | |||
| 106 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "find this user sentence"}}}, | |||
| 107 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "find this assistant sentence"}, provider.ToolCallBlock{ID: "call", Name: "secret-tool"}}}, | |||
| 108 | + | {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{CallID: "call", Content: []provider.Content{provider.TextBlock{Text: "never expose tool result"}}}}}, | |||
| 109 | + | {Role: provider.RoleDeveloper, Content: []provider.Content{provider.TextBlock{Text: "never expose developer text"}}}, | |||
| 110 | + | } { | |||
| 111 | + | if err := s.AppendMessage(msg); err != nil { | |||
| 112 | + | t.Fatal(err) | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | if err := s.Close(); err != nil { | |||
| 116 | + | t.Fatal(err) | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | segments, err := ReadSessionSearchSegments(context.Background(), s.Path) | |||
| 120 | + | if err != nil { | |||
| 121 | + | t.Fatal(err) | |||
| 122 | + | } | |||
| 123 | + | if len(segments) != 2 { | |||
| 124 | + | t.Fatalf("segments = %#v, want exactly user and assistant text", segments) | |||
| 125 | + | } | |||
| 126 | + | if segments[0].Text != "find this user sentence" || segments[1].Text != "find this assistant sentence" { | |||
| 127 | + | t.Fatalf("segments = %#v", segments) | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func containsPath(paths []string, want string) bool { | |||
| 132 | + | for _, path := range paths { | |||
| 133 | + | if path == want { | |||
| 134 | + | return true | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | return false | |||
| 138 | + | } | |||