detached 7794a25

feat(session): /session fork + /session tree

Branch semantics for conversations: rewind to a past user message
and continue from there in a new session, with a visual tree
picker to switch between branches later.

/session fork
  Opens the /jump turn picker in fork mode. Pick any past user
  message; zot copies every message from the session start up to
  and including that turn into a new session file, records the
  parent id + fork point in the new meta, and swaps the running
  agent onto the new branch. The parent session file stays on
  disk unchanged; you can return to it later via /session tree.

/session tree
  Shows every session in the current cwd arranged by parent/child
  relationships. Depth-first flatten with two-space indent per
  level; the current session is tagged "[current]". Pick any
  other entry to switch into it (same semantics as /sessions).

Why both commands:
  /sessions remains the "flat list of everything in this
  directory" resume picker. /session tree is the fork-aware
  variant. /session fork is the equivalent of git branch; /session
  tree is the equivalent of checkout.

core additions:

  SessionMeta gains two fields:
    - Parent    string  (parent session ID, empty for roots)
    - ForkPoint int     (0-indexed message position of the cut)

  core.BranchSession(parentPath, root, cwd, version, upToIdx)
    Reads the parent session, writes a new session file in
    SessionsDir(root, cwd) containing the first upToIdx message
    rows + any usage rows that came before the cut. The new meta
    records Parent=<parent id>, ForkPoint=<upToIdx>, fresh id,
    cwd, Started, Version.

  core.BuildSessionTree(root, cwd) []*TreeNode
    Walks every session file in the cwd dir, reads each one's
    meta, links children to parents by ID. Returns the forest
    rooted at parentless sessions. Missing-parent sessions (if
    the parent file was manually deleted) surface as roots so
    they stay discoverable.

  core.FindSessionByID(root, cwd, id) string
    O(n) lookup used when resolving a tree pick back to a file
    path. Files in the dir are small in practice.

  readSessionMeta helper (unexported) reads just the first line
    of a session file and decodes the meta; avoids loading the
    whole transcript when BuildSessionTree only needs the
    parent/id pair.

tui additions:

  session_tree_dialog.go
    Flat list with indent-based nesting to match the other
    picker dialogs' shape. Up/down moves; enter switches; esc
    cancels. Rows show "<relative-when> <prompt-preview> N msgs"
    with a muted "[current]" tag on the current session.

  interactive.go
    - sessionTreeDialog field + constructor.
    - /session fork / /session tree cases in doSessionOp.
    - doSessionFork flips pendingFork=true and opens the
      jumpDialog over the agent's current messages.
    - The jump-dialog key handler checks pendingFork; if set,
      routes the selection to applyForkSelection instead of the
      normal applyJumpSelection. pendingFork clears on select
      OR on dismiss so a later plain /jump isn't hijacked.
    - applyForkSelection calls FlushSession (so the branch gets
      everything in memory, not just what was lazy-flushed),
      then core.BranchSession, then LoadSession to swap.
    - doSessionTree calls FlushSession first so the tree shows
      the true current message count, then
      core.BuildSessionTree, then hands the forest to the tree
      dialog.
    - applySessionTreeSelection hands the picked path to
      LoadSession.

tests:

  TestBranchSessionCopiesPrefix
    Parent with three messages; branch at upToIdx=2; verify the
    child has exactly 2 messages, parent ID matches, fork point
    = 2, ID rotated.

  TestBuildSessionTree
    Parent + 2 branches off it; verify roots=[parent],
    roots[0].Children has both branches.

README: /session row expanded to cover all four ops.
commit
7794a253b9f42648dba4bc5885388305cfd65aa7
author
patriceckhart · · 2026-04-20 09:10 UTC
parent
9b063d8
signature
unsigned
refs
tag: v0.0.59
6 files · +716 -45
M README.md +1 -1
@@ -190,7 +190,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands:
190190 | `/logout [provider]` | Clear credentials for `anthropic`, `openai`, or all when omitted. |
191191 | `/model` | Pick a model from a list (or `/model <id>` to set directly). |
192192 | `/sessions` | Resume a previous session for this directory. |
193-| `/session` | Export the current session to a portable `.zotsession` file, or import one. Opens a picker without an argument; direct form: `/session export [path]` / `/session import <path>`. Default export destination is `~/Downloads`. |
193+| `/session` | Four ops on the current session: `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. |
194194 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |
195195 | `/btw` | Side chat with full context that doesn't add to the main thread. |
196196 | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. |
@@ -190,7 +190,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands:
190 | `/logout [provider]` | Clear credentials for `anthropic`, `openai`, or all when omitted. |190 | `/logout [provider]` | Clear credentials for `anthropic`, `openai`, or all when omitted. |
191 | `/model` | Pick a model from a list (or `/model <id>` to set directly). |191 | `/model` | Pick a model from a list (or `/model <id>` to set directly). |
192 | `/sessions` | Resume a previous session for this directory. |192 | `/sessions` | Resume a previous session for this directory. |
193-| `/session` | Export the current session to a portable `.zotsession` file, or import one. Opens a picker without an argument; direct form: `/session export [path]` / `/session import <path>`. Default export destination is `~/Downloads`. |193+| `/session` | Four ops on the current session: `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. |
194 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |194 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |
195 | `/btw` | Side chat with full context that doesn't add to the main thread. |195 | `/btw` | Side chat with full context that doesn't add to the main thread. |
196 | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. |196 | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. |
M internal/agent/modes/interactive.go +234 -44
@@ -189,20 +189,27 @@ type Interactive struct {
189189 // while the check hasn't completed or nothing is available.
190190 updateInfo UpdateInfo
191191
192- dialog *loginDialog
193- modelDialog *modelDialog
194- sessionDialog *sessionDialog
195- jumpDialog *jumpDialog
196- btwDialog *btwDialog
197- skillsDialog *skillsDialog
198- changelogDialog *changelogDialog
199- confirmDialog *confirmDialog
200- logoutDialog *logoutDialog
201- telegramDialog *telegramDialog
202- telegramBridge *telegram.Bridge
203- sessionOpsDialog *sessionOpsDialog
204- suggest *slashSuggester
205- spin *spinner
192+ dialog *loginDialog
193+ modelDialog *modelDialog
194+ sessionDialog *sessionDialog
195+ jumpDialog *jumpDialog
196+ btwDialog *btwDialog
197+ skillsDialog *skillsDialog
198+ changelogDialog *changelogDialog
199+ confirmDialog *confirmDialog
200+ logoutDialog *logoutDialog
201+ telegramDialog *telegramDialog
202+ telegramBridge *telegram.Bridge
203+ sessionOpsDialog *sessionOpsDialog
204+ sessionTreeDialog *sessionTreeDialog
205+
206+ // pendingFork is true when the user ran /session fork: the next
207+ // jump-picker selection should branch off that message instead
208+ // of scrolling. Flag resets after the action fires or the dialog
209+ // is dismissed, so repeated /jump calls don't turn into forks.
210+ pendingFork bool
211+ suggest *slashSuggester
212+ spin *spinner
206213
207214 // parkedTurn is the 1-based turn number the viewport is currently
208215 // scrolled to by /jump. 0 = not parked, showing the tail as usual.
@@ -240,23 +247,24 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
240247 Theme: cfg.Theme,
241248 ImageProto: tui.DetectImageProtocol(),
242249 },
243- ed: tui.NewEditor(cfg.Theme.FG256(cfg.Theme.Accent, "▌ ")),
244- rend: tui.NewRenderer(cfg.Terminal),
245- toolCalls: map[string]*tui.ToolCallView{},
246- dirty: make(chan struct{}, 8),
247- dialog: newLoginDialog(),
248- modelDialog: newModelDialog(),
249- sessionDialog: newSessionDialog(),
250- jumpDialog: newJumpDialog(),
251- btwDialog: newBtwDialog(),
252- skillsDialog: newSkillsDialog(),
253- changelogDialog: newChangelogDialog(),
254- confirmDialog: newConfirmDialog(),
255- logoutDialog: newLogoutDialog(),
256- telegramDialog: newTelegramDialog(),
257- sessionOpsDialog: newSessionOpsDialog(),
258- suggest: newSlashSuggester(),
259- spin: newSpinner(),
250+ ed: tui.NewEditor(cfg.Theme.FG256(cfg.Theme.Accent, "▌ ")),
251+ rend: tui.NewRenderer(cfg.Terminal),
252+ toolCalls: map[string]*tui.ToolCallView{},
253+ dirty: make(chan struct{}, 8),
254+ dialog: newLoginDialog(),
255+ modelDialog: newModelDialog(),
256+ sessionDialog: newSessionDialog(),
257+ jumpDialog: newJumpDialog(),
258+ btwDialog: newBtwDialog(),
259+ skillsDialog: newSkillsDialog(),
260+ changelogDialog: newChangelogDialog(),
261+ confirmDialog: newConfirmDialog(),
262+ logoutDialog: newLogoutDialog(),
263+ telegramDialog: newTelegramDialog(),
264+ sessionOpsDialog: newSessionOpsDialog(),
265+ sessionTreeDialog: newSessionTreeDialog(),
266+ suggest: newSlashSuggester(),
267+ spin: newSpinner(),
260268 }
261269 if cfg.Agent != nil {
262270 i.agent = cfg.Agent
@@ -449,7 +457,7 @@ func (i *Interactive) Run(ctx context.Context) error {
449457 // and the AfterFunc-driven invalidate got dropped on a
450458 // full channel.
451459 drainPending()
452- if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() || i.logoutDialog.Active() || i.telegramDialog.Active() || i.sessionOpsDialog.Active() {
460+ if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() || i.logoutDialog.Active() || i.telegramDialog.Active() || i.sessionOpsDialog.Active() || i.sessionTreeDialog.Active() {
453461 requestRedraw() // keep the spinner / dialog animation moving
454462 }
455463 }
@@ -615,6 +623,8 @@ func (i *Interactive) redraw() {
615623 dialog = i.telegramDialog.Render(i.cfg.Theme, cols)
616624 case i.sessionOpsDialog.Active():
617625 dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)
626+ case i.sessionTreeDialog.Active():
627+ dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols)
618628 }
619629
620630 // Slash-command autocomplete: popup above the status line, only
@@ -905,14 +915,38 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
905915 i.invalidate()
906916 return false
907917 }
918+ if i.sessionTreeDialog.Active() {
919+ if k.Kind == tui.KeyCtrlC {
920+ i.sessionTreeDialog.Close()
921+ i.invalidate()
922+ return false
923+ }
924+ act := i.sessionTreeDialog.HandleKey(k)
925+ if act.Select {
926+ i.applySessionTreeSelection(act.Path)
927+ }
928+ i.invalidate()
929+ return false
930+ }
908931 if i.jumpDialog.Active() {
909932 if k.Kind == tui.KeyCtrlC {
910933 i.jumpDialog.Close()
934+ i.pendingFork = false
911935 return false
912936 }
913937 act := i.jumpDialog.HandleKey(k)
914938 if act.Select {
915- i.applyJumpSelection(act.MessageIdx, act.TurnNo)
939+ if i.pendingFork {
940+ i.applyForkSelection(act.MessageIdx)
941+ } else {
942+ i.applyJumpSelection(act.MessageIdx, act.TurnNo)
943+ }
944+ }
945+ // If the user dismissed the dialog without selecting, also
946+ // clear the pending-fork flag so a later plain /jump isn't
947+ // hijacked.
948+ if act.Close {
949+ i.pendingFork = false
916950 }
917951 return false
918952 }
@@ -2524,31 +2558,36 @@ func (h *telegramHost) Notify(level, message string) {
25242558 }
25252559
25262560 // openSessionOpsDialog shows the picker for `/session` with no arg.
2527-// Always offers both export and import; the handlers bail with a
2528-// clear status message when the precondition isn't met (empty
2529-// transcript on export; missing file on import).
2561+// Always offers export, import, fork, tree; the handlers bail with
2562+// a clear status message when the precondition isn't met (empty
2563+// transcript on fork; no parent/siblings on tree).
25302564 func (i *Interactive) openSessionOpsDialog() {
25312565 items := []sessionOpsItem{
25322566 {label: "export", action: "export", hint: "write the current session to a .zotsession file"},
25332567 {label: "import", action: "import", hint: "load a .zotsession file into this directory"},
2568+ {label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
2569+ {label: "tree", action: "tree", hint: "switch between branches in this directory"},
25342570 }
25352571 i.sessionOpsDialog.Open(items)
25362572 i.invalidate()
25372573 }
25382574
2539-// doSessionOp dispatches export or import. path is the optional
2540-// positional argument from /session export <path> or /session
2541-// import <path>; when empty, sensible defaults apply (see
2542-// doSessionExport / doSessionImport).
2543-func (i *Interactive) doSessionOp(action, path string) {
2575+// doSessionOp dispatches export, import, fork, or tree. arg is the
2576+// optional positional argument from e.g. /session export <path>
2577+// or /session import <path>; fork and tree ignore it.
2578+func (i *Interactive) doSessionOp(action, arg string) {
25442579 switch action {
25452580 case "export":
2546- i.doSessionExport(path)
2581+ i.doSessionExport(arg)
25472582 case "import":
2548- i.doSessionImport(path)
2583+ i.doSessionImport(arg)
2584+ case "fork":
2585+ i.doSessionFork()
2586+ case "tree":
2587+ i.doSessionTree()
25492588 default:
25502589 i.mu.Lock()
2551- i.statusErr = "unknown /session action: " + action + " (use export or import)"
2590+ i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)"
25522591 i.mu.Unlock()
25532592 i.invalidate()
25542593 }
@@ -2716,3 +2755,154 @@ func friendlyPath(p string) string {
27162755 }
27172756 return p
27182757 }
2758+
2759+// doSessionFork opens the /jump turn picker in "fork mode". The
2760+// next selection branches the current session at that user turn
2761+// instead of scrolling the viewport to it.
2762+func (i *Interactive) doSessionFork() {
2763+ if i.cfg.CurrentSessionPath == nil || i.cfg.CurrentSessionPath() == "" {
2764+ i.mu.Lock()
2765+ i.statusErr = "fork: no session is active (running with --no-session?)"
2766+ i.mu.Unlock()
2767+ i.invalidate()
2768+ return
2769+ }
2770+ msgs := []provider.Message{}
2771+ if i.agent != nil {
2772+ msgs = i.agent.Messages()
2773+ }
2774+ if len(msgs) == 0 {
2775+ i.mu.Lock()
2776+ i.statusErr = "fork: transcript is empty; nothing to fork from"
2777+ i.mu.Unlock()
2778+ i.invalidate()
2779+ return
2780+ }
2781+ i.pendingFork = true
2782+ i.jumpDialog.Open(msgs, "")
2783+ i.invalidate()
2784+}
2785+
2786+// doSessionTree shows the branch topology picker for this cwd.
2787+// Pick an entry to switch into it (same semantics as /sessions
2788+// picking a past session, but with the parent/child indentation).
2789+func (i *Interactive) doSessionTree() {
2790+ if i.cfg.ZotHome == "" || i.cfg.CWD == "" {
2791+ i.mu.Lock()
2792+ i.statusErr = "tree: session storage not configured"
2793+ i.mu.Unlock()
2794+ i.invalidate()
2795+ return
2796+ }
2797+ // Flush the running agent's transcript first so its message
2798+ // count + latest preview are accurate in the tree.
2799+ if i.cfg.FlushSession != nil {
2800+ i.cfg.FlushSession()
2801+ }
2802+ roots := core.BuildSessionTree(i.cfg.ZotHome, i.cfg.CWD)
2803+ if len(roots) == 0 {
2804+ i.mu.Lock()
2805+ i.statusErr = "tree: no sessions in this directory yet"
2806+ i.mu.Unlock()
2807+ i.invalidate()
2808+ return
2809+ }
2810+ current := ""
2811+ if i.cfg.CurrentSessionPath != nil {
2812+ current = i.cfg.CurrentSessionPath()
2813+ }
2814+ if !i.sessionTreeDialog.Open(roots, current) {
2815+ i.mu.Lock()
2816+ i.statusErr = "tree: no branches to show"
2817+ i.mu.Unlock()
2818+ }
2819+ i.invalidate()
2820+}
2821+
2822+// applySessionTreeSelection switches the running agent to the
2823+// session file at path. Thin wrapper around LoadSession that also
2824+// writes a status line.
2825+func (i *Interactive) applySessionTreeSelection(path string) {
2826+ if i.cfg.LoadSession == nil {
2827+ i.mu.Lock()
2828+ i.statusErr = "tree: session swap not available in this build"
2829+ i.mu.Unlock()
2830+ i.invalidate()
2831+ return
2832+ }
2833+ if err := i.cfg.LoadSession(path); err != nil {
2834+ i.mu.Lock()
2835+ i.statusErr = "tree: load failed: " + err.Error()
2836+ i.mu.Unlock()
2837+ i.invalidate()
2838+ return
2839+ }
2840+ i.mu.Lock()
2841+ i.statusOK = "switched to branch " + friendlyPath(path)
2842+ i.statusErr = ""
2843+ i.mu.Unlock()
2844+ i.invalidate()
2845+}
2846+
2847+// applyForkSelection branches the current session at msgIdx+1 (so
2848+// the selected user message and everything before it is included
2849+// in the new branch), then switches the running agent to the new
2850+// file. Called from the jump-dialog handler when pendingFork=true.
2851+func (i *Interactive) applyForkSelection(msgIdx int) {
2852+ i.pendingFork = false
2853+ if i.cfg.CurrentSessionPath == nil {
2854+ i.mu.Lock()
2855+ i.statusErr = "fork: no session is active"
2856+ i.mu.Unlock()
2857+ i.invalidate()
2858+ return
2859+ }
2860+ src := i.cfg.CurrentSessionPath()
2861+ if src == "" {
2862+ i.mu.Lock()
2863+ i.statusErr = "fork: no session is active"
2864+ i.mu.Unlock()
2865+ i.invalidate()
2866+ return
2867+ }
2868+ if i.cfg.FlushSession != nil {
2869+ i.cfg.FlushSession()
2870+ }
2871+ // msgIdx is 0-indexed message position; copy msgIdx+1 rows so
2872+ // the selected user message is included.
2873+ upTo := msgIdx + 1
2874+ newPath, err := core.BranchSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version, upTo)
2875+ if err != nil {
2876+ i.mu.Lock()
2877+ i.statusErr = "fork: " + err.Error()
2878+ i.mu.Unlock()
2879+ i.invalidate()
2880+ return
2881+ }
2882+ if i.cfg.LoadSession == nil {
2883+ i.mu.Lock()
2884+ i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)"
2885+ i.statusErr = ""
2886+ i.mu.Unlock()
2887+ i.invalidate()
2888+ return
2889+ }
2890+ if err := i.cfg.LoadSession(newPath); err != nil {
2891+ i.mu.Lock()
2892+ i.statusErr = "fork: switch failed: " + err.Error()
2893+ i.mu.Unlock()
2894+ i.invalidate()
2895+ return
2896+ }
2897+ i.mu.Lock()
2898+ i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
2899+ i.statusErr = ""
2900+ i.mu.Unlock()
2901+ i.invalidate()
2902+}
2903+
2904+// formatInt is a tiny strconv.Itoa shim; keeps the handler above
2905+// from needing a strconv import just for one call.
2906+func formatInt(n int) string {
2907+ return fmt.Sprintf("%d", n)
2908+}
@@ -189,20 +189,27 @@ type Interactive struct {
189 // while the check hasn't completed or nothing is available.189 // while the check hasn't completed or nothing is available.
190 updateInfo UpdateInfo190 updateInfo UpdateInfo
191 191
192- dialog *loginDialog192+ dialog *loginDialog
193- modelDialog *modelDialog193+ modelDialog *modelDialog
194- sessionDialog *sessionDialog194+ sessionDialog *sessionDialog
195- jumpDialog *jumpDialog195+ jumpDialog *jumpDialog
196- btwDialog *btwDialog196+ btwDialog *btwDialog
197- skillsDialog *skillsDialog197+ skillsDialog *skillsDialog
198- changelogDialog *changelogDialog198+ changelogDialog *changelogDialog
199- confirmDialog *confirmDialog199+ confirmDialog *confirmDialog
200- logoutDialog *logoutDialog200+ logoutDialog *logoutDialog
201- telegramDialog *telegramDialog201+ telegramDialog *telegramDialog
202- telegramBridge *telegram.Bridge202+ telegramBridge *telegram.Bridge
203- sessionOpsDialog *sessionOpsDialog203+ sessionOpsDialog *sessionOpsDialog
204- suggest *slashSuggester204+ sessionTreeDialog *sessionTreeDialog
205- spin *spinner205+
206+ // pendingFork is true when the user ran /session fork: the next
207+ // jump-picker selection should branch off that message instead
208+ // of scrolling. Flag resets after the action fires or the dialog
209+ // is dismissed, so repeated /jump calls don't turn into forks.
210+ pendingFork bool
211+ suggest *slashSuggester
212+ spin *spinner
206 213
207 // parkedTurn is the 1-based turn number the viewport is currently214 // parkedTurn is the 1-based turn number the viewport is currently
208 // scrolled to by /jump. 0 = not parked, showing the tail as usual.215 // scrolled to by /jump. 0 = not parked, showing the tail as usual.
@@ -240,23 +247,24 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
240 Theme: cfg.Theme,247 Theme: cfg.Theme,
241 ImageProto: tui.DetectImageProtocol(),248 ImageProto: tui.DetectImageProtocol(),
242 },249 },
243- ed: tui.NewEditor(cfg.Theme.FG256(cfg.Theme.Accent, "▌ ")),250+ ed: tui.NewEditor(cfg.Theme.FG256(cfg.Theme.Accent, "▌ ")),
244- rend: tui.NewRenderer(cfg.Terminal),251+ rend: tui.NewRenderer(cfg.Terminal),
245- toolCalls: map[string]*tui.ToolCallView{},252+ toolCalls: map[string]*tui.ToolCallView{},
246- dirty: make(chan struct{}, 8),253+ dirty: make(chan struct{}, 8),
247- dialog: newLoginDialog(),254+ dialog: newLoginDialog(),
248- modelDialog: newModelDialog(),255+ modelDialog: newModelDialog(),
249- sessionDialog: newSessionDialog(),256+ sessionDialog: newSessionDialog(),
250- jumpDialog: newJumpDialog(),257+ jumpDialog: newJumpDialog(),
251- btwDialog: newBtwDialog(),258+ btwDialog: newBtwDialog(),
252- skillsDialog: newSkillsDialog(),259+ skillsDialog: newSkillsDialog(),
253- changelogDialog: newChangelogDialog(),260+ changelogDialog: newChangelogDialog(),
254- confirmDialog: newConfirmDialog(),261+ confirmDialog: newConfirmDialog(),
255- logoutDialog: newLogoutDialog(),262+ logoutDialog: newLogoutDialog(),
256- telegramDialog: newTelegramDialog(),263+ telegramDialog: newTelegramDialog(),
257- sessionOpsDialog: newSessionOpsDialog(),264+ sessionOpsDialog: newSessionOpsDialog(),
258- suggest: newSlashSuggester(),265+ sessionTreeDialog: newSessionTreeDialog(),
259- spin: newSpinner(),266+ suggest: newSlashSuggester(),
267+ spin: newSpinner(),
260 }268 }
261 if cfg.Agent != nil {269 if cfg.Agent != nil {
262 i.agent = cfg.Agent270 i.agent = cfg.Agent
@@ -449,7 +457,7 @@ func (i *Interactive) Run(ctx context.Context) error {
449 // and the AfterFunc-driven invalidate got dropped on a457 // and the AfterFunc-driven invalidate got dropped on a
450 // full channel.458 // full channel.
451 drainPending()459 drainPending()
452- if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() || i.logoutDialog.Active() || i.telegramDialog.Active() || i.sessionOpsDialog.Active() {460+ if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() || i.logoutDialog.Active() || i.telegramDialog.Active() || i.sessionOpsDialog.Active() || i.sessionTreeDialog.Active() {
453 requestRedraw() // keep the spinner / dialog animation moving461 requestRedraw() // keep the spinner / dialog animation moving
454 }462 }
455 }463 }
@@ -615,6 +623,8 @@ func (i *Interactive) redraw() {
615 dialog = i.telegramDialog.Render(i.cfg.Theme, cols)623 dialog = i.telegramDialog.Render(i.cfg.Theme, cols)
616 case i.sessionOpsDialog.Active():624 case i.sessionOpsDialog.Active():
617 dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)625 dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)
626+ case i.sessionTreeDialog.Active():
627+ dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols)
618 }628 }
619 629
620 // Slash-command autocomplete: popup above the status line, only630 // Slash-command autocomplete: popup above the status line, only
@@ -905,14 +915,38 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
905 i.invalidate()915 i.invalidate()
906 return false916 return false
907 }917 }
918+ if i.sessionTreeDialog.Active() {
919+ if k.Kind == tui.KeyCtrlC {
920+ i.sessionTreeDialog.Close()
921+ i.invalidate()
922+ return false
923+ }
924+ act := i.sessionTreeDialog.HandleKey(k)
925+ if act.Select {
926+ i.applySessionTreeSelection(act.Path)
927+ }
928+ i.invalidate()
929+ return false
930+ }
908 if i.jumpDialog.Active() {931 if i.jumpDialog.Active() {
909 if k.Kind == tui.KeyCtrlC {932 if k.Kind == tui.KeyCtrlC {
910 i.jumpDialog.Close()933 i.jumpDialog.Close()
934+ i.pendingFork = false
911 return false935 return false
912 }936 }
913 act := i.jumpDialog.HandleKey(k)937 act := i.jumpDialog.HandleKey(k)
914 if act.Select {938 if act.Select {
915- i.applyJumpSelection(act.MessageIdx, act.TurnNo)939+ if i.pendingFork {
940+ i.applyForkSelection(act.MessageIdx)
941+ } else {
942+ i.applyJumpSelection(act.MessageIdx, act.TurnNo)
943+ }
944+ }
945+ // If the user dismissed the dialog without selecting, also
946+ // clear the pending-fork flag so a later plain /jump isn't
947+ // hijacked.
948+ if act.Close {
949+ i.pendingFork = false
916 }950 }
917 return false951 return false
918 }952 }
@@ -2524,31 +2558,36 @@ func (h *telegramHost) Notify(level, message string) {
2524 }2558 }
2525 2559
2526 // openSessionOpsDialog shows the picker for `/session` with no arg.2560 // openSessionOpsDialog shows the picker for `/session` with no arg.
2527-// Always offers both export and import; the handlers bail with a2561+// Always offers export, import, fork, tree; the handlers bail with
2528-// clear status message when the precondition isn't met (empty2562+// a clear status message when the precondition isn't met (empty
2529-// transcript on export; missing file on import).2563+// transcript on fork; no parent/siblings on tree).
2530 func (i *Interactive) openSessionOpsDialog() {2564 func (i *Interactive) openSessionOpsDialog() {
2531 items := []sessionOpsItem{2565 items := []sessionOpsItem{
2532 {label: "export", action: "export", hint: "write the current session to a .zotsession file"},2566 {label: "export", action: "export", hint: "write the current session to a .zotsession file"},
2533 {label: "import", action: "import", hint: "load a .zotsession file into this directory"},2567 {label: "import", action: "import", hint: "load a .zotsession file into this directory"},
2568+ {label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
2569+ {label: "tree", action: "tree", hint: "switch between branches in this directory"},
2534 }2570 }
2535 i.sessionOpsDialog.Open(items)2571 i.sessionOpsDialog.Open(items)
2536 i.invalidate()2572 i.invalidate()
2537 }2573 }
2538 2574
2539-// doSessionOp dispatches export or import. path is the optional2575+// doSessionOp dispatches export, import, fork, or tree. arg is the
2540-// positional argument from /session export <path> or /session2576+// optional positional argument from e.g. /session export <path>
2541-// import <path>; when empty, sensible defaults apply (see2577+// or /session import <path>; fork and tree ignore it.
2542-// doSessionExport / doSessionImport).2578+func (i *Interactive) doSessionOp(action, arg string) {
2543-func (i *Interactive) doSessionOp(action, path string) {
2544 switch action {2579 switch action {
2545 case "export":2580 case "export":
2546- i.doSessionExport(path)2581+ i.doSessionExport(arg)
2547 case "import":2582 case "import":
2548- i.doSessionImport(path)2583+ i.doSessionImport(arg)
2584+ case "fork":
2585+ i.doSessionFork()
2586+ case "tree":
2587+ i.doSessionTree()
2549 default:2588 default:
2550 i.mu.Lock()2589 i.mu.Lock()
2551- i.statusErr = "unknown /session action: " + action + " (use export or import)"2590+ i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)"
2552 i.mu.Unlock()2591 i.mu.Unlock()
2553 i.invalidate()2592 i.invalidate()
2554 }2593 }
@@ -2716,3 +2755,154 @@ func friendlyPath(p string) string {
2716 }2755 }
2717 return p2756 return p
2718 }2757 }
2758+
2759+// doSessionFork opens the /jump turn picker in "fork mode". The
2760+// next selection branches the current session at that user turn
2761+// instead of scrolling the viewport to it.
2762+func (i *Interactive) doSessionFork() {
2763+ if i.cfg.CurrentSessionPath == nil || i.cfg.CurrentSessionPath() == "" {
2764+ i.mu.Lock()
2765+ i.statusErr = "fork: no session is active (running with --no-session?)"
2766+ i.mu.Unlock()
2767+ i.invalidate()
2768+ return
2769+ }
2770+ msgs := []provider.Message{}
2771+ if i.agent != nil {
2772+ msgs = i.agent.Messages()
2773+ }
2774+ if len(msgs) == 0 {
2775+ i.mu.Lock()
2776+ i.statusErr = "fork: transcript is empty; nothing to fork from"
2777+ i.mu.Unlock()
2778+ i.invalidate()
2779+ return
2780+ }
2781+ i.pendingFork = true
2782+ i.jumpDialog.Open(msgs, "")
2783+ i.invalidate()
2784+}
2785+
2786+// doSessionTree shows the branch topology picker for this cwd.
2787+// Pick an entry to switch into it (same semantics as /sessions
2788+// picking a past session, but with the parent/child indentation).
2789+func (i *Interactive) doSessionTree() {
2790+ if i.cfg.ZotHome == "" || i.cfg.CWD == "" {
2791+ i.mu.Lock()
2792+ i.statusErr = "tree: session storage not configured"
2793+ i.mu.Unlock()
2794+ i.invalidate()
2795+ return
2796+ }
2797+ // Flush the running agent's transcript first so its message
2798+ // count + latest preview are accurate in the tree.
2799+ if i.cfg.FlushSession != nil {
2800+ i.cfg.FlushSession()
2801+ }
2802+ roots := core.BuildSessionTree(i.cfg.ZotHome, i.cfg.CWD)
2803+ if len(roots) == 0 {
2804+ i.mu.Lock()
2805+ i.statusErr = "tree: no sessions in this directory yet"
2806+ i.mu.Unlock()
2807+ i.invalidate()
2808+ return
2809+ }
2810+ current := ""
2811+ if i.cfg.CurrentSessionPath != nil {
2812+ current = i.cfg.CurrentSessionPath()
2813+ }
2814+ if !i.sessionTreeDialog.Open(roots, current) {
2815+ i.mu.Lock()
2816+ i.statusErr = "tree: no branches to show"
2817+ i.mu.Unlock()
2818+ }
2819+ i.invalidate()
2820+}
2821+
2822+// applySessionTreeSelection switches the running agent to the
2823+// session file at path. Thin wrapper around LoadSession that also
2824+// writes a status line.
2825+func (i *Interactive) applySessionTreeSelection(path string) {
2826+ if i.cfg.LoadSession == nil {
2827+ i.mu.Lock()
2828+ i.statusErr = "tree: session swap not available in this build"
2829+ i.mu.Unlock()
2830+ i.invalidate()
2831+ return
2832+ }
2833+ if err := i.cfg.LoadSession(path); err != nil {
2834+ i.mu.Lock()
2835+ i.statusErr = "tree: load failed: " + err.Error()
2836+ i.mu.Unlock()
2837+ i.invalidate()
2838+ return
2839+ }
2840+ i.mu.Lock()
2841+ i.statusOK = "switched to branch " + friendlyPath(path)
2842+ i.statusErr = ""
2843+ i.mu.Unlock()
2844+ i.invalidate()
2845+}
2846+
2847+// applyForkSelection branches the current session at msgIdx+1 (so
2848+// the selected user message and everything before it is included
2849+// in the new branch), then switches the running agent to the new
2850+// file. Called from the jump-dialog handler when pendingFork=true.
2851+func (i *Interactive) applyForkSelection(msgIdx int) {
2852+ i.pendingFork = false
2853+ if i.cfg.CurrentSessionPath == nil {
2854+ i.mu.Lock()
2855+ i.statusErr = "fork: no session is active"
2856+ i.mu.Unlock()
2857+ i.invalidate()
2858+ return
2859+ }
2860+ src := i.cfg.CurrentSessionPath()
2861+ if src == "" {
2862+ i.mu.Lock()
2863+ i.statusErr = "fork: no session is active"
2864+ i.mu.Unlock()
2865+ i.invalidate()
2866+ return
2867+ }
2868+ if i.cfg.FlushSession != nil {
2869+ i.cfg.FlushSession()
2870+ }
2871+ // msgIdx is 0-indexed message position; copy msgIdx+1 rows so
2872+ // the selected user message is included.
2873+ upTo := msgIdx + 1
2874+ newPath, err := core.BranchSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version, upTo)
2875+ if err != nil {
2876+ i.mu.Lock()
2877+ i.statusErr = "fork: " + err.Error()
2878+ i.mu.Unlock()
2879+ i.invalidate()
2880+ return
2881+ }
2882+ if i.cfg.LoadSession == nil {
2883+ i.mu.Lock()
2884+ i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)"
2885+ i.statusErr = ""
2886+ i.mu.Unlock()
2887+ i.invalidate()
2888+ return
2889+ }
2890+ if err := i.cfg.LoadSession(newPath); err != nil {
2891+ i.mu.Lock()
2892+ i.statusErr = "fork: switch failed: " + err.Error()
2893+ i.mu.Unlock()
2894+ i.invalidate()
2895+ return
2896+ }
2897+ i.mu.Lock()
2898+ i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
2899+ i.statusErr = ""
2900+ i.mu.Unlock()
2901+ i.invalidate()
2902+}
2903+
2904+// formatInt is a tiny strconv.Itoa shim; keeps the handler above
2905+// from needing a strconv import just for one call.
2906+func formatInt(n int) string {
2907+ return fmt.Sprintf("%d", n)
2908+}
A internal/agent/modes/session_tree_dialog.go +166 -0
@@ -0,0 +1,166 @@
1+package modes
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/patriceckhart/zot/internal/core"
8+ "github.com/patriceckhart/zot/internal/tui"
9+)
10+
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]".
18+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)
23+}
24+
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
32+}
33+
34+type sessionTreeAction struct {
35+ Select bool
36+ Path string
37+ Close bool
38+}
39+
40+func newSessionTreeDialog() *sessionTreeDialog { return &sessionTreeDialog{} }
41+
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 {
47+ return false
48+ }
49+ d.items = items
50+ d.current = currentPath
51+ d.cursor = indexOfCurrent(items, currentPath)
52+ d.active = true
53+ return true
54+}
55+
56+// Close hides the dialog.
57+func (d *sessionTreeDialog) Close() { d.active = false }
58+
59+// Active reports whether the dialog consumes input.
60+func (d *sessionTreeDialog) Active() bool { return d != nil && d.active }
61+
62+// HandleKey advances the cursor or resolves the selection.
63+func (d *sessionTreeDialog) HandleKey(k tui.Key) sessionTreeAction {
64+ switch k.Kind {
65+ case tui.KeyUp:
66+ if d.cursor > 0 {
67+ d.cursor--
68+ }
69+ case tui.KeyDown:
70+ if d.cursor < len(d.items)-1 {
71+ d.cursor++
72+ }
73+ case tui.KeyEsc:
74+ d.Close()
75+ return sessionTreeAction{Close: true}
76+ case tui.KeyEnter:
77+ if len(d.items) == 0 || d.cursor < 0 || d.cursor >= len(d.items) {
78+ d.Close()
79+ return sessionTreeAction{Close: true}
80+ }
81+ it := d.items[d.cursor]
82+ d.Close()
83+ if it.isCur {
84+ return sessionTreeAction{Close: true}
85+ }
86+ return sessionTreeAction{Select: true, Path: it.path}
87+ }
88+ return sessionTreeAction{}
89+}
90+
91+// Render returns the dialog lines.
92+func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string {
93+ if !d.Active() {
94+ return nil
95+ }
96+ var lines []string
97+ 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 {
100+ indent := strings.Repeat(" ", it.depth)
101+ label := " " + indent + it.label
102+ if it.isCur {
103+ label += " " + th.FG256(th.Muted, "[current]")
104+ }
105+ if i == d.cursor {
106+ lines = append(lines, th.PadHighlight(label, width))
107+ } else {
108+ lines = append(lines, th.FG256(th.Muted, label))
109+ }
110+ }
111+ lines = append(lines, frameRule(th, width))
112+ return lines
113+}
114+
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)
130+ }
131+ }
132+ for _, r := range roots {
133+ walk(r, 0)
134+ }
135+ return out
136+}
137+
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)"
149+ }
150+ }
151+ if len(preview) > 50 {
152+ preview = preview[:49] + "\u2026"
153+ }
154+ return fmt.Sprintf("%-14s %s %d msgs", when, preview, n.Summary.MessageCount)
155+}
156+
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 {
160+ for i, it := range items {
161+ if it.path == currentPath {
162+ return i
163+ }
164+ }
165+ return 0
166+}
@@ -0,0 +1,166 @@
1+package modes
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/patriceckhart/zot/internal/core"
8+ "github.com/patriceckhart/zot/internal/tui"
9+)
10+
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]".
18+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)
23+}
24+
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
32+}
33+
34+type sessionTreeAction struct {
35+ Select bool
36+ Path string
37+ Close bool
38+}
39+
40+func newSessionTreeDialog() *sessionTreeDialog { return &sessionTreeDialog{} }
41+
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 {
47+ return false
48+ }
49+ d.items = items
50+ d.current = currentPath
51+ d.cursor = indexOfCurrent(items, currentPath)
52+ d.active = true
53+ return true
54+}
55+
56+// Close hides the dialog.
57+func (d *sessionTreeDialog) Close() { d.active = false }
58+
59+// Active reports whether the dialog consumes input.
60+func (d *sessionTreeDialog) Active() bool { return d != nil && d.active }
61+
62+// HandleKey advances the cursor or resolves the selection.
63+func (d *sessionTreeDialog) HandleKey(k tui.Key) sessionTreeAction {
64+ switch k.Kind {
65+ case tui.KeyUp:
66+ if d.cursor > 0 {
67+ d.cursor--
68+ }
69+ case tui.KeyDown:
70+ if d.cursor < len(d.items)-1 {
71+ d.cursor++
72+ }
73+ case tui.KeyEsc:
74+ d.Close()
75+ return sessionTreeAction{Close: true}
76+ case tui.KeyEnter:
77+ if len(d.items) == 0 || d.cursor < 0 || d.cursor >= len(d.items) {
78+ d.Close()
79+ return sessionTreeAction{Close: true}
80+ }
81+ it := d.items[d.cursor]
82+ d.Close()
83+ if it.isCur {
84+ return sessionTreeAction{Close: true}
85+ }
86+ return sessionTreeAction{Select: true, Path: it.path}
87+ }
88+ return sessionTreeAction{}
89+}
90+
91+// Render returns the dialog lines.
92+func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string {
93+ if !d.Active() {
94+ return nil
95+ }
96+ var lines []string
97+ 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 {
100+ indent := strings.Repeat(" ", it.depth)
101+ label := " " + indent + it.label
102+ if it.isCur {
103+ label += " " + th.FG256(th.Muted, "[current]")
104+ }
105+ if i == d.cursor {
106+ lines = append(lines, th.PadHighlight(label, width))
107+ } else {
108+ lines = append(lines, th.FG256(th.Muted, label))
109+ }
110+ }
111+ lines = append(lines, frameRule(th, width))
112+ return lines
113+}
114+
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)
130+ }
131+ }
132+ for _, r := range roots {
133+ walk(r, 0)
134+ }
135+ return out
136+}
137+
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)"
149+ }
150+ }
151+ if len(preview) > 50 {
152+ preview = preview[:49] + "\u2026"
153+ }
154+ return fmt.Sprintf("%-14s %s %d msgs", when, preview, n.Summary.MessageCount)
155+}
156+
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 {
160+ for i, it := range items {
161+ if it.path == currentPath {
162+ return i
163+ }
164+ }
165+ return 0
166+}
M internal/core/session.go +11 -0
@@ -45,6 +45,17 @@ type SessionMeta struct {
4545 Provider string `json:"provider"`
4646 Started time.Time `json:"started"`
4747 Version string `json:"version"`
48+
49+ // Parent is the ID of the session this one was forked from, or
50+ // empty for top-level sessions. The tree picker walks parents
51+ // upward and sibling files (same cwd dir, same parent ID)
52+ // laterally to render the branch topology.
53+ Parent string `json:"parent,omitempty"`
54+ // ForkPoint is the 0-indexed message position within the parent
55+ // transcript where this branch diverges. Messages 0..ForkPoint-1
56+ // are copied from the parent verbatim; the user's next turn on
57+ // the child session continues from there.
58+ ForkPoint int `json:"fork_point,omitempty"`
4859 }
4960
5061 // sessionLine is the on-disk row type. Message is kept as a raw
@@ -45,6 +45,17 @@ type SessionMeta struct {
45 Provider string `json:"provider"`45 Provider string `json:"provider"`
46 Started time.Time `json:"started"`46 Started time.Time `json:"started"`
47 Version string `json:"version"`47 Version string `json:"version"`
48+
49+ // Parent is the ID of the session this one was forked from, or
50+ // empty for top-level sessions. The tree picker walks parents
51+ // upward and sibling files (same cwd dir, same parent ID)
52+ // laterally to render the branch topology.
53+ Parent string `json:"parent,omitempty"`
54+ // ForkPoint is the 0-indexed message position within the parent
55+ // transcript where this branch diverges. Messages 0..ForkPoint-1
56+ // are copied from the parent verbatim; the user's next turn on
57+ // the child session continues from there.
58+ ForkPoint int `json:"fork_point,omitempty"`
48 }59 }
49 60
50 // sessionLine is the on-disk row type. Message is kept as a raw61 // sessionLine is the on-disk row type. Message is kept as a raw
M internal/core/session_portable.go +218 -0
@@ -238,6 +238,224 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) {
238238 return outPath, nil
239239 }
240240
241+// BranchSession creates a new session in root/cwd that contains the
242+// parent's messages 0..upToMessageIdx-1 (i.e. the first N user+
243+// assistant+tool rows). The new meta records Parent=<parent id> and
244+// ForkPoint=N so /session tree can rebuild the branch topology
245+// later. All non-message rows (usage) are preserved up to the cut
246+// point so the running cost tracker stays accurate.
247+//
248+// upToMessageIdx is a count over the flat message stream as
249+// returned by OpenSession. To "branch at user turn 3" the caller
250+// passes the index of that user message in msgs + 1 (so the
251+// message itself is included). The caller figures that out; this
252+// helper just copies the first N message rows.
253+//
254+// Returns the path of the new session file, ready for OpenSession.
255+func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (string, error) {
256+ if parentPath == "" {
257+ return "", errors.New("branch: parent path is empty")
258+ }
259+ if upToMessageIdx < 0 {
260+ return "", errors.New("branch: upToMessageIdx must be >= 0")
261+ }
262+
263+ src, err := os.Open(parentPath)
264+ if err != nil {
265+ return "", fmt.Errorf("branch: open parent: %w", err)
266+ }
267+ defer src.Close()
268+
269+ // Read the parent meta so we can copy model/provider and record
270+ // the parent id on the child.
271+ sc := bufio.NewScanner(src)
272+ sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
273+ if !sc.Scan() {
274+ return "", errors.New("branch: parent session is empty")
275+ }
276+ var head sessionLine
277+ if err := json.Unmarshal(sc.Bytes(), &head); err != nil {
278+ return "", fmt.Errorf("branch: parse parent meta: %w", err)
279+ }
280+ if head.Type != "meta" || head.Meta == nil {
281+ return "", errors.New("branch: parent first line is not a meta row")
282+ }
283+ parentMeta := *head.Meta
284+
285+ // Build the destination file.
286+ dir := SessionsDir(root, cwd)
287+ if err := os.MkdirAll(dir, 0o755); err != nil {
288+ return "", err
289+ }
290+ newID := uuid.NewString()
291+ name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), newID[:8])
292+ outPath := filepath.Join(dir, name)
293+ dst, err := os.OpenFile(outPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
294+ if err != nil {
295+ return "", fmt.Errorf("branch: create dst: %w", err)
296+ }
297+ defer dst.Close()
298+ bw := bufio.NewWriter(dst)
299+
300+ // Write the branch meta.
301+ branchMeta := SessionMeta{
302+ ID: newID,
303+ CWD: cwd,
304+ Model: parentMeta.Model,
305+ Provider: parentMeta.Provider,
306+ Started: time.Now().UTC(),
307+ Version: version,
308+ Parent: parentMeta.ID,
309+ ForkPoint: upToMessageIdx,
310+ }
311+ metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta})
312+ if err != nil {
313+ return "", fmt.Errorf("branch: marshal meta: %w", err)
314+ }
315+ if _, err := bw.Write(metaLine); err != nil {
316+ return "", err
317+ }
318+ if err := bw.WriteByte('\n'); err != nil {
319+ return "", err
320+ }
321+
322+ // Copy message rows up to the cut point, plus all usage rows
323+ // that land before the cut (they describe the cost of those
324+ // messages).
325+ msgCount := 0
326+ for sc.Scan() && msgCount < upToMessageIdx {
327+ line := sc.Bytes()
328+ var h sessionLineHead
329+ if err := json.Unmarshal(line, &h); err != nil {
330+ continue
331+ }
332+ switch h.Type {
333+ case "message":
334+ if _, err := bw.Write(line); err != nil {
335+ return "", err
336+ }
337+ if err := bw.WriteByte('\n'); err != nil {
338+ return "", err
339+ }
340+ msgCount++
341+ case "usage":
342+ if _, err := bw.Write(line); err != nil {
343+ return "", err
344+ }
345+ if err := bw.WriteByte('\n'); err != nil {
346+ return "", err
347+ }
348+ // don't increment msgCount for usage rows
349+ }
350+ }
351+ if err := sc.Err(); err != nil {
352+ return "", fmt.Errorf("branch: read parent: %w", err)
353+ }
354+ if err := bw.Flush(); err != nil {
355+ return "", err
356+ }
357+ return outPath, nil
358+}
359+
360+// TreeNode is one entry in the branch tree returned by
361+// BuildSessionTree. Children are populated by linking on Parent ID.
362+type TreeNode struct {
363+ Summary SessionSummary
364+ Meta SessionMeta
365+ Children []*TreeNode
366+}
367+
368+// BuildSessionTree loads every session in the cwd dir and returns
369+// the forest rooted at parentless sessions, with each non-root
370+// session placed under its parent. Used by /session tree to render
371+// the branch hierarchy.
372+func BuildSessionTree(root, cwd string) []*TreeNode {
373+ dir := SessionsDir(root, cwd)
374+ entries, err := os.ReadDir(dir)
375+ if err != nil {
376+ return nil
377+ }
378+ nodes := make(map[string]*TreeNode)
379+ order := []string{}
380+ for _, e := range entries {
381+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
382+ continue
383+ }
384+ path := filepath.Join(dir, e.Name())
385+ summary := describeSession(path)
386+ meta, _ := readSessionMeta(path)
387+ if meta.ID == "" {
388+ continue
389+ }
390+ nodes[meta.ID] = &TreeNode{Summary: summary, Meta: meta}
391+ order = append(order, meta.ID)
392+ }
393+ var roots []*TreeNode
394+ for _, id := range order {
395+ n := nodes[id]
396+ if n.Meta.Parent == "" {
397+ roots = append(roots, n)
398+ continue
399+ }
400+ if parent, ok := nodes[n.Meta.Parent]; ok {
401+ parent.Children = append(parent.Children, n)
402+ } else {
403+ // Parent file missing (was manually deleted). Treat as
404+ // a root so it still shows up in the tree.
405+ roots = append(roots, n)
406+ }
407+ }
408+ return roots
409+}
410+
411+// readSessionMeta opens path, reads the meta row, and returns it.
412+// Empty SessionMeta when the file is missing or not a valid session.
413+func readSessionMeta(path string) (SessionMeta, error) {
414+ f, err := os.Open(path)
415+ if err != nil {
416+ return SessionMeta{}, err
417+ }
418+ defer f.Close()
419+ sc := bufio.NewScanner(f)
420+ sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
421+ if !sc.Scan() {
422+ return SessionMeta{}, errors.New("empty file")
423+ }
424+ var line sessionLine
425+ if err := json.Unmarshal(sc.Bytes(), &line); err != nil {
426+ return SessionMeta{}, err
427+ }
428+ if line.Type != "meta" || line.Meta == nil {
429+ return SessionMeta{}, errors.New("first line is not meta")
430+ }
431+ return *line.Meta, nil
432+}
433+
434+// FindSessionByID looks up a session file in root/cwd whose meta id
435+// matches. Used by /session tree when the user picks an entry. O(n)
436+// over the files in the dir; the list is small in practice.
437+func FindSessionByID(root, cwd, id string) string {
438+ dir := SessionsDir(root, cwd)
439+ entries, err := os.ReadDir(dir)
440+ if err != nil {
441+ return ""
442+ }
443+ for _, e := range entries {
444+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
445+ continue
446+ }
447+ path := filepath.Join(dir, e.Name())
448+ meta, err := readSessionMeta(path)
449+ if err != nil {
450+ continue
451+ }
452+ if meta.ID == id {
453+ return path
454+ }
455+ }
456+ return ""
457+}
458+
241459 // firstUserPrompt scans forward from the current scanner position
242460 // looking for the first user-role message and returns its text
243461 // (trimmed, short). Used to build a humane export filename.
@@ -238,6 +238,224 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) {
238 return outPath, nil238 return outPath, nil
239 }239 }
240 240
241+// BranchSession creates a new session in root/cwd that contains the
242+// parent's messages 0..upToMessageIdx-1 (i.e. the first N user+
243+// assistant+tool rows). The new meta records Parent=<parent id> and
244+// ForkPoint=N so /session tree can rebuild the branch topology
245+// later. All non-message rows (usage) are preserved up to the cut
246+// point so the running cost tracker stays accurate.
247+//
248+// upToMessageIdx is a count over the flat message stream as
249+// returned by OpenSession. To "branch at user turn 3" the caller
250+// passes the index of that user message in msgs + 1 (so the
251+// message itself is included). The caller figures that out; this
252+// helper just copies the first N message rows.
253+//
254+// Returns the path of the new session file, ready for OpenSession.
255+func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (string, error) {
256+ if parentPath == "" {
257+ return "", errors.New("branch: parent path is empty")
258+ }
259+ if upToMessageIdx < 0 {
260+ return "", errors.New("branch: upToMessageIdx must be >= 0")
261+ }
262+
263+ src, err := os.Open(parentPath)
264+ if err != nil {
265+ return "", fmt.Errorf("branch: open parent: %w", err)
266+ }
267+ defer src.Close()
268+
269+ // Read the parent meta so we can copy model/provider and record
270+ // the parent id on the child.
271+ sc := bufio.NewScanner(src)
272+ sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
273+ if !sc.Scan() {
274+ return "", errors.New("branch: parent session is empty")
275+ }
276+ var head sessionLine
277+ if err := json.Unmarshal(sc.Bytes(), &head); err != nil {
278+ return "", fmt.Errorf("branch: parse parent meta: %w", err)
279+ }
280+ if head.Type != "meta" || head.Meta == nil {
281+ return "", errors.New("branch: parent first line is not a meta row")
282+ }
283+ parentMeta := *head.Meta
284+
285+ // Build the destination file.
286+ dir := SessionsDir(root, cwd)
287+ if err := os.MkdirAll(dir, 0o755); err != nil {
288+ return "", err
289+ }
290+ newID := uuid.NewString()
291+ name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), newID[:8])
292+ outPath := filepath.Join(dir, name)
293+ dst, err := os.OpenFile(outPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
294+ if err != nil {
295+ return "", fmt.Errorf("branch: create dst: %w", err)
296+ }
297+ defer dst.Close()
298+ bw := bufio.NewWriter(dst)
299+
300+ // Write the branch meta.
301+ branchMeta := SessionMeta{
302+ ID: newID,
303+ CWD: cwd,
304+ Model: parentMeta.Model,
305+ Provider: parentMeta.Provider,
306+ Started: time.Now().UTC(),
307+ Version: version,
308+ Parent: parentMeta.ID,
309+ ForkPoint: upToMessageIdx,
310+ }
311+ metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta})
312+ if err != nil {
313+ return "", fmt.Errorf("branch: marshal meta: %w", err)
314+ }
315+ if _, err := bw.Write(metaLine); err != nil {
316+ return "", err
317+ }
318+ if err := bw.WriteByte('\n'); err != nil {
319+ return "", err
320+ }
321+
322+ // Copy message rows up to the cut point, plus all usage rows
323+ // that land before the cut (they describe the cost of those
324+ // messages).
325+ msgCount := 0
326+ for sc.Scan() && msgCount < upToMessageIdx {
327+ line := sc.Bytes()
328+ var h sessionLineHead
329+ if err := json.Unmarshal(line, &h); err != nil {
330+ continue
331+ }
332+ switch h.Type {
333+ case "message":
334+ if _, err := bw.Write(line); err != nil {
335+ return "", err
336+ }
337+ if err := bw.WriteByte('\n'); err != nil {
338+ return "", err
339+ }
340+ msgCount++
341+ case "usage":
342+ if _, err := bw.Write(line); err != nil {
343+ return "", err
344+ }
345+ if err := bw.WriteByte('\n'); err != nil {
346+ return "", err
347+ }
348+ // don't increment msgCount for usage rows
349+ }
350+ }
351+ if err := sc.Err(); err != nil {
352+ return "", fmt.Errorf("branch: read parent: %w", err)
353+ }
354+ if err := bw.Flush(); err != nil {
355+ return "", err
356+ }
357+ return outPath, nil
358+}
359+
360+// TreeNode is one entry in the branch tree returned by
361+// BuildSessionTree. Children are populated by linking on Parent ID.
362+type TreeNode struct {
363+ Summary SessionSummary
364+ Meta SessionMeta
365+ Children []*TreeNode
366+}
367+
368+// BuildSessionTree loads every session in the cwd dir and returns
369+// the forest rooted at parentless sessions, with each non-root
370+// session placed under its parent. Used by /session tree to render
371+// the branch hierarchy.
372+func BuildSessionTree(root, cwd string) []*TreeNode {
373+ dir := SessionsDir(root, cwd)
374+ entries, err := os.ReadDir(dir)
375+ if err != nil {
376+ return nil
377+ }
378+ nodes := make(map[string]*TreeNode)
379+ order := []string{}
380+ for _, e := range entries {
381+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
382+ continue
383+ }
384+ path := filepath.Join(dir, e.Name())
385+ summary := describeSession(path)
386+ meta, _ := readSessionMeta(path)
387+ if meta.ID == "" {
388+ continue
389+ }
390+ nodes[meta.ID] = &TreeNode{Summary: summary, Meta: meta}
391+ order = append(order, meta.ID)
392+ }
393+ var roots []*TreeNode
394+ for _, id := range order {
395+ n := nodes[id]
396+ if n.Meta.Parent == "" {
397+ roots = append(roots, n)
398+ continue
399+ }
400+ if parent, ok := nodes[n.Meta.Parent]; ok {
401+ parent.Children = append(parent.Children, n)
402+ } else {
403+ // Parent file missing (was manually deleted). Treat as
404+ // a root so it still shows up in the tree.
405+ roots = append(roots, n)
406+ }
407+ }
408+ return roots
409+}
410+
411+// readSessionMeta opens path, reads the meta row, and returns it.
412+// Empty SessionMeta when the file is missing or not a valid session.
413+func readSessionMeta(path string) (SessionMeta, error) {
414+ f, err := os.Open(path)
415+ if err != nil {
416+ return SessionMeta{}, err
417+ }
418+ defer f.Close()
419+ sc := bufio.NewScanner(f)
420+ sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
421+ if !sc.Scan() {
422+ return SessionMeta{}, errors.New("empty file")
423+ }
424+ var line sessionLine
425+ if err := json.Unmarshal(sc.Bytes(), &line); err != nil {
426+ return SessionMeta{}, err
427+ }
428+ if line.Type != "meta" || line.Meta == nil {
429+ return SessionMeta{}, errors.New("first line is not meta")
430+ }
431+ return *line.Meta, nil
432+}
433+
434+// FindSessionByID looks up a session file in root/cwd whose meta id
435+// matches. Used by /session tree when the user picks an entry. O(n)
436+// over the files in the dir; the list is small in practice.
437+func FindSessionByID(root, cwd, id string) string {
438+ dir := SessionsDir(root, cwd)
439+ entries, err := os.ReadDir(dir)
440+ if err != nil {
441+ return ""
442+ }
443+ for _, e := range entries {
444+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
445+ continue
446+ }
447+ path := filepath.Join(dir, e.Name())
448+ meta, err := readSessionMeta(path)
449+ if err != nil {
450+ continue
451+ }
452+ if meta.ID == id {
453+ return path
454+ }
455+ }
456+ return ""
457+}
458+
241 // firstUserPrompt scans forward from the current scanner position459 // firstUserPrompt scans forward from the current scanner position
242 // looking for the first user-role message and returns its text460 // looking for the first user-role message and returns its text
243 // (trimmed, short). Used to build a humane export filename.461 // (trimmed, short). Used to build a humane export filename.
M internal/core/session_portable_test.go +86 -0
@@ -130,3 +130,89 @@ func TestExportStripsCWDFromMeta(t *testing.T) {
130130 t.Errorf("exported file leaks the source cwd: %s", string(b))
131131 }
132132 }
133+
134+// TestBranchSessionCopiesPrefix writes several messages to a
135+// session, branches at message index 2 (first user + first
136+// assistant), and verifies the new session has exactly those two
137+// messages with parent + fork_point meta set.
138+func TestBranchSessionCopiesPrefix(t *testing.T) {
139+ root := t.TempDir()
140+ cwd := "/project"
141+ parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
142+ if err != nil {
143+ t.Fatal(err)
144+ }
145+ _ = parent.AppendMessage(provider.Message{
146+ Role: provider.RoleUser,
147+ Content: []provider.Content{provider.TextBlock{Text: "first"}},
148+ })
149+ _ = parent.AppendMessage(provider.Message{
150+ Role: provider.RoleAssistant,
151+ Content: []provider.Content{provider.TextBlock{Text: "first reply"}},
152+ })
153+ _ = parent.AppendMessage(provider.Message{
154+ Role: provider.RoleUser,
155+ Content: []provider.Content{provider.TextBlock{Text: "second"}},
156+ })
157+ _ = parent.Close()
158+
159+ // Branch at the first user+assistant pair (upToMessageIdx=2).
160+ branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 2)
161+ if err != nil {
162+ t.Fatalf("BranchSession: %v", err)
163+ }
164+ branch, msgs, err := OpenSession(branchPath)
165+ if err != nil {
166+ t.Fatal(err)
167+ }
168+ defer branch.Close()
169+
170+ if len(msgs) != 2 {
171+ t.Errorf("want 2 copied messages, got %d", len(msgs))
172+ }
173+ if branch.Meta.Parent != parent.Meta.ID {
174+ t.Errorf("parent id: want %q, got %q", parent.Meta.ID, branch.Meta.Parent)
175+ }
176+ if branch.Meta.ForkPoint != 2 {
177+ t.Errorf("fork_point: want 2, got %d", branch.Meta.ForkPoint)
178+ }
179+ if branch.Meta.ID == parent.Meta.ID {
180+ t.Errorf("branch kept parent id; must rotate")
181+ }
182+}
183+
184+// TestBuildSessionTree verifies parent/child edges are rebuilt
185+// from meta + sibling-scan.
186+func TestBuildSessionTree(t *testing.T) {
187+ root := t.TempDir()
188+ cwd := "/project"
189+ parent, _ := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
190+ _ = parent.AppendMessage(provider.Message{
191+ Role: provider.RoleUser,
192+ Content: []provider.Content{provider.TextBlock{Text: "x"}},
193+ })
194+ _ = parent.Close()
195+
196+ childA, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
197+ if err != nil {
198+ t.Fatal(err)
199+ }
200+ childB, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
201+ if err != nil {
202+ t.Fatal(err)
203+ }
204+ _ = childA
205+ _ = childB
206+
207+ tree := BuildSessionTree(root, cwd)
208+ if len(tree) != 1 {
209+ t.Fatalf("want 1 root, got %d", len(tree))
210+ }
211+ rootNode := tree[0]
212+ if rootNode.Meta.ID != parent.Meta.ID {
213+ t.Errorf("root should be parent %q, got %q", parent.Meta.ID, rootNode.Meta.ID)
214+ }
215+ if len(rootNode.Children) != 2 {
216+ t.Errorf("want 2 children, got %d", len(rootNode.Children))
217+ }
218+}
@@ -130,3 +130,89 @@ func TestExportStripsCWDFromMeta(t *testing.T) {
130 t.Errorf("exported file leaks the source cwd: %s", string(b))130 t.Errorf("exported file leaks the source cwd: %s", string(b))
131 }131 }
132 }132 }
133+
134+// TestBranchSessionCopiesPrefix writes several messages to a
135+// session, branches at message index 2 (first user + first
136+// assistant), and verifies the new session has exactly those two
137+// messages with parent + fork_point meta set.
138+func TestBranchSessionCopiesPrefix(t *testing.T) {
139+ root := t.TempDir()
140+ cwd := "/project"
141+ parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
142+ if err != nil {
143+ t.Fatal(err)
144+ }
145+ _ = parent.AppendMessage(provider.Message{
146+ Role: provider.RoleUser,
147+ Content: []provider.Content{provider.TextBlock{Text: "first"}},
148+ })
149+ _ = parent.AppendMessage(provider.Message{
150+ Role: provider.RoleAssistant,
151+ Content: []provider.Content{provider.TextBlock{Text: "first reply"}},
152+ })
153+ _ = parent.AppendMessage(provider.Message{
154+ Role: provider.RoleUser,
155+ Content: []provider.Content{provider.TextBlock{Text: "second"}},
156+ })
157+ _ = parent.Close()
158+
159+ // Branch at the first user+assistant pair (upToMessageIdx=2).
160+ branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 2)
161+ if err != nil {
162+ t.Fatalf("BranchSession: %v", err)
163+ }
164+ branch, msgs, err := OpenSession(branchPath)
165+ if err != nil {
166+ t.Fatal(err)
167+ }
168+ defer branch.Close()
169+
170+ if len(msgs) != 2 {
171+ t.Errorf("want 2 copied messages, got %d", len(msgs))
172+ }
173+ if branch.Meta.Parent != parent.Meta.ID {
174+ t.Errorf("parent id: want %q, got %q", parent.Meta.ID, branch.Meta.Parent)
175+ }
176+ if branch.Meta.ForkPoint != 2 {
177+ t.Errorf("fork_point: want 2, got %d", branch.Meta.ForkPoint)
178+ }
179+ if branch.Meta.ID == parent.Meta.ID {
180+ t.Errorf("branch kept parent id; must rotate")
181+ }
182+}
183+
184+// TestBuildSessionTree verifies parent/child edges are rebuilt
185+// from meta + sibling-scan.
186+func TestBuildSessionTree(t *testing.T) {
187+ root := t.TempDir()
188+ cwd := "/project"
189+ parent, _ := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
190+ _ = parent.AppendMessage(provider.Message{
191+ Role: provider.RoleUser,
192+ Content: []provider.Content{provider.TextBlock{Text: "x"}},
193+ })
194+ _ = parent.Close()
195+
196+ childA, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
197+ if err != nil {
198+ t.Fatal(err)
199+ }
200+ childB, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
201+ if err != nil {
202+ t.Fatal(err)
203+ }
204+ _ = childA
205+ _ = childB
206+
207+ tree := BuildSessionTree(root, cwd)
208+ if len(tree) != 1 {
209+ t.Fatalf("want 1 root, got %d", len(tree))
210+ }
211+ rootNode := tree[0]
212+ if rootNode.Meta.ID != parent.Meta.ID {
213+ t.Errorf("root should be parent %q, got %q", parent.Meta.ID, rootNode.Meta.ID)
214+ }
215+ if len(rootNode.Children) != 2 {
216+ t.Errorf("want 2 children, got %d", len(rootNode.Children))
217+ }
218+}

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 477

↑↓ moveenter openesc close