v0.2.51 92241f6

swarm_slash.go RawHistoryFolder

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

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