1package modes
2
3import (
4 "fmt"
5 "strings"
6 "sync"
7
8 "github.com/bnema/zut/packages/agent/subagents"
9 "github.com/bnema/zut/packages/provider"
10 "github.com/bnema/zut/packages/tui"
11)
12
13// residentChildSession owns a bounded child transcript assembled from the
14// resident journal plus the child's immutable live projection. It deliberately
15// has no UI lock or disk path: callers load pages asynchronously and discard
16// them when their own dialog generation no longer matches.
17type residentChildSession struct {
18 mu sync.Mutex
19 manager *subagents.ResidentManager
20 childID string
21 olderCursor string
22 view tui.View
23 composer *tui.Editor
24 loading bool
25 recentReloadPending bool
26 err string
27 // scrollOffset is measured from the rendered live tail. Keeping the
28 // viewport bottom-relative preserves the same finalized content when an
29 // older page is prepended or the active turn grows.
30 scrollOffset int
31 unread int
32 lastRows int
33 recentRows int
34}
35
36func newResidentChildSession(manager *subagents.ResidentManager, childID string, theme tui.Theme) *residentChildSession {
37 return &residentChildSession{manager: manager, childID: childID, view: tui.View{Theme: theme, ExpandAll: true}, composer: tui.NewEditor("follow-up > ")}
38}
39
40func (s *residentChildSession) LoadRecent(limit int) error {
41 if s == nil || s.manager == nil {
42 return fmt.Errorf("resident child session: unavailable")
43 }
44 page, err := s.manager.HistoryPage(s.childID, "", limit)
45 if err != nil {
46 return err
47 }
48 messages, err := subagents.ResidentHistoryMessages(page.Items)
49 if err != nil {
50 return err
51 }
52 s.replaceRecent(messages, page.OlderCursor)
53 return nil
54}
55
56// ReloadRecent replaces only the newest bounded history page. Any older
57// pages already loaded by PgUp remain intact, so following a completed turn
58// does not discard the user's scrollback.
59func (s *residentChildSession) ReloadRecent(limit int) error {
60 return s.LoadRecent(limit)
61}
62
63func (s *residentChildSession) replaceRecent(messages []provider.Message, olderCursor string) {
64 if s == nil {
65 return
66 }
67 s.mu.Lock()
68 older := len(s.view.Messages) - s.recentRows
69 if older < 0 {
70 older = 0
71 }
72 prefix := append([]provider.Message(nil), s.view.Messages[:older]...)
73 s.view.Messages = append(prefix, messages...)
74 s.view.MessagesRevision++
75 s.olderCursor = olderCursor
76 s.recentRows = len(messages)
77 s.err = ""
78 s.refreshLiveLocked()
79 s.mu.Unlock()
80}
81
82func (s *residentChildSession) LoadOlder(limit int) error {
83 if s == nil || s.manager == nil {
84 return fmt.Errorf("resident child session: unavailable")
85 }
86 s.mu.Lock()
87 cursor := s.olderCursor
88 s.mu.Unlock()
89 if cursor == "" {
90 return nil
91 }
92 page, err := s.manager.HistoryPage(s.childID, cursor, limit)
93 if err != nil {
94 return err
95 }
96 messages, err := subagents.ResidentHistoryMessages(page.Items)
97 if err != nil {
98 return err
99 }
100 s.mu.Lock()
101 s.view.Messages = append(messages, s.view.Messages...)
102 s.view.MessagesRevision++
103 s.olderCursor = page.OlderCursor
104 s.err = ""
105 s.refreshLiveLocked()
106 s.mu.Unlock()
107 return nil
108}
109
110func (s *residentChildSession) refreshLiveLocked() {
111 if s == nil || s.manager == nil {
112 return
113 }
114 live, ok := s.manager.Live(s.childID)
115 if !ok {
116 s.view.Streaming = ""
117 s.view.StreamingActive = false
118 s.view.ToolCalls = nil
119 return
120 }
121 s.view.Streaming = live.AssistantText
122 s.view.StreamingActive = live.State == subagents.ResidentRunning && live.AssistantText != ""
123 s.view.ToolCalls = make([]tui.ToolCallView, len(live.Tools))
124 for index, tool := range live.Tools {
125 s.view.ToolCalls[index] = tui.ToolCallView{ID: tool.ID, Name: tool.Name, Args: tui.ShortArgs(tool.Name, tool.Args), RawJSONBuf: string(tool.Args), Streaming: tool.State == subagents.ResidentLiveToolComposing, Revision: live.Revision}
126 }
127}
128
129func (s *residentChildSession) View() *tui.View {
130 if s == nil {
131 return nil
132 }
133 s.mu.Lock()
134 s.refreshLiveLocked()
135 defer s.mu.Unlock()
136 return s.view.CloneForRender()
137}
138
139func (s *residentChildSession) BeginLoad() bool {
140 if s == nil {
141 return false
142 }
143 s.mu.Lock()
144 defer s.mu.Unlock()
145 if s.loading {
146 return false
147 }
148 s.loading = true
149 s.err = ""
150 return true
151}
152
153// RequestRecentReload reserves a newest-page refresh. If another history
154// read is already in progress, it coalesces one refresh behind that read.
155func (s *residentChildSession) RequestRecentReload() bool {
156 if s == nil {
157 return false
158 }
159 s.mu.Lock()
160 defer s.mu.Unlock()
161 if s.loading {
162 s.recentReloadPending = true
163 return false
164 }
165 s.loading = true
166 s.err = ""
167 return true
168}
169
170// FinishLoad clears the current history operation and reports whether a
171// terminal child update arrived while it was loading and needs one coalesced
172// newest-page refresh.
173func (s *residentChildSession) FinishLoad(err error) bool {
174 if s == nil {
175 return false
176 }
177 s.mu.Lock()
178 s.loading = false
179 if err != nil {
180 s.err = err.Error()
181 }
182 pending := s.recentReloadPending
183 s.recentReloadPending = false
184 s.mu.Unlock()
185 return pending
186}
187
188// HandleKey updates only the child-local composer. It returns a durable prompt
189// candidate; the controller leaves it intact until Manager.Resume succeeds.
190func (s *residentChildSession) HandleKey(k tui.Key) (string, bool) {
191 if s == nil || s.composer == nil {
192 return "", false
193 }
194 s.mu.Lock()
195 defer s.mu.Unlock()
196 if !s.composer.HandleKey(k) {
197 return "", false
198 }
199 prompt := strings.TrimSpace(s.composer.SubmitValue())
200 return prompt, prompt != ""
201}
202
203func (s *residentChildSession) FinishSubmission(err error) {
204 if s == nil {
205 return
206 }
207 s.mu.Lock()
208 if err != nil {
209 s.err = err.Error()
210 } else if s.composer != nil {
211 s.composer.Clear()
212 s.err = ""
213 }
214 s.mu.Unlock()
215}
216
217func (s *residentChildSession) Scroll(delta int) {
218 if s == nil || delta == 0 {
219 return
220 }
221 s.mu.Lock()
222 s.scrollOffset += delta
223 if s.scrollOffset < 0 {
224 s.scrollOffset = 0
225 }
226 if s.scrollOffset == 0 {
227 s.unread = 0
228 }
229 s.mu.Unlock()
230}
231
232func (s *residentChildSession) FollowTail() {
233 if s == nil {
234 return
235 }
236 s.mu.Lock()
237 s.scrollOffset, s.unread = 0, 0
238 s.mu.Unlock()
239}
240
241// Render keeps all transcript rendering in tui.View. Disk-backed history is
242// loaded by the controller before this is called; this method consults only
243// the immutable live projection maintained by the resident child.
244func (s *residentChildSession) Render(width, height int) []string {
245 if s == nil {
246 return nil
247 }
248 view := s.View()
249 if view == nil {
250 return []string{" resident subagent unavailable"}
251 }
252 s.mu.Lock()
253 loading, loadErr := s.loading, s.err
254 var editorLines []string
255 if s.composer != nil {
256 editorLines, _, _ = s.composer.Render(width)
257 }
258 s.mu.Unlock()
259 header := fmt.Sprintf(" Resident subagent %s", s.childID)
260 if s.manager != nil {
261 if snapshot, ok := s.manager.SnapshotFor(s.childID); ok {
262 header += fmt.Sprintf(" %s %s/%s", snapshot.State, snapshot.Provider, snapshot.Model)
263 if snapshot.Profile != "" {
264 header += " " + snapshot.Profile
265 }
266 if snapshot.WorkspaceMode != "" {
267 header += " " + string(snapshot.WorkspaceMode)
268 }
269 }
270 }
271 lines := []string{header}
272 if loading {
273 lines = append(lines, " Loading history…")
274 }
275 if loadErr != "" {
276 lines = append(lines, " history: "+loadErr)
277 }
278 history := view.Build(width)
279 s.mu.Lock()
280 if s.lastRows != 0 && len(history) > s.lastRows && s.scrollOffset > 0 {
281 s.scrollOffset += len(history) - s.lastRows
282 s.unread++
283 }
284 s.lastRows = len(history)
285 maxRows := height - len(lines) - len(editorLines) - 3
286 if maxRows < 0 {
287 maxRows = 0
288 }
289 if s.scrollOffset > len(history)-maxRows {
290 s.scrollOffset = len(history) - maxRows
291 if s.scrollOffset < 0 {
292 s.scrollOffset = 0
293 }
294 }
295 end := len(history) - s.scrollOffset
296 start := end - maxRows
297 if start < 0 {
298 start = 0
299 }
300 unread := s.unread
301 s.mu.Unlock()
302 lines = append(lines, history[start:end]...)
303 if unread > 0 {
304 lines = append(lines, fmt.Sprintf(" %d new updates below; PgDn to follow", unread))
305 }
306 lines = append(lines, "")
307 lines = append(lines, editorLines...)
308 lines = append(lines, " Enter: send follow-up/resume Esc: close PgUp: load older history")
309 if height <= 0 {
310 return nil
311 }
312 if len(lines) > height {
313 return lines[:height]
314 }
315 return lines
316}