v0.3.83 d66bd89

manager_test.go RawHistoryFolder

1package extensions
2
3import (
4	"context"
5	"encoding/json"
6	"os"
7
8	"github.com/patriceckhart/zot/packages/agent/extproto"
9	"github.com/patriceckhart/zot/packages/agent/skills"
10	"path/filepath"
11	"runtime"
12	"strings"
13	"sync"
14	"testing"
15	"time"
16)
17
18// stubHooks records every callback so the test can assert on them.
19type stubHooks struct {
20	mu          sync.Mutex
21	notifies    []string
22	displays    []string
23	submits     []string
24	submitSlash []string
25	clearNotes  []string
26	panels      []extproto.PanelSpec
27	panelExts   []string
28}
29
30func (s *stubHooks) Notify(name, level, message string) {
31	s.mu.Lock()
32	defer s.mu.Unlock()
33	s.notifies = append(s.notifies, name+":"+level+":"+message)
34}
35func (s *stubHooks) Submit(text string) {
36	s.mu.Lock()
37	defer s.mu.Unlock()
38	s.submits = append(s.submits, text)
39}
40func (s *stubHooks) SubmitSlash(text string) {
41	s.mu.Lock()
42	defer s.mu.Unlock()
43	s.submitSlash = append(s.submitSlash, text)
44}
45func (s *stubHooks) Insert(string) {}
46func (s *stubHooks) Display(name, text string) {
47	s.mu.Lock()
48	defer s.mu.Unlock()
49	s.displays = append(s.displays, name+":"+text)
50}
51func (s *stubHooks) ClearNotes(name string) {
52	s.mu.Lock()
53	defer s.mu.Unlock()
54	s.clearNotes = append(s.clearNotes, name)
55}
56func (s *stubHooks) OpenPanel(extName string, spec extproto.PanelSpec) {
57	s.mu.Lock()
58	defer s.mu.Unlock()
59	s.panelExts = append(s.panelExts, extName)
60	s.panels = append(s.panels, spec)
61}
62func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {}
63func (s *stubHooks) ClosePanel(string, string)                            {}
64
65// writeMockExtension creates a minimal extension on disk that uses a
66// shell script (or batch file on windows) to drive the protocol. The
67// script reads commands from stdin and emits hard-coded responses,
68// exercising the manager's spawn/handshake/dispatch path without
69// needing the SDK.
70func writeMockExtension(t *testing.T, root string) {
71	t.Helper()
72	if runtime.GOOS == "windows" {
73		t.Skip("mock extension uses /bin/sh; skip on windows")
74	}
75
76	dir := filepath.Join(root, "mock")
77	if err := os.MkdirAll(dir, 0o755); err != nil {
78		t.Fatal(err)
79	}
80
81	// Shell script: emit hello, read frames, respond. Reads until
82	// stdin closes; tail's -F keeps the pipe alive long enough for
83	// the manager to send command_invoked.
84	script := `#!/bin/sh
85printf '%s\n' '{"type":"hello","name":"mock","version":"0.0.1","capabilities":["commands"]}'
86printf '%s\n' '{"type":"register_command","name":"Ping","description":"ping/pong"}'
87while IFS= read -r line; do
88  case "$line" in
89    *'"type":"command_invoked"'*)
90      id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
91      case "$line" in
92        *'"name":"Ping"'*)
93          printf '%s\n' "{\"type\":\"command_response\",\"id\":\"$id\",\"action\":\"display\",\"display\":\"pong\"}"
94          ;;
95        *)
96          printf '%s\n' "{\"type\":\"command_response\",\"id\":\"$id\",\"error\":\"non-canonical command name\"}"
97          ;;
98      esac
99      ;;
100    *'"type":"shutdown"'*)
101      printf '%s\n' '{"type":"shutdown_ack"}'
102      exit 0
103      ;;
104  esac
105done
106`
107	scriptPath := filepath.Join(dir, "run.sh")
108	if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
109		t.Fatal(err)
110	}
111
112	manifest := map[string]any{
113		"name":    "mock",
114		"version": "0.0.1",
115		"exec":    "./run.sh",
116	}
117	mfb, _ := json.Marshal(manifest)
118	if err := os.WriteFile(filepath.Join(dir, "extension.json"), mfb, 0o644); err != nil {
119		t.Fatal(err)
120	}
121}
122
123func TestDiscoverReportsExitStatusAndLogWhenExtensionExitsBeforeHello(t *testing.T) {
124	if runtime.GOOS == "windows" {
125		t.Skip("mock extension uses /bin/sh; skip on windows")
126	}
127
128	tmp := t.TempDir()
129	extDir := filepath.Join(tmp, "extensions", "broken")
130	if err := os.MkdirAll(extDir, 0o755); err != nil {
131		t.Fatal(err)
132	}
133	script := "#!/bin/sh\nprintf '%s\\n' 'compile failed' >&2\nexit 23\n"
134	if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
135		t.Fatal(err)
136	}
137	if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"broken","exec":"./run.sh"}`), 0o644); err != nil {
138		t.Fatal(err)
139	}
140
141	mgr := New(tmp, "", "0.0.0-test", "", "", nil)
142	errs := mgr.Discover(context.Background())
143	if len(errs) != 1 {
144		t.Fatalf("discover errors = %v, want one", errs)
145	}
146	logPath := filepath.Join(tmp, "logs", "ext-broken.log")
147	got := errs[0].Error()
148	if strings.Contains(got, "%!w") {
149		t.Fatalf("error contains formatting artifact: %s", got)
150	}
151	if !strings.Contains(got, "extension exited before hello: exit status 23") || !strings.Contains(got, logPath) {
152		t.Fatalf("error does not report exit status and identify stderr log: %s", got)
153	}
154	logData, err := os.ReadFile(logPath)
155	if err != nil {
156		t.Fatalf("read extension log: %v", err)
157	}
158	if !strings.Contains(string(logData), "compile failed") {
159		t.Fatalf("extension stderr missing from log: %s", logData)
160	}
161}
162
163func TestDiscoverReportsMissingRuntimeWhenExtensionCannotStart(t *testing.T) {
164	tmp := t.TempDir()
165	extDir := filepath.Join(tmp, "extensions", "missing-runtime")
166	if err := os.MkdirAll(extDir, 0o755); err != nil {
167		t.Fatal(err)
168	}
169	if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"missing-runtime","exec":"python3","language":"python"}`), 0o644); err != nil {
170		t.Fatal(err)
171	}
172
173	// Isolate PATH so this tests a missing language runtime rather than
174	// relying on a made-up executable name that could theoretically exist.
175	t.Setenv("PATH", filepath.Join(tmp, "empty-bin"))
176	mgr := New(tmp, "", "0.0.0-test", "", "", nil)
177	errs := mgr.Discover(context.Background())
178	if len(errs) != 1 {
179		t.Fatalf("discover errors = %v, want one", errs)
180	}
181	got := errs[0].Error()
182	for _, want := range []string{
183		`Extension missing-runtime failed to start.`,
184		`exec: "python3"`,
185		`declared language: "python"`,
186		"executable file not found",
187	} {
188		if !strings.Contains(got, want) {
189			t.Fatalf("error = %q, want it to contain %q", got, want)
190		}
191	}
192}
193
194func TestDiscoverReportsSignalWhenExtensionExitsBeforeHello(t *testing.T) {
195	if runtime.GOOS == "windows" {
196		t.Skip("mock extension uses Unix signals; skip on windows")
197	}
198
199	tmp := t.TempDir()
200	extDir := filepath.Join(tmp, "extensions", "signaled")
201	if err := os.MkdirAll(extDir, 0o755); err != nil {
202		t.Fatal(err)
203	}
204	script := "#!/bin/sh\nkill -TERM $$\n"
205	if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
206		t.Fatal(err)
207	}
208	if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"signaled","exec":"./run.sh"}`), 0o644); err != nil {
209		t.Fatal(err)
210	}
211
212	mgr := New(tmp, "", "0.0.0-test", "", "", nil)
213	errs := mgr.Discover(context.Background())
214	if len(errs) != 1 {
215		t.Fatalf("discover errors = %v, want one", errs)
216	}
217	got := errs[0].Error()
218	if !strings.Contains(got, "extension exited before hello: signal:") || !strings.Contains(got, "ext-signaled.log") {
219		t.Fatalf("error does not report signal and identify stderr log: %s", got)
220	}
221}
222
223func TestSpawnCleansUpProcessAfterInvalidHello(t *testing.T) {
224	if runtime.GOOS == "windows" {
225		t.Skip("mock extension uses /bin/sh; skip on windows")
226	}
227
228	tmp := t.TempDir()
229	extDir := filepath.Join(tmp, "broken")
230	if err := os.MkdirAll(extDir, 0o755); err != nil {
231		t.Fatal(err)
232	}
233	scriptPath := filepath.Join(extDir, "run.sh")
234	script := "#!/bin/sh\nprintf '%s\\n' 'not-json'\nwhile IFS= read -r line; do :; done\n"
235	if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
236		t.Fatal(err)
237	}
238
239	ext := &Extension{Manifest: Manifest{Name: "broken", Exec: "./run.sh"}, Dir: extDir}
240	mgr := New(tmp, "", "0.0.0-test", "", "", nil)
241	err := mgr.spawn(context.Background(), ext)
242	if err == nil || !strings.Contains(err.Error(), "parse hello") {
243		t.Fatalf("spawn error = %v, want parse hello failure", err)
244	}
245	if ext.cmd == nil || ext.cmd.ProcessState == nil {
246		t.Fatal("failed handshake process was not reaped")
247	}
248	if _, err := ext.logFile.WriteString("after cleanup"); err == nil {
249		t.Fatal("failed handshake log file was not closed")
250	}
251}
252
253func TestPlanSkillSourcesFollowsSymlinkedExtension(t *testing.T) {
254	if runtime.GOOS == "windows" {
255		t.Skip("directory symlinks require elevated privileges on Windows")
256	}
257
258	tmp := t.TempDir()
259	extRoot := filepath.Join(tmp, "extensions")
260	target := filepath.Join(tmp, "target")
261	if err := os.MkdirAll(filepath.Join(target, "skills", "review"), 0o755); err != nil {
262		t.Fatal(err)
263	}
264	if err := os.WriteFile(filepath.Join(target, "extension.json"), []byte(`{"name":"linked","skills":["./skills"]}`), 0o644); err != nil {
265		t.Fatal(err)
266	}
267	if err := os.WriteFile(filepath.Join(target, "skills", "review", "SKILL.md"), []byte("---\ndescription: linked\n---\nbody"), 0o644); err != nil {
268		t.Fatal(err)
269	}
270	if err := os.MkdirAll(extRoot, 0o755); err != nil {
271		t.Fatal(err)
272	}
273	if err := os.Symlink(target, filepath.Join(extRoot, "linked")); err != nil {
274		t.Fatal(err)
275	}
276
277	sources, errs := PlanSkillSources(tmp, "", nil, true)
278	if len(errs) != 0 || len(sources) != 1 {
279		t.Fatalf("sources=%#v errs=%v", sources, errs)
280	}
281	got, errs := skills.DiscoverSources(sources, false)
282	if len(errs) != 0 || len(got) != 1 || got[0].Name != "linked:review" {
283		t.Fatalf("skills=%#v errs=%v", got, errs)
284	}
285}
286
287func TestPlanSkillSources(t *testing.T) {
288	tmp := t.TempDir()
289	extDir := filepath.Join(tmp, "bundle")
290	skillDir := filepath.Join(extDir, "skills", "Writing Git Commits")
291	if err := os.MkdirAll(skillDir, 0o755); err != nil {
292		t.Fatal(err)
293	}
294	if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\ndescription: commits\n---\nbody"), 0o644); err != nil {
295		t.Fatal(err)
296	}
297	manifest := `{"name":"Git Tools","skills":["./skills"],"enabled":true}`
298	if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(manifest), 0o644); err != nil {
299		t.Fatal(err)
300	}
301	sources, errs := PlanSkillSources("", "", []string{extDir}, false)
302	if len(errs) != 0 || len(sources) != 1 {
303		t.Fatalf("sources=%#v errs=%v", sources, errs)
304	}
305	got, errs := skills.DiscoverSources(sources, false)
306	if len(errs) != 0 || len(got) != 1 || got[0].Name != "git-tools:writing-git-commits" {
307		t.Fatalf("skills=%#v errs=%v", got, errs)
308	}
309}
310
311func TestPlanSkillSourcesExplicitBundleNamedExtensions(t *testing.T) {
312	tmp := t.TempDir()
313	extDir := filepath.Join(tmp, "extensions")
314	skillDir := filepath.Join(extDir, "skills", "review")
315	if err := os.MkdirAll(skillDir, 0o755); err != nil {
316		t.Fatal(err)
317	}
318	if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"named-extensions","skills":["./skills"]}`), 0o644); err != nil {
319		t.Fatal(err)
320	}
321	if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\ndescription: review\n---\nbody"), 0o644); err != nil {
322		t.Fatal(err)
323	}
324
325	sources, errs := PlanSkillSources("", "", []string{extDir}, false)
326	if len(errs) != 0 || len(sources) != 1 {
327		t.Fatalf("sources=%#v errs=%v", sources, errs)
328	}
329}
330
331func TestPlanSkillSourcesRejectsTraversal(t *testing.T) {
332	tmp := t.TempDir()
333	extDir := filepath.Join(tmp, "bundle")
334	if err := os.MkdirAll(extDir, 0o755); err != nil {
335		t.Fatal(err)
336	}
337	if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"bad","skills":["../outside"]}`), 0o644); err != nil {
338		t.Fatal(err)
339	}
340	_, errs := PlanSkillSources("", "", []string{extDir}, false)
341	if len(errs) == 0 || !strings.Contains(errs[0].Error(), "escapes") {
342		t.Fatalf("errs=%v", errs)
343	}
344}
345
346func TestDiscoverLoadsThemeOnlyExtension(t *testing.T) {
347	tmp := t.TempDir()
348	extDir := filepath.Join(tmp, "extensions", "theme-only")
349	if err := os.MkdirAll(extDir, 0o755); err != nil {
350		t.Fatal(err)
351	}
352	manifest := `{"name":"theme-only","version":"1.0.0","description":"theme only"}`
353	if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(manifest), 0o644); err != nil {
354		t.Fatal(err)
355	}
356	theme := `{"name":"Theme Only","description":"theme from extension","colors":{"dark":{"accent":204}}}`
357	if err := os.WriteFile(filepath.Join(extDir, "theme.json"), []byte(theme), 0o644); err != nil {
358		t.Fatal(err)
359	}
360
361	mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", nil)
362	if errs := mgr.Discover(context.Background()); len(errs) > 0 {
363		t.Fatalf("discover errors: %v", errs)
364	}
365	defer mgr.Stop(10 * time.Millisecond)
366
367	opts := mgr.ThemeOptions()
368	if len(opts) != 1 {
369		t.Fatalf("theme options = %d, want 1", len(opts))
370	}
371	if opts[0].Label != "Theme Only" || opts[0].Path != filepath.Join(extDir, "theme.json") {
372		t.Fatalf("unexpected theme option: %#v", opts[0])
373	}
374	if !strings.Contains(opts[0].Description, "from extension theme-only") {
375		t.Fatalf("description missing extension source: %q", opts[0].Description)
376	}
377}
378
379func TestManagerSpawnAndInvoke(t *testing.T) {
380	tmp := t.TempDir()
381	extRoot := filepath.Join(tmp, "extensions")
382	if err := os.MkdirAll(extRoot, 0o755); err != nil {
383		t.Fatal(err)
384	}
385	writeMockExtension(t, extRoot)
386
387	hooks := &stubHooks{}
388	mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks)
389
390	if errs := mgr.Discover(context.Background()); len(errs) > 0 {
391		t.Fatalf("discover errors: %v", errs)
392	}
393	defer mgr.Stop(2 * time.Second)
394
395	// Give the extension a beat to send register_command frames after
396	// the hello handshake.
397	time.Sleep(150 * time.Millisecond)
398
399	cmds := mgr.Commands()
400	found := false
401	for _, c := range cmds {
402		if c.Name == "Ping" {
403			found = true
404			break
405		}
406	}
407	if !found {
408		t.Fatalf("expected canonical command 'Ping', got %#v", cmds)
409	}
410	if !mgr.HasCommand("PING") {
411		t.Fatal("HasCommand(\"PING\") = false")
412	}
413	if owner := mgr.CommandOwner("pInG"); owner != "mock" {
414		t.Fatalf("CommandOwner(\"pInG\") = %q, want mock", owner)
415	}
416
417	resp, err := mgr.Invoke(context.Background(), "PING", "", 2*time.Second)
418	if err != nil {
419		t.Fatalf("invoke: %v", err)
420	}
421	if resp.Action != "display" {
422		t.Errorf("expected action=display, got %q", resp.Action)
423	}
424	if resp.Display != "pong" {
425		t.Errorf("expected display=pong, got %q", resp.Display)
426	}
427}
428
429func TestDiagnosticsReportMalformedFramesAndConflicts(t *testing.T) {
430	if runtime.GOOS == "windows" {
431		t.Skip("mock extension uses /bin/sh; skip on windows")
432	}
433
434	tmp := t.TempDir()
435	extRoot := filepath.Join(tmp, "extensions")
436	writeDiagExtension := func(name, script string) {
437		t.Helper()
438		dir := filepath.Join(extRoot, name)
439		if err := os.MkdirAll(dir, 0o755); err != nil {
440			t.Fatal(err)
441		}
442		if err := os.WriteFile(filepath.Join(dir, "run.sh"), []byte(script), 0o755); err != nil {
443			t.Fatal(err)
444		}
445		mfb, _ := json.Marshal(map[string]any{"name": name, "exec": "./run.sh"})
446		if err := os.WriteFile(filepath.Join(dir, "extension.json"), mfb, 0o644); err != nil {
447			t.Fatal(err)
448		}
449	}
450
451	writeDiagExtension("a-first", `#!/bin/sh
452printf '%s\n' '{"type":"hello","name":"a-first","version":"0.1","capabilities":["commands","tools"]}'
453printf '%s\n' '{"type":"register_command","name":"CaseTest","description":"first"}'
454printf '%s\n' '{"type":"register_tool","name":"shared","description":"first","schema":{"type":"object"}}'
455printf '%s\n' '{"type":"register_tool","name":"broken","description":"bad","schema":'
456printf '%s\n' '{"type":"ready"}'
457while IFS= read -r line; do
458  case "$line" in
459    *'"type":"shutdown"'*) exit 0 ;;
460  esac
461done
462`)
463	writeDiagExtension("b-second", `#!/bin/sh
464printf '%s\n' '{"type":"hello","name":"b-second","version":"0.1","capabilities":["commands","tools"]}'
465printf '%s\n' '{"type":"register_command","name":"casetest","description":"second"}'
466printf '%s\n' '{"type":"register_tool","name":"shared","description":"second","schema":{"type":"object"}}'
467printf '%s\n' '{"type":"ready"}'
468while IFS= read -r line; do
469  case "$line" in
470    *'"type":"shutdown"'*) exit 0 ;;
471  esac
472done
473`)
474
475	mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", &stubHooks{})
476	if errs := mgr.Discover(context.Background()); len(errs) > 0 {
477		t.Fatalf("discover errors: %v", errs)
478	}
479	defer mgr.Stop(2 * time.Second)
480	mgr.WaitForReady(2 * time.Second)
481
482	diags := mgr.Diagnostics()
483	byName := map[string]ExtensionDiagnostic{}
484	for _, d := range diags {
485		byName[d.Name] = d
486	}
487
488	first := byName["a-first"]
489	if len(first.Messages) == 0 || !strings.Contains(strings.Join(first.Messages, "\n"), "malformed json frame") {
490		t.Fatalf("expected malformed-frame diagnostic, got %#v", first.Messages)
491	}
492
493	var shadowedTool bool
494	var activeCommands, shadowedCommands int
495	var conflictMessage bool
496	for _, d := range diags {
497		if strings.Contains(strings.Join(d.Messages, "\n"), "conflicts with another extension") {
498			conflictMessage = true
499		}
500		for _, tool := range d.Tools {
501			if tool.Name == "shared" && !tool.Active {
502				shadowedTool = true
503			}
504		}
505		for _, command := range d.Commands {
506			if !strings.EqualFold(command.Name, "casetest") {
507				continue
508			}
509			if command.Active {
510				activeCommands++
511			} else {
512				shadowedCommands++
513			}
514		}
515	}
516	if !shadowedTool {
517		t.Fatalf("expected one shared tool registration to be inactive, got %#v", diags)
518	}
519	if activeCommands != 1 || shadowedCommands != 1 {
520		t.Fatalf("case-only command registrations: active=%d shadowed=%d; diagnostics=%#v", activeCommands, shadowedCommands, diags)
521	}
522	if !conflictMessage {
523		t.Fatalf("expected conflict diagnostic, got %#v", diags)
524	}
525}
526
527// TestSpontaneousSubmit verifies that an extension can submit a
528// non-empty prompt outside of any command response.
529func TestSpontaneousSubmit(t *testing.T) {
530	if runtime.GOOS == "windows" {
531		t.Skip("mock extension uses /bin/sh; skip on windows")
532	}
533
534	tmp := t.TempDir()
535	extDir := filepath.Join(tmp, "extensions", "submit-mock")
536	if err := os.MkdirAll(extDir, 0o755); err != nil {
537		t.Fatal(err)
538	}
539
540	script := `#!/bin/sh
541printf '%s\n' '{"type":"hello","name":"submit-mock","version":"0.1","capabilities":["submit"]}'
542printf '%s\n' '{"type":"ready"}'
543printf '%s\n' '{"type":"submit","text":"  explain this repository briefly  "}'
544printf '%s\n' '{"type":"submit","text":"   "}'
545while IFS= read -r line; do
546  case "$line" in
547    *'"type":"shutdown"'*)
548      printf '%s\n' '{"type":"shutdown_ack"}'
549      exit 0
550      ;;
551  esac
552done
553`
554	if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
555		t.Fatal(err)
556	}
557	mfb, _ := json.Marshal(map[string]any{"name": "submit-mock", "exec": "./run.sh"})
558	if err := os.WriteFile(filepath.Join(extDir, "extension.json"), mfb, 0o644); err != nil {
559		t.Fatal(err)
560	}
561
562	hooks := &stubHooks{}
563	mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks)
564	if errs := mgr.Discover(context.Background()); len(errs) > 0 {
565		t.Fatalf("discover errors: %v", errs)
566	}
567	defer mgr.Stop(2 * time.Second)
568
569	deadline := time.Now().Add(2 * time.Second)
570	for time.Now().Before(deadline) {
571		hooks.mu.Lock()
572		n := len(hooks.submits)
573		hooks.mu.Unlock()
574		if n > 0 {
575			break
576		}
577		time.Sleep(20 * time.Millisecond)
578	}
579
580	hooks.mu.Lock()
581	defer hooks.mu.Unlock()
582	if len(hooks.submits) != 1 {
583		t.Fatalf("Submit calls = %#v, want one non-empty call", hooks.submits)
584	}
585	if hooks.submits[0] != "explain this repository briefly" {
586		t.Fatalf("Submit text = %q", hooks.submits[0])
587	}
588}
589
590// TestSpontaneousOpenPanel verifies that an extension sending an
591// open_panel frame outside of any command response causes the manager
592// to call hooks.OpenPanel with the correct PanelSpec fields.
593func TestSpontaneousOpenPanel(t *testing.T) {
594	if runtime.GOOS == "windows" {
595		t.Skip("mock extension uses /bin/sh; skip on windows")
596	}
597
598	tmp := t.TempDir()
599	extDir := filepath.Join(tmp, "extensions", "panel-mock")
600	if err := os.MkdirAll(extDir, 0o755); err != nil {
601		t.Fatal(err)
602	}
603
604	// Extension emits hello + ready, then immediately fires a
605	// spontaneous open_panel, then waits for shutdown.
606	script := `#!/bin/sh
607printf '%s\n' '{"type":"hello","name":"panel-mock","version":"0.1","capabilities":["panels"]}'
608printf '%s\n' '{"type":"ready"}'
609printf '%s\n' '{"type":"open_panel","panel":{"id":"test-panel","title":"Hello Panel","lines":["line one","line two"],"footer":"esc close"}}'
610while IFS= read -r line; do
611  case "$line" in
612    *'"type":"shutdown"'*)
613      printf '%s\n' '{"type":"shutdown_ack"}'
614      exit 0
615      ;;
616  esac
617done
618`
619	if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
620		t.Fatal(err)
621	}
622	mfb, _ := json.Marshal(map[string]any{"name": "panel-mock", "exec": "./run.sh"})
623	if err := os.WriteFile(filepath.Join(extDir, "extension.json"), mfb, 0o644); err != nil {
624		t.Fatal(err)
625	}
626
627	hooks := &stubHooks{}
628	mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks)
629	if errs := mgr.Discover(context.Background()); len(errs) > 0 {
630		t.Fatalf("discover errors: %v", errs)
631	}
632	defer mgr.Stop(2 * time.Second)
633
634	// Give the extension time to flush its open_panel frame.
635	deadline := time.Now().Add(2 * time.Second)
636	for time.Now().Before(deadline) {
637		hooks.mu.Lock()
638		n := len(hooks.panels)
639		hooks.mu.Unlock()
640		if n > 0 {
641			break
642		}
643		time.Sleep(20 * time.Millisecond)
644	}
645
646	hooks.mu.Lock()
647	defer hooks.mu.Unlock()
648
649	if len(hooks.panels) == 0 {
650		t.Fatal("hooks.OpenPanel was never called")
651	}
652	spec := hooks.panels[0]
653	if spec.ID != "test-panel" {
654		t.Errorf("panel id: want %q, got %q", "test-panel", spec.ID)
655	}
656	if spec.Title != "Hello Panel" {
657		t.Errorf("panel title: want %q, got %q", "Hello Panel", spec.Title)
658	}
659	if len(spec.Lines) != 2 || spec.Lines[0] != "line one" || spec.Lines[1] != "line two" {
660		t.Errorf("panel lines: want [line one line two], got %v", spec.Lines)
661	}
662	if spec.Footer != "esc close" {
663		t.Errorf("panel footer: want %q, got %q", "esc close", spec.Footer)
664	}
665	if hooks.panelExts[0] != "panel-mock" {
666		t.Errorf("ext name: want %q, got %q", "panel-mock", hooks.panelExts[0])
667	}
668}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close