v0.38.0 58133ea

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

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