v0.17.0 949637b

subagent_session.go RawHistoryFolder

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}

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