v0.31.0 9467429

session_tree_dialog.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"fmt"
6	"reflect"
7	"strings"
8	"unicode"
9
10	"github.com/mattn/go-runewidth"
11
12	"github.com/bnema/zut/packages/core"
13	"github.com/bnema/zut/packages/provider"
14	"github.com/bnema/zut/packages/tui"
15)
16
17// sessionTreeBoundary describes what a tree row points at. Message rows are
18// normal transcript rows. Empty and detached rows are real, selectable
19// boundaries even though they do not correspond to a message.
20type sessionTreeBoundary uint8
21
22const (
23	sessionTreeMessageBoundary sessionTreeBoundary = iota
24	sessionTreeEmptyBoundary
25	sessionTreeDetachedBoundary
26)
27
28func (b sessionTreeBoundary) String() string {
29	switch b {
30	case sessionTreeEmptyBoundary:
31		return "empty"
32	case sessionTreeDetachedBoundary:
33		return "detached"
34	default:
35		return "message"
36	}
37}
38
39// sessionTreeTarget is the stable description of a row in the dialog.
40// EffectiveIndex and SelectionBoundary refer to the row's provider-valid
41// segment. Ordinary rows use SourcePath's effective transcript; historical
42// rows use the pre-compaction segment identified by HistorySegment. Keeping
43// both values is important for a user row: selecting it branches before that
44// row so the complete draft can be put back in the editor.
45type sessionTreeTarget struct {
46	SourcePath        string
47	EffectiveIndex    int
48	SelectionBoundary int
49	Role              provider.Role
50	UserDraft         string
51	Boundary          sessionTreeBoundary
52	Historical        bool
53	HistorySegment    int
54}
55
56func (t sessionTreeTarget) IsBoundary() bool {
57	return t.Boundary != sessionTreeMessageBoundary
58}
59
60func (t sessionTreeTarget) IsEmptyBoundary() bool {
61	return t.Boundary == sessionTreeEmptyBoundary
62}
63
64func (t sessionTreeTarget) IsDetachedBoundary() bool {
65	return t.Boundary == sessionTreeDetachedBoundary
66}
67
68// sessionTreeDialog renders a compact outline of the current session family.
69// Branches are shown inline at the point where they forked, using indentation
70// rather than file-level rows. Selecting a node checks out that point into a
71// new branch.
72type sessionTreeDialog struct {
73	active bool
74	items  []sessionTreeItem
75	cursor int
76
77	loading        bool
78	loadingDone    int
79	loadGeneration uint64
80	loadCancel     context.CancelFunc
81	loadDone       chan struct{}
82	loadFamily     func(context.Context, string, string, string) ([]sessionTreeItem, error)
83
84	// MaxRows is the maximum number of transcript/boundary rows rendered in
85	// one viewport. The interactive host may set it from terminal height. A
86	// bounded default is deliberately used when it is left at zero so a large
87	// family cannot consume the whole chat pane.
88	MaxRows int
89	viewTop int
90}
91
92type sessionTreeItem struct {
93	label      string
94	messageIdx int // compatibility alias for target.EffectiveIndex
95	turnNo     int
96	role       provider.Role
97	prompt     string // compatibility alias for target.UserDraft
98	path       string // compatibility alias for target.SourcePath
99	depth      int
100	current    bool
101	target     sessionTreeTarget
102}
103
104// sessionTreeAction is returned by HandleKey. Target is the structured API
105// for the integration layer. The scalar fields are retained for the existing
106// interactive.go integration and mirror a message target where possible.
107type sessionTreeAction struct {
108	Select bool
109	Target sessionTreeTarget
110
111	// Legacy/package integration fields. New callers should use Target,
112	// especially Target.SelectionBoundary for empty or detached rows.
113	MessageIdx int
114	TurnNo     int
115	Role       provider.Role
116	Prompt     string
117	Path       string
118	Boundary   sessionTreeBoundary
119	Close      bool
120}
121
122type sessionTreeSnapshot struct {
123	path     string
124	messages []provider.Message
125	history  []core.SessionHistorySegment
126}
127
128type sessionTreeLoadEventKind uint8
129
130const (
131	sessionTreeLoadStarted sessionTreeLoadEventKind = iota
132	sessionTreeLoadEntry
133	sessionTreeLoadFinished
134	sessionTreeLoadFailed
135)
136
137type sessionTreeLoadEvent struct {
138	kind       sessionTreeLoadEventKind
139	generation uint64
140	item       sessionTreeItem
141	err        error
142}
143
144const defaultSessionTreeRows = 12
145
146func newSessionTreeDialog() *sessionTreeDialog { return &sessionTreeDialog{} }
147
148// OpenMessages opens a snapshot supplied by the running agent. This is the
149// fallback used before a session has a persisted source path.
150func (d *sessionTreeDialog) OpenMessages(msgs []provider.Message) bool {
151	items := buildSessionTreeItems("", msgs, 0, false)
152	if len(items) == 0 {
153		return false
154	}
155	d.activate(items, len(items)-1)
156	return true
157}
158
159// OpenSessionFamily preflights the complete family containing currentPath,
160// then activates the dialog in one step. A missing current path is not
161// recoverable by choosing an arbitrary forest root. Failed reads leave the
162// prior dialog state untouched.
163func (d *sessionTreeDialog) OpenSessionFamily(root, cwd, currentPath string) bool {
164	items, err := loadSessionTreeFamily(context.Background(), root, cwd, currentPath)
165	if err != nil || len(items) == 0 {
166		return false
167	}
168	d.activate(items, indexCurrentTreeItem(items))
169	return true
170}
171
172// OpenSessionFamilyAsync shows the tree immediately and streams rows from the
173// newest visible content toward the oldest. The caller applies returned events
174// on its UI goroutine, so disk reads never contend with input or rendering.
175func (d *sessionTreeDialog) OpenSessionFamilyAsync(parent context.Context, root, cwd, currentPath string, beforeLoad func()) <-chan sessionTreeLoadEvent {
176	if d.loadCancel != nil {
177		d.loadCancel()
178	}
179	previousDone := d.loadDone
180	if parent == nil {
181		parent = context.Background()
182	}
183	ctx, cancel := context.WithCancel(parent)
184	d.loadCancel = cancel
185	d.loadGeneration++
186	generation := d.loadGeneration
187	d.loadDone = make(chan struct{})
188	d.items = nil
189	d.cursor = 0
190	d.viewTop = 0
191	d.loading = true
192	d.loadingDone = 0
193	d.active = true
194
195	// Keep the producer ahead of a redraw so a large family does not require
196	// one main-loop iteration for every emitted row.
197	events := make(chan sessionTreeLoadEvent, 64)
198	send := func(event sessionTreeLoadEvent) bool {
199		select {
200		case events <- event:
201			return true
202		case <-ctx.Done():
203			return false
204		}
205	}
206	loadFamily := d.loadFamily
207	done := d.loadDone
208	go func() {
209		defer close(done)
210		defer close(events)
211		if previousDone != nil {
212			select {
213			case <-previousDone:
214			case <-ctx.Done():
215				return
216			}
217		}
218		if beforeLoad != nil {
219			beforeLoad()
220		}
221		if ctx.Err() != nil {
222			return
223		}
224		if !send(sessionTreeLoadEvent{kind: sessionTreeLoadStarted, generation: generation}) {
225			return
226		}
227		emit := func(item sessionTreeItem) bool {
228			return send(sessionTreeLoadEvent{kind: sessionTreeLoadEntry, generation: generation, item: item})
229		}
230		var err error
231		if loadFamily != nil {
232			items, loadErr := loadFamily(ctx, root, cwd, currentPath)
233			err = loadErr
234			for index := len(items) - 1; err == nil && index >= 0; index-- {
235				if !emit(items[index]) {
236					return
237				}
238			}
239		} else {
240			err = streamSessionTreeFamily(ctx, root, cwd, currentPath, emit)
241		}
242		if err != nil {
243			if ctx.Err() == nil {
244				send(sessionTreeLoadEvent{kind: sessionTreeLoadFailed, generation: generation, err: err})
245			}
246			return
247		}
248		send(sessionTreeLoadEvent{kind: sessionTreeLoadFinished, generation: generation})
249	}()
250	return events
251}
252
253// ApplyLoad incorporates a background event on the UI goroutine. A non-nil
254// result means the complete family could not be read and should be reported by
255// the host after it closes the overlay.
256func (d *sessionTreeDialog) ApplyLoad(event sessionTreeLoadEvent) error {
257	if !d.active || event.generation != d.loadGeneration {
258		return nil
259	}
260	switch event.kind {
261	case sessionTreeLoadStarted:
262	case sessionTreeLoadEntry:
263		d.items = append(d.items, event.item)
264		d.loadingDone++
265	case sessionTreeLoadFinished:
266		if cursor := indexCurrentTreeItem(d.items); cursor >= 0 {
267			d.cursor = cursor
268		}
269		d.finishLoad()
270	case sessionTreeLoadFailed:
271		d.finishLoad()
272		d.active = false
273		return event.err
274	}
275	return nil
276}
277
278func (d *sessionTreeDialog) ApplyLoadClosed() {
279	if d.active && d.loading {
280		d.finishLoad()
281		d.active = false
282	}
283}
284
285func (d *sessionTreeDialog) finishLoad() {
286	d.loading = false
287	if d.loadCancel != nil {
288		d.loadCancel()
289		d.loadCancel = nil
290	}
291}
292
293func loadSessionTreeFamily(ctx context.Context, root, cwd, currentPath string) ([]sessionTreeItem, error) {
294	roots, err := core.BuildSessionTreeFamilyContext(ctx, root, cwd, currentPath)
295	if err != nil {
296		return nil, err
297	}
298	if len(roots) == 0 {
299		return nil, fmt.Errorf("session tree: no readable session family")
300	}
301	familyRoot := roots[0]
302	snapshots, err := preflightSessionTreeFamilyContext(ctx, familyRoot)
303	if err != nil {
304		return nil, err
305	}
306	items := flattenSessionFamilySnapshot(familyRoot, currentPath, snapshots)
307	if len(items) == 0 || !sessionTreeItemsMeaningful(items, familyRoot.Summary.Path) {
308		return nil, fmt.Errorf("session tree: no messages in this family")
309	}
310	return items, nil
311}
312
313func streamSessionTreeFamily(ctx context.Context, root, cwd, currentPath string, emit func(sessionTreeItem) bool) error {
314	roots, err := core.BuildSessionTreeFamilyContext(ctx, root, cwd, currentPath)
315	if err != nil {
316		return err
317	}
318	if len(roots) == 0 {
319		return fmt.Errorf("session tree: no readable session family")
320	}
321	familyRoot := roots[0]
322	current := findSessionTreeNode(familyRoot, currentPath)
323	if current == nil {
324		return fmt.Errorf("session tree: current session is missing from its family")
325	}
326	currentHistory, err := core.ReadSessionHistoryContext(ctx, current.Summary.Path)
327	if err != nil {
328		return fmt.Errorf("session tree: read current %q: %w", current.Summary.Path, err)
329	}
330	if currentHistory.Meta.ID != current.Meta.ID {
331		return fmt.Errorf("session tree: current session id changed in %q", current.Summary.Path)
332	}
333	currentSnapshots := map[string]sessionTreeSnapshot{
334		current.Summary.Path: {path: current.Summary.Path, messages: sessionHistoryMessages(currentHistory), history: currentHistory.Segments},
335	}
336	preview := flattenSessionNode(current, currentPath, currentSnapshots, 0, current.Meta.ForkPoint, false, false)
337	seen := make(map[sessionTreeTarget]struct{}, len(preview))
338	for index := len(preview) - 1; index >= 0; index-- {
339		seen[preview[index].target] = struct{}{}
340		if !emit(preview[index]) {
341			return ctx.Err()
342		}
343	}
344
345	snapshots, err := preflightSessionTreeFamilyContext(ctx, familyRoot)
346	if err != nil {
347		return err
348	}
349	items := flattenSessionFamilySnapshot(familyRoot, currentPath, snapshots)
350	if len(items) == 0 || !sessionTreeItemsMeaningful(items, familyRoot.Summary.Path) {
351		return fmt.Errorf("session tree: no messages in this family")
352	}
353	for index := len(items) - 1; index >= 0; index-- {
354		if _, alreadyEmitted := seen[items[index].target]; alreadyEmitted {
355			continue
356		}
357		if !emit(items[index]) {
358			return ctx.Err()
359		}
360	}
361	return nil
362}
363
364func findSessionTreeNode(node *core.TreeNode, path string) *core.TreeNode {
365	if node == nil {
366		return nil
367	}
368	if node.Summary.Path == path {
369		return node
370	}
371	for _, child := range node.Children {
372		if found := findSessionTreeNode(child, path); found != nil {
373			return found
374		}
375	}
376	return nil
377}
378
379func sessionHistoryMessages(history core.SessionHistory) []provider.Message {
380	if len(history.Segments) == 0 {
381		return nil
382	}
383	return history.Segments[len(history.Segments)-1].Messages
384}
385
386func sessionTreeItemsMeaningful(items []sessionTreeItem, rootPath string) bool {
387	for _, item := range items {
388		if !item.target.IsBoundary() || item.path != rootPath {
389			return true
390		}
391	}
392	return false
393}
394
395func (d *sessionTreeDialog) activate(items []sessionTreeItem, cursor int) {
396	if d.loadCancel != nil {
397		d.loadCancel()
398		d.loadCancel = nil
399	}
400	d.loading = false
401	if cursor < 0 {
402		cursor = 0
403	}
404	if cursor >= len(items) {
405		cursor = len(items) - 1
406	}
407	d.items = items
408	d.cursor = cursor
409	d.viewTop = 0
410	d.active = true
411}
412
413// Close hides the dialog and cancels any in-flight family read.
414func (d *sessionTreeDialog) Close() {
415	if d.loadCancel != nil {
416		d.loadCancel()
417		d.loadCancel = nil
418	}
419	d.loading = false
420	d.active = false
421}
422
423// CursorPos returns -1 because this dialog has no inline editor.
424func (d *sessionTreeDialog) CursorPos() (row, col int) { return -1, -1 }
425
426// Active reports whether the dialog consumes input.
427func (d *sessionTreeDialog) Active() bool { return d != nil && d.active }
428
429func (d *sessionTreeDialog) pageRows() int {
430	if d.MaxRows > 0 {
431		return d.MaxRows
432	}
433	return defaultSessionTreeRows
434}
435
436func (d *sessionTreeDialog) followCursor() {
437	d.viewTop = clampSessionTreeViewTop(d.viewTop, d.cursor, d.pageRows(), len(d.items))
438}
439
440// HandleKey advances the cursor or resolves the selection.
441func (d *sessionTreeDialog) HandleKey(k tui.Key) sessionTreeAction {
442	if d == nil {
443		return sessionTreeAction{}
444	}
445	page := d.pageRows()
446	if page < 1 {
447		page = 1
448	}
449	switch k.Kind {
450	case tui.KeyUp:
451		if d.cursor > 0 {
452			d.cursor--
453		}
454		d.followCursor()
455	case tui.KeyDown:
456		if d.cursor < len(d.items)-1 {
457			d.cursor++
458		}
459		d.followCursor()
460	case tui.KeyPageUp:
461		d.cursor -= page
462		if d.cursor < 0 {
463			d.cursor = 0
464		}
465		d.followCursor()
466	case tui.KeyPageDown:
467		d.cursor += page
468		if d.cursor >= len(d.items) {
469			d.cursor = len(d.items) - 1
470		}
471		if d.cursor < 0 {
472			d.cursor = 0
473		}
474		d.followCursor()
475	case tui.KeyHome:
476		d.cursor = 0
477		d.followCursor()
478	case tui.KeyEnd:
479		if len(d.items) > 0 {
480			d.cursor = len(d.items) - 1
481		}
482		d.followCursor()
483	case tui.KeyEsc:
484		d.Close()
485		return sessionTreeAction{Close: true}
486	case tui.KeyEnter:
487		if len(d.items) == 0 || d.cursor < 0 || d.cursor >= len(d.items) {
488			d.Close()
489			return sessionTreeAction{Close: true}
490		}
491		it := d.items[d.cursor]
492		d.Close()
493		return sessionTreeActionForTarget(it.target, it.turnNo)
494	}
495	return sessionTreeAction{}
496}
497
498func sessionTreeActionForTarget(target sessionTreeTarget, turnNo int) sessionTreeAction {
499	act := sessionTreeAction{
500		Select:   true,
501		Target:   target,
502		TurnNo:   turnNo,
503		Boundary: target.Boundary,
504		Path:     target.SourcePath,
505		Role:     target.Role,
506		Prompt:   target.UserDraft,
507		// MessageIdx is the effective index for ordinary rows. For a
508		// boundary, use the old integration's "last included row" form
509		// so msgIdx+1 still equals the safe selection boundary.
510		MessageIdx: target.EffectiveIndex,
511	}
512	if target.IsBoundary() {
513		act.MessageIdx = target.SelectionBoundary - 1
514		act.Role = provider.RoleAssistant
515		act.Prompt = ""
516	}
517	return act
518}
519
520// Render returns the dialog lines. MaxRows controls the number of selectable
521// rows, not the chrome; every returned row is still hard-clipped to width.
522func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string {
523	if !d.Active() {
524		return nil
525	}
526	renderWidth := width
527	if renderWidth < 0 {
528		renderWidth = 0
529	}
530	line := func(s string) string { return truncateSessionTreeANSI(s, renderWidth) }
531
532	lines := []string{line(frameHeader(th, "session tree", renderWidth))}
533	if d.loading {
534		progress := fmt.Sprintf("loading session history... %d loaded", d.loadingDone)
535		lines = append(lines, line(th.FGColor(th.Muted, progress)))
536		if len(d.items) == 0 {
537			lines = append(lines,
538				line(th.FGColor(th.Muted, "recent messages appear first; esc cancels")),
539				line(frameRule(th, renderWidth)),
540			)
541			return lines
542		}
543	}
544	if len(d.items) == 0 {
545		lines = append(lines,
546			line(th.FGColor(th.Muted, "no messages in this session yet")),
547			line(th.FGColor(th.Muted, "press esc to close")),
548			line(frameRule(th, renderWidth)),
549		)
550		return lines
551	}
552	hint := "session history and branches (↑/↓, pgup/pgdn, home/end, enter checkout, esc cancel):"
553	if d.loading {
554		hint = "session history and branches (↑/↓, pgup/pgdn, home/end, enter checkout, esc cancel; more loading):"
555	}
556	lines = append(lines, line(th.FGColor(th.Muted, hint)))
557
558	d.followCursor()
559	start := d.viewTop
560	end := start + d.pageRows()
561	if end > len(d.items) {
562		end = len(d.items)
563	}
564	if start > end {
565		start = end
566	}
567	if start > 0 {
568		lines = append(lines, line(th.FGColor(th.Muted, fmt.Sprintf("  ↑ %d more above", start))))
569	}
570	for i := start; i < end; i++ {
571		it := d.items[i]
572		plain := fitSessionTreeItemPlain(it, renderWidth)
573		if i == d.cursor {
574			lines = append(lines, line(th.PadHighlight(plain, renderWidth)))
575		} else {
576			lines = append(lines, line(colorSessionTreeLine(th, plain)))
577		}
578	}
579	if end < len(d.items) {
580		lines = append(lines, line(th.FGColor(th.Muted, fmt.Sprintf("  ↓ %d more below", len(d.items)-end))))
581	}
582	lines = append(lines, line(th.FGColor(th.Muted, fmt.Sprintf("%d/%d", d.cursor+1, len(d.items)))), line(frameRule(th, renderWidth)))
583	return lines
584}
585
586func clampSessionTreeViewTop(viewTop, cursor, window, total int) int {
587	if window <= 0 || total <= 0 || window >= total {
588		return 0
589	}
590	if cursor < viewTop {
591		viewTop = cursor
592	}
593	if cursor >= viewTop+window {
594		viewTop = cursor - window + 1
595	}
596	if viewTop < 0 {
597		viewTop = 0
598	}
599	if viewTop+window > total {
600		viewTop = total - window
601	}
602	return viewTop
603}
604
605func fitSessionTreeItemPlain(it sessionTreeItem, maxWidth int) string {
606	if maxWidth <= 0 {
607		return ""
608	}
609	prefix := "  " + strings.Repeat("  ", it.depth)
610	suffix := ""
611	if it.current {
612		suffix = "  [current]"
613	}
614	prefixWidth := runewidth.StringWidth(prefix)
615	suffixWidth := runewidth.StringWidth(suffix)
616	if prefixWidth+suffixWidth >= maxWidth {
617		// A terminal narrower than the structural chrome cannot show the
618		// complete row; retain the current marker as the highest-value suffix.
619		return fitSessionTreeLabel(suffix, maxWidth)
620	}
621	labelWidth := maxWidth - prefixWidth - suffixWidth
622	return prefix + fitSessionTreeLabel(it.label, labelWidth) + suffix
623}
624
625// preflightSessionTreeFamilyContext reads every node and returns the shared
626// effective snapshots used by all flattening and target construction for one
627// open operation.
628func preflightSessionTreeFamilyContext(ctx context.Context, root *core.TreeNode) (map[string]sessionTreeSnapshot, error) {
629	if err := ctx.Err(); err != nil {
630		return nil, err
631	}
632	if root == nil {
633		return nil, fmt.Errorf("session tree: nil family root")
634	}
635	snapshots := make(map[string]sessionTreeSnapshot)
636	visiting := make(map[string]bool)
637	var walk func(*core.TreeNode) error
638	walk = func(node *core.TreeNode) error {
639		if err := ctx.Err(); err != nil {
640			return err
641		}
642		if node == nil {
643			return nil
644		}
645		path := node.Summary.Path
646		if visiting[path] {
647			return fmt.Errorf("session tree: cycle at %q", path)
648		}
649		if _, ok := snapshots[path]; ok {
650			return nil
651		}
652		visiting[path] = true
653		history, err := core.ReadSessionHistoryContext(ctx, path)
654		if err != nil {
655			delete(visiting, path)
656			return fmt.Errorf("session tree: read %q: %w", path, err)
657		}
658		if history.Meta.ID != node.Meta.ID {
659			delete(visiting, path)
660			return fmt.Errorf("session tree: session id changed in %q", path)
661		}
662		var messages []provider.Message
663		if len(history.Segments) > 0 {
664			messages = history.Segments[len(history.Segments)-1].Messages
665		}
666		snapshots[path] = sessionTreeSnapshot{path: path, messages: messages, history: history.Segments}
667		for _, child := range node.Children {
668			if err := walk(child); err != nil {
669				delete(visiting, path)
670				return err
671			}
672		}
673		delete(visiting, path)
674		return nil
675	}
676	if err := walk(root); err != nil {
677		return nil, err
678	}
679	return snapshots, nil
680}
681
682func flattenSessionFamilySnapshot(root *core.TreeNode, currentPath string, snapshots map[string]sessionTreeSnapshot) []sessionTreeItem {
683	return flattenSessionNode(root, currentPath, snapshots, 0, 0, true, false)
684}
685
686// flattenSessionNode emits the visible suffix for a node and inserts children
687// at their effective fork boundary. Fork points are counts, so fork N appears
688// after message N-1. A fork beyond the parent's effective snapshot is kept at
689// the end and gets a detached boundary instead of silently disappearing.
690func flattenSessionNode(node *core.TreeNode, currentPath string, snapshots map[string]sessionTreeSnapshot, depth, parentLen int, rootNode, detachedFromParent bool) []sessionTreeItem {
691	if node == nil {
692		return nil
693	}
694	snapshot, ok := snapshots[node.Summary.Path]
695	if !ok {
696		return nil
697	}
698	msgs := snapshot.messages
699	start := 0
700	var selectionBoundary int
701	boundary := sessionTreeMessageBoundary
702	rawFork := 0
703	if !rootNode {
704		rawFork = node.Meta.ForkPoint
705		switch {
706		case detachedFromParent || rawFork < 0 || rawFork > parentLen || rawFork > len(msgs):
707			// Historical placement no longer maps to either snapshot. Keep the
708			// branch visible at the parent's tail, but render its complete
709			// current snapshot rather than slicing it at the stale fork point.
710			boundary = sessionTreeDetachedBoundary
711			start = 0
712			selectionBoundary = len(msgs)
713		default:
714			start = rawFork
715			selectionBoundary = rawFork
716			if start >= len(msgs) {
717				boundary = sessionTreeEmptyBoundary
718			}
719		}
720	} else if len(msgs) == 0 {
721		boundary = sessionTreeEmptyBoundary
722		selectionBoundary = 0
723	} else {
724		selectionBoundary = len(msgs)
725	}
726
727	historyItems := buildSessionTreeHistoryItems(snapshot.path, snapshot.history, depth, snapshot.path == currentPath)
728	allByIndex := make(map[int]sessionTreeItem, len(msgs))
729	for _, item := range historyItems {
730		if !item.target.Historical {
731			allByIndex[item.target.EffectiveIndex] = item
732		}
733	}
734	childrenAt := make(map[int][]*core.TreeNode)
735	var stale []*core.TreeNode
736	for _, child := range node.Children {
737		fork := child.Meta.ForkPoint
738		attach := fork
739		if attach < start {
740			attach = start
741		}
742		childSnapshot, childLoaded := snapshots[child.Summary.Path]
743		// A branch with no post-fork messages still needs its explicit empty
744		// boundary even when the copied historical prefix is no longer
745		// comparable after compaction; there is no suffix whose placement could
746		// be wrong. Non-empty suffixes require the prefix identity check.
747		emptySuffix := childLoaded && fork >= 0 && fork == len(childSnapshot.messages)
748		if fork < 0 || fork > len(msgs) || !childLoaded ||
749			(!emptySuffix && !sessionTreePrefixMatches(msgs, childSnapshot.messages, fork)) {
750			// Numeric fork points are only placement hints. A parent may have
751			// been compacted and later grown back to the old length, so bounds
752			// alone are insufficient: verify that the child's copied prefix is
753			// still the parent's current prefix before placing the edge.
754			stale = append(stale, child)
755			continue
756		}
757		childrenAt[attach] = append(childrenAt[attach], child)
758	}
759
760	var out []sessionTreeItem
761	for _, item := range historyItems {
762		if item.target.Historical {
763			out = append(out, item)
764		}
765	}
766	if boundary != sessionTreeMessageBoundary {
767		turn := treeTurnAtBoundary(msgs, selectionBoundary)
768		boundaryCurrent := snapshot.path == currentPath && (len(msgs) == 0 || boundary == sessionTreeEmptyBoundary)
769		out = append(out, makeSessionTreeBoundaryItem(snapshot.path, selectionBoundary, depth, boundaryCurrent, boundary, rawFork, len(msgs), turn))
770	}
771
772	emitChildren := func(children []*core.TreeNode, childDetached bool) {
773		for _, child := range children {
774			out = append(out, flattenSessionNode(child, currentPath, snapshots, depth+1, len(msgs), false, childDetached)...)
775		}
776	}
777
778	// A fork at zero is shown before the first visible message. For a branch,
779	// children that forked in the hidden copied prefix are normalized to its
780	// first visible index above, so they remain discoverable too.
781	emitChildren(childrenAt[start], false)
782	for idx := start; idx < len(msgs); idx++ {
783		if item, ok := allByIndex[idx]; ok {
784			out = append(out, item)
785		}
786		emitChildren(childrenAt[idx+1], false)
787	}
788	// Stale children are attached after the current effective snapshot. Their
789	// own row makes the missing historical boundary explicit.
790	emitChildren(stale, true)
791	return out
792}
793
794func makeSessionTreeBoundaryItem(path string, effectiveIndex, depth int, current bool, boundary sessionTreeBoundary, forkPoint, snapshotLen, turn int) sessionTreeItem {
795	label := fmt.Sprintf("%s branch boundary", boundary.String())
796	if boundary == sessionTreeDetachedBoundary {
797		label = fmt.Sprintf("detached branch boundary (fork %d; snapshot %d)", forkPoint, snapshotLen)
798	}
799	target := sessionTreeTarget{
800		SourcePath:        path,
801		EffectiveIndex:    effectiveIndex,
802		SelectionBoundary: effectiveIndex,
803		Boundary:          boundary,
804	}
805	return sessionTreeItem{
806		label:      label,
807		messageIdx: effectiveIndex,
808		turnNo:     turn,
809		path:       path,
810		depth:      depth,
811		current:    current,
812		target:     target,
813	}
814}
815
816func sessionTreePrefixMatches(parent, child []provider.Message, fork int) bool {
817	if fork < 0 || fork > len(parent) || fork > len(child) {
818		return false
819	}
820	return reflect.DeepEqual(parent[:fork], child[:fork])
821}
822
823func treeTurnAtBoundary(msgs []provider.Message, boundary int) int {
824	if boundary < 0 {
825		boundary = 0
826	}
827	if boundary > len(msgs) {
828		boundary = len(msgs)
829	}
830	turn := 0
831	for _, msg := range msgs[:boundary] {
832		if msg.Role == provider.RoleUser {
833			turn++
834		}
835	}
836	if turn == 0 {
837		return 1
838	}
839	return turn
840}
841
842func buildSessionTreeItems(path string, msgs []provider.Message, depth int, currentPath bool) []sessionTreeItem {
843	return buildSessionTreeSegmentItems(path, msgs, depth, currentPath, false, -1, nil)
844}
845
846func buildSessionTreeHistoryItems(path string, segments []core.SessionHistorySegment, depth int, currentPath bool) []sessionTreeItem {
847	var out []sessionTreeItem
848	for segmentIdx, segment := range segments {
849		historical := segmentIdx < len(segments)-1
850		skip := compactionTailIndices(segments, segmentIdx)
851		out = append(out, buildSessionTreeSegmentItems(path, segment.Messages, depth, currentPath && !historical, historical, segmentIdx, skip)...)
852	}
853	return out
854}
855
856func buildSessionTreeSegmentItems(path string, msgs []provider.Message, depth int, currentPath, historical bool, historySegment int, skip map[int]bool) []sessionTreeItem {
857	out := make([]sessionTreeItem, 0, len(msgs))
858	turn := 0
859	lastTurn := 0
860	visible := make([]int, 0, len(msgs))
861	for idx, msg := range msgs {
862		if skip[idx] || !isForkableSessionTreeMessage(msg) {
863			continue
864		}
865		visible = append(visible, idx)
866	}
867	for visibleIndex, idx := range visible {
868		msg := msgs[idx]
869		if msg.Role == provider.RoleUser {
870			turn++
871			lastTurn = turn
872		} else if lastTurn == 0 {
873			lastTurn = 1
874		}
875		roleLabel := sessionTreeRoleLabel(msg.Role)
876		preview := sessionTreePreview(msg)
877		boundary := idx + 1
878		draft := ""
879		if msg.Role == provider.RoleUser {
880			boundary = idx
881			draft = completeUserDraft(msg)
882		}
883		target := sessionTreeTarget{
884			SourcePath:        path,
885			EffectiveIndex:    idx,
886			SelectionBoundary: boundary,
887			Role:              msg.Role,
888			UserDraft:         draft,
889			Boundary:          sessionTreeMessageBoundary,
890			Historical:        historical,
891			HistorySegment:    historySegment,
892		}
893		out = append(out, sessionTreeItem{
894			label:      fmt.Sprintf("%s: %s", roleLabel, preview),
895			messageIdx: idx,
896			turnNo:     lastTurn,
897			role:       msg.Role,
898			prompt:     draft,
899			path:       path,
900			depth:      depth,
901			current:    currentPath && visibleIndex == len(visible)-1,
902			target:     target,
903		})
904	}
905	return out
906}
907
908func compactionTailIndices(segments []core.SessionHistorySegment, segmentIdx int) map[int]bool {
909	if segmentIdx <= 0 || segmentIdx >= len(segments) || !segments[segmentIdx].Compacted {
910		return nil
911	}
912	current := segments[segmentIdx].Messages
913	previous := segments[segmentIdx-1].Messages
914	if len(current) < 2 || len(previous) == 0 || !isCompactionSummaryMessage(current[0]) && current[0].Meta["compaction"] != "true" {
915		return nil
916	}
917	maxTail := len(current) - 1
918	if len(previous) < maxTail {
919		maxTail = len(previous)
920	}
921	for tailLen := maxTail; tailLen > 0; tailLen-- {
922		if !reflect.DeepEqual(previous[len(previous)-tailLen:], current[1:1+tailLen]) {
923			continue
924		}
925		skip := make(map[int]bool, tailLen)
926		for idx := 1; idx <= tailLen; idx++ {
927			skip[idx] = true
928		}
929		return skip
930	}
931	return nil
932}
933
934func isForkableSessionTreeMessage(msg provider.Message) bool {
935	if isHiddenTranscriptMessage(msg) || msg.Meta[shellEscapeMetaKey] == "true" || msg.Meta[autoCompactContinueMetaKey] == "true" || msg.Meta["compaction"] == "true" || isCompactionSummaryMessage(msg) {
936		return false
937	}
938	switch msg.Role {
939	case provider.RoleUser:
940		return isForkableUserMessage(msg)
941	case provider.RoleAssistant:
942		if messageHasToolCall(msg) {
943			return false
944		}
945		for _, content := range msg.Content {
946			switch block := content.(type) {
947			case provider.TextBlock:
948				if strings.TrimSpace(block.Text) != "" {
949					return true
950				}
951			case provider.ImageBlock:
952				return true
953			}
954		}
955	}
956	return false
957}
958
959func isForkableUserMessage(msg provider.Message) bool {
960	if msg.Role != provider.RoleUser || isHiddenTranscriptMessage(msg) || msg.Meta[shellEscapeMetaKey] == "true" || msg.Meta[autoCompactContinueMetaKey] == "true" || msg.Meta["compaction"] == "true" || isCompactionSummaryMessage(msg) {
961		return false
962	}
963	return sessionTreePreview(msg) != "(empty)"
964}
965
966func isCompactionSummaryMessage(msg provider.Message) bool {
967	if msg.Role != provider.RoleUser {
968		return false
969	}
970	for _, content := range msg.Content {
971		if block, ok := content.(provider.TextBlock); ok && strings.HasPrefix(strings.TrimSpace(block.Text), "## Context Summary (compacted)") {
972			return true
973		}
974	}
975	return false
976}
977
978func completeUserDraft(msg provider.Message) string {
979	var text []string
980	for _, c := range msg.Content {
981		if tb, ok := c.(provider.TextBlock); ok {
982			text = append(text, tb.Text)
983		}
984	}
985	return strings.Join(text, "\n")
986}
987
988func indexCurrentTreeItem(items []sessionTreeItem) int {
989	for i, it := range items {
990		if it.current {
991			return i
992		}
993	}
994	if len(items) == 0 {
995		return 0
996	}
997	return len(items) - 1
998}
999
1000func sessionTreeRoleLabel(role provider.Role) string {
1001	switch role {
1002	case provider.RoleUser:
1003		return "you"
1004	case provider.RoleAssistant:
1005		return "zut"
1006	case provider.RoleTool:
1007		return "tool"
1008	default:
1009		return string(role)
1010	}
1011}
1012
1013func sessionTreePreview(msg provider.Message) string {
1014	var parts []string
1015	for _, c := range msg.Content {
1016		switch b := c.(type) {
1017		case provider.TextBlock:
1018			if text := sanitizeSessionTreeText(b.Text); text != "" {
1019				parts = append(parts, text)
1020			}
1021		case provider.ImageBlock:
1022			parts = append(parts, "[image]")
1023		case provider.ToolCallBlock:
1024			parts = append(parts, "tool "+sanitizeSessionTreeText(b.Name))
1025		case provider.ToolResultBlock:
1026			if b.IsError {
1027				parts = append(parts, "tool result error")
1028			} else {
1029				parts = append(parts, "tool result")
1030			}
1031		case provider.ReasoningBlock:
1032			if b.Summary != "" {
1033				parts = append(parts, "reasoning")
1034			}
1035		}
1036	}
1037	if len(parts) == 0 {
1038		return "(empty)"
1039	}
1040	return strings.Join(parts, " ")
1041}
1042
1043// sanitizeSessionTreeText keeps previews single-line and prevents transcript
1044// content from injecting terminal control sequences into the dialog frame.
1045func sanitizeSessionTreeText(s string) string {
1046	runes := []rune(s)
1047	var b strings.Builder
1048	for i := 0; i < len(runes); {
1049		if end, ok := sessionTreeCSIEnd(runes, i); ok {
1050			i = end
1051			continue
1052		}
1053		if runes[i] == '\x1b' && i+1 < len(runes) && runes[i+1] == ']' {
1054			// OSC sequences end at BEL or ST. Drop the whole sequence when
1055			// present.
1056			i += 2
1057			for i < len(runes) && runes[i] != '\a' {
1058				if runes[i] == '\x1b' && i+1 < len(runes) && runes[i+1] == '\\' {
1059					i += 2
1060					break
1061				}
1062				i++
1063			}
1064			if i < len(runes) && runes[i] == '\a' {
1065				i++
1066			}
1067			continue
1068		}
1069		if runes[i] == '\x1b' {
1070			// An isolated ESC (or a two-byte escape) is dropped by itself.
1071			i++
1072			continue
1073		}
1074		if unicode.IsControl(runes[i]) {
1075			b.WriteByte(' ')
1076		} else {
1077			b.WriteRune(runes[i])
1078		}
1079		i++
1080	}
1081	return strings.Join(strings.Fields(b.String()), " ")
1082}
1083
1084func colorSessionTreeLine(th tui.Theme, line string) string {
1085	plain := strings.TrimSpace(line)
1086	switch {
1087	case strings.HasPrefix(plain, "you:"):
1088		return th.FGColor(th.FG, line)
1089	case strings.HasPrefix(plain, "zut:"):
1090		return th.FGColor(th.Muted, line)
1091	case strings.HasPrefix(plain, "tool:"):
1092		return th.FGColor(th.ToolOut, line)
1093	default:
1094		return th.FGColor(th.Muted, line)
1095	}
1096}
1097
1098// fitSessionTreeLabel truncates by terminal cells, not Go runes. This is
1099// intentionally also used for the indentation and current marker together so
1100// neither can push a row past the terminal edge.
1101func fitSessionTreeLabel(label string, maxWidth int) string {
1102	if maxWidth <= 0 {
1103		return ""
1104	}
1105	if runewidth.StringWidth(label) <= maxWidth {
1106		return label
1107	}
1108	if maxWidth <= 3 {
1109		return strings.Repeat(".", maxWidth)
1110	}
1111	return truncateSessionTreePlain(label, maxWidth-3) + "..."
1112}
1113
1114func truncateSessionTreePlain(s string, maxWidth int) string {
1115	if maxWidth <= 0 {
1116		return ""
1117	}
1118	var b strings.Builder
1119	used := 0
1120	for _, r := range s {
1121		w := runewidth.RuneWidth(r)
1122		if w > 0 && used+w > maxWidth {
1123			break
1124		}
1125		b.WriteRune(r)
1126		used += w
1127	}
1128	return b.String()
1129}
1130
1131// truncateSessionTreeANSI is the dialog-local equivalent of tui's renderer
1132// clipping helper. Keeping it here avoids changing tui for one dialog while
1133// still making headers, hints, colors, and highlighted rows width-safe.
1134func truncateSessionTreeANSI(s string, maxWidth int) string {
1135	if maxWidth <= 0 {
1136		return ""
1137	}
1138	if sessionTreeANSIWidth(s) <= maxWidth {
1139		return s
1140	}
1141	runes := []rune(s)
1142	var b strings.Builder
1143	used := 0
1144	clipped := false
1145	for i := 0; i < len(runes); {
1146		if end, ok := sessionTreeCSIEnd(runes, i); ok {
1147			b.WriteString(string(runes[i:end]))
1148			i = end
1149			continue
1150		}
1151		w := runewidth.RuneWidth(runes[i])
1152		if w > 0 && used+w > maxWidth {
1153			clipped = true
1154			break
1155		}
1156		b.WriteRune(runes[i])
1157		used += w
1158		i++
1159	}
1160	if clipped && strings.Contains(s, "\x1b[") {
1161		b.WriteString("\x1b[0m")
1162	}
1163	return b.String()
1164}
1165
1166func sessionTreeANSIWidth(s string) int {
1167	runes := []rune(s)
1168	width := 0
1169	for i := 0; i < len(runes); {
1170		if end, ok := sessionTreeCSIEnd(runes, i); ok {
1171			i = end
1172			continue
1173		}
1174		width += runewidth.RuneWidth(runes[i])
1175		i++
1176	}
1177	return width
1178}
1179
1180func sessionTreeCSIEnd(runes []rune, start int) (int, bool) {
1181	if start+1 >= len(runes) || runes[start] != '\x1b' || runes[start+1] != '[' {
1182		return start, false
1183	}
1184	for i := start + 2; i < len(runes); i++ {
1185		if runes[i] >= 0x40 && runes[i] <= 0x7e {
1186			return i + 1, true
1187		}
1188	}
1189	return len(runes), true
1190}

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