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) { | |||
| 51 | 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 | 59 | func (h *interactiveExtHooks) Insert(text string) { | |
| 55 | 60 | if iv := h.iv(); iv != nil { | |
| 56 | 61 | iv.Insert(text) | |
| @@ -240,6 +245,7 @@ func (nonInteractiveExtHooks) Notify(ext, level, message string) { | |||
| 240 | 245 | fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message) | |
| 241 | 246 | } | |
| 242 | 247 | func (nonInteractiveExtHooks) Submit(string) {} | |
| 248 | + | func (nonInteractiveExtHooks) SubmitSlash(string) {} | |
| 243 | 249 | func (nonInteractiveExtHooks) Insert(string) {} | |
| 244 | 250 | func (nonInteractiveExtHooks) Display(string, string) {} | |
| 245 | 251 | func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec) {} | |
| @@ -675,6 +681,126 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 675 | 681 | 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 | 804 | term := tui.NewProcTerm() | |
| 679 | 805 | ||
| 680 | 806 | // 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 | 912 | return out | |
| 787 | 913 | }, | |
| 788 | 914 | LoadSession: loadSession, | |
| 915 | + | ChangeCWD: changeCWD, | |
| 789 | 916 | CurrentSessionPath: func() string { | |
| 790 | 917 | if sess == nil { | |
| 791 | 918 | 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 nil | 681 | 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 the | 806 | // 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 out | 912 | 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 { | |||
| 103 | 103 | // running it through the agent loop. | |
| 104 | 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 | 113 | // Insert places text at the cursor in the editor. | |
| 107 | 114 | Insert(text string) | |
| 108 | 115 | ||
| @@ -729,6 +736,20 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||
| 729 | 736 | if err := json.Unmarshal(line, &n); err == nil { | |
| 730 | 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 | 753 | case "command_response": | |
| 733 | 754 | var cr extproto.CommandResponseFromExt | |
| 734 | 755 | 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.CommandResponseFromExt | 754 | 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) { | |||
| 25 | 25 | defer s.mu.Unlock() | |
| 26 | 26 | s.notifies = append(s.notifies, name+":"+level+":"+message) | |
| 27 | 27 | } | |
| 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) {} | |
| 30 | 31 | func (s *stubHooks) Display(name, text string) { | |
| 31 | 32 | s.mu.Lock() | |
| 32 | 33 | 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 { | |||
| 101 | 101 | // callback returns the new agent message slice so the TUI can invalidate. | |
| 102 | 102 | 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 | 114 | // CurrentSessionPath returns the path of the live session file | |
| 105 | 115 | // on disk (the one every AppendMessage writes to). Used by | |
| 106 | 116 | // /session export so the exporter ships the exact bytes on | |
| @@ -2202,6 +2212,55 @@ func (i *Interactive) Submit(text string) { | |||
| 2202 | 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 | 2264 | // SubmitOrQueue runs text immediately if the agent is idle, or | |
| 2206 | 2265 | // appends it to the pending queue if a turn is already in flight. | |
| 2207 | 2266 | // 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 | 2764 | break | |
| 2706 | 2765 | } | |
| 2707 | 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 | 2812 | case "/jail": | |
| 2709 | 2813 | if i.cfg.Sandbox == nil { | |
| 2710 | 2814 | 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) error | 102 | 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 file | 114 | // CurrentSessionPath returns the path of the live session file | ||
| 105 | // on disk (the one every AppendMessage writes to). Used by | 115 | // on disk (the one every AppendMessage writes to). Used by | ||
| 106 | // /session export so the exporter ships the exact bytes on | 116 | // /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, or | 2264 | // 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) so | 2266 | // 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 | break | 2764 | 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 { | |||
| 23 | 23 | // the streaming response without trouble. | |
| 24 | 24 | func slashCancelsTurn(head string) bool { | |
| 25 | 25 | switch head { | |
| 26 | - | case "/clear", "/compact", "/logout", "/login", "/model", "/reload-ext": | |
| 26 | + | case "/clear", "/compact", "/logout", "/login", "/model", "/reload-ext", "/cd": | |
| 27 | 27 | return true | |
| 28 | 28 | } | |
| 29 | 29 | return false | |
| @@ -166,9 +166,9 @@ func looksLikeSlashCommand(text string) bool { | |||
| 166 | 166 | } | |
| 167 | 167 | ||
| 168 | 168 | // 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). | |
| 172 | 172 | func isKnownSlashCommand(text string) bool { | |
| 173 | 173 | text = strings.TrimSpace(text) | |
| 174 | 174 | if text == "" || text[0] != '/' { | |
| @@ -183,9 +183,22 @@ func isKnownSlashCommand(text string) bool { | |||
| 183 | 183 | return true | |
| 184 | 184 | } | |
| 185 | 185 | } | |
| 186 | + | for _, h := range hiddenSlashCommands { | |
| 187 | + | if h == head { | |
| 188 | + | return true | |
| 189 | + | } | |
| 190 | + | } | |
| 186 | 191 | 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 | 202 | func newSlashSuggester() *slashSuggester { return &slashSuggester{} } | |
| 190 | 203 | ||
| 191 | 204 | // 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 true | 27 | return true | ||
| 28 | } | 28 | } | ||
| 29 | return false | 29 | return false | ||
| @@ -166,9 +166,9 @@ func looksLikeSlashCommand(text string) bool { | |||||
| 166 | } | 166 | } | ||
| 167 | 167 | ||||
| 168 | // isKnownSlashCommand reports whether text's head matches a registered | 168 | // isKnownSlashCommand reports whether text's head matches a registered | ||
| 169 | - | // slash command name in slashCatalog. Built-in only; extension | 169 | + | // slash command name in slashCatalog or hiddenSlashCommands. Built-in |
| 170 | - | // commands are looked up separately by the dispatcher (which | 170 | + | // 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 true | 183 | return true | ||
| 184 | } | 184 | } | ||
| 185 | } | 185 | } | ||
| 186 | + | for _, h := range hiddenSlashCommands { | |||
| 187 | + | if h == head { | |||
| 188 | + | return true | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 186 | return false | 191 | 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) { | |||
| 143 | 143 | } | |
| 144 | 144 | } | |
| 145 | 145 | func (h *rpcExtHooks) Submit(string) {} // ignored in rpc mode | |
| 146 | + | func (h *rpcExtHooks) SubmitSlash(string) {} // ignored in rpc mode | |
| 146 | 147 | func (h *rpcExtHooks) Insert(string) {} // ignored in rpc mode | |
| 147 | 148 | func (h *rpcExtHooks) OpenPanel(string, extproto.PanelSpec) {} | |
| 148 | 149 | 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 mode | 145 | 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 mode | 147 | 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 { | |||
| 116 | 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 | 129 | type ShutdownAckFromExt struct { | |
| 120 | 130 | Type string `json:"type"` | |
| 121 | 131 | } | |
| @@ -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 | } | ||