detached 0c5f7c1

feat(sessions): add scoped fuzzy session picker

commit
0c5f7c1b715e0ece3048b4712d63d414edb9823a
author
brice · · 2026-08-19 05:19 UTC
parent
0b52359
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +1077 -25
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 +78 -6
@@ -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 }
@@ -2028,6 +2035,18 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
20282035 // loadSession replaces the current session with the one at path and
20292036 // hands its messages to the agent. Used by the /sessions picker.
20302037 loadSession := func(path string) (loadErr error) {
2038+ selectedMeta, err := core.ManagedSessionMeta(context.Background(), agentSessionsRoot(ZutHome(), args), path)
2039+ if err != nil {
2040+ return fmt.Errorf("validate selected session: %w", err)
2041+ }
2042+ if info, statErr := os.Stat(selectedMeta.CWD); statErr != nil || !info.IsDir() {
2043+ if statErr != nil {
2044+ return fmt.Errorf("selected session cwd: %w", statErr)
2045+ }
2046+ return fmt.Errorf("selected session cwd is not a directory: %s", selectedMeta.CWD)
2047+ }
2048+ workspaceChanged := filepath.Clean(selectedMeta.CWD) != filepath.Clean(args.CWD)
2049+
20312050 // Hold the transition lock from the pre-flush through the commit.
20322051 // Persistence callbacks take the read side, so an active session
20332052 // cannot be snapshotted before its lazy writes land or overwritten by
@@ -2064,15 +2083,63 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
20642083 // provider settings on the shared runtime, so restore those settings on
20652084 // every failed path, including errors returned during preparation.
20662085 runtimeConfig := runtime.snapshotConfiguration()
2086+ oldCWD := args.CWD
2087+ oldResolvedCWD := r.CWD
2088+ oldPermissionSet := args.PermissionSet
2089+ oldSandboxRoot := ""
2090+ oldSandboxLocked := false
2091+ if sharedSandbox != nil {
2092+ oldSandboxRoot = sharedSandbox.Root
2093+ oldSandboxLocked = sharedSandbox.Locked()
2094+ }
2095+ rollbackWorkspace := func() {
2096+ if !workspaceChanged {
2097+ return
2098+ }
2099+ args.CWD = oldCWD
2100+ r.CWD = oldResolvedCWD
2101+ args.PermissionSet = oldPermissionSet
2102+ runtime.SetRepoRoot(oldResolvedCWD)
2103+ if sharedSandbox != nil {
2104+ sharedSandbox.Root = oldSandboxRoot
2105+ if oldSandboxLocked {
2106+ sharedSandbox.Lock()
2107+ } else {
2108+ sharedSandbox.Unlock()
2109+ }
2110+ }
2111+ }
2112+ if workspaceChanged {
2113+ args.CWD = selectedMeta.CWD
2114+ r.CWD = selectedMeta.CWD
2115+ runtime.SetRepoRoot(selectedMeta.CWD)
2116+ if args.PermissionSet != nil {
2117+ expanded := args.PermissionSet.Expand(selectedMeta.CWD, args.AgentDataDir)
2118+ args.PermissionSet = &expanded
2119+ }
2120+ if sharedSandbox != nil {
2121+ sharedSandbox.Root = selectedMeta.CWD
2122+ if oldSandboxLocked {
2123+ sharedSandbox.Lock()
2124+ } else {
2125+ sharedSandbox.Unlock()
2126+ }
2127+ }
2128+ }
20672129 committed := false
20682130 var candidate sessionResumeCandidate
20692131 defer func() {
20702132 if !committed {
2133+ rollbackWorkspace()
20712134 runtime.restoreConfiguration(runtimeConfig)
20722135 loadErr = joinSessionCloseError(loadErr, candidate.session)
20732136 }
20742137 }()
2075- candidate, err := prepareSessionResume(path, currentAg, currentProvider, currentModel, baseBuildAgentFor)
2138+ if workspaceChanged {
2139+ candidate, err = prepareSessionResumeForWorkspace(path, currentAg, currentProvider, currentModel, baseBuildAgentFor)
2140+ } else {
2141+ candidate, err = prepareSessionResume(path, currentAg, currentProvider, currentModel, baseBuildAgentFor)
2142+ }
20762143 if err != nil {
20772144 // prepareSessionResume closes its append handle on failure;
20782145 // no live agent or session has been changed.
@@ -2118,7 +2185,11 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
21182185 // writer, so no new message can land on the old file between the
21192186 // two state changes.
21202187 if iv != nil {
2121- iv.ApplySessionAgentWithCompactHandoff(candidate.agent, candidate.provider, candidate.model, candidate.session.Meta.CompactHandoff)
2188+ if workspaceChanged {
2189+ iv.ApplySessionAgentWithCWD(candidate.agent, candidate.provider, candidate.model, selectedMeta.CWD, candidate.session.Meta.CompactHandoff)
2190+ } else {
2191+ iv.ApplySessionAgentWithCompactHandoff(candidate.agent, candidate.provider, candidate.model, candidate.session.Meta.CompactHandoff)
2192+ }
21222193 }
21232194 ag = candidate.agent
21242195 } else {
@@ -2496,6 +2567,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
24962567 ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
24972568 ZutHome: ZutHome(),
24982569 SessionsRoot: agentSessionsRoot(ZutHome(), args),
2570+ SessionsDisabled: args.NoSess,
24992571 Version: version,
25002572 UpdateInfoChan: updateCh,
25012573 Sandbox: sharedSandbox,
@@ -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 }
@@ -2028,6 +2035,18 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2028 // loadSession replaces the current session with the one at path and2035 // loadSession replaces the current session with the one at path and
2029 // hands its messages to the agent. Used by the /sessions picker.2036 // hands its messages to the agent. Used by the /sessions picker.
2030 loadSession := func(path string) (loadErr error) {2037 loadSession := func(path string) (loadErr error) {
2038+ selectedMeta, err := core.ManagedSessionMeta(context.Background(), agentSessionsRoot(ZutHome(), args), path)
2039+ if err != nil {
2040+ return fmt.Errorf("validate selected session: %w", err)
2041+ }
2042+ if info, statErr := os.Stat(selectedMeta.CWD); statErr != nil || !info.IsDir() {
2043+ if statErr != nil {
2044+ return fmt.Errorf("selected session cwd: %w", statErr)
2045+ }
2046+ return fmt.Errorf("selected session cwd is not a directory: %s", selectedMeta.CWD)
2047+ }
2048+ workspaceChanged := filepath.Clean(selectedMeta.CWD) != filepath.Clean(args.CWD)
2049+
2031 // Hold the transition lock from the pre-flush through the commit.2050 // Hold the transition lock from the pre-flush through the commit.
2032 // Persistence callbacks take the read side, so an active session2051 // Persistence callbacks take the read side, so an active session
2033 // cannot be snapshotted before its lazy writes land or overwritten by2052 // cannot be snapshotted before its lazy writes land or overwritten by
@@ -2064,15 +2083,63 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2064 // provider settings on the shared runtime, so restore those settings on2083 // provider settings on the shared runtime, so restore those settings on
2065 // every failed path, including errors returned during preparation.2084 // every failed path, including errors returned during preparation.
2066 runtimeConfig := runtime.snapshotConfiguration()2085 runtimeConfig := runtime.snapshotConfiguration()
2086+ oldCWD := args.CWD
2087+ oldResolvedCWD := r.CWD
2088+ oldPermissionSet := args.PermissionSet
2089+ oldSandboxRoot := ""
2090+ oldSandboxLocked := false
2091+ if sharedSandbox != nil {
2092+ oldSandboxRoot = sharedSandbox.Root
2093+ oldSandboxLocked = sharedSandbox.Locked()
2094+ }
2095+ rollbackWorkspace := func() {
2096+ if !workspaceChanged {
2097+ return
2098+ }
2099+ args.CWD = oldCWD
2100+ r.CWD = oldResolvedCWD
2101+ args.PermissionSet = oldPermissionSet
2102+ runtime.SetRepoRoot(oldResolvedCWD)
2103+ if sharedSandbox != nil {
2104+ sharedSandbox.Root = oldSandboxRoot
2105+ if oldSandboxLocked {
2106+ sharedSandbox.Lock()
2107+ } else {
2108+ sharedSandbox.Unlock()
2109+ }
2110+ }
2111+ }
2112+ if workspaceChanged {
2113+ args.CWD = selectedMeta.CWD
2114+ r.CWD = selectedMeta.CWD
2115+ runtime.SetRepoRoot(selectedMeta.CWD)
2116+ if args.PermissionSet != nil {
2117+ expanded := args.PermissionSet.Expand(selectedMeta.CWD, args.AgentDataDir)
2118+ args.PermissionSet = &expanded
2119+ }
2120+ if sharedSandbox != nil {
2121+ sharedSandbox.Root = selectedMeta.CWD
2122+ if oldSandboxLocked {
2123+ sharedSandbox.Lock()
2124+ } else {
2125+ sharedSandbox.Unlock()
2126+ }
2127+ }
2128+ }
2067 committed := false2129 committed := false
2068 var candidate sessionResumeCandidate2130 var candidate sessionResumeCandidate
2069 defer func() {2131 defer func() {
2070 if !committed {2132 if !committed {
2133+ rollbackWorkspace()
2071 runtime.restoreConfiguration(runtimeConfig)2134 runtime.restoreConfiguration(runtimeConfig)
2072 loadErr = joinSessionCloseError(loadErr, candidate.session)2135 loadErr = joinSessionCloseError(loadErr, candidate.session)
2073 }2136 }
2074 }()2137 }()
2075- candidate, err := prepareSessionResume(path, currentAg, currentProvider, currentModel, baseBuildAgentFor)2138+ if workspaceChanged {
2139+ candidate, err = prepareSessionResumeForWorkspace(path, currentAg, currentProvider, currentModel, baseBuildAgentFor)
2140+ } else {
2141+ candidate, err = prepareSessionResume(path, currentAg, currentProvider, currentModel, baseBuildAgentFor)
2142+ }
2076 if err != nil {2143 if err != nil {
2077 // prepareSessionResume closes its append handle on failure;2144 // prepareSessionResume closes its append handle on failure;
2078 // no live agent or session has been changed.2145 // no live agent or session has been changed.
@@ -2118,7 +2185,11 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2118 // writer, so no new message can land on the old file between the2185 // writer, so no new message can land on the old file between the
2119 // two state changes.2186 // two state changes.
2120 if iv != nil {2187 if iv != nil {
2121- iv.ApplySessionAgentWithCompactHandoff(candidate.agent, candidate.provider, candidate.model, candidate.session.Meta.CompactHandoff)2188+ if workspaceChanged {
2189+ iv.ApplySessionAgentWithCWD(candidate.agent, candidate.provider, candidate.model, selectedMeta.CWD, candidate.session.Meta.CompactHandoff)
2190+ } else {
2191+ iv.ApplySessionAgentWithCompactHandoff(candidate.agent, candidate.provider, candidate.model, candidate.session.Meta.CompactHandoff)
2192+ }
2122 }2193 }
2123 ag = candidate.agent2194 ag = candidate.agent
2124 } else {2195 } else {
@@ -2496,6 +2567,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2496 ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,2567 ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
2497 ZutHome: ZutHome(),2568 ZutHome: ZutHome(),
2498 SessionsRoot: agentSessionsRoot(ZutHome(), args),2569 SessionsRoot: agentSessionsRoot(ZutHome(), args),
2570+ SessionsDisabled: args.NoSess,
2499 Version: version,2571 Version: version,
2500 UpdateInfoChan: updateCh,2572 UpdateInfoChan: updateCh,
2501 Sandbox: sharedSandbox,2573 Sandbox: sharedSandbox,
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 +16 -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
@@ -895,6 +898,9 @@ type Interactive struct {
895898 // picker owns the load lifecycle; the interactive loop applies results so
896899 // rendering never races with background file reads.
897900 sessionLoads <-chan sessionLoadEvent
901+ // sessionSearches carries the one-time corpus read and query-match results.
902+ // It stays separate from summary loading so a query edit never reopens JSONL.
903+ sessionSearches <-chan sessionSearchEvent
898904
899905 // sessionTreeLoads delivers asynchronously-read tree rows. Like the flat
900906 // picker, the main loop is the sole owner of dialog mutation and rendering.
@@ -1365,6 +1371,16 @@ func (i *Interactive) Run(ctx context.Context) error {
13651371 i.sessionDialog.ApplyLoad(event)
13661372 i.mu.Unlock()
13671373 i.invalidate()
1374+ case event, ok := <-i.sessionSearches:
1375+ i.mu.Lock()
1376+ if !ok {
1377+ i.sessionSearches = nil
1378+ i.mu.Unlock()
1379+ break
1380+ }
1381+ i.sessionDialog.ApplySearch(event)
1382+ i.mu.Unlock()
1383+ i.invalidate()
13681384 case event, ok := <-i.sessionTreeLoads:
13691385 i.mu.Lock()
13701386 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
@@ -895,6 +898,9 @@ type Interactive struct {
895 // picker owns the load lifecycle; the interactive loop applies results so898 // picker owns the load lifecycle; the interactive loop applies results so
896 // rendering never races with background file reads.899 // rendering never races with background file reads.
897 sessionLoads <-chan sessionLoadEvent900 sessionLoads <-chan sessionLoadEvent
901+ // sessionSearches carries the one-time corpus read and query-match results.
902+ // It stays separate from summary loading so a query edit never reopens JSONL.
903+ sessionSearches <-chan sessionSearchEvent
898 904
899 // sessionTreeLoads delivers asynchronously-read tree rows. Like the flat905 // sessionTreeLoads delivers asynchronously-read tree rows. Like the flat
900 // picker, the main loop is the sole owner of dialog mutation and rendering.906 // picker, the main loop is the sole owner of dialog mutation and rendering.
@@ -1365,6 +1371,16 @@ func (i *Interactive) Run(ctx context.Context) error {
1365 i.sessionDialog.ApplyLoad(event)1371 i.sessionDialog.ApplyLoad(event)
1366 i.mu.Unlock()1372 i.mu.Unlock()
1367 i.invalidate()1373 i.invalidate()
1374+ case event, ok := <-i.sessionSearches:
1375+ i.mu.Lock()
1376+ if !ok {
1377+ i.sessionSearches = nil
1378+ i.mu.Unlock()
1379+ break
1380+ }
1381+ i.sessionDialog.ApplySearch(event)
1382+ i.mu.Unlock()
1383+ i.invalidate()
1368 case event, ok := <-i.sessionTreeLoads:1384 case event, ok := <-i.sessionTreeLoads:
1369 i.mu.Lock()1385 i.mu.Lock()
1370 if !ok {1386 if !ok {
M packages/agent/modes/interactive_commands.go +21 -0
@@ -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()
@@ -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()
M packages/agent/modes/interactive_host.go +26 -0
@@ -126,6 +126,32 @@ 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+ if i.cfg.Terminal != nil {
145+ if seq := tui.ReportCWD(cwd); seq != "" {
146+ _, _ = i.cfg.Terminal.Write([]byte(seq))
147+ }
148+ }
149+ i.mu.Unlock()
150+ i.fileSuggest.Reset()
151+ i.fileSuggest.SetCWD(cwd)
152+ i.invalidate()
153+}
154+
129155 func (i *Interactive) SetSubagentSessionScope(_ string) {
130156 i.invalidate()
131157 }
@@ -126,6 +126,32 @@ 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+ if i.cfg.Terminal != nil {
145+ if seq := tui.ReportCWD(cwd); seq != "" {
146+ _, _ = i.cfg.Terminal.Write([]byte(seq))
147+ }
148+ }
149+ i.mu.Unlock()
150+ i.fileSuggest.Reset()
151+ i.fileSuggest.SetCWD(cwd)
152+ i.invalidate()
153+}
154+
129 func (i *Interactive) SetSubagentSessionScope(_ string) {155 func (i *Interactive) SetSubagentSessionScope(_ string) {
130 i.invalidate()156 i.invalidate()
131 }157 }
M packages/agent/modes/interactive_host_test.go +30 -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,34 @@ 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})
24+ i.ApplySessionAgentWithCWD(selected, "provider", "model", newCWD, nil)
25+ if i.cfg.CWD != newCWD || i.agent != selected {
26+ t.Fatalf("interactive workspace = %q agent=%p, want cwd=%q agent=%p", i.cfg.CWD, i.agent, newCWD, selected)
27+ }
28+ text, _ := i.view.Messages[0].Content[0].(provider.TextBlock)
29+ if text.Text != "selected" {
30+ t.Fatalf("resumed view text = %q", text.Text)
31+ }
32+}
33+
34+func TestCanResumeSessionSelectionRejectsBusyTurn(t *testing.T) {
35+ i := NewInteractive(InteractiveConfig{Agent: core.NewAgent(nil, "model", "", nil), LoadSession: func(string) error { return nil }})
36+ i.busy = true
37+ if i.canResumeSessionSelection() {
38+ t.Fatal("busy turn allowed session selection")
39+ }
40+}
41+
1242 func TestSubmitOrQueuePreservesImagesInHostQueue(t *testing.T) {
1343 i := &Interactive{agent: &core.Agent{}, busy: true, compacting: true}
1444 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,34 @@ 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})
24+ i.ApplySessionAgentWithCWD(selected, "provider", "model", newCWD, nil)
25+ if i.cfg.CWD != newCWD || i.agent != selected {
26+ t.Fatalf("interactive workspace = %q agent=%p, want cwd=%q agent=%p", i.cfg.CWD, i.agent, newCWD, selected)
27+ }
28+ text, _ := i.view.Messages[0].Content[0].(provider.TextBlock)
29+ if text.Text != "selected" {
30+ t.Fatalf("resumed view text = %q", text.Text)
31+ }
32+}
33+
34+func TestCanResumeSessionSelectionRejectsBusyTurn(t *testing.T) {
35+ i := NewInteractive(InteractiveConfig{Agent: core.NewAgent(nil, "model", "", nil), LoadSession: func(string) error { return nil }})
36+ i.busy = true
37+ if i.canResumeSessionSelection() {
38+ t.Fatal("busy turn allowed session selection")
39+ }
40+}
41+
12 func TestSubmitOrQueuePreservesImagesInHostQueue(t *testing.T) {42 func TestSubmitOrQueuePreservesImagesInHostQueue(t *testing.T) {
13 i := &Interactive{agent: &core.Agent{}, busy: true, compacting: true}43 i := &Interactive{agent: &core.Agent{}, busy: true, compacting: true}
14 image := provider.ImageBlock{MimeType: "image/png", Data: []byte("png-1")}44 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 +336 -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,26 @@ 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+ searchReady bool
84+ searchGeneration uint64
85+ searchCancel context.CancelFunc
86+ searchEvents chan sessionSearchEvent
87+ searchSegments []core.SessionSearchSegment
88+ searchMatches map[string]sessionSearchMatch
89+ searchErr string
4990
5091 loading bool
5192 loadingDone int
@@ -86,6 +127,8 @@ type sessionDialogAction struct {
86127 Close bool
87128 Renamed bool
88129 RenameTitle string
130+ ToggleScope bool
131+ StartSearch bool
89132 Err error
90133 }
91134
@@ -94,10 +137,14 @@ func newSessionDialog() *sessionDialog { return &sessionDialog{} }
94137 // Open shows the dialog immediately and loads session entries on bounded
95138 // background workers. Results are delivered to the caller so the UI goroutine
96139 // remains responsible for mutating dialog state and rendering it.
97-func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan sessionLoadEvent {
140+func (d *sessionDialog) Open(parent context.Context, root, cwd string, allScope ...bool) <-chan sessionLoadEvent {
98141 if d.loadCancel != nil {
99142 d.loadCancel()
100143 }
144+ if d.searchCancel != nil {
145+ d.searchCancel()
146+ d.searchCancel = nil
147+ }
101148 previousDone := d.loadDone
102149 if parent == nil {
103150 parent = context.Background()
@@ -109,10 +156,22 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan se
109156 done := make(chan struct{})
110157 d.loadDone = done
111158 d.sessions = nil
159+ d.baseSessions = nil
112160 d.cursor = 0
113161 d.viewTop = 0
114162 d.renaming = false
115163 d.rename = ""
164+ d.root = root
165+ d.cwd = cwd
166+ d.allScope = len(allScope) > 0 && allScope[0]
167+ d.paths = nil
168+ d.query = ""
169+ d.searching = false
170+ d.searchReady = false
171+ d.searchSegments = nil
172+ d.searchMatches = nil
173+ d.searchErr = ""
174+ d.searchEvents = nil
116175 d.loading = true
117176 d.loadingDone = 0
118177 d.loadingTotal = 0
@@ -157,11 +216,12 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan se
157216 return
158217 default:
159218 }
160- paths := core.ListSessionsContext(ctx, root, cwd)
219+ paths := core.ListSessionPathsContext(ctx, root, cwd, len(allScope) > 0 && allScope[0])
161220 if !send(sessionLoadEvent{
162221 kind: sessionLoadStarted,
163222 generation: generation,
164223 total: len(paths),
224+ paths: paths,
165225 }) {
166226 return
167227 }
@@ -230,9 +290,11 @@ func (d *sessionDialog) ApplyLoad(event sessionLoadEvent) {
230290 case sessionLoadStarted:
231291 d.loadingTotal = event.total
232292 d.loadingDone = 0
293+ d.paths = append([]string(nil), event.paths...)
233294 d.loadSlots = make([]sessionLoadSlot, event.total)
234295 d.loadReadyThrough = 0
235296 d.sessions = nil
297+ d.baseSessions = nil
236298 case sessionLoadEntry:
237299 if event.index < 0 || event.index >= len(d.loadSlots) || d.loadSlots[event.index].loaded {
238300 return
@@ -296,7 +358,10 @@ func (d *sessionDialog) appendLoadedSessions(start, end int) {
296358 if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 {
297359 continue
298360 }
299- d.sessions = append(d.sessions, slot.summary)
361+ d.baseSessions = append(d.baseSessions, slot.summary)
362+ if d.query == "" {
363+ d.sessions = append(d.sessions, slot.summary)
364+ }
300365 }
301366 }
302367
@@ -311,7 +376,12 @@ func (d *sessionDialog) rebuildLoadedSessions(limit int) {
311376 }
312377 filtered = append(filtered, slot.summary)
313378 }
314- d.sessions = filtered
379+ d.baseSessions = filtered
380+ if d.query == "" {
381+ d.sessions = append([]core.SessionSummary(nil), filtered...)
382+ } else {
383+ d.applySearchFilter()
384+ }
315385 if d.cursor >= len(d.sessions) {
316386 d.cursor = len(d.sessions) - 1
317387 }
@@ -337,12 +407,145 @@ func (d *sessionDialog) Close() {
337407 d.loadCancel()
338408 d.loadCancel = nil
339409 }
410+ if d.searchCancel != nil {
411+ d.searchCancel()
412+ d.searchCancel = nil
413+ }
340414 d.loading = false
415+ d.searching = false
341416 d.loadSlots = nil
342417 d.loadReadyThrough = 0
418+ d.searchEvents = nil
343419 d.active = false
344420 }
345421
422+// StartSearch starts one background corpus read for this picker opening. Query
423+// edits reuse the retained corpus and never reopen a session file.
424+func (d *sessionDialog) StartSearch(parent context.Context) <-chan sessionSearchEvent {
425+ if d.searchEvents != nil {
426+ return d.searchEvents
427+ }
428+ if parent == nil {
429+ parent = context.Background()
430+ }
431+ ctx, cancel := context.WithCancel(parent)
432+ d.searchCancel = cancel
433+ d.searching = true
434+ d.searchGeneration++
435+ generation := d.searchGeneration
436+ events := make(chan sessionSearchEvent, 1)
437+ d.searchEvents = events
438+ paths := append([]string(nil), d.paths...)
439+ go func() {
440+ segments := make([]core.SessionSearchSegment, 0)
441+ skipped := 0
442+ for _, path := range paths {
443+ if ctx.Err() != nil {
444+ return
445+ }
446+ read, err := core.ReadSessionSearchSegments(ctx, path)
447+ if err != nil {
448+ if ctx.Err() != nil {
449+ return
450+ }
451+ skipped++
452+ continue
453+ }
454+ segments = append(segments, read...)
455+ }
456+ select {
457+ case events <- sessionSearchEvent{kind: sessionSearchCorpusReady, generation: generation, segments: segments, skipped: skipped}:
458+ case <-ctx.Done():
459+ }
460+ }()
461+ return events
462+}
463+
464+// ApplySearch incorporates corpus and match results on the UI goroutine.
465+func (d *sessionDialog) ApplySearch(event sessionSearchEvent) {
466+ if !d.active || event.generation != d.searchGeneration {
467+ return
468+ }
469+ switch event.kind {
470+ case sessionSearchCorpusReady:
471+ d.searching = false
472+ d.searchReady = true
473+ if event.skipped > 0 {
474+ d.searchErr = fmt.Sprintf("search skipped %d unreadable session(s)", event.skipped)
475+ }
476+ d.searchSegments = event.segments
477+ d.scheduleSearchMatch()
478+ case sessionSearchMatchesReady:
479+ if event.query != d.query {
480+ return
481+ }
482+ d.searchMatches = event.matches
483+ d.applySearchFilter()
484+ }
485+}
486+
487+func (d *sessionDialog) scheduleSearchMatch() {
488+ if !d.searchReady || d.query == "" || d.searchEvents == nil || d.searchCancel == nil {
489+ return
490+ }
491+ query := d.query
492+ segments := append([]core.SessionSearchSegment(nil), d.searchSegments...)
493+ generation := d.searchGeneration
494+ ctx := context.Background()
495+ go func() {
496+ matches := matchSessionSearchSegments(ctx, query, segments)
497+ select {
498+ case d.searchEvents <- sessionSearchEvent{kind: sessionSearchMatchesReady, generation: generation, query: query, matches: matches}:
499+ default:
500+ }
501+ }()
502+}
503+
504+func matchSessionSearchSegments(ctx context.Context, query string, segments []core.SessionSearchSegment) map[string]sessionSearchMatch {
505+ query = core.NormalizeSessionSearchText(query)
506+ if query == "" {
507+ return nil
508+ }
509+ texts := make([]string, len(segments))
510+ for index, segment := range segments {
511+ texts[index] = core.NormalizeSessionSearchText(segment.Text)
512+ }
513+ ranked := fuzzy.Find(query, texts)
514+ matches := make(map[string]sessionSearchMatch)
515+ for _, match := range ranked {
516+ if ctx.Err() != nil || match.Index < 0 || match.Index >= len(segments) {
517+ return matches
518+ }
519+ segment := segments[match.Index]
520+ current := matches[segment.Path]
521+ current.count++
522+ if current.excerpt == "" || match.Score > current.score {
523+ current.excerpt = segment.Text
524+ current.indexes = append([]int(nil), match.MatchedIndexes...)
525+ current.score = match.Score
526+ }
527+ matches[segment.Path] = current
528+ }
529+ return matches
530+}
531+
532+func (d *sessionDialog) applySearchFilter() {
533+ if d.query == "" {
534+ d.sessions = append(d.sessions[:0], d.baseSessions...)
535+ } else {
536+ filtered := make([]core.SessionSummary, 0, len(d.baseSessions))
537+ for _, summary := range d.baseSessions {
538+ if _, ok := d.searchMatches[summary.Path]; ok {
539+ filtered = append(filtered, summary)
540+ }
541+ }
542+ d.sessions = filtered
543+ }
544+ if d.cursor >= len(d.sessions) {
545+ d.cursor = max(0, len(d.sessions)-1)
546+ }
547+}
548+
346549 // Active reports whether the dialog is visible and consumes input.
347550 func (d *sessionDialog) Active() bool { return d != nil && d.active }
348551
@@ -355,7 +558,11 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
355558 return nil
356559 }
357560 var lines []string
358- lines = append(lines, frameHeader(th, "sessions", width))
561+ title := "sessions · this directory"
562+ if d.allScope {
563+ title = "sessions · all"
564+ }
565+ lines = append(lines, frameHeader(th, title, width))
359566 if d.loading {
360567 lines = append(lines, th.FGColor(th.Muted, d.loadingMessage(th)))
361568 if len(d.sessions) == 0 {
@@ -364,8 +571,15 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
364571 return lines
365572 }
366573 } 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"))
574+ message := "no previous sessions for this directory"
575+ if d.allScope {
576+ message = "no previous sessions in this namespace"
577+ }
578+ if d.query != "" && d.searchReady {
579+ message = "no matching sessions"
580+ }
581+ lines = append(lines, th.FGColor(th.Muted, message))
582+ lines = append(lines, th.FGColor(th.Muted, "tab changes scope · esc closes"))
369583 lines = append(lines, frameRule(th, width))
370584 return lines
371585 }
@@ -381,11 +595,20 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
381595 lines = append(lines, frameRule(th, width))
382596 return lines
383597 }
384- hint := "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)"
598+ hint := "↑/↓ pick · enter resume · / search · tab scope · r rename · esc cancel"
599+ if d.searchEvents != nil {
600+ hint = "search: " + d.query + " (esc clear · tab scope)"
601+ if d.searching {
602+ hint += " · indexing"
603+ }
604+ }
385605 if d.loading {
386- hint = "pick a session (↑/↓, pgup/pgdn, enter resume, esc cancel; more loading)"
606+ hint += " · loading"
387607 }
388608 lines = append(lines, th.FGColor(th.Muted, hint))
609+ if d.searchErr != "" {
610+ lines = append(lines, th.FGColor(th.Warning, d.searchErr))
611+ }
389612
390613 // Viewport: windowed slice of d.sessions around d.cursor so a
391614 // list taller than the terminal still scrolls. Caller sets
@@ -410,7 +633,8 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
410633 }
411634 for i := d.viewTop; i < viewBot; i++ {
412635 s := d.sessions[i]
413- plain := " " + formatSessionRowPlain(s, width-2)
636+ match := d.searchMatches[s.Path]
637+ plain := " " + formatSessionSearchRowPlain(s, width-2, match.count)
414638 if i == d.cursor {
415639 lines = append(lines, th.PadHighlight(plain, width))
416640 } else {
@@ -422,6 +646,14 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
422646 hidden := total - viewBot
423647 lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ↓ %d more below", hidden)))
424648 }
649+ if d.allScope && d.cursor >= 0 && d.cursor < len(d.sessions) && d.sessions[d.cursor].CWD != "" {
650+ lines = append(lines, th.FGColor(th.Muted, " cwd: "+friendlyPath(d.sessions[d.cursor].CWD)))
651+ }
652+ if d.query != "" && d.cursor >= 0 && d.cursor < len(d.sessions) {
653+ if match := d.searchMatches[d.sessions[d.cursor].Path]; match.excerpt != "" {
654+ lines = append(lines, " "+highlightSessionSearchExcerpt(th, match.excerpt, match.indexes, width-2))
655+ }
656+ }
425657 lines = append(lines, frameRule(th, width))
426658 return lines
427659 }
@@ -462,6 +694,55 @@ func clampViewTop(viewTop, cursor, window, total int) int {
462694 // full-row selection highlight. The returned string is guaranteed to
463695 // fit within maxWidth visible characters so the terminal never soft-
464696 // wraps it into the next row.
697+func formatSessionSearchRowPlain(s core.SessionSummary, maxWidth, matchCount int) string {
698+ row := formatSessionRowPlain(s, maxWidth)
699+ if matchCount == 0 {
700+ return row
701+ }
702+ count := fmt.Sprintf(" %d matches", matchCount)
703+ if len([]rune(row))+len([]rune(count)) > maxWidth {
704+ return row
705+ }
706+ return row + count
707+}
708+
709+func highlightSessionSearchExcerpt(th tui.Theme, excerpt string, indexes []int, maxWidth int) string {
710+ excerpt = truncateSessionSearchExcerpt(excerpt, 240)
711+ if maxWidth > 0 && len([]rune(excerpt)) > maxWidth {
712+ excerpt = truncateSessionSearchExcerpt(excerpt, maxWidth)
713+ }
714+ if len(indexes) == 0 {
715+ return th.FGColor(th.Muted, excerpt)
716+ }
717+ matched := make(map[int]bool, len(indexes))
718+ for _, index := range indexes {
719+ matched[index] = true
720+ }
721+ var out strings.Builder
722+ for index := 0; index < len(excerpt); {
723+ r, size := utf8.DecodeRuneInString(excerpt[index:])
724+ part := string(r)
725+ if matched[index] {
726+ out.WriteString(th.FGColor(th.Accent, part))
727+ } else {
728+ out.WriteString(th.FGColor(th.Muted, part))
729+ }
730+ index += size
731+ }
732+ return out.String()
733+}
734+
735+func truncateSessionSearchExcerpt(text string, limit int) string {
736+ runes := []rune(text)
737+ if limit <= 0 || len(runes) <= limit {
738+ return text
739+ }
740+ if limit <= 3 {
741+ return string(runes[:limit])
742+ }
743+ return string(runes[:limit-3]) + "..."
744+}
745+
465746 func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string {
466747 when := formatRelative(s.Started)
467748 summary := strings.TrimSpace(s.Title)
@@ -584,6 +865,46 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
584865 return sessionDialogAction{}
585866 }
586867
868+ if k.Kind == tui.KeyTab {
869+ return sessionDialogAction{ToggleScope: true}
870+ }
871+ if k.Kind == tui.KeyRune && k.Rune == '/' && d.query == "" {
872+ return sessionDialogAction{StartSearch: true}
873+ }
874+ if d.query != "" || d.searchEvents != nil {
875+ switch k.Kind {
876+ case tui.KeyEsc:
877+ d.query = ""
878+ d.searchMatches = nil
879+ d.applySearchFilter()
880+ return sessionDialogAction{}
881+ case tui.KeyBackspace:
882+ runes := []rune(d.query)
883+ if len(runes) > 0 {
884+ d.query = string(runes[:len(runes)-1])
885+ }
886+ d.searchMatches = nil
887+ d.applySearchFilter()
888+ d.scheduleSearchMatch()
889+ return sessionDialogAction{}
890+ case tui.KeyPaste:
891+ d.query += k.Paste
892+ case tui.KeyRune:
893+ if k.Rune != 0 {
894+ d.query += string(k.Rune)
895+ }
896+ default:
897+ return sessionDialogAction{}
898+ }
899+ if len([]rune(d.query)) > 256 {
900+ d.query = string([]rune(d.query)[:256])
901+ }
902+ d.searchMatches = nil
903+ d.applySearchFilter()
904+ d.scheduleSearchMatch()
905+ return sessionDialogAction{}
906+ }
907+
587908 if d.loading && len(d.sessions) == 0 {
588909 if k.Kind == tui.KeyEsc {
589910 d.Close()
@@ -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,26 @@ 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+ searchReady bool
84+ searchGeneration uint64
85+ searchCancel context.CancelFunc
86+ searchEvents chan sessionSearchEvent
87+ searchSegments []core.SessionSearchSegment
88+ searchMatches map[string]sessionSearchMatch
89+ searchErr string
49 90
50 loading bool91 loading bool
51 loadingDone int92 loadingDone int
@@ -86,6 +127,8 @@ type sessionDialogAction struct {
86 Close bool127 Close bool
87 Renamed bool128 Renamed bool
88 RenameTitle string129 RenameTitle string
130+ ToggleScope bool
131+ StartSearch bool
89 Err error132 Err error
90 }133 }
91 134
@@ -94,10 +137,14 @@ func newSessionDialog() *sessionDialog { return &sessionDialog{} }
94 // Open shows the dialog immediately and loads session entries on bounded137 // Open shows the dialog immediately and loads session entries on bounded
95 // background workers. Results are delivered to the caller so the UI goroutine138 // background workers. Results are delivered to the caller so the UI goroutine
96 // remains responsible for mutating dialog state and rendering it.139 // remains responsible for mutating dialog state and rendering it.
97-func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan sessionLoadEvent {140+func (d *sessionDialog) Open(parent context.Context, root, cwd string, allScope ...bool) <-chan sessionLoadEvent {
98 if d.loadCancel != nil {141 if d.loadCancel != nil {
99 d.loadCancel()142 d.loadCancel()
100 }143 }
144+ if d.searchCancel != nil {
145+ d.searchCancel()
146+ d.searchCancel = nil
147+ }
101 previousDone := d.loadDone148 previousDone := d.loadDone
102 if parent == nil {149 if parent == nil {
103 parent = context.Background()150 parent = context.Background()
@@ -109,10 +156,22 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan se
109 done := make(chan struct{})156 done := make(chan struct{})
110 d.loadDone = done157 d.loadDone = done
111 d.sessions = nil158 d.sessions = nil
159+ d.baseSessions = nil
112 d.cursor = 0160 d.cursor = 0
113 d.viewTop = 0161 d.viewTop = 0
114 d.renaming = false162 d.renaming = false
115 d.rename = ""163 d.rename = ""
164+ d.root = root
165+ d.cwd = cwd
166+ d.allScope = len(allScope) > 0 && allScope[0]
167+ d.paths = nil
168+ d.query = ""
169+ d.searching = false
170+ d.searchReady = false
171+ d.searchSegments = nil
172+ d.searchMatches = nil
173+ d.searchErr = ""
174+ d.searchEvents = nil
116 d.loading = true175 d.loading = true
117 d.loadingDone = 0176 d.loadingDone = 0
118 d.loadingTotal = 0177 d.loadingTotal = 0
@@ -157,11 +216,12 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan se
157 return216 return
158 default:217 default:
159 }218 }
160- paths := core.ListSessionsContext(ctx, root, cwd)219+ paths := core.ListSessionPathsContext(ctx, root, cwd, len(allScope) > 0 && allScope[0])
161 if !send(sessionLoadEvent{220 if !send(sessionLoadEvent{
162 kind: sessionLoadStarted,221 kind: sessionLoadStarted,
163 generation: generation,222 generation: generation,
164 total: len(paths),223 total: len(paths),
224+ paths: paths,
165 }) {225 }) {
166 return226 return
167 }227 }
@@ -230,9 +290,11 @@ func (d *sessionDialog) ApplyLoad(event sessionLoadEvent) {
230 case sessionLoadStarted:290 case sessionLoadStarted:
231 d.loadingTotal = event.total291 d.loadingTotal = event.total
232 d.loadingDone = 0292 d.loadingDone = 0
293+ d.paths = append([]string(nil), event.paths...)
233 d.loadSlots = make([]sessionLoadSlot, event.total)294 d.loadSlots = make([]sessionLoadSlot, event.total)
234 d.loadReadyThrough = 0295 d.loadReadyThrough = 0
235 d.sessions = nil296 d.sessions = nil
297+ d.baseSessions = nil
236 case sessionLoadEntry:298 case sessionLoadEntry:
237 if event.index < 0 || event.index >= len(d.loadSlots) || d.loadSlots[event.index].loaded {299 if event.index < 0 || event.index >= len(d.loadSlots) || d.loadSlots[event.index].loaded {
238 return300 return
@@ -296,7 +358,10 @@ func (d *sessionDialog) appendLoadedSessions(start, end int) {
296 if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 {358 if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 {
297 continue359 continue
298 }360 }
299- d.sessions = append(d.sessions, slot.summary)361+ d.baseSessions = append(d.baseSessions, slot.summary)
362+ if d.query == "" {
363+ d.sessions = append(d.sessions, slot.summary)
364+ }
300 }365 }
301 }366 }
302 367
@@ -311,7 +376,12 @@ func (d *sessionDialog) rebuildLoadedSessions(limit int) {
311 }376 }
312 filtered = append(filtered, slot.summary)377 filtered = append(filtered, slot.summary)
313 }378 }
314- d.sessions = filtered379+ d.baseSessions = filtered
380+ if d.query == "" {
381+ d.sessions = append([]core.SessionSummary(nil), filtered...)
382+ } else {
383+ d.applySearchFilter()
384+ }
315 if d.cursor >= len(d.sessions) {385 if d.cursor >= len(d.sessions) {
316 d.cursor = len(d.sessions) - 1386 d.cursor = len(d.sessions) - 1
317 }387 }
@@ -337,12 +407,145 @@ func (d *sessionDialog) Close() {
337 d.loadCancel()407 d.loadCancel()
338 d.loadCancel = nil408 d.loadCancel = nil
339 }409 }
410+ if d.searchCancel != nil {
411+ d.searchCancel()
412+ d.searchCancel = nil
413+ }
340 d.loading = false414 d.loading = false
415+ d.searching = false
341 d.loadSlots = nil416 d.loadSlots = nil
342 d.loadReadyThrough = 0417 d.loadReadyThrough = 0
418+ d.searchEvents = nil
343 d.active = false419 d.active = false
344 }420 }
345 421
422+// StartSearch starts one background corpus read for this picker opening. Query
423+// edits reuse the retained corpus and never reopen a session file.
424+func (d *sessionDialog) StartSearch(parent context.Context) <-chan sessionSearchEvent {
425+ if d.searchEvents != nil {
426+ return d.searchEvents
427+ }
428+ if parent == nil {
429+ parent = context.Background()
430+ }
431+ ctx, cancel := context.WithCancel(parent)
432+ d.searchCancel = cancel
433+ d.searching = true
434+ d.searchGeneration++
435+ generation := d.searchGeneration
436+ events := make(chan sessionSearchEvent, 1)
437+ d.searchEvents = events
438+ paths := append([]string(nil), d.paths...)
439+ go func() {
440+ segments := make([]core.SessionSearchSegment, 0)
441+ skipped := 0
442+ for _, path := range paths {
443+ if ctx.Err() != nil {
444+ return
445+ }
446+ read, err := core.ReadSessionSearchSegments(ctx, path)
447+ if err != nil {
448+ if ctx.Err() != nil {
449+ return
450+ }
451+ skipped++
452+ continue
453+ }
454+ segments = append(segments, read...)
455+ }
456+ select {
457+ case events <- sessionSearchEvent{kind: sessionSearchCorpusReady, generation: generation, segments: segments, skipped: skipped}:
458+ case <-ctx.Done():
459+ }
460+ }()
461+ return events
462+}
463+
464+// ApplySearch incorporates corpus and match results on the UI goroutine.
465+func (d *sessionDialog) ApplySearch(event sessionSearchEvent) {
466+ if !d.active || event.generation != d.searchGeneration {
467+ return
468+ }
469+ switch event.kind {
470+ case sessionSearchCorpusReady:
471+ d.searching = false
472+ d.searchReady = true
473+ if event.skipped > 0 {
474+ d.searchErr = fmt.Sprintf("search skipped %d unreadable session(s)", event.skipped)
475+ }
476+ d.searchSegments = event.segments
477+ d.scheduleSearchMatch()
478+ case sessionSearchMatchesReady:
479+ if event.query != d.query {
480+ return
481+ }
482+ d.searchMatches = event.matches
483+ d.applySearchFilter()
484+ }
485+}
486+
487+func (d *sessionDialog) scheduleSearchMatch() {
488+ if !d.searchReady || d.query == "" || d.searchEvents == nil || d.searchCancel == nil {
489+ return
490+ }
491+ query := d.query
492+ segments := append([]core.SessionSearchSegment(nil), d.searchSegments...)
493+ generation := d.searchGeneration
494+ ctx := context.Background()
495+ go func() {
496+ matches := matchSessionSearchSegments(ctx, query, segments)
497+ select {
498+ case d.searchEvents <- sessionSearchEvent{kind: sessionSearchMatchesReady, generation: generation, query: query, matches: matches}:
499+ default:
500+ }
501+ }()
502+}
503+
504+func matchSessionSearchSegments(ctx context.Context, query string, segments []core.SessionSearchSegment) map[string]sessionSearchMatch {
505+ query = core.NormalizeSessionSearchText(query)
506+ if query == "" {
507+ return nil
508+ }
509+ texts := make([]string, len(segments))
510+ for index, segment := range segments {
511+ texts[index] = core.NormalizeSessionSearchText(segment.Text)
512+ }
513+ ranked := fuzzy.Find(query, texts)
514+ matches := make(map[string]sessionSearchMatch)
515+ for _, match := range ranked {
516+ if ctx.Err() != nil || match.Index < 0 || match.Index >= len(segments) {
517+ return matches
518+ }
519+ segment := segments[match.Index]
520+ current := matches[segment.Path]
521+ current.count++
522+ if current.excerpt == "" || match.Score > current.score {
523+ current.excerpt = segment.Text
524+ current.indexes = append([]int(nil), match.MatchedIndexes...)
525+ current.score = match.Score
526+ }
527+ matches[segment.Path] = current
528+ }
529+ return matches
530+}
531+
532+func (d *sessionDialog) applySearchFilter() {
533+ if d.query == "" {
534+ d.sessions = append(d.sessions[:0], d.baseSessions...)
535+ } else {
536+ filtered := make([]core.SessionSummary, 0, len(d.baseSessions))
537+ for _, summary := range d.baseSessions {
538+ if _, ok := d.searchMatches[summary.Path]; ok {
539+ filtered = append(filtered, summary)
540+ }
541+ }
542+ d.sessions = filtered
543+ }
544+ if d.cursor >= len(d.sessions) {
545+ d.cursor = max(0, len(d.sessions)-1)
546+ }
547+}
548+
346 // Active reports whether the dialog is visible and consumes input.549 // Active reports whether the dialog is visible and consumes input.
347 func (d *sessionDialog) Active() bool { return d != nil && d.active }550 func (d *sessionDialog) Active() bool { return d != nil && d.active }
348 551
@@ -355,7 +558,11 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
355 return nil558 return nil
356 }559 }
357 var lines []string560 var lines []string
358- lines = append(lines, frameHeader(th, "sessions", width))561+ title := "sessions · this directory"
562+ if d.allScope {
563+ title = "sessions · all"
564+ }
565+ lines = append(lines, frameHeader(th, title, width))
359 if d.loading {566 if d.loading {
360 lines = append(lines, th.FGColor(th.Muted, d.loadingMessage(th)))567 lines = append(lines, th.FGColor(th.Muted, d.loadingMessage(th)))
361 if len(d.sessions) == 0 {568 if len(d.sessions) == 0 {
@@ -364,8 +571,15 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
364 return lines571 return lines
365 }572 }
366 } else if len(d.sessions) == 0 {573 } else if len(d.sessions) == 0 {
367- lines = append(lines, th.FGColor(th.Muted, "no previous sessions for this directory"))574+ message := "no previous sessions for this directory"
368- lines = append(lines, th.FGColor(th.Muted, "press esc to close"))575+ if d.allScope {
576+ message = "no previous sessions in this namespace"
577+ }
578+ if d.query != "" && d.searchReady {
579+ message = "no matching sessions"
580+ }
581+ lines = append(lines, th.FGColor(th.Muted, message))
582+ lines = append(lines, th.FGColor(th.Muted, "tab changes scope · esc closes"))
369 lines = append(lines, frameRule(th, width))583 lines = append(lines, frameRule(th, width))
370 return lines584 return lines
371 }585 }
@@ -381,11 +595,20 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
381 lines = append(lines, frameRule(th, width))595 lines = append(lines, frameRule(th, width))
382 return lines596 return lines
383 }597 }
384- hint := "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)"598+ hint := "↑/↓ pick · enter resume · / search · tab scope · r rename · esc cancel"
599+ if d.searchEvents != nil {
600+ hint = "search: " + d.query + " (esc clear · tab scope)"
601+ if d.searching {
602+ hint += " · indexing"
603+ }
604+ }
385 if d.loading {605 if d.loading {
386- hint = "pick a session (↑/↓, pgup/pgdn, enter resume, esc cancel; more loading)"606+ hint += " · loading"
387 }607 }
388 lines = append(lines, th.FGColor(th.Muted, hint))608 lines = append(lines, th.FGColor(th.Muted, hint))
609+ if d.searchErr != "" {
610+ lines = append(lines, th.FGColor(th.Warning, d.searchErr))
611+ }
389 612
390 // Viewport: windowed slice of d.sessions around d.cursor so a613 // Viewport: windowed slice of d.sessions around d.cursor so a
391 // list taller than the terminal still scrolls. Caller sets614 // list taller than the terminal still scrolls. Caller sets
@@ -410,7 +633,8 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
410 }633 }
411 for i := d.viewTop; i < viewBot; i++ {634 for i := d.viewTop; i < viewBot; i++ {
412 s := d.sessions[i]635 s := d.sessions[i]
413- plain := " " + formatSessionRowPlain(s, width-2)636+ match := d.searchMatches[s.Path]
637+ plain := " " + formatSessionSearchRowPlain(s, width-2, match.count)
414 if i == d.cursor {638 if i == d.cursor {
415 lines = append(lines, th.PadHighlight(plain, width))639 lines = append(lines, th.PadHighlight(plain, width))
416 } else {640 } else {
@@ -422,6 +646,14 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
422 hidden := total - viewBot646 hidden := total - viewBot
423 lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ↓ %d more below", hidden)))647 lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ↓ %d more below", hidden)))
424 }648 }
649+ if d.allScope && d.cursor >= 0 && d.cursor < len(d.sessions) && d.sessions[d.cursor].CWD != "" {
650+ lines = append(lines, th.FGColor(th.Muted, " cwd: "+friendlyPath(d.sessions[d.cursor].CWD)))
651+ }
652+ if d.query != "" && d.cursor >= 0 && d.cursor < len(d.sessions) {
653+ if match := d.searchMatches[d.sessions[d.cursor].Path]; match.excerpt != "" {
654+ lines = append(lines, " "+highlightSessionSearchExcerpt(th, match.excerpt, match.indexes, width-2))
655+ }
656+ }
425 lines = append(lines, frameRule(th, width))657 lines = append(lines, frameRule(th, width))
426 return lines658 return lines
427 }659 }
@@ -462,6 +694,55 @@ func clampViewTop(viewTop, cursor, window, total int) int {
462 // full-row selection highlight. The returned string is guaranteed to694 // full-row selection highlight. The returned string is guaranteed to
463 // fit within maxWidth visible characters so the terminal never soft-695 // fit within maxWidth visible characters so the terminal never soft-
464 // wraps it into the next row.696 // wraps it into the next row.
697+func formatSessionSearchRowPlain(s core.SessionSummary, maxWidth, matchCount int) string {
698+ row := formatSessionRowPlain(s, maxWidth)
699+ if matchCount == 0 {
700+ return row
701+ }
702+ count := fmt.Sprintf(" %d matches", matchCount)
703+ if len([]rune(row))+len([]rune(count)) > maxWidth {
704+ return row
705+ }
706+ return row + count
707+}
708+
709+func highlightSessionSearchExcerpt(th tui.Theme, excerpt string, indexes []int, maxWidth int) string {
710+ excerpt = truncateSessionSearchExcerpt(excerpt, 240)
711+ if maxWidth > 0 && len([]rune(excerpt)) > maxWidth {
712+ excerpt = truncateSessionSearchExcerpt(excerpt, maxWidth)
713+ }
714+ if len(indexes) == 0 {
715+ return th.FGColor(th.Muted, excerpt)
716+ }
717+ matched := make(map[int]bool, len(indexes))
718+ for _, index := range indexes {
719+ matched[index] = true
720+ }
721+ var out strings.Builder
722+ for index := 0; index < len(excerpt); {
723+ r, size := utf8.DecodeRuneInString(excerpt[index:])
724+ part := string(r)
725+ if matched[index] {
726+ out.WriteString(th.FGColor(th.Accent, part))
727+ } else {
728+ out.WriteString(th.FGColor(th.Muted, part))
729+ }
730+ index += size
731+ }
732+ return out.String()
733+}
734+
735+func truncateSessionSearchExcerpt(text string, limit int) string {
736+ runes := []rune(text)
737+ if limit <= 0 || len(runes) <= limit {
738+ return text
739+ }
740+ if limit <= 3 {
741+ return string(runes[:limit])
742+ }
743+ return string(runes[:limit-3]) + "..."
744+}
745+
465 func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string {746 func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string {
466 when := formatRelative(s.Started)747 when := formatRelative(s.Started)
467 summary := strings.TrimSpace(s.Title)748 summary := strings.TrimSpace(s.Title)
@@ -584,6 +865,46 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
584 return sessionDialogAction{}865 return sessionDialogAction{}
585 }866 }
586 867
868+ if k.Kind == tui.KeyTab {
869+ return sessionDialogAction{ToggleScope: true}
870+ }
871+ if k.Kind == tui.KeyRune && k.Rune == '/' && d.query == "" {
872+ return sessionDialogAction{StartSearch: true}
873+ }
874+ if d.query != "" || d.searchEvents != nil {
875+ switch k.Kind {
876+ case tui.KeyEsc:
877+ d.query = ""
878+ d.searchMatches = nil
879+ d.applySearchFilter()
880+ return sessionDialogAction{}
881+ case tui.KeyBackspace:
882+ runes := []rune(d.query)
883+ if len(runes) > 0 {
884+ d.query = string(runes[:len(runes)-1])
885+ }
886+ d.searchMatches = nil
887+ d.applySearchFilter()
888+ d.scheduleSearchMatch()
889+ return sessionDialogAction{}
890+ case tui.KeyPaste:
891+ d.query += k.Paste
892+ case tui.KeyRune:
893+ if k.Rune != 0 {
894+ d.query += string(k.Rune)
895+ }
896+ default:
897+ return sessionDialogAction{}
898+ }
899+ if len([]rune(d.query)) > 256 {
900+ d.query = string([]rune(d.query)[:256])
901+ }
902+ d.searchMatches = nil
903+ d.applySearchFilter()
904+ d.scheduleSearchMatch()
905+ return sessionDialogAction{}
906+ }
907+
587 if d.loading && len(d.sessions) == 0 {908 if d.loading && len(d.sessions) == 0 {
588 if k.Kind == tui.KeyEsc {909 if k.Kind == tui.KeyEsc {
589 d.Close()910 d.Close()
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 +320 -0
@@ -0,0 +1,320 @@
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+ Role provider.Role
33+ Time time.Time
34+ Order int
35+}
36+
37+// ListSessionPathsContext lists eligible session files for cwd or, when all is
38+// true, for every cwd bucket in root's active session namespace. It ignores
39+// symlinks, malformed buckets, files with invalid metadata, and files whose
40+// stored cwd does not hash to the directory that contains them.
41+func ListSessionPathsContext(ctx context.Context, root, cwd string, all bool) []string {
42+ if err := contextErr(ctx); err != nil || strings.TrimSpace(root) == "" {
43+ return nil
44+ }
45+
46+ var dirs []string
47+ if all {
48+ sessionsDir := filepath.Join(root, "sessions")
49+ entries, err := os.ReadDir(sessionsDir)
50+ if err != nil {
51+ return nil
52+ }
53+ for _, entry := range entries {
54+ if err := contextErr(ctx); err != nil {
55+ return nil
56+ }
57+ if entry.Type()&os.ModeSymlink != 0 || !entry.IsDir() || !isSessionBucketName(entry.Name()) {
58+ continue
59+ }
60+ dirs = append(dirs, filepath.Join(sessionsDir, entry.Name()))
61+ }
62+ } else {
63+ dirs = []string{SessionsDir(root, cwd)}
64+ }
65+
66+ type candidate struct {
67+ path string
68+ mod time.Time
69+ }
70+ var candidates []candidate
71+ for _, dir := range dirs {
72+ if err := contextErr(ctx); err != nil {
73+ return nil
74+ }
75+ entries, err := os.ReadDir(dir)
76+ if err != nil {
77+ continue
78+ }
79+ for _, entry := range entries {
80+ if err := contextErr(ctx); err != nil {
81+ return nil
82+ }
83+ if entry.Type()&os.ModeSymlink != 0 || entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
84+ continue
85+ }
86+ info, err := entry.Info()
87+ if err != nil || !info.Mode().IsRegular() {
88+ continue
89+ }
90+ path := filepath.Join(dir, entry.Name())
91+ meta, err := scanSessionMetaContext(ctx, path)
92+ if err != nil || meta.ID == "" || meta.CWD == "" || meta.HideFromSessions {
93+ continue
94+ }
95+ if filepath.Clean(SessionsDir(root, meta.CWD)) != filepath.Clean(dir) {
96+ continue
97+ }
98+ if !all && meta.CWD != cwd {
99+ continue
100+ }
101+ candidates = append(candidates, candidate{path: path, mod: info.ModTime()})
102+ }
103+ }
104+ sort.Slice(candidates, func(i, j int) bool {
105+ if !candidates[i].mod.Equal(candidates[j].mod) {
106+ return candidates[i].mod.After(candidates[j].mod)
107+ }
108+ return candidates[i].path > candidates[j].path
109+ })
110+ paths := make([]string, len(candidates))
111+ for i, candidate := range candidates {
112+ paths[i] = candidate.path
113+ }
114+ return paths
115+}
116+
117+// ManagedSessionMeta validates that path is a regular session file directly
118+// owned by root's active namespace and returns its latest metadata. It is the
119+// trust boundary for a picker selection before a host changes workspace state.
120+func ManagedSessionMeta(ctx context.Context, root, path string) (SessionMeta, error) {
121+ if err := contextErr(ctx); err != nil {
122+ return SessionMeta{}, err
123+ }
124+ if strings.TrimSpace(root) == "" || strings.TrimSpace(path) == "" {
125+ return SessionMeta{}, errors.New("managed session: root or path is empty")
126+ }
127+ info, err := os.Lstat(path)
128+ if err != nil {
129+ return SessionMeta{}, fmt.Errorf("managed session: stat: %w", err)
130+ }
131+ if !info.Mode().IsRegular() {
132+ return SessionMeta{}, errors.New("managed session: path is not a regular file")
133+ }
134+ meta, err := scanSessionMetaContext(ctx, path)
135+ if err != nil {
136+ return SessionMeta{}, err
137+ }
138+ if meta.ID == "" || meta.CWD == "" {
139+ return SessionMeta{}, errors.New("managed session: invalid session metadata")
140+ }
141+ wantDir := filepath.Clean(SessionsDir(root, meta.CWD))
142+ if filepath.Clean(filepath.Dir(path)) != wantDir {
143+ return SessionMeta{}, errors.New("managed session: cwd bucket does not match metadata")
144+ }
145+ return meta, nil
146+}
147+
148+// ReadSessionSearchSegments extracts bounded searchable text from persisted
149+// message rows. It keeps historical user/assistant rows after compaction but
150+// excludes compaction payloads and every non-text content block.
151+func ReadSessionSearchSegments(ctx context.Context, path string) ([]SessionSearchSegment, error) {
152+ if err := contextErr(ctx); err != nil {
153+ return nil, err
154+ }
155+ file, err := os.Open(path)
156+ if err != nil {
157+ return nil, fmt.Errorf("read session search text: open: %w", err)
158+ }
159+ defer file.Close()
160+
161+ reader := bufio.NewReaderSize(file, 64*1024)
162+ var segments []SessionSearchSegment
163+ order := 0
164+ for {
165+ if err := contextErr(ctx); err != nil {
166+ return nil, err
167+ }
168+ line, err := readBoundedSessionSearchLine(reader)
169+ if err != nil && !errors.Is(err, io.EOF) {
170+ return nil, fmt.Errorf("read session search text: %w", err)
171+ }
172+ if len(line) > 0 {
173+ var head sessionLineHead
174+ if jsonErr := json.Unmarshal(line, &head); jsonErr != nil {
175+ return nil, fmt.Errorf("read session search text: invalid JSON: %w", jsonErr)
176+ }
177+ if head.Type == "message" {
178+ message, messageErr := readSearchMessage(line)
179+ if messageErr != nil {
180+ return nil, fmt.Errorf("read session search text: %w", messageErr)
181+ }
182+ if message.Role == provider.RoleUser || message.Role == provider.RoleAssistant {
183+ for _, rawText := range message.Texts {
184+ for _, text := range splitSessionSearchText(rawText) {
185+ segments = append(segments, SessionSearchSegment{Path: path, Text: text, Role: message.Role, Time: message.Time, Order: order})
186+ order++
187+ }
188+ }
189+ }
190+ }
191+ }
192+ if errors.Is(err, io.EOF) {
193+ break
194+ }
195+ }
196+ return segments, nil
197+}
198+
199+type sessionSearchMessage struct {
200+ Role provider.Role
201+ Time time.Time
202+ Texts []string
203+}
204+
205+func readSearchMessage(line []byte) (sessionSearchMessage, error) {
206+ var row struct {
207+ Message json.RawMessage `json:"message"`
208+ }
209+ if err := json.Unmarshal(line, &row); err != nil {
210+ return sessionSearchMessage{}, err
211+ }
212+ if len(row.Message) == 0 || bytes.Equal(bytes.TrimSpace(row.Message), []byte("null")) {
213+ return sessionSearchMessage{}, errors.New("message row has no message")
214+ }
215+ var message struct {
216+ Role provider.Role `json:"role"`
217+ Time time.Time `json:"time"`
218+ Content []json.RawMessage `json:"content"`
219+ }
220+ if err := json.Unmarshal(row.Message, &message); err != nil {
221+ return sessionSearchMessage{}, err
222+ }
223+ if message.Role != provider.RoleUser && message.Role != provider.RoleAssistant && message.Role != provider.RoleTool && message.Role != provider.RoleDeveloper {
224+ return sessionSearchMessage{}, fmt.Errorf("message has invalid role %q", message.Role)
225+ }
226+ if len(message.Content) == 0 {
227+ return sessionSearchMessage{}, errors.New("message has no content")
228+ }
229+ result := sessionSearchMessage{Role: message.Role, Time: message.Time}
230+ for _, rawContent := range message.Content {
231+ var textBlock struct {
232+ Text string `json:"text"`
233+ }
234+ if err := json.Unmarshal(rawContent, &textBlock); err != nil {
235+ return sessionSearchMessage{}, err
236+ }
237+ if textBlock.Text != "" {
238+ result.Texts = append(result.Texts, textBlock.Text)
239+ }
240+ }
241+ return result, nil
242+}
243+
244+func readBoundedSessionSearchLine(reader *bufio.Reader) ([]byte, error) {
245+ var line []byte
246+ for {
247+ fragment, err := reader.ReadSlice('\n')
248+ line = append(line, fragment...)
249+ if len(line) > maxSessionSearchRowBytes {
250+ return nil, fmt.Errorf("JSONL row exceeds %d bytes", maxSessionSearchRowBytes)
251+ }
252+ switch {
253+ case err == nil:
254+ return bytes.TrimRight(line, "\r\n"), nil
255+ case errors.Is(err, bufio.ErrBufferFull):
256+ continue
257+ case errors.Is(err, io.EOF):
258+ return bytes.TrimRight(line, "\r\n"), io.EOF
259+ default:
260+ return nil, err
261+ }
262+ }
263+}
264+
265+func splitSessionSearchText(text string) []string {
266+ text = sanitizeSessionSearchText(text)
267+ if text == "" {
268+ return nil
269+ }
270+ runes := []rune(text)
271+ if len(runes) <= maxSessionSearchRunes {
272+ return []string{text}
273+ }
274+ segments := make([]string, 0, len(runes)/maxSessionSearchRunes+1)
275+ for len(runes) > 0 {
276+ end := min(len(runes), maxSessionSearchRunes)
277+ if end < len(runes) {
278+ for split := end; split > end/2; split-- {
279+ if unicode.IsSpace(runes[split]) || unicode.Is(unicode.Sentence_Terminal, runes[split]) {
280+ end = split + 1
281+ break
282+ }
283+ }
284+ }
285+ segment := strings.TrimSpace(string(runes[:end]))
286+ if segment != "" {
287+ segments = append(segments, segment)
288+ }
289+ runes = runes[end:]
290+ }
291+ return segments
292+}
293+
294+// NormalizeSessionSearchText returns the display-safe case-folded form used
295+// by the picker matcher. It removes terminal and bidi controls and collapses
296+// whitespace without retaining any raw transcript representation.
297+func NormalizeSessionSearchText(text string) string {
298+ return strings.ToLower(sanitizeSessionSearchText(text))
299+}
300+
301+func sanitizeSessionSearchText(text string) string {
302+ var out strings.Builder
303+ out.Grow(len(text))
304+ space := true
305+ for _, r := range text {
306+ if unicode.IsControl(r) || unicode.Is(unicode.Bidi_Control, r) || unicode.Is(unicode.Cf, r) {
307+ continue
308+ }
309+ if unicode.IsSpace(r) {
310+ if !space {
311+ out.WriteByte(' ')
312+ space = true
313+ }
314+ continue
315+ }
316+ out.WriteRune(r)
317+ space = false
318+ }
319+ return strings.TrimSpace(out.String())
320+}
@@ -0,0 +1,320 @@
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+ Role provider.Role
33+ Time time.Time
34+ Order int
35+}
36+
37+// ListSessionPathsContext lists eligible session files for cwd or, when all is
38+// true, for every cwd bucket in root's active session namespace. It ignores
39+// symlinks, malformed buckets, files with invalid metadata, and files whose
40+// stored cwd does not hash to the directory that contains them.
41+func ListSessionPathsContext(ctx context.Context, root, cwd string, all bool) []string {
42+ if err := contextErr(ctx); err != nil || strings.TrimSpace(root) == "" {
43+ return nil
44+ }
45+
46+ var dirs []string
47+ if all {
48+ sessionsDir := filepath.Join(root, "sessions")
49+ entries, err := os.ReadDir(sessionsDir)
50+ if err != nil {
51+ return nil
52+ }
53+ for _, entry := range entries {
54+ if err := contextErr(ctx); err != nil {
55+ return nil
56+ }
57+ if entry.Type()&os.ModeSymlink != 0 || !entry.IsDir() || !isSessionBucketName(entry.Name()) {
58+ continue
59+ }
60+ dirs = append(dirs, filepath.Join(sessionsDir, entry.Name()))
61+ }
62+ } else {
63+ dirs = []string{SessionsDir(root, cwd)}
64+ }
65+
66+ type candidate struct {
67+ path string
68+ mod time.Time
69+ }
70+ var candidates []candidate
71+ for _, dir := range dirs {
72+ if err := contextErr(ctx); err != nil {
73+ return nil
74+ }
75+ entries, err := os.ReadDir(dir)
76+ if err != nil {
77+ continue
78+ }
79+ for _, entry := range entries {
80+ if err := contextErr(ctx); err != nil {
81+ return nil
82+ }
83+ if entry.Type()&os.ModeSymlink != 0 || entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
84+ continue
85+ }
86+ info, err := entry.Info()
87+ if err != nil || !info.Mode().IsRegular() {
88+ continue
89+ }
90+ path := filepath.Join(dir, entry.Name())
91+ meta, err := scanSessionMetaContext(ctx, path)
92+ if err != nil || meta.ID == "" || meta.CWD == "" || meta.HideFromSessions {
93+ continue
94+ }
95+ if filepath.Clean(SessionsDir(root, meta.CWD)) != filepath.Clean(dir) {
96+ continue
97+ }
98+ if !all && meta.CWD != cwd {
99+ continue
100+ }
101+ candidates = append(candidates, candidate{path: path, mod: info.ModTime()})
102+ }
103+ }
104+ sort.Slice(candidates, func(i, j int) bool {
105+ if !candidates[i].mod.Equal(candidates[j].mod) {
106+ return candidates[i].mod.After(candidates[j].mod)
107+ }
108+ return candidates[i].path > candidates[j].path
109+ })
110+ paths := make([]string, len(candidates))
111+ for i, candidate := range candidates {
112+ paths[i] = candidate.path
113+ }
114+ return paths
115+}
116+
117+// ManagedSessionMeta validates that path is a regular session file directly
118+// owned by root's active namespace and returns its latest metadata. It is the
119+// trust boundary for a picker selection before a host changes workspace state.
120+func ManagedSessionMeta(ctx context.Context, root, path string) (SessionMeta, error) {
121+ if err := contextErr(ctx); err != nil {
122+ return SessionMeta{}, err
123+ }
124+ if strings.TrimSpace(root) == "" || strings.TrimSpace(path) == "" {
125+ return SessionMeta{}, errors.New("managed session: root or path is empty")
126+ }
127+ info, err := os.Lstat(path)
128+ if err != nil {
129+ return SessionMeta{}, fmt.Errorf("managed session: stat: %w", err)
130+ }
131+ if !info.Mode().IsRegular() {
132+ return SessionMeta{}, errors.New("managed session: path is not a regular file")
133+ }
134+ meta, err := scanSessionMetaContext(ctx, path)
135+ if err != nil {
136+ return SessionMeta{}, err
137+ }
138+ if meta.ID == "" || meta.CWD == "" {
139+ return SessionMeta{}, errors.New("managed session: invalid session metadata")
140+ }
141+ wantDir := filepath.Clean(SessionsDir(root, meta.CWD))
142+ if filepath.Clean(filepath.Dir(path)) != wantDir {
143+ return SessionMeta{}, errors.New("managed session: cwd bucket does not match metadata")
144+ }
145+ return meta, nil
146+}
147+
148+// ReadSessionSearchSegments extracts bounded searchable text from persisted
149+// message rows. It keeps historical user/assistant rows after compaction but
150+// excludes compaction payloads and every non-text content block.
151+func ReadSessionSearchSegments(ctx context.Context, path string) ([]SessionSearchSegment, error) {
152+ if err := contextErr(ctx); err != nil {
153+ return nil, err
154+ }
155+ file, err := os.Open(path)
156+ if err != nil {
157+ return nil, fmt.Errorf("read session search text: open: %w", err)
158+ }
159+ defer file.Close()
160+
161+ reader := bufio.NewReaderSize(file, 64*1024)
162+ var segments []SessionSearchSegment
163+ order := 0
164+ for {
165+ if err := contextErr(ctx); err != nil {
166+ return nil, err
167+ }
168+ line, err := readBoundedSessionSearchLine(reader)
169+ if err != nil && !errors.Is(err, io.EOF) {
170+ return nil, fmt.Errorf("read session search text: %w", err)
171+ }
172+ if len(line) > 0 {
173+ var head sessionLineHead
174+ if jsonErr := json.Unmarshal(line, &head); jsonErr != nil {
175+ return nil, fmt.Errorf("read session search text: invalid JSON: %w", jsonErr)
176+ }
177+ if head.Type == "message" {
178+ message, messageErr := readSearchMessage(line)
179+ if messageErr != nil {
180+ return nil, fmt.Errorf("read session search text: %w", messageErr)
181+ }
182+ if message.Role == provider.RoleUser || message.Role == provider.RoleAssistant {
183+ for _, rawText := range message.Texts {
184+ for _, text := range splitSessionSearchText(rawText) {
185+ segments = append(segments, SessionSearchSegment{Path: path, Text: text, Role: message.Role, Time: message.Time, Order: order})
186+ order++
187+ }
188+ }
189+ }
190+ }
191+ }
192+ if errors.Is(err, io.EOF) {
193+ break
194+ }
195+ }
196+ return segments, nil
197+}
198+
199+type sessionSearchMessage struct {
200+ Role provider.Role
201+ Time time.Time
202+ Texts []string
203+}
204+
205+func readSearchMessage(line []byte) (sessionSearchMessage, error) {
206+ var row struct {
207+ Message json.RawMessage `json:"message"`
208+ }
209+ if err := json.Unmarshal(line, &row); err != nil {
210+ return sessionSearchMessage{}, err
211+ }
212+ if len(row.Message) == 0 || bytes.Equal(bytes.TrimSpace(row.Message), []byte("null")) {
213+ return sessionSearchMessage{}, errors.New("message row has no message")
214+ }
215+ var message struct {
216+ Role provider.Role `json:"role"`
217+ Time time.Time `json:"time"`
218+ Content []json.RawMessage `json:"content"`
219+ }
220+ if err := json.Unmarshal(row.Message, &message); err != nil {
221+ return sessionSearchMessage{}, err
222+ }
223+ if message.Role != provider.RoleUser && message.Role != provider.RoleAssistant && message.Role != provider.RoleTool && message.Role != provider.RoleDeveloper {
224+ return sessionSearchMessage{}, fmt.Errorf("message has invalid role %q", message.Role)
225+ }
226+ if len(message.Content) == 0 {
227+ return sessionSearchMessage{}, errors.New("message has no content")
228+ }
229+ result := sessionSearchMessage{Role: message.Role, Time: message.Time}
230+ for _, rawContent := range message.Content {
231+ var textBlock struct {
232+ Text string `json:"text"`
233+ }
234+ if err := json.Unmarshal(rawContent, &textBlock); err != nil {
235+ return sessionSearchMessage{}, err
236+ }
237+ if textBlock.Text != "" {
238+ result.Texts = append(result.Texts, textBlock.Text)
239+ }
240+ }
241+ return result, nil
242+}
243+
244+func readBoundedSessionSearchLine(reader *bufio.Reader) ([]byte, error) {
245+ var line []byte
246+ for {
247+ fragment, err := reader.ReadSlice('\n')
248+ line = append(line, fragment...)
249+ if len(line) > maxSessionSearchRowBytes {
250+ return nil, fmt.Errorf("JSONL row exceeds %d bytes", maxSessionSearchRowBytes)
251+ }
252+ switch {
253+ case err == nil:
254+ return bytes.TrimRight(line, "\r\n"), nil
255+ case errors.Is(err, bufio.ErrBufferFull):
256+ continue
257+ case errors.Is(err, io.EOF):
258+ return bytes.TrimRight(line, "\r\n"), io.EOF
259+ default:
260+ return nil, err
261+ }
262+ }
263+}
264+
265+func splitSessionSearchText(text string) []string {
266+ text = sanitizeSessionSearchText(text)
267+ if text == "" {
268+ return nil
269+ }
270+ runes := []rune(text)
271+ if len(runes) <= maxSessionSearchRunes {
272+ return []string{text}
273+ }
274+ segments := make([]string, 0, len(runes)/maxSessionSearchRunes+1)
275+ for len(runes) > 0 {
276+ end := min(len(runes), maxSessionSearchRunes)
277+ if end < len(runes) {
278+ for split := end; split > end/2; split-- {
279+ if unicode.IsSpace(runes[split]) || unicode.Is(unicode.Sentence_Terminal, runes[split]) {
280+ end = split + 1
281+ break
282+ }
283+ }
284+ }
285+ segment := strings.TrimSpace(string(runes[:end]))
286+ if segment != "" {
287+ segments = append(segments, segment)
288+ }
289+ runes = runes[end:]
290+ }
291+ return segments
292+}
293+
294+// NormalizeSessionSearchText returns the display-safe case-folded form used
295+// by the picker matcher. It removes terminal and bidi controls and collapses
296+// whitespace without retaining any raw transcript representation.
297+func NormalizeSessionSearchText(text string) string {
298+ return strings.ToLower(sanitizeSessionSearchText(text))
299+}
300+
301+func sanitizeSessionSearchText(text string) string {
302+ var out strings.Builder
303+ out.Grow(len(text))
304+ space := true
305+ for _, r := range text {
306+ if unicode.IsControl(r) || unicode.Is(unicode.Bidi_Control, r) || unicode.Is(unicode.Cf, r) {
307+ continue
308+ }
309+ if unicode.IsSpace(r) {
310+ if !space {
311+ out.WriteByte(' ')
312+ space = true
313+ }
314+ continue
315+ }
316+ out.WriteRune(r)
317+ space = false
318+ }
319+ return strings.TrimSpace(out.String())
320+}
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