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 matched := true
923 for idx, prior := range previous[len(previous)-tailLen:] {
924 // Historical rows can recover provenance that an older compaction
925 // tail did not persist. Compare with only missing origin filled.
926 next := provider.WithMessageOrigin(current[1+idx], prior.Meta["provider"], prior.Meta["model"])
927 if !reflect.DeepEqual(prior, next) {
928 matched = false
929 break
930 }
931 }
932 if !matched {
933 continue
934 }
935 skip := make(map[int]bool, tailLen)
936 for idx := 1; idx <= tailLen; idx++ {
937 skip[idx] = true
938 }
939 return skip
940 }
941 return nil
942}
943
944func isForkableSessionTreeMessage(msg provider.Message) bool {
945 if isHiddenTranscriptMessage(msg) || msg.Meta[shellEscapeMetaKey] == "true" || msg.Meta[autoCompactContinueMetaKey] == "true" || msg.Meta["compaction"] == "true" || isCompactionSummaryMessage(msg) {
946 return false
947 }
948 switch msg.Role {
949 case provider.RoleUser:
950 return isForkableUserMessage(msg)
951 case provider.RoleAssistant:
952 if messageHasToolCall(msg) {
953 return false
954 }
955 for _, content := range msg.Content {
956 switch block := content.(type) {
957 case provider.TextBlock:
958 if strings.TrimSpace(block.Text) != "" {
959 return true
960 }
961 case provider.ImageBlock:
962 return true
963 }
964 }
965 }
966 return false
967}
968
969func isForkableUserMessage(msg provider.Message) bool {
970 if msg.Role != provider.RoleUser || isHiddenTranscriptMessage(msg) || msg.Meta[shellEscapeMetaKey] == "true" || msg.Meta[autoCompactContinueMetaKey] == "true" || msg.Meta["compaction"] == "true" || isCompactionSummaryMessage(msg) {
971 return false
972 }
973 return sessionTreePreview(msg) != "(empty)"
974}
975
976func isCompactionSummaryMessage(msg provider.Message) bool {
977 if msg.Role != provider.RoleUser {
978 return false
979 }
980 for _, content := range msg.Content {
981 if block, ok := content.(provider.TextBlock); ok && strings.HasPrefix(strings.TrimSpace(block.Text), "## Context Summary (compacted)") {
982 return true
983 }
984 }
985 return false
986}
987
988func completeUserDraft(msg provider.Message) string {
989 var text []string
990 for _, c := range msg.Content {
991 if tb, ok := c.(provider.TextBlock); ok {
992 text = append(text, tb.Text)
993 }
994 }
995 return strings.Join(text, "\n")
996}
997
998func indexCurrentTreeItem(items []sessionTreeItem) int {
999 for i, it := range items {
1000 if it.current {
1001 return i
1002 }
1003 }
1004 if len(items) == 0 {
1005 return 0
1006 }
1007 return len(items) - 1
1008}
1009
1010func sessionTreeRoleLabel(role provider.Role) string {
1011 switch role {
1012 case provider.RoleUser:
1013 return "you"
1014 case provider.RoleAssistant:
1015 return "zut"
1016 case provider.RoleTool:
1017 return "tool"
1018 default:
1019 return string(role)
1020 }
1021}
1022
1023func sessionTreePreview(msg provider.Message) string {
1024 var parts []string
1025 for _, c := range msg.Content {
1026 switch b := c.(type) {
1027 case provider.TextBlock:
1028 if text := sanitizeSessionTreeText(b.Text); text != "" {
1029 parts = append(parts, text)
1030 }
1031 case provider.ImageBlock:
1032 parts = append(parts, "[image]")
1033 case provider.ToolCallBlock:
1034 parts = append(parts, "tool "+sanitizeSessionTreeText(b.Name))
1035 case provider.ToolResultBlock:
1036 if b.IsError {
1037 parts = append(parts, "tool result error")
1038 } else {
1039 parts = append(parts, "tool result")
1040 }
1041 case provider.ReasoningBlock:
1042 if b.Summary != "" {
1043 parts = append(parts, "reasoning")
1044 }
1045 }
1046 }
1047 if len(parts) == 0 {
1048 return "(empty)"
1049 }
1050 return strings.Join(parts, " ")
1051}
1052
1053// sanitizeSessionTreeText keeps previews single-line and prevents transcript
1054// content from injecting terminal control sequences into the dialog frame.
1055func sanitizeSessionTreeText(s string) string {
1056 runes := []rune(s)
1057 var b strings.Builder
1058 for i := 0; i < len(runes); {
1059 if end, ok := sessionTreeCSIEnd(runes, i); ok {
1060 i = end
1061 continue
1062 }
1063 if runes[i] == '\x1b' && i+1 < len(runes) && runes[i+1] == ']' {
1064 // OSC sequences end at BEL or ST. Drop the whole sequence when
1065 // present.
1066 i += 2
1067 for i < len(runes) && runes[i] != '\a' {
1068 if runes[i] == '\x1b' && i+1 < len(runes) && runes[i+1] == '\\' {
1069 i += 2
1070 break
1071 }
1072 i++
1073 }
1074 if i < len(runes) && runes[i] == '\a' {
1075 i++
1076 }
1077 continue
1078 }
1079 if runes[i] == '\x1b' {
1080 // An isolated ESC (or a two-byte escape) is dropped by itself.
1081 i++
1082 continue
1083 }
1084 if unicode.IsControl(runes[i]) {
1085 b.WriteByte(' ')
1086 } else {
1087 b.WriteRune(runes[i])
1088 }
1089 i++
1090 }
1091 return strings.Join(strings.Fields(b.String()), " ")
1092}
1093
1094func colorSessionTreeLine(th tui.Theme, line string) string {
1095 plain := strings.TrimSpace(line)
1096 switch {
1097 case strings.HasPrefix(plain, "you:"):
1098 return th.FGColor(th.FG, line)
1099 case strings.HasPrefix(plain, "zut:"):
1100 return th.FGColor(th.Muted, line)
1101 case strings.HasPrefix(plain, "tool:"):
1102 return th.FGColor(th.ToolOut, line)
1103 default:
1104 return th.FGColor(th.Muted, line)
1105 }
1106}
1107
1108// fitSessionTreeLabel truncates by terminal cells, not Go runes. This is
1109// intentionally also used for the indentation and current marker together so
1110// neither can push a row past the terminal edge.
1111func fitSessionTreeLabel(label string, maxWidth int) string {
1112 if maxWidth <= 0 {
1113 return ""
1114 }
1115 if runewidth.StringWidth(label) <= maxWidth {
1116 return label
1117 }
1118 if maxWidth <= 3 {
1119 return strings.Repeat(".", maxWidth)
1120 }
1121 return truncateSessionTreePlain(label, maxWidth-3) + "..."
1122}
1123
1124func truncateSessionTreePlain(s string, maxWidth int) string {
1125 if maxWidth <= 0 {
1126 return ""
1127 }
1128 var b strings.Builder
1129 used := 0
1130 for _, r := range s {
1131 w := runewidth.RuneWidth(r)
1132 if w > 0 && used+w > maxWidth {
1133 break
1134 }
1135 b.WriteRune(r)
1136 used += w
1137 }
1138 return b.String()
1139}
1140
1141// truncateSessionTreeANSI is the dialog-local equivalent of tui's renderer
1142// clipping helper. Keeping it here avoids changing tui for one dialog while
1143// still making headers, hints, colors, and highlighted rows width-safe.
1144func truncateSessionTreeANSI(s string, maxWidth int) string {
1145 if maxWidth <= 0 {
1146 return ""
1147 }
1148 if sessionTreeANSIWidth(s) <= maxWidth {
1149 return s
1150 }
1151 runes := []rune(s)
1152 var b strings.Builder
1153 used := 0
1154 clipped := false
1155 for i := 0; i < len(runes); {
1156 if end, ok := sessionTreeCSIEnd(runes, i); ok {
1157 b.WriteString(string(runes[i:end]))
1158 i = end
1159 continue
1160 }
1161 w := runewidth.RuneWidth(runes[i])
1162 if w > 0 && used+w > maxWidth {
1163 clipped = true
1164 break
1165 }
1166 b.WriteRune(runes[i])
1167 used += w
1168 i++
1169 }
1170 if clipped && strings.Contains(s, "\x1b[") {
1171 b.WriteString("\x1b[0m")
1172 }
1173 return b.String()
1174}
1175
1176func sessionTreeANSIWidth(s string) int {
1177 runes := []rune(s)
1178 width := 0
1179 for i := 0; i < len(runes); {
1180 if end, ok := sessionTreeCSIEnd(runes, i); ok {
1181 i = end
1182 continue
1183 }
1184 width += runewidth.RuneWidth(runes[i])
1185 i++
1186 }
1187 return width
1188}
1189
1190func sessionTreeCSIEnd(runes []rune, start int) (int, bool) {
1191 if start+1 >= len(runes) || runes[start] != '\x1b' || runes[start+1] != '[' {
1192 return start, false
1193 }
1194 for i := start + 2; i < len(runes); i++ {
1195 if runes[i] >= 0x40 && runes[i] <= 0x7e {
1196 return i + 1, true
1197 }
1198 }
1199 return len(runes), true
1200}