v0.15.0 6b178f8

subagent_indicator.go RawHistoryFolder

1package modes
2
3import (
4	"fmt"
5	"strings"
6	"time"
7
8	"github.com/bnema/zut/packages/agent/subagents"
9	"github.com/bnema/zut/packages/tui"
10	"github.com/mattn/go-runewidth"
11)
12
13// renderSubagentActivityLines returns one compact, input-adjacent row for each
14// active delegated turn. Terminal and idle workers are intentionally omitted:
15// the /subagents dashboard remains the durable history, while this surface is
16// only a live "is it still moving?" signal.
17func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots []subagents.AgentSnapshot, width int, now time.Time) []string {
18	lines := make([]string, 0, len(snapshots))
19	for _, snapshot := range snapshots {
20		if !subagentTurnIsActive(snapshot) {
21			continue
22		}
23		line := renderSubagentActivityLine(th, spinnerGlyph, snapshot, width, now)
24		if line != "" {
25			lines = append(lines, line)
26		}
27	}
28	return lines
29}
30
31func subagentTurnIsActive(snapshot subagents.AgentSnapshot) bool {
32	switch snapshot.ProcessState {
33	case subagents.ProcessPending, subagents.ProcessStarting, subagents.ProcessAlive:
34	default:
35		// A detached worker has no confirmed live connection. Keep it in the
36		// dashboard, rather than presenting an endlessly animated row beside
37		// the input as if it were still making progress.
38		return false
39	}
40
41	switch snapshot.TurnState {
42	case subagents.TurnSucceeded, subagents.TurnFailed, subagents.TurnCanceled:
43		return false
44	}
45	switch snapshot.Status {
46	case subagents.StatusPending:
47		return true
48	case subagents.StatusRunning:
49	default:
50		return false
51	}
52
53	switch snapshot.TurnState {
54	case subagents.TurnQueued, subagents.TurnRunning, subagents.TurnCanceling:
55		return true
56	}
57
58	// The worker enters TurnIdle while it waits for a follow-up prompt. Keep a
59	// brief "starting" phase visible, but remove the row as soon as it reports
60	// ordinary idle so an already-completed task never looks active.
61	return strings.TrimSpace(snapshot.Activity) != "" && strings.TrimSpace(snapshot.Activity) != "idle"
62}
63
64func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot subagents.AgentSnapshot, width int, now time.Time) string {
65	name := sanitizeSubagentIndicatorText(snapshot.Subagent)
66	if name == "" {
67		name = sanitizeSubagentIndicatorText(snapshot.ID)
68	}
69	if name == "" {
70		name = "subagent"
71	}
72	spinnerGlyph = strings.TrimSpace(spinnerGlyph)
73	if spinnerGlyph == "" {
74		spinnerGlyph = "."
75	}
76	activity := compactSubagentActivity(snapshot)
77	age := formatSubagentActivityAge(snapshot, now)
78	plain, layout := fitSubagentActivityLine(spinnerGlyph, name, activity, age, width-2)
79	if plain == "" {
80		return ""
81	}
82
83	switch layout {
84	case subagentActivityFull:
85		parts := strings.SplitN(plain, " · ", 3)
86		if len(parts) != 3 {
87			return "  " + th.FGColor(th.Muted, plain)
88		}
89		name = strings.TrimPrefix(parts[0], spinnerGlyph+" ")
90		return "  " + th.FGColor(th.Spinner, spinnerGlyph) + " " +
91			th.FGColor(th.Assistant, name) + th.FGColor(th.Muted, " · ") +
92			th.FGColor(th.FG, parts[1]) + th.FGColor(th.Muted, " · "+parts[2])
93	case subagentActivityNameAge:
94		parts := strings.SplitN(plain, " · ", 2)
95		if len(parts) != 2 {
96			return "  " + th.FGColor(th.Muted, plain)
97		}
98		name = strings.TrimPrefix(parts[0], spinnerGlyph+" ")
99		return "  " + th.FGColor(th.Spinner, spinnerGlyph) + " " +
100			th.FGColor(th.Assistant, name) + th.FGColor(th.Muted, " · "+parts[1])
101	case subagentActivityCompact:
102		prefix := spinnerGlyph + " "
103		suffix := " " + age
104		if strings.HasPrefix(plain, prefix) && strings.HasSuffix(plain, suffix) {
105			name = strings.TrimSuffix(strings.TrimPrefix(plain, prefix), suffix)
106			return "  " + th.FGColor(th.Spinner, spinnerGlyph) + " " +
107				th.FGColor(th.Assistant, name) + " " + th.FGColor(th.Muted, age)
108		}
109	}
110	return "  " + th.FGColor(th.Muted, plain)
111}
112
113type subagentActivityLayout uint8
114
115const (
116	subagentActivityFull subagentActivityLayout = iota
117	subagentActivityNameAge
118	subagentActivityCompact
119)
120
121func fitSubagentActivityLine(spinnerGlyph, name, activity, age string, width int) (string, subagentActivityLayout) {
122	spinnerGlyph = strings.TrimSpace(spinnerGlyph)
123	if spinnerGlyph == "" {
124		spinnerGlyph = "."
125	}
126	if width <= 0 {
127		return "", subagentActivityCompact
128	}
129
130	full := spinnerGlyph + " " + name + " · " + activity + " · " + age
131	if runewidth.StringWidth(full) <= width {
132		return full, subagentActivityFull
133	}
134
135	nameAge := spinnerGlyph + " " + name + " · " + age
136	if activityWidth := width - runewidth.StringWidth(nameAge) - runewidth.StringWidth(" · "); activityWidth >= 4 {
137		activity = truncateSubagentIndicatorText(activity, activityWidth)
138		return spinnerGlyph + " " + name + " · " + activity + " · " + age, subagentActivityFull
139	}
140	if runewidth.StringWidth(nameAge) <= width {
141		return nameAge, subagentActivityNameAge
142	}
143
144	prefix := spinnerGlyph + " "
145	suffix := " " + age
146	nameWidth := width - runewidth.StringWidth(prefix) - runewidth.StringWidth(suffix)
147	if nameWidth >= 1 {
148		return prefix + truncateSubagentIndicatorText(name, nameWidth) + suffix, subagentActivityCompact
149	}
150	if runewidth.StringWidth(age) <= width {
151		return age, subagentActivityCompact
152	}
153	return truncateSubagentIndicatorText(spinnerGlyph, width), subagentActivityCompact
154}
155
156func compactSubagentActivity(snapshot subagents.AgentSnapshot) string {
157	activity := sanitizeSubagentIndicatorText(snapshot.Activity)
158	// Heartbeats report "idle" even while a delegated turn is still running.
159	// Keep the row meaningful in that interval instead of contradicting its
160	// spinner and turn state.
161	if activity != "" && (activity != "idle" || snapshot.TurnState != subagents.TurnRunning) {
162		return activity
163	}
164	switch snapshot.TurnState {
165	case subagents.TurnQueued:
166		return "queued"
167	case subagents.TurnCanceling:
168		return "cancelling"
169	default:
170		return "working"
171	}
172}
173
174func formatSubagentActivityAge(snapshot subagents.AgentSnapshot, now time.Time) string {
175	last := snapshot.LastActivity
176	if last.IsZero() {
177		last = snapshot.UpdatedAt
178	}
179	if last.IsZero() {
180		last = snapshot.Started
181	}
182	if last.IsZero() {
183		return "-"
184	}
185	if now.IsZero() {
186		now = time.Now()
187	}
188	elapsed := now.Sub(last)
189	if elapsed < 0 {
190		elapsed = 0
191	}
192	seconds := int(elapsed.Seconds())
193	switch {
194	case seconds < 60:
195		return fmt.Sprintf("%ds", seconds)
196	case seconds < 60*60:
197		return fmt.Sprintf("%dm%02ds", seconds/60, seconds%60)
198	case seconds < 24*60*60:
199		minutes := (seconds % (60 * 60)) / 60
200		return fmt.Sprintf("%dh%02dm", seconds/(60*60), minutes)
201	default:
202		hours := (seconds % (24 * 60 * 60)) / (60 * 60)
203		return fmt.Sprintf("%dd%02dh", seconds/(24*60*60), hours)
204	}
205}
206
207func truncateSubagentIndicatorText(s string, width int) string {
208	if width <= 0 {
209		return ""
210	}
211	if runewidth.StringWidth(s) <= width {
212		return s
213	}
214	if width <= 3 {
215		return strings.Repeat(".", width)
216	}
217	return runewidth.Truncate(s, width, "...")
218}
219
220// sanitizeSubagentIndicatorText keeps worker-derived labels on one safe
221// terminal row before they are measured or styled. A worker's profile, id, or
222// tool activity is data, never terminal control output.
223func sanitizeSubagentIndicatorText(s string) string {
224	return sanitizeSessionTreeText(s)
225}
226
227// limitSubagentActivityLines reserves the input's terminal rows before adding
228// activity rows. When the frame has room for only part of the live set, retain
229// the earliest rows and make the omitted count explicit in one final row.
230func limitSubagentActivityLines(th tui.Theme, lines []string, maxRows, width int) []string {
231	if maxRows <= 0 || len(lines) == 0 {
232		return nil
233	}
234	if len(lines) <= maxRows {
235		return lines
236	}
237	if maxRows == 1 {
238		return []string{subagentActivityOverflowLine(th, len(lines), width)}
239	}
240	out := append([]string(nil), lines[:maxRows-1]...)
241	return append(out, subagentActivityOverflowLine(th, len(lines)-len(out), width))
242}
243
244func subagentActivityOverflowLine(th tui.Theme, hidden, width int) string {
245	text := fmt.Sprintf("  … %d more active subagents", hidden)
246	return th.FGColor(th.Muted, truncateSubagentIndicatorText(text, width))
247}
248
249// activeSubagentActivitySnapshots builds the compact state needed by the
250// input indicator without copying transcript buffers. It mirrors the
251// supervisor's live-session filtering while deliberately omitting completed
252// workers, which belong in the dashboard and completion notification instead.
253func (i *Interactive) activeSubagentActivitySnapshots() []subagents.AgentSnapshot {
254	supervisor := i.cfg.Supervisor
255	if supervisor == nil {
256		return nil
257	}
258
259	activeSession := supervisor.ActiveSession()
260	agents := supervisor.List()
261	out := make([]subagents.AgentSnapshot, 0, len(agents))
262	for _, agent := range agents {
263		if activeSession != "" && agent.SessionID != "" && agent.SessionID != activeSession {
264			continue
265		}
266		snapshot := subagents.AgentSnapshot{
267			ID:           agent.ID,
268			Status:       agent.Status(),
269			ProcessState: agent.ProcessState(),
270			TurnState:    agent.TurnState(),
271			Activity:     agent.Activity(),
272			Started:      agent.Started,
273			UpdatedAt:    agent.UpdatedAt(),
274			LastActivity: agent.LastActivity(),
275			Subagent:     agent.Subagent,
276		}
277		if subagentTurnIsActive(snapshot) {
278			out = append(out, snapshot)
279		}
280	}
281	return out
282}

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