detached 26cdde5

fix(agent): harden subagent swarm dispatch

commit
26cdde58d5d88fa4c84360c2cb1a9397b0689781
author
brice · · 2026-08-04 04:33 UTC
parent
e6dacd2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +139 -23
M packages/agent/build.go +4 -1
@@ -309,7 +309,10 @@ func findSubagentProfile(cwd, name string) (*subagents.Profile, error) {
309309 return nil, nil
310310 }
311311 home, _ := os.UserHomeDir()
312- profiles, _ := subagents.Discover(cwd, home)
312+ profiles, discoveryErrors := subagents.Discover(cwd, home)
313+ if len(discoveryErrors) > 0 {
314+ return nil, fmt.Errorf("discover subagent profiles: %d profile discovery error(s)", len(discoveryErrors))
315+ }
313316 profile := subagents.Find(profiles, name)
314317 if profile == nil {
315318 return nil, fmt.Errorf("subagent profile %q not found (looked in ~/.agents/agents and compatibility locations)", name)
@@ -309,7 +309,10 @@ func findSubagentProfile(cwd, name string) (*subagents.Profile, error) {
309 return nil, nil309 return nil, nil
310 }310 }
311 home, _ := os.UserHomeDir()311 home, _ := os.UserHomeDir()
312- profiles, _ := subagents.Discover(cwd, home)312+ profiles, discoveryErrors := subagents.Discover(cwd, home)
313+ if len(discoveryErrors) > 0 {
314+ return nil, fmt.Errorf("discover subagent profiles: %d profile discovery error(s)", len(discoveryErrors))
315+ }
313 profile := subagents.Find(profiles, name)316 profile := subagents.Find(profiles, name)
314 if profile == nil {317 if profile == nil {
315 return nil, fmt.Errorf("subagent profile %q not found (looked in ~/.agents/agents and compatibility locations)", name)318 return nil, fmt.Errorf("subagent profile %q not found (looked in ~/.agents/agents and compatibility locations)", name)
M packages/agent/build_test.go +19 -0
@@ -59,6 +59,25 @@ func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) {
5959 }
6060 }
6161
62+func TestFindSubagentProfileReportsDiscoveryFailure(t *testing.T) {
63+ profileSource := filepath.Join(t.TempDir(), "profiles.md")
64+ if err := os.WriteFile(profileSource, []byte("not a directory"), 0o600); err != nil {
65+ t.Fatal(err)
66+ }
67+ t.Setenv("ZOT_AGENT_PROFILES", profileSource)
68+
69+ profile, err := findSubagentProfile("", "reviewer")
70+ if profile != nil {
71+ t.Fatalf("profile = %#v, want nil", profile)
72+ }
73+ if err == nil || !strings.Contains(err.Error(), "discover subagent profiles") {
74+ t.Fatalf("error = %v, want discovery failure", err)
75+ }
76+ if strings.Contains(err.Error(), profileSource) {
77+ t.Fatalf("error leaked profile path: %v", err)
78+ }
79+}
80+
6281 func TestResolveIncludesNamedSubagentsListWhenAutoSwarmIsEnabled(t *testing.T) {
6382 root := t.TempDir()
6483 home := filepath.Join(root, "home")
@@ -59,6 +59,25 @@ func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) {
59 }59 }
60 }60 }
61 61
62+func TestFindSubagentProfileReportsDiscoveryFailure(t *testing.T) {
63+ profileSource := filepath.Join(t.TempDir(), "profiles.md")
64+ if err := os.WriteFile(profileSource, []byte("not a directory"), 0o600); err != nil {
65+ t.Fatal(err)
66+ }
67+ t.Setenv("ZOT_AGENT_PROFILES", profileSource)
68+
69+ profile, err := findSubagentProfile("", "reviewer")
70+ if profile != nil {
71+ t.Fatalf("profile = %#v, want nil", profile)
72+ }
73+ if err == nil || !strings.Contains(err.Error(), "discover subagent profiles") {
74+ t.Fatalf("error = %v, want discovery failure", err)
75+ }
76+ if strings.Contains(err.Error(), profileSource) {
77+ t.Fatalf("error leaked profile path: %v", err)
78+ }
79+}
80+
62 func TestResolveIncludesNamedSubagentsListWhenAutoSwarmIsEnabled(t *testing.T) {81 func TestResolveIncludesNamedSubagentsListWhenAutoSwarmIsEnabled(t *testing.T) {
63 root := t.TempDir()82 root := t.TempDir()
64 home := filepath.Join(root, "home")83 home := filepath.Join(root, "home")
M packages/agent/cli.go +3 -0
@@ -1121,6 +1121,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11211121 wasJailed := sharedSandbox != nil && sharedSandbox.Locked()
11221122 args.CWD = absPath
11231123 r.CWD = absPath
1124+ if swarmMgr != nil {
1125+ swarmMgr.SetRepoRoot(absPath)
1126+ }
11241127 if args.PermissionSet != nil {
11251128 expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir)
11261129 args.PermissionSet = &expanded
@@ -1121,6 +1121,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1121 wasJailed := sharedSandbox != nil && sharedSandbox.Locked()1121 wasJailed := sharedSandbox != nil && sharedSandbox.Locked()
1122 args.CWD = absPath1122 args.CWD = absPath
1123 r.CWD = absPath1123 r.CWD = absPath
1124+ if swarmMgr != nil {
1125+ swarmMgr.SetRepoRoot(absPath)
1126+ }
1124 if args.PermissionSet != nil {1127 if args.PermissionSet != nil {
1125 expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir)1128 expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir)
1126 args.PermissionSet = &expanded1129 args.PermissionSet = &expanded
M packages/agent/modes/swarm_slash.go +25 -15
@@ -6,6 +6,7 @@ import (
66 "strings"
77
88 "github.com/patriceckhart/zot/packages/agent/swarm"
9+ "github.com/patriceckhart/zot/packages/provider"
910 "github.com/patriceckhart/zot/packages/tui"
1011 )
1112
@@ -126,6 +127,14 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
126127 if reasoning == "" {
127128 reasoning = i.cfg.Reasoning
128129 }
130+ if reasoning != "" {
131+ normalizedReasoning, err := normalizeSpawnReasoning(reasoning)
132+ if err != nil {
133+ i.swarmStatus("", "spawn: "+err.Error())
134+ return
135+ }
136+ reasoning = normalizedReasoning
137+ }
129138 a, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{
130139 Task: task, Model: model, Provider: provider, Reasoning: reasoning, Subagent: subagent,
131140 })
@@ -243,22 +252,23 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
243252 }
244253 }
245254
255+// normalizeSpawnReasoning validates and canonicalizes the reasoning levels
256+// accepted by ParseArgs before a child process is launched.
257+func normalizeSpawnReasoning(value string) (string, error) {
258+ raw := strings.ToLower(strings.TrimSpace(value))
259+ switch raw {
260+ case "", "off":
261+ return raw, nil
262+ case "minimal", "minimum", "low", "medium", "high", "xhigh", "maximum", "max":
263+ return provider.NormalizeReasoning(raw), nil
264+ default:
265+ return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max")
266+ }
267+}
268+
246269 // parseSpawnFlags consumes leading profile/model/provider/reasoning flags
247-// from s and returns them along with the remaining task body.
248-// We deliberately only honour LEADING flags so a task like "check
249-// --model lookup" doesn't accidentally swallow part of its prose as
250-// the model name.
251-//
252-// Recognised forms:
253-//
254-// --agent X two-token form
255-// --agent=X single-token form
256-// --model X two-token form
257-// --model=X single-token form
258-// --provider X two-token form
259-// --provider=X single-token form
260-// --reasoning X two-token form (also --thinking)
261-// --reasoning=X single-token form
270+// using both separate-value and equals forms. Flags after the task remain
271+// part of the task text.
262272 func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task string) {
263273 fields := strings.Fields(s)
264274 i := 0
@@ -6,6 +6,7 @@ import (
6 "strings"6 "strings"
7 7
8 "github.com/patriceckhart/zot/packages/agent/swarm"8 "github.com/patriceckhart/zot/packages/agent/swarm"
9+ "github.com/patriceckhart/zot/packages/provider"
9 "github.com/patriceckhart/zot/packages/tui"10 "github.com/patriceckhart/zot/packages/tui"
10 )11 )
11 12
@@ -126,6 +127,14 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
126 if reasoning == "" {127 if reasoning == "" {
127 reasoning = i.cfg.Reasoning128 reasoning = i.cfg.Reasoning
128 }129 }
130+ if reasoning != "" {
131+ normalizedReasoning, err := normalizeSpawnReasoning(reasoning)
132+ if err != nil {
133+ i.swarmStatus("", "spawn: "+err.Error())
134+ return
135+ }
136+ reasoning = normalizedReasoning
137+ }
129 a, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{138 a, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{
130 Task: task, Model: model, Provider: provider, Reasoning: reasoning, Subagent: subagent,139 Task: task, Model: model, Provider: provider, Reasoning: reasoning, Subagent: subagent,
131 })140 })
@@ -243,22 +252,23 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
243 }252 }
244 }253 }
245 254
255+// normalizeSpawnReasoning validates and canonicalizes the reasoning levels
256+// accepted by ParseArgs before a child process is launched.
257+func normalizeSpawnReasoning(value string) (string, error) {
258+ raw := strings.ToLower(strings.TrimSpace(value))
259+ switch raw {
260+ case "", "off":
261+ return raw, nil
262+ case "minimal", "minimum", "low", "medium", "high", "xhigh", "maximum", "max":
263+ return provider.NormalizeReasoning(raw), nil
264+ default:
265+ return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max")
266+ }
267+}
268+
246 // parseSpawnFlags consumes leading profile/model/provider/reasoning flags269 // parseSpawnFlags consumes leading profile/model/provider/reasoning flags
247-// from s and returns them along with the remaining task body.270+// using both separate-value and equals forms. Flags after the task remain
248-// We deliberately only honour LEADING flags so a task like "check271+// part of the task text.
249-// --model lookup" doesn't accidentally swallow part of its prose as
250-// the model name.
251-//
252-// Recognised forms:
253-//
254-// --agent X two-token form
255-// --agent=X single-token form
256-// --model X two-token form
257-// --model=X single-token form
258-// --provider X two-token form
259-// --provider=X single-token form
260-// --reasoning X two-token form (also --thinking)
261-// --reasoning=X single-token form
262 func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task string) {272 func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task string) {
263 fields := strings.Fields(s)273 fields := strings.Fields(s)
264 i := 0274 i := 0
M packages/agent/modes/swarm_slash_test.go +25 -0
@@ -175,6 +175,31 @@ func TestRunSwarmSendDeliversToAgentInbox(t *testing.T) {
175175 }
176176 }
177177
178+func TestNormalizeSpawnReasoning(t *testing.T) {
179+ cases := []struct {
180+ in, want string
181+ ok bool
182+ }{
183+ {in: "OFF", want: "off", ok: true},
184+ {in: "minimal", want: "minimum", ok: true},
185+ {in: "maximum", want: "xhigh", ok: true},
186+ {in: "MAX", want: "max", ok: true},
187+ {in: "bogus", ok: false},
188+ }
189+ for _, tc := range cases {
190+ got, err := normalizeSpawnReasoning(tc.in)
191+ if tc.ok {
192+ if err != nil || got != tc.want {
193+ t.Errorf("normalizeSpawnReasoning(%q) = %q, %v; want %q", tc.in, got, err, tc.want)
194+ }
195+ continue
196+ }
197+ if err == nil {
198+ t.Errorf("normalizeSpawnReasoning(%q) succeeded with %q; want error", tc.in, got)
199+ }
200+ }
201+}
202+
178203 func TestParseSpawnFlags(t *testing.T) {
179204 cases := []struct {
180205 in string
@@ -175,6 +175,31 @@ func TestRunSwarmSendDeliversToAgentInbox(t *testing.T) {
175 }175 }
176 }176 }
177 177
178+func TestNormalizeSpawnReasoning(t *testing.T) {
179+ cases := []struct {
180+ in, want string
181+ ok bool
182+ }{
183+ {in: "OFF", want: "off", ok: true},
184+ {in: "minimal", want: "minimum", ok: true},
185+ {in: "maximum", want: "xhigh", ok: true},
186+ {in: "MAX", want: "max", ok: true},
187+ {in: "bogus", ok: false},
188+ }
189+ for _, tc := range cases {
190+ got, err := normalizeSpawnReasoning(tc.in)
191+ if tc.ok {
192+ if err != nil || got != tc.want {
193+ t.Errorf("normalizeSpawnReasoning(%q) = %q, %v; want %q", tc.in, got, err, tc.want)
194+ }
195+ continue
196+ }
197+ if err == nil {
198+ t.Errorf("normalizeSpawnReasoning(%q) succeeded with %q; want error", tc.in, got)
199+ }
200+ }
201+}
202+
178 func TestParseSpawnFlags(t *testing.T) {203 func TestParseSpawnFlags(t *testing.T) {
179 cases := []struct {204 cases := []struct {
180 in string205 in string
M packages/agent/swarm/swarm.go +15 -7
@@ -139,6 +139,15 @@ func New(cfg Config) *Swarm {
139139 }
140140 }
141141
142+// SetRepoRoot updates the shared working directory for subsequently
143+// spawned agents. Existing agents keep the directory they were started
144+// with; callers use this when the host session changes cwd.
145+func (f *Swarm) SetRepoRoot(root string) {
146+ f.mu.Lock()
147+ defer f.mu.Unlock()
148+ f.cfg.RepoRoot = root
149+}
150+
142151 // SetActiveSession scopes the dashboard view (and Spawn stamping)
143152 // to a particular host zot session id. Pass empty to clear the
144153 // scope and revert to "show every agent" (the original behaviour).
@@ -213,7 +222,13 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error)
213222 return nil, errors.New("swarm: empty task")
214223 }
215224 id := newAgentID(task, f.cfg.Now())
225+
226+ // Snapshot the shared cwd together with the active session so a
227+ // concurrent /cd cannot race a spawn or produce a mixed snapshot.
228+ f.mu.Lock()
216229 dir := f.cfg.RepoRoot
230+ sessionID := f.activeSession
231+ f.mu.Unlock()
217232
218233 stateDir := f.agentStateDir(id)
219234 if err := os.MkdirAll(stateDir, 0o755); err != nil {
@@ -229,13 +244,6 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error)
229244 return nil, fmt.Errorf("swarm inbox path: %w", err)
230245 }
231246
232- // Snapshot activeSession under the lock; we use it twice (struct
233- // init below + persistence). Reading it without the lock would
234- // race a concurrent SetActiveSession call.
235- f.mu.Lock()
236- sessionID := f.activeSession
237- f.mu.Unlock()
238-
239247 a := &Agent{
240248 ID: id,
241249 Task: task,
@@ -139,6 +139,15 @@ func New(cfg Config) *Swarm {
139 }139 }
140 }140 }
141 141
142+// SetRepoRoot updates the shared working directory for subsequently
143+// spawned agents. Existing agents keep the directory they were started
144+// with; callers use this when the host session changes cwd.
145+func (f *Swarm) SetRepoRoot(root string) {
146+ f.mu.Lock()
147+ defer f.mu.Unlock()
148+ f.cfg.RepoRoot = root
149+}
150+
142 // SetActiveSession scopes the dashboard view (and Spawn stamping)151 // SetActiveSession scopes the dashboard view (and Spawn stamping)
143 // to a particular host zot session id. Pass empty to clear the152 // to a particular host zot session id. Pass empty to clear the
144 // scope and revert to "show every agent" (the original behaviour).153 // scope and revert to "show every agent" (the original behaviour).
@@ -213,7 +222,13 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error)
213 return nil, errors.New("swarm: empty task")222 return nil, errors.New("swarm: empty task")
214 }223 }
215 id := newAgentID(task, f.cfg.Now())224 id := newAgentID(task, f.cfg.Now())
225+
226+ // Snapshot the shared cwd together with the active session so a
227+ // concurrent /cd cannot race a spawn or produce a mixed snapshot.
228+ f.mu.Lock()
216 dir := f.cfg.RepoRoot229 dir := f.cfg.RepoRoot
230+ sessionID := f.activeSession
231+ f.mu.Unlock()
217 232
218 stateDir := f.agentStateDir(id)233 stateDir := f.agentStateDir(id)
219 if err := os.MkdirAll(stateDir, 0o755); err != nil {234 if err := os.MkdirAll(stateDir, 0o755); err != nil {
@@ -229,13 +244,6 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error)
229 return nil, fmt.Errorf("swarm inbox path: %w", err)244 return nil, fmt.Errorf("swarm inbox path: %w", err)
230 }245 }
231 246
232- // Snapshot activeSession under the lock; we use it twice (struct
233- // init below + persistence). Reading it without the lock would
234- // race a concurrent SetActiveSession call.
235- f.mu.Lock()
236- sessionID := f.activeSession
237- f.mu.Unlock()
238-
239 a := &Agent{247 a := &Agent{
240 ID: id,248 ID: id,
241 Task: task,249 Task: task,
M packages/agent/swarm/swarm_test.go +16 -0
@@ -78,6 +78,22 @@ func TestSpawnAgentSharesRepoRoot(t *testing.T) {
7878 }
7979 }
8080
81+func TestSetRepoRootAffectsSubsequentSpawns(t *testing.T) {
82+ f := newTestSwarm(t, func(a *Agent) Runner {
83+ return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
84+ })
85+ want := t.TempDir()
86+ f.SetRepoRoot(want)
87+ a, err := f.Spawn(context.Background(), "use new root")
88+ if err != nil {
89+ t.Fatal(err)
90+ }
91+ a.Wait()
92+ if a.Dir != want {
93+ t.Fatalf("Dir = %q; want updated RepoRoot %q", a.Dir, want)
94+ }
95+}
96+
8197 func TestSpawnEmptyTaskFails(t *testing.T) {
8298 f := newTestSwarm(t, func(a *Agent) Runner {
8399 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
@@ -78,6 +78,22 @@ func TestSpawnAgentSharesRepoRoot(t *testing.T) {
78 }78 }
79 }79 }
80 80
81+func TestSetRepoRootAffectsSubsequentSpawns(t *testing.T) {
82+ f := newTestSwarm(t, func(a *Agent) Runner {
83+ return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
84+ })
85+ want := t.TempDir()
86+ f.SetRepoRoot(want)
87+ a, err := f.Spawn(context.Background(), "use new root")
88+ if err != nil {
89+ t.Fatal(err)
90+ }
91+ a.Wait()
92+ if a.Dir != want {
93+ t.Fatalf("Dir = %q; want updated RepoRoot %q", a.Dir, want)
94+ }
95+}
96+
81 func TestSpawnEmptyTaskFails(t *testing.T) {97 func TestSpawnEmptyTaskFails(t *testing.T) {
82 f := newTestSwarm(t, func(a *Agent) Runner {98 f := newTestSwarm(t, func(a *Agent) Runner {
83 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })99 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
M packages/agent/tools/swarm_spawn_test.go +32 -0
@@ -104,6 +104,38 @@ func TestSwarmSpawnSelectsProfileAndReasoning(t *testing.T) {
104104 if len(agents) != 1 || agents[0].Subagent != "reviewer" || agents[0].Reasoning != "high" {
105105 t.Fatalf("agent profile/reasoning = %#v", agents)
106106 }
107+ if agents[0].Model != "gpt-5.6-luna" || agents[0].Provider != "openai-codex" {
108+ t.Fatalf("agent model/provider = %q/%q, want gpt-5.6-luna/openai-codex", agents[0].Model, agents[0].Provider)
109+ }
110+}
111+
112+func TestSwarmSpawnUsesProfileReasoningWhenOmitted(t *testing.T) {
113+ tool := &SwarmSpawnTool{
114+ Swarm: newTestSwarm(t),
115+ Enabled: func() bool { return true },
116+ ResolveSubagent: func(name string) (*subagents.Profile, error) {
117+ if name != "reviewer" {
118+ return nil, nil
119+ }
120+ return &subagents.Profile{
121+ Name: "reviewer",
122+ Model: "openai-codex/gpt-5.6-luna",
123+ Thinking: "low",
124+ }, nil
125+ },
126+ }
127+
128+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"reviewer"}`), nil)
129+ if err != nil {
130+ t.Fatal(err)
131+ }
132+ if res.IsError {
133+ t.Fatalf("unexpected tool error: %s", textResult(res.Content))
134+ }
135+ agents := tool.Swarm.List()
136+ if len(agents) != 1 || agents[0].Reasoning != "low" {
137+ t.Fatalf("agent reasoning = %#v, want low", agents)
138+ }
107139 }
108140
109141 func TestSwarmSpawnRejectsUnknownProfile(t *testing.T) {
@@ -104,6 +104,38 @@ func TestSwarmSpawnSelectsProfileAndReasoning(t *testing.T) {
104 if len(agents) != 1 || agents[0].Subagent != "reviewer" || agents[0].Reasoning != "high" {104 if len(agents) != 1 || agents[0].Subagent != "reviewer" || agents[0].Reasoning != "high" {
105 t.Fatalf("agent profile/reasoning = %#v", agents)105 t.Fatalf("agent profile/reasoning = %#v", agents)
106 }106 }
107+ if agents[0].Model != "gpt-5.6-luna" || agents[0].Provider != "openai-codex" {
108+ t.Fatalf("agent model/provider = %q/%q, want gpt-5.6-luna/openai-codex", agents[0].Model, agents[0].Provider)
109+ }
110+}
111+
112+func TestSwarmSpawnUsesProfileReasoningWhenOmitted(t *testing.T) {
113+ tool := &SwarmSpawnTool{
114+ Swarm: newTestSwarm(t),
115+ Enabled: func() bool { return true },
116+ ResolveSubagent: func(name string) (*subagents.Profile, error) {
117+ if name != "reviewer" {
118+ return nil, nil
119+ }
120+ return &subagents.Profile{
121+ Name: "reviewer",
122+ Model: "openai-codex/gpt-5.6-luna",
123+ Thinking: "low",
124+ }, nil
125+ },
126+ }
127+
128+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"reviewer"}`), nil)
129+ if err != nil {
130+ t.Fatal(err)
131+ }
132+ if res.IsError {
133+ t.Fatalf("unexpected tool error: %s", textResult(res.Content))
134+ }
135+ agents := tool.Swarm.List()
136+ if len(agents) != 1 || agents[0].Reasoning != "low" {
137+ t.Fatalf("agent reasoning = %#v, want low", agents)
138+ }
107 }139 }
108 140
109 func TestSwarmSpawnRejectsUnknownProfile(t *testing.T) {141 func TestSwarmSpawnRejectsUnknownProfile(t *testing.T) {

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