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}