v0.20.0 29be61c

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 bounded, non-sensitive
19// background work indicator shown directly below the main editor. A queued
20// child is waiting for a resident scheduler slot; only running children use
21// the animated glyph.
22func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, snapshots []subagents.ResidentSnapshot, width int, now time.Time) []string {
23	lines := make([]string, 0, len(snapshots)*2)
24	for _, snapshot := range snapshots {
25		// Foreign journals remain discoverable through /subagents, but their
26		// activity belongs to another zut host and must not occupy this
27		// session's always-visible editor-adjacent indicator.
28		if snapshot.OwnedElsewhere || (snapshot.State != subagents.ResidentQueued && snapshot.State != subagents.ResidentRunning) {
29			continue
30		}
31		name := sanitizeSessionTreeText(snapshot.Profile)
32		if name == "" {
33			name = sanitizeSessionTreeText(snapshot.ID)
34		}
35		if name == "" {
36			name = "subagent"
37		}
38		glyph := "…"
39		color := theme.Muted
40		activity := "queued"
41		if snapshot.State == subagents.ResidentRunning {
42			glyph = strings.TrimSpace(spinnerGlyph)
43			if glyph == "" {
44				glyph = "."
45			}
46			color = theme.Spinner
47			if snapshot.WaitingForModel {
48				activity = residentProviderWaitingSpinner.FrameAt(activityTime(snapshot), now)
49			} else {
50				activity = "running"
51			}
52		}
53		plain := fmt.Sprintf("%s %s · %s · activity %s ago · %s", glyph, name, activity,
54			formatResidentSubagentActivityAge(activityTime(snapshot), now),
55			formatResidentSubagentActivityAge(turnStartTime(snapshot), now))
56		plain = truncateResidentSubagentIndicator(plain, width-2)
57		if plain == "" {
58			continue
59		}
60		lines = append(lines, "  "+theme.FGColor(color, plain))
61		if metadata := residentUsageMetadata(snapshot); metadata != "" {
62			metadata = truncateResidentSubagentIndicator(metadata, width-4)
63			if metadata != "" {
64				lines = append(lines, "    "+theme.FGColor(theme.Muted, metadata))
65			}
66		}
67	}
68	return lines
69}
70
71func residentUsageMetadata(snapshot subagents.ResidentSnapshot) string {
72	parts := tui.UsageStatsParts(tui.UsageStatsParams{Usage: snapshot.Usage, Subscription: snapshot.Subscription})
73	if context := tui.ContextUsageText(snapshot.ContextUsed, snapshot.ContextMax); context != "" {
74		parts = append(parts, context)
75	}
76	return strings.Join(parts, " ")
77}
78
79func turnStartTime(snapshot subagents.ResidentSnapshot) time.Time {
80	if !snapshot.TurnStartedAt.IsZero() {
81		return snapshot.TurnStartedAt
82	}
83	return snapshot.UpdatedAt
84}
85
86func activityTime(snapshot subagents.ResidentSnapshot) time.Time {
87	if !snapshot.ActivityUpdatedAt.IsZero() {
88		return snapshot.ActivityUpdatedAt
89	}
90	return snapshot.UpdatedAt
91}
92
93func formatResidentSubagentActivityAge(started, now time.Time) string {
94	if started.IsZero() {
95		return "-"
96	}
97	if now.IsZero() {
98		now = time.Now()
99	}
100	elapsed := now.Sub(started)
101	if elapsed < 0 {
102		elapsed = 0
103	}
104	seconds := int(elapsed.Seconds())
105	switch {
106	case seconds < 60:
107		return fmt.Sprintf("%ds", seconds)
108	case seconds < 60*60:
109		return fmt.Sprintf("%dm%02ds", seconds/60, seconds%60)
110	case seconds < 24*60*60:
111		return fmt.Sprintf("%dh%02dm", seconds/(60*60), (seconds%(60*60))/60)
112	default:
113		return fmt.Sprintf("%dd%02dh", seconds/(24*60*60), (seconds%(24*60*60))/(60*60))
114	}
115}
116
117func limitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, maxRows, width int) []string {
118	if maxRows <= 0 || (len(lines) == 0 && hidden == 0) {
119		return nil
120	}
121	groups := make([][]string, 0, len(lines))
122	for _, line := range lines {
123		if strings.HasPrefix(line, "    ") && len(groups) > 0 {
124			groups[len(groups)-1] = append(groups[len(groups)-1], line)
125			continue
126		}
127		groups = append(groups, []string{line})
128	}
129	if hidden == 0 && len(lines) <= maxRows {
130		return lines
131	}
132	if maxRows == 1 {
133		return []string{residentSubagentActivityOverflowLine(theme, len(groups)+hidden, width)}
134	}
135	out := make([]string, 0, maxRows)
136	visibleGroups := 0
137	for _, group := range groups {
138		if len(out)+len(group) > maxRows-1 {
139			break
140		}
141		out = append(out, group...)
142		visibleGroups++
143	}
144	return append(out, residentSubagentActivityOverflowLine(theme, hidden+len(groups)-visibleGroups, width))
145}
146
147func residentSubagentActivityOverflowLine(theme tui.Theme, hidden, width int) string {
148	text := fmt.Sprintf("  … %d more active subagents", hidden)
149	return theme.FGColor(theme.Muted, truncateResidentSubagentIndicator(text, width))
150}
151
152func truncateResidentSubagentIndicator(s string, width int) string {
153	if width <= 0 {
154		return ""
155	}
156	if runewidth.StringWidth(s) <= width {
157		return s
158	}
159	if width <= 3 {
160		return strings.Repeat(".", width)
161	}
162	return runewidth.Truncate(s, width, "...")
163}

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