v0.31.0 9467429

resident_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
13var residentProviderWaitingSpinner = tui.NewStringSpinner(
14	[]string{"Zzzz", "zZzz", "zzZz", "zzzZ"},
15	time.Second,
16)
17
18// renderResidentSubagentActivityLines renders the non-sensitive background
19// work indicator shown directly below the main editor. A queued child is
20// waiting for a resident scheduler slot; only running children use the
21// animated glyph. Height limiting happens after this function so every active
22// child in the provided page can be shown whenever the terminal has room.
23func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, snapshots []subagents.ResidentSnapshot, width int, now time.Time) []string {
24	lines := make([]string, 0, len(snapshots)*2)
25	for _, snapshot := range snapshots {
26		// Foreign journals remain discoverable through /subagents, but their
27		// activity belongs to another zut host and must not occupy this
28		// session's always-visible editor-adjacent indicator.
29		if snapshot.OwnedElsewhere || (snapshot.State != subagents.ResidentQueued && snapshot.State != subagents.ResidentRunning) {
30			continue
31		}
32		name := sanitizeSessionTreeText(snapshot.Profile)
33		if name == "" {
34			name = sanitizeSessionTreeText(snapshot.ID)
35		}
36		if name == "" {
37			name = "subagent"
38		}
39		glyph := "…"
40		color := theme.Muted
41		activity := "queued"
42		if snapshot.State == subagents.ResidentRunning {
43			glyph = strings.TrimSpace(spinnerGlyph)
44			if glyph == "" {
45				glyph = "."
46			}
47			color = theme.Spinner
48			if snapshot.WaitingForModel {
49				activity = residentProviderWaitingSpinner.FrameAt(activityTime(snapshot), now)
50			} else {
51				activity = "running"
52			}
53		}
54		plain := fmt.Sprintf("%s %s · %s · activity %s ago · %s", glyph, name, activity,
55			formatResidentSubagentActivityAge(activityTime(snapshot), now),
56			formatResidentSubagentActivityAge(turnStartTime(snapshot), now))
57		lines = append(lines, renderResidentSubagentActivityGroup(theme, color, plain, residentUsageMetadata(snapshot), width)...)
58	}
59	return lines
60}
61
62func renderResidentSubagentActivityGroup(theme tui.Theme, color tui.TerminalColor, activity, metadata string, width int) []string {
63	if width <= 0 {
64		return nil
65	}
66	const activityIndent = "  "
67	const metadataIndent = "    "
68	activityWidth := runewidth.StringWidth(activity)
69	metadataWidth := runewidth.StringWidth(metadata)
70	if metadata != "" && runewidth.StringWidth(activityIndent)+activityWidth+1+metadataWidth <= width {
71		gap := width - runewidth.StringWidth(activityIndent) - activityWidth - metadataWidth
72		if gap < 1 {
73			gap = 1
74		}
75		return []string{activityIndent + theme.FGColor(color, activity) + strings.Repeat(" ", gap) + theme.FGColor(theme.Muted, metadata)}
76	}
77
78	activity = truncateResidentSubagentIndicator(activity, width-runewidth.StringWidth(activityIndent))
79	if activity == "" {
80		return nil
81	}
82	lines := []string{activityIndent + theme.FGColor(color, activity)}
83	if metadata == "" {
84		return lines
85	}
86	metadataWidthLimit := width - runewidth.StringWidth(metadataIndent)
87	if metadataWidthLimit <= 0 {
88		return lines
89	}
90	for _, metadataLine := range tui.WrapANSILine(metadata, metadataWidthLimit) {
91		metadataLine = truncateResidentSubagentIndicator(metadataLine, metadataWidthLimit)
92		if metadataLine != "" {
93			lines = append(lines, metadataIndent+theme.FGColor(theme.Muted, metadataLine))
94		}
95	}
96	return lines
97}
98
99func residentUsageMetadata(snapshot subagents.ResidentSnapshot) string {
100	parts := tui.UsageStatsParts(tui.UsageStatsParams{Usage: snapshot.Usage, Subscription: snapshot.Subscription})
101	if context := tui.ContextUsageText(snapshot.ContextUsed, snapshot.ContextMax); context != "" {
102		parts = append(parts, context)
103	}
104	if snapshot.Budget.Limit > 0 {
105		parts = append(parts, fmt.Sprintf("budget:%s %d%%/%s", snapshot.Budget.State, snapshot.Budget.Percent, compactResidentTokens(snapshot.Budget.Limit)))
106	}
107	return strings.Join(parts, " ")
108}
109
110func compactResidentTokens(tokens int64) string {
111	switch {
112	case tokens >= 999_500:
113		return fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
114	case tokens >= 1_000:
115		return fmt.Sprintf("%.0fk", float64(tokens)/1_000)
116	default:
117		return fmt.Sprintf("%d", tokens)
118	}
119}
120
121func turnStartTime(snapshot subagents.ResidentSnapshot) time.Time {
122	if !snapshot.TurnStartedAt.IsZero() {
123		return snapshot.TurnStartedAt
124	}
125	return snapshot.UpdatedAt
126}
127
128func activityTime(snapshot subagents.ResidentSnapshot) time.Time {
129	if !snapshot.ActivityUpdatedAt.IsZero() {
130		return snapshot.ActivityUpdatedAt
131	}
132	return snapshot.UpdatedAt
133}
134
135func formatResidentSubagentActivityAge(started, now time.Time) string {
136	if started.IsZero() {
137		return "-"
138	}
139	if now.IsZero() {
140		now = time.Now()
141	}
142	elapsed := now.Sub(started)
143	if elapsed < 0 {
144		elapsed = 0
145	}
146	seconds := int(elapsed.Seconds())
147	switch {
148	case seconds < 60:
149		return fmt.Sprintf("%ds", seconds)
150	case seconds < 60*60:
151		return fmt.Sprintf("%dm%02ds", seconds/60, seconds%60)
152	case seconds < 24*60*60:
153		return fmt.Sprintf("%dh%02dm", seconds/(60*60), (seconds%(60*60))/60)
154	default:
155		return fmt.Sprintf("%dd%02dh", seconds/(24*60*60), (seconds%(24*60*60))/(60*60))
156	}
157}
158
159func renderResidentSubagentActivityPage(theme tui.Theme, manager *subagents.ResidentManager, spinnerGlyph string, snapshotLimit, width int, now time.Time) ([]string, int) {
160	if manager == nil || snapshotLimit <= 0 {
161		return nil, 0
162	}
163	snapshots, total := manager.ActiveSnapshotPage(snapshotLimit)
164	hidden := total - len(snapshots)
165	if hidden < 0 {
166		hidden = 0
167	}
168	return renderResidentSubagentActivityLines(theme, spinnerGlyph, snapshots, width, now), hidden
169}
170
171func fitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, maxBottomRows, width int, bottomRows func([]string) int) []string {
172	if len(lines) == 0 && hidden == 0 {
173		return nil
174	}
175	for maxRows := len(lines) + 1; maxRows >= 0; maxRows-- {
176		candidate := limitResidentSubagentActivityLines(theme, lines, hidden, maxRows, width)
177		if maxRows == 0 || bottomRows(candidate) <= maxBottomRows {
178			return candidate
179		}
180	}
181	return nil
182}
183
184func limitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, maxRows, width int) []string {
185	if maxRows <= 0 || (len(lines) == 0 && hidden == 0) {
186		return nil
187	}
188	groups := residentSubagentActivityLineGroups(lines)
189	if len(groups) == 0 {
190		return []string{residentSubagentActivityOverflowLine(theme, hidden, width)}
191	}
192	if hidden == 0 && len(lines) <= maxRows {
193		return lines
194	}
195
196	if maxRows == 1 {
197		if hidden == 0 && len(groups) == 1 {
198			return []string{groups[0][0]}
199		}
200		return []string{residentSubagentActivityOverflowLine(theme, hidden+len(groups), width)}
201	}
202
203	visibleGroups := len(groups)
204	overflowHidden := hidden
205	if hidden > 0 {
206		if visibleGroups > maxRows-1 {
207			visibleGroups = maxRows - 1
208		}
209		overflowHidden = hidden + len(groups) - visibleGroups
210	} else if visibleGroups > maxRows {
211		visibleGroups = maxRows - 1
212		overflowHidden = len(groups) - visibleGroups
213	}
214	if visibleGroups < 0 {
215		visibleGroups = 0
216	}
217	if visibleGroups > len(groups) {
218		visibleGroups = len(groups)
219	}
220
221	out := make([]string, 0, maxRows)
222	for index := 0; index < visibleGroups && len(out) < maxRows; index++ {
223		group := groups[index]
224		out = append(out, group[0])
225		remainingActivityRows := visibleGroups - index - 1
226		reservedRows := remainingActivityRows
227		if overflowHidden > 0 {
228			reservedRows++
229		}
230		metadataRows := maxRows - len(out) - reservedRows
231		for _, metadataLine := range group[1:] {
232			if metadataRows <= 0 || len(out) >= maxRows {
233				break
234			}
235			out = append(out, metadataLine)
236			metadataRows--
237		}
238	}
239	if overflowHidden > 0 && len(out) < maxRows {
240		out = append(out, residentSubagentActivityOverflowLine(theme, overflowHidden, width))
241	}
242	return out
243}
244
245func residentSubagentActivityLineGroups(lines []string) [][]string {
246	groups := make([][]string, 0, len(lines))
247	for _, line := range lines {
248		if strings.HasPrefix(line, "    ") && len(groups) > 0 {
249			groups[len(groups)-1] = append(groups[len(groups)-1], line)
250			continue
251		}
252		groups = append(groups, []string{line})
253	}
254	return groups
255}
256
257func residentSubagentActivityOverflowLine(theme tui.Theme, hidden, width int) string {
258	text := fmt.Sprintf("  … %d more active subagents", hidden)
259	return theme.FGColor(theme.Muted, truncateResidentSubagentIndicator(text, width))
260}
261
262func truncateResidentSubagentIndicator(s string, width int) string {
263	if width <= 0 {
264		return ""
265	}
266	if runewidth.StringWidth(s) <= width {
267		return s
268	}
269	if width <= 3 {
270		return strings.Repeat(".", width)
271	}
272	return runewidth.Truncate(s, width, "...")
273}

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