v0.15.0 6b178f8

subagents_slash.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"fmt"
6	"strings"
7
8	"github.com/bnema/zut/packages/agent/subagents"
9	"github.com/bnema/zut/packages/provider"
10	"github.com/bnema/zut/packages/tui"
11)
12
13// runSubagents dispatches /subagents subcommands. Layout:
14//
15//	/subagents                       -> open the dashboard
16//	/subagents list                  -> open the dashboard
17//	/subagents new [--agent A] [--model M] [--provider P] [--reasoning L] <task...>
18//	                             -> spawn an agent with optional profile/model overrides
19//	/subagents kill <id>             -> stop a running agent
20//	/subagents cancel <id>           -> cancel the active turn
21//	/subagents remove <id>           -> tear down a terminated agent
22//	/subagents logs <id>             -> open the scrollable transcript view
23//	/subagents result|inspect <id>   -> show the completed result
24//	/subagents wait <id>             -> wait for the agent to finish
25//	/subagents send <id> <text...>   -> send a follow-up user turn to <id>
26//	/subagents resume [id]           -> resume an agent (omit id to pick from a list)
27//	/subagents restart-task|restart <id> -> restart the original task
28//	/subagents attach <id>           -> (planned) drop into the agent's TUI
29//
30// When cfg.Supervisor is nil the command tells the user the feature is
31// disabled instead of pretending to work.
32func (i *Interactive) runSubagents(ctx context.Context, args []string) {
33	if i.cfg.Supervisor == nil {
34		i.mu.Lock()
35		i.statusErr = "subagent is disabled in this build"
36		i.statusOK = ""
37		i.mu.Unlock()
38		return
39	}
40
41	sub := ""
42	rest := ""
43	if len(args) > 0 {
44		sub = strings.ToLower(args[0])
45		// Guard the args[1:] reslice: when only the subcommand was
46		// typed (e.g. bare "/subagents new"), args has length 1 and the
47		// naive args[1:] is fine, but when args is empty (bare
48		// "/subagents") the reslice is [1:0] and panics. The len>0 branch
49		// here keeps both cases safe.
50		if len(args) > 1 {
51			rest = strings.TrimSpace(strings.Join(args[1:], " "))
52		}
53	}
54
55	// spawnAdapter, sendAdapter, resumeAdapter wrap the Supervisor methods
56	// in the signatures the dialog expects. Defined once here so the
57	// three Open()-shaped entry points (list, logs/view-jump, resume)
58	// feed the dialog identical callbacks.
59	spawnAdapter := func(task, model, provider string) error {
60		_, err := i.cfg.Supervisor.SpawnReq(ctx, subagents.SpawnRequest{
61			Task: task, Model: model, Provider: provider, Reasoning: i.cfg.Reasoning,
62		})
63		return err
64	}
65	sendAdapter := func(id, text string) error {
66		return i.cfg.Supervisor.SendUserTurn(id, text)
67	}
68	resumeAdapter := func(id string) error {
69		_, err := i.cfg.Supervisor.ResumeSession(ctx, id)
70		return err
71	}
72
73	// Pin every fresh spawn to whatever the host's /model selection
74	// is right now. This is captured at /subagents time — if the user
75	// wants a different model for the next subagent agent, they pick it
76	// via /model first (globally), or, while inside the spawn
77	// editor, by typing /model on its own line to pop the picker.
78	i.subagentsDialog.SetCompactMode(i.compactModeEnabled())
79	i.subagentsDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines)
80	i.subagentsDialog.SetAllSnapshots(i.cfg.Supervisor.SnapshotAllSessions)
81	i.subagentsDialog.SetLoadTranscript(i.cfg.Supervisor.LoadTranscript)
82	i.subagentsDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider)
83	if i.cfg.LoggedInProviders != nil {
84		i.subagentsDialog.SetLoggedInProviders(i.cfg.LoggedInProviders())
85	}
86
87	switch sub {
88	case "", "list", "ls", "ps":
89		i.subagentsDialog.Open(
90			i.cfg.Supervisor.SnapshotCurrentSession,
91			i.cfg.Supervisor.Stop,
92			i.cfg.Supervisor.Remove,
93			spawnAdapter,
94			sendAdapter,
95			resumeAdapter,
96			i.cfg.CWD,
97		)
98	case "new", "spawn":
99		if rest == "" {
100			i.subagentsStatus("", "/subagents new <task>: missing task")
101			return
102		}
103		// Permit profile/model/provider/reasoning flags before the task
104		// so scripts can pin a child without going through the dialog.
105		// Anything that isn't a recognised flag terminates parsing and
106		// the rest becomes the task; only leading flags are consumed.
107		model, provider, reasoning, subagent, task := parseSpawnFlags(rest)
108		var profileTools []string
109		var fastModeOverride *bool
110		if task == "" {
111			i.subagentsStatus("", "/subagents new: missing task (after any spawn flags)")
112			return
113		}
114		if subagent != "" {
115			if i.cfg.ResolveSubagent == nil {
116				i.subagentsStatus("", "spawn: named subagent profiles are unavailable")
117				return
118			}
119			profile, err := i.cfg.ResolveSubagent(subagent)
120			if err != nil {
121				i.subagentsStatus("", "spawn: "+err.Error())
122				return
123			}
124			if profile == nil {
125				i.subagentsStatus("", "spawn: unknown subagent profile "+subagent)
126				return
127			}
128			subagent = profile.Name
129			profileTools = append([]string(nil), profile.Tools...)
130			fastModeOverride = profile.FastMode
131			profileProvider, profileModel := profile.ModelSelection()
132			if model == "" {
133				model = profileModel
134			}
135			if provider == "" {
136				provider = profileProvider
137			}
138			if reasoning == "" {
139				reasoning = profile.Thinking
140			}
141		}
142		if model == "" {
143			model = i.cfg.Model
144		}
145		if provider == "" {
146			provider = i.cfg.Provider
147		}
148		if reasoning == "" {
149			reasoning = i.cfg.Reasoning
150		}
151		if reasoning != "" {
152			normalizedReasoning, err := normalizeSpawnReasoning(reasoning)
153			if err != nil {
154				i.subagentsStatus("", "spawn: "+err.Error())
155				return
156			}
157			reasoning = normalizedReasoning
158		}
159		a, err := i.cfg.Supervisor.SpawnReq(ctx, subagents.SpawnRequest{
160			Task: task, Model: model, Provider: provider, Reasoning: reasoning,
161			FastMode: fastModeOverride, Subagent: subagent,
162			Tools: profileTools,
163		})
164		if err != nil {
165			i.subagentsStatus("", "spawn: "+err.Error())
166			return
167		}
168		status := "spawned " + a.ID
169		if subagent != "" {
170			status += " (agent " + subagent + ")"
171		} else if model != "" {
172			status += " (model " + model + ")"
173		}
174		i.subagentsStatus(status, "")
175	case "kill", "stop":
176		if rest == "" {
177			i.subagentsStatus("", "/subagents kill <id>: missing id")
178			return
179		}
180		if err := i.cfg.Supervisor.Stop(rest); err != nil {
181			i.subagentsStatus("", "kill: "+err.Error())
182			return
183		}
184		i.subagentsStatus("stopped "+rest, "")
185	case "cancel":
186		if rest == "" {
187			i.subagentsStatus("", "/subagents cancel <id>: missing id")
188			return
189		}
190		if err := i.cfg.Supervisor.CancelTurn(rest); err != nil {
191			i.subagentsStatus("", "cancel: "+err.Error())
192			return
193		}
194		i.subagentsStatus("canceling turn "+rest, "")
195	case "remove", "rm":
196		if rest == "" {
197			i.subagentsStatus("", "/subagents remove <id>: missing id")
198			return
199		}
200		if err := i.cfg.Supervisor.Remove(rest); err != nil {
201			i.subagentsStatus("", "remove: "+err.Error())
202			return
203		}
204		i.subagentsStatus("removed "+rest, "")
205	case "logs", "log", "view":
206		if rest == "" {
207			i.subagentsStatus("", "/subagents logs <id>: missing id")
208			return
209		}
210		if err := i.cfg.Supervisor.LoadTranscript(rest); err != nil {
211			i.subagentsStatus("", "logs: "+err.Error())
212			return
213		}
214		ok := i.subagentsDialog.OpenViewing(
215			rest,
216			i.cfg.Supervisor.SnapshotAll,
217			i.cfg.Supervisor.Stop,
218			i.cfg.Supervisor.Remove,
219			spawnAdapter,
220			sendAdapter,
221			resumeAdapter,
222			i.cfg.CWD,
223		)
224		if !ok {
225			i.subagentsStatus("", "/subagents logs: no agent matching "+rest)
226		}
227	case "result", "inspect":
228		if rest == "" {
229			i.subagentsStatus("", "/subagents result <id>: missing id")
230			return
231		}
232		result, err := i.cfg.Supervisor.ReadResult(rest)
233		if err != nil {
234			i.subagentsStatus("", "result: "+err.Error())
235			return
236		}
237		status := fmt.Sprintf("%s result %s", result.Status, i.cfg.Supervisor.ResultReference(rest))
238		if result.Summary != "" {
239			status += ": " + truncateStatus(result.Summary, 160)
240		}
241		i.subagentsStatus(status, "")
242	case "wait":
243		if rest == "" {
244			i.subagentsStatus("", "/subagents wait <id>: missing id")
245			return
246		}
247		a := i.cfg.Supervisor.Get(rest)
248		if a == nil {
249			i.subagentsStatus("", "wait: no such agent "+rest)
250			return
251		}
252		waitWatcherDone := i.subagentsWaitWatcherDone
253		// Publish the initial state before starting the watcher. A worker may
254		// already be done, and its completion must be the final status rather
255		// than being overwritten by a late "waiting" update.
256		i.subagentsStatus("waiting for "+a.ID, "")
257		go func() {
258			defer func() {
259				if waitWatcherDone != nil {
260					waitWatcherDone()
261				}
262			}()
263			if err := a.WaitContext(ctx); err != nil {
264				return
265			}
266			// If cancellation became observable at the same time as
267			// completion, do not report a completion that the caller
268			// no longer owns.
269			if ctx.Err() == nil {
270				i.subagentsStatus("completed "+a.ID, "")
271			}
272		}()
273	case "resume-session", "resume", "reattach", "reopen":
274		if rest == "" {
275			count := i.subagentsDialog.OpenForResume(
276				i.cfg.Supervisor.SnapshotCurrentSession,
277				i.cfg.Supervisor.Stop,
278				i.cfg.Supervisor.Remove,
279				spawnAdapter,
280				sendAdapter,
281				resumeAdapter,
282				i.cfg.CWD,
283			)
284			switch count {
285			case 0:
286				i.subagentsStatus("", "/subagents resume-session: no resumable agents")
287			case 1:
288				i.subagentsStatus("1 resumable agent, press R to resume", "")
289			default:
290				i.subagentsStatus(fmt.Sprintf("%d resumable agents, ↑/↓ to pick, R to resume", count), "")
291			}
292			return
293		}
294		a, err := i.cfg.Supervisor.ResumeSession(ctx, rest)
295		if err != nil {
296			i.subagentsStatus("", "resume-session: "+err.Error())
297			return
298		}
299		i.subagentsStatus("resumed "+a.ID, "")
300	case "restart-task", "restart":
301		if rest == "" {
302			i.subagentsStatus("", "/subagents restart-task <id>: missing id")
303			return
304		}
305		a, err := i.cfg.Supervisor.RestartTask(ctx, rest)
306		if err != nil {
307			i.subagentsStatus("", "restart-task: "+err.Error())
308			return
309		}
310		i.subagentsStatus("restarted task "+a.ID, "")
311	case "send", "prompt", "msg":
312		// /subagents send <id> <text...> is the non-interactive
313		// counterpart of pressing 'p' in the dashboard. We split the
314		// joined `rest` ourselves rather than reusing the dispatcher's
315		// already-fielded args[] because the text may contain spaces
316		// the user expects to be preserved verbatim.
317		id, text := splitIDAndRest(rest)
318		if id == "" {
319			i.subagentsStatus("", "/subagents send <id> <text>: missing id")
320			return
321		}
322		if text == "" {
323			i.subagentsStatus("", "/subagents send <id> <text>: missing text")
324			return
325		}
326		if err := i.cfg.Supervisor.SendUserTurn(id, text); err != nil {
327			i.subagentsStatus("", friendlySendErr(id, err))
328			return
329		}
330		i.subagentsStatus("sent to "+id, "")
331	case "attach":
332		// PTY-reparenting is a significant chunk of work I haven't
333		// landed yet (see the design sketch). Recognise the name so
334		// /subagents attach doesn't fall through to the generic "unknown
335		// subcommand" path — that error message is misleading because
336		// it makes attach sound like a typo instead of a planned
337		// feature. Point the user at /subagents logs in the meantime.
338		i.subagentsStatus("", "/subagents attach: not implemented yet (needs PTY reparenting). Use /subagents logs "+firstWord(rest)+" to watch its transcript.")
339	default:
340		i.subagentsStatus("", "/subagents: unknown subcommand "+sub+" (try list / new / kill / cancel / remove / logs / send / result / wait / resume-session / restart-task)")
341	}
342}
343
344// normalizeSpawnReasoning validates and canonicalizes the reasoning levels
345// accepted by ParseArgs before a child process is launched.
346func normalizeSpawnReasoning(value string) (string, error) {
347	raw := strings.ToLower(strings.TrimSpace(value))
348	switch raw {
349	case "", "off":
350		return raw, nil
351	case "minimal", "minimum", "low", "medium", "high", "xhigh", "maximum", "max":
352		return provider.NormalizeReasoning(raw), nil
353	default:
354		return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max")
355	}
356}
357
358// parseSpawnFlags consumes leading profile/model/provider/reasoning flags
359// using both separate-value and equals forms. Flags after the task remain
360// part of the task text.
361func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task string) {
362	fields := strings.Fields(s)
363	i := 0
364	for i < len(fields) {
365		f := fields[i]
366		switch {
367		case f == "--agent":
368			if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
369				subagent = fields[i+1]
370				i += 2
371			} else {
372				i++
373			}
374			continue
375		case strings.HasPrefix(f, "--agent="):
376			subagent = strings.TrimPrefix(f, "--agent=")
377			i++
378			continue
379		case f == "--model":
380			// Consume the flag even when no value follows so a
381			// dangling "--model" doesn't leak into the task. The
382			// caller surfaces "missing task" instead.
383			if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
384				model = fields[i+1]
385				i += 2
386			} else {
387				i++
388			}
389			continue
390		case strings.HasPrefix(f, "--model="):
391			model = strings.TrimPrefix(f, "--model=")
392			i++
393			continue
394		case f == "--provider":
395			if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
396				provider = fields[i+1]
397				i += 2
398			} else {
399				i++
400			}
401			continue
402		case strings.HasPrefix(f, "--provider="):
403			provider = strings.TrimPrefix(f, "--provider=")
404			i++
405			continue
406		case f == "--reasoning" || f == "--thinking":
407			if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
408				reasoning = fields[i+1]
409				i += 2
410			} else {
411				i++
412			}
413			continue
414		case strings.HasPrefix(f, "--reasoning="):
415			reasoning = strings.TrimPrefix(f, "--reasoning=")
416			i++
417			continue
418		case strings.HasPrefix(f, "--thinking="):
419			reasoning = strings.TrimPrefix(f, "--thinking=")
420			i++
421			continue
422		}
423		break
424	}
425	task = strings.TrimSpace(strings.Join(fields[i:], " "))
426	return
427}
428
429// splitIDAndRest splits "<id> <text...>" into (id, text). The text
430// half preserves all whitespace after the first token so the agent
431// receives the user's prompt verbatim (modulo a single trim of the
432// boundary space). Returns ("", "") when s is empty so the caller
433// can surface a missing-id error.
434func splitIDAndRest(s string) (id, text string) {
435	s = strings.TrimLeft(s, " \t")
436	if s == "" {
437		return "", ""
438	}
439	cut := strings.IndexAny(s, " \t")
440	if cut < 0 {
441		return s, ""
442	}
443	return s[:cut], strings.TrimLeft(s[cut+1:], " \t")
444}
445
446// firstWord returns the first whitespace-separated token of s, or
447// "<id>" when s is empty. Used to keep the "/subagents attach" hint
448// readable even when the user typed no argument.
449func firstWord(s string) string {
450	s = strings.TrimSpace(s)
451	if s == "" {
452		return "<id>"
453	}
454	if i := strings.IndexAny(s, " \t"); i >= 0 {
455		return s[:i]
456	}
457	return s
458}
459
460func truncateStatus(value string, max int) string {
461	value = strings.ReplaceAll(strings.TrimSpace(value), "\n", " ")
462	if len([]rune(value)) <= max {
463		return value
464	}
465	if max <= 3 {
466		return string([]rune(value)[:max])
467	}
468	return string([]rune(value)[:max-3]) + "..."
469}
470
471func (i *Interactive) subagentsStatus(ok, errMsg string) {
472	i.mu.Lock()
473	i.statusOK = ok
474	i.statusErr = errMsg
475	i.mu.Unlock()
476	i.invalidate()
477}

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