feat(/study): accept an optional file or directory argument
/study previously hard-coded the prompt to 'the current directory'. It now takes an optional path - typed, drag-dropped, or selected via the @ file picker - and tailors the prompt to whatever was passed, distinguishing files from directories via os.Stat and rendering paths under cwd as relative for readability. With no argument, behaviour is unchanged. Examples: /study -> current directory (old behaviour) /study internal -> directory internal /study [dir:internal/] -> directory internal (via @-picker) /study cmd/zot/main.go -> file cmd/zot/main.go /study [file:cmd/zot/main.go] -> file cmd/zot/main.go (via @-picker)
- commit
- 81c913aef9bad8c16d46637140b0679e6d631c3a
- author
- patriceckhart · · 2026-05-19 16:37 UTC
- parent
- e0c933a
- signature
- unsigned
4 files · +133 -7
M README.md +1 -1
| @@ -190,7 +190,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands: | |||
| 190 | 190 | | `/swarm` | Spawn, monitor, and chat with background subagents. Each runs in parallel with your main session and shares its working directory. | | |
| 191 | 191 | | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. | | |
| 192 | 192 | | `/compact` | Summarize the transcript into one message to free up context. | | |
| 193 | - | | `/study` | Run the canned prompt "Read and understand everything in the current directory." so the agent has full project context before you start asking targeted questions. | | |
| 193 | + | | `/study` | Run the canned prompt "Read and understand everything in the current directory." so the agent has full project context before you start asking targeted questions. Pass a path — typed, drag-dropped, or selected via `@` — to target a specific file or directory instead: `/study [dir:internal/]`, `/study cmd/zot/main.go`. | | |
| 194 | 194 | | `/jail` | Confine tools to the current directory. | | |
| 195 | 195 | | `/unjail` | Allow tools to touch paths outside again. | | |
| 196 | 196 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | |
| @@ -190,7 +190,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands: | |||||
| 190 | | `/swarm` | Spawn, monitor, and chat with background subagents. Each runs in parallel with your main session and shares its working directory. | | 190 | | `/swarm` | Spawn, monitor, and chat with background subagents. Each runs in parallel with your main session and shares its working directory. | | ||
| 191 | | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. | | 191 | | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. | | ||
| 192 | | `/compact` | Summarize the transcript into one message to free up context. | | 192 | | `/compact` | Summarize the transcript into one message to free up context. | | ||
| 193 | - | | `/study` | Run the canned prompt "Read and understand everything in the current directory." so the agent has full project context before you start asking targeted questions. | | 193 | + | | `/study` | Run the canned prompt "Read and understand everything in the current directory." so the agent has full project context before you start asking targeted questions. Pass a path — typed, drag-dropped, or selected via `@` — to target a specific file or directory instead: `/study [dir:internal/]`, `/study cmd/zot/main.go`. | |
| 194 | | `/jail` | Confine tools to the current directory. | | 194 | | `/jail` | Confine tools to the current directory. | | ||
| 195 | | `/unjail` | Allow tools to touch paths outside again. | | 195 | | `/unjail` | Allow tools to touch paths outside again. | | ||
| 196 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | 196 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | ||
M internal/agent/modes/interactive.go +47 -5
| @@ -2362,6 +2362,43 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 2362 | 2362 | } | |
| 2363 | 2363 | } | |
| 2364 | 2364 | ||
| 2365 | + | // buildStudyPrompt returns the canned prompt the /study command | |
| 2366 | + | // submits to the agent. | |
| 2367 | + | // | |
| 2368 | + | // With no argument, /study targets the current directory — the | |
| 2369 | + | // historical behaviour. With an argument, /study targets that path | |
| 2370 | + | // instead; either a directory ("read every file in here") or a | |
| 2371 | + | // single file ("read this file"). The argument can be: | |
| 2372 | + | // | |
| 2373 | + | // - a relative path (resolved against cwd) | |
| 2374 | + | // - an absolute path | |
| 2375 | + | // - an @-picker chip, which has already been expanded to an | |
| 2376 | + | // absolute path by expandFileChips before runSlash sees it | |
| 2377 | + | // | |
| 2378 | + | // The path is stat'd to pick the right wording ("directory" vs | |
| 2379 | + | // "file"). If the path doesn't exist, we still build a sensible | |
| 2380 | + | // prompt rather than erroring — the agent will surface the | |
| 2381 | + | // missing-file failure itself when it tries to read it, which is | |
| 2382 | + | // more useful than a refusal here. | |
| 2383 | + | func buildStudyPrompt(arg, cwd string) string { | |
| 2384 | + | arg = strings.TrimSpace(arg) | |
| 2385 | + | if arg == "" { | |
| 2386 | + | return "Read and understand everything in the current directory." | |
| 2387 | + | } | |
| 2388 | + | abs := arg | |
| 2389 | + | if !filepath.IsAbs(abs) { | |
| 2390 | + | abs = filepath.Join(cwd, abs) | |
| 2391 | + | } | |
| 2392 | + | display := arg | |
| 2393 | + | if rel, err := filepath.Rel(cwd, abs); err == nil && !strings.HasPrefix(rel, "..") { | |
| 2394 | + | display = rel | |
| 2395 | + | } | |
| 2396 | + | if info, err := os.Stat(abs); err == nil && !info.IsDir() { | |
| 2397 | + | return "Read and understand the file " + display + "." | |
| 2398 | + | } | |
| 2399 | + | return "Read and understand everything in the directory " + display + "." | |
| 2400 | + | } | |
| 2401 | + | ||
| 2365 | 2402 | func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |
| 2366 | 2403 | parts := strings.Fields(cmd) | |
| 2367 | 2404 | switch parts[0] { | |
| @@ -2432,11 +2469,16 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 2432 | 2469 | i.runCompact(ctx, false) | |
| 2433 | 2470 | case "/study": | |
| 2434 | 2471 | // Canned prompt that tells the agent to read every file | |
| 2435 | - | // in the current directory so its later turns have the | |
| 2436 | - | // whole project in context. Dispatched through the normal | |
| 2437 | - | // queue-or-start path so it behaves identically to | |
| 2438 | - | // typing the prompt by hand. | |
| 2439 | - | const studyPrompt = "Read and understand everything in the current directory." | |
| 2472 | + | // in some target so its later turns have the whole thing | |
| 2473 | + | // in context. With no argument, the target is the current | |
| 2474 | + | // directory. With an argument, the target is whatever the | |
| 2475 | + | // user passed — typed by hand, drag-dropped, or selected | |
| 2476 | + | // via the @ file picker (which is why we accept both files | |
| 2477 | + | // and directories; the @-picker chips for both have already | |
| 2478 | + | // been expanded to absolute paths by expandFileChips above). | |
| 2479 | + | // Dispatched through the normal queue-or-start path so it | |
| 2480 | + | // behaves identically to typing the prompt by hand. | |
| 2481 | + | studyPrompt := buildStudyPrompt(strings.TrimSpace(strings.TrimPrefix(cmd, parts[0])), i.cfg.CWD) | |
| 2440 | 2482 | i.mu.Lock() | |
| 2441 | 2483 | busy := i.busy | |
| 2442 | 2484 | ag := i.agent | |
| @@ -2362,6 +2362,43 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||||
| 2362 | } | 2362 | } | ||
| 2363 | } | 2363 | } | ||
| 2364 | 2364 | ||||
| 2365 | + | // buildStudyPrompt returns the canned prompt the /study command | |||
| 2366 | + | // submits to the agent. | |||
| 2367 | + | // | |||
| 2368 | + | // With no argument, /study targets the current directory — the | |||
| 2369 | + | // historical behaviour. With an argument, /study targets that path | |||
| 2370 | + | // instead; either a directory ("read every file in here") or a | |||
| 2371 | + | // single file ("read this file"). The argument can be: | |||
| 2372 | + | // | |||
| 2373 | + | // - a relative path (resolved against cwd) | |||
| 2374 | + | // - an absolute path | |||
| 2375 | + | // - an @-picker chip, which has already been expanded to an | |||
| 2376 | + | // absolute path by expandFileChips before runSlash sees it | |||
| 2377 | + | // | |||
| 2378 | + | // The path is stat'd to pick the right wording ("directory" vs | |||
| 2379 | + | // "file"). If the path doesn't exist, we still build a sensible | |||
| 2380 | + | // prompt rather than erroring — the agent will surface the | |||
| 2381 | + | // missing-file failure itself when it tries to read it, which is | |||
| 2382 | + | // more useful than a refusal here. | |||
| 2383 | + | func buildStudyPrompt(arg, cwd string) string { | |||
| 2384 | + | arg = strings.TrimSpace(arg) | |||
| 2385 | + | if arg == "" { | |||
| 2386 | + | return "Read and understand everything in the current directory." | |||
| 2387 | + | } | |||
| 2388 | + | abs := arg | |||
| 2389 | + | if !filepath.IsAbs(abs) { | |||
| 2390 | + | abs = filepath.Join(cwd, abs) | |||
| 2391 | + | } | |||
| 2392 | + | display := arg | |||
| 2393 | + | if rel, err := filepath.Rel(cwd, abs); err == nil && !strings.HasPrefix(rel, "..") { | |||
| 2394 | + | display = rel | |||
| 2395 | + | } | |||
| 2396 | + | if info, err := os.Stat(abs); err == nil && !info.IsDir() { | |||
| 2397 | + | return "Read and understand the file " + display + "." | |||
| 2398 | + | } | |||
| 2399 | + | return "Read and understand everything in the directory " + display + "." | |||
| 2400 | + | } | |||
| 2401 | + | ||||
| 2365 | func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | 2402 | func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | ||
| 2366 | parts := strings.Fields(cmd) | 2403 | parts := strings.Fields(cmd) | ||
| 2367 | switch parts[0] { | 2404 | switch parts[0] { | ||
| @@ -2432,11 +2469,16 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 2432 | i.runCompact(ctx, false) | 2469 | i.runCompact(ctx, false) | ||
| 2433 | case "/study": | 2470 | case "/study": | ||
| 2434 | // Canned prompt that tells the agent to read every file | 2471 | // Canned prompt that tells the agent to read every file | ||
| 2435 | - | // in the current directory so its later turns have the | 2472 | + | // in some target so its later turns have the whole thing |
| 2436 | - | // whole project in context. Dispatched through the normal | 2473 | + | // in context. With no argument, the target is the current |
| 2437 | - | // queue-or-start path so it behaves identically to | 2474 | + | // directory. With an argument, the target is whatever the |
| 2438 | - | // typing the prompt by hand. | 2475 | + | // user passed — typed by hand, drag-dropped, or selected |
| 2439 | - | const studyPrompt = "Read and understand everything in the current directory." | 2476 | + | // via the @ file picker (which is why we accept both files |
| 2477 | + | // and directories; the @-picker chips for both have already | |||
| 2478 | + | // been expanded to absolute paths by expandFileChips above). | |||
| 2479 | + | // Dispatched through the normal queue-or-start path so it | |||
| 2480 | + | // behaves identically to typing the prompt by hand. | |||
| 2481 | + | studyPrompt := buildStudyPrompt(strings.TrimSpace(strings.TrimPrefix(cmd, parts[0])), i.cfg.CWD) | |||
| 2440 | i.mu.Lock() | 2482 | i.mu.Lock() | ||
| 2441 | busy := i.busy | 2483 | busy := i.busy | ||
| 2442 | ag := i.agent | 2484 | ag := i.agent | ||
M internal/agent/modes/slash_suggest.go +1 -1
| @@ -40,7 +40,7 @@ var slashCatalog = []slashCommand{ | |||
| 40 | 40 | {Name: "/session", Desc: "export the current session to a .zotsession file, or import one"}, | |
| 41 | 41 | {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"}, | |
| 42 | 42 | {Name: "/compact", Desc: "summarize and replace the transcript to free up context"}, | |
| 43 | - | {Name: "/study", Desc: "read every file in the current directory so the agent has full project context"}, | |
| 43 | + | {Name: "/study", Desc: "read every file in the cwd (or a passed file/dir) so the agent has full context"}, | |
| 44 | 44 | {Name: "/btw", Desc: "side-chat that doesn't add to the main thread (saves tokens)"}, | |
| 45 | 45 | {Name: "/jail", Desc: "confine tools to the current directory"}, | |
| 46 | 46 | {Name: "/unjail", Desc: "allow tools to touch paths outside this directory"}, | |
| @@ -40,7 +40,7 @@ var slashCatalog = []slashCommand{ | |||||
| 40 | {Name: "/session", Desc: "export the current session to a .zotsession file, or import one"}, | 40 | {Name: "/session", Desc: "export the current session to a .zotsession file, or import one"}, | ||
| 41 | {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"}, | 41 | {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"}, | ||
| 42 | {Name: "/compact", Desc: "summarize and replace the transcript to free up context"}, | 42 | {Name: "/compact", Desc: "summarize and replace the transcript to free up context"}, | ||
| 43 | - | {Name: "/study", Desc: "read every file in the current directory so the agent has full project context"}, | 43 | + | {Name: "/study", Desc: "read every file in the cwd (or a passed file/dir) so the agent has full context"}, |
| 44 | {Name: "/btw", Desc: "side-chat that doesn't add to the main thread (saves tokens)"}, | 44 | {Name: "/btw", Desc: "side-chat that doesn't add to the main thread (saves tokens)"}, | ||
| 45 | {Name: "/jail", Desc: "confine tools to the current directory"}, | 45 | {Name: "/jail", Desc: "confine tools to the current directory"}, | ||
| 46 | {Name: "/unjail", Desc: "allow tools to touch paths outside this directory"}, | 46 | {Name: "/unjail", Desc: "allow tools to touch paths outside this directory"}, | ||
A internal/agent/modes/study_test.go +84 -0
| @@ -0,0 +1,84 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestBuildStudyPrompt(t *testing.T) { | |
| 10 | + | tmp := t.TempDir() | |
| 11 | + | subdir := filepath.Join(tmp, "internal") | |
| 12 | + | if err := os.Mkdir(subdir, 0o755); err != nil { | |
| 13 | + | t.Fatal(err) | |
| 14 | + | } | |
| 15 | + | file := filepath.Join(tmp, "main.go") | |
| 16 | + | if err := os.WriteFile(file, []byte("package main\n"), 0o644); err != nil { | |
| 17 | + | t.Fatal(err) | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | cases := []struct { | |
| 21 | + | name string | |
| 22 | + | arg string | |
| 23 | + | cwd string | |
| 24 | + | want string | |
| 25 | + | }{ | |
| 26 | + | { | |
| 27 | + | name: "empty arg keeps the original cwd prompt", | |
| 28 | + | arg: "", | |
| 29 | + | cwd: tmp, | |
| 30 | + | want: "Read and understand everything in the current directory.", | |
| 31 | + | }, | |
| 32 | + | { | |
| 33 | + | name: "relative dir becomes a directory prompt", | |
| 34 | + | arg: "internal", | |
| 35 | + | cwd: tmp, | |
| 36 | + | want: "Read and understand everything in the directory internal.", | |
| 37 | + | }, | |
| 38 | + | { | |
| 39 | + | name: "absolute dir under cwd is shown as a relative path", | |
| 40 | + | arg: subdir, | |
| 41 | + | cwd: tmp, | |
| 42 | + | want: "Read and understand everything in the directory internal.", | |
| 43 | + | }, | |
| 44 | + | { | |
| 45 | + | name: "relative file becomes a file prompt", | |
| 46 | + | arg: "main.go", | |
| 47 | + | cwd: tmp, | |
| 48 | + | want: "Read and understand the file main.go.", | |
| 49 | + | }, | |
| 50 | + | { | |
| 51 | + | name: "absolute file under cwd is shown as a relative path", | |
| 52 | + | arg: file, | |
| 53 | + | cwd: tmp, | |
| 54 | + | want: "Read and understand the file main.go.", | |
| 55 | + | }, | |
| 56 | + | { | |
| 57 | + | name: "missing path falls back to the directory phrasing", | |
| 58 | + | arg: "does-not-exist", | |
| 59 | + | cwd: tmp, | |
| 60 | + | want: "Read and understand everything in the directory does-not-exist.", | |
| 61 | + | }, | |
| 62 | + | { | |
| 63 | + | name: "absolute path outside cwd keeps its absolute form", | |
| 64 | + | arg: subdir, | |
| 65 | + | cwd: filepath.Join(tmp, "elsewhere"), | |
| 66 | + | want: "Read and understand everything in the directory " + subdir + ".", | |
| 67 | + | }, | |
| 68 | + | { | |
| 69 | + | name: "leading and trailing whitespace are stripped", | |
| 70 | + | arg: " main.go ", | |
| 71 | + | cwd: tmp, | |
| 72 | + | want: "Read and understand the file main.go.", | |
| 73 | + | }, | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | for _, tc := range cases { | |
| 77 | + | t.Run(tc.name, func(t *testing.T) { | |
| 78 | + | got := buildStudyPrompt(tc.arg, tc.cwd) | |
| 79 | + | if got != tc.want { | |
| 80 | + | t.Fatalf("buildStudyPrompt(%q, %q)\n got: %q\n want: %q", tc.arg, tc.cwd, got, tc.want) | |
| 81 | + | } | |
| 82 | + | }) | |
| 83 | + | } | |
| 84 | + | } | |
| @@ -0,0 +1,84 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestBuildStudyPrompt(t *testing.T) { | |||
| 10 | + | tmp := t.TempDir() | |||
| 11 | + | subdir := filepath.Join(tmp, "internal") | |||
| 12 | + | if err := os.Mkdir(subdir, 0o755); err != nil { | |||
| 13 | + | t.Fatal(err) | |||
| 14 | + | } | |||
| 15 | + | file := filepath.Join(tmp, "main.go") | |||
| 16 | + | if err := os.WriteFile(file, []byte("package main\n"), 0o644); err != nil { | |||
| 17 | + | t.Fatal(err) | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | cases := []struct { | |||
| 21 | + | name string | |||
| 22 | + | arg string | |||
| 23 | + | cwd string | |||
| 24 | + | want string | |||
| 25 | + | }{ | |||
| 26 | + | { | |||
| 27 | + | name: "empty arg keeps the original cwd prompt", | |||
| 28 | + | arg: "", | |||
| 29 | + | cwd: tmp, | |||
| 30 | + | want: "Read and understand everything in the current directory.", | |||
| 31 | + | }, | |||
| 32 | + | { | |||
| 33 | + | name: "relative dir becomes a directory prompt", | |||
| 34 | + | arg: "internal", | |||
| 35 | + | cwd: tmp, | |||
| 36 | + | want: "Read and understand everything in the directory internal.", | |||
| 37 | + | }, | |||
| 38 | + | { | |||
| 39 | + | name: "absolute dir under cwd is shown as a relative path", | |||
| 40 | + | arg: subdir, | |||
| 41 | + | cwd: tmp, | |||
| 42 | + | want: "Read and understand everything in the directory internal.", | |||
| 43 | + | }, | |||
| 44 | + | { | |||
| 45 | + | name: "relative file becomes a file prompt", | |||
| 46 | + | arg: "main.go", | |||
| 47 | + | cwd: tmp, | |||
| 48 | + | want: "Read and understand the file main.go.", | |||
| 49 | + | }, | |||
| 50 | + | { | |||
| 51 | + | name: "absolute file under cwd is shown as a relative path", | |||
| 52 | + | arg: file, | |||
| 53 | + | cwd: tmp, | |||
| 54 | + | want: "Read and understand the file main.go.", | |||
| 55 | + | }, | |||
| 56 | + | { | |||
| 57 | + | name: "missing path falls back to the directory phrasing", | |||
| 58 | + | arg: "does-not-exist", | |||
| 59 | + | cwd: tmp, | |||
| 60 | + | want: "Read and understand everything in the directory does-not-exist.", | |||
| 61 | + | }, | |||
| 62 | + | { | |||
| 63 | + | name: "absolute path outside cwd keeps its absolute form", | |||
| 64 | + | arg: subdir, | |||
| 65 | + | cwd: filepath.Join(tmp, "elsewhere"), | |||
| 66 | + | want: "Read and understand everything in the directory " + subdir + ".", | |||
| 67 | + | }, | |||
| 68 | + | { | |||
| 69 | + | name: "leading and trailing whitespace are stripped", | |||
| 70 | + | arg: " main.go ", | |||
| 71 | + | cwd: tmp, | |||
| 72 | + | want: "Read and understand the file main.go.", | |||
| 73 | + | }, | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | for _, tc := range cases { | |||
| 77 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 78 | + | got := buildStudyPrompt(tc.arg, tc.cwd) | |||
| 79 | + | if got != tc.want { | |||
| 80 | + | t.Fatalf("buildStudyPrompt(%q, %q)\n got: %q\n want: %q", tc.arg, tc.cwd, got, tc.want) | |||
| 81 | + | } | |||
| 82 | + | }) | |||
| 83 | + | } | |||
| 84 | + | } | |||