1package modes
2
3import (
4 "context"
5 "fmt"
6 "strings"
7 "sync"
8
9 "github.com/bnema/zut/packages/agent/subagents"
10 "github.com/bnema/zut/packages/provider"
11 "github.com/bnema/zut/packages/tui"
12)
13
14// residentChildSession owns a bounded child transcript assembled from the
15// resident journal plus the child's immutable live projection. It deliberately
16// has no UI lock or disk path: callers load pages asynchronously and discard
17// them when their own dialog generation no longer matches.
18type residentChildSession struct {
19 mu sync.Mutex
20 manager *subagents.ResidentManager
21 childID string
22 olderCursor string
23 view tui.View
24 composer *tui.Editor
25 loading bool
26 recentReloadPending bool
27 err string
28 // scrollOffset is measured from the rendered live tail. Keeping the
29 // viewport bottom-relative preserves the same finalized content when an
30 // older page is prepended or the active turn grows.
31 scrollOffset int
32 unread int
33 lastRows int
34 recentRows int
35 liveRevision uint64
36 livePresent bool
37 cacheWidth int
38 cacheMessagesRevision uint64
39 cacheMessageCount int
40 cacheLiveRevision uint64
41 cacheLivePresent bool
42 cacheRows []string
43 loadCtx context.Context
44 cancelLoad context.CancelFunc
45}
46
47func newResidentChildSession(manager *subagents.ResidentManager, childID string, theme tui.Theme) *residentChildSession {
48 ctx, cancel := context.WithCancel(context.Background())
49 return &residentChildSession{manager: manager, childID: childID, view: tui.View{Theme: theme, ExpandAll: true}, composer: tui.NewEditor("follow-up > "), loadCtx: ctx, cancelLoad: cancel}
50}
51
52func (s *residentChildSession) Close() {
53 if s != nil && s.cancelLoad != nil {
54 s.cancelLoad()
55 }
56}
57
58func (s *residentChildSession) LoadRecent(limit int) error {
59 if s == nil || s.manager == nil {
60 return fmt.Errorf("resident child session: unavailable")
61 }
62 page, err := s.manager.HistoryPage(s.childID, "", limit)
63 if err != nil {
64 return err
65 }
66 messages, err := subagents.ResidentHistoryMessages(page.Items)
67 if err != nil {
68 return err
69 }
70 s.replaceRecent(messages, page.OlderCursor)
71 return nil
72}
73
74// ReloadRecent replaces only the newest bounded history page. Any older
75// pages already loaded by PgUp remain intact, so following a completed turn
76// does not discard the user's scrollback.
77func (s *residentChildSession) ReloadRecent(limit int) error {
78 return s.LoadRecent(limit)
79}
80
81// LoadAll progressively installs the newest page then prepends every older
82// page. The callback runs after each durable page so the floating pane grows
83// naturally while disk history is being reconstructed.
84func (s *residentChildSession) LoadAll(limit int, progressed func()) error {
85 if s.loadCtx != nil {
86 select {
87 case <-s.loadCtx.Done():
88 return s.loadCtx.Err()
89 default:
90 }
91 }
92 if err := s.LoadRecent(limit); err != nil {
93 return err
94 }
95 if progressed != nil {
96 progressed()
97 }
98 for {
99 if s.loadCtx != nil {
100 select {
101 case <-s.loadCtx.Done():
102 return s.loadCtx.Err()
103 default:
104 }
105 }
106 s.mu.Lock()
107 more := s.olderCursor != ""
108 s.mu.Unlock()
109 if !more {
110 return nil
111 }
112 if err := s.LoadOlder(limit); err != nil {
113 return err
114 }
115 if progressed != nil {
116 progressed()
117 }
118 }
119}
120
121// ReloadAll rebuilds from one journal generation instead of replacing a
122// projected-message suffix whose page boundary may not match tool projection.
123func (s *residentChildSession) ReloadAll(limit int, progressed func()) error {
124 s.mu.Lock()
125 s.view.Messages = nil
126 s.view.MessagesRevision++
127 s.olderCursor = ""
128 s.recentRows = 0
129 // Keep a reader's bottom-relative viewport across a durable reload.
130 // Render clamps it against the rebuilt rows; a tail follower remains at 0.
131 s.mu.Unlock()
132 return s.LoadAll(limit, progressed)
133}
134
135func (s *residentChildSession) replaceRecent(messages []provider.Message, olderCursor string) {
136 if s == nil {
137 return
138 }
139 s.mu.Lock()
140 older := len(s.view.Messages) - s.recentRows
141 if older < 0 {
142 older = 0
143 }
144 prefix := append([]provider.Message(nil), s.view.Messages[:older]...)
145 s.view.Messages = append(prefix, messages...)
146 s.view.MessagesRevision++
147 s.olderCursor = olderCursor
148 s.recentRows = len(messages)
149 s.err = ""
150 s.refreshLiveLocked()
151 s.mu.Unlock()
152}
153
154func (s *residentChildSession) LoadOlder(limit int) error {
155 if s == nil || s.manager == nil {
156 return fmt.Errorf("resident child session: unavailable")
157 }
158 s.mu.Lock()
159 cursor := s.olderCursor
160 s.mu.Unlock()
161 if cursor == "" {
162 return nil
163 }
164 page, err := s.manager.HistoryPage(s.childID, cursor, limit)
165 if err != nil {
166 return err
167 }
168 messages, err := subagents.ResidentHistoryMessages(page.Items)
169 if err != nil {
170 return err
171 }
172 s.mu.Lock()
173 s.view.Messages = append(messages, s.view.Messages...)
174 s.view.MessagesRevision++
175 s.olderCursor = page.OlderCursor
176 s.err = ""
177 s.refreshLiveLocked()
178 s.mu.Unlock()
179 return nil
180}
181
182func (s *residentChildSession) refreshLiveLocked() {
183 if s == nil || s.manager == nil {
184 return
185 }
186 live, ok := s.manager.Live(s.childID)
187 if !ok {
188 s.view.Streaming = ""
189 s.view.StreamingActive = false
190 s.view.ToolCalls = nil
191 s.liveRevision = 0
192 s.livePresent = false
193 return
194 }
195 s.liveRevision = live.Revision
196 s.livePresent = true
197 s.view.Streaming = live.AssistantText
198 s.view.StreamingActive = live.State == subagents.ResidentRunning && live.AssistantText != ""
199 s.view.ToolCalls = make([]tui.ToolCallView, len(live.Tools))
200 for index, tool := range live.Tools {
201 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}
202 }
203}
204
205func (s *residentChildSession) renderedHistory(width int) []string {
206 s.mu.Lock()
207 s.refreshLiveLocked()
208 if s.cacheWidth == width && s.cacheMessagesRevision == s.view.MessagesRevision && s.cacheMessageCount == len(s.view.Messages) && s.cacheLiveRevision == s.liveRevision && s.cacheLivePresent == s.livePresent && s.cacheRows != nil {
209 rows := s.cacheRows
210 s.mu.Unlock()
211 return rows
212 }
213 view := s.view.CloneForRender()
214 messagesRevision, liveRevision := s.view.MessagesRevision, s.liveRevision
215 s.mu.Unlock()
216 rows := view.Build(width)
217 s.mu.Lock()
218 if s.view.MessagesRevision == messagesRevision && s.liveRevision == liveRevision {
219 s.cacheWidth, s.cacheMessagesRevision, s.cacheMessageCount, s.cacheLiveRevision, s.cacheLivePresent, s.cacheRows = width, messagesRevision, len(s.view.Messages), liveRevision, s.livePresent, rows
220 s.view.AdoptRenderCacheFrom(view)
221 }
222 s.mu.Unlock()
223 return rows
224}
225
226func (s *residentChildSession) View() *tui.View {
227 if s == nil {
228 return nil
229 }
230 s.mu.Lock()
231 s.refreshLiveLocked()
232 defer s.mu.Unlock()
233 return s.view.CloneForRender()
234}
235
236func (s *residentChildSession) BeginLoad() bool {
237 if s == nil {
238 return false
239 }
240 s.mu.Lock()
241 defer s.mu.Unlock()
242 if s.loading {
243 return false
244 }
245 s.loading = true
246 s.err = ""
247 return true
248}
249
250// RequestRecentReload reserves a newest-page refresh. If another history
251// read is already in progress, it coalesces one refresh behind that read.
252func (s *residentChildSession) RequestRecentReload() bool {
253 if s == nil {
254 return false
255 }
256 s.mu.Lock()
257 defer s.mu.Unlock()
258 if s.loading {
259 s.recentReloadPending = true
260 return false
261 }
262 s.loading = true
263 s.err = ""
264 return true
265}
266
267// FinishLoad clears the current history operation and reports whether a
268// terminal child update arrived while it was loading and needs one coalesced
269// newest-page refresh.
270func (s *residentChildSession) FinishLoad(err error) bool {
271 if s == nil {
272 return false
273 }
274 s.mu.Lock()
275 s.loading = false
276 if err != nil {
277 s.err = err.Error()
278 }
279 pending := s.recentReloadPending
280 s.recentReloadPending = false
281 s.mu.Unlock()
282 return pending
283}
284
285// HandleKey updates only the child-local composer. It returns a durable prompt
286// candidate; the controller leaves it intact until Manager.Resume succeeds.
287func (s *residentChildSession) HandleKey(k tui.Key) (string, bool) {
288 if s == nil || s.composer == nil {
289 return "", false
290 }
291 s.mu.Lock()
292 defer s.mu.Unlock()
293 if !s.composer.HandleKey(k) {
294 return "", false
295 }
296 prompt := strings.TrimSpace(s.composer.SubmitValue())
297 return prompt, prompt != ""
298}
299
300func (s *residentChildSession) HandleNavigation(k tui.Key) bool {
301 if s == nil || s.composer == nil {
302 return false
303 }
304 s.mu.Lock()
305 defer s.mu.Unlock()
306 return s.composer.HandleKey(k)
307}
308
309func (s *residentChildSession) FinishSubmission(err error) {
310 if s == nil {
311 return
312 }
313 s.mu.Lock()
314 if err != nil {
315 s.err = err.Error()
316 } else if s.composer != nil {
317 s.composer.Clear()
318 s.err = ""
319 }
320 s.mu.Unlock()
321}
322
323func (s *residentChildSession) Scroll(delta int) {
324 if s == nil || delta == 0 {
325 return
326 }
327 s.mu.Lock()
328 s.scrollOffset += delta
329 if s.scrollOffset < 0 {
330 s.scrollOffset = 0
331 }
332 if s.scrollOffset == 0 {
333 s.unread = 0
334 }
335 s.mu.Unlock()
336}
337
338func (s *residentChildSession) FollowTail() {
339 if s == nil {
340 return
341 }
342 s.mu.Lock()
343 s.scrollOffset, s.unread = 0, 0
344 s.mu.Unlock()
345}
346
347// Render keeps all transcript rendering in tui.View. Disk-backed history is
348// loaded by the controller before this is called; this method consults only
349// the immutable live projection maintained by the resident child.
350func (s *residentChildSession) Render(width, height int) []string {
351 if s == nil {
352 return nil
353 }
354 s.mu.Lock()
355 loading, loadErr := s.loading, s.err
356 var editorLines []string
357 if s.composer != nil {
358 editorLines, _, _ = s.composer.Render(width)
359 }
360 s.mu.Unlock()
361 header := fmt.Sprintf(" Resident subagent %s", s.childID)
362 if s.manager != nil {
363 if snapshot, ok := s.manager.SnapshotFor(s.childID); ok {
364 header += fmt.Sprintf(" %s %s/%s", snapshot.State, snapshot.Provider, snapshot.Model)
365 if snapshot.Profile != "" {
366 header += " " + snapshot.Profile
367 }
368 if snapshot.WorkspaceMode != "" {
369 header += " " + string(snapshot.WorkspaceMode)
370 }
371 }
372 }
373 lines := []string{header}
374 if loading {
375 lines = append(lines, " Loading history…")
376 }
377 if loadErr != "" {
378 lines = append(lines, " history: "+loadErr)
379 }
380 history := s.renderedHistory(width)
381 s.mu.Lock()
382 if s.lastRows != 0 && len(history) > s.lastRows && s.scrollOffset > 0 {
383 s.scrollOffset += len(history) - s.lastRows
384 s.unread++
385 }
386 s.lastRows = len(history)
387 maxRows := height - len(lines) - len(editorLines) - 3
388 if maxRows < 0 {
389 maxRows = 0
390 }
391 if s.scrollOffset > len(history)-maxRows {
392 s.scrollOffset = len(history) - maxRows
393 if s.scrollOffset < 0 {
394 s.scrollOffset = 0
395 }
396 }
397 end := len(history) - s.scrollOffset
398 start := end - maxRows
399 if start < 0 {
400 start = 0
401 }
402 unread := s.unread
403 s.mu.Unlock()
404 lines = append(lines, history[start:end]...)
405 if unread > 0 {
406 lines = append(lines, fmt.Sprintf(" %d new updates below; PgDn to follow", unread))
407 }
408 lines = append(lines, "")
409 lines = append(lines, editorLines...)
410 lines = append(lines, " Enter: send follow-up/resume Esc: close PgUp/PgDn: scroll history")
411 if height <= 0 {
412 return nil
413 }
414 if len(lines) > height {
415 return lines[:height]
416 }
417 return lines
418}