detached 8b4e3af

Merge pull request #149 from bnema/feat/session-picker-fuzzy-search

feat(sessions): add scoped fuzzy session picker
commit
8b4e3aff9ecaccf38fb3ef804d4e455a24ddd9a3
author
bnema · · 2026-08-19 07:44 UTC
committer
GitHub · · 2026-08-19 07:44 UTC
merge of
f2559ec + 2d08d05 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
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
316316 | `/orchestrator` | Toggle automatic subagent orchestration. When disabled, subagent tools remain available for explicit delegation. |
317317 | `/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. |
318318 | `/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. |
320320 | `/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. |
321321 | `/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. |
322322 | `/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
359359
360360 ### `/sessions`
361361
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.
363363
364364 ### `/session`
365365
@@ -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 {
436436 // error this function closes the candidate session and leaves the current
437437 // agent untouched.
438438 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)
440447 }
441448
442449 // prepareSessionResumeWithOptions is the provider/model-aware implementation.
443450 // An explicit CLI provider or model keeps that field authoritative; an empty
444451 // 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) {
446453 if current == nil {
447454 return candidate, fmt.Errorf("no agent running; log in first")
448455 }
@@ -496,7 +503,7 @@ func prepareSessionResumeWithOptions(path string, current *core.Agent, currentPr
496503 if explicitModel {
497504 resumeModel = currentModel
498505 }
499- if resumeProvider == currentProvider && resumeModel == currentModel {
506+ if !forceRebuild && resumeProvider == currentProvider && resumeModel == currentModel {
500507 keepSession = true
501508 return candidate, nil
502509 }
@@ -581,7 +588,7 @@ func applySessionResume(sess *core.Session, ag *core.Agent, currentProvider, cur
581588 strings.TrimSpace(sess.Meta.Model) == strings.TrimSpace(currentModel) {
582589 return candidate, nil
583590 }
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)
585592 if err != nil {
586593 return sessionResumeCandidate{}, joinSessionCloseError(err, sess)
587594 }
@@ -2030,13 +2037,33 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
20302037
20312038 // loadSession replaces the current session with the one at path and
20322039 // 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+ }
20342054 // Hold the transition lock from the pre-flush through the commit.
20352055 // Persistence callbacks take the read side, so an active session
20362056 // cannot be snapshotted before its lazy writes land or overwritten by
20372057 // a callback while the candidate is being installed.
2058+ if err := loadCtx.Err(); err != nil {
2059+ return err
2060+ }
20382061 sessionTransitionMu.Lock()
20392062 defer sessionTransitionMu.Unlock()
2063+ if err := loadCtx.Err(); err != nil {
2064+ return err
2065+ }
2066+ workspaceChanged := filepath.Clean(selectedMeta.CWD) != filepath.Clean(args.CWD)
20402067
20412068 currentAg := liveInteractiveAgent(iv, ag)
20422069 if currentAg == nil {
@@ -2067,15 +2094,63 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
20672094 // provider settings on the shared runtime, so restore those settings on
20682095 // every failed path, including errors returned during preparation.
20692096 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+ }
20702140 committed := false
20712141 var candidate sessionResumeCandidate
20722142 defer func() {
20732143 if !committed {
2144+ rollbackWorkspace()
20742145 runtime.restoreConfiguration(runtimeConfig)
20752146 loadErr = joinSessionCloseError(loadErr, candidate.session)
20762147 }
20772148 }()
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+ }
20792154 if err != nil {
20802155 // prepareSessionResume closes its append handle on failure;
20812156 // no live agent or session has been changed.
@@ -2087,6 +2162,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
20872162 // agent is reused and only its transcript/usage are refreshed.
20882163 wireAgentPersist(candidate.agent)
20892164
2165+ if err := loadCtx.Err(); err != nil {
2166+ return err
2167+ }
20902168 persistMu.Lock()
20912169 // A provider rebuild ran outside persistMu. Refuse to install it if
20922170 // 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
21212199 // writer, so no new message can land on the old file between the
21222200 // two state changes.
21232201 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+ }
21252207 }
21262208 ag = candidate.agent
21272209 } else {
@@ -2154,6 +2236,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
21542236 }
21552237 return nil
21562238 }
2239+ loadSession := func(path string) error {
2240+ return loadSessionContext(context.Background(), path)
2241+ }
21572242
21582243 // changeCWD switches the running session to a new working directory.
21592244 // Wired into InteractiveConfig.ChangeCWD and invoked by the hidden
@@ -2499,6 +2584,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
24992584 ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
25002585 ZutHome: ZutHome(),
25012586 SessionsRoot: agentSessionsRoot(ZutHome(), args),
2587+ SessionsDisabled: args.NoSess,
25022588 Version: version,
25032589 UpdateInfoChan: updateCh,
25042590 Sandbox: sharedSandbox,
@@ -2578,6 +2664,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
25782664 return out
25792665 },
25802666 LoadSession: loadSession,
2667+ LoadSessionContext: loadSessionContext,
25812668 ChangeCWD: changeCWD,
25822669 PersistCompactHandoff: persistCompactHandoff,
25832670 CurrentCompactHandoff: currentCompactHandoff,
@@ -436,13 +436,20 @@ type sessionResumeCandidate struct {
436 // error this function closes the candidate session and leaves the current436 // 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 empty450 // 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 = currentModel504 resumeModel = currentModel
498 }505 }
499- if resumeProvider == currentProvider && resumeModel == currentModel {506+ if !forceRebuild && resumeProvider == currentProvider && resumeModel == currentModel {
500 keepSession = true507 keepSession = true
501 return candidate, nil508 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, nil589 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 and2038 // 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 session2055 // Persistence callbacks take the read side, so an active session
2036 // cannot be snapshotted before its lazy writes land or overwritten by2056 // 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 on2094 // 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 := false2140 committed := false
2071 var candidate sessionResumeCandidate2141 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 if2169 // A provider rebuild ran outside persistMu. Refuse to install it if
2092 // the live agent, session, selection, transcript, or cost changed in2170 // 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 the2199 // 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.agent2208 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 nil2237 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 hidden2244 // 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 out2664 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) {
885885 }
886886 }
887887
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+
888905 func TestPrepareSessionResumePreservesLegacyMissingMetadata(t *testing.T) {
889906 old := core.NewAgent(nil, "old-model", "", nil)
890907 old.SetMessages([]provider.Message{{
@@ -987,7 +1004,7 @@ func TestPrepareSessionResumeHonorsExplicitProviderModelFields(t *testing.T) {
9871004 old := core.NewAgent(nil, "old-model", "", nil)
9881005 path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{})
9891006 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) {
9911008 gotProvider, gotModel = providerName, model
9921009 return core.NewAgent(nil, model, "", nil), providerName, model, nil
9931010 })
@@ -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 string1006 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, model1008 gotProvider, gotModel = providerName, model
992 return core.NewAgent(nil, model, "", nil), providerName, model, nil1009 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 {
277277 // from ZutHome for Zutfile agents, whose sessions are isolated by agent
278278 // name. Empty falls back to ZutHome for embedders and tests.
279279 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
280283
281284 // Version is the binary's current version (from main.version).
282285 // Used only for display; the update check itself is done outside
@@ -296,6 +299,9 @@ type InteractiveConfig struct {
296299 // LoadSession swaps the current session for the one at path. The
297300 // callback returns the new agent message slice so the TUI can invalidate.
298301 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
299305
300306 // ChangeCWD switches the running zut session's working directory
301307 // to path. The host closes the current session, rebuilds the
@@ -895,6 +901,9 @@ type Interactive struct {
895901 // picker owns the load lifecycle; the interactive loop applies results so
896902 // rendering never races with background file reads.
897903 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
898907
899908 // sessionTreeLoads delivers asynchronously-read tree rows. Like the flat
900909 // 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 {
13651374 i.sessionDialog.ApplyLoad(event)
13661375 i.mu.Unlock()
13671376 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()
13681387 case event, ok := <-i.sessionTreeLoads:
13691388 i.mu.Lock()
13701389 if !ok {
@@ -277,6 +277,9 @@ type InteractiveConfig struct {
277 // from ZutHome for Zutfile agents, whose sessions are isolated by agent277 // 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 string279 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 outside285 // 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. The299 // 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) error301 LoadSession func(path string) error
302+ // LoadSessionContext is the cancellation-aware picker path. Embedders that
303+ // only implement LoadSession retain the existing synchronous contract.
304+ LoadSessionContext func(context.Context, string) error
299 305
300 // ChangeCWD switches the running zut session's working directory306 // ChangeCWD switches the running zut session's working directory
301 // to path. The host closes the current session, rebuilds the307 // 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 so901 // 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 sessionLoadEvent903 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 flat908 // 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) {
310310 case "/settings":
311311 i.openSettingsDialog()
312312 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+ }
313320 i.mu.Lock()
314321 i.sessionLoads = i.sessionDialog.Open(ctx, i.sessionsRoot(), i.cfg.CWD)
322+ i.sessionSearches = nil
315323 i.mu.Unlock()
316324 case "/fork":
317325 i.doSessionFork()
@@ -978,6 +986,19 @@ func totalTurnsLocked(msgs []provider.Message) int {
978986 }
979987 return n
980988 }
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+
9811002 func (i *Interactive) applySessionSelection(path string) {
9821003 if i.cfg.LoadSession == nil {
9831004 i.mu.Lock()
@@ -1000,58 +1021,73 @@ func (i *Interactive) applySessionSelection(path string) {
10001021 i.markSessionTitleSwitching()
10011022
10021023 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)
10121032 return
10131033 }
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()
10161042 i.mu.Lock()
1017- i.compactContinuation = state
10181043 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 = ""
10481046 i.mu.Unlock()
10491047 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
10521074 }
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+ }
10541089 }
1090+
10551091 func (i *Interactive) applyModelSelection(prov, model string) {
10561092 i.swapModel(prov, model, i.cfg.BuildAgentFor, false)
10571093 }
@@ -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 n987 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 = false1028+ 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 return1032 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 = false1043 i.sessionLoading = false
1019- i.statusOK = "resumed session: " + path1044+ 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
126126 i.mu.Unlock()
127127 i.invalidate()
128128 }
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+
129157 func (i *Interactive) SetSubagentSessionScope(_ string) {
130158 i.invalidate()
131159 }
@@ -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 @@
11 package modes
22
33 import (
4+ "os"
5+ "path/filepath"
46 "testing"
57
68 "github.com/bnema/zut/packages/agent/internal/orchestration"
@@ -9,6 +11,38 @@ import (
911 "github.com/bnema/zut/packages/provider"
1012 )
1113
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+
1246 func TestSubmitOrQueuePreservesImagesInHostQueue(t *testing.T) {
1347 i := &Interactive{agent: &core.Agent{}, busy: true, compacting: true}
1448 image := provider.ImageBlock{MimeType: "image/png", Data: []byte("png-1")}
@@ -1,6 +1,8 @@
1 package modes1 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) {
201201 i.mu.Lock()
202202 i.sessionDialog.Close()
203203 i.sessionLoads = nil
204+ i.sessionSearches = nil
204205 i.mu.Unlock()
205206 i.invalidate()
206207 return false
207208 }
208209 manualRenamePath := ""
210+ renameActive := false
211+ hasSelection := false
209212 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 {
211216 manualRenamePath = i.sessionDialog.sessions[i.sessionDialog.cursor].Path
212217 }
213218 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+ }
214227 manualRenameCurrent := manualRenamePath != "" && i.cfg.CurrentSessionPath() == manualRenamePath
215228 if manualRenameCurrent {
216229 i.markSessionTitleSwitching()
@@ -219,6 +232,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
219232 act := i.sessionDialog.HandleKey(k)
220233 if act.Select || act.Close {
221234 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)
222243 }
223244 i.mu.Unlock()
224245 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 = nil203 i.sessionLoads = nil
204+ i.sessionSearches = nil
204 i.mu.Unlock()205 i.mu.Unlock()
205 i.invalidate()206 i.invalidate()
206 return false207 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].Path216 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() == manualRenamePath227 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 = nil234 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) {
4646 }
4747 }
4848
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+
4960 func TestSessionsRootDefaultsToZutHome(t *testing.T) {
5061 i := &Interactive{cfg: InteractiveConfig{ZutHome: "/zut/home"}}
5162 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 (
66 "strings"
77 "sync"
88 "time"
9+ "unicode/utf8"
910
1011 "github.com/bnema/zut/packages/core"
1112 "github.com/bnema/zut/packages/tui"
13+ "github.com/sahilm/fuzzy"
1214 )
1315
1416 type sessionLoadEventKind uint8
@@ -19,10 +21,34 @@ const (
1921 sessionLoadFinished
2022 )
2123
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+
2247 type sessionLoadEvent struct {
2348 kind sessionLoadEventKind
2449 generation uint64
2550 total int
51+ paths []string
2652 index int
2753 summary core.SessionSummary
2854 }
@@ -41,11 +67,29 @@ const maxSessionLoadWorkers = 8
4167
4268 // sessionDialog is the inline picker shown when the user runs /sessions.
4369 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
4993
5094 loading bool
5195 loadingDone int
@@ -86,6 +130,8 @@ type sessionDialogAction struct {
86130 Close bool
87131 Renamed bool
88132 RenameTitle string
133+ ToggleScope bool
134+ StartSearch bool
89135 Err error
90136 }
91137
@@ -94,10 +140,18 @@ func newSessionDialog() *sessionDialog { return &sessionDialog{} }
94140 // Open shows the dialog immediately and loads session entries on bounded
95141 // background workers. Results are delivered to the caller so the UI goroutine
96142 // 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 {
98144 if d.loadCancel != nil {
99145 d.loadCancel()
100146 }
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+ }
101155 previousDone := d.loadDone
102156 if parent == nil {
103157 parent = context.Background()
@@ -109,10 +163,23 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan se
109163 done := make(chan struct{})
110164 d.loadDone = done
111165 d.sessions = nil
166+ d.baseSessions = nil
112167 d.cursor = 0
113168 d.viewTop = 0
114169 d.renaming = false
115170 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
116183 d.loading = true
117184 d.loadingDone = 0
118185 d.loadingTotal = 0
@@ -157,11 +224,12 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan se
157224 return
158225 default:
159226 }
160- paths := core.ListSessionsContext(ctx, root, cwd)
227+ paths := core.ListSessionPathsContext(ctx, root, cwd, len(allScope) > 0 && allScope[0])
161228 if !send(sessionLoadEvent{
162229 kind: sessionLoadStarted,
163230 generation: generation,
164231 total: len(paths),
232+ paths: paths,
165233 }) {
166234 return
167235 }
@@ -230,9 +298,15 @@ func (d *sessionDialog) ApplyLoad(event sessionLoadEvent) {
230298 case sessionLoadStarted:
231299 d.loadingTotal = event.total
232300 d.loadingDone = 0
301+ d.paths = append([]string(nil), event.paths...)
302+ if d.searchPending {
303+ d.searchPending = false
304+ d.startSearchReader()
305+ }
233306 d.loadSlots = make([]sessionLoadSlot, event.total)
234307 d.loadReadyThrough = 0
235308 d.sessions = nil
309+ d.baseSessions = nil
236310 case sessionLoadEntry:
237311 if event.index < 0 || event.index >= len(d.loadSlots) || d.loadSlots[event.index].loaded {
238312 return
@@ -296,7 +370,10 @@ func (d *sessionDialog) appendLoadedSessions(start, end int) {
296370 if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 {
297371 continue
298372 }
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+ }
300377 }
301378 }
302379
@@ -311,7 +388,12 @@ func (d *sessionDialog) rebuildLoadedSessions(limit int) {
311388 }
312389 filtered = append(filtered, slot.summary)
313390 }
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+ }
315397 if d.cursor >= len(d.sessions) {
316398 d.cursor = len(d.sessions) - 1
317399 }
@@ -337,12 +419,185 @@ func (d *sessionDialog) Close() {
337419 d.loadCancel()
338420 d.loadCancel = nil
339421 }
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+ }
340430 d.loading = false
431+ d.searching = false
341432 d.loadSlots = nil
342433 d.loadReadyThrough = 0
434+ d.searchEvents = nil
435+ d.searchSegments = nil
436+ d.searchMatches = nil
343437 d.active = false
344438 }
345439
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+
346601 // Active reports whether the dialog is visible and consumes input.
347602 func (d *sessionDialog) Active() bool { return d != nil && d.active }
348603
@@ -355,7 +610,11 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
355610 return nil
356611 }
357612 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))
359618 if d.loading {
360619 lines = append(lines, th.FGColor(th.Muted, d.loadingMessage(th)))
361620 if len(d.sessions) == 0 {
@@ -364,8 +623,15 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
364623 return lines
365624 }
366625 } 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"))
369635 lines = append(lines, frameRule(th, width))
370636 return lines
371637 }
@@ -381,11 +647,20 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
381647 lines = append(lines, frameRule(th, width))
382648 return lines
383649 }
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+ }
385657 if d.loading {
386- hint = "pick a session (↑/↓, pgup/pgdn, enter resume, esc cancel; more loading)"
658+ hint += " · loading"
387659 }
388660 lines = append(lines, th.FGColor(th.Muted, hint))
661+ if d.searchErr != "" {
662+ lines = append(lines, th.FGColor(th.Warning, d.searchErr))
663+ }
389664
390665 // Viewport: windowed slice of d.sessions around d.cursor so a
391666 // list taller than the terminal still scrolls. Caller sets
@@ -410,7 +685,8 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
410685 }
411686 for i := d.viewTop; i < viewBot; i++ {
412687 s := d.sessions[i]
413- plain := " " + formatSessionRowPlain(s, width-2)
688+ match := d.searchMatches[s.Path]
689+ plain := " " + formatSessionSearchRowPlain(s, width-2, match.count)
414690 if i == d.cursor {
415691 lines = append(lines, th.PadHighlight(plain, width))
416692 } else {
@@ -422,6 +698,15 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
422698 hidden := total - viewBot
423699 lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ↓ %d more below", hidden)))
424700 }
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+ }
425710 lines = append(lines, frameRule(th, width))
426711 return lines
427712 }
@@ -462,6 +747,96 @@ func clampViewTop(viewTop, cursor, window, total int) int {
462747 // full-row selection highlight. The returned string is guaranteed to
463748 // fit within maxWidth visible characters so the terminal never soft-
464749 // 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+
465840 func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string {
466841 when := formatRelative(s.Started)
467842 summary := strings.TrimSpace(s.Title)
@@ -556,6 +931,12 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
556931 renameErr = err
557932 } else {
558933 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+ }
559940 renamed = true
560941 }
561942 }
@@ -584,6 +965,50 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
584965 return sessionDialogAction{}
585966 }
586967
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+
5871012 if d.loading && len(d.sessions) == 0 {
5881013 if k.Kind == tui.KeyEsc {
5891014 d.Close()
@@ -592,6 +1017,7 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
5921017 return sessionDialogAction{}
5931018 }
5941019
1020+navigate:
5951021 page := d.MaxRows
5961022 if page <= 0 {
5971023 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 uint816 type sessionLoadEventKind uint8
@@ -19,10 +21,34 @@ const (
19 sessionLoadFinished21 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 sessionLoadEventKind48 kind sessionLoadEventKind
24 generation uint6449 generation uint64
25 total int50 total int
51+ paths []string
26 index int52 index int
27 summary core.SessionSummary53 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 bool70+ active bool
45- sessions []core.SessionSummary71+ sessions []core.SessionSummary
46- cursor int72+ baseSessions []core.SessionSummary
47- renaming bool73+ cursor int
48- rename string74+ 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 bool94 loading bool
51 loadingDone int95 loadingDone int
@@ -86,6 +130,8 @@ type sessionDialogAction struct {
86 Close bool130 Close bool
87 Renamed bool131 Renamed bool
88 RenameTitle string132 RenameTitle string
133+ ToggleScope bool
134+ StartSearch bool
89 Err error135 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 bounded140 // Open shows the dialog immediately and loads session entries on bounded
95 // background workers. Results are delivered to the caller so the UI goroutine141 // 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.loadDone155 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 = done164 d.loadDone = done
111 d.sessions = nil165 d.sessions = nil
166+ d.baseSessions = nil
112 d.cursor = 0167 d.cursor = 0
113 d.viewTop = 0168 d.viewTop = 0
114 d.renaming = false169 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 = true183 d.loading = true
117 d.loadingDone = 0184 d.loadingDone = 0
118 d.loadingTotal = 0185 d.loadingTotal = 0
@@ -157,11 +224,12 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan se
157 return224 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 return234 return
167 }235 }
@@ -230,9 +298,15 @@ func (d *sessionDialog) ApplyLoad(event sessionLoadEvent) {
230 case sessionLoadStarted:298 case sessionLoadStarted:
231 d.loadingTotal = event.total299 d.loadingTotal = event.total
232 d.loadingDone = 0300 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 = 0307 d.loadReadyThrough = 0
235 d.sessions = nil308 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 return312 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 continue371 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 = filtered391+ 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) - 1398 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 = nil420 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 = false430 d.loading = false
431+ d.searching = false
341 d.loadSlots = nil432 d.loadSlots = nil
342 d.loadReadyThrough = 0433 d.loadReadyThrough = 0
434+ d.searchEvents = nil
435+ d.searchSegments = nil
436+ d.searchMatches = nil
343 d.active = false437 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 nil610 return nil
356 }611 }
357 var lines []string612 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 lines623 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 lines636 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 lines648 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 a665 // Viewport: windowed slice of d.sessions around d.cursor so a
391 // list taller than the terminal still scrolls. Caller sets666 // 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 - viewBot698 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 lines711 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 to747 // 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 = err931 renameErr = err
557 } else {932 } else {
558 d.sessions[d.cursor].Title = title933 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 = true940 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.MaxRows1021 page := d.MaxRows
596 if page <= 0 {1022 if page <= 0 {
597 page = 101023 page = 10
M packages/agent/modes/session_dialog_test.go +57 -0
@@ -362,6 +362,63 @@ func TestSessionDialogCanSelectLoadedEntryWhileLoading(t *testing.T) {
362362 }
363363 }
364364
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+
365422 func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) {
366423 first := newSessionDialog()
367424 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 {
955955 // SessionSummary describes one on-disk session at a glance for UI pickers.
956956 type SessionSummary struct {
957957 Path string
958+ CWD string
958959 Started time.Time
959960 Model string
960961 Provider string
@@ -1065,6 +1066,7 @@ func describeSessionContext(ctx context.Context, path string) SessionSummary {
10651066 if err != nil {
10661067 return s
10671068 }
1069+ s.CWD = snapshot.Meta.CWD
10681070 s.Started = snapshot.Meta.Started
10691071 s.Model = snapshot.Meta.Model
10701072 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 string957 Path string
958+ CWD string
958 Started time.Time959 Started time.Time
959 Model string960 Model string
960 Provider string961 Provider string
@@ -1065,6 +1066,7 @@ func describeSessionContext(ctx context.Context, path string) SessionSummary {
1065 if err != nil {1066 if err != nil {
1066 return s1067 return s
1067 }1068 }
1069+ s.CWD = snapshot.Meta.CWD
1068 s.Started = snapshot.Meta.Started1070 s.Started = snapshot.Meta.Started
1069 s.Model = snapshot.Meta.Model1071 s.Model = snapshot.Meta.Model
1070 s.Provider = snapshot.Meta.Provider1072 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+}

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close