detached 444dd12

Improve session tree navigation

commit
444dd1282b70d339f839be297ee815a39f18666a
author
patriceckhart · · 2026-07-09 17:09 UTC
parent
88a6f6e
signature
unsigned
refs
tag: v0.2.68
6 files · +582 -130
M packages/agent/modes/interactive.go +63 -30
@@ -1532,6 +1532,12 @@ func (i *Interactive) redraw() {
15321532 cursorCol = c
15331533 }
15341534 }
1535+ if i.sessionTreeDialog.Active() {
1536+ if r, c := i.sessionTreeDialog.CursorPos(); r >= 0 {
1537+ cursorRow = dialogLead + r
1538+ cursorCol = c
1539+ }
1540+ }
15351541 if i.swarmDialog.Active() {
15361542 if r, c := i.swarmDialog.CursorPos(cols); r >= 0 {
15371543 cursorRow = dialogLead + r
@@ -1961,7 +1967,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
19611967 }
19621968 act := i.sessionTreeDialog.HandleKey(k)
19631969 if act.Select {
1964- i.applySessionTreeSelection(act.Path)
1970+ i.applySessionTreeMessageSelection(act.Path, act.MessageIdx, act.TurnNo, act.Role, act.Prompt)
19651971 }
19661972 i.invalidate()
19671973 return false
@@ -6138,62 +6144,89 @@ func (i *Interactive) doSessionFork() {
61386144 i.invalidate()
61396145 }
61406146
6141-// doSessionTree shows the branch topology picker for this cwd.
6142-// Pick an entry to switch into it (same semantics as /sessions
6143-// picking a past session, but with the parent/child indentation).
6147+// doSessionTree shows the current session family as an inline branch tree.
6148+// Pick an entry to check out that point into a new branch. Subsequent
6149+// prompts use only context up to the selected message.
61446150 func (i *Interactive) doSessionTree() {
6145- if i.cfg.ZotHome == "" || i.cfg.CWD == "" {
6146- i.mu.Lock()
6147- i.statusErr = "tree: session storage not configured"
6148- i.mu.Unlock()
6151+ current := ""
6152+ if i.cfg.CurrentSessionPath != nil {
6153+ current = i.cfg.CurrentSessionPath()
6154+ }
6155+ if current != "" && i.sessionTreeDialog.OpenSessionFamily(i.cfg.ZotHome, i.cfg.CWD, current) {
61496156 i.invalidate()
61506157 return
61516158 }
6152- // Flush the running agent's transcript first so its message
6153- // count + latest preview are accurate in the tree.
6154- if i.cfg.FlushSession != nil {
6155- i.cfg.FlushSession()
6159+ msgs := []provider.Message{}
6160+ if i.agent != nil {
6161+ msgs = i.agent.Messages()
61566162 }
6157- roots := core.BuildSessionTree(i.cfg.ZotHome, i.cfg.CWD)
6158- if len(roots) == 0 {
6163+ if len(msgs) == 0 {
61596164 i.mu.Lock()
6160- i.statusErr = "tree: no sessions in this directory yet"
6165+ i.statusErr = "tree: no messages in this session yet"
61616166 i.mu.Unlock()
61626167 i.invalidate()
61636168 return
61646169 }
6165- current := ""
6166- if i.cfg.CurrentSessionPath != nil {
6167- current = i.cfg.CurrentSessionPath()
6168- }
6169- if !i.sessionTreeDialog.Open(roots, current) {
6170+ if !i.sessionTreeDialog.OpenMessages(msgs) {
61706171 i.mu.Lock()
6171- i.statusErr = "tree: no branches to show"
6172+ i.statusErr = "tree: no messages to show"
61726173 i.mu.Unlock()
61736174 }
61746175 i.invalidate()
61756176 }
61766177
6177-// applySessionTreeSelection switches the running agent to the
6178-// session file at path. Thin wrapper around LoadSession that also
6179-// writes a status line.
6180-func (i *Interactive) applySessionTreeSelection(path string) {
6181- if i.cfg.LoadSession == nil {
6178+// applySessionTreeMessageSelection checks out the selected message into a
6179+// new branch, then switches the running agent to that branch. Unlike /jump,
6180+// this changes future model context: only messages up to the selected row are
6181+// sent on subsequent turns.
6182+func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) {
6183+ if i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil {
61826184 i.mu.Lock()
6183- i.statusErr = "tree: session swap not available in this build"
6185+ i.statusErr = "tree: session branching is not available in this build"
61846186 i.mu.Unlock()
61856187 i.invalidate()
61866188 return
61876189 }
6188- if err := i.cfg.LoadSession(path); err != nil {
6190+ if src == "" {
6191+ src = i.cfg.CurrentSessionPath()
6192+ }
6193+ if src == "" {
6194+ i.mu.Lock()
6195+ i.statusErr = "tree: no session is active"
6196+ i.mu.Unlock()
6197+ i.invalidate()
6198+ return
6199+ }
6200+ if i.cfg.FlushSession != nil {
6201+ i.cfg.FlushSession()
6202+ }
6203+ upTo := msgIdx + 1
6204+ if role == provider.RoleUser {
6205+ upTo = msgIdx
6206+ }
6207+ newPath, err := core.BranchSessionHidden(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version, upTo)
6208+ if err != nil {
61896209 i.mu.Lock()
6190- i.statusErr = "tree: load failed: " + err.Error()
6210+ i.statusErr = "tree: " + err.Error()
61916211 i.mu.Unlock()
61926212 i.invalidate()
61936213 return
61946214 }
6215+ if err := i.cfg.LoadSession(newPath); err != nil {
6216+ i.mu.Lock()
6217+ i.statusErr = "tree: checkout failed: " + err.Error()
6218+ i.mu.Unlock()
6219+ i.invalidate()
6220+ return
6221+ }
6222+ i.scrollToBottom()
61956223 i.mu.Lock()
6196- i.statusOK = "switched to branch " + friendlyPath(path)
6224+ if role == provider.RoleUser {
6225+ i.ed.SetValue(prompt)
6226+ i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo)
6227+ } else {
6228+ i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo)
6229+ }
61976230 i.statusErr = ""
61986231 i.mu.Unlock()
61996232 i.invalidate()
@@ -1532,6 +1532,12 @@ func (i *Interactive) redraw() {
1532 cursorCol = c1532 cursorCol = c
1533 }1533 }
1534 }1534 }
1535+ if i.sessionTreeDialog.Active() {
1536+ if r, c := i.sessionTreeDialog.CursorPos(); r >= 0 {
1537+ cursorRow = dialogLead + r
1538+ cursorCol = c
1539+ }
1540+ }
1535 if i.swarmDialog.Active() {1541 if i.swarmDialog.Active() {
1536 if r, c := i.swarmDialog.CursorPos(cols); r >= 0 {1542 if r, c := i.swarmDialog.CursorPos(cols); r >= 0 {
1537 cursorRow = dialogLead + r1543 cursorRow = dialogLead + r
@@ -1961,7 +1967,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1961 }1967 }
1962 act := i.sessionTreeDialog.HandleKey(k)1968 act := i.sessionTreeDialog.HandleKey(k)
1963 if act.Select {1969 if act.Select {
1964- i.applySessionTreeSelection(act.Path)1970+ i.applySessionTreeMessageSelection(act.Path, act.MessageIdx, act.TurnNo, act.Role, act.Prompt)
1965 }1971 }
1966 i.invalidate()1972 i.invalidate()
1967 return false1973 return false
@@ -6138,62 +6144,89 @@ func (i *Interactive) doSessionFork() {
6138 i.invalidate()6144 i.invalidate()
6139 }6145 }
6140 6146
6141-// doSessionTree shows the branch topology picker for this cwd.6147+// doSessionTree shows the current session family as an inline branch tree.
6142-// Pick an entry to switch into it (same semantics as /sessions6148+// Pick an entry to check out that point into a new branch. Subsequent
6143-// picking a past session, but with the parent/child indentation).6149+// prompts use only context up to the selected message.
6144 func (i *Interactive) doSessionTree() {6150 func (i *Interactive) doSessionTree() {
6145- if i.cfg.ZotHome == "" || i.cfg.CWD == "" {6151+ current := ""
6146- i.mu.Lock()6152+ if i.cfg.CurrentSessionPath != nil {
6147- i.statusErr = "tree: session storage not configured"6153+ current = i.cfg.CurrentSessionPath()
6148- i.mu.Unlock()6154+ }
6155+ if current != "" && i.sessionTreeDialog.OpenSessionFamily(i.cfg.ZotHome, i.cfg.CWD, current) {
6149 i.invalidate()6156 i.invalidate()
6150 return6157 return
6151 }6158 }
6152- // Flush the running agent's transcript first so its message6159+ msgs := []provider.Message{}
6153- // count + latest preview are accurate in the tree.6160+ if i.agent != nil {
6154- if i.cfg.FlushSession != nil {6161+ msgs = i.agent.Messages()
6155- i.cfg.FlushSession()
6156 }6162 }
6157- roots := core.BuildSessionTree(i.cfg.ZotHome, i.cfg.CWD)6163+ if len(msgs) == 0 {
6158- if len(roots) == 0 {
6159 i.mu.Lock()6164 i.mu.Lock()
6160- i.statusErr = "tree: no sessions in this directory yet"6165+ i.statusErr = "tree: no messages in this session yet"
6161 i.mu.Unlock()6166 i.mu.Unlock()
6162 i.invalidate()6167 i.invalidate()
6163 return6168 return
6164 }6169 }
6165- current := ""6170+ if !i.sessionTreeDialog.OpenMessages(msgs) {
6166- if i.cfg.CurrentSessionPath != nil {
6167- current = i.cfg.CurrentSessionPath()
6168- }
6169- if !i.sessionTreeDialog.Open(roots, current) {
6170 i.mu.Lock()6171 i.mu.Lock()
6171- i.statusErr = "tree: no branches to show"6172+ i.statusErr = "tree: no messages to show"
6172 i.mu.Unlock()6173 i.mu.Unlock()
6173 }6174 }
6174 i.invalidate()6175 i.invalidate()
6175 }6176 }
6176 6177
6177-// applySessionTreeSelection switches the running agent to the6178+// applySessionTreeMessageSelection checks out the selected message into a
6178-// session file at path. Thin wrapper around LoadSession that also6179+// new branch, then switches the running agent to that branch. Unlike /jump,
6179-// writes a status line.6180+// this changes future model context: only messages up to the selected row are
6180-func (i *Interactive) applySessionTreeSelection(path string) {6181+// sent on subsequent turns.
6181- if i.cfg.LoadSession == nil {6182+func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) {
6183+ if i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil {
6182 i.mu.Lock()6184 i.mu.Lock()
6183- i.statusErr = "tree: session swap not available in this build"6185+ i.statusErr = "tree: session branching is not available in this build"
6184 i.mu.Unlock()6186 i.mu.Unlock()
6185 i.invalidate()6187 i.invalidate()
6186 return6188 return
6187 }6189 }
6188- if err := i.cfg.LoadSession(path); err != nil {6190+ if src == "" {
6191+ src = i.cfg.CurrentSessionPath()
6192+ }
6193+ if src == "" {
6194+ i.mu.Lock()
6195+ i.statusErr = "tree: no session is active"
6196+ i.mu.Unlock()
6197+ i.invalidate()
6198+ return
6199+ }
6200+ if i.cfg.FlushSession != nil {
6201+ i.cfg.FlushSession()
6202+ }
6203+ upTo := msgIdx + 1
6204+ if role == provider.RoleUser {
6205+ upTo = msgIdx
6206+ }
6207+ newPath, err := core.BranchSessionHidden(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version, upTo)
6208+ if err != nil {
6189 i.mu.Lock()6209 i.mu.Lock()
6190- i.statusErr = "tree: load failed: " + err.Error()6210+ i.statusErr = "tree: " + err.Error()
6191 i.mu.Unlock()6211 i.mu.Unlock()
6192 i.invalidate()6212 i.invalidate()
6193 return6213 return
6194 }6214 }
6215+ if err := i.cfg.LoadSession(newPath); err != nil {
6216+ i.mu.Lock()
6217+ i.statusErr = "tree: checkout failed: " + err.Error()
6218+ i.mu.Unlock()
6219+ i.invalidate()
6220+ return
6221+ }
6222+ i.scrollToBottom()
6195 i.mu.Lock()6223 i.mu.Lock()
6196- i.statusOK = "switched to branch " + friendlyPath(path)6224+ if role == provider.RoleUser {
6225+ i.ed.SetValue(prompt)
6226+ i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo)
6227+ } else {
6228+ i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo)
6229+ }
6197 i.statusErr = ""6230 i.statusErr = ""
6198 i.mu.Unlock()6231 i.mu.Unlock()
6199 i.invalidate()6232 i.invalidate()
M packages/agent/modes/session_dialog.go +11 -5
@@ -68,9 +68,9 @@ func (d *sessionDialog) CursorPos() (row, col int) {
6868 if !d.renaming {
6969 return -1, -1
7070 }
71- // Row: frameHeader(1) + rename hint(1) = row 2 (0-indexed)
72- // Col: 2 spaces indent + text length
73- return 2, 2 + len([]rune(d.rename))
71+ // Row: frameHeader + padDialogFrame blank + rename hint = row 3 (0-indexed).
72+ // Col: " ▌ " prefix + text length.
73+ return 3, 4 + len([]rune(d.rename))
7474 }
7575
7676 // Close hides the dialog.
@@ -93,8 +93,14 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
9393 return lines
9494 }
9595 if d.renaming {
96- lines = append(lines, th.FG256(th.Muted, "rename session (enter to save, esc to cancel):"))
97- lines = append(lines, " "+th.FG256(th.FG, d.rename))
96+ lines = append(lines, th.FG256(th.Muted, "rename session (enter save, esc cancel):"))
97+ text := d.rename
98+ if text == "" {
99+ text = th.FG256(th.Muted, "session name")
100+ } else {
101+ text = th.FG256(th.FG, text)
102+ }
103+ lines = append(lines, " "+th.FG256(th.Accent, "▌ ")+text)
98104 lines = append(lines, frameRule(th, width))
99105 return lines
100106 }
@@ -68,9 +68,9 @@ func (d *sessionDialog) CursorPos() (row, col int) {
68 if !d.renaming {68 if !d.renaming {
69 return -1, -169 return -1, -1
70 }70 }
71- // Row: frameHeader(1) + rename hint(1) = row 2 (0-indexed)71+ // Row: frameHeader + padDialogFrame blank + rename hint = row 3 (0-indexed).
72- // Col: 2 spaces indent + text length72+ // Col: " ▌ " prefix + text length.
73- return 2, 2 + len([]rune(d.rename))73+ return 3, 4 + len([]rune(d.rename))
74 }74 }
75 75
76 // Close hides the dialog.76 // Close hides the dialog.
@@ -93,8 +93,14 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
93 return lines93 return lines
94 }94 }
95 if d.renaming {95 if d.renaming {
96- lines = append(lines, th.FG256(th.Muted, "rename session (enter to save, esc to cancel):"))96+ lines = append(lines, th.FG256(th.Muted, "rename session (enter save, esc cancel):"))
97- lines = append(lines, " "+th.FG256(th.FG, d.rename))97+ text := d.rename
98+ if text == "" {
99+ text = th.FG256(th.Muted, "session name")
100+ } else {
101+ text = th.FG256(th.FG, text)
102+ }
103+ lines = append(lines, " "+th.FG256(th.Accent, "▌ ")+text)
98 lines = append(lines, frameRule(th, width))104 lines = append(lines, frameRule(th, width))
99 return lines105 return lines
100 }106 }
M packages/agent/modes/session_tree_dialog.go +277 -77
@@ -5,57 +5,74 @@ import (
55 "strings"
66
77 "github.com/patriceckhart/zot/packages/core"
8+ "github.com/patriceckhart/zot/packages/provider"
89 "github.com/patriceckhart/zot/packages/tui"
910 )
1011
11-// sessionTreeDialog renders the branch topology for the current
12-// cwd (parents, children, siblings) and lets the user pick another
13-// session file to switch into. Reached via /session tree.
14-//
15-// Layout is a single flat list with indentation-based nesting so
16-// we can reuse the simple up/down cursor model the other dialogs
17-// use. The current session is tagged with a muted "[current]".
12+// sessionTreeDialog renders a compact outline of the current session family.
13+// Branches are shown inline at the point where they forked, using indentation
14+// rather than file-level rows. Selecting a node checks out that point into a
15+// new branch.
1816 type sessionTreeDialog struct {
19- active bool
20- items []treeItem
21- cursor int
22- current string // path of the session we're currently in (not selectable)
17+ active bool
18+ items []sessionTreeItem
19+ cursor int
2320 }
2421
25-// treeItem is one visible row. Depth drives the indent; Path is the
26-// on-disk session file to swap to when the user hits enter.
27-type treeItem struct {
28- label string
29- path string
30- depth int
31- isCur bool
22+type sessionTreeItem struct {
23+ label string
24+ messageIdx int
25+ turnNo int
26+ role provider.Role
27+ prompt string
28+ path string
29+ depth int
30+ current bool
3231 }
3332
3433 type sessionTreeAction struct {
35- Select bool
36- Path string
37- Close bool
34+ Select bool
35+ MessageIdx int
36+ TurnNo int
37+ Role provider.Role
38+ Prompt string
39+ Path string
40+ Close bool
3841 }
3942
4043 func newSessionTreeDialog() *sessionTreeDialog { return &sessionTreeDialog{} }
4144
42-// Open flattens the given forest into indented rows. currentPath
43-// is highlighted and non-selectable (enter on it closes the dialog).
44-func (d *sessionTreeDialog) Open(roots []*core.TreeNode, currentPath string) bool {
45- items := flattenTree(roots, currentPath)
46- if len(items) == 0 {
45+func (d *sessionTreeDialog) OpenMessages(msgs []provider.Message) bool {
46+ d.items = buildSessionTreeItems("", msgs, 0, false)
47+ d.cursor = len(d.items) - 1
48+ if d.cursor < 0 {
49+ d.cursor = 0
50+ }
51+ d.active = true
52+ return len(d.items) > 0
53+}
54+
55+func (d *sessionTreeDialog) OpenSessionFamily(root, cwd, currentPath string) bool {
56+ roots := core.BuildSessionTree(root, cwd)
57+ if len(roots) == 0 {
4758 return false
4859 }
49- d.items = items
50- d.current = currentPath
51- d.cursor = indexOfCurrent(items, currentPath)
60+ rootNode := findTreeRootForPath(roots, currentPath)
61+ if rootNode == nil {
62+ rootNode = roots[0]
63+ }
64+ d.items = flattenSessionFamily(rootNode, currentPath)
65+ d.cursor = indexCurrentTreeItem(d.items)
5266 d.active = true
53- return true
67+ return len(d.items) > 0
5468 }
5569
5670 // Close hides the dialog.
5771 func (d *sessionTreeDialog) Close() { d.active = false }
5872
73+// CursorPos returns -1 because this dialog has no inline editor.
74+func (d *sessionTreeDialog) CursorPos() (row, col int) { return -1, -1 }
75+
5976 // Active reports whether the dialog consumes input.
6077 func (d *sessionTreeDialog) Active() bool { return d != nil && d.active }
6178
@@ -70,6 +87,19 @@ func (d *sessionTreeDialog) HandleKey(k tui.Key) sessionTreeAction {
7087 if d.cursor < len(d.items)-1 {
7188 d.cursor++
7289 }
90+ case tui.KeyPageUp:
91+ d.cursor -= 5
92+ if d.cursor < 0 {
93+ d.cursor = 0
94+ }
95+ case tui.KeyPageDown:
96+ d.cursor += 5
97+ if d.cursor >= len(d.items) {
98+ d.cursor = len(d.items) - 1
99+ }
100+ if d.cursor < 0 {
101+ d.cursor = 0
102+ }
73103 case tui.KeyEsc:
74104 d.Close()
75105 return sessionTreeAction{Close: true}
@@ -80,10 +110,7 @@ func (d *sessionTreeDialog) HandleKey(k tui.Key) sessionTreeAction {
80110 }
81111 it := d.items[d.cursor]
82112 d.Close()
83- if it.isCur {
84- return sessionTreeAction{Close: true}
85- }
86- return sessionTreeAction{Select: true, Path: it.path}
113+ return sessionTreeAction{Select: true, MessageIdx: it.messageIdx, TurnNo: it.turnNo, Role: it.role, Prompt: it.prompt, Path: it.path}
87114 }
88115 return sessionTreeAction{}
89116 }
@@ -95,72 +122,245 @@ func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string {
95122 }
96123 var lines []string
97124 lines = append(lines, frameHeader(th, "session tree", width))
98- lines = append(lines, th.FG256(th.Muted, "pick a branch to switch to (\u2191/\u2193, enter, esc to cancel):"))
99- for i, it := range d.items {
125+ if len(d.items) == 0 {
126+ lines = append(lines, th.FG256(th.Muted, "no messages in this session yet"))
127+ lines = append(lines, th.FG256(th.Muted, "press esc to close"))
128+ lines = append(lines, frameRule(th, width))
129+ return lines
130+ }
131+ lines = append(lines, th.FG256(th.Muted, "session branches (↑/↓, pgup/pgdn, enter checkout, esc cancel):"))
132+
133+ const maxRows = 12
134+ start := 0
135+ end := len(d.items)
136+ if end > maxRows {
137+ start = d.cursor - maxRows/2
138+ if start < 0 {
139+ start = 0
140+ }
141+ end = start + maxRows
142+ if end > len(d.items) {
143+ end = len(d.items)
144+ start = end - maxRows
145+ }
146+ }
147+ if start > 0 {
148+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↑ %d more above", start)))
149+ }
150+ for i := start; i < end; i++ {
151+ it := d.items[i]
100152 indent := strings.Repeat(" ", it.depth)
101- label := " " + indent + it.label
102- if it.isCur {
103- label += " " + th.FG256(th.Muted, "[current]")
153+ plain := " " + indent + fitSessionTreeLabel(it.label, width-2-len([]rune(indent)))
154+ if it.current {
155+ plain += " [current]"
104156 }
105157 if i == d.cursor {
106- lines = append(lines, th.PadHighlight(label, width))
158+ lines = append(lines, th.PadHighlight(plain, width))
107159 } else {
108- lines = append(lines, th.FG256(th.Muted, label))
160+ lines = append(lines, colorSessionTreeLine(th, plain))
109161 }
110162 }
163+ if end < len(d.items) {
164+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↓ %d more below", len(d.items)-end)))
165+ }
166+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf("%d/%d", d.cursor+1, len(d.items))))
111167 lines = append(lines, frameRule(th, width))
112168 return lines
113169 }
114170
115-// flattenTree walks the forest depth-first and returns one treeItem
116-// per node. Each label has the shape "<when> <first-prompt> (N msgs)".
117-func flattenTree(roots []*core.TreeNode, currentPath string) []treeItem {
118- var out []treeItem
119- var walk func(n *core.TreeNode, depth int)
120- walk = func(n *core.TreeNode, depth int) {
121- label := formatTreeRow(n)
122- out = append(out, treeItem{
123- label: label,
124- path: n.Summary.Path,
125- depth: depth,
126- isCur: n.Summary.Path == currentPath,
127- })
128- for _, c := range n.Children {
129- walk(c, depth+1)
171+func flattenSessionFamily(root *core.TreeNode, currentPath string) []sessionTreeItem {
172+ childrenByFork := map[int][]*core.TreeNode{}
173+ for _, child := range root.Children {
174+ childrenByFork[child.Meta.ForkPoint] = append(childrenByFork[child.Meta.ForkPoint], child)
175+ }
176+ sess, msgs, err := core.OpenSession(root.Summary.Path)
177+ if err != nil {
178+ return nil
179+ }
180+ _ = sess.Close()
181+ items := buildSessionTreeItems(root.Summary.Path, msgs, 0, root.Summary.Path == currentPath)
182+ var out []sessionTreeItem
183+ for idx, item := range items {
184+ out = append(out, item)
185+ for _, child := range childrenByFork[idx+1] {
186+ out = append(out, flattenSessionBranch(child, currentPath, 1)...)
130187 }
131188 }
132- for _, r := range roots {
133- walk(r, 0)
189+ for _, child := range childrenByFork[0] {
190+ out = append(out, flattenSessionBranch(child, currentPath, 1)...)
134191 }
135192 return out
136193 }
137194
138-// formatTreeRow renders one node's label line. Tries to fit a
139-// compact "date preview (N msgs, $cost)" shape; falls back to
140-// the meta id suffix when everything else is empty.
141-func formatTreeRow(n *core.TreeNode) string {
142- when := formatRelative(n.Summary.Started)
143- preview := strings.TrimSpace(n.Summary.FirstUserText)
144- if preview == "" {
145- if n.Meta.ID != "" && len(n.Meta.ID) >= 8 {
146- preview = "(" + n.Meta.ID[:8] + ")"
147- } else {
148- preview = "(empty)"
195+func flattenSessionBranch(node *core.TreeNode, currentPath string, depth int) []sessionTreeItem {
196+ sess, msgs, err := core.OpenSession(node.Summary.Path)
197+ if err != nil {
198+ return nil
199+ }
200+ _ = sess.Close()
201+ start := node.Meta.ForkPoint
202+ if start < 0 {
203+ start = 0
204+ }
205+ if start > len(msgs) {
206+ start = len(msgs)
207+ }
208+ items := buildSessionTreeItems(node.Summary.Path, msgs[start:], depth, node.Summary.Path == currentPath)
209+ for idx := range items {
210+ items[idx].messageIdx += start
211+ }
212+ childrenByFork := map[int][]*core.TreeNode{}
213+ for _, child := range node.Children {
214+ childrenByFork[child.Meta.ForkPoint] = append(childrenByFork[child.Meta.ForkPoint], child)
215+ }
216+ var out []sessionTreeItem
217+ for relIdx, item := range items {
218+ out = append(out, item)
219+ absAfter := start + relIdx + 1
220+ for _, child := range childrenByFork[absAfter] {
221+ out = append(out, flattenSessionBranch(child, currentPath, depth+1)...)
222+ }
223+ }
224+ return out
225+}
226+
227+func buildSessionTreeItems(path string, msgs []provider.Message, depth int, currentPath bool) []sessionTreeItem {
228+ out := make([]sessionTreeItem, 0, len(msgs))
229+ turn := 0
230+ lastTurn := 0
231+ for idx, msg := range msgs {
232+ label := sessionTreeRoleLabel(msg.Role)
233+ if msg.Role == provider.RoleUser {
234+ turn++
235+ lastTurn = turn
236+ } else if lastTurn == 0 {
237+ lastTurn = 1
149238 }
239+ preview := sessionTreePreview(msg)
240+ out = append(out, sessionTreeItem{
241+ label: fmt.Sprintf("%s: %s", label, preview),
242+ messageIdx: idx,
243+ turnNo: lastTurn,
244+ role: msg.Role,
245+ prompt: firstTextFromTreeMessage(msg),
246+ path: path,
247+ depth: depth,
248+ current: currentPath && idx == len(msgs)-1,
249+ })
150250 }
151- if len(preview) > 50 {
152- preview = preview[:47] + "..."
251+ return out
252+}
253+
254+func findTreeRootForPath(roots []*core.TreeNode, path string) *core.TreeNode {
255+ for _, root := range roots {
256+ if treeContainsPath(root, path) {
257+ return root
258+ }
153259 }
154- return fmt.Sprintf("%-14s %s %d msgs", when, preview, n.Summary.MessageCount)
260+ return nil
155261 }
156262
157-// indexOfCurrent returns the flat-list index of the row whose path
158-// matches currentPath. -1 when not found; the caller defaults to 0.
159-func indexOfCurrent(items []treeItem, currentPath string) int {
263+func treeContainsPath(n *core.TreeNode, path string) bool {
264+ if n == nil {
265+ return false
266+ }
267+ if n.Summary.Path == path {
268+ return true
269+ }
270+ for _, child := range n.Children {
271+ if treeContainsPath(child, path) {
272+ return true
273+ }
274+ }
275+ return false
276+}
277+
278+func indexCurrentTreeItem(items []sessionTreeItem) int {
160279 for i, it := range items {
161- if it.path == currentPath {
280+ if it.current {
162281 return i
163282 }
164283 }
165- return 0
284+ if len(items) == 0 {
285+ return 0
286+ }
287+ return len(items) - 1
288+}
289+
290+func firstTextFromTreeMessage(msg provider.Message) string {
291+ for _, c := range msg.Content {
292+ if tb, ok := c.(provider.TextBlock); ok {
293+ return tb.Text
294+ }
295+ }
296+ return ""
297+}
298+
299+func sessionTreeRoleLabel(role provider.Role) string {
300+ switch role {
301+ case provider.RoleUser:
302+ return "you"
303+ case provider.RoleAssistant:
304+ return "zot"
305+ case provider.RoleTool:
306+ return "tool"
307+ default:
308+ return string(role)
309+ }
310+}
311+
312+func sessionTreePreview(msg provider.Message) string {
313+ var parts []string
314+ for _, c := range msg.Content {
315+ switch b := c.(type) {
316+ case provider.TextBlock:
317+ text := strings.TrimSpace(strings.ReplaceAll(b.Text, "\n", " "))
318+ if text != "" {
319+ parts = append(parts, text)
320+ }
321+ case provider.ImageBlock:
322+ parts = append(parts, "[image]")
323+ case provider.ToolCallBlock:
324+ parts = append(parts, "tool "+b.Name)
325+ case provider.ToolResultBlock:
326+ if b.IsError {
327+ parts = append(parts, "tool result error")
328+ } else {
329+ parts = append(parts, "tool result")
330+ }
331+ case provider.ReasoningBlock:
332+ if b.Summary != "" {
333+ parts = append(parts, "reasoning")
334+ }
335+ }
336+ }
337+ if len(parts) == 0 {
338+ return "(empty)"
339+ }
340+ return strings.Join(parts, " ")
341+}
342+
343+func colorSessionTreeLine(th tui.Theme, line string) string {
344+ plain := strings.TrimSpace(line)
345+ switch {
346+ case strings.HasPrefix(plain, "you:"):
347+ return th.FG256(th.FG, line)
348+ case strings.HasPrefix(plain, "zot:"):
349+ return th.FG256(th.Muted, line)
350+ case strings.HasPrefix(plain, "tool:"):
351+ return th.FG256(th.ToolOut, line)
352+ default:
353+ return th.FG256(th.Muted, line)
354+ }
355+}
356+
357+func fitSessionTreeLabel(label string, maxWidth int) string {
358+ if maxWidth < 4 {
359+ maxWidth = 4
360+ }
361+ runes := []rune(label)
362+ if len(runes) <= maxWidth {
363+ return label
364+ }
365+ return string(runes[:maxWidth-3]) + "..."
166366 }
@@ -5,57 +5,74 @@ import (
5 "strings"5 "strings"
6 6
7 "github.com/patriceckhart/zot/packages/core"7 "github.com/patriceckhart/zot/packages/core"
8+ "github.com/patriceckhart/zot/packages/provider"
8 "github.com/patriceckhart/zot/packages/tui"9 "github.com/patriceckhart/zot/packages/tui"
9 )10 )
10 11
11-// sessionTreeDialog renders the branch topology for the current12+// sessionTreeDialog renders a compact outline of the current session family.
12-// cwd (parents, children, siblings) and lets the user pick another13+// Branches are shown inline at the point where they forked, using indentation
13-// session file to switch into. Reached via /session tree.14+// rather than file-level rows. Selecting a node checks out that point into a
14-//15+// new branch.
15-// Layout is a single flat list with indentation-based nesting so
16-// we can reuse the simple up/down cursor model the other dialogs
17-// use. The current session is tagged with a muted "[current]".
18 type sessionTreeDialog struct {16 type sessionTreeDialog struct {
19- active bool17+ active bool
20- items []treeItem18+ items []sessionTreeItem
21- cursor int19+ cursor int
22- current string // path of the session we're currently in (not selectable)
23 }20 }
24 21
25-// treeItem is one visible row. Depth drives the indent; Path is the22+type sessionTreeItem struct {
26-// on-disk session file to swap to when the user hits enter.23+ label string
27-type treeItem struct {24+ messageIdx int
28- label string25+ turnNo int
29- path string26+ role provider.Role
30- depth int27+ prompt string
31- isCur bool28+ path string
29+ depth int
30+ current bool
32 }31 }
33 32
34 type sessionTreeAction struct {33 type sessionTreeAction struct {
35- Select bool34+ Select bool
36- Path string35+ MessageIdx int
37- Close bool36+ TurnNo int
37+ Role provider.Role
38+ Prompt string
39+ Path string
40+ Close bool
38 }41 }
39 42
40 func newSessionTreeDialog() *sessionTreeDialog { return &sessionTreeDialog{} }43 func newSessionTreeDialog() *sessionTreeDialog { return &sessionTreeDialog{} }
41 44
42-// Open flattens the given forest into indented rows. currentPath45+func (d *sessionTreeDialog) OpenMessages(msgs []provider.Message) bool {
43-// is highlighted and non-selectable (enter on it closes the dialog).46+ d.items = buildSessionTreeItems("", msgs, 0, false)
44-func (d *sessionTreeDialog) Open(roots []*core.TreeNode, currentPath string) bool {47+ d.cursor = len(d.items) - 1
45- items := flattenTree(roots, currentPath)48+ if d.cursor < 0 {
46- if len(items) == 0 {49+ d.cursor = 0
50+ }
51+ d.active = true
52+ return len(d.items) > 0
53+}
54+
55+func (d *sessionTreeDialog) OpenSessionFamily(root, cwd, currentPath string) bool {
56+ roots := core.BuildSessionTree(root, cwd)
57+ if len(roots) == 0 {
47 return false58 return false
48 }59 }
49- d.items = items60+ rootNode := findTreeRootForPath(roots, currentPath)
50- d.current = currentPath61+ if rootNode == nil {
51- d.cursor = indexOfCurrent(items, currentPath)62+ rootNode = roots[0]
63+ }
64+ d.items = flattenSessionFamily(rootNode, currentPath)
65+ d.cursor = indexCurrentTreeItem(d.items)
52 d.active = true66 d.active = true
53- return true67+ return len(d.items) > 0
54 }68 }
55 69
56 // Close hides the dialog.70 // Close hides the dialog.
57 func (d *sessionTreeDialog) Close() { d.active = false }71 func (d *sessionTreeDialog) Close() { d.active = false }
58 72
73+// CursorPos returns -1 because this dialog has no inline editor.
74+func (d *sessionTreeDialog) CursorPos() (row, col int) { return -1, -1 }
75+
59 // Active reports whether the dialog consumes input.76 // Active reports whether the dialog consumes input.
60 func (d *sessionTreeDialog) Active() bool { return d != nil && d.active }77 func (d *sessionTreeDialog) Active() bool { return d != nil && d.active }
61 78
@@ -70,6 +87,19 @@ func (d *sessionTreeDialog) HandleKey(k tui.Key) sessionTreeAction {
70 if d.cursor < len(d.items)-1 {87 if d.cursor < len(d.items)-1 {
71 d.cursor++88 d.cursor++
72 }89 }
90+ case tui.KeyPageUp:
91+ d.cursor -= 5
92+ if d.cursor < 0 {
93+ d.cursor = 0
94+ }
95+ case tui.KeyPageDown:
96+ d.cursor += 5
97+ if d.cursor >= len(d.items) {
98+ d.cursor = len(d.items) - 1
99+ }
100+ if d.cursor < 0 {
101+ d.cursor = 0
102+ }
73 case tui.KeyEsc:103 case tui.KeyEsc:
74 d.Close()104 d.Close()
75 return sessionTreeAction{Close: true}105 return sessionTreeAction{Close: true}
@@ -80,10 +110,7 @@ func (d *sessionTreeDialog) HandleKey(k tui.Key) sessionTreeAction {
80 }110 }
81 it := d.items[d.cursor]111 it := d.items[d.cursor]
82 d.Close()112 d.Close()
83- if it.isCur {113+ return sessionTreeAction{Select: true, MessageIdx: it.messageIdx, TurnNo: it.turnNo, Role: it.role, Prompt: it.prompt, Path: it.path}
84- return sessionTreeAction{Close: true}
85- }
86- return sessionTreeAction{Select: true, Path: it.path}
87 }114 }
88 return sessionTreeAction{}115 return sessionTreeAction{}
89 }116 }
@@ -95,72 +122,245 @@ func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string {
95 }122 }
96 var lines []string123 var lines []string
97 lines = append(lines, frameHeader(th, "session tree", width))124 lines = append(lines, frameHeader(th, "session tree", width))
98- lines = append(lines, th.FG256(th.Muted, "pick a branch to switch to (\u2191/\u2193, enter, esc to cancel):"))125+ if len(d.items) == 0 {
99- for i, it := range d.items {126+ lines = append(lines, th.FG256(th.Muted, "no messages in this session yet"))
127+ lines = append(lines, th.FG256(th.Muted, "press esc to close"))
128+ lines = append(lines, frameRule(th, width))
129+ return lines
130+ }
131+ lines = append(lines, th.FG256(th.Muted, "session branches (↑/↓, pgup/pgdn, enter checkout, esc cancel):"))
132+
133+ const maxRows = 12
134+ start := 0
135+ end := len(d.items)
136+ if end > maxRows {
137+ start = d.cursor - maxRows/2
138+ if start < 0 {
139+ start = 0
140+ }
141+ end = start + maxRows
142+ if end > len(d.items) {
143+ end = len(d.items)
144+ start = end - maxRows
145+ }
146+ }
147+ if start > 0 {
148+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↑ %d more above", start)))
149+ }
150+ for i := start; i < end; i++ {
151+ it := d.items[i]
100 indent := strings.Repeat(" ", it.depth)152 indent := strings.Repeat(" ", it.depth)
101- label := " " + indent + it.label153+ plain := " " + indent + fitSessionTreeLabel(it.label, width-2-len([]rune(indent)))
102- if it.isCur {154+ if it.current {
103- label += " " + th.FG256(th.Muted, "[current]")155+ plain += " [current]"
104 }156 }
105 if i == d.cursor {157 if i == d.cursor {
106- lines = append(lines, th.PadHighlight(label, width))158+ lines = append(lines, th.PadHighlight(plain, width))
107 } else {159 } else {
108- lines = append(lines, th.FG256(th.Muted, label))160+ lines = append(lines, colorSessionTreeLine(th, plain))
109 }161 }
110 }162 }
163+ if end < len(d.items) {
164+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↓ %d more below", len(d.items)-end)))
165+ }
166+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf("%d/%d", d.cursor+1, len(d.items))))
111 lines = append(lines, frameRule(th, width))167 lines = append(lines, frameRule(th, width))
112 return lines168 return lines
113 }169 }
114 170
115-// flattenTree walks the forest depth-first and returns one treeItem171+func flattenSessionFamily(root *core.TreeNode, currentPath string) []sessionTreeItem {
116-// per node. Each label has the shape "<when> <first-prompt> (N msgs)".172+ childrenByFork := map[int][]*core.TreeNode{}
117-func flattenTree(roots []*core.TreeNode, currentPath string) []treeItem {173+ for _, child := range root.Children {
118- var out []treeItem174+ childrenByFork[child.Meta.ForkPoint] = append(childrenByFork[child.Meta.ForkPoint], child)
119- var walk func(n *core.TreeNode, depth int)175+ }
120- walk = func(n *core.TreeNode, depth int) {176+ sess, msgs, err := core.OpenSession(root.Summary.Path)
121- label := formatTreeRow(n)177+ if err != nil {
122- out = append(out, treeItem{178+ return nil
123- label: label,179+ }
124- path: n.Summary.Path,180+ _ = sess.Close()
125- depth: depth,181+ items := buildSessionTreeItems(root.Summary.Path, msgs, 0, root.Summary.Path == currentPath)
126- isCur: n.Summary.Path == currentPath,182+ var out []sessionTreeItem
127- })183+ for idx, item := range items {
128- for _, c := range n.Children {184+ out = append(out, item)
129- walk(c, depth+1)185+ for _, child := range childrenByFork[idx+1] {
186+ out = append(out, flattenSessionBranch(child, currentPath, 1)...)
130 }187 }
131 }188 }
132- for _, r := range roots {189+ for _, child := range childrenByFork[0] {
133- walk(r, 0)190+ out = append(out, flattenSessionBranch(child, currentPath, 1)...)
134 }191 }
135 return out192 return out
136 }193 }
137 194
138-// formatTreeRow renders one node's label line. Tries to fit a195+func flattenSessionBranch(node *core.TreeNode, currentPath string, depth int) []sessionTreeItem {
139-// compact "date preview (N msgs, $cost)" shape; falls back to196+ sess, msgs, err := core.OpenSession(node.Summary.Path)
140-// the meta id suffix when everything else is empty.197+ if err != nil {
141-func formatTreeRow(n *core.TreeNode) string {198+ return nil
142- when := formatRelative(n.Summary.Started)199+ }
143- preview := strings.TrimSpace(n.Summary.FirstUserText)200+ _ = sess.Close()
144- if preview == "" {201+ start := node.Meta.ForkPoint
145- if n.Meta.ID != "" && len(n.Meta.ID) >= 8 {202+ if start < 0 {
146- preview = "(" + n.Meta.ID[:8] + ")"203+ start = 0
147- } else {204+ }
148- preview = "(empty)"205+ if start > len(msgs) {
206+ start = len(msgs)
207+ }
208+ items := buildSessionTreeItems(node.Summary.Path, msgs[start:], depth, node.Summary.Path == currentPath)
209+ for idx := range items {
210+ items[idx].messageIdx += start
211+ }
212+ childrenByFork := map[int][]*core.TreeNode{}
213+ for _, child := range node.Children {
214+ childrenByFork[child.Meta.ForkPoint] = append(childrenByFork[child.Meta.ForkPoint], child)
215+ }
216+ var out []sessionTreeItem
217+ for relIdx, item := range items {
218+ out = append(out, item)
219+ absAfter := start + relIdx + 1
220+ for _, child := range childrenByFork[absAfter] {
221+ out = append(out, flattenSessionBranch(child, currentPath, depth+1)...)
222+ }
223+ }
224+ return out
225+}
226+
227+func buildSessionTreeItems(path string, msgs []provider.Message, depth int, currentPath bool) []sessionTreeItem {
228+ out := make([]sessionTreeItem, 0, len(msgs))
229+ turn := 0
230+ lastTurn := 0
231+ for idx, msg := range msgs {
232+ label := sessionTreeRoleLabel(msg.Role)
233+ if msg.Role == provider.RoleUser {
234+ turn++
235+ lastTurn = turn
236+ } else if lastTurn == 0 {
237+ lastTurn = 1
149 }238 }
239+ preview := sessionTreePreview(msg)
240+ out = append(out, sessionTreeItem{
241+ label: fmt.Sprintf("%s: %s", label, preview),
242+ messageIdx: idx,
243+ turnNo: lastTurn,
244+ role: msg.Role,
245+ prompt: firstTextFromTreeMessage(msg),
246+ path: path,
247+ depth: depth,
248+ current: currentPath && idx == len(msgs)-1,
249+ })
150 }250 }
151- if len(preview) > 50 {251+ return out
152- preview = preview[:47] + "..."252+}
253+
254+func findTreeRootForPath(roots []*core.TreeNode, path string) *core.TreeNode {
255+ for _, root := range roots {
256+ if treeContainsPath(root, path) {
257+ return root
258+ }
153 }259 }
154- return fmt.Sprintf("%-14s %s %d msgs", when, preview, n.Summary.MessageCount)260+ return nil
155 }261 }
156 262
157-// indexOfCurrent returns the flat-list index of the row whose path263+func treeContainsPath(n *core.TreeNode, path string) bool {
158-// matches currentPath. -1 when not found; the caller defaults to 0.264+ if n == nil {
159-func indexOfCurrent(items []treeItem, currentPath string) int {265+ return false
266+ }
267+ if n.Summary.Path == path {
268+ return true
269+ }
270+ for _, child := range n.Children {
271+ if treeContainsPath(child, path) {
272+ return true
273+ }
274+ }
275+ return false
276+}
277+
278+func indexCurrentTreeItem(items []sessionTreeItem) int {
160 for i, it := range items {279 for i, it := range items {
161- if it.path == currentPath {280+ if it.current {
162 return i281 return i
163 }282 }
164 }283 }
165- return 0284+ if len(items) == 0 {
285+ return 0
286+ }
287+ return len(items) - 1
288+}
289+
290+func firstTextFromTreeMessage(msg provider.Message) string {
291+ for _, c := range msg.Content {
292+ if tb, ok := c.(provider.TextBlock); ok {
293+ return tb.Text
294+ }
295+ }
296+ return ""
297+}
298+
299+func sessionTreeRoleLabel(role provider.Role) string {
300+ switch role {
301+ case provider.RoleUser:
302+ return "you"
303+ case provider.RoleAssistant:
304+ return "zot"
305+ case provider.RoleTool:
306+ return "tool"
307+ default:
308+ return string(role)
309+ }
310+}
311+
312+func sessionTreePreview(msg provider.Message) string {
313+ var parts []string
314+ for _, c := range msg.Content {
315+ switch b := c.(type) {
316+ case provider.TextBlock:
317+ text := strings.TrimSpace(strings.ReplaceAll(b.Text, "\n", " "))
318+ if text != "" {
319+ parts = append(parts, text)
320+ }
321+ case provider.ImageBlock:
322+ parts = append(parts, "[image]")
323+ case provider.ToolCallBlock:
324+ parts = append(parts, "tool "+b.Name)
325+ case provider.ToolResultBlock:
326+ if b.IsError {
327+ parts = append(parts, "tool result error")
328+ } else {
329+ parts = append(parts, "tool result")
330+ }
331+ case provider.ReasoningBlock:
332+ if b.Summary != "" {
333+ parts = append(parts, "reasoning")
334+ }
335+ }
336+ }
337+ if len(parts) == 0 {
338+ return "(empty)"
339+ }
340+ return strings.Join(parts, " ")
341+}
342+
343+func colorSessionTreeLine(th tui.Theme, line string) string {
344+ plain := strings.TrimSpace(line)
345+ switch {
346+ case strings.HasPrefix(plain, "you:"):
347+ return th.FG256(th.FG, line)
348+ case strings.HasPrefix(plain, "zot:"):
349+ return th.FG256(th.Muted, line)
350+ case strings.HasPrefix(plain, "tool:"):
351+ return th.FG256(th.ToolOut, line)
352+ default:
353+ return th.FG256(th.Muted, line)
354+ }
355+}
356+
357+func fitSessionTreeLabel(label string, maxWidth int) string {
358+ if maxWidth < 4 {
359+ maxWidth = 4
360+ }
361+ runes := []rune(label)
362+ if len(runes) <= maxWidth {
363+ return label
364+ }
365+ return string(runes[:maxWidth-3]) + "..."
166 }366 }
M packages/core/session.go +99 -6
@@ -59,6 +59,9 @@ type SessionMeta struct {
5959 // are copied from the parent verbatim; the user's next turn on
6060 // the child session continues from there.
6161 ForkPoint int `json:"fork_point,omitempty"`
62+ // HideFromSessions hides internal tree-navigation branches from the
63+ // flat /sessions picker while keeping them available to /session tree.
64+ HideFromSessions bool `json:"hide_from_sessions,omitempty"`
6265 }
6366
6467 // sessionLine is the on-disk row type. Message is kept as a raw
@@ -389,6 +392,7 @@ type SessionSummary struct {
389392 FirstUserText string
390393 TotalCost float64
391394 Title string
395+ BranchDepth int
392396 }
393397
394398 // RenameSession updates the title field in the session's meta line.
@@ -409,18 +413,59 @@ func RenameSession(path, title string) error {
409413 return err
410414 }
411415
416+// DeleteSession removes a session file. It refuses an empty path so
417+// callers do not accidentally delete their cwd or another implicit target.
418+func DeleteSession(path string) error {
419+ if strings.TrimSpace(path) == "" {
420+ return fmt.Errorf("session path is empty")
421+ }
422+ return os.Remove(path)
423+}
424+
412425 // DescribeSessions returns lightweight summaries for every session in
413426 // cwd, newest first. Parses only the first few lines and the last usage
414427 // line so it's cheap to run on every dialog open.
415428 func DescribeSessions(root, cwd string) []SessionSummary {
416429 paths := ListSessions(root, cwd)
417430 summaries := make([]SessionSummary, 0, len(paths))
431+ metas := make(map[string]SessionMeta, len(paths))
432+ idToPath := make(map[string]string, len(paths))
433+ for _, p := range paths {
434+ if meta, err := readSessionMeta(p); err == nil && meta.ID != "" {
435+ metas[p] = meta
436+ idToPath[meta.ID] = p
437+ }
438+ }
418439 for _, p := range paths {
440+ if meta := metas[p]; meta.HideFromSessions {
441+ continue
442+ }
419443 summaries = append(summaries, describeSession(p))
420444 }
445+ for idx := range summaries {
446+ summaries[idx].BranchDepth = sessionBranchDepth(summaries[idx].Path, metas, idToPath)
447+ }
421448 return summaries
422449 }
423450
451+func sessionBranchDepth(path string, metas map[string]SessionMeta, idToPath map[string]string) int {
452+ depth := 0
453+ seen := map[string]bool{}
454+ for {
455+ meta, ok := metas[path]
456+ if !ok || meta.Parent == "" || seen[meta.ID] {
457+ return depth
458+ }
459+ seen[meta.ID] = true
460+ parentPath := idToPath[meta.Parent]
461+ if parentPath == "" {
462+ return depth
463+ }
464+ depth++
465+ path = parentPath
466+ }
467+}
468+
424469 func describeSession(path string) SessionSummary {
425470 s := SessionSummary{Path: path}
426471 f, err := os.Open(path)
@@ -428,6 +473,12 @@ func describeSession(path string) SessionSummary {
428473 return s
429474 }
430475 defer f.Close()
476+ isBranch := false
477+ forkPoint := 0
478+ messageIdx := 0
479+ branchPrompt := ""
480+ lastUser := ""
481+ renameMessageIdx := -1
431482 _ = forEachJSONLLine(f, func(line []byte) error {
432483 var head sessionLineHead
433484 if err := json.Unmarshal(line, &head); err != nil {
@@ -443,18 +494,28 @@ func describeSession(path string) SessionSummary {
443494 s.Model = row.Meta.Model
444495 s.Provider = row.Meta.Provider
445496 s.Title = row.Meta.Title
497+ isBranch = row.Meta.Parent != ""
498+ forkPoint = row.Meta.ForkPoint
446499 }
447500 case "message":
448- s.MessageCount++
449- if s.FirstUserText == "" {
450- s.FirstUserText = firstUserText(line)
501+ if text := firstUserText(line); text != "" {
502+ lastUser = text
503+ if s.FirstUserText == "" {
504+ s.FirstUserText = text
505+ }
506+ if isBranch && messageIdx >= forkPoint && branchPrompt == "" {
507+ branchPrompt = text
508+ }
451509 }
510+ messageIdx++
511+ s.MessageCount++
452512 case "compaction":
453513 if compacted, err := hydrateCompaction(line); err == nil {
454514 s.MessageCount = len(compacted)
455- if s.FirstUserText == "" && len(compacted) > 0 {
456- s.FirstUserText = firstTextFromMessage(compacted[0])
457- }
515+ messageIdx = len(compacted)
516+ s.FirstUserText = firstUserTextFromMessages(compacted)
517+ lastUser = lastUserText(compacted)
518+ branchPrompt = firstUserTextFromMessagesAfter(compacted, forkPoint)
458519 }
459520 case "rename":
460521 var row struct {
@@ -462,6 +523,7 @@ func describeSession(path string) SessionSummary {
462523 }
463524 if err := json.Unmarshal(line, &row); err == nil && row.Title != "" {
464525 s.Title = row.Title
526+ renameMessageIdx = messageIdx
465527 }
466528 case "usage":
467529 var row struct {
@@ -473,6 +535,18 @@ func describeSession(path string) SessionSummary {
473535 }
474536 return nil
475537 })
538+ if isBranch {
539+ // A rename written after the branch has diverged is user intent
540+ // and must win. Older generated branch titles were written at
541+ // fork creation time, before any post-fork prompt, so those are
542+ // allowed to be replaced by the branch prompt fallback.
543+ userRenamedAfterFork := renameMessageIdx > forkPoint
544+ if !userRenamedAfterFork && branchPrompt != "" {
545+ s.Title = branchPrompt
546+ } else if s.Title == "" && lastUser != "" {
547+ s.Title = lastUser
548+ }
549+ }
476550 return s
477551 }
478552
@@ -499,6 +573,25 @@ func firstUserText(line []byte) string {
499573 return ""
500574 }
501575
576+func firstUserTextFromMessages(msgs []provider.Message) string {
577+ return firstUserTextFromMessagesAfter(msgs, 0)
578+}
579+
580+func firstUserTextFromMessagesAfter(msgs []provider.Message, start int) string {
581+ if start < 0 {
582+ start = 0
583+ }
584+ for idx, msg := range msgs {
585+ if idx < start || msg.Role != provider.RoleUser {
586+ continue
587+ }
588+ if text := firstTextFromMessage(msg); text != "" {
589+ return text
590+ }
591+ }
592+ return ""
593+}
594+
502595 func firstTextFromMessage(msg provider.Message) string {
503596 for _, c := range msg.Content {
504597 if tb, ok := c.(provider.TextBlock); ok && tb.Text != "" {
@@ -59,6 +59,9 @@ type SessionMeta struct {
59 // are copied from the parent verbatim; the user's next turn on59 // are copied from the parent verbatim; the user's next turn on
60 // the child session continues from there.60 // the child session continues from there.
61 ForkPoint int `json:"fork_point,omitempty"`61 ForkPoint int `json:"fork_point,omitempty"`
62+ // HideFromSessions hides internal tree-navigation branches from the
63+ // flat /sessions picker while keeping them available to /session tree.
64+ HideFromSessions bool `json:"hide_from_sessions,omitempty"`
62 }65 }
63 66
64 // sessionLine is the on-disk row type. Message is kept as a raw67 // sessionLine is the on-disk row type. Message is kept as a raw
@@ -389,6 +392,7 @@ type SessionSummary struct {
389 FirstUserText string392 FirstUserText string
390 TotalCost float64393 TotalCost float64
391 Title string394 Title string
395+ BranchDepth int
392 }396 }
393 397
394 // RenameSession updates the title field in the session's meta line.398 // RenameSession updates the title field in the session's meta line.
@@ -409,18 +413,59 @@ func RenameSession(path, title string) error {
409 return err413 return err
410 }414 }
411 415
416+// DeleteSession removes a session file. It refuses an empty path so
417+// callers do not accidentally delete their cwd or another implicit target.
418+func DeleteSession(path string) error {
419+ if strings.TrimSpace(path) == "" {
420+ return fmt.Errorf("session path is empty")
421+ }
422+ return os.Remove(path)
423+}
424+
412 // DescribeSessions returns lightweight summaries for every session in425 // DescribeSessions returns lightweight summaries for every session in
413 // cwd, newest first. Parses only the first few lines and the last usage426 // cwd, newest first. Parses only the first few lines and the last usage
414 // line so it's cheap to run on every dialog open.427 // line so it's cheap to run on every dialog open.
415 func DescribeSessions(root, cwd string) []SessionSummary {428 func DescribeSessions(root, cwd string) []SessionSummary {
416 paths := ListSessions(root, cwd)429 paths := ListSessions(root, cwd)
417 summaries := make([]SessionSummary, 0, len(paths))430 summaries := make([]SessionSummary, 0, len(paths))
431+ metas := make(map[string]SessionMeta, len(paths))
432+ idToPath := make(map[string]string, len(paths))
433+ for _, p := range paths {
434+ if meta, err := readSessionMeta(p); err == nil && meta.ID != "" {
435+ metas[p] = meta
436+ idToPath[meta.ID] = p
437+ }
438+ }
418 for _, p := range paths {439 for _, p := range paths {
440+ if meta := metas[p]; meta.HideFromSessions {
441+ continue
442+ }
419 summaries = append(summaries, describeSession(p))443 summaries = append(summaries, describeSession(p))
420 }444 }
445+ for idx := range summaries {
446+ summaries[idx].BranchDepth = sessionBranchDepth(summaries[idx].Path, metas, idToPath)
447+ }
421 return summaries448 return summaries
422 }449 }
423 450
451+func sessionBranchDepth(path string, metas map[string]SessionMeta, idToPath map[string]string) int {
452+ depth := 0
453+ seen := map[string]bool{}
454+ for {
455+ meta, ok := metas[path]
456+ if !ok || meta.Parent == "" || seen[meta.ID] {
457+ return depth
458+ }
459+ seen[meta.ID] = true
460+ parentPath := idToPath[meta.Parent]
461+ if parentPath == "" {
462+ return depth
463+ }
464+ depth++
465+ path = parentPath
466+ }
467+}
468+
424 func describeSession(path string) SessionSummary {469 func describeSession(path string) SessionSummary {
425 s := SessionSummary{Path: path}470 s := SessionSummary{Path: path}
426 f, err := os.Open(path)471 f, err := os.Open(path)
@@ -428,6 +473,12 @@ func describeSession(path string) SessionSummary {
428 return s473 return s
429 }474 }
430 defer f.Close()475 defer f.Close()
476+ isBranch := false
477+ forkPoint := 0
478+ messageIdx := 0
479+ branchPrompt := ""
480+ lastUser := ""
481+ renameMessageIdx := -1
431 _ = forEachJSONLLine(f, func(line []byte) error {482 _ = forEachJSONLLine(f, func(line []byte) error {
432 var head sessionLineHead483 var head sessionLineHead
433 if err := json.Unmarshal(line, &head); err != nil {484 if err := json.Unmarshal(line, &head); err != nil {
@@ -443,18 +494,28 @@ func describeSession(path string) SessionSummary {
443 s.Model = row.Meta.Model494 s.Model = row.Meta.Model
444 s.Provider = row.Meta.Provider495 s.Provider = row.Meta.Provider
445 s.Title = row.Meta.Title496 s.Title = row.Meta.Title
497+ isBranch = row.Meta.Parent != ""
498+ forkPoint = row.Meta.ForkPoint
446 }499 }
447 case "message":500 case "message":
448- s.MessageCount++501+ if text := firstUserText(line); text != "" {
449- if s.FirstUserText == "" {502+ lastUser = text
450- s.FirstUserText = firstUserText(line)503+ if s.FirstUserText == "" {
504+ s.FirstUserText = text
505+ }
506+ if isBranch && messageIdx >= forkPoint && branchPrompt == "" {
507+ branchPrompt = text
508+ }
451 }509 }
510+ messageIdx++
511+ s.MessageCount++
452 case "compaction":512 case "compaction":
453 if compacted, err := hydrateCompaction(line); err == nil {513 if compacted, err := hydrateCompaction(line); err == nil {
454 s.MessageCount = len(compacted)514 s.MessageCount = len(compacted)
455- if s.FirstUserText == "" && len(compacted) > 0 {515+ messageIdx = len(compacted)
456- s.FirstUserText = firstTextFromMessage(compacted[0])516+ s.FirstUserText = firstUserTextFromMessages(compacted)
457- }517+ lastUser = lastUserText(compacted)
518+ branchPrompt = firstUserTextFromMessagesAfter(compacted, forkPoint)
458 }519 }
459 case "rename":520 case "rename":
460 var row struct {521 var row struct {
@@ -462,6 +523,7 @@ func describeSession(path string) SessionSummary {
462 }523 }
463 if err := json.Unmarshal(line, &row); err == nil && row.Title != "" {524 if err := json.Unmarshal(line, &row); err == nil && row.Title != "" {
464 s.Title = row.Title525 s.Title = row.Title
526+ renameMessageIdx = messageIdx
465 }527 }
466 case "usage":528 case "usage":
467 var row struct {529 var row struct {
@@ -473,6 +535,18 @@ func describeSession(path string) SessionSummary {
473 }535 }
474 return nil536 return nil
475 })537 })
538+ if isBranch {
539+ // A rename written after the branch has diverged is user intent
540+ // and must win. Older generated branch titles were written at
541+ // fork creation time, before any post-fork prompt, so those are
542+ // allowed to be replaced by the branch prompt fallback.
543+ userRenamedAfterFork := renameMessageIdx > forkPoint
544+ if !userRenamedAfterFork && branchPrompt != "" {
545+ s.Title = branchPrompt
546+ } else if s.Title == "" && lastUser != "" {
547+ s.Title = lastUser
548+ }
549+ }
476 return s550 return s
477 }551 }
478 552
@@ -499,6 +573,25 @@ func firstUserText(line []byte) string {
499 return ""573 return ""
500 }574 }
501 575
576+func firstUserTextFromMessages(msgs []provider.Message) string {
577+ return firstUserTextFromMessagesAfter(msgs, 0)
578+}
579+
580+func firstUserTextFromMessagesAfter(msgs []provider.Message, start int) string {
581+ if start < 0 {
582+ start = 0
583+ }
584+ for idx, msg := range msgs {
585+ if idx < start || msg.Role != provider.RoleUser {
586+ continue
587+ }
588+ if text := firstTextFromMessage(msg); text != "" {
589+ return text
590+ }
591+ }
592+ return ""
593+}
594+
502 func firstTextFromMessage(msg provider.Message) string {595 func firstTextFromMessage(msg provider.Message) string {
503 for _, c := range msg.Content {596 for _, c := range msg.Content {
504 if tb, ok := c.(provider.TextBlock); ok && tb.Text != "" {597 if tb, ok := c.(provider.TextBlock); ok && tb.Text != "" {
M packages/core/session_portable.go +45 -12
@@ -256,6 +256,16 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) {
256256 //
257257 // Returns the path of the new session file, ready for OpenSession.
258258 func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (string, error) {
259+ return branchSession(parentPath, root, cwd, version, upToMessageIdx, false)
260+}
261+
262+// BranchSessionHidden creates a branch that participates in /session tree but
263+// is hidden from the flat /sessions picker. Used for in-place tree navigation.
264+func BranchSessionHidden(parentPath, root, cwd, version string, upToMessageIdx int) (string, error) {
265+ return branchSession(parentPath, root, cwd, version, upToMessageIdx, true)
266+}
267+
268+func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hideFromSessions bool) (string, error) {
259269 if parentPath == "" {
260270 return "", errors.New("branch: parent path is empty")
261271 }
@@ -302,14 +312,15 @@ func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (s
302312
303313 // Write the branch meta.
304314 branchMeta := SessionMeta{
305- ID: newID,
306- CWD: cwd,
307- Model: parentMeta.Model,
308- Provider: parentMeta.Provider,
309- Started: time.Now().UTC(),
310- Version: version,
311- Parent: parentMeta.ID,
312- ForkPoint: upToMessageIdx,
315+ ID: newID,
316+ CWD: cwd,
317+ Model: parentMeta.Model,
318+ Provider: parentMeta.Provider,
319+ Started: time.Now().UTC(),
320+ Version: version,
321+ Parent: parentMeta.ID,
322+ ForkPoint: upToMessageIdx,
323+ HideFromSessions: hideFromSessions,
313324 }
314325 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta})
315326 if err != nil {
@@ -364,11 +375,12 @@ func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (s
364375 }); err != nil && err != io.EOF {
365376 return "", fmt.Errorf("branch: read parent: %w", err)
366377 }
378+ limit := upToMessageIdx
379+ if limit > len(effective) {
380+ limit = len(effective)
381+ }
382+ branchTitle := lastUserText(effective[:limit])
367383 if sawCompaction {
368- limit := upToMessageIdx
369- if limit > len(effective) {
370- limit = len(effective)
371- }
372384 for i := 0; i < limit; i++ {
373385 msg := effective[i]
374386 line, err := json.Marshal(sessionLine{Type: "message", Message: &msg})
@@ -392,12 +404,33 @@ func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (s
392404 }
393405 }
394406 }
407+ if branchTitle != "" {
408+ line, _ := json.Marshal(map[string]string{"type": "rename", "title": branchTitle})
409+ if _, err := bw.Write(line); err != nil {
410+ return "", err
411+ }
412+ if err := bw.WriteByte('\n'); err != nil {
413+ return "", err
414+ }
415+ }
395416 if err := bw.Flush(); err != nil {
396417 return "", err
397418 }
398419 return outPath, nil
399420 }
400421
422+func lastUserText(msgs []provider.Message) string {
423+ for i := len(msgs) - 1; i >= 0; i-- {
424+ if msgs[i].Role != provider.RoleUser {
425+ continue
426+ }
427+ if text := strings.TrimSpace(firstTextFromMessage(msgs[i])); text != "" {
428+ return text
429+ }
430+ }
431+ return ""
432+}
433+
401434 // TreeNode is one entry in the branch tree returned by
402435 // BuildSessionTree. Children are populated by linking on Parent ID.
403436 type TreeNode struct {
@@ -256,6 +256,16 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) {
256 //256 //
257 // Returns the path of the new session file, ready for OpenSession.257 // Returns the path of the new session file, ready for OpenSession.
258 func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (string, error) {258 func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (string, error) {
259+ return branchSession(parentPath, root, cwd, version, upToMessageIdx, false)
260+}
261+
262+// BranchSessionHidden creates a branch that participates in /session tree but
263+// is hidden from the flat /sessions picker. Used for in-place tree navigation.
264+func BranchSessionHidden(parentPath, root, cwd, version string, upToMessageIdx int) (string, error) {
265+ return branchSession(parentPath, root, cwd, version, upToMessageIdx, true)
266+}
267+
268+func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hideFromSessions bool) (string, error) {
259 if parentPath == "" {269 if parentPath == "" {
260 return "", errors.New("branch: parent path is empty")270 return "", errors.New("branch: parent path is empty")
261 }271 }
@@ -302,14 +312,15 @@ func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (s
302 312
303 // Write the branch meta.313 // Write the branch meta.
304 branchMeta := SessionMeta{314 branchMeta := SessionMeta{
305- ID: newID,315+ ID: newID,
306- CWD: cwd,316+ CWD: cwd,
307- Model: parentMeta.Model,317+ Model: parentMeta.Model,
308- Provider: parentMeta.Provider,318+ Provider: parentMeta.Provider,
309- Started: time.Now().UTC(),319+ Started: time.Now().UTC(),
310- Version: version,320+ Version: version,
311- Parent: parentMeta.ID,321+ Parent: parentMeta.ID,
312- ForkPoint: upToMessageIdx,322+ ForkPoint: upToMessageIdx,
323+ HideFromSessions: hideFromSessions,
313 }324 }
314 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta})325 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta})
315 if err != nil {326 if err != nil {
@@ -364,11 +375,12 @@ func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (s
364 }); err != nil && err != io.EOF {375 }); err != nil && err != io.EOF {
365 return "", fmt.Errorf("branch: read parent: %w", err)376 return "", fmt.Errorf("branch: read parent: %w", err)
366 }377 }
378+ limit := upToMessageIdx
379+ if limit > len(effective) {
380+ limit = len(effective)
381+ }
382+ branchTitle := lastUserText(effective[:limit])
367 if sawCompaction {383 if sawCompaction {
368- limit := upToMessageIdx
369- if limit > len(effective) {
370- limit = len(effective)
371- }
372 for i := 0; i < limit; i++ {384 for i := 0; i < limit; i++ {
373 msg := effective[i]385 msg := effective[i]
374 line, err := json.Marshal(sessionLine{Type: "message", Message: &msg})386 line, err := json.Marshal(sessionLine{Type: "message", Message: &msg})
@@ -392,12 +404,33 @@ func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (s
392 }404 }
393 }405 }
394 }406 }
407+ if branchTitle != "" {
408+ line, _ := json.Marshal(map[string]string{"type": "rename", "title": branchTitle})
409+ if _, err := bw.Write(line); err != nil {
410+ return "", err
411+ }
412+ if err := bw.WriteByte('\n'); err != nil {
413+ return "", err
414+ }
415+ }
395 if err := bw.Flush(); err != nil {416 if err := bw.Flush(); err != nil {
396 return "", err417 return "", err
397 }418 }
398 return outPath, nil419 return outPath, nil
399 }420 }
400 421
422+func lastUserText(msgs []provider.Message) string {
423+ for i := len(msgs) - 1; i >= 0; i-- {
424+ if msgs[i].Role != provider.RoleUser {
425+ continue
426+ }
427+ if text := strings.TrimSpace(firstTextFromMessage(msgs[i])); text != "" {
428+ return text
429+ }
430+ }
431+ return ""
432+}
433+
401 // TreeNode is one entry in the branch tree returned by434 // TreeNode is one entry in the branch tree returned by
402 // BuildSessionTree. Children are populated by linking on Parent ID.435 // BuildSessionTree. Children are populated by linking on Parent ID.
403 type TreeNode struct {436 type TreeNode struct {
M packages/core/session_portable_test.go +87 -0
@@ -229,6 +229,93 @@ func TestBranchSessionCopiesPrefix(t *testing.T) {
229229 if branch.Meta.ID == parent.Meta.ID {
230230 t.Errorf("branch kept parent id; must rotate")
231231 }
232+ if branch.Meta.Title != "" {
233+ t.Errorf("meta title should remain append-only, got %q", branch.Meta.Title)
234+ }
235+ if summary := describeSession(branchPath); summary.Title != "first" {
236+ t.Errorf("branch title: want %q, got %q", "first", summary.Title)
237+ }
238+}
239+
240+func TestBranchSessionTitleUsesSelectedUserPrompt(t *testing.T) {
241+ root := t.TempDir()
242+ cwd := "/project"
243+ parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
244+ if err != nil {
245+ t.Fatal(err)
246+ }
247+ for _, msg := range []provider.Message{
248+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first prompt"}}},
249+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "first reply"}}},
250+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "second prompt"}}},
251+ } {
252+ _ = parent.AppendMessage(msg)
253+ }
254+ _ = parent.Close()
255+
256+ branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 3)
257+ if err != nil {
258+ t.Fatalf("BranchSession: %v", err)
259+ }
260+ if summary := describeSession(branchPath); summary.Title != "second prompt" {
261+ t.Errorf("branch title: want %q, got %q", "second prompt", summary.Title)
262+ }
263+}
264+
265+func TestBranchSessionTitleUsesFirstPromptAfterForkWhenBranchDiverges(t *testing.T) {
266+ root := t.TempDir()
267+ cwd := "/project"
268+ parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
269+ if err != nil {
270+ t.Fatal(err)
271+ }
272+ _ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}})
273+ _ = parent.Close()
274+
275+ branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
276+ if err != nil {
277+ t.Fatalf("BranchSession: %v", err)
278+ }
279+ branch, _, err := OpenSession(branchPath)
280+ if err != nil {
281+ t.Fatal(err)
282+ }
283+ _ = branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}})
284+ _ = branch.AppendMessage(provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "fine"}}})
285+ _ = branch.Close()
286+
287+ if summary := describeSession(branchPath); summary.Title != "how are you?" {
288+ t.Errorf("branch title: want %q, got %q", "how are you?", summary.Title)
289+ }
290+}
291+
292+func TestRenamedBranchKeepsExplicitTitle(t *testing.T) {
293+ root := t.TempDir()
294+ cwd := "/project"
295+ parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
296+ if err != nil {
297+ t.Fatal(err)
298+ }
299+ _ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}})
300+ _ = parent.Close()
301+
302+ branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
303+ if err != nil {
304+ t.Fatalf("BranchSession: %v", err)
305+ }
306+ branch, _, err := OpenSession(branchPath)
307+ if err != nil {
308+ t.Fatal(err)
309+ }
310+ _ = branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}})
311+ _ = branch.Close()
312+ if err := RenameSession(branchPath, "friendly branch name"); err != nil {
313+ t.Fatal(err)
314+ }
315+
316+ if summary := describeSession(branchPath); summary.Title != "friendly branch name" {
317+ t.Errorf("branch title: want %q, got %q", "friendly branch name", summary.Title)
318+ }
232319 }
233320
234321 func TestBranchSessionUsesEffectiveTranscriptAfterCompaction(t *testing.T) {
@@ -229,6 +229,93 @@ func TestBranchSessionCopiesPrefix(t *testing.T) {
229 if branch.Meta.ID == parent.Meta.ID {229 if branch.Meta.ID == parent.Meta.ID {
230 t.Errorf("branch kept parent id; must rotate")230 t.Errorf("branch kept parent id; must rotate")
231 }231 }
232+ if branch.Meta.Title != "" {
233+ t.Errorf("meta title should remain append-only, got %q", branch.Meta.Title)
234+ }
235+ if summary := describeSession(branchPath); summary.Title != "first" {
236+ t.Errorf("branch title: want %q, got %q", "first", summary.Title)
237+ }
238+}
239+
240+func TestBranchSessionTitleUsesSelectedUserPrompt(t *testing.T) {
241+ root := t.TempDir()
242+ cwd := "/project"
243+ parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
244+ if err != nil {
245+ t.Fatal(err)
246+ }
247+ for _, msg := range []provider.Message{
248+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first prompt"}}},
249+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "first reply"}}},
250+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "second prompt"}}},
251+ } {
252+ _ = parent.AppendMessage(msg)
253+ }
254+ _ = parent.Close()
255+
256+ branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 3)
257+ if err != nil {
258+ t.Fatalf("BranchSession: %v", err)
259+ }
260+ if summary := describeSession(branchPath); summary.Title != "second prompt" {
261+ t.Errorf("branch title: want %q, got %q", "second prompt", summary.Title)
262+ }
263+}
264+
265+func TestBranchSessionTitleUsesFirstPromptAfterForkWhenBranchDiverges(t *testing.T) {
266+ root := t.TempDir()
267+ cwd := "/project"
268+ parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
269+ if err != nil {
270+ t.Fatal(err)
271+ }
272+ _ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}})
273+ _ = parent.Close()
274+
275+ branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
276+ if err != nil {
277+ t.Fatalf("BranchSession: %v", err)
278+ }
279+ branch, _, err := OpenSession(branchPath)
280+ if err != nil {
281+ t.Fatal(err)
282+ }
283+ _ = branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}})
284+ _ = branch.AppendMessage(provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "fine"}}})
285+ _ = branch.Close()
286+
287+ if summary := describeSession(branchPath); summary.Title != "how are you?" {
288+ t.Errorf("branch title: want %q, got %q", "how are you?", summary.Title)
289+ }
290+}
291+
292+func TestRenamedBranchKeepsExplicitTitle(t *testing.T) {
293+ root := t.TempDir()
294+ cwd := "/project"
295+ parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
296+ if err != nil {
297+ t.Fatal(err)
298+ }
299+ _ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}})
300+ _ = parent.Close()
301+
302+ branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
303+ if err != nil {
304+ t.Fatalf("BranchSession: %v", err)
305+ }
306+ branch, _, err := OpenSession(branchPath)
307+ if err != nil {
308+ t.Fatal(err)
309+ }
310+ _ = branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}})
311+ _ = branch.Close()
312+ if err := RenameSession(branchPath, "friendly branch name"); err != nil {
313+ t.Fatal(err)
314+ }
315+
316+ if summary := describeSession(branchPath); summary.Title != "friendly branch name" {
317+ t.Errorf("branch title: want %q, got %q", "friendly branch name", summary.Title)
318+ }
232 }319 }
233 320
234 func TestBranchSessionUsesEffectiveTranscriptAfterCompaction(t *testing.T) {321 func TestBranchSessionUsesEffectiveTranscriptAfterCompaction(t *testing.T) {

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