v0.3.1 5c1a9ec

session_tree_dialog.go RawHistoryFolder

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

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