From 3b69b135567de38a4854e222ace2f829061d56d7 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Thu, 26 Mar 2026 15:41:01 +0300 Subject: [PATCH] feat: make /new create a new session file like Pi Change /new behavior to match Pi: - Create a completely new session file instead of just resetting the leaf - Previous session is closed and saved (accessible via /resume) - New session starts with 0 entries, 0 messages - clean slate - Update help text to reflect new behavior Key fix: SwitchTreeSession now updates the kit SDK's tree session reference so messages are persisted to the correct file. Files changed: - internal/app/app.go: update kit SDK session reference - internal/ui/model.go: create new session file on /new - internal/ui/model_test.go: add SwitchTreeSession stub --- internal/app/app.go | 4 ++ internal/message/content_test.go | 113 +++++++++++++++++++++++++++++++ internal/ui/model.go | 23 +++++-- internal/ui/model_test.go | 4 ++ 4 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 internal/message/content_test.go diff --git a/internal/app/app.go b/internal/app/app.go index a0747768..4461418a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -272,6 +272,10 @@ func (a *App) SwitchTreeSession(ts *session.TreeManager) { _ = old.Close() } a.opts.TreeSession = ts + // Also update the kit SDK's tree session so messages are persisted correctly. + if a.opts.Kit != nil { + a.opts.Kit.SetTreeSession(ts) + } // Reload messages from new session. a.store.Clear() if ts != nil { diff --git a/internal/message/content_test.go b/internal/message/content_test.go new file mode 100644 index 00000000..43ec34d2 --- /dev/null +++ b/internal/message/content_test.go @@ -0,0 +1,113 @@ +package message + +import ( + "testing" +) + +func TestSanitizeToolCallID(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "valid alphanumeric ID", + input: "call_123abc", + expected: "call_123abc", + }, + { + name: "ID with dots (OpenCode/Kimi style)", + input: "call.123.abc", + expected: "call_123_abc", + }, + { + name: "ID with colons", + input: "tool:123:abc", + expected: "tool_123_abc", + }, + { + name: "ID with special characters", + input: "tool@#$%^&*()", + expected: "tool_________", + }, + { + name: "Anthropic style ID (already valid)", + input: "toolu_0123456789ABCDEF", + expected: "toolu_0123456789ABCDEF", + }, + { + name: "OpenAI style ID (already valid)", + input: "call_O17Uplv4lJvD6DVdIvFFeRMw", + expected: "call_O17Uplv4lJvD6DVdIvFFeRMw", + }, + { + name: "ID with hyphens", + input: "my-tool-call-123", + expected: "my-tool-call-123", + }, + { + name: "empty string", + input: "", + expected: "tool_0", + }, + { + name: "only special characters", + input: "@#$%", + expected: "____", + }, + { + name: "mixed valid and invalid", + input: "call_123.abc-def@ghi", + expected: "call_123_abc-def_ghi", + }, + { + name: "Unicode characters", + input: "tool_日本語", + expected: "tool____", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := sanitizeToolCallID(tt.input) + if result != tt.expected { + t.Errorf("sanitizeToolCallID(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestSanitizeToolCallID_MatchesAnthropicPattern(t *testing.T) { + // Test that sanitized IDs match Anthropic's required pattern: ^[a-zA-Z0-9_-]+$ + // This is a simplified check - in reality the pattern allows alphanumeric, underscore, hyphen + testIDs := []string{ + "call.123.abc", + "tool:123:def", + "id@#$%^&*()", + "mixed.valid-id_test", + "", + } + + for _, id := range testIDs { + sanitized := sanitizeToolCallID(id) + + // Verify each character is valid + for i, r := range sanitized { + valid := (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || + r == '_' || + r == '-' + + if !valid { + t.Errorf("sanitizeToolCallID(%q) = %q, contains invalid character at position %d: %q", + id, sanitized, i, string(r)) + } + } + + // Verify non-empty + if sanitized == "" { + t.Errorf("sanitizeToolCallID(%q) returned empty string", id) + } + } +} diff --git a/internal/ui/model.go b/internal/ui/model.go index 5d7da169..de90fd1e 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -84,6 +84,9 @@ type AppController interface { // GetTreeSession returns the tree session manager, or nil if tree sessions // are not enabled. Used by slash commands like /tree, /fork, /session. GetTreeSession() *session.TreeManager + // SwitchTreeSession replaces the active tree session with a new one, + // closing the old session. Used by /new to create a completely fresh session. + SwitchTreeSession(ts *session.TreeManager) // SendEvent sends a tea.Msg to the program asynchronously. Safe to call // from any goroutine. Used by extension command goroutines to deliver // results back to the TUI without going through tea.Cmd (which can stall @@ -2423,7 +2426,7 @@ func (m *AppModel) printHelpMessage() { "**Navigation:**\n" + "- `/tree`: Navigate session tree (switch branches)\n" + "- `/fork`: Branch from an earlier message\n" + - "- `/new`: Start a new branch (preserves history)\n" + + "- `/new`: Start a new session (discards context, saves old session)\n" + "- `/resume`: Open session picker to switch sessions\n" + "- `/name `: Set a display name for this session\n\n" + "**System:**\n" + @@ -2991,7 +2994,8 @@ func (m *AppModel) handleForkCommand() tea.Cmd { return nil } -// handleNewCommand starts a fresh session by resetting the tree leaf. +// handleNewCommand starts a completely new session (Pi-style /new behavior). +// Creates a new session file, discarding all context from the previous conversation. func (m *AppModel) handleNewCommand() tea.Cmd { // Emit before-session-switch event in a goroutine so that extension // handlers can call blocking operations (e.g. ctx.PromptConfirm) without @@ -3014,6 +3018,8 @@ func (m *AppModel) handleNewCommand() tea.Cmd { // performNewSession performs the actual session reset. Called either directly // (when no before-hook exists) or after the async hook completes. +// Matches Pi behavior: creates a completely new session file, discarding all +// context from the previous conversation. func (m *AppModel) performNewSession() tea.Cmd { ts := m.appCtrl.GetTreeSession() if ts == nil { @@ -3025,11 +3031,16 @@ func (m *AppModel) performNewSession() tea.Cmd { return nil } - ts.ResetLeaf() - if m.appCtrl != nil { - m.appCtrl.ClearMessages() + // Create a brand new session file (Pi-style /new behavior) + newTs, err := session.CreateTreeSession(m.cwd) + if err != nil { + m.printSystemMessage(fmt.Sprintf("Failed to create new session: %v", err)) + return nil } - m.printSystemMessage("New branch started. Previous conversation is preserved in the tree.") + + // Switch to the new session, closing the old one + m.appCtrl.SwitchTreeSession(newTs) + m.printSystemMessage("New session started. Previous conversation saved.") return nil } diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index de3785c7..0cf2f720 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -54,6 +54,10 @@ func (s *stubAppController) GetTreeSession() *session.TreeManager { return nil } +func (s *stubAppController) SwitchTreeSession(_ *session.TreeManager) { + // no-op in tests +} + func (s *stubAppController) SendEvent(_ tea.Msg) { // no-op in tests }