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: | |||
| 190 | 190 | | `/logout [provider]` | Clear credentials for `anthropic`, `openai`, or all when omitted. | | |
| 191 | 191 | | `/model` | Pick a model from a list (or `/model <id>` to set directly). | | |
| 192 | 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 | 194 | | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). | | |
| 195 | 195 | | `/btw` | Side chat with full context that doesn't add to the main thread. | | |
| 196 | 196 | | `/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 { | |||
| 189 | 189 | // while the check hasn't completed or nothing is available. | |
| 190 | 190 | updateInfo UpdateInfo | |
| 191 | 191 | ||
| 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 | |
| 206 | 213 | ||
| 207 | 214 | // parkedTurn is the 1-based turn number the viewport is currently | |
| 208 | 215 | // scrolled to by /jump. 0 = not parked, showing the tail as usual. | |
| @@ -240,23 +247,24 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 240 | 247 | Theme: cfg.Theme, | |
| 241 | 248 | ImageProto: tui.DetectImageProtocol(), | |
| 242 | 249 | }, | |
| 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(), | |
| 260 | 268 | } | |
| 261 | 269 | if cfg.Agent != nil { | |
| 262 | 270 | i.agent = cfg.Agent | |
| @@ -449,7 +457,7 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 449 | 457 | // and the AfterFunc-driven invalidate got dropped on a | |
| 450 | 458 | // full channel. | |
| 451 | 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 | 461 | requestRedraw() // keep the spinner / dialog animation moving | |
| 454 | 462 | } | |
| 455 | 463 | } | |
| @@ -615,6 +623,8 @@ func (i *Interactive) redraw() { | |||
| 615 | 623 | dialog = i.telegramDialog.Render(i.cfg.Theme, cols) | |
| 616 | 624 | case i.sessionOpsDialog.Active(): | |
| 617 | 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 | 630 | // 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 | 915 | i.invalidate() | |
| 906 | 916 | 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 | 931 | if i.jumpDialog.Active() { | |
| 909 | 932 | if k.Kind == tui.KeyCtrlC { | |
| 910 | 933 | i.jumpDialog.Close() | |
| 934 | + | i.pendingFork = false | |
| 911 | 935 | return false | |
| 912 | 936 | } | |
| 913 | 937 | act := i.jumpDialog.HandleKey(k) | |
| 914 | 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 | 951 | return false | |
| 918 | 952 | } | |
| @@ -2524,31 +2558,36 @@ func (h *telegramHost) Notify(level, message string) { | |||
| 2524 | 2558 | } | |
| 2525 | 2559 | ||
| 2526 | 2560 | // 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). | |
| 2530 | 2564 | func (i *Interactive) openSessionOpsDialog() { | |
| 2531 | 2565 | items := []sessionOpsItem{ | |
| 2532 | 2566 | {label: "export", action: "export", hint: "write the current session to a .zotsession file"}, | |
| 2533 | 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 | 2571 | i.sessionOpsDialog.Open(items) | |
| 2536 | 2572 | i.invalidate() | |
| 2537 | 2573 | } | |
| 2538 | 2574 | ||
| 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) { | |
| 2544 | 2579 | switch action { | |
| 2545 | 2580 | case "export": | |
| 2546 | - | i.doSessionExport(path) | |
| 2581 | + | i.doSessionExport(arg) | |
| 2547 | 2582 | case "import": | |
| 2548 | - | i.doSessionImport(path) | |
| 2583 | + | i.doSessionImport(arg) | |
| 2584 | + | case "fork": | |
| 2585 | + | i.doSessionFork() | |
| 2586 | + | case "tree": | |
| 2587 | + | i.doSessionTree() | |
| 2549 | 2588 | default: | |
| 2550 | 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 | 2591 | i.mu.Unlock() | |
| 2553 | 2592 | i.invalidate() | |
| 2554 | 2593 | } | |
| @@ -2716,3 +2755,154 @@ func friendlyPath(p string) string { | |||
| 2716 | 2755 | } | |
| 2717 | 2756 | 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 | + | } | |
| @@ -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 UpdateInfo | 190 | updateInfo UpdateInfo | ||
| 191 | 191 | ||||
| 192 | - | dialog *loginDialog | 192 | + | dialog *loginDialog |
| 193 | - | modelDialog *modelDialog | 193 | + | modelDialog *modelDialog |
| 194 | - | sessionDialog *sessionDialog | 194 | + | sessionDialog *sessionDialog |
| 195 | - | jumpDialog *jumpDialog | 195 | + | jumpDialog *jumpDialog |
| 196 | - | btwDialog *btwDialog | 196 | + | btwDialog *btwDialog |
| 197 | - | skillsDialog *skillsDialog | 197 | + | skillsDialog *skillsDialog |
| 198 | - | changelogDialog *changelogDialog | 198 | + | changelogDialog *changelogDialog |
| 199 | - | confirmDialog *confirmDialog | 199 | + | confirmDialog *confirmDialog |
| 200 | - | logoutDialog *logoutDialog | 200 | + | logoutDialog *logoutDialog |
| 201 | - | telegramDialog *telegramDialog | 201 | + | telegramDialog *telegramDialog |
| 202 | - | telegramBridge *telegram.Bridge | 202 | + | telegramBridge *telegram.Bridge |
| 203 | - | sessionOpsDialog *sessionOpsDialog | 203 | + | sessionOpsDialog *sessionOpsDialog |
| 204 | - | suggest *slashSuggester | 204 | + | sessionTreeDialog *sessionTreeDialog |
| 205 | - | spin *spinner | 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 | |||
| 206 | 213 | ||||
| 207 | // parkedTurn is the 1-based turn number the viewport is currently | 214 | // 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.Agent | 270 | 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 a | 457 | // 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 moving | 461 | 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, only | 630 | // 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 false | 916 | 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 false | 935 | 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 false | 951 | 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 a | 2561 | + | // Always offers export, import, fork, tree; the handlers bail with |
| 2528 | - | // clear status message when the precondition isn't met (empty | 2562 | + | // 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 optional | 2575 | + | // doSessionOp dispatches export, import, fork, or tree. arg is the |
| 2540 | - | // positional argument from /session export <path> or /session | 2576 | + | // optional positional argument from e.g. /session export <path> |
| 2541 | - | // import <path>; when empty, sensible defaults apply (see | 2577 | + | // 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 p | 2756 | 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 { | |||
| 45 | 45 | Provider string `json:"provider"` | |
| 46 | 46 | Started time.Time `json:"started"` | |
| 47 | 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 | 61 | // 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 raw | 61 | // 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) { | |||
| 238 | 238 | 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 | 459 | // firstUserPrompt scans forward from the current scanner position | |
| 242 | 460 | // looking for the first user-role message and returns its text | |
| 243 | 461 | // (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, nil | 238 | 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 position | 459 | // firstUserPrompt scans forward from the current scanner position | ||
| 242 | // looking for the first user-role message and returns its text | 460 | // 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) { | |||
| 130 | 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 | + | } | |
| @@ -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 | + | } | |||