detached 6f6753c

Add command to start a new session

commit
6f6753ccfc15aeb4a54fb500a55b6f72db55fd02
author
patriceckhart · · 2026-09-24 05:48 UTC
parent
7d78d75
signature
unsigned
refs
tag: v0.3.90
9 files · +332 -20
M README.md +3 -2
@@ -351,6 +351,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
351351 | `/reasoning` | Set the reasoning level for subsequent model calls. |
352352 | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. |
353353 | `/sessions` | Resume a previous session for this directory. |
354+| `/new` | Persist the current conversation and start a fresh session in the same directory. Unavailable with `--no-session`. |
354355 | `/session` | Five ops on the current session: inspect its `timeline`, `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message, or view its branch `tree`. Opens a picker without an argument; direct forms include `/session timeline`, `/session export [path]`, `/session import <path>`, `/session fork`, and `/session tree`. Default export destination is `~/Downloads`. |
355356 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |
356357 | `/btw` | Side chat with full context that doesn't add to the main thread. |
@@ -511,7 +512,7 @@ This is a guardrail against accidents, not a hard security boundary. If you need
511512
512513 ## Sessions
513514
514-Every interactive or print/json run (unless `--no-session`) writes a JSONL transcript under `$ZOT_HOME/sessions/<cwd-hash>/`. Resume any of them with `--continue`, `--resume`, `--session <path>`, or interactively via `/sessions` inside the TUI. Empty sessions (the user exited without prompting) are deleted on close so the list stays tidy.
515+Every interactive or print/json run (unless `--no-session`) writes a JSONL transcript under `$ZOT_HOME/sessions/<cwd-hash>/`. Resume any of them with `--continue`, `--resume`, `--session <path>`, or interactively via `/sessions` inside the TUI. In interactive mode, `/new` persists the current conversation and starts an empty session while preserving the working directory, model, tools, extensions, and runtime settings. It does not make a model call, and the previous conversation remains available through `/sessions`. `/new` is hidden from autocomplete and `/help` with `--no-session`, and invoking it directly reports that sessions are disabled. Empty sessions (the user exited without prompting) are deleted on close so the list stays tidy.
515516
516517 Use `zot sessions prune` outside the TUI to find sessions whose recorded working directories no longer exist. The command groups sessions by directory, shows the number and human-readable total size of stored sessions, lets you select groups, and requires confirmation before permanently deleting files. It preserves sessions when a directory check fails with an error other than "not found" and rechecks each selected directory immediately before deletion. A deleted directory and a path hidden by some unmounted filesystems are indistinguishable, so review the selection and ensure remote filesystems are mounted before deleting missing-directory groups.
517518
@@ -1118,7 +1119,7 @@ You can keep typing while the agent is working. Pressing `enter` during a turn q
11181119
11191120 To recover the most recently queued message back into the editor (to tweak it before it runs), press `Option+↑`. In VS Code's integrated terminal that chord doesn't survive xterm.js's macOS key handling — use `Option+Shift+↑` there. zot's hint line under the sliding-in queue adapts automatically based on `$TERM_PROGRAM`.
11201121
1121-Slash commands also work while the agent is busy. Non-destructive ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/reasoning`, `/settings`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run.
1122+Slash commands also work while the agent is busy. Non-destructive ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/reasoning`, `/settings`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/new`, `/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run.
11221123
11231124
11241125 ## Keys (interactive mode)
@@ -351,6 +351,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
351 | `/reasoning` | Set the reasoning level for subsequent model calls. |351 | `/reasoning` | Set the reasoning level for subsequent model calls. |
352 | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. |352 | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. |
353 | `/sessions` | Resume a previous session for this directory. |353 | `/sessions` | Resume a previous session for this directory. |
354+| `/new` | Persist the current conversation and start a fresh session in the same directory. Unavailable with `--no-session`. |
354 | `/session` | Five ops on the current session: inspect its `timeline`, `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message, or view its branch `tree`. Opens a picker without an argument; direct forms include `/session timeline`, `/session export [path]`, `/session import <path>`, `/session fork`, and `/session tree`. Default export destination is `~/Downloads`. |355 | `/session` | Five ops on the current session: inspect its `timeline`, `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message, or view its branch `tree`. Opens a picker without an argument; direct forms include `/session timeline`, `/session export [path]`, `/session import <path>`, `/session fork`, and `/session tree`. Default export destination is `~/Downloads`. |
355 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |356 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |
356 | `/btw` | Side chat with full context that doesn't add to the main thread. |357 | `/btw` | Side chat with full context that doesn't add to the main thread. |
@@ -511,7 +512,7 @@ This is a guardrail against accidents, not a hard security boundary. If you need
511 512
512 ## Sessions513 ## Sessions
513 514
514-Every interactive or print/json run (unless `--no-session`) writes a JSONL transcript under `$ZOT_HOME/sessions/<cwd-hash>/`. Resume any of them with `--continue`, `--resume`, `--session <path>`, or interactively via `/sessions` inside the TUI. Empty sessions (the user exited without prompting) are deleted on close so the list stays tidy.515+Every interactive or print/json run (unless `--no-session`) writes a JSONL transcript under `$ZOT_HOME/sessions/<cwd-hash>/`. Resume any of them with `--continue`, `--resume`, `--session <path>`, or interactively via `/sessions` inside the TUI. In interactive mode, `/new` persists the current conversation and starts an empty session while preserving the working directory, model, tools, extensions, and runtime settings. It does not make a model call, and the previous conversation remains available through `/sessions`. `/new` is hidden from autocomplete and `/help` with `--no-session`, and invoking it directly reports that sessions are disabled. Empty sessions (the user exited without prompting) are deleted on close so the list stays tidy.
515 516
516 Use `zot sessions prune` outside the TUI to find sessions whose recorded working directories no longer exist. The command groups sessions by directory, shows the number and human-readable total size of stored sessions, lets you select groups, and requires confirmation before permanently deleting files. It preserves sessions when a directory check fails with an error other than "not found" and rechecks each selected directory immediately before deletion. A deleted directory and a path hidden by some unmounted filesystems are indistinguishable, so review the selection and ensure remote filesystems are mounted before deleting missing-directory groups.517 Use `zot sessions prune` outside the TUI to find sessions whose recorded working directories no longer exist. The command groups sessions by directory, shows the number and human-readable total size of stored sessions, lets you select groups, and requires confirmation before permanently deleting files. It preserves sessions when a directory check fails with an error other than "not found" and rechecks each selected directory immediately before deletion. A deleted directory and a path hidden by some unmounted filesystems are indistinguishable, so review the selection and ensure remote filesystems are mounted before deleting missing-directory groups.
517 518
@@ -1118,7 +1119,7 @@ You can keep typing while the agent is working. Pressing `enter` during a turn q
1118 1119
1119 To recover the most recently queued message back into the editor (to tweak it before it runs), press `Option+↑`. In VS Code's integrated terminal that chord doesn't survive xterm.js's macOS key handling — use `Option+Shift+↑` there. zot's hint line under the sliding-in queue adapts automatically based on `$TERM_PROGRAM`.1120 To recover the most recently queued message back into the editor (to tweak it before it runs), press `Option+↑`. In VS Code's integrated terminal that chord doesn't survive xterm.js's macOS key handling — use `Option+Shift+↑` there. zot's hint line under the sliding-in queue adapts automatically based on `$TERM_PROGRAM`.
1120 1121
1121-Slash commands also work while the agent is busy. Non-destructive ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/reasoning`, `/settings`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run.1122+Slash commands also work while the agent is busy. Non-destructive ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/reasoning`, `/settings`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/new`, `/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run.
1122 1123
1123 1124
1124 ## Keys (interactive mode)1125 ## Keys (interactive mode)
M packages/agent/cli.go +58 -0
@@ -1105,6 +1105,40 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11051105 return nil
11061106 }
11071107
1108+ var newSession func(providerName, model string) error
1109+ if !args.NoSess {
1110+ newSession = func(providerName, model string) error {
1111+ currentAg := liveInteractiveAgent(iv, ag)
1112+ if currentAg == nil {
1113+ return fmt.Errorf("no agent running; log in first")
1114+ }
1115+
1116+ persistMu.Lock()
1117+ newSess, err := rotateInteractiveSession(
1118+ agentSessionsRoot(ZotHome(), args), r.CWD, providerName, model, version,
1119+ currentAg, sess, sessBaselineMsgs,
1120+ )
1121+ if err == nil {
1122+ sess = newSess
1123+ sessBaselineMsgs = 0
1124+ }
1125+ persistMu.Unlock()
1126+ if err != nil {
1127+ return err
1128+ }
1129+
1130+ active := r
1131+ active.Provider = providerName
1132+ active.Model = model
1133+ setZotSessionEnvironment(active, newSess)
1134+ startExtensionSession(extMgr, currentAg, r.CWD, "session_switch")
1135+ if swarmMgr != nil {
1136+ swarmMgr.SetActiveSession(newSess.ID)
1137+ }
1138+ return nil
1139+ }
1140+ }
1141+
11081142 // changeCWD switches the running session to a new working directory.
11091143 // Wired into InteractiveConfig.ChangeCWD and invoked by the hidden
11101144 // /cd slash command (which itself is only fired by the workspaces
@@ -1424,6 +1458,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14241458 }
14251459 return out
14261460 },
1461+ NewSession: newSession,
14271462 LoadSession: loadSession,
14281463 ChangeCWD: changeCWD,
14291464 CurrentSessionPath: func() string {
@@ -1688,6 +1723,29 @@ func pickSession(root, cwd string) (string, error) {
16881723 return files[n-1], nil
16891724 }
16901725
1726+// rotateInteractiveSession creates the replacement before mutating the live
1727+// agent, so a creation failure leaves the current conversation usable.
1728+// Callers must hold the interactive persistence mutex.
1729+func rotateInteractiveSession(root, cwd, providerName, model, version string, ag *core.Agent, old *core.Session, baseline int) (*core.Session, error) {
1730+ if ag == nil {
1731+ return nil, fmt.Errorf("no agent running")
1732+ }
1733+ fresh, err := core.NewSession(root, cwd, providerName, model, version)
1734+ if err != nil {
1735+ return nil, err
1736+ }
1737+ if old != nil {
1738+ writeNewTranscriptLocked(ag, old, baseline)
1739+ _ = old.Close()
1740+ }
1741+ ag.SetMessages(nil)
1742+ ag.DrainQueuedMessages()
1743+ ag.SeedCost(provider.Usage{})
1744+ ag.SeedLastTurnUsage(provider.Usage{})
1745+ bindAgentSession(ag, fresh)
1746+ return fresh, nil
1747+}
1748+
16911749 // WriteNewTranscript appends only messages after index `from` from the
16921750 // agent's transcript to the session. Used by callers that don't hold
16931751 // the persistMu (non-interactive print/json modes which run a single
@@ -1105,6 +1105,40 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1105 return nil1105 return nil
1106 }1106 }
1107 1107
1108+ var newSession func(providerName, model string) error
1109+ if !args.NoSess {
1110+ newSession = func(providerName, model string) error {
1111+ currentAg := liveInteractiveAgent(iv, ag)
1112+ if currentAg == nil {
1113+ return fmt.Errorf("no agent running; log in first")
1114+ }
1115+
1116+ persistMu.Lock()
1117+ newSess, err := rotateInteractiveSession(
1118+ agentSessionsRoot(ZotHome(), args), r.CWD, providerName, model, version,
1119+ currentAg, sess, sessBaselineMsgs,
1120+ )
1121+ if err == nil {
1122+ sess = newSess
1123+ sessBaselineMsgs = 0
1124+ }
1125+ persistMu.Unlock()
1126+ if err != nil {
1127+ return err
1128+ }
1129+
1130+ active := r
1131+ active.Provider = providerName
1132+ active.Model = model
1133+ setZotSessionEnvironment(active, newSess)
1134+ startExtensionSession(extMgr, currentAg, r.CWD, "session_switch")
1135+ if swarmMgr != nil {
1136+ swarmMgr.SetActiveSession(newSess.ID)
1137+ }
1138+ return nil
1139+ }
1140+ }
1141+
1108 // changeCWD switches the running session to a new working directory.1142 // changeCWD switches the running session to a new working directory.
1109 // Wired into InteractiveConfig.ChangeCWD and invoked by the hidden1143 // Wired into InteractiveConfig.ChangeCWD and invoked by the hidden
1110 // /cd slash command (which itself is only fired by the workspaces1144 // /cd slash command (which itself is only fired by the workspaces
@@ -1424,6 +1458,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1424 }1458 }
1425 return out1459 return out
1426 },1460 },
1461+ NewSession: newSession,
1427 LoadSession: loadSession,1462 LoadSession: loadSession,
1428 ChangeCWD: changeCWD,1463 ChangeCWD: changeCWD,
1429 CurrentSessionPath: func() string {1464 CurrentSessionPath: func() string {
@@ -1688,6 +1723,29 @@ func pickSession(root, cwd string) (string, error) {
1688 return files[n-1], nil1723 return files[n-1], nil
1689 }1724 }
1690 1725
1726+// rotateInteractiveSession creates the replacement before mutating the live
1727+// agent, so a creation failure leaves the current conversation usable.
1728+// Callers must hold the interactive persistence mutex.
1729+func rotateInteractiveSession(root, cwd, providerName, model, version string, ag *core.Agent, old *core.Session, baseline int) (*core.Session, error) {
1730+ if ag == nil {
1731+ return nil, fmt.Errorf("no agent running")
1732+ }
1733+ fresh, err := core.NewSession(root, cwd, providerName, model, version)
1734+ if err != nil {
1735+ return nil, err
1736+ }
1737+ if old != nil {
1738+ writeNewTranscriptLocked(ag, old, baseline)
1739+ _ = old.Close()
1740+ }
1741+ ag.SetMessages(nil)
1742+ ag.DrainQueuedMessages()
1743+ ag.SeedCost(provider.Usage{})
1744+ ag.SeedLastTurnUsage(provider.Usage{})
1745+ bindAgentSession(ag, fresh)
1746+ return fresh, nil
1747+}
1748+
1691 // WriteNewTranscript appends only messages after index `from` from the1749 // WriteNewTranscript appends only messages after index `from` from the
1692 // agent's transcript to the session. Used by callers that don't hold1750 // agent's transcript to the session. Used by callers that don't hold
1693 // the persistMu (non-interactive print/json modes which run a single1751 // the persistMu (non-interactive print/json modes which run a single
M packages/agent/cli_session_test.go +68 -0
@@ -1,6 +1,8 @@
11 package agent
22
33 import (
4+ "os"
5+ "path/filepath"
46 "testing"
57
68 "github.com/patriceckhart/zot/packages/agent/modes"
@@ -48,6 +50,72 @@ func TestBindAgentSession(t *testing.T) {
4850 }
4951 }
5052
53+func TestRotateInteractiveSessionPreservesOldTranscriptAndResetsAgent(t *testing.T) {
54+ root := t.TempDir()
55+ cwd := t.TempDir()
56+ old, err := core.NewSession(root, cwd, "test-provider", "old-model", "test")
57+ if err != nil {
58+ t.Fatal(err)
59+ }
60+ ag := core.NewAgent(nil, "new-model", "", nil)
61+ ag.SetMessages([]provider.Message{{
62+ Role: provider.RoleUser,
63+ Content: []provider.Content{provider.TextBlock{Text: "keep this conversation"}},
64+ }})
65+ ag.SeedCost(provider.Usage{InputTokens: 12, CostUSD: 0.25})
66+ ag.SeedLastTurnUsage(provider.Usage{InputTokens: 12})
67+ ag.QueueMessage("drop queued prompt")
68+
69+ fresh, err := rotateInteractiveSession(root, cwd, "test-provider", "new-model", "test", ag, old, 0)
70+ if err != nil {
71+ t.Fatal(err)
72+ }
73+ defer fresh.Close()
74+ if fresh.ID == old.ID || fresh.Path == old.Path {
75+ t.Fatalf("session was not rotated: old=%s new=%s", old.Path, fresh.Path)
76+ }
77+ if ag.SessionID != fresh.ID {
78+ t.Fatalf("agent session ID = %q, want %q", ag.SessionID, fresh.ID)
79+ }
80+ if len(ag.Messages()) != 0 {
81+ t.Fatalf("new transcript is not empty: %+v", ag.Messages())
82+ }
83+ if got := ag.Cost(); got != (provider.Usage{}) {
84+ t.Fatalf("new session cost = %+v, want zero", got)
85+ }
86+ if got := ag.LastTurnUsage(); got != (provider.Usage{}) {
87+ t.Fatalf("new session last usage = %+v, want zero", got)
88+ }
89+ if got := ag.QueuedMessageCount(); got != 0 {
90+ t.Fatalf("queued messages = %d, want zero", got)
91+ }
92+
93+ persisted, messages, err := core.OpenSession(old.Path)
94+ if err != nil {
95+ t.Fatalf("open previous session: %v", err)
96+ }
97+ defer persisted.Close()
98+ if got := firstMessageText(messages); got != "keep this conversation" {
99+ t.Fatalf("previous transcript = %q", got)
100+ }
101+}
102+
103+func TestRotateInteractiveSessionCreationFailureLeavesAgentUntouched(t *testing.T) {
104+ ag := core.NewAgent(nil, "model", "", nil)
105+ ag.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "still active"}}}})
106+ blockedRoot := filepath.Join(t.TempDir(), "file")
107+ if err := os.WriteFile(blockedRoot, []byte("not a directory"), 0o600); err != nil {
108+ t.Fatal(err)
109+ }
110+
111+ if _, err := rotateInteractiveSession(blockedRoot, t.TempDir(), "test", "model", "test", ag, nil, 0); err == nil {
112+ t.Fatal("rotateInteractiveSession returned nil error for invalid root")
113+ }
114+ if got := firstMessageText(ag.Messages()); got != "still active" {
115+ t.Fatalf("agent transcript changed to %q after failed rotation", got)
116+ }
117+}
118+
51119 func TestLiveInteractiveAgentFallsBackBeforeInteractiveConstruction(t *testing.T) {
52120 startup := core.NewAgent(nil, "startup-model", "", nil)
53121 if got := liveInteractiveAgent(nil, startup); got != startup {
@@ -1,6 +1,8 @@
1 package agent1 package agent
2 2
3 import (3 import (
4+ "os"
5+ "path/filepath"
4 "testing"6 "testing"
5 7
6 "github.com/patriceckhart/zot/packages/agent/modes"8 "github.com/patriceckhart/zot/packages/agent/modes"
@@ -48,6 +50,72 @@ func TestBindAgentSession(t *testing.T) {
48 }50 }
49 }51 }
50 52
53+func TestRotateInteractiveSessionPreservesOldTranscriptAndResetsAgent(t *testing.T) {
54+ root := t.TempDir()
55+ cwd := t.TempDir()
56+ old, err := core.NewSession(root, cwd, "test-provider", "old-model", "test")
57+ if err != nil {
58+ t.Fatal(err)
59+ }
60+ ag := core.NewAgent(nil, "new-model", "", nil)
61+ ag.SetMessages([]provider.Message{{
62+ Role: provider.RoleUser,
63+ Content: []provider.Content{provider.TextBlock{Text: "keep this conversation"}},
64+ }})
65+ ag.SeedCost(provider.Usage{InputTokens: 12, CostUSD: 0.25})
66+ ag.SeedLastTurnUsage(provider.Usage{InputTokens: 12})
67+ ag.QueueMessage("drop queued prompt")
68+
69+ fresh, err := rotateInteractiveSession(root, cwd, "test-provider", "new-model", "test", ag, old, 0)
70+ if err != nil {
71+ t.Fatal(err)
72+ }
73+ defer fresh.Close()
74+ if fresh.ID == old.ID || fresh.Path == old.Path {
75+ t.Fatalf("session was not rotated: old=%s new=%s", old.Path, fresh.Path)
76+ }
77+ if ag.SessionID != fresh.ID {
78+ t.Fatalf("agent session ID = %q, want %q", ag.SessionID, fresh.ID)
79+ }
80+ if len(ag.Messages()) != 0 {
81+ t.Fatalf("new transcript is not empty: %+v", ag.Messages())
82+ }
83+ if got := ag.Cost(); got != (provider.Usage{}) {
84+ t.Fatalf("new session cost = %+v, want zero", got)
85+ }
86+ if got := ag.LastTurnUsage(); got != (provider.Usage{}) {
87+ t.Fatalf("new session last usage = %+v, want zero", got)
88+ }
89+ if got := ag.QueuedMessageCount(); got != 0 {
90+ t.Fatalf("queued messages = %d, want zero", got)
91+ }
92+
93+ persisted, messages, err := core.OpenSession(old.Path)
94+ if err != nil {
95+ t.Fatalf("open previous session: %v", err)
96+ }
97+ defer persisted.Close()
98+ if got := firstMessageText(messages); got != "keep this conversation" {
99+ t.Fatalf("previous transcript = %q", got)
100+ }
101+}
102+
103+func TestRotateInteractiveSessionCreationFailureLeavesAgentUntouched(t *testing.T) {
104+ ag := core.NewAgent(nil, "model", "", nil)
105+ ag.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "still active"}}}})
106+ blockedRoot := filepath.Join(t.TempDir(), "file")
107+ if err := os.WriteFile(blockedRoot, []byte("not a directory"), 0o600); err != nil {
108+ t.Fatal(err)
109+ }
110+
111+ if _, err := rotateInteractiveSession(blockedRoot, t.TempDir(), "test", "model", "test", ag, nil, 0); err == nil {
112+ t.Fatal("rotateInteractiveSession returned nil error for invalid root")
113+ }
114+ if got := firstMessageText(ag.Messages()); got != "still active" {
115+ t.Fatalf("agent transcript changed to %q after failed rotation", got)
116+ }
117+}
118+
51 func TestLiveInteractiveAgentFallsBackBeforeInteractiveConstruction(t *testing.T) {119 func TestLiveInteractiveAgentFallsBackBeforeInteractiveConstruction(t *testing.T) {
52 startup := core.NewAgent(nil, "startup-model", "", nil)120 startup := core.NewAgent(nil, "startup-model", "", nil)
53 if got := liveInteractiveAgent(nil, startup); got != startup {121 if got := liveInteractiveAgent(nil, startup); got != startup {
M packages/agent/modes/help.go +3 -3
@@ -36,7 +36,7 @@ var helpKeyRows = [][2]string{
3636 // section it lives in. The width is computed from the longest label
3737 // across BOTH lists, with a minimum of 14 cells so changes to either
3838 // list don't compress the column visually.
39-func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool, keymap []keymapBinding) []string {
39+func renderHelpBlock(th tui.Theme, width int, llamaConfigured, sessionsEnabled bool, keymap []keymapBinding) []string {
4040 if width < 20 {
4141 width = 20
4242 }
@@ -50,7 +50,7 @@ func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool, keymap []key
5050 // already, leaving its description mis-aligned).
5151 labelWidth := 14
5252 for _, c := range slashCatalog {
53- if c.Name == "/llama" && !llamaConfigured {
53+ if (c.Name == "/llama" && !llamaConfigured) || (c.Name == "/new" && !sessionsEnabled) {
5454 continue
5555 }
5656 if n := runewidth.StringWidth(c.Name); n > labelWidth {
@@ -82,7 +82,7 @@ func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool, keymap []key
8282 // commands section
8383 out = append(out, tui.Bold("slash commands:"))
8484 for _, c := range slashCatalog {
85- if c.Name == "/llama" && !llamaConfigured {
85+ if (c.Name == "/llama" && !llamaConfigured) || (c.Name == "/new" && !sessionsEnabled) {
8686 continue
8787 }
8888 out = append(out, fmt.Sprintf(" %s %s",
@@ -36,7 +36,7 @@ var helpKeyRows = [][2]string{
36 // section it lives in. The width is computed from the longest label36 // section it lives in. The width is computed from the longest label
37 // across BOTH lists, with a minimum of 14 cells so changes to either37 // across BOTH lists, with a minimum of 14 cells so changes to either
38 // list don't compress the column visually.38 // list don't compress the column visually.
39-func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool, keymap []keymapBinding) []string {39+func renderHelpBlock(th tui.Theme, width int, llamaConfigured, sessionsEnabled bool, keymap []keymapBinding) []string {
40 if width < 20 {40 if width < 20 {
41 width = 2041 width = 20
42 }42 }
@@ -50,7 +50,7 @@ func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool, keymap []key
50 // already, leaving its description mis-aligned).50 // already, leaving its description mis-aligned).
51 labelWidth := 1451 labelWidth := 14
52 for _, c := range slashCatalog {52 for _, c := range slashCatalog {
53- if c.Name == "/llama" && !llamaConfigured {53+ if (c.Name == "/llama" && !llamaConfigured) || (c.Name == "/new" && !sessionsEnabled) {
54 continue54 continue
55 }55 }
56 if n := runewidth.StringWidth(c.Name); n > labelWidth {56 if n := runewidth.StringWidth(c.Name); n > labelWidth {
@@ -82,7 +82,7 @@ func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool, keymap []key
82 // commands section82 // commands section
83 out = append(out, tui.Bold("slash commands:"))83 out = append(out, tui.Bold("slash commands:"))
84 for _, c := range slashCatalog {84 for _, c := range slashCatalog {
85- if c.Name == "/llama" && !llamaConfigured {85+ if (c.Name == "/llama" && !llamaConfigured) || (c.Name == "/new" && !sessionsEnabled) {
86 continue86 continue
87 }87 }
88 out = append(out, fmt.Sprintf(" %s %s",88 out = append(out, fmt.Sprintf(" %s %s",
M packages/agent/modes/help_test.go +14 -3
@@ -8,16 +8,27 @@ import (
88 )
99
1010 func TestHelpShowsLlamaOnlyWhenConfigured(t *testing.T) {
11- without := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, nil), "\n")
11+ without := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, true, nil), "\n")
1212 if strings.Contains(without, "/llama") {
1313 t.Fatalf("help exposed /llama without login: %q", without)
1414 }
15- with := strings.Join(renderHelpBlock(tui.Theme{}, 80, true, nil), "\n")
15+ with := strings.Join(renderHelpBlock(tui.Theme{}, 80, true, true, nil), "\n")
1616 if !strings.Contains(with, "/llama") {
1717 t.Fatalf("help omitted /llama with login: %q", with)
1818 }
1919 }
2020
21+func TestHelpHidesNewWhenSessionsAreDisabled(t *testing.T) {
22+ without := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, false, nil), "\n")
23+ if strings.Contains(without, "/new") {
24+ t.Fatalf("help exposed /new with sessions disabled: %q", without)
25+ }
26+ with := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, true, nil), "\n")
27+ if !strings.Contains(with, "/new") {
28+ t.Fatalf("help omitted /new with sessions enabled: %q", with)
29+ }
30+}
31+
2132 func TestHelpShowsConfiguredCustomKeys(t *testing.T) {
2233 bindings, issues := compileKeymap(map[string]string{
2334 "Ctrl+S": "/skill:review",
@@ -26,7 +37,7 @@ func TestHelpShowsConfiguredCustomKeys(t *testing.T) {
2637 if len(issues) != 1 {
2738 t.Fatalf("compileKeymap issues = %v, want one for bogus", issues)
2839 }
29- help := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, bindings), "\n")
40+ help := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, true, bindings), "\n")
3041 if !strings.Contains(help, "custom keys:") || !strings.Contains(help, "ctrl+s") || !strings.Contains(help, "/skill:review") {
3142 t.Fatalf("help omitted configured custom key: %q", help)
3243 }
@@ -8,16 +8,27 @@ import (
8 )8 )
9 9
10 func TestHelpShowsLlamaOnlyWhenConfigured(t *testing.T) {10 func TestHelpShowsLlamaOnlyWhenConfigured(t *testing.T) {
11- without := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, nil), "\n")11+ without := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, true, nil), "\n")
12 if strings.Contains(without, "/llama") {12 if strings.Contains(without, "/llama") {
13 t.Fatalf("help exposed /llama without login: %q", without)13 t.Fatalf("help exposed /llama without login: %q", without)
14 }14 }
15- with := strings.Join(renderHelpBlock(tui.Theme{}, 80, true, nil), "\n")15+ with := strings.Join(renderHelpBlock(tui.Theme{}, 80, true, true, nil), "\n")
16 if !strings.Contains(with, "/llama") {16 if !strings.Contains(with, "/llama") {
17 t.Fatalf("help omitted /llama with login: %q", with)17 t.Fatalf("help omitted /llama with login: %q", with)
18 }18 }
19 }19 }
20 20
21+func TestHelpHidesNewWhenSessionsAreDisabled(t *testing.T) {
22+ without := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, false, nil), "\n")
23+ if strings.Contains(without, "/new") {
24+ t.Fatalf("help exposed /new with sessions disabled: %q", without)
25+ }
26+ with := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, true, nil), "\n")
27+ if !strings.Contains(with, "/new") {
28+ t.Fatalf("help omitted /new with sessions enabled: %q", with)
29+ }
30+}
31+
21 func TestHelpShowsConfiguredCustomKeys(t *testing.T) {32 func TestHelpShowsConfiguredCustomKeys(t *testing.T) {
22 bindings, issues := compileKeymap(map[string]string{33 bindings, issues := compileKeymap(map[string]string{
23 "Ctrl+S": "/skill:review",34 "Ctrl+S": "/skill:review",
@@ -26,7 +37,7 @@ func TestHelpShowsConfiguredCustomKeys(t *testing.T) {
26 if len(issues) != 1 {37 if len(issues) != 1 {
27 t.Fatalf("compileKeymap issues = %v, want one for bogus", issues)38 t.Fatalf("compileKeymap issues = %v, want one for bogus", issues)
28 }39 }
29- help := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, bindings), "\n")40+ help := strings.Join(renderHelpBlock(tui.Theme{}, 80, false, true, bindings), "\n")
30 if !strings.Contains(help, "custom keys:") || !strings.Contains(help, "ctrl+s") || !strings.Contains(help, "/skill:review") {41 if !strings.Contains(help, "custom keys:") || !strings.Contains(help, "ctrl+s") || !strings.Contains(help, "/skill:review") {
31 t.Fatalf("help omitted configured custom key: %q", help)42 t.Fatalf("help omitted configured custom key: %q", help)
32 }43 }
M packages/agent/modes/interactive.go +70 -10
@@ -253,6 +253,11 @@ type InteractiveConfig struct {
253253 // Sandbox is the shared sandbox pointer. Toggled by /jail and /unjail.
254254 Sandbox *tools.Sandbox
255255
256+ // NewSession persists and closes the current session, then creates and
257+ // binds an empty session in the same working directory. It is nil when
258+ // persistence is disabled, including runs started with --no-session.
259+ NewSession func(providerName, model string) error
260+
256261 // LoadSession swaps the current session for the one at path. The
257262 // callback returns the new agent message slice so the TUI can invalidate.
258263 LoadSession func(path string) error
@@ -729,6 +734,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
729734 i.reloadErrors = append(i.reloadErrors, keymapIssues...)
730735 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
731736 i.suggest.SetFuzzySkills(cfg.FuzzySkillSuggest != nil && *cfg.FuzzySkillSuggest)
737+ i.suggest.SetSessionsEnabled(cfg.NewSession != nil)
732738 i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)
733739 if cfg.LlamaCPPConfig != nil {
734740 baseURL, _, err := cfg.LlamaCPPConfig()
@@ -1449,8 +1455,8 @@ func (i *Interactive) redraw() {
14491455 i.suggest.SetSkills(list)
14501456 }
14511457 // Slash popup renders even while the agent is busy so the user
1452- // can queue a destructive command (/clear, /compact, /logout,
1453- // /model) or a read-only one (/help, /jump, /sessions, etc.)
1458+ // can queue a destructive command (/new, /clear, /compact,
1459+ // /logout, /model) or a read-only one (/help, /jump, /sessions, etc.)
14541460 // without waiting for the current turn to finish. The dispatcher
14551461 // in runSlash already handles the busy case per-command: safe
14561462 // ones run immediately, destructive ones cancel the turn first.
@@ -2537,7 +2543,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
25372543 if !keymapReservedKey(k) {
25382544 if command := lookupKeymap(i.keymap, k); command != "" {
25392545 parts := strings.Fields(command)
2540- if len(parts) > 0 && slashCancelsTurn(parts[0]) {
2546+ if len(parts) > 0 && i.slashCancelsActiveTurn(parts[0]) {
25412547 i.cancelAndWaitForIdle()
25422548 }
25432549 i.ed.Clear()
@@ -2916,13 +2922,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
29162922 return false
29172923 }
29182924 // Slash commands run regardless of busy state. Commands that
2919- // would mutate the transcript or replace the agent (/clear,
2920- // /compact, /logout, /login, /model) cancel the active turn
2925+ // would mutate the transcript or replace the agent (/new,
2926+ // /clear, /compact, /logout, /login, /model) cancel the active turn
29212927 // first and wait for the goroutine to wind down so they don't
29222928 // race with a streaming response. Safe commands (/help,
29232929 // /jump, /sessions, /jail, /unjail, /exit) run immediately
29242930 // without disturbing the active turn.
2925- if slashCancelsTurn(head) {
2931+ if i.slashCancelsActiveTurn(head) {
29262932 i.cancelAndWaitForIdle()
29272933 }
29282934 return i.runSlash(ctx, text)
@@ -3331,7 +3337,7 @@ func (i *Interactive) SubmitSlash(text string) {
33313337 if idx := strings.IndexAny(text, " \t"); idx >= 0 {
33323338 head = text[:idx]
33333339 }
3334- if slashCancelsTurn(head) {
3340+ if i.slashCancelsActiveTurn(head) {
33353341 i.cancelAndWaitForIdle()
33363342 }
33373343 i.runSlash(i.runCtx, text)
@@ -4721,6 +4727,53 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
47214727 switch strings.ToLower(parts[0]) {
47224728 case "/exit":
47234729 return true
4730+ case "/new":
4731+ if i.cfg.NewSession == nil {
4732+ i.mu.Lock()
4733+ i.statusErr = "sessions are disabled by --no-session"
4734+ i.statusOK = ""
4735+ i.mu.Unlock()
4736+ break
4737+ }
4738+ if err := i.cfg.NewSession(i.cfg.Provider, i.cfg.Model); err != nil {
4739+ i.mu.Lock()
4740+ i.statusErr = "start new session: " + err.Error()
4741+ i.statusOK = ""
4742+ i.mu.Unlock()
4743+ break
4744+ }
4745+ i.mu.Lock()
4746+ i.toolCalls = map[string]*tui.ToolCallView{}
4747+ i.toolOrder = nil
4748+ i.toolGate = map[string]int{}
4749+ i.queued = nil
4750+ i.clipboardImages = nil
4751+ i.resetStreamingStateLocked()
4752+ i.clearPendingCompactTurnLocked()
4753+ i.pendingRescuePrompt = ""
4754+ i.pendingRescueImages = nil
4755+ i.statusErr = ""
4756+ i.statusOK = "started new session"
4757+ i.helpBlock = nil
4758+ i.parkedTurn = 0
4759+ i.parkedTotal = 0
4760+ i.scrollOffset = 0
4761+ i.prevChatLen = 0
4762+ i.prevChatCols = 0
4763+ i.extNotes = nil
4764+ i.reloadErrors = nil
4765+ i.cumUsage = provider.Usage{}
4766+ i.lastCtxInput = 0
4767+ i.inputHistoryIndex = -1
4768+ i.pendingFork = false
4769+ i.view.Messages = nil
4770+ i.view.TailLimit = 0
4771+ i.view.InvalidateRenderCache()
4772+ i.mu.Unlock()
4773+ if i.cfg.ConfirmGate != nil {
4774+ i.cfg.ConfirmGate.Reset()
4775+ }
4776+ i.SetPinnedSkillsPending(true)
47244777 case "/clear":
47254778 if i.agent != nil {
47264779 i.agent.SetMessages(nil)
@@ -4742,7 +4795,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
47424795 i.SetPinnedSkillsPending(true)
47434796 case "/help":
47444797 i.mu.Lock()
4745- i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured, i.keymap)
4798+ i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured, i.cfg.NewSession != nil, i.keymap)
47464799 i.statusErr = ""
47474800 i.statusOK = ""
47484801 // Pin the viewport to the newest content so the help block,
@@ -5328,14 +5381,21 @@ func (i *Interactive) submitManualOAuthCode(code string) {
53285381 }()
53295382 }
53305383
5384+// slashCancelsActiveTurn applies command availability to the destructive
5385+// command classification. In particular, /new must not cancel work when
5386+// session persistence is disabled and the command cannot run.
5387+func (i *Interactive) slashCancelsActiveTurn(head string) bool {
5388+ return slashCancelsTurn(head) && !(strings.EqualFold(head, "/new") && i.cfg.NewSession == nil)
5389+}
5390+
53315391 // applyModelSelection switches the active model (and provider, if the
53325392 // new model belongs to a different one). It rebuilds the underlying
53335393 // client when needed so the provider wire-protocol matches.
53345394 // cancelAndWaitForIdle cancels the active turn (if any) and blocks
53355395 // briefly until the turn goroutine has updated i.busy = false. Used
53365396 // before destructive slash commands so transcript-mutating work
5337-// (/clear, /compact, /logout, /login completion, cross-provider
5338-// /model swap) doesn't race with the still-running stream.
5397+// (/new, /clear, /compact, /logout, /login completion,
5398+// cross-provider /model swap) doesn't race with the still-running stream.
53395399 //
53405400 // The wait is bounded; if the turn doesn't release within the timeout
53415401 // we proceed anyway. Worst case is a brief overlap that the agent's
@@ -253,6 +253,11 @@ type InteractiveConfig struct {
253 // Sandbox is the shared sandbox pointer. Toggled by /jail and /unjail.253 // Sandbox is the shared sandbox pointer. Toggled by /jail and /unjail.
254 Sandbox *tools.Sandbox254 Sandbox *tools.Sandbox
255 255
256+ // NewSession persists and closes the current session, then creates and
257+ // binds an empty session in the same working directory. It is nil when
258+ // persistence is disabled, including runs started with --no-session.
259+ NewSession func(providerName, model string) error
260+
256 // LoadSession swaps the current session for the one at path. The261 // LoadSession swaps the current session for the one at path. The
257 // callback returns the new agent message slice so the TUI can invalidate.262 // callback returns the new agent message slice so the TUI can invalidate.
258 LoadSession func(path string) error263 LoadSession func(path string) error
@@ -729,6 +734,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
729 i.reloadErrors = append(i.reloadErrors, keymapIssues...)734 i.reloadErrors = append(i.reloadErrors, keymapIssues...)
730 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)735 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
731 i.suggest.SetFuzzySkills(cfg.FuzzySkillSuggest != nil && *cfg.FuzzySkillSuggest)736 i.suggest.SetFuzzySkills(cfg.FuzzySkillSuggest != nil && *cfg.FuzzySkillSuggest)
737+ i.suggest.SetSessionsEnabled(cfg.NewSession != nil)
732 i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)738 i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)
733 if cfg.LlamaCPPConfig != nil {739 if cfg.LlamaCPPConfig != nil {
734 baseURL, _, err := cfg.LlamaCPPConfig()740 baseURL, _, err := cfg.LlamaCPPConfig()
@@ -1449,8 +1455,8 @@ func (i *Interactive) redraw() {
1449 i.suggest.SetSkills(list)1455 i.suggest.SetSkills(list)
1450 }1456 }
1451 // Slash popup renders even while the agent is busy so the user1457 // Slash popup renders even while the agent is busy so the user
1452- // can queue a destructive command (/clear, /compact, /logout,1458+ // can queue a destructive command (/new, /clear, /compact,
1453- // /model) or a read-only one (/help, /jump, /sessions, etc.)1459+ // /logout, /model) or a read-only one (/help, /jump, /sessions, etc.)
1454 // without waiting for the current turn to finish. The dispatcher1460 // without waiting for the current turn to finish. The dispatcher
1455 // in runSlash already handles the busy case per-command: safe1461 // in runSlash already handles the busy case per-command: safe
1456 // ones run immediately, destructive ones cancel the turn first.1462 // ones run immediately, destructive ones cancel the turn first.
@@ -2537,7 +2543,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2537 if !keymapReservedKey(k) {2543 if !keymapReservedKey(k) {
2538 if command := lookupKeymap(i.keymap, k); command != "" {2544 if command := lookupKeymap(i.keymap, k); command != "" {
2539 parts := strings.Fields(command)2545 parts := strings.Fields(command)
2540- if len(parts) > 0 && slashCancelsTurn(parts[0]) {2546+ if len(parts) > 0 && i.slashCancelsActiveTurn(parts[0]) {
2541 i.cancelAndWaitForIdle()2547 i.cancelAndWaitForIdle()
2542 }2548 }
2543 i.ed.Clear()2549 i.ed.Clear()
@@ -2916,13 +2922,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2916 return false2922 return false
2917 }2923 }
2918 // Slash commands run regardless of busy state. Commands that2924 // Slash commands run regardless of busy state. Commands that
2919- // would mutate the transcript or replace the agent (/clear,2925+ // would mutate the transcript or replace the agent (/new,
2920- // /compact, /logout, /login, /model) cancel the active turn2926+ // /clear, /compact, /logout, /login, /model) cancel the active turn
2921 // first and wait for the goroutine to wind down so they don't2927 // first and wait for the goroutine to wind down so they don't
2922 // race with a streaming response. Safe commands (/help,2928 // race with a streaming response. Safe commands (/help,
2923 // /jump, /sessions, /jail, /unjail, /exit) run immediately2929 // /jump, /sessions, /jail, /unjail, /exit) run immediately
2924 // without disturbing the active turn.2930 // without disturbing the active turn.
2925- if slashCancelsTurn(head) {2931+ if i.slashCancelsActiveTurn(head) {
2926 i.cancelAndWaitForIdle()2932 i.cancelAndWaitForIdle()
2927 }2933 }
2928 return i.runSlash(ctx, text)2934 return i.runSlash(ctx, text)
@@ -3331,7 +3337,7 @@ func (i *Interactive) SubmitSlash(text string) {
3331 if idx := strings.IndexAny(text, " \t"); idx >= 0 {3337 if idx := strings.IndexAny(text, " \t"); idx >= 0 {
3332 head = text[:idx]3338 head = text[:idx]
3333 }3339 }
3334- if slashCancelsTurn(head) {3340+ if i.slashCancelsActiveTurn(head) {
3335 i.cancelAndWaitForIdle()3341 i.cancelAndWaitForIdle()
3336 }3342 }
3337 i.runSlash(i.runCtx, text)3343 i.runSlash(i.runCtx, text)
@@ -4721,6 +4727,53 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
4721 switch strings.ToLower(parts[0]) {4727 switch strings.ToLower(parts[0]) {
4722 case "/exit":4728 case "/exit":
4723 return true4729 return true
4730+ case "/new":
4731+ if i.cfg.NewSession == nil {
4732+ i.mu.Lock()
4733+ i.statusErr = "sessions are disabled by --no-session"
4734+ i.statusOK = ""
4735+ i.mu.Unlock()
4736+ break
4737+ }
4738+ if err := i.cfg.NewSession(i.cfg.Provider, i.cfg.Model); err != nil {
4739+ i.mu.Lock()
4740+ i.statusErr = "start new session: " + err.Error()
4741+ i.statusOK = ""
4742+ i.mu.Unlock()
4743+ break
4744+ }
4745+ i.mu.Lock()
4746+ i.toolCalls = map[string]*tui.ToolCallView{}
4747+ i.toolOrder = nil
4748+ i.toolGate = map[string]int{}
4749+ i.queued = nil
4750+ i.clipboardImages = nil
4751+ i.resetStreamingStateLocked()
4752+ i.clearPendingCompactTurnLocked()
4753+ i.pendingRescuePrompt = ""
4754+ i.pendingRescueImages = nil
4755+ i.statusErr = ""
4756+ i.statusOK = "started new session"
4757+ i.helpBlock = nil
4758+ i.parkedTurn = 0
4759+ i.parkedTotal = 0
4760+ i.scrollOffset = 0
4761+ i.prevChatLen = 0
4762+ i.prevChatCols = 0
4763+ i.extNotes = nil
4764+ i.reloadErrors = nil
4765+ i.cumUsage = provider.Usage{}
4766+ i.lastCtxInput = 0
4767+ i.inputHistoryIndex = -1
4768+ i.pendingFork = false
4769+ i.view.Messages = nil
4770+ i.view.TailLimit = 0
4771+ i.view.InvalidateRenderCache()
4772+ i.mu.Unlock()
4773+ if i.cfg.ConfirmGate != nil {
4774+ i.cfg.ConfirmGate.Reset()
4775+ }
4776+ i.SetPinnedSkillsPending(true)
4724 case "/clear":4777 case "/clear":
4725 if i.agent != nil {4778 if i.agent != nil {
4726 i.agent.SetMessages(nil)4779 i.agent.SetMessages(nil)
@@ -4742,7 +4795,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
4742 i.SetPinnedSkillsPending(true)4795 i.SetPinnedSkillsPending(true)
4743 case "/help":4796 case "/help":
4744 i.mu.Lock()4797 i.mu.Lock()
4745- i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured, i.keymap)4798+ i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured, i.cfg.NewSession != nil, i.keymap)
4746 i.statusErr = ""4799 i.statusErr = ""
4747 i.statusOK = ""4800 i.statusOK = ""
4748 // Pin the viewport to the newest content so the help block,4801 // Pin the viewport to the newest content so the help block,
@@ -5328,14 +5381,21 @@ func (i *Interactive) submitManualOAuthCode(code string) {
5328 }()5381 }()
5329 }5382 }
5330 5383
5384+// slashCancelsActiveTurn applies command availability to the destructive
5385+// command classification. In particular, /new must not cancel work when
5386+// session persistence is disabled and the command cannot run.
5387+func (i *Interactive) slashCancelsActiveTurn(head string) bool {
5388+ return slashCancelsTurn(head) && !(strings.EqualFold(head, "/new") && i.cfg.NewSession == nil)
5389+}
5390+
5331 // applyModelSelection switches the active model (and provider, if the5391 // applyModelSelection switches the active model (and provider, if the
5332 // new model belongs to a different one). It rebuilds the underlying5392 // new model belongs to a different one). It rebuilds the underlying
5333 // client when needed so the provider wire-protocol matches.5393 // client when needed so the provider wire-protocol matches.
5334 // cancelAndWaitForIdle cancels the active turn (if any) and blocks5394 // cancelAndWaitForIdle cancels the active turn (if any) and blocks
5335 // briefly until the turn goroutine has updated i.busy = false. Used5395 // briefly until the turn goroutine has updated i.busy = false. Used
5336 // before destructive slash commands so transcript-mutating work5396 // before destructive slash commands so transcript-mutating work
5337-// (/clear, /compact, /logout, /login completion, cross-provider5397+// (/new, /clear, /compact, /logout, /login completion,
5338-// /model swap) doesn't race with the still-running stream.5398+// cross-provider /model swap) doesn't race with the still-running stream.
5339 //5399 //
5340 // The wait is bounded; if the turn doesn't release within the timeout5400 // The wait is bounded; if the turn doesn't release within the timeout
5341 // we proceed anyway. Worst case is a brief overlap that the agent's5401 // we proceed anyway. Worst case is a brief overlap that the agent's
A packages/agent/modes/new_session_test.go +95 -0
@@ -0,0 +1,95 @@
1+package modes
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/patriceckhart/zot/packages/core"
9+ "github.com/patriceckhart/zot/packages/provider"
10+)
11+
12+func TestNewSessionUnavailableWithoutPersistence(t *testing.T) {
13+ i := NewInteractive(InteractiveConfig{})
14+ if i.slashCancelsActiveTurn("/new") {
15+ t.Fatal("unavailable /new would cancel the active turn")
16+ }
17+ i.runSlash(context.Background(), "/new")
18+
19+ if i.statusErr != "sessions are disabled by --no-session" {
20+ t.Fatalf("status error = %q", i.statusErr)
21+ }
22+}
23+
24+func TestNewSessionResetsConversationView(t *testing.T) {
25+ ag := core.NewAgent(nil, "model", "", nil)
26+ ag.SetMessages([]provider.Message{{
27+ Role: provider.RoleUser,
28+ Content: []provider.Content{provider.TextBlock{Text: "old conversation"}},
29+ }})
30+ calls := 0
31+ var gotProvider, gotModel string
32+ gate := core.NewConfirmGate(nil)
33+ gate.AllowAll()
34+ i := NewInteractive(InteractiveConfig{
35+ Agent: ag,
36+ Provider: "test-provider",
37+ Model: "active-model",
38+ ConfirmGate: gate,
39+ NewSession: func(providerName, model string) error {
40+ calls++
41+ gotProvider, gotModel = providerName, model
42+ ag.SetMessages(nil)
43+ return nil
44+ },
45+ })
46+ i.cumUsage = provider.Usage{InputTokens: 10}
47+ i.lastCtxInput = 10
48+ i.extNotes = []string{"old notice"}
49+
50+ i.runSlash(context.Background(), "/new")
51+
52+ if calls != 1 {
53+ t.Fatalf("NewSession calls = %d, want 1", calls)
54+ }
55+ if gotProvider != "test-provider" || gotModel != "active-model" {
56+ t.Fatalf("NewSession model = %q/%q", gotProvider, gotModel)
57+ }
58+ if len(ag.Messages()) != 0 || len(i.view.Messages) != 0 {
59+ t.Fatal("new session retained the previous transcript")
60+ }
61+ if i.cumUsage != (provider.Usage{}) || i.lastCtxInput != 0 {
62+ t.Fatalf("usage was not reset: cumulative=%+v context=%d", i.cumUsage, i.lastCtxInput)
63+ }
64+ if len(i.extNotes) != 0 {
65+ t.Fatalf("extension notices were not cleared: %v", i.extNotes)
66+ }
67+ if decision := gate.DecideToolCall(core.ToolCallConfirmation{Name: "bash"}); decision.Allow {
68+ t.Fatal("new session retained session-scoped confirmation approval")
69+ }
70+ if i.statusOK != "started new session" || i.statusErr != "" {
71+ t.Fatalf("status = %q, %q", i.statusOK, i.statusErr)
72+ }
73+}
74+
75+func TestNewSessionFailureKeepsConversationView(t *testing.T) {
76+ ag := core.NewAgent(nil, "model", "", nil)
77+ messages := []provider.Message{{
78+ Role: provider.RoleUser,
79+ Content: []provider.Content{provider.TextBlock{Text: "still active"}},
80+ }}
81+ ag.SetMessages(messages)
82+ i := NewInteractive(InteractiveConfig{
83+ Agent: ag,
84+ NewSession: func(string, string) error { return errors.New("disk full") },
85+ })
86+
87+ i.runSlash(context.Background(), "/new")
88+
89+ if len(ag.Messages()) != 1 || len(i.view.Messages) != 1 {
90+ t.Fatal("failed session creation cleared the current transcript")
91+ }
92+ if i.statusErr != "start new session: disk full" {
93+ t.Fatalf("status error = %q", i.statusErr)
94+ }
95+}
@@ -0,0 +1,95 @@
1+package modes
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/patriceckhart/zot/packages/core"
9+ "github.com/patriceckhart/zot/packages/provider"
10+)
11+
12+func TestNewSessionUnavailableWithoutPersistence(t *testing.T) {
13+ i := NewInteractive(InteractiveConfig{})
14+ if i.slashCancelsActiveTurn("/new") {
15+ t.Fatal("unavailable /new would cancel the active turn")
16+ }
17+ i.runSlash(context.Background(), "/new")
18+
19+ if i.statusErr != "sessions are disabled by --no-session" {
20+ t.Fatalf("status error = %q", i.statusErr)
21+ }
22+}
23+
24+func TestNewSessionResetsConversationView(t *testing.T) {
25+ ag := core.NewAgent(nil, "model", "", nil)
26+ ag.SetMessages([]provider.Message{{
27+ Role: provider.RoleUser,
28+ Content: []provider.Content{provider.TextBlock{Text: "old conversation"}},
29+ }})
30+ calls := 0
31+ var gotProvider, gotModel string
32+ gate := core.NewConfirmGate(nil)
33+ gate.AllowAll()
34+ i := NewInteractive(InteractiveConfig{
35+ Agent: ag,
36+ Provider: "test-provider",
37+ Model: "active-model",
38+ ConfirmGate: gate,
39+ NewSession: func(providerName, model string) error {
40+ calls++
41+ gotProvider, gotModel = providerName, model
42+ ag.SetMessages(nil)
43+ return nil
44+ },
45+ })
46+ i.cumUsage = provider.Usage{InputTokens: 10}
47+ i.lastCtxInput = 10
48+ i.extNotes = []string{"old notice"}
49+
50+ i.runSlash(context.Background(), "/new")
51+
52+ if calls != 1 {
53+ t.Fatalf("NewSession calls = %d, want 1", calls)
54+ }
55+ if gotProvider != "test-provider" || gotModel != "active-model" {
56+ t.Fatalf("NewSession model = %q/%q", gotProvider, gotModel)
57+ }
58+ if len(ag.Messages()) != 0 || len(i.view.Messages) != 0 {
59+ t.Fatal("new session retained the previous transcript")
60+ }
61+ if i.cumUsage != (provider.Usage{}) || i.lastCtxInput != 0 {
62+ t.Fatalf("usage was not reset: cumulative=%+v context=%d", i.cumUsage, i.lastCtxInput)
63+ }
64+ if len(i.extNotes) != 0 {
65+ t.Fatalf("extension notices were not cleared: %v", i.extNotes)
66+ }
67+ if decision := gate.DecideToolCall(core.ToolCallConfirmation{Name: "bash"}); decision.Allow {
68+ t.Fatal("new session retained session-scoped confirmation approval")
69+ }
70+ if i.statusOK != "started new session" || i.statusErr != "" {
71+ t.Fatalf("status = %q, %q", i.statusOK, i.statusErr)
72+ }
73+}
74+
75+func TestNewSessionFailureKeepsConversationView(t *testing.T) {
76+ ag := core.NewAgent(nil, "model", "", nil)
77+ messages := []provider.Message{{
78+ Role: provider.RoleUser,
79+ Content: []provider.Content{provider.TextBlock{Text: "still active"}},
80+ }}
81+ ag.SetMessages(messages)
82+ i := NewInteractive(InteractiveConfig{
83+ Agent: ag,
84+ NewSession: func(string, string) error { return errors.New("disk full") },
85+ })
86+
87+ i.runSlash(context.Background(), "/new")
88+
89+ if len(ag.Messages()) != 1 || len(i.view.Messages) != 1 {
90+ t.Fatal("failed session creation cleared the current transcript")
91+ }
92+ if i.statusErr != "start new session: disk full" {
93+ t.Fatalf("status error = %q", i.statusErr)
94+ }
95+}
M packages/agent/modes/slash_suggest.go +7 -2
@@ -28,7 +28,7 @@ type slashCommand struct {
2828 // the streaming response without trouble.
2929 func slashCancelsTurn(head string) bool {
3030 switch strings.ToLower(head) {
31- case "/clear", "/compact", "/logout", "/login", "/model", "/llama", "/reload-ext", "/cd":
31+ case "/new", "/clear", "/compact", "/logout", "/login", "/model", "/llama", "/reload-ext", "/cd":
3232 return true
3333 }
3434 return false
@@ -44,6 +44,7 @@ var slashCatalog = []slashCommand{
4444 {Name: "/reasoning", Desc: "set the reasoning level"},
4545 {Name: "/llama", Desc: "manage llama.cpp router models"},
4646 {Name: "/sessions", Desc: "resume a previous session for this directory"},
47+ {Name: "/new", Desc: "start a fresh session in this directory"},
4748 {Name: "/session", Desc: "manage the current session"},
4849 {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"},
4950 {Name: "/compact", Desc: "summarize and replace the transcript to free up context"},
@@ -78,6 +79,7 @@ type slashSuggester struct {
7879 // jailed and llamaConfigured drive state-dependent command visibility.
7980 jailed bool
8081 llamaConfigured bool
82+ sessionsEnabled bool
8183
8284 // extra are commands contributed by extensions, refreshed each
8385 // frame from the extension manager. Empty when no extensions
@@ -195,6 +197,9 @@ func (s *slashSuggester) SetJailed(jailed bool) { s.jailed = jailed }
195197 // available.
196198 func (s *slashSuggester) SetLlamaConfigured(configured bool) { s.llamaConfigured = configured }
197199
200+// SetSessionsEnabled controls commands that require persisted sessions.
201+func (s *slashSuggester) SetSessionsEnabled(enabled bool) { s.sessionsEnabled = enabled }
202+
198203 // allCatalog returns slashCatalog plus the current extra commands
199204 // (extension-registered) with a header divider between the two
200205 // groups. Extra entries are only kept if they don't collide with
@@ -235,7 +240,7 @@ func (s *slashSuggester) baseCatalog() []slashCommand {
235240 }
236241 out := make([]slashCommand, 0, len(slashCatalog)-1)
237242 for _, c := range slashCatalog {
238- if c.Name == hide || (c.Name == "/llama" && !s.llamaConfigured) {
243+ if c.Name == hide || (c.Name == "/llama" && !s.llamaConfigured) || (c.Name == "/new" && !s.sessionsEnabled) {
239244 continue
240245 }
241246 out = append(out, c)
@@ -28,7 +28,7 @@ type slashCommand struct {
28 // the streaming response without trouble.28 // the streaming response without trouble.
29 func slashCancelsTurn(head string) bool {29 func slashCancelsTurn(head string) bool {
30 switch strings.ToLower(head) {30 switch strings.ToLower(head) {
31- case "/clear", "/compact", "/logout", "/login", "/model", "/llama", "/reload-ext", "/cd":31+ case "/new", "/clear", "/compact", "/logout", "/login", "/model", "/llama", "/reload-ext", "/cd":
32 return true32 return true
33 }33 }
34 return false34 return false
@@ -44,6 +44,7 @@ var slashCatalog = []slashCommand{
44 {Name: "/reasoning", Desc: "set the reasoning level"},44 {Name: "/reasoning", Desc: "set the reasoning level"},
45 {Name: "/llama", Desc: "manage llama.cpp router models"},45 {Name: "/llama", Desc: "manage llama.cpp router models"},
46 {Name: "/sessions", Desc: "resume a previous session for this directory"},46 {Name: "/sessions", Desc: "resume a previous session for this directory"},
47+ {Name: "/new", Desc: "start a fresh session in this directory"},
47 {Name: "/session", Desc: "manage the current session"},48 {Name: "/session", Desc: "manage the current session"},
48 {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"},49 {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"},
49 {Name: "/compact", Desc: "summarize and replace the transcript to free up context"},50 {Name: "/compact", Desc: "summarize and replace the transcript to free up context"},
@@ -78,6 +79,7 @@ type slashSuggester struct {
78 // jailed and llamaConfigured drive state-dependent command visibility.79 // jailed and llamaConfigured drive state-dependent command visibility.
79 jailed bool80 jailed bool
80 llamaConfigured bool81 llamaConfigured bool
82+ sessionsEnabled bool
81 83
82 // extra are commands contributed by extensions, refreshed each84 // extra are commands contributed by extensions, refreshed each
83 // frame from the extension manager. Empty when no extensions85 // frame from the extension manager. Empty when no extensions
@@ -195,6 +197,9 @@ func (s *slashSuggester) SetJailed(jailed bool) { s.jailed = jailed }
195 // available.197 // available.
196 func (s *slashSuggester) SetLlamaConfigured(configured bool) { s.llamaConfigured = configured }198 func (s *slashSuggester) SetLlamaConfigured(configured bool) { s.llamaConfigured = configured }
197 199
200+// SetSessionsEnabled controls commands that require persisted sessions.
201+func (s *slashSuggester) SetSessionsEnabled(enabled bool) { s.sessionsEnabled = enabled }
202+
198 // allCatalog returns slashCatalog plus the current extra commands203 // allCatalog returns slashCatalog plus the current extra commands
199 // (extension-registered) with a header divider between the two204 // (extension-registered) with a header divider between the two
200 // groups. Extra entries are only kept if they don't collide with205 // groups. Extra entries are only kept if they don't collide with
@@ -235,7 +240,7 @@ func (s *slashSuggester) baseCatalog() []slashCommand {
235 }240 }
236 out := make([]slashCommand, 0, len(slashCatalog)-1)241 out := make([]slashCommand, 0, len(slashCatalog)-1)
237 for _, c := range slashCatalog {242 for _, c := range slashCatalog {
238- if c.Name == hide || (c.Name == "/llama" && !s.llamaConfigured) {243+ if c.Name == hide || (c.Name == "/llama" && !s.llamaConfigured) || (c.Name == "/new" && !s.sessionsEnabled) {
239 continue244 continue
240 }245 }
241 out = append(out, c)246 out = append(out, c)
M packages/agent/modes/slash_suggest_test.go +14 -0
@@ -37,6 +37,17 @@ func TestSlashSuggesterShowsLlamaOnlyWhenConfigured(t *testing.T) {
3737 }
3838 }
3939
40+func TestSlashSuggesterShowsNewOnlyWithSessions(t *testing.T) {
41+ s := newSlashSuggester()
42+ if got := commandNames(s.matches("/new")); contains(got, "/new") {
43+ t.Fatalf("/new visible with sessions disabled: %v", got)
44+ }
45+ s.SetSessionsEnabled(true)
46+ if got := commandNames(s.matches("/new")); !contains(got, "/new") {
47+ t.Fatalf("/new missing with sessions enabled: %v", got)
48+ }
49+}
50+
4051 func TestSlashSuggesterHasSwarm(t *testing.T) {
4152 s := newSlashSuggester()
4253 if got := commandNames(s.matches("/sw")); !contains(got, "/swarm") {
@@ -84,6 +95,9 @@ func TestSlashCommandsAreCaseInsensitive(t *testing.T) {
8495 if !slashCancelsTurn("/CLEAR") {
8596 t.Fatal("/CLEAR did not retain /clear cancellation semantics")
8697 }
98+ if !slashCancelsTurn("/NEW") {
99+ t.Fatal("/NEW did not retain /new cancellation semantics")
100+ }
87101 }
88102
89103 func TestSlashSuggesterBuiltinsShadowExtensionsCaseInsensitively(t *testing.T) {
@@ -37,6 +37,17 @@ func TestSlashSuggesterShowsLlamaOnlyWhenConfigured(t *testing.T) {
37 }37 }
38 }38 }
39 39
40+func TestSlashSuggesterShowsNewOnlyWithSessions(t *testing.T) {
41+ s := newSlashSuggester()
42+ if got := commandNames(s.matches("/new")); contains(got, "/new") {
43+ t.Fatalf("/new visible with sessions disabled: %v", got)
44+ }
45+ s.SetSessionsEnabled(true)
46+ if got := commandNames(s.matches("/new")); !contains(got, "/new") {
47+ t.Fatalf("/new missing with sessions enabled: %v", got)
48+ }
49+}
50+
40 func TestSlashSuggesterHasSwarm(t *testing.T) {51 func TestSlashSuggesterHasSwarm(t *testing.T) {
41 s := newSlashSuggester()52 s := newSlashSuggester()
42 if got := commandNames(s.matches("/sw")); !contains(got, "/swarm") {53 if got := commandNames(s.matches("/sw")); !contains(got, "/swarm") {
@@ -84,6 +95,9 @@ func TestSlashCommandsAreCaseInsensitive(t *testing.T) {
84 if !slashCancelsTurn("/CLEAR") {95 if !slashCancelsTurn("/CLEAR") {
85 t.Fatal("/CLEAR did not retain /clear cancellation semantics")96 t.Fatal("/CLEAR did not retain /clear cancellation semantics")
86 }97 }
98+ if !slashCancelsTurn("/NEW") {
99+ t.Fatal("/NEW did not retain /new cancellation semantics")
100+ }
87 }101 }
88 102
89 func TestSlashSuggesterBuiltinsShadowExtensionsCaseInsensitively(t *testing.T) {103 func TestSlashSuggesterBuiltinsShadowExtensionsCaseInsensitively(t *testing.T) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close