feat(ui): make /fork create new session file matching Pi behavior

- Add ForkToNewSession method to create new session with history up to target
- Add NewTreeSelectorForFork showing only user messages (flat list)
- Update performFork to create and switch to new session file
- Update /fork command description in docs and help text

Previously /fork just branched within the same session file like /tree.
Now /fork creates a completely new session file with parent_session reference,
matching Pi's behavior exactly.
This commit is contained in:
Ed Zynda
2026-04-01 16:10:55 +03:00
parent 7a16c76adc
commit 4e7d823ee4
7 changed files with 236 additions and 21 deletions
+1 -1
View File
@@ -477,7 +477,7 @@ During an interactive session, use these slash commands:
| `/import <path>` | Import and switch to a session from a JSONL file |
| `/share` | Upload session to GitHub Gist and get a shareable viewer URL |
| `/tree` | Navigate the session tree |
| `/fork` | Branch from an earlier message |
| `/fork` | Fork to new session from an earlier message |
| `/new` | Start a fresh session |
## Go SDK
+181
View File
@@ -114,6 +114,187 @@ func CreateTreeSession(cwd string) (*TreeManager, error) {
return tm, nil
}
// ForkToNewSession creates a new session file containing the history up to and
// including the target entry ID. This matches Pi's /fork behavior: it creates
// a completely new session file with a parent_session reference, copying all
// entries from the root to the target point.
func (tm *TreeManager) ForkToNewSession(cwd string, targetID string) (*TreeManager, error) {
tm.mu.RLock()
defer tm.mu.RUnlock()
// Get the branch from root to target (root-to-leaf order).
branch := tm.getBranchLocked(targetID)
if len(branch) == 0 {
return nil, fmt.Errorf("target entry %q not found", targetID)
}
// Create a new session file.
newTm, err := CreateTreeSession(cwd)
if err != nil {
return nil, err
}
// Set the parent session reference in the header.
newTm.header.ParentSession = tm.filePath
newTm.header.ParentSessionID = tm.header.ID
// Rewrite the header with the parent reference.
// We need to close and recreate the file to rewrite the header.
if err := newTm.file.Close(); err != nil {
return nil, fmt.Errorf("failed to close new session file: %w", err)
}
// Recreate the file and write the updated header.
f, err := os.Create(newTm.filePath)
if err != nil {
return nil, fmt.Errorf("failed to recreate session file: %w", err)
}
newTm.file = f
if err := newTm.writeEntry(&newTm.header); err != nil {
_ = f.Close()
return nil, fmt.Errorf("failed to write session header: %w", err)
}
// Copy entries from the branch to the new session.
// We need to remap IDs since the new session is independent.
idMap := make(map[string]string) // old ID -> new ID
var prevNewID string
for _, entry := range branch {
oldID := tm.EntryID(entry)
newID := GenerateEntryID()
idMap[oldID] = newID
// Create a copy of the entry with the new ID and remapped parent.
var newEntry any
switch e := entry.(type) {
case *MessageEntry:
newEntry = &MessageEntry{
Entry: Entry{
Type: EntryTypeMessage,
ID: newID,
ParentID: prevNewID, // Chain sequentially in new session
Timestamp: e.Timestamp,
},
Role: e.Role,
Parts: e.Parts,
Model: e.Model,
Provider: e.Provider,
}
// Copy label if present.
if label, ok := tm.labels[oldID]; ok {
newTm.labels[newID] = label
}
case *ModelChangeEntry:
newEntry = &ModelChangeEntry{
Entry: Entry{
Type: EntryTypeModelChange,
ID: newID,
ParentID: prevNewID,
Timestamp: e.Timestamp,
},
Provider: e.Provider,
ModelID: e.ModelID,
}
case *LabelEntry:
// Remap the target ID if it's in our copied branch.
newTargetID := e.TargetID
if mapped, ok := idMap[e.TargetID]; ok {
newTargetID = mapped
}
newEntry = &LabelEntry{
Entry: Entry{
Type: EntryTypeLabel,
ID: newID,
ParentID: prevNewID,
Timestamp: e.Timestamp,
},
TargetID: newTargetID,
Label: e.Label,
}
case *SessionInfoEntry:
newEntry = &SessionInfoEntry{
Entry: Entry{
Type: EntryTypeSessionInfo,
ID: newID,
ParentID: prevNewID,
Timestamp: e.Timestamp,
},
Name: e.Name,
}
newTm.sessionName = e.Name
case *ExtensionDataEntry:
newEntry = &ExtensionDataEntry{
Entry: Entry{
Type: EntryTypeExtensionData,
ID: newID,
ParentID: prevNewID,
Timestamp: e.Timestamp,
},
ExtType: e.ExtType,
Data: e.Data,
}
case *BranchSummaryEntry:
// Remap the from ID if it's in our copied branch.
newFromID := e.FromID
if mapped, ok := idMap[e.FromID]; ok {
newFromID = mapped
}
newEntry = &BranchSummaryEntry{
Entry: Entry{
Type: EntryTypeBranchSummary,
ID: newID,
ParentID: prevNewID,
Timestamp: e.Timestamp,
},
FromID: newFromID,
Summary: e.Summary,
}
case *CompactionEntry:
// Remap the first kept entry ID if it's in our copied branch.
newFirstKeptID := e.FirstKeptEntryID
if mapped, ok := idMap[e.FirstKeptEntryID]; ok {
newFirstKeptID = mapped
}
newEntry = &CompactionEntry{
Entry: Entry{
Type: EntryTypeCompaction,
ID: newID,
ParentID: prevNewID,
Timestamp: e.Timestamp,
},
Summary: e.Summary,
FirstKeptEntryID: newFirstKeptID,
TokensBefore: e.TokensBefore,
TokensAfter: e.TokensAfter,
MessagesRemoved: e.MessagesRemoved,
ReadFiles: e.ReadFiles,
ModifiedFiles: e.ModifiedFiles,
}
}
if newEntry != nil {
if err := newTm.appendAndPersist(newEntry); err != nil {
_ = f.Close()
return nil, fmt.Errorf("failed to copy entry: %w", err)
}
prevNewID = newID
}
}
// Set the leaf to the last entry in the new session.
newTm.leafID = prevNewID
return newTm, nil
}
// OpenTreeSession opens an existing JSONL session file.
func OpenTreeSession(path string) (*TreeManager, error) {
data, err := os.ReadFile(path)
+1 -1
View File
@@ -134,7 +134,7 @@ var SlashCommands = []SlashCommand{
},
{
Name: "/fork",
Description: "Branch from an earlier message",
Description: "Fork to new session from an earlier message",
Category: "Navigation",
},
{
+29 -17
View File
@@ -3202,6 +3202,8 @@ func (m *AppModel) handleTreeCommand() tea.Cmd {
// handleForkCommand creates a branch from the current position. Like /tree
// but opens the selector directly for fork semantics.
// Unlike /tree which shows the full tree, /fork shows only user messages
// (matching Pi's behavior) and creates a new session file when a message is selected.
func (m *AppModel) handleForkCommand() tea.Cmd {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
@@ -3213,7 +3215,8 @@ func (m *AppModel) handleForkCommand() tea.Cmd {
return nil
}
m.treeSelector = NewTreeSelector(ts, m.width, m.height)
// Use the fork-specific selector that shows only user messages.
m.treeSelector = NewTreeSelectorForFork(ts, m.width, m.height)
m.state = stateTreeSelector
return nil
}
@@ -3280,8 +3283,11 @@ func (m *AppModel) performNewSession() tea.Cmd {
return nil
}
// performFork performs the actual tree branch. Called either directly (when no
// before-hook exists) or after the async before-fork hook completes.
// performFork creates a new session by forking from the target entry.
// This matches Pi's /fork behavior: it creates a completely new session file
// with the history up to the target point, then switches to that session.
// Called either directly (when no before-hook exists) or after the async
// before-fork hook completes.
func (m *AppModel) performFork(targetID string, isUser bool, userText string) tea.Cmd {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
@@ -3289,12 +3295,25 @@ func (m *AppModel) performFork(targetID string, isUser bool, userText string) te
return nil
}
// Branch the tree session to the target entry. We must NOT call
// ClearMessages() here because it resets the leaf pointer back to "",
// undoing the branch we just set. Instead, branch first and then
// reload the in-memory store from the tree session's current branch.
_ = ts.Branch(targetID)
m.appCtrl.ReloadMessagesFromTree()
// Create a new session by forking from the target entry.
// This creates a new session file with the history up to the target point.
newTs, err := ts.ForkToNewSession(m.cwd, targetID)
if err != nil {
m.printSystemMessage(fmt.Sprintf("Failed to fork session: %v", err))
return nil
}
// Switch to the new forked session.
m.appCtrl.SwitchTreeSession(newTs)
// Reset usage statistics for the new session.
if m.usageTracker != nil {
m.usageTracker.Reset()
}
// Clear the scroll list and populate all messages from the forked history.
m.messages = []MessageItem{}
m.renderSessionHistory()
// If it was a user message, populate the input with the text.
if isUser && userText != "" {
@@ -3304,14 +3323,7 @@ func (m *AppModel) performFork(targetID string, isUser bool, userText string) te
}
}
m.printSystemMessage(
fmt.Sprintf("Navigated to branch point. %s",
func() string {
if isUser {
return "Edit and resubmit to create a new branch."
}
return "Continue from this point."
}()))
m.printSystemMessage("Forked to new session. Edit and resubmit to continue.")
return nil
}
+22
View File
@@ -89,6 +89,28 @@ func NewTreeSelector(tm *session.TreeManager, width, height int) *TreeSelectorCo
return ts
}
// NewTreeSelectorForFork creates a tree selector for the /fork command.
// It shows only user messages (flat list) matching Pi's fork behavior.
func NewTreeSelectorForFork(tm *session.TreeManager, width, height int) *TreeSelectorComponent {
ts := &TreeSelectorComponent{
tm: tm,
filter: TreeFilterUserOnly,
leafID: tm.GetLeafID(),
width: width,
height: height,
active: true,
}
ts.rebuildFlatList()
// Position cursor at the last user message before the leaf.
for i := len(ts.flatNodes) - 1; i >= 0; i-- {
if ts.isUserMessage(ts.flatNodes[i].Entry) {
ts.cursor = i
break
}
}
return ts
}
// Init implements tea.Model.
func (ts *TreeSelectorComponent) Init() tea.Cmd {
return nil
+1 -1
View File
@@ -73,7 +73,7 @@ These commands are available inside the Kit TUI during an interactive session:
| `/usage` | Show token usage |
| `/reset-usage` | Reset usage statistics |
| `/tree` | Navigate session tree |
| `/fork` | Branch from an earlier message |
| `/fork` | Fork to new session from an earlier message |
| `/new` | Start a new session (creates new session file) |
| `/name [name]` | Set or show session display name |
| `/resume` | Open session picker to switch sessions (alias: `/r`) |
+1 -1
View File
@@ -80,7 +80,7 @@ These slash commands are available during an interactive session:
| `/import <path>` | Import and switch to a session from a JSONL file |
| `/share` | Upload session to GitHub Gist and get a shareable viewer URL |
| `/tree` | Navigate the session tree |
| `/fork` | Branch from an earlier message |
| `/fork` | Fork to new session from an earlier message (creates new session file) |
| `/new` | Start a new session (creates new session file) |
## Ephemeral mode