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)
This commit is contained in:
patriceckhart 2026-05-19 18:37:27 +02:00
parent e0c933ad7e
commit 81c913aef9
4 changed files with 133 additions and 7 deletions

View file

@ -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). |

View file

@ -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

View file

@ -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 <text>)"},
{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"},

View file

@ -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)
}
})
}
}