detached 8b35416

Add extension submit frame

commit
8b354163d969e10170ce5c8e674d2b737280720b
author
patriceckhart · · 2026-07-03 18:48 UTC
parent
be0642f
signature
unsigned
5 files · +113 -10
M docs/extensions.md +14 -0
@@ -316,6 +316,20 @@ Example:
316316 If `error` is non-empty, zot renders it as a red status line
317317 regardless of `action`.
318318
319+#### `submit` (one-way, any time)
320+
321+Submits text as a user prompt in the interactive host. If the agent is
322+idle, zot starts a turn immediately. If a turn is already running, zot
323+queues the prompt behind it using the same queue path as typed input.
324+Empty or whitespace-only text is ignored.
325+
326+```json
327+{"type":"submit","text":"Summarize the selected workspace."}
328+```
329+
330+In print / JSON / RPC modes this frame is ignored because there is no
331+interactive editor or prompt queue.
332+
319333 #### `panel_render` (one-way, while a panel is open)
320334
321335 Pushes a fresh frame for an already-open panel.
@@ -316,6 +316,20 @@ Example:
316 If `error` is non-empty, zot renders it as a red status line316 If `error` is non-empty, zot renders it as a red status line
317 regardless of `action`.317 regardless of `action`.
318 318
319+#### `submit` (one-way, any time)
320+
321+Submits text as a user prompt in the interactive host. If the agent is
322+idle, zot starts a turn immediately. If a turn is already running, zot
323+queues the prompt behind it using the same queue path as typed input.
324+Empty or whitespace-only text is ignored.
325+
326+```json
327+{"type":"submit","text":"Summarize the selected workspace."}
328+```
329+
330+In print / JSON / RPC modes this frame is ignored because there is no
331+interactive editor or prompt queue.
332+
319 #### `panel_render` (one-way, while a panel is open)333 #### `panel_render` (one-way, while a panel is open)
320 334
321 Pushes a fresh frame for an already-open panel.335 Pushes a fresh frame for an already-open panel.
M packages/agent/cli.go +1 -1
@@ -49,7 +49,7 @@ func (h *interactiveExtHooks) Notify(extName, level, message string) {
4949 }
5050 func (h *interactiveExtHooks) Submit(text string) {
5151 if iv := h.iv(); iv != nil {
52- iv.Submit(text)
52+ iv.SubmitOrQueue(text, nil)
5353 }
5454 }
5555 func (h *interactiveExtHooks) SubmitSlash(text string) {
@@ -49,7 +49,7 @@ func (h *interactiveExtHooks) Notify(extName, level, message string) {
49 }49 }
50 func (h *interactiveExtHooks) Submit(text string) {50 func (h *interactiveExtHooks) Submit(text string) {
51 if iv := h.iv(); iv != nil {51 if iv := h.iv(); iv != nil {
52- iv.Submit(text)52+ iv.SubmitOrQueue(text, nil)
53 }53 }
54 }54 }
55 func (h *interactiveExtHooks) SubmitSlash(text string) {55 func (h *interactiveExtHooks) SubmitSlash(text string) {
M packages/agent/extensions/manager.go +8 -0
@@ -781,6 +781,14 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
781781 }
782782 case "clear_notes":
783783 m.hooks.ClearNotes(ext.Manifest.Name)
784+ case "submit":
785+ var s extproto.SubmitFromExt
786+ if err := json.Unmarshal(line, &s); err == nil {
787+ text := strings.TrimSpace(s.Text)
788+ if text != "" {
789+ m.hooks.Submit(text)
790+ }
791+ }
784792 case "submit_slash":
785793 // Spontaneous request to invoke a slash command in the
786794 // TUI. Refused unless the payload looks like a slash
@@ -781,6 +781,14 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
781 }781 }
782 case "clear_notes":782 case "clear_notes":
783 m.hooks.ClearNotes(ext.Manifest.Name)783 m.hooks.ClearNotes(ext.Manifest.Name)
784+ case "submit":
785+ var s extproto.SubmitFromExt
786+ if err := json.Unmarshal(line, &s); err == nil {
787+ text := strings.TrimSpace(s.Text)
788+ if text != "" {
789+ m.hooks.Submit(text)
790+ }
791+ }
784 case "submit_slash":792 case "submit_slash":
785 // Spontaneous request to invoke a slash command in the793 // Spontaneous request to invoke a slash command in the
786 // TUI. Refused unless the payload looks like a slash794 // TUI. Refused unless the payload looks like a slash
M packages/agent/extensions/manager_test.go +82 -9
@@ -16,12 +16,14 @@ import (
1616
1717 // stubHooks records every callback so the test can assert on them.
1818 type stubHooks struct {
19- mu sync.Mutex
20- notifies []string
21- displays []string
22- clearNotes []string
23- panels []extproto.PanelSpec
24- panelExts []string
19+ mu sync.Mutex
20+ notifies []string
21+ displays []string
22+ submits []string
23+ submitSlash []string
24+ clearNotes []string
25+ panels []extproto.PanelSpec
26+ panelExts []string
2527 }
2628
2729 func (s *stubHooks) Notify(name, level, message string) {
@@ -29,9 +31,17 @@ func (s *stubHooks) Notify(name, level, message string) {
2931 defer s.mu.Unlock()
3032 s.notifies = append(s.notifies, name+":"+level+":"+message)
3133 }
32-func (s *stubHooks) Submit(string) {}
33-func (s *stubHooks) SubmitSlash(string) {}
34-func (s *stubHooks) Insert(string) {}
34+func (s *stubHooks) Submit(text string) {
35+ s.mu.Lock()
36+ defer s.mu.Unlock()
37+ s.submits = append(s.submits, text)
38+}
39+func (s *stubHooks) SubmitSlash(text string) {
40+ s.mu.Lock()
41+ defer s.mu.Unlock()
42+ s.submitSlash = append(s.submitSlash, text)
43+}
44+func (s *stubHooks) Insert(string) {}
3545 func (s *stubHooks) Display(name, text string) {
3646 s.mu.Lock()
3747 defer s.mu.Unlock()
@@ -264,6 +274,69 @@ done
264274 }
265275 }
266276
277+// TestSpontaneousSubmit verifies that an extension can submit a
278+// non-empty prompt outside of any command response.
279+func TestSpontaneousSubmit(t *testing.T) {
280+ if runtime.GOOS == "windows" {
281+ t.Skip("mock extension uses /bin/sh; skip on windows")
282+ }
283+
284+ tmp := t.TempDir()
285+ extDir := filepath.Join(tmp, "extensions", "submit-mock")
286+ if err := os.MkdirAll(extDir, 0o755); err != nil {
287+ t.Fatal(err)
288+ }
289+
290+ script := `#!/bin/sh
291+printf '%s\n' '{"type":"hello","name":"submit-mock","version":"0.1","capabilities":["submit"]}'
292+printf '%s\n' '{"type":"ready"}'
293+printf '%s\n' '{"type":"submit","text":" explain this repository briefly "}'
294+printf '%s\n' '{"type":"submit","text":" "}'
295+while IFS= read -r line; do
296+ case "$line" in
297+ *'"type":"shutdown"'*)
298+ printf '%s\n' '{"type":"shutdown_ack"}'
299+ exit 0
300+ ;;
301+ esac
302+done
303+`
304+ if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
305+ t.Fatal(err)
306+ }
307+ mfb, _ := json.Marshal(map[string]any{"name": "submit-mock", "exec": "./run.sh"})
308+ if err := os.WriteFile(filepath.Join(extDir, "extension.json"), mfb, 0o644); err != nil {
309+ t.Fatal(err)
310+ }
311+
312+ hooks := &stubHooks{}
313+ mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks)
314+ if errs := mgr.Discover(context.Background()); len(errs) > 0 {
315+ t.Fatalf("discover errors: %v", errs)
316+ }
317+ defer mgr.Stop(2 * time.Second)
318+
319+ deadline := time.Now().Add(2 * time.Second)
320+ for time.Now().Before(deadline) {
321+ hooks.mu.Lock()
322+ n := len(hooks.submits)
323+ hooks.mu.Unlock()
324+ if n > 0 {
325+ break
326+ }
327+ time.Sleep(20 * time.Millisecond)
328+ }
329+
330+ hooks.mu.Lock()
331+ defer hooks.mu.Unlock()
332+ if len(hooks.submits) != 1 {
333+ t.Fatalf("Submit calls = %#v, want one non-empty call", hooks.submits)
334+ }
335+ if hooks.submits[0] != "explain this repository briefly" {
336+ t.Fatalf("Submit text = %q", hooks.submits[0])
337+ }
338+}
339+
267340 // TestSpontaneousOpenPanel verifies that an extension sending an
268341 // open_panel frame outside of any command response causes the manager
269342 // to call hooks.OpenPanel with the correct PanelSpec fields.
@@ -16,12 +16,14 @@ import (
16 16
17 // stubHooks records every callback so the test can assert on them.17 // stubHooks records every callback so the test can assert on them.
18 type stubHooks struct {18 type stubHooks struct {
19- mu sync.Mutex19+ mu sync.Mutex
20- notifies []string20+ notifies []string
21- displays []string21+ displays []string
22- clearNotes []string22+ submits []string
23- panels []extproto.PanelSpec23+ submitSlash []string
24- panelExts []string24+ clearNotes []string
25+ panels []extproto.PanelSpec
26+ panelExts []string
25 }27 }
26 28
27 func (s *stubHooks) Notify(name, level, message string) {29 func (s *stubHooks) Notify(name, level, message string) {
@@ -29,9 +31,17 @@ func (s *stubHooks) Notify(name, level, message string) {
29 defer s.mu.Unlock()31 defer s.mu.Unlock()
30 s.notifies = append(s.notifies, name+":"+level+":"+message)32 s.notifies = append(s.notifies, name+":"+level+":"+message)
31 }33 }
32-func (s *stubHooks) Submit(string) {}34+func (s *stubHooks) Submit(text string) {
33-func (s *stubHooks) SubmitSlash(string) {}35+ s.mu.Lock()
34-func (s *stubHooks) Insert(string) {}36+ defer s.mu.Unlock()
37+ s.submits = append(s.submits, text)
38+}
39+func (s *stubHooks) SubmitSlash(text string) {
40+ s.mu.Lock()
41+ defer s.mu.Unlock()
42+ s.submitSlash = append(s.submitSlash, text)
43+}
44+func (s *stubHooks) Insert(string) {}
35 func (s *stubHooks) Display(name, text string) {45 func (s *stubHooks) Display(name, text string) {
36 s.mu.Lock()46 s.mu.Lock()
37 defer s.mu.Unlock()47 defer s.mu.Unlock()
@@ -264,6 +274,69 @@ done
264 }274 }
265 }275 }
266 276
277+// TestSpontaneousSubmit verifies that an extension can submit a
278+// non-empty prompt outside of any command response.
279+func TestSpontaneousSubmit(t *testing.T) {
280+ if runtime.GOOS == "windows" {
281+ t.Skip("mock extension uses /bin/sh; skip on windows")
282+ }
283+
284+ tmp := t.TempDir()
285+ extDir := filepath.Join(tmp, "extensions", "submit-mock")
286+ if err := os.MkdirAll(extDir, 0o755); err != nil {
287+ t.Fatal(err)
288+ }
289+
290+ script := `#!/bin/sh
291+printf '%s\n' '{"type":"hello","name":"submit-mock","version":"0.1","capabilities":["submit"]}'
292+printf '%s\n' '{"type":"ready"}'
293+printf '%s\n' '{"type":"submit","text":" explain this repository briefly "}'
294+printf '%s\n' '{"type":"submit","text":" "}'
295+while IFS= read -r line; do
296+ case "$line" in
297+ *'"type":"shutdown"'*)
298+ printf '%s\n' '{"type":"shutdown_ack"}'
299+ exit 0
300+ ;;
301+ esac
302+done
303+`
304+ if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
305+ t.Fatal(err)
306+ }
307+ mfb, _ := json.Marshal(map[string]any{"name": "submit-mock", "exec": "./run.sh"})
308+ if err := os.WriteFile(filepath.Join(extDir, "extension.json"), mfb, 0o644); err != nil {
309+ t.Fatal(err)
310+ }
311+
312+ hooks := &stubHooks{}
313+ mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks)
314+ if errs := mgr.Discover(context.Background()); len(errs) > 0 {
315+ t.Fatalf("discover errors: %v", errs)
316+ }
317+ defer mgr.Stop(2 * time.Second)
318+
319+ deadline := time.Now().Add(2 * time.Second)
320+ for time.Now().Before(deadline) {
321+ hooks.mu.Lock()
322+ n := len(hooks.submits)
323+ hooks.mu.Unlock()
324+ if n > 0 {
325+ break
326+ }
327+ time.Sleep(20 * time.Millisecond)
328+ }
329+
330+ hooks.mu.Lock()
331+ defer hooks.mu.Unlock()
332+ if len(hooks.submits) != 1 {
333+ t.Fatalf("Submit calls = %#v, want one non-empty call", hooks.submits)
334+ }
335+ if hooks.submits[0] != "explain this repository briefly" {
336+ t.Fatalf("Submit text = %q", hooks.submits[0])
337+ }
338+}
339+
267 // TestSpontaneousOpenPanel verifies that an extension sending an340 // TestSpontaneousOpenPanel verifies that an extension sending an
268 // open_panel frame outside of any command response causes the manager341 // open_panel frame outside of any command response causes the manager
269 // to call hooks.OpenPanel with the correct PanelSpec fields.342 // to call hooks.OpenPanel with the correct PanelSpec fields.
M packages/agent/extproto/extproto.go +8 -0
@@ -143,6 +143,14 @@ type SubmitSlashFromExt struct {
143143 Text string `json:"text"`
144144 }
145145
146+// SubmitFromExt is a spontaneous frame an extension can send to queue
147+// a model prompt in the interactive host. The manager routes it through
148+// HostHooks.Submit, so the TUI handles busy state and turn startup.
149+type SubmitFromExt struct {
150+ Type string `json:"type"`
151+ Text string `json:"text"`
152+}
153+
146154 type ShutdownAckFromExt struct {
147155 Type string `json:"type"`
148156 }
@@ -143,6 +143,14 @@ type SubmitSlashFromExt struct {
143 Text string `json:"text"`143 Text string `json:"text"`
144 }144 }
145 145
146+// SubmitFromExt is a spontaneous frame an extension can send to queue
147+// a model prompt in the interactive host. The manager routes it through
148+// HostHooks.Submit, so the TUI handles busy state and turn startup.
149+type SubmitFromExt struct {
150+ Type string `json:"type"`
151+ Text string `json:"text"`
152+}
153+
146 type ShutdownAckFromExt struct {154 type ShutdownAckFromExt struct {
147 Type string `json:"type"`155 Type string `json:"type"`
148 }156 }

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