v0.50.1 a637010

resident_subagents_dialog.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)
11
12// residentSubagentsDialog is intentionally only a discovery/selection list.
13// Detailed transcript, paging, and composition belong to residentChildSession.
14type residentSubagentsDialog struct {
15	active   bool
16	manager  *subagents.ResidentManager
17	rows     []subagents.ResidentSnapshot
18	selected int // absolute index in the sorted manager projection
19	offset   int
20	total    int
21}
22
23func newResidentSubagentsDialog() *residentSubagentsDialog { return &residentSubagentsDialog{} }
24
25func (d *residentSubagentsDialog) Open(manager *subagents.ResidentManager) {
26	d.active, d.manager, d.selected, d.offset, d.total = true, manager, 0, 0, 0
27	d.refresh(12)
28}
29
30func (d *residentSubagentsDialog) Active() bool { return d != nil && d.active }
31func (d *residentSubagentsDialog) Close() {
32	if d != nil {
33		d.active = false
34	}
35}
36
37func (d *residentSubagentsDialog) refresh(limit int) {
38	if d == nil {
39		return
40	}
41	if d.manager == nil {
42		d.rows = nil
43		d.total = 0
44		return
45	}
46	if limit < 0 {
47		limit = 0
48	}
49	_, total := d.manager.SnapshotPage(0, 0)
50	d.total = total
51	if d.selected >= d.total {
52		d.selected = d.total - 1
53	}
54	if d.selected < 0 || d.total == 0 {
55		d.selected = 0
56	}
57	if limit == 0 {
58		d.rows = nil
59		return
60	}
61	if d.selected < d.offset {
62		d.offset = d.selected
63	}
64	if d.selected >= d.offset+limit {
65		d.offset = d.selected - limit + 1
66	}
67	if d.offset < 0 {
68		d.offset = 0
69	}
70	d.rows, d.total = d.manager.RecentSnapshotPage(d.offset, limit)
71}
72
73func (d *residentSubagentsDialog) HandleKey(k tui.Key) (openID string) {
74	if d == nil {
75		return ""
76	}
77	switch k.Kind {
78	case tui.KeyUp:
79		if d.selected > 0 {
80			d.selected--
81		}
82	case tui.KeyDown:
83		if d.selected+1 < d.total {
84			d.selected++
85		}
86	case tui.KeyEnter:
87		if len(d.rows) > 0 && d.selected >= d.offset && d.selected < d.offset+len(d.rows) {
88			return d.rows[d.selected-d.offset].ID
89		}
90	}
91	return ""
92}
93
94func (d *residentSubagentsDialog) Render(theme tui.Theme, width, height int) []string {
95	if d == nil {
96		return nil
97	}
98	// Reserve two lines per child so usage metadata never pushes the dialog
99	// beyond its content height. Children without usage simply consume less.
100	maxRows := (height - 3) / 2 // title, hint, and page indicator/empty state
101	if maxRows < 0 {
102		maxRows = 0
103	}
104	d.refresh(maxRows)
105	lines := []string{"  Resident subagents", "  Enter: open   Esc: close   /subagents new <task>: spawn"}
106	if len(d.rows) == 0 {
107		if d.total > 0 {
108			return limitResidentSubagentsDialogHeight(lines, height)
109		}
110		if height == len(lines)+1 {
111			lines = append(lines, "  No resident subagents.")
112		} else {
113			lines = append(lines, "", "  No resident subagents.")
114		}
115		return limitResidentSubagentsDialogHeight(lines, height)
116	}
117	for index, row := range d.rows {
118		marker := "  "
119		if d.offset+index == d.selected {
120			marker = theme.AccentBar(theme.Accent) + " "
121		}
122		name := residentDisplayName(row)
123		label := fmt.Sprintf("%s  %s  %s  %s/%s  %s", name, residentDisplayState(row.State), formatResidentUpdatedAt(row.UpdatedAt, time.Now()), row.Provider, row.Model, shortResidentID(row.ID))
124		if row.WorkspaceMode != "" {
125			label += "  " + string(row.WorkspaceMode)
126		}
127		if row.Required {
128			label += "  required"
129		}
130		lines = append(lines, marker+truncateResidentSubagentIndicator(label, width-2))
131		if metadata := residentUsageMetadata(row); metadata != "" {
132			metadata = truncateResidentSubagentIndicator(metadata, width-4)
133			if metadata != "" {
134				lines = append(lines, "    "+metadata)
135			}
136		}
137	}
138	if d.total > len(d.rows) {
139		lines = append(lines, fmt.Sprintf("  %d-%d of %d", d.offset+1, d.offset+len(d.rows), d.total))
140	}
141	return limitResidentSubagentsDialogHeight(lines, height)
142}
143
144func limitResidentSubagentsDialogHeight(lines []string, height int) []string {
145	if height <= 0 {
146		return nil
147	}
148	if len(lines) <= height {
149		return lines
150	}
151	return lines[:height]
152}
153
154func residentDisplayName(row subagents.ResidentSnapshot) string {
155	if name := sanitizeSessionTreeText(row.Profile); name != "" {
156		return name
157	}
158	if model := sanitizeSessionTreeText(row.Model); model != "" {
159		return model
160	}
161	return "subagent"
162}
163
164func residentDisplayState(state subagents.ResidentState) string {
165	if state == subagents.ResidentIdle {
166		return "completed"
167	}
168	return string(state)
169}
170
171func shortResidentID(id string) string {
172	id = strings.TrimSpace(id)
173	if len(id) > 8 {
174		return id[:8]
175	}
176	return id
177}
178
179func formatResidentUpdatedAt(updatedAt, now time.Time) string {
180	if updatedAt.IsZero() {
181		return "time unknown"
182	}
183	if now.IsZero() {
184		now = time.Now()
185	}
186	elapsed := now.Sub(updatedAt)
187	if elapsed < 0 {
188		elapsed = 0
189	}
190	switch {
191	case elapsed < time.Minute:
192		return fmt.Sprintf("%ds ago", int(elapsed.Seconds()))
193	case elapsed < time.Hour:
194		return fmt.Sprintf("%dm ago", int(elapsed.Minutes()))
195	case elapsed < 24*time.Hour:
196		return fmt.Sprintf("%dh ago", int(elapsed.Hours()))
197	default:
198		return fmt.Sprintf("%dd ago", int(elapsed.Hours()/24))
199	}
200}

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