v0.51.0 a9d674e

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

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