detached 81c913a

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:
190190 | `/swarm` | Spawn, monitor, and chat with background subagents. Each runs in parallel with your main session and shares its working directory. |
191191 | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. |
192192 | `/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`. |
194194 | `/jail` | Confine tools to the current directory. |
195195 | `/unjail` | Allow tools to touch paths outside again. |
196196 | `/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) {
23622362 }
23632363 }
23642364
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+
23652402 func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
23662403 parts := strings.Fields(cmd)
23672404 switch parts[0] {
@@ -2432,11 +2469,16 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
24322469 i.runCompact(ctx, false)
24332470 case "/study":
24342471 // 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)
24402482 i.mu.Lock()
24412483 busy := i.busy
24422484 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 file2471 // Canned prompt that tells the agent to read every file
2435- // in the current directory so its later turns have the2472+ // in some target so its later turns have the whole thing
2436- // whole project in context. Dispatched through the normal2473+ // in context. With no argument, the target is the current
2437- // queue-or-start path so it behaves identically to2474+ // 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.busy2483 busy := i.busy
2442 ag := i.agent2484 ag := i.agent
M internal/agent/modes/slash_suggest.go +1 -1
@@ -40,7 +40,7 @@ var slashCatalog = []slashCommand{
4040 {Name: "/session", Desc: "export the current session to a .zotsession file, or import one"},
4141 {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"},
4242 {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"},
4444 {Name: "/btw", Desc: "side-chat that doesn't add to the main thread (saves tokens)"},
4545 {Name: "/jail", Desc: "confine tools to the current directory"},
4646 {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+}

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 477

↑↓ moveenter openesc close