v0.17.0 949637b

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 < 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}

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