1package modes
2
3import (
4 "context"
5 "fmt"
6 "strings"
7
8 "github.com/patriceckhart/zot/packages/agent/swarm"
9 "github.com/patriceckhart/zot/packages/tui"
10)
11
12// runSwarm dispatches /swarm subcommands. Layout:
13//
14// /swarm -> open the dashboard
15// /swarm list -> open the dashboard
16// /swarm new [--model M] [--provider P] <task...>
17// -> spawn an agent (optionally pinned to a model)
18// /swarm kill <id> -> stop a running agent
19// /swarm remove <id> -> tear down a terminated agent
20// /swarm logs <id> -> open the scrollable transcript view
21// /swarm send <id> <text...> -> send a follow-up user turn to <id>
22// /swarm resume [id] -> resume an agent (omit id to pick from a list)
23// /swarm attach <id> -> (planned) drop into the agent's TUI
24//
25// When cfg.Swarm is nil the command tells the user the feature is
26// disabled instead of pretending to work.
27func (i *Interactive) runSwarm(ctx context.Context, args []string) {
28 if i.cfg.Swarm == nil {
29 i.mu.Lock()
30 i.statusErr = "swarm is disabled in this build"
31 i.statusOK = ""
32 i.mu.Unlock()
33 return
34 }
35
36 sub := ""
37 rest := ""
38 if len(args) > 0 {
39 sub = strings.ToLower(args[0])
40 // Guard the args[1:] reslice: when only the subcommand was
41 // typed (e.g. bare "/swarm new"), args has length 1 and the
42 // naive args[1:] is fine, but when args is empty (bare
43 // "/swarm") the reslice is [1:0] and panics. The len>0 branch
44 // here keeps both cases safe.
45 if len(args) > 1 {
46 rest = strings.TrimSpace(strings.Join(args[1:], " "))
47 }
48 }
49
50 // spawnAdapter, sendAdapter, resumeAdapter wrap the Swarm methods
51 // in the signatures the dialog expects. Defined once here so the
52 // three Open()-shaped entry points (list, logs/view-jump, resume)
53 // feed the dialog identical callbacks.
54 spawnAdapter := func(task, model, provider string) error {
55 _, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{
56 Task: task, Model: model, Provider: provider,
57 })
58 return err
59 }
60 sendAdapter := func(id, text string) error {
61 return i.cfg.Swarm.SendUserTurn(id, text)
62 }
63 resumeAdapter := func(id string) error {
64 _, err := i.cfg.Swarm.Resume(ctx, id)
65 return err
66 }
67
68 // Pin every fresh spawn to whatever the host's /model selection
69 // is right now. This is captured at /swarm time — if the user
70 // wants a different model for the next swarm agent, they pick it
71 // via /model first (globally), or, while inside the spawn
72 // editor, by typing /model on its own line to pop the picker.
73 i.swarmDialog.SetCompactMode(i.compactModeEnabled())
74 i.swarmDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines)
75 i.swarmDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider)
76 if i.cfg.LoggedInProviders != nil {
77 i.swarmDialog.SetLoggedInProviders(i.cfg.LoggedInProviders())
78 }
79
80 switch sub {
81 case "", "list", "ls", "ps":
82 i.swarmDialog.Open(
83 i.cfg.Swarm.SnapshotAll,
84 i.cfg.Swarm.Stop,
85 i.cfg.Swarm.Remove,
86 spawnAdapter,
87 sendAdapter,
88 resumeAdapter,
89 i.cfg.CWD,
90 )
91 case "new", "spawn":
92 if rest == "" {
93 i.swarmStatus("", "/swarm new <task>: missing task")
94 return
95 }
96 // Permit `--model X --provider Y` flags before the task so
97 // scripts can pin a model without going through the dialog.
98 // Anything that isn't a recognised flag terminates parsing
99 // and the rest becomes the task; this keeps `/swarm new
100 // --model foo do a thing` and `/swarm new do --model thing`
101 // (where --model is part of the task) unambiguous — only
102 // leading flags are consumed.
103 model, provider, task := parseSpawnFlags(rest)
104 if task == "" {
105 i.swarmStatus("", "/swarm new: missing task (after any --model/--provider flags)")
106 return
107 }
108 a, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{
109 Task: task, Model: model, Provider: provider,
110 })
111 if err != nil {
112 i.swarmStatus("", "spawn: "+err.Error())
113 return
114 }
115 if model != "" {
116 i.swarmStatus("spawned "+a.ID+" (model "+model+")", "")
117 } else {
118 i.swarmStatus("spawned "+a.ID, "")
119 }
120 case "kill", "stop":
121 if rest == "" {
122 i.swarmStatus("", "/swarm kill <id>: missing id")
123 return
124 }
125 if err := i.cfg.Swarm.Stop(rest); err != nil {
126 i.swarmStatus("", "kill: "+err.Error())
127 return
128 }
129 i.swarmStatus("stopped "+rest, "")
130 case "remove", "rm":
131 if rest == "" {
132 i.swarmStatus("", "/swarm remove <id>: missing id")
133 return
134 }
135 if err := i.cfg.Swarm.Remove(rest); err != nil {
136 i.swarmStatus("", "remove: "+err.Error())
137 return
138 }
139 i.swarmStatus("removed "+rest, "")
140 case "logs", "log", "view":
141 if rest == "" {
142 i.swarmStatus("", "/swarm logs <id>: missing id")
143 return
144 }
145 ok := i.swarmDialog.OpenViewing(
146 rest,
147 i.cfg.Swarm.SnapshotAll,
148 i.cfg.Swarm.Stop,
149 i.cfg.Swarm.Remove,
150 spawnAdapter,
151 sendAdapter,
152 resumeAdapter,
153 i.cfg.CWD,
154 )
155 if !ok {
156 i.swarmStatus("", "/swarm logs: no agent matching "+rest)
157 }
158 case "resume", "reattach", "reopen":
159 if rest == "" {
160 // No id given: open the dashboard with the cursor
161 // pre-positioned on the first resumable agent, and
162 // tell the user how many there are so they know what
163 // to expect. Pressing R confirms; ↑/↓ to pick a
164 // different row first.
165 count := i.swarmDialog.OpenForResume(
166 i.cfg.Swarm.SnapshotAll,
167 i.cfg.Swarm.Stop,
168 i.cfg.Swarm.Remove,
169 spawnAdapter,
170 sendAdapter,
171 resumeAdapter,
172 i.cfg.CWD,
173 )
174 switch count {
175 case 0:
176 i.swarmStatus("", "/swarm resume: no resumable agents (none detached or terminated)")
177 case 1:
178 i.swarmStatus("1 resumable agent, press R to resume", "")
179 default:
180 i.swarmStatus(fmt.Sprintf("%d resumable agents, ↑/↓ to pick, R to resume", count), "")
181 }
182 return
183 }
184 a, err := i.cfg.Swarm.Resume(ctx, rest)
185 if err != nil {
186 i.swarmStatus("", "resume: "+err.Error())
187 return
188 }
189 i.swarmStatus("resumed "+a.ID, "")
190 case "send", "prompt", "msg":
191 // /swarm send <id> <text...> is the non-interactive
192 // counterpart of pressing 'p' in the dashboard. We split the
193 // joined `rest` ourselves rather than reusing the dispatcher's
194 // already-fielded args[] because the text may contain spaces
195 // the user expects to be preserved verbatim.
196 id, text := splitIDAndRest(rest)
197 if id == "" {
198 i.swarmStatus("", "/swarm send <id> <text>: missing id")
199 return
200 }
201 if text == "" {
202 i.swarmStatus("", "/swarm send <id> <text>: missing text")
203 return
204 }
205 if err := i.cfg.Swarm.SendUserTurn(id, text); err != nil {
206 i.swarmStatus("", friendlySendErr(id, err))
207 return
208 }
209 i.swarmStatus("sent to "+id, "")
210 case "attach":
211 // PTY-reparenting is a significant chunk of work I haven't
212 // landed yet (see the design sketch). Recognise the name so
213 // /swarm attach doesn't fall through to the generic "unknown
214 // subcommand" path — that error message is misleading because
215 // it makes attach sound like a typo instead of a planned
216 // feature. Point the user at /swarm logs in the meantime.
217 i.swarmStatus("", "/swarm attach: not implemented yet (needs PTY reparenting). Use /swarm logs "+firstWord(rest)+" to watch its transcript.")
218 default:
219 i.swarmStatus("", "/swarm: unknown subcommand "+sub+" (try list / new / kill / remove / logs / send / resume)")
220 }
221}
222
223// parseSpawnFlags consumes any leading `--model X` / `--provider Y`
224// flags from s and returns them along with the remaining task body.
225// We deliberately only honour LEADING flags so a task like "check
226// --model lookup" doesn't accidentally swallow part of its prose as
227// the model name.
228//
229// Recognised forms:
230//
231// --model X two-token form
232// --model=X single-token form
233// --provider X two-token form
234// --provider=X single-token form
235func parseSpawnFlags(s string) (model, provider, task string) {
236 fields := strings.Fields(s)
237 i := 0
238 for i < len(fields) {
239 f := fields[i]
240 switch {
241 case f == "--model":
242 // Consume the flag even when no value follows so a
243 // dangling "--model" doesn't leak into the task. The
244 // caller surfaces "missing task" instead.
245 if i+1 < len(fields) {
246 model = fields[i+1]
247 i += 2
248 } else {
249 i++
250 }
251 continue
252 case strings.HasPrefix(f, "--model="):
253 model = strings.TrimPrefix(f, "--model=")
254 i++
255 continue
256 case f == "--provider":
257 if i+1 < len(fields) {
258 provider = fields[i+1]
259 i += 2
260 } else {
261 i++
262 }
263 continue
264 case strings.HasPrefix(f, "--provider="):
265 provider = strings.TrimPrefix(f, "--provider=")
266 i++
267 continue
268 }
269 break
270 }
271 task = strings.TrimSpace(strings.Join(fields[i:], " "))
272 return
273}
274
275// splitIDAndRest splits "<id> <text...>" into (id, text). The text
276// half preserves all whitespace after the first token so the agent
277// receives the user's prompt verbatim (modulo a single trim of the
278// boundary space). Returns ("", "") when s is empty so the caller
279// can surface a missing-id error.
280func splitIDAndRest(s string) (id, text string) {
281 s = strings.TrimLeft(s, " \t")
282 if s == "" {
283 return "", ""
284 }
285 cut := strings.IndexAny(s, " \t")
286 if cut < 0 {
287 return s, ""
288 }
289 return s[:cut], strings.TrimLeft(s[cut+1:], " \t")
290}
291
292// firstWord returns the first whitespace-separated token of s, or
293// "<id>" when s is empty. Used to keep the "/swarm attach" hint
294// readable even when the user typed no argument.
295func firstWord(s string) string {
296 s = strings.TrimSpace(s)
297 if s == "" {
298 return "<id>"
299 }
300 if i := strings.IndexAny(s, " \t"); i >= 0 {
301 return s[:i]
302 }
303 return s
304}
305
306func (i *Interactive) swarmStatus(ok, errMsg string) {
307 i.mu.Lock()
308 i.statusOK = ok
309 i.statusErr = errMsg
310 i.mu.Unlock()
311 i.invalidate()
312}