v0.23.0 edaaa7c

subagent_session.go RawHistoryFolder

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}

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 476

↑↓ moveenter openesc close