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