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}