diff --git a/README.md b/README.md index e9aeb70..e0b56d6 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands: | `/swarm` | Spawn, monitor, and chat with background subagents. Each runs in parallel with your main session and shares its working directory. | | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. | | `/compact` | Summarize the transcript into one message to free up context. | -| `/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. | +| `/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`. | | `/jail` | Confine tools to the current directory. | | `/unjail` | Allow tools to touch paths outside again. | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | diff --git a/internal/agent/modes/interactive.go b/internal/agent/modes/interactive.go index 84d79b1..ad11137 100644 --- a/internal/agent/modes/interactive.go +++ b/internal/agent/modes/interactive.go @@ -2362,6 +2362,43 @@ func (i *Interactive) applySettingToggle(key string, value bool) { } } +// buildStudyPrompt returns the canned prompt the /study command +// submits to the agent. +// +// With no argument, /study targets the current directory — the +// historical behaviour. With an argument, /study targets that path +// instead; either a directory ("read every file in here") or a +// single file ("read this file"). The argument can be: +// +// - a relative path (resolved against cwd) +// - an absolute path +// - an @-picker chip, which has already been expanded to an +// absolute path by expandFileChips before runSlash sees it +// +// The path is stat'd to pick the right wording ("directory" vs +// "file"). If the path doesn't exist, we still build a sensible +// prompt rather than erroring — the agent will surface the +// missing-file failure itself when it tries to read it, which is +// more useful than a refusal here. +func buildStudyPrompt(arg, cwd string) string { + arg = strings.TrimSpace(arg) + if arg == "" { + return "Read and understand everything in the current directory." + } + abs := arg + if !filepath.IsAbs(abs) { + abs = filepath.Join(cwd, abs) + } + display := arg + if rel, err := filepath.Rel(cwd, abs); err == nil && !strings.HasPrefix(rel, "..") { + display = rel + } + if info, err := os.Stat(abs); err == nil && !info.IsDir() { + return "Read and understand the file " + display + "." + } + return "Read and understand everything in the directory " + display + "." +} + func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { parts := strings.Fields(cmd) switch parts[0] { @@ -2432,11 +2469,16 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { i.runCompact(ctx, false) case "/study": // Canned prompt that tells the agent to read every file - // in the current directory so its later turns have the - // whole project in context. Dispatched through the normal - // queue-or-start path so it behaves identically to - // typing the prompt by hand. - const studyPrompt = "Read and understand everything in the current directory." + // in some target so its later turns have the whole thing + // in context. With no argument, the target is the current + // directory. With an argument, the target is whatever the + // user passed — typed by hand, drag-dropped, or selected + // via the @ file picker (which is why we accept both files + // and directories; the @-picker chips for both have already + // been expanded to absolute paths by expandFileChips above). + // Dispatched through the normal queue-or-start path so it + // behaves identically to typing the prompt by hand. + studyPrompt := buildStudyPrompt(strings.TrimSpace(strings.TrimPrefix(cmd, parts[0])), i.cfg.CWD) i.mu.Lock() busy := i.busy ag := i.agent diff --git a/internal/agent/modes/slash_suggest.go b/internal/agent/modes/slash_suggest.go index c09b2a5..fbaed56 100644 --- a/internal/agent/modes/slash_suggest.go +++ b/internal/agent/modes/slash_suggest.go @@ -40,7 +40,7 @@ var slashCatalog = []slashCommand{ {Name: "/session", Desc: "export the current session to a .zotsession file, or import one"}, {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump )"}, {Name: "/compact", Desc: "summarize and replace the transcript to free up context"}, - {Name: "/study", Desc: "read every file in the current directory so the agent has full project context"}, + {Name: "/study", Desc: "read every file in the cwd (or a passed file/dir) so the agent has full context"}, {Name: "/btw", Desc: "side-chat that doesn't add to the main thread (saves tokens)"}, {Name: "/jail", Desc: "confine tools to the current directory"}, {Name: "/unjail", Desc: "allow tools to touch paths outside this directory"}, diff --git a/internal/agent/modes/study_test.go b/internal/agent/modes/study_test.go new file mode 100644 index 0000000..abb04fd --- /dev/null +++ b/internal/agent/modes/study_test.go @@ -0,0 +1,84 @@ +package modes + +import ( + "os" + "path/filepath" + "testing" +) + +func TestBuildStudyPrompt(t *testing.T) { + tmp := t.TempDir() + subdir := filepath.Join(tmp, "internal") + if err := os.Mkdir(subdir, 0o755); err != nil { + t.Fatal(err) + } + file := filepath.Join(tmp, "main.go") + if err := os.WriteFile(file, []byte("package main\n"), 0o644); err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + arg string + cwd string + want string + }{ + { + name: "empty arg keeps the original cwd prompt", + arg: "", + cwd: tmp, + want: "Read and understand everything in the current directory.", + }, + { + name: "relative dir becomes a directory prompt", + arg: "internal", + cwd: tmp, + want: "Read and understand everything in the directory internal.", + }, + { + name: "absolute dir under cwd is shown as a relative path", + arg: subdir, + cwd: tmp, + want: "Read and understand everything in the directory internal.", + }, + { + name: "relative file becomes a file prompt", + arg: "main.go", + cwd: tmp, + want: "Read and understand the file main.go.", + }, + { + name: "absolute file under cwd is shown as a relative path", + arg: file, + cwd: tmp, + want: "Read and understand the file main.go.", + }, + { + name: "missing path falls back to the directory phrasing", + arg: "does-not-exist", + cwd: tmp, + want: "Read and understand everything in the directory does-not-exist.", + }, + { + name: "absolute path outside cwd keeps its absolute form", + arg: subdir, + cwd: filepath.Join(tmp, "elsewhere"), + want: "Read and understand everything in the directory " + subdir + ".", + }, + { + name: "leading and trailing whitespace are stripped", + arg: " main.go ", + cwd: tmp, + want: "Read and understand the file main.go.", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := buildStudyPrompt(tc.arg, tc.cwd) + if got != tc.want { + t.Fatalf("buildStudyPrompt(%q, %q)\n got: %q\n want: %q", tc.arg, tc.cwd, got, tc.want) + } + }) + } +}