v0.23.0 edaaa7c

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	return strings.Join(parts, " ")
105}
106
107func turnStartTime(snapshot subagents.ResidentSnapshot) time.Time {
108	if !snapshot.TurnStartedAt.IsZero() {
109		return snapshot.TurnStartedAt
110	}
111	return snapshot.UpdatedAt
112}
113
114func activityTime(snapshot subagents.ResidentSnapshot) time.Time {
115	if !snapshot.ActivityUpdatedAt.IsZero() {
116		return snapshot.ActivityUpdatedAt
117	}
118	return snapshot.UpdatedAt
119}
120
121func formatResidentSubagentActivityAge(started, now time.Time) string {
122	if started.IsZero() {
123		return "-"
124	}
125	if now.IsZero() {
126		now = time.Now()
127	}
128	elapsed := now.Sub(started)
129	if elapsed < 0 {
130		elapsed = 0
131	}
132	seconds := int(elapsed.Seconds())
133	switch {
134	case seconds < 60:
135		return fmt.Sprintf("%ds", seconds)
136	case seconds < 60*60:
137		return fmt.Sprintf("%dm%02ds", seconds/60, seconds%60)
138	case seconds < 24*60*60:
139		return fmt.Sprintf("%dh%02dm", seconds/(60*60), (seconds%(60*60))/60)
140	default:
141		return fmt.Sprintf("%dd%02dh", seconds/(24*60*60), (seconds%(24*60*60))/(60*60))
142	}
143}
144
145func renderResidentSubagentActivityPage(theme tui.Theme, manager *subagents.ResidentManager, spinnerGlyph string, snapshotLimit, width int, now time.Time) ([]string, int) {
146	if manager == nil || snapshotLimit <= 0 {
147		return nil, 0
148	}
149	snapshots, total := manager.ActiveSnapshotPage(snapshotLimit)
150	hidden := total - len(snapshots)
151	if hidden < 0 {
152		hidden = 0
153	}
154	return renderResidentSubagentActivityLines(theme, spinnerGlyph, snapshots, width, now), hidden
155}
156
157func fitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, maxBottomRows, width int, bottomRows func([]string) int) []string {
158	if len(lines) == 0 && hidden == 0 {
159		return nil
160	}
161	for maxRows := len(lines) + 1; maxRows >= 0; maxRows-- {
162		candidate := limitResidentSubagentActivityLines(theme, lines, hidden, maxRows, width)
163		if maxRows == 0 || bottomRows(candidate) <= maxBottomRows {
164			return candidate
165		}
166	}
167	return nil
168}
169
170func limitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, maxRows, width int) []string {
171	if maxRows <= 0 || (len(lines) == 0 && hidden == 0) {
172		return nil
173	}
174	groups := residentSubagentActivityLineGroups(lines)
175	if len(groups) == 0 {
176		return []string{residentSubagentActivityOverflowLine(theme, hidden, width)}
177	}
178	if hidden == 0 && len(lines) <= maxRows {
179		return lines
180	}
181
182	if maxRows == 1 {
183		if hidden == 0 && len(groups) == 1 {
184			return []string{groups[0][0]}
185		}
186		return []string{residentSubagentActivityOverflowLine(theme, hidden+len(groups), width)}
187	}
188
189	visibleGroups := len(groups)
190	overflowHidden := hidden
191	if hidden > 0 {
192		if visibleGroups > maxRows-1 {
193			visibleGroups = maxRows - 1
194		}
195		overflowHidden = hidden + len(groups) - visibleGroups
196	} else if visibleGroups > maxRows {
197		visibleGroups = maxRows - 1
198		overflowHidden = len(groups) - visibleGroups
199	}
200	if visibleGroups < 0 {
201		visibleGroups = 0
202	}
203	if visibleGroups > len(groups) {
204		visibleGroups = len(groups)
205	}
206
207	out := make([]string, 0, maxRows)
208	for index := 0; index < visibleGroups && len(out) < maxRows; index++ {
209		group := groups[index]
210		out = append(out, group[0])
211		remainingActivityRows := visibleGroups - index - 1
212		reservedRows := remainingActivityRows
213		if overflowHidden > 0 {
214			reservedRows++
215		}
216		metadataRows := maxRows - len(out) - reservedRows
217		for _, metadataLine := range group[1:] {
218			if metadataRows <= 0 || len(out) >= maxRows {
219				break
220			}
221			out = append(out, metadataLine)
222			metadataRows--
223		}
224	}
225	if overflowHidden > 0 && len(out) < maxRows {
226		out = append(out, residentSubagentActivityOverflowLine(theme, overflowHidden, width))
227	}
228	return out
229}
230
231func residentSubagentActivityLineGroups(lines []string) [][]string {
232	groups := make([][]string, 0, len(lines))
233	for _, line := range lines {
234		if strings.HasPrefix(line, "    ") && len(groups) > 0 {
235			groups[len(groups)-1] = append(groups[len(groups)-1], line)
236			continue
237		}
238		groups = append(groups, []string{line})
239	}
240	return groups
241}
242
243func residentSubagentActivityOverflowLine(theme tui.Theme, hidden, width int) string {
244	text := fmt.Sprintf("  … %d more active subagents", hidden)
245	return theme.FGColor(theme.Muted, truncateResidentSubagentIndicator(text, width))
246}
247
248func truncateResidentSubagentIndicator(s string, width int) string {
249	if width <= 0 {
250		return ""
251	}
252	if runewidth.StringWidth(s) <= width {
253		return s
254	}
255	if width <= 3 {
256		return strings.Repeat(".", width)
257	}
258	return runewidth.Truncate(s, width, "...")
259}

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