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 < 1 {
47 limit = 1
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 d.selected < d.offset {
58 d.offset = d.selected
59 }
60 if d.selected >= d.offset+limit {
61 d.offset = d.selected - limit + 1
62 }
63 if d.offset < 0 {
64 d.offset = 0
65 }
66 d.rows, d.total = d.manager.RecentSnapshotPage(d.offset, limit)
67}
68
69func (d *residentSubagentsDialog) HandleKey(k tui.Key) (openID string) {
70 if d == nil {
71 return ""
72 }
73 switch k.Kind {
74 case tui.KeyUp:
75 if d.selected > 0 {
76 d.selected--
77 }
78 case tui.KeyDown:
79 if d.selected+1 < d.total {
80 d.selected++
81 }
82 case tui.KeyEnter:
83 if len(d.rows) > 0 && d.selected >= d.offset && d.selected < d.offset+len(d.rows) {
84 return d.rows[d.selected-d.offset].ID
85 }
86 }
87 return ""
88}
89
90func (d *residentSubagentsDialog) Render(theme tui.Theme, width, height int) []string {
91 if d == nil {
92 return nil
93 }
94 maxRows := height - 3 // title, hint, and page indicator/empty state
95 if maxRows < 1 {
96 maxRows = 1
97 }
98 d.refresh(maxRows)
99 lines := []string{" Resident subagents", " Enter: open Esc: close /subagents new <task>: spawn"}
100 if len(d.rows) == 0 {
101 return append(lines, "", " No resident subagents.")
102 }
103 for index, row := range d.rows {
104 marker := " "
105 if d.offset+index == d.selected {
106 marker = theme.AccentBar(theme.Accent) + " "
107 }
108 name := residentDisplayName(row)
109 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))
110 if row.WorkspaceMode != "" {
111 label += " " + string(row.WorkspaceMode)
112 }
113 if row.Required {
114 label += " required"
115 }
116 lines = append(lines, marker+truncateResidentSubagentIndicator(label, width-2))
117 }
118 if d.total > len(d.rows) {
119 lines = append(lines, fmt.Sprintf(" %d-%d of %d", d.offset+1, d.offset+len(d.rows), d.total))
120 }
121 return lines
122}
123
124func residentDisplayName(row subagents.ResidentSnapshot) string {
125 if name := sanitizeSessionTreeText(row.Profile); name != "" {
126 return name
127 }
128 if model := sanitizeSessionTreeText(row.Model); model != "" {
129 return model
130 }
131 return "subagent"
132}
133
134func residentDisplayState(state subagents.ResidentState) string {
135 if state == subagents.ResidentIdle {
136 return "completed"
137 }
138 return string(state)
139}
140
141func shortResidentID(id string) string {
142 id = strings.TrimSpace(id)
143 if len(id) > 8 {
144 return id[:8]
145 }
146 return id
147}
148
149func formatResidentUpdatedAt(updatedAt, now time.Time) string {
150 if updatedAt.IsZero() {
151 return "time unknown"
152 }
153 if now.IsZero() {
154 now = time.Now()
155 }
156 elapsed := now.Sub(updatedAt)
157 if elapsed < 0 {
158 elapsed = 0
159 }
160 switch {
161 case elapsed < time.Minute:
162 return fmt.Sprintf("%ds ago", int(elapsed.Seconds()))
163 case elapsed < time.Hour:
164 return fmt.Sprintf("%dm ago", int(elapsed.Minutes()))
165 case elapsed < 24*time.Hour:
166 return fmt.Sprintf("%dh ago", int(elapsed.Hours()))
167 default:
168 return fmt.Sprintf("%dd ago", int(elapsed.Hours()/24))
169 }
170}