detached e48f4dd

feat: runtime cwd switching via hidden /cd and extension submit_slash

Adds the plumbing needed to let an extension jump the running zot
session into a different working directory without restarting the
process. Two pieces:

1) Hidden /cd <path> slash command

   * accepts ~/abs/relative paths, validates the target is a real
     directory
   * cancels the active turn, flushes + closes the current session
   * re-roots the shared sandbox (the /jail state is preserved
     verbatim - if jailed it stays jailed, just pointed at the new
     cwd)
   * rebuilds the agent via the existing buildAgent() so tools,
     AGENTS.md addendum, system prompt, and sessions dir all bind
     to the new cwd
   * opens a fresh session in the new cwd's bucket (matches the
     semantics of relaunching with zot --cwd <path>)
   * pushes the new state into the running Interactive via a new
     ApplyChangedCWD method and re-scopes the swarm dashboard

   /cd is not in slash_suggest's catalog, not in /help, not in the
   README. It's in a new hiddenSlashCommands list so the dispatcher
   accepts it without surfacing the verb to autocomplete. The
   slashCancelsTurn switch returns true for /cd so it never races
   with a streaming turn.

   InteractiveConfig.ChangeCWD is the optional host hook; embedders
   that don't wire it surface 'host did not wire ChangeCWD' instead
   of no-oping.

2) submit_slash extension protocol frame

   Extensions can now send a spontaneous {type:'submit_slash',
   text:'/...'} frame from any handler (notably panel_key) to run
   a slash command in the host TUI as if the user had typed it.
   The host refuses anything that doesn't start with '/' and logs
   the refusal to the extension's stderr log file, so a misbehaving
   extension can't sneak a plain-text model prompt through this
   path.

   HostHooks gains a SubmitSlash(text string) method; the three
   existing implementers (interactive, non-interactive, rpc) and
   the test stub now satisfy it. Only the interactive hook does
   anything; the rest no-op since slash commands aren't meaningful
   outside the TUI.

   Interactive.SubmitSlash routes the text through runSlash with
   the same cancel-active-turn-if-destructive treatment the editor
   uses for typed commands.

The workspaces extension drives this end-to-end: pressing Enter on
a row sends panel_close + submit_slash '/cd <abs>', which jumps
zot into that directory in place.
commit
e48f4dda76cf8411a9ce9732ab075a3accd0ba4a
author
patriceckhart · · 2026-05-19 17:38 UTC
parent
ad93054
signature
unsigned
refs
tag: v0.1.85
7 files · +283 -6
M internal/agent/cli.go +127 -0
@@ -51,6 +51,11 @@ func (h *interactiveExtHooks) Submit(text string) {
5151 iv.Submit(text)
5252 }
5353 }
54+func (h *interactiveExtHooks) SubmitSlash(text string) {
55+ if iv := h.iv(); iv != nil {
56+ iv.SubmitSlash(text)
57+ }
58+}
5459 func (h *interactiveExtHooks) Insert(text string) {
5560 if iv := h.iv(); iv != nil {
5661 iv.Insert(text)
@@ -240,6 +245,7 @@ func (nonInteractiveExtHooks) Notify(ext, level, message string) {
240245 fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message)
241246 }
242247 func (nonInteractiveExtHooks) Submit(string) {}
248+func (nonInteractiveExtHooks) SubmitSlash(string) {}
243249 func (nonInteractiveExtHooks) Insert(string) {}
244250 func (nonInteractiveExtHooks) Display(string, string) {}
245251 func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec) {}
@@ -675,6 +681,126 @@ func runInteractive(ctx context.Context, args Args, version string) error {
675681 return nil
676682 }
677683
684+ // changeCWD switches the running session to a new working directory.
685+ // Wired into InteractiveConfig.ChangeCWD and invoked by the hidden
686+ // /cd slash command (which itself is only fired by the workspaces
687+ // extension's panel-key Enter handler today; the user can type /cd
688+ // directly but it's not in autocomplete / help / the README).
689+ //
690+ // Steps, in order:
691+ // 1. resolve + validate the new path (~ expansion, abs/rel)
692+ // 2. close the current session, flushing pending messages
693+ // 3. mutate captured args.CWD + r.CWD so future buildAgent
694+ // calls bind to the new cwd
695+ // 4. re-root the shared sandbox («·«) so /jail follows the
696+ // session into the new cwd instead of widening or silently
697+ // dropping
698+ // 5. rebuild the agent via buildAgent() so tools, AGENTS.md
699+ // addendum, system prompt, sessions dir all bind correctly
700+ // 6. open a fresh session in the new cwd's bucket
701+ // 7. push the new state into the running Interactive
702+ // 8. re-scope the swarm dashboard to the freshly-opened session
703+ //
704+ // The /jail state is preserved verbatim: if the sandbox was locked
705+ // to the old cwd, it stays locked, just re-pointed at the new one.
706+ changeCWD := func(path string) error {
707+ if path == "" {
708+ return fmt.Errorf("empty path")
709+ }
710+ // ~ expansion.
711+ if path == "~" || strings.HasPrefix(path, "~/") {
712+ home, herr := os.UserHomeDir()
713+ if herr != nil || home == "" {
714+ return fmt.Errorf("cannot expand ~: %v", herr)
715+ }
716+ if path == "~" {
717+ path = home
718+ } else {
719+ path = filepath.Join(home, path[2:])
720+ }
721+ }
722+ if !filepath.IsAbs(path) {
723+ path = filepath.Join(args.CWD, path)
724+ }
725+ absPath, err := filepath.Abs(path)
726+ if err != nil {
727+ return err
728+ }
729+ info, err := os.Stat(absPath)
730+ if err != nil {
731+ return err
732+ }
733+ if !info.IsDir() {
734+ return fmt.Errorf("not a directory: %s", absPath)
735+ }
736+
737+ currentAg := ag
738+ if currentAg == nil {
739+ return fmt.Errorf("no agent running; log in first")
740+ }
741+
742+ // Close the current session before we drop the reference.
743+ // Per-message persistence keeps it current already; this is
744+ // a defensive flush + final fsync via Close.
745+ persistMu.Lock()
746+ if sess != nil {
747+ writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
748+ _ = sess.Close()
749+ sess = nil
750+ }
751+ sessBaselineMsgs = 0
752+ persistMu.Unlock()
753+
754+ // Mutate captured state so subsequent agent rebuilds and
755+ // session opens see the new cwd.
756+ wasJailed := sharedSandbox != nil && sharedSandbox.Locked()
757+ args.CWD = absPath
758+ r.CWD = absPath
759+ if sharedSandbox != nil {
760+ sharedSandbox.Root = absPath
761+ if wasJailed {
762+ sharedSandbox.Lock()
763+ } else {
764+ sharedSandbox.Unlock()
765+ }
766+ }
767+
768+ // Rebuild the agent so tools / AGENTS.md / system prompt
769+ // re-bind to the new cwd. buildAgent() reads from args + r.
770+ newAg, newProvider, newModel, berr := buildAgent()
771+ if berr != nil {
772+ return fmt.Errorf("rebuild agent: %v", berr)
773+ }
774+ ag = newAg
775+
776+ // Fresh session in the new cwd's bucket. We bypass
777+ // openOrCreateSession's --continue / --resume branches
778+ // because /cd's semantics are "start fresh here", matching
779+ // what relaunching `zot --cwd <path>` would do today.
780+ if !args.NoSess {
781+ core.PruneEmptySessions(ZotHome(), absPath)
782+ newSess, serr := core.NewSession(ZotHome(), absPath, newProvider, newModel, version)
783+ if serr != nil {
784+ return fmt.Errorf("open session in %s: %v", absPath, serr)
785+ }
786+ persistMu.Lock()
787+ sess = newSess
788+ sessBaselineMsgs = 0
789+ persistMu.Unlock()
790+ }
791+
792+ // Push the new state into the running Interactive.
793+ if iv != nil {
794+ iv.ApplyChangedCWD(newAg, newProvider, newModel, absPath)
795+ }
796+
797+ // Re-scope the swarm dashboard to the new session.
798+ if swarmMgr != nil && sess != nil {
799+ swarmMgr.SetActiveSession(sess.ID)
800+ }
801+ return nil
802+ }
803+
678804 term := tui.NewProcTerm()
679805
680806 // Kick off the async update check so the banner can appear when the
@@ -786,6 +912,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
786912 return out
787913 },
788914 LoadSession: loadSession,
915+ ChangeCWD: changeCWD,
789916 CurrentSessionPath: func() string {
790917 if sess == nil {
791918 return ""
@@ -51,6 +51,11 @@ func (h *interactiveExtHooks) Submit(text string) {
51 iv.Submit(text)51 iv.Submit(text)
52 }52 }
53 }53 }
54+func (h *interactiveExtHooks) SubmitSlash(text string) {
55+ if iv := h.iv(); iv != nil {
56+ iv.SubmitSlash(text)
57+ }
58+}
54 func (h *interactiveExtHooks) Insert(text string) {59 func (h *interactiveExtHooks) Insert(text string) {
55 if iv := h.iv(); iv != nil {60 if iv := h.iv(); iv != nil {
56 iv.Insert(text)61 iv.Insert(text)
@@ -240,6 +245,7 @@ func (nonInteractiveExtHooks) Notify(ext, level, message string) {
240 fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message)245 fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message)
241 }246 }
242 func (nonInteractiveExtHooks) Submit(string) {}247 func (nonInteractiveExtHooks) Submit(string) {}
248+func (nonInteractiveExtHooks) SubmitSlash(string) {}
243 func (nonInteractiveExtHooks) Insert(string) {}249 func (nonInteractiveExtHooks) Insert(string) {}
244 func (nonInteractiveExtHooks) Display(string, string) {}250 func (nonInteractiveExtHooks) Display(string, string) {}
245 func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec) {}251 func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec) {}
@@ -675,6 +681,126 @@ func runInteractive(ctx context.Context, args Args, version string) error {
675 return nil681 return nil
676 }682 }
677 683
684+ // changeCWD switches the running session to a new working directory.
685+ // Wired into InteractiveConfig.ChangeCWD and invoked by the hidden
686+ // /cd slash command (which itself is only fired by the workspaces
687+ // extension's panel-key Enter handler today; the user can type /cd
688+ // directly but it's not in autocomplete / help / the README).
689+ //
690+ // Steps, in order:
691+ // 1. resolve + validate the new path (~ expansion, abs/rel)
692+ // 2. close the current session, flushing pending messages
693+ // 3. mutate captured args.CWD + r.CWD so future buildAgent
694+ // calls bind to the new cwd
695+ // 4. re-root the shared sandbox («·«) so /jail follows the
696+ // session into the new cwd instead of widening or silently
697+ // dropping
698+ // 5. rebuild the agent via buildAgent() so tools, AGENTS.md
699+ // addendum, system prompt, sessions dir all bind correctly
700+ // 6. open a fresh session in the new cwd's bucket
701+ // 7. push the new state into the running Interactive
702+ // 8. re-scope the swarm dashboard to the freshly-opened session
703+ //
704+ // The /jail state is preserved verbatim: if the sandbox was locked
705+ // to the old cwd, it stays locked, just re-pointed at the new one.
706+ changeCWD := func(path string) error {
707+ if path == "" {
708+ return fmt.Errorf("empty path")
709+ }
710+ // ~ expansion.
711+ if path == "~" || strings.HasPrefix(path, "~/") {
712+ home, herr := os.UserHomeDir()
713+ if herr != nil || home == "" {
714+ return fmt.Errorf("cannot expand ~: %v", herr)
715+ }
716+ if path == "~" {
717+ path = home
718+ } else {
719+ path = filepath.Join(home, path[2:])
720+ }
721+ }
722+ if !filepath.IsAbs(path) {
723+ path = filepath.Join(args.CWD, path)
724+ }
725+ absPath, err := filepath.Abs(path)
726+ if err != nil {
727+ return err
728+ }
729+ info, err := os.Stat(absPath)
730+ if err != nil {
731+ return err
732+ }
733+ if !info.IsDir() {
734+ return fmt.Errorf("not a directory: %s", absPath)
735+ }
736+
737+ currentAg := ag
738+ if currentAg == nil {
739+ return fmt.Errorf("no agent running; log in first")
740+ }
741+
742+ // Close the current session before we drop the reference.
743+ // Per-message persistence keeps it current already; this is
744+ // a defensive flush + final fsync via Close.
745+ persistMu.Lock()
746+ if sess != nil {
747+ writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
748+ _ = sess.Close()
749+ sess = nil
750+ }
751+ sessBaselineMsgs = 0
752+ persistMu.Unlock()
753+
754+ // Mutate captured state so subsequent agent rebuilds and
755+ // session opens see the new cwd.
756+ wasJailed := sharedSandbox != nil && sharedSandbox.Locked()
757+ args.CWD = absPath
758+ r.CWD = absPath
759+ if sharedSandbox != nil {
760+ sharedSandbox.Root = absPath
761+ if wasJailed {
762+ sharedSandbox.Lock()
763+ } else {
764+ sharedSandbox.Unlock()
765+ }
766+ }
767+
768+ // Rebuild the agent so tools / AGENTS.md / system prompt
769+ // re-bind to the new cwd. buildAgent() reads from args + r.
770+ newAg, newProvider, newModel, berr := buildAgent()
771+ if berr != nil {
772+ return fmt.Errorf("rebuild agent: %v", berr)
773+ }
774+ ag = newAg
775+
776+ // Fresh session in the new cwd's bucket. We bypass
777+ // openOrCreateSession's --continue / --resume branches
778+ // because /cd's semantics are "start fresh here", matching
779+ // what relaunching `zot --cwd <path>` would do today.
780+ if !args.NoSess {
781+ core.PruneEmptySessions(ZotHome(), absPath)
782+ newSess, serr := core.NewSession(ZotHome(), absPath, newProvider, newModel, version)
783+ if serr != nil {
784+ return fmt.Errorf("open session in %s: %v", absPath, serr)
785+ }
786+ persistMu.Lock()
787+ sess = newSess
788+ sessBaselineMsgs = 0
789+ persistMu.Unlock()
790+ }
791+
792+ // Push the new state into the running Interactive.
793+ if iv != nil {
794+ iv.ApplyChangedCWD(newAg, newProvider, newModel, absPath)
795+ }
796+
797+ // Re-scope the swarm dashboard to the new session.
798+ if swarmMgr != nil && sess != nil {
799+ swarmMgr.SetActiveSession(sess.ID)
800+ }
801+ return nil
802+ }
803+
678 term := tui.NewProcTerm()804 term := tui.NewProcTerm()
679 805
680 // Kick off the async update check so the banner can appear when the806 // Kick off the async update check so the banner can appear when the
@@ -786,6 +912,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
786 return out912 return out
787 },913 },
788 LoadSession: loadSession,914 LoadSession: loadSession,
915+ ChangeCWD: changeCWD,
789 CurrentSessionPath: func() string {916 CurrentSessionPath: func() string {
790 if sess == nil {917 if sess == nil {
791 return ""918 return ""
M internal/agent/extensions/manager.go +21 -0
@@ -103,6 +103,13 @@ type HostHooks interface {
103103 // running it through the agent loop.
104104 Submit(text string)
105105
106+ // SubmitSlash runs text as a slash command in the TUI as if the
107+ // user had typed it (text must start with '/'). Unlike Submit it
108+ // does NOT run text through the model. Wired to the spontaneous
109+ // submit_slash frame from extensions; ignored when the host is
110+ // not interactive.
111+ SubmitSlash(text string)
112+
106113 // Insert places text at the cursor in the editor.
107114 Insert(text string)
108115
@@ -729,6 +736,20 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
729736 if err := json.Unmarshal(line, &n); err == nil {
730737 m.hooks.Notify(ext.Manifest.Name, n.Level, n.Message)
731738 }
739+ case "submit_slash":
740+ // Spontaneous request to invoke a slash command in the
741+ // TUI. Refused unless the payload looks like a slash
742+ // command so a misbehaving extension can't sneak a model
743+ // prompt through this path.
744+ var s extproto.SubmitSlashFromExt
745+ if err := json.Unmarshal(line, &s); err == nil {
746+ text := strings.TrimSpace(s.Text)
747+ if strings.HasPrefix(text, "/") {
748+ m.hooks.SubmitSlash(text)
749+ } else {
750+ fmt.Fprintf(ext.logFile, "[zot] submit_slash refused (not a slash command): %q\n", s.Text)
751+ }
752+ }
732753 case "command_response":
733754 var cr extproto.CommandResponseFromExt
734755 if err := json.Unmarshal(line, &cr); err == nil {
@@ -103,6 +103,13 @@ type HostHooks interface {
103 // running it through the agent loop.103 // running it through the agent loop.
104 Submit(text string)104 Submit(text string)
105 105
106+ // SubmitSlash runs text as a slash command in the TUI as if the
107+ // user had typed it (text must start with '/'). Unlike Submit it
108+ // does NOT run text through the model. Wired to the spontaneous
109+ // submit_slash frame from extensions; ignored when the host is
110+ // not interactive.
111+ SubmitSlash(text string)
112+
106 // Insert places text at the cursor in the editor.113 // Insert places text at the cursor in the editor.
107 Insert(text string)114 Insert(text string)
108 115
@@ -729,6 +736,20 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
729 if err := json.Unmarshal(line, &n); err == nil {736 if err := json.Unmarshal(line, &n); err == nil {
730 m.hooks.Notify(ext.Manifest.Name, n.Level, n.Message)737 m.hooks.Notify(ext.Manifest.Name, n.Level, n.Message)
731 }738 }
739+ case "submit_slash":
740+ // Spontaneous request to invoke a slash command in the
741+ // TUI. Refused unless the payload looks like a slash
742+ // command so a misbehaving extension can't sneak a model
743+ // prompt through this path.
744+ var s extproto.SubmitSlashFromExt
745+ if err := json.Unmarshal(line, &s); err == nil {
746+ text := strings.TrimSpace(s.Text)
747+ if strings.HasPrefix(text, "/") {
748+ m.hooks.SubmitSlash(text)
749+ } else {
750+ fmt.Fprintf(ext.logFile, "[zot] submit_slash refused (not a slash command): %q\n", s.Text)
751+ }
752+ }
732 case "command_response":753 case "command_response":
733 var cr extproto.CommandResponseFromExt754 var cr extproto.CommandResponseFromExt
734 if err := json.Unmarshal(line, &cr); err == nil {755 if err := json.Unmarshal(line, &cr); err == nil {
M internal/agent/extensions/manager_test.go +3 -2
@@ -25,8 +25,9 @@ func (s *stubHooks) Notify(name, level, message string) {
2525 defer s.mu.Unlock()
2626 s.notifies = append(s.notifies, name+":"+level+":"+message)
2727 }
28-func (s *stubHooks) Submit(string) {}
29-func (s *stubHooks) Insert(string) {}
28+func (s *stubHooks) Submit(string) {}
29+func (s *stubHooks) SubmitSlash(string) {}
30+func (s *stubHooks) Insert(string) {}
3031 func (s *stubHooks) Display(name, text string) {
3132 s.mu.Lock()
3233 defer s.mu.Unlock()
@@ -25,8 +25,9 @@ func (s *stubHooks) Notify(name, level, message string) {
25 defer s.mu.Unlock()25 defer s.mu.Unlock()
26 s.notifies = append(s.notifies, name+":"+level+":"+message)26 s.notifies = append(s.notifies, name+":"+level+":"+message)
27 }27 }
28-func (s *stubHooks) Submit(string) {}28+func (s *stubHooks) Submit(string) {}
29-func (s *stubHooks) Insert(string) {}29+func (s *stubHooks) SubmitSlash(string) {}
30+func (s *stubHooks) Insert(string) {}
30 func (s *stubHooks) Display(name, text string) {31 func (s *stubHooks) Display(name, text string) {
31 s.mu.Lock()32 s.mu.Lock()
32 defer s.mu.Unlock()33 defer s.mu.Unlock()
M internal/agent/modes/interactive.go +104 -0
@@ -101,6 +101,16 @@ type InteractiveConfig struct {
101101 // callback returns the new agent message slice so the TUI can invalidate.
102102 LoadSession func(path string) error
103103
104+ // ChangeCWD switches the running zot session's working directory
105+ // to path. The host closes the current session, rebuilds the
106+ // agent so tools / AGENTS.md / sandbox bind to the new cwd, and
107+ // opens a fresh session there. Returns an error if path doesn't
108+ // exist, isn't a directory, or the host can't rebuild the agent.
109+ //
110+ // Optional: not wired by every embedder. When nil the hidden /cd
111+ // command surfaces a clear error rather than no-oping.
112+ ChangeCWD func(path string) error
113+
104114 // CurrentSessionPath returns the path of the live session file
105115 // on disk (the one every AppendMessage writes to). Used by
106116 // /session export so the exporter ships the exact bytes on
@@ -2202,6 +2212,55 @@ func (i *Interactive) Submit(text string) {
22022212 i.startTurn(i.runCtx, text)
22032213 }
22042214
2215+// ApplyChangedCWD is called by the host after a successful /cd hook.
2216+// The host has already rebuilt the agent and opened a fresh session
2217+// in the new cwd; this method swaps the fresh agent into the running
2218+// TUI, updates the displayed cwd, clears the transcript display
2219+// caches, and points the file picker at the new directory.
2220+//
2221+// The fresh agent's transcript is empty (new session) so the chat
2222+// view starts blank, matching what relaunching `zot --cwd <path>`
2223+// would show. Cost meters reset.
2224+func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {
2225+ i.mu.Lock()
2226+ i.agent = ag
2227+ i.cfg.CWD = cwd
2228+ i.cfg.Provider = provider
2229+ i.cfg.Model = model
2230+ i.toolCalls = map[string]*tui.ToolCallView{}
2231+ i.toolOrder = nil
2232+ i.helpBlock = nil
2233+ i.parkedTurn = 0
2234+ i.statusErr = ""
2235+ i.mu.Unlock()
2236+ i.fileSuggest.Reset()
2237+ i.fileSuggest.SetCWD(cwd)
2238+ i.invalidate()
2239+}
2240+
2241+// SubmitSlash runs text as a slash command in the TUI as if the user
2242+// had typed it. text must start with '/' — callers that hand it
2243+// plain prose silently get a no-op so a misbehaving extension can't
2244+// run a stray prompt through this path. Read-only commands run in
2245+// place; commands that would mutate the transcript or replace the
2246+// agent cancel the active turn first via the same path the editor
2247+// uses for typed slash commands.
2248+func (i *Interactive) SubmitSlash(text string) {
2249+ text = strings.TrimSpace(text)
2250+ if !strings.HasPrefix(text, "/") {
2251+ return
2252+ }
2253+ head := text
2254+ if idx := strings.IndexAny(text, " \t"); idx >= 0 {
2255+ head = text[:idx]
2256+ }
2257+ if slashCancelsTurn(head) {
2258+ i.cancelAndWaitForIdle()
2259+ }
2260+ i.runSlash(i.runCtx, text)
2261+ i.invalidate()
2262+}
2263+
22052264 // SubmitOrQueue runs text immediately if the agent is idle, or
22062265 // appends it to the pending queue if a turn is already in flight.
22072266 // Used by the telegram bridge (and by the editor submit path) so
@@ -2705,6 +2764,51 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
27052764 break
27062765 }
27072766 i.startTurn(ctx, studyPrompt)
2767+ case "/cd":
2768+ // Hidden command: switch the running session's cwd. Not in
2769+ // slash_suggest, not in /help. Used by the workspaces
2770+ // extension's panel-key Enter handler so picking a row
2771+ // jumps zot into that directory without relaunching.
2772+ //
2773+ // Recovers the raw argument (path) from the original cmd
2774+ // string rather than parts, so paths with spaces survive.
2775+ // The host's ChangeCWD hook handles validation, session
2776+ // close + reopen, agent rebuild, sandbox re-rooting, and
2777+ // re-jail-if-jailed semantics.
2778+ if i.cfg.ChangeCWD == nil {
2779+ i.mu.Lock()
2780+ i.statusErr = "/cd unavailable: host did not wire ChangeCWD"
2781+ i.mu.Unlock()
2782+ break
2783+ }
2784+ path := strings.TrimSpace(strings.TrimPrefix(cmd, parts[0]))
2785+ if path == "" {
2786+ i.mu.Lock()
2787+ i.statusErr = "/cd: missing path"
2788+ i.mu.Unlock()
2789+ break
2790+ }
2791+ if err := i.cfg.ChangeCWD(path); err != nil {
2792+ i.mu.Lock()
2793+ i.statusErr = "/cd: " + err.Error()
2794+ i.statusOK = ""
2795+ i.mu.Unlock()
2796+ break
2797+ }
2798+ // ChangeCWD has already updated i.cfg.CWD and swapped the
2799+ // agent + session. Reset transient TUI state so the new
2800+ // session opens clean.
2801+ i.mu.Lock()
2802+ i.toolCalls = map[string]*tui.ToolCallView{}
2803+ i.toolOrder = nil
2804+ i.helpBlock = nil
2805+ i.parkedTurn = 0
2806+ i.statusOK = "cwd " + i.cfg.CWD
2807+ i.statusErr = ""
2808+ i.mu.Unlock()
2809+ i.fileSuggest.Reset()
2810+ i.fileSuggest.SetCWD(i.cfg.CWD)
2811+ i.invalidate()
27082812 case "/jail":
27092813 if i.cfg.Sandbox == nil {
27102814 i.mu.Lock()
@@ -101,6 +101,16 @@ type InteractiveConfig struct {
101 // callback returns the new agent message slice so the TUI can invalidate.101 // callback returns the new agent message slice so the TUI can invalidate.
102 LoadSession func(path string) error102 LoadSession func(path string) error
103 103
104+ // ChangeCWD switches the running zot session's working directory
105+ // to path. The host closes the current session, rebuilds the
106+ // agent so tools / AGENTS.md / sandbox bind to the new cwd, and
107+ // opens a fresh session there. Returns an error if path doesn't
108+ // exist, isn't a directory, or the host can't rebuild the agent.
109+ //
110+ // Optional: not wired by every embedder. When nil the hidden /cd
111+ // command surfaces a clear error rather than no-oping.
112+ ChangeCWD func(path string) error
113+
104 // CurrentSessionPath returns the path of the live session file114 // CurrentSessionPath returns the path of the live session file
105 // on disk (the one every AppendMessage writes to). Used by115 // on disk (the one every AppendMessage writes to). Used by
106 // /session export so the exporter ships the exact bytes on116 // /session export so the exporter ships the exact bytes on
@@ -2202,6 +2212,55 @@ func (i *Interactive) Submit(text string) {
2202 i.startTurn(i.runCtx, text)2212 i.startTurn(i.runCtx, text)
2203 }2213 }
2204 2214
2215+// ApplyChangedCWD is called by the host after a successful /cd hook.
2216+// The host has already rebuilt the agent and opened a fresh session
2217+// in the new cwd; this method swaps the fresh agent into the running
2218+// TUI, updates the displayed cwd, clears the transcript display
2219+// caches, and points the file picker at the new directory.
2220+//
2221+// The fresh agent's transcript is empty (new session) so the chat
2222+// view starts blank, matching what relaunching `zot --cwd <path>`
2223+// would show. Cost meters reset.
2224+func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {
2225+ i.mu.Lock()
2226+ i.agent = ag
2227+ i.cfg.CWD = cwd
2228+ i.cfg.Provider = provider
2229+ i.cfg.Model = model
2230+ i.toolCalls = map[string]*tui.ToolCallView{}
2231+ i.toolOrder = nil
2232+ i.helpBlock = nil
2233+ i.parkedTurn = 0
2234+ i.statusErr = ""
2235+ i.mu.Unlock()
2236+ i.fileSuggest.Reset()
2237+ i.fileSuggest.SetCWD(cwd)
2238+ i.invalidate()
2239+}
2240+
2241+// SubmitSlash runs text as a slash command in the TUI as if the user
2242+// had typed it. text must start with '/' — callers that hand it
2243+// plain prose silently get a no-op so a misbehaving extension can't
2244+// run a stray prompt through this path. Read-only commands run in
2245+// place; commands that would mutate the transcript or replace the
2246+// agent cancel the active turn first via the same path the editor
2247+// uses for typed slash commands.
2248+func (i *Interactive) SubmitSlash(text string) {
2249+ text = strings.TrimSpace(text)
2250+ if !strings.HasPrefix(text, "/") {
2251+ return
2252+ }
2253+ head := text
2254+ if idx := strings.IndexAny(text, " \t"); idx >= 0 {
2255+ head = text[:idx]
2256+ }
2257+ if slashCancelsTurn(head) {
2258+ i.cancelAndWaitForIdle()
2259+ }
2260+ i.runSlash(i.runCtx, text)
2261+ i.invalidate()
2262+}
2263+
2205 // SubmitOrQueue runs text immediately if the agent is idle, or2264 // SubmitOrQueue runs text immediately if the agent is idle, or
2206 // appends it to the pending queue if a turn is already in flight.2265 // appends it to the pending queue if a turn is already in flight.
2207 // Used by the telegram bridge (and by the editor submit path) so2266 // Used by the telegram bridge (and by the editor submit path) so
@@ -2705,6 +2764,51 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
2705 break2764 break
2706 }2765 }
2707 i.startTurn(ctx, studyPrompt)2766 i.startTurn(ctx, studyPrompt)
2767+ case "/cd":
2768+ // Hidden command: switch the running session's cwd. Not in
2769+ // slash_suggest, not in /help. Used by the workspaces
2770+ // extension's panel-key Enter handler so picking a row
2771+ // jumps zot into that directory without relaunching.
2772+ //
2773+ // Recovers the raw argument (path) from the original cmd
2774+ // string rather than parts, so paths with spaces survive.
2775+ // The host's ChangeCWD hook handles validation, session
2776+ // close + reopen, agent rebuild, sandbox re-rooting, and
2777+ // re-jail-if-jailed semantics.
2778+ if i.cfg.ChangeCWD == nil {
2779+ i.mu.Lock()
2780+ i.statusErr = "/cd unavailable: host did not wire ChangeCWD"
2781+ i.mu.Unlock()
2782+ break
2783+ }
2784+ path := strings.TrimSpace(strings.TrimPrefix(cmd, parts[0]))
2785+ if path == "" {
2786+ i.mu.Lock()
2787+ i.statusErr = "/cd: missing path"
2788+ i.mu.Unlock()
2789+ break
2790+ }
2791+ if err := i.cfg.ChangeCWD(path); err != nil {
2792+ i.mu.Lock()
2793+ i.statusErr = "/cd: " + err.Error()
2794+ i.statusOK = ""
2795+ i.mu.Unlock()
2796+ break
2797+ }
2798+ // ChangeCWD has already updated i.cfg.CWD and swapped the
2799+ // agent + session. Reset transient TUI state so the new
2800+ // session opens clean.
2801+ i.mu.Lock()
2802+ i.toolCalls = map[string]*tui.ToolCallView{}
2803+ i.toolOrder = nil
2804+ i.helpBlock = nil
2805+ i.parkedTurn = 0
2806+ i.statusOK = "cwd " + i.cfg.CWD
2807+ i.statusErr = ""
2808+ i.mu.Unlock()
2809+ i.fileSuggest.Reset()
2810+ i.fileSuggest.SetCWD(i.cfg.CWD)
2811+ i.invalidate()
2708 case "/jail":2812 case "/jail":
2709 if i.cfg.Sandbox == nil {2813 if i.cfg.Sandbox == nil {
2710 i.mu.Lock()2814 i.mu.Lock()
M internal/agent/modes/slash_suggest.go +17 -4
@@ -23,7 +23,7 @@ type slashCommand struct {
2323 // the streaming response without trouble.
2424 func slashCancelsTurn(head string) bool {
2525 switch head {
26- case "/clear", "/compact", "/logout", "/login", "/model", "/reload-ext":
26+ case "/clear", "/compact", "/logout", "/login", "/model", "/reload-ext", "/cd":
2727 return true
2828 }
2929 return false
@@ -166,9 +166,9 @@ func looksLikeSlashCommand(text string) bool {
166166 }
167167
168168 // isKnownSlashCommand reports whether text's head matches a registered
169-// slash command name in slashCatalog. Built-in only; extension
170-// commands are looked up separately by the dispatcher (which
171-// consults the extension manager).
169+// slash command name in slashCatalog or hiddenSlashCommands. Built-in
170+// only; extension commands are looked up separately by the dispatcher
171+// (which consults the extension manager).
172172 func isKnownSlashCommand(text string) bool {
173173 text = strings.TrimSpace(text)
174174 if text == "" || text[0] != '/' {
@@ -183,9 +183,22 @@ func isKnownSlashCommand(text string) bool {
183183 return true
184184 }
185185 }
186+ for _, h := range hiddenSlashCommands {
187+ if h == head {
188+ return true
189+ }
190+ }
186191 return false
187192 }
188193
194+// hiddenSlashCommands are dispatchable but intentionally absent from
195+// the autocomplete popup, /help, and the README. Used for commands
196+// driven by extensions or by other internal flows where surfacing
197+// the verb to humans would be confusing or premature.
198+var hiddenSlashCommands = []string{
199+ "/cd",
200+}
201+
189202 func newSlashSuggester() *slashSuggester { return &slashSuggester{} }
190203
191204 // matches returns the commands whose name has input as a prefix.
@@ -23,7 +23,7 @@ type slashCommand struct {
23 // the streaming response without trouble.23 // the streaming response without trouble.
24 func slashCancelsTurn(head string) bool {24 func slashCancelsTurn(head string) bool {
25 switch head {25 switch head {
26- case "/clear", "/compact", "/logout", "/login", "/model", "/reload-ext":26+ case "/clear", "/compact", "/logout", "/login", "/model", "/reload-ext", "/cd":
27 return true27 return true
28 }28 }
29 return false29 return false
@@ -166,9 +166,9 @@ func looksLikeSlashCommand(text string) bool {
166 }166 }
167 167
168 // isKnownSlashCommand reports whether text's head matches a registered168 // isKnownSlashCommand reports whether text's head matches a registered
169-// slash command name in slashCatalog. Built-in only; extension169+// slash command name in slashCatalog or hiddenSlashCommands. Built-in
170-// commands are looked up separately by the dispatcher (which170+// only; extension commands are looked up separately by the dispatcher
171-// consults the extension manager).171+// (which consults the extension manager).
172 func isKnownSlashCommand(text string) bool {172 func isKnownSlashCommand(text string) bool {
173 text = strings.TrimSpace(text)173 text = strings.TrimSpace(text)
174 if text == "" || text[0] != '/' {174 if text == "" || text[0] != '/' {
@@ -183,9 +183,22 @@ func isKnownSlashCommand(text string) bool {
183 return true183 return true
184 }184 }
185 }185 }
186+ for _, h := range hiddenSlashCommands {
187+ if h == head {
188+ return true
189+ }
190+ }
186 return false191 return false
187 }192 }
188 193
194+// hiddenSlashCommands are dispatchable but intentionally absent from
195+// the autocomplete popup, /help, and the README. Used for commands
196+// driven by extensions or by other internal flows where surfacing
197+// the verb to humans would be confusing or premature.
198+var hiddenSlashCommands = []string{
199+ "/cd",
200+}
201+
189 func newSlashSuggester() *slashSuggester { return &slashSuggester{} }202 func newSlashSuggester() *slashSuggester { return &slashSuggester{} }
190 203
191 // matches returns the commands whose name has input as a prefix.204 // matches returns the commands whose name has input as a prefix.
M internal/agent/rpc.go +1 -0
@@ -143,6 +143,7 @@ func (h *rpcExtHooks) Display(extName, text string) {
143143 }
144144 }
145145 func (h *rpcExtHooks) Submit(string) {} // ignored in rpc mode
146+func (h *rpcExtHooks) SubmitSlash(string) {} // ignored in rpc mode
146147 func (h *rpcExtHooks) Insert(string) {} // ignored in rpc mode
147148 func (h *rpcExtHooks) OpenPanel(string, extproto.PanelSpec) {}
148149 func (h *rpcExtHooks) UpdatePanel(string, string, string, []string, string) {}
@@ -143,6 +143,7 @@ func (h *rpcExtHooks) Display(extName, text string) {
143 }143 }
144 }144 }
145 func (h *rpcExtHooks) Submit(string) {} // ignored in rpc mode145 func (h *rpcExtHooks) Submit(string) {} // ignored in rpc mode
146+func (h *rpcExtHooks) SubmitSlash(string) {} // ignored in rpc mode
146 func (h *rpcExtHooks) Insert(string) {} // ignored in rpc mode147 func (h *rpcExtHooks) Insert(string) {} // ignored in rpc mode
147 func (h *rpcExtHooks) OpenPanel(string, extproto.PanelSpec) {}148 func (h *rpcExtHooks) OpenPanel(string, extproto.PanelSpec) {}
148 func (h *rpcExtHooks) UpdatePanel(string, string, string, []string, string) {}149 func (h *rpcExtHooks) UpdatePanel(string, string, string, []string, string) {}
M internal/extproto/extproto.go +10 -0
@@ -116,6 +116,16 @@ type NotifyFromExt struct {
116116 Message string `json:"message"`
117117 }
118118
119+// SubmitSlashFromExt is a spontaneous frame an extension can send at
120+// any time (typically from a panel_key handler) to invoke a slash
121+// command in the host's TUI as if the user had typed it. Text must
122+// start with '/'. Reserved for internal / opt-in extensions today;
123+// the wire format is stable but not yet exposed in the public docs.
124+type SubmitSlashFromExt struct {
125+ Type string `json:"type"`
126+ Text string `json:"text"`
127+}
128+
119129 type ShutdownAckFromExt struct {
120130 Type string `json:"type"`
121131 }
@@ -116,6 +116,16 @@ type NotifyFromExt struct {
116 Message string `json:"message"`116 Message string `json:"message"`
117 }117 }
118 118
119+// SubmitSlashFromExt is a spontaneous frame an extension can send at
120+// any time (typically from a panel_key handler) to invoke a slash
121+// command in the host's TUI as if the user had typed it. Text must
122+// start with '/'. Reserved for internal / opt-in extensions today;
123+// the wire format is stable but not yet exposed in the public docs.
124+type SubmitSlashFromExt struct {
125+ Type string `json:"type"`
126+ Text string `json:"text"`
127+}
128+
119 type ShutdownAckFromExt struct {129 type ShutdownAckFromExt struct {
120 Type string `json:"type"`130 Type string `json:"type"`
121 }131 }

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