v0.46.0 ea0e112

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/agent/tools"
10	"github.com/bnema/zut/packages/provider"
11)
12
13func (i *Interactive) runSubagents(ctx context.Context, args []string) {
14	if i.cfg.ResidentManager == nil {
15		i.mu.Lock()
16		i.statusErr = i.autoSubagentsUnavailableHint()
17		i.statusOK = ""
18		i.mu.Unlock()
19		return
20	}
21	i.runResidentSubagents(ctx, args)
22}
23
24// runResidentSubagents is the production slash-command path. The list/editor
25// migration is deliberately kept separate from the legacy process dashboard;
26// details open a structured journal-backed child session immediately.
27func (i *Interactive) runResidentSubagents(ctx context.Context, args []string) {
28	sub := ""
29	rest := ""
30	if len(args) > 0 {
31		sub = strings.ToLower(args[0])
32		if len(args) > 1 {
33			rest = strings.TrimSpace(strings.Join(args[1:], " "))
34		}
35	}
36	switch sub {
37	case "", "list", "ls", "ps":
38		i.mu.Lock()
39		if i.residentSubagentsDialog == nil {
40			i.residentSubagentsDialog = newResidentSubagentsDialog()
41		}
42		i.residentSubagentsDialog.Open(i.cfg.ResidentManager)
43		i.mu.Unlock()
44		i.invalidate()
45	case "new", "spawn":
46		i.spawnResidentSubagent(rest)
47	case "logs", "log", "view":
48		if rest == "" {
49			i.subagentsStatus("", "/subagents logs <id>: missing id")
50			return
51		}
52		i.openResidentChildSession(rest)
53	case "result", "inspect":
54		if rest == "" {
55			i.subagentsStatus("", "/subagents result <id>: missing id")
56			return
57		}
58		result, err := i.cfg.ResidentManager.Result(rest)
59		if err != nil {
60			i.subagentsStatus("", "result: "+err.Error())
61			return
62		}
63		i.subagentsStatus(residentResultStatus(rest, result), "")
64	case "kill", "stop":
65		if rest == "" {
66			i.subagentsStatus("", "/subagents kill <id>: missing id")
67			return
68		}
69		if err := i.cfg.ResidentManager.Stop(ctx, rest); err != nil {
70			i.subagentsStatus("", "kill: "+err.Error())
71			return
72		}
73		i.subagentsStatus("stopped "+rest, "")
74	case "send", "prompt", "msg", "resume":
75		id, prompt := splitIDAndRest(rest)
76		if id == "" || prompt == "" {
77			i.subagentsStatus("", "/subagents "+sub+" <id> <prompt>: missing id or prompt")
78			return
79		}
80		outcome, err := i.cfg.ResidentManager.ResumeFollowUp(ctx, id, prompt, subagents.ResumeSteer, "", nil)
81		if err != nil {
82			i.subagentsStatus("", sub+": "+err.Error())
83			return
84		}
85		if outcome.Steered {
86			i.subagentsStatus("steered follow-up into the running turn of "+id, "")
87		} else {
88			i.subagentsStatus("queued follow-up for "+id, "")
89		}
90	default:
91		i.subagentsStatus("", "/subagents: resident child sessions support list / new / logs / result / kill / send / resume")
92	}
93}
94
95func residentResultStatus(id string, result subagents.ResidentResult) string {
96	status := fmt.Sprintf("%s result %s", residentDisplayState(result.State), id)
97	text := result.Summary
98	if result.Handoff != "" {
99		text = result.Handoff
100	}
101	if summary := truncateStatus(sanitizeSessionTreeText(text), 240); summary != "" {
102		status += ": " + summary
103	}
104	return status
105}
106
107func (i *Interactive) openResidentChildSession(childID string) {
108	session := newResidentChildSession(i.cfg.ResidentManager, childID, i.cfg.Theme)
109	i.mu.Lock()
110	previous := i.residentChildSession
111	if i.residentSubagentsDialog != nil {
112		i.residentSubagentsDialog.Close()
113	}
114	i.residentChildSession = session
115	i.mu.Unlock()
116	if previous != nil {
117		previous.Close()
118	}
119	i.setResidentChildMouseReporting(true)
120	i.invalidate()
121	if session.BeginLoad() {
122		go func() {
123			err := session.LoadAll(200, i.invalidate)
124			if session.FinishLoad(err) {
125				i.reloadResidentChildSession(session)
126			}
127			i.invalidate()
128		}()
129	}
130}
131
132func (i *Interactive) spawnResidentSubagent(rest string) {
133	if i.cfg.SpawnResident == nil {
134		i.subagentsStatus("", "spawn: resident construction is unavailable")
135		return
136	}
137	model, providerID, reasoning, profileName, task := parseSpawnFlags(rest)
138	if task == "" {
139		i.subagentsStatus("", "/subagents new <task>: missing task")
140		return
141	}
142	var profile *subagents.Profile
143	var fastModeOverride *bool
144	if profileName != "" {
145		if i.cfg.ResolveSubagent == nil {
146			i.subagentsStatus("", "spawn: named subagent profiles are unavailable")
147			return
148		}
149		resolved, err := i.cfg.ResolveSubagent(profileName)
150		if err != nil || resolved == nil {
151			if err != nil {
152				i.subagentsStatus("", "spawn: "+err.Error())
153			} else {
154				i.subagentsStatus("", "spawn: unknown subagent profile "+profileName)
155			}
156			return
157		}
158		profile = resolved
159		fastModeOverride = profile.FastMode
160		if model == "" {
161			providerID, model = profile.ModelSelection()
162		}
163		if reasoning == "" {
164			reasoning = profile.Thinking
165		}
166	}
167	if model == "" {
168		model = i.cfg.Model
169	}
170	if providerID == "" {
171		providerID = i.cfg.Provider
172	}
173	if reasoning == "" {
174		reasoning = i.cfg.Reasoning
175	}
176	if reasoning != "" {
177		var err error
178		reasoning, err = normalizeSpawnReasoning(reasoning)
179		if err != nil {
180			i.subagentsStatus("", "spawn: "+err.Error())
181			return
182		}
183	}
184	childID, err := i.cfg.SpawnResident(context.Background(), tools.ResidentSpawnRequest{Task: task, Profile: profile, Model: model, Provider: providerID, Reasoning: reasoning, FastMode: fastModeOverride})
185	if err != nil {
186		i.subagentsStatus("", "spawn: "+err.Error())
187		return
188	}
189	i.subagentsStatus("spawned "+childID, "")
190}
191
192// normalizeSpawnReasoning validates and canonicalizes the reasoning levels
193// accepted by ParseArgs before a child process is launched.
194func normalizeSpawnReasoning(value string) (string, error) {
195	raw := strings.ToLower(strings.TrimSpace(value))
196	switch raw {
197	case "", "off":
198		return raw, nil
199	case "minimal", "minimum", "low", "medium", "high", "xhigh", "maximum", "max":
200		return provider.NormalizeReasoning(raw), nil
201	default:
202		return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max")
203	}
204}
205
206// parseSpawnFlags consumes leading profile/model/provider/reasoning flags
207// using both separate-value and equals forms. Flags after the task remain
208// part of the task text.
209func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task string) {
210	fields := strings.Fields(s)
211	i := 0
212	for i < len(fields) {
213		f := fields[i]
214		switch {
215		case f == "--agent":
216			if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
217				subagent = fields[i+1]
218				i += 2
219			} else {
220				i++
221			}
222			continue
223		case strings.HasPrefix(f, "--agent="):
224			subagent = strings.TrimPrefix(f, "--agent=")
225			i++
226			continue
227		case f == "--model":
228			// Consume the flag even when no value follows so a
229			// dangling "--model" doesn't leak into the task. The
230			// caller surfaces "missing task" instead.
231			if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
232				model = fields[i+1]
233				i += 2
234			} else {
235				i++
236			}
237			continue
238		case strings.HasPrefix(f, "--model="):
239			model = strings.TrimPrefix(f, "--model=")
240			i++
241			continue
242		case f == "--provider":
243			if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
244				provider = fields[i+1]
245				i += 2
246			} else {
247				i++
248			}
249			continue
250		case strings.HasPrefix(f, "--provider="):
251			provider = strings.TrimPrefix(f, "--provider=")
252			i++
253			continue
254		case f == "--reasoning" || f == "--thinking":
255			if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
256				reasoning = fields[i+1]
257				i += 2
258			} else {
259				i++
260			}
261			continue
262		case strings.HasPrefix(f, "--reasoning="):
263			reasoning = strings.TrimPrefix(f, "--reasoning=")
264			i++
265			continue
266		case strings.HasPrefix(f, "--thinking="):
267			reasoning = strings.TrimPrefix(f, "--thinking=")
268			i++
269			continue
270		}
271		break
272	}
273	task = strings.TrimSpace(strings.Join(fields[i:], " "))
274	return
275}
276
277// splitIDAndRest splits "<id> <text...>" into (id, text). The text
278// half preserves all whitespace after the first token so the agent
279// receives the user's prompt verbatim (modulo a single trim of the
280// boundary space). Returns ("", "") when s is empty so the caller
281// can surface a missing-id error.
282func splitIDAndRest(s string) (id, text string) {
283	s = strings.TrimLeft(s, " \t")
284	if s == "" {
285		return "", ""
286	}
287	cut := strings.IndexAny(s, " \t")
288	if cut < 0 {
289		return s, ""
290	}
291	return s[:cut], strings.TrimLeft(s[cut+1:], " \t")
292}
293
294func truncateStatus(value string, max int) string {
295	value = strings.ReplaceAll(strings.TrimSpace(value), "\n", " ")
296	if len([]rune(value)) <= max {
297		return value
298	}
299	if max <= 3 {
300		return string([]rune(value)[:max])
301	}
302	return string([]rune(value)[:max-3]) + "..."
303}
304
305func (i *Interactive) subagentsStatus(ok, errMsg string) {
306	i.mu.Lock()
307	i.statusOK = ok
308	i.statusErr = errMsg
309	i.mu.Unlock()
310	i.invalidate()
311}

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