detached 92ac3e3

Fix interactive session resume

commit
92ac3e34cb24b63b72d91c70fe7b71c359766f23
author
patriceckhart · · 2026-07-29 07:28 UTC
parent
d3084cc
signature
unsigned
5 files · +139 -14
M docs/zotfiles.md +1 -1
@@ -471,7 +471,7 @@ Sessions created by a zotfile are isolated from ordinary zot sessions and from o
471471 $ZOT_HOME/sessions/agents/<name>/
472472 ```
473473
474-Normal session flags still apply, including `--continue`, `--resume`, `--session`, and `--no-session`, but their default storage root is scoped to the active agent.
474+Normal session flags still apply, including `--continue`, `--resume`, `--session`, and `--no-session`, but their default storage root is scoped to the active agent. The interactive `/sessions` picker and `/session` import, fork, and tree commands use that same agent-scoped root.
475475
476476 ## Commands
477477
@@ -471,7 +471,7 @@ Sessions created by a zotfile are isolated from ordinary zot sessions and from o
471 $ZOT_HOME/sessions/agents/<name>/471 $ZOT_HOME/sessions/agents/<name>/
472 ```472 ```
473 473
474-Normal session flags still apply, including `--continue`, `--resume`, `--session`, and `--no-session`, but their default storage root is scoped to the active agent.474+Normal session flags still apply, including `--continue`, `--resume`, `--session`, and `--no-session`, but their default storage root is scoped to the active agent. The interactive `/sessions` picker and `/session` import, fork, and tree commands use that same agent-scoped root.
475 475
476 ## Commands476 ## Commands
477 477
M packages/agent/cli.go +20 -6
@@ -122,11 +122,19 @@ func (a *extToolAdapter) NewExtensionTool(info ExtensionToolInfo) core.Tool {
122122 })
123123 }
124124
125-// fanoutAgentEvent translates a core.AgentEvent into the wire-format
126-// EventFromHost and pushes it through the extension manager. Only
127-// the events that have a clear extension-facing meaning are
128-// forwarded; internal-only ones (text_delta, tool_progress) are
129-// dropped to keep the per-extension stream sane.
125+// liveInteractiveAgent returns the agent currently owned by the TUI. The
126+// fallback is only used before the Interactive has been constructed.
127+//
128+// Agent rebuilds, such as cross-provider model switches, replace the TUI's
129+// agent without replacing the startup pointer held by runInteractive. Session
130+// operations must therefore resolve the live agent at the time of the action.
131+func liveInteractiveAgent(iv *modes.Interactive, fallback *core.Agent) *core.Agent {
132+ if iv != nil {
133+ return iv.Agent()
134+ }
135+ return fallback
136+}
137+
130138 func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Message {
131139 if keepTail <= 0 || len(msgs) <= keepTail {
132140 return provider.RepairOrphanedToolResults(msgs)
@@ -147,6 +155,11 @@ func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Mes
147155 return provider.RepairOrphanedToolResults(out)
148156 }
149157
158+// fanoutAgentEvent translates a core.AgentEvent into the wire-format
159+// EventFromHost and pushes it through the extension manager. Only
160+// the events that have a clear extension-facing meaning are
161+// forwarded; internal-only ones (text_delta, tool_progress) are
162+// dropped to keep the per-extension stream sane.
150163 func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
151164 if mgr == nil {
152165 return
@@ -732,7 +745,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
732745 // loadSession replaces the current session with the one at path and
733746 // hands its messages to the agent. Used by the /sessions picker.
734747 loadSession := func(path string) error {
735- currentAg := ag // captured
748+ currentAg := liveInteractiveAgent(iv, ag)
736749 if currentAg == nil {
737750 return fmt.Errorf("no agent running; log in first")
738751 }
@@ -1020,6 +1033,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
10201033 StartupSkillNames: startupSkillNames(startupSkills),
10211034 ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
10221035 ZotHome: ZotHome(),
1036+ SessionsRoot: agentSessionsRoot(ZotHome(), args),
10231037 Version: version,
10241038 UpdateInfoChan: updateCh,
10251039 Sandbox: sharedSandbox,
@@ -122,11 +122,19 @@ func (a *extToolAdapter) NewExtensionTool(info ExtensionToolInfo) core.Tool {
122 })122 })
123 }123 }
124 124
125-// fanoutAgentEvent translates a core.AgentEvent into the wire-format125+// liveInteractiveAgent returns the agent currently owned by the TUI. The
126-// EventFromHost and pushes it through the extension manager. Only126+// fallback is only used before the Interactive has been constructed.
127-// the events that have a clear extension-facing meaning are127+//
128-// forwarded; internal-only ones (text_delta, tool_progress) are128+// Agent rebuilds, such as cross-provider model switches, replace the TUI's
129-// dropped to keep the per-extension stream sane.129+// agent without replacing the startup pointer held by runInteractive. Session
130+// operations must therefore resolve the live agent at the time of the action.
131+func liveInteractiveAgent(iv *modes.Interactive, fallback *core.Agent) *core.Agent {
132+ if iv != nil {
133+ return iv.Agent()
134+ }
135+ return fallback
136+}
137+
130 func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Message {138 func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Message {
131 if keepTail <= 0 || len(msgs) <= keepTail {139 if keepTail <= 0 || len(msgs) <= keepTail {
132 return provider.RepairOrphanedToolResults(msgs)140 return provider.RepairOrphanedToolResults(msgs)
@@ -147,6 +155,11 @@ func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Mes
147 return provider.RepairOrphanedToolResults(out)155 return provider.RepairOrphanedToolResults(out)
148 }156 }
149 157
158+// fanoutAgentEvent translates a core.AgentEvent into the wire-format
159+// EventFromHost and pushes it through the extension manager. Only
160+// the events that have a clear extension-facing meaning are
161+// forwarded; internal-only ones (text_delta, tool_progress) are
162+// dropped to keep the per-extension stream sane.
150 func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {163 func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
151 if mgr == nil {164 if mgr == nil {
152 return165 return
@@ -732,7 +745,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
732 // loadSession replaces the current session with the one at path and745 // loadSession replaces the current session with the one at path and
733 // hands its messages to the agent. Used by the /sessions picker.746 // hands its messages to the agent. Used by the /sessions picker.
734 loadSession := func(path string) error {747 loadSession := func(path string) error {
735- currentAg := ag // captured748+ currentAg := liveInteractiveAgent(iv, ag)
736 if currentAg == nil {749 if currentAg == nil {
737 return fmt.Errorf("no agent running; log in first")750 return fmt.Errorf("no agent running; log in first")
738 }751 }
@@ -1020,6 +1033,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1020 StartupSkillNames: startupSkillNames(startupSkills),1033 StartupSkillNames: startupSkillNames(startupSkills),
1021 ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,1034 ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
1022 ZotHome: ZotHome(),1035 ZotHome: ZotHome(),
1036+ SessionsRoot: agentSessionsRoot(ZotHome(), args),
1023 Version: version,1037 Version: version,
1024 UpdateInfoChan: updateCh,1038 UpdateInfoChan: updateCh,
1025 Sandbox: sharedSandbox,1039 Sandbox: sharedSandbox,
A packages/agent/cli_session_test.go +48 -0
@@ -0,0 +1,48 @@
1+package agent
2+
3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/agent/modes"
7+ "github.com/patriceckhart/zot/packages/core"
8+ "github.com/patriceckhart/zot/packages/provider"
9+)
10+
11+func TestLiveInteractiveAgentUsesReplacementAgentForSessionResume(t *testing.T) {
12+ startup := core.NewAgent(nil, "startup-model", "", nil)
13+ startup.SetMessages([]provider.Message{{
14+ Role: provider.RoleUser,
15+ Content: []provider.Content{provider.TextBlock{Text: "startup transcript"}},
16+ }})
17+
18+ replacement := core.NewAgent(nil, "replacement-model", "", nil)
19+ iv := modes.NewInteractive(modes.InteractiveConfig{Agent: replacement})
20+
21+ resumed := []provider.Message{{
22+ Role: provider.RoleUser,
23+ Content: []provider.Content{provider.TextBlock{Text: "resumed transcript"}},
24+ }}
25+ liveInteractiveAgent(iv, startup).SetMessages(resumed)
26+
27+ if got := firstMessageText(replacement.Messages()); got != "resumed transcript" {
28+ t.Fatalf("replacement agent transcript = %q, want resumed transcript", got)
29+ }
30+ if got := firstMessageText(startup.Messages()); got != "startup transcript" {
31+ t.Fatalf("startup agent transcript changed to %q", got)
32+ }
33+}
34+
35+func TestLiveInteractiveAgentFallsBackBeforeInteractiveConstruction(t *testing.T) {
36+ startup := core.NewAgent(nil, "startup-model", "", nil)
37+ if got := liveInteractiveAgent(nil, startup); got != startup {
38+ t.Fatalf("fallback agent = %p, want %p", got, startup)
39+ }
40+}
41+
42+func firstMessageText(messages []provider.Message) string {
43+ if len(messages) == 0 || len(messages[0].Content) == 0 {
44+ return ""
45+ }
46+ text, _ := messages[0].Content[0].(provider.TextBlock)
47+ return text.Text
48+}
@@ -0,0 +1,48 @@
1+package agent
2+
3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/agent/modes"
7+ "github.com/patriceckhart/zot/packages/core"
8+ "github.com/patriceckhart/zot/packages/provider"
9+)
10+
11+func TestLiveInteractiveAgentUsesReplacementAgentForSessionResume(t *testing.T) {
12+ startup := core.NewAgent(nil, "startup-model", "", nil)
13+ startup.SetMessages([]provider.Message{{
14+ Role: provider.RoleUser,
15+ Content: []provider.Content{provider.TextBlock{Text: "startup transcript"}},
16+ }})
17+
18+ replacement := core.NewAgent(nil, "replacement-model", "", nil)
19+ iv := modes.NewInteractive(modes.InteractiveConfig{Agent: replacement})
20+
21+ resumed := []provider.Message{{
22+ Role: provider.RoleUser,
23+ Content: []provider.Content{provider.TextBlock{Text: "resumed transcript"}},
24+ }}
25+ liveInteractiveAgent(iv, startup).SetMessages(resumed)
26+
27+ if got := firstMessageText(replacement.Messages()); got != "resumed transcript" {
28+ t.Fatalf("replacement agent transcript = %q, want resumed transcript", got)
29+ }
30+ if got := firstMessageText(startup.Messages()); got != "startup transcript" {
31+ t.Fatalf("startup agent transcript changed to %q", got)
32+ }
33+}
34+
35+func TestLiveInteractiveAgentFallsBackBeforeInteractiveConstruction(t *testing.T) {
36+ startup := core.NewAgent(nil, "startup-model", "", nil)
37+ if got := liveInteractiveAgent(nil, startup); got != startup {
38+ t.Fatalf("fallback agent = %p, want %p", got, startup)
39+ }
40+}
41+
42+func firstMessageText(messages []provider.Message) string {
43+ if len(messages) == 0 || len(messages[0].Content) == 0 {
44+ return ""
45+ }
46+ text, _ := messages[0].Content[0].(provider.TextBlock)
47+ return text.Text
48+}
M packages/agent/modes/interactive.go +20 -7
@@ -171,10 +171,15 @@ type InteractiveConfig struct {
171171 // picker to only show reachable models.
172172 LoggedInProviders func() []string
173173
174- // ZotHome is the root directory for sessions/, used by /sessions
175- // and the update-check cache.
174+ // ZotHome is zot's global state directory, used by authentication,
175+ // themes, extensions, and other shared configuration.
176176 ZotHome string
177177
178+ // SessionsRoot is the root passed to core session operations. It differs
179+ // from ZotHome for Zotfile agents, whose sessions are isolated by agent
180+ // name. Empty falls back to ZotHome for embedders and tests.
181+ SessionsRoot string
182+
178183 // Version is the binary's current version (from main.version).
179184 // Used only for display; the update check itself is done outside
180185 // this package to avoid an import cycle.
@@ -1081,6 +1086,14 @@ func (i *Interactive) buildChatLocked(cols int) []string {
10811086 return chat
10821087 }
10831088
1089+// sessionsRoot returns the session namespace for this run.
1090+func (i *Interactive) sessionsRoot() string {
1091+ if i.cfg.SessionsRoot != "" {
1092+ return i.cfg.SessionsRoot
1093+ }
1094+ return i.cfg.ZotHome
1095+}
1096+
10841097 // lastCols returns the current terminal width in columns.
10851098 func (i *Interactive) lastCols() int {
10861099 cols, _ := i.cfg.Terminal.Size()
@@ -4220,7 +4233,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
42204233 case "/settings":
42214234 i.openSettingsDialog()
42224235 case "/sessions":
4223- i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
4236+ i.sessionDialog.Open(i.sessionsRoot(), i.cfg.CWD)
42244237 case "/jump":
42254238 i.openJumpDialog(parts[1:])
42264239 case "/btw":
@@ -6476,7 +6489,7 @@ func (i *Interactive) doSessionImport(src string) {
64766489 i.invalidate()
64776490 return
64786491 }
6479- newPath, err := core.ImportSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version)
6492+ newPath, err := core.ImportSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version)
64806493 if err != nil {
64816494 i.mu.Lock()
64826495 i.statusErr = "import: " + err.Error()
@@ -6605,7 +6618,7 @@ func (i *Interactive) doSessionTree() {
66056618 if i.cfg.CurrentSessionPath != nil {
66066619 current = i.cfg.CurrentSessionPath()
66076620 }
6608- if current != "" && i.sessionTreeDialog.OpenSessionFamily(i.cfg.ZotHome, i.cfg.CWD, current) {
6621+ if current != "" && i.sessionTreeDialog.OpenSessionFamily(i.sessionsRoot(), i.cfg.CWD, current) {
66096622 i.invalidate()
66106623 return
66116624 }
@@ -6657,7 +6670,7 @@ func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnN
66576670 if role == provider.RoleUser {
66586671 upTo = msgIdx
66596672 }
6660- newPath, err := core.BranchSessionHidden(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version, upTo)
6673+ newPath, err := core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo)
66616674 if err != nil {
66626675 i.mu.Lock()
66636676 i.statusErr = "tree: " + err.Error()
@@ -6712,7 +6725,7 @@ func (i *Interactive) applyForkSelection(msgIdx int) {
67126725 // msgIdx is 0-indexed message position; copy msgIdx+1 rows so
67136726 // the selected user message is included.
67146727 upTo := msgIdx + 1
6715- newPath, err := core.BranchSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version, upTo)
6728+ newPath, err := core.BranchSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo)
67166729 if err != nil {
67176730 i.mu.Lock()
67186731 i.statusErr = "fork: " + err.Error()
@@ -171,10 +171,15 @@ type InteractiveConfig struct {
171 // picker to only show reachable models.171 // picker to only show reachable models.
172 LoggedInProviders func() []string172 LoggedInProviders func() []string
173 173
174- // ZotHome is the root directory for sessions/, used by /sessions174+ // ZotHome is zot's global state directory, used by authentication,
175- // and the update-check cache.175+ // themes, extensions, and other shared configuration.
176 ZotHome string176 ZotHome string
177 177
178+ // SessionsRoot is the root passed to core session operations. It differs
179+ // from ZotHome for Zotfile agents, whose sessions are isolated by agent
180+ // name. Empty falls back to ZotHome for embedders and tests.
181+ SessionsRoot string
182+
178 // Version is the binary's current version (from main.version).183 // Version is the binary's current version (from main.version).
179 // Used only for display; the update check itself is done outside184 // Used only for display; the update check itself is done outside
180 // this package to avoid an import cycle.185 // this package to avoid an import cycle.
@@ -1081,6 +1086,14 @@ func (i *Interactive) buildChatLocked(cols int) []string {
1081 return chat1086 return chat
1082 }1087 }
1083 1088
1089+// sessionsRoot returns the session namespace for this run.
1090+func (i *Interactive) sessionsRoot() string {
1091+ if i.cfg.SessionsRoot != "" {
1092+ return i.cfg.SessionsRoot
1093+ }
1094+ return i.cfg.ZotHome
1095+}
1096+
1084 // lastCols returns the current terminal width in columns.1097 // lastCols returns the current terminal width in columns.
1085 func (i *Interactive) lastCols() int {1098 func (i *Interactive) lastCols() int {
1086 cols, _ := i.cfg.Terminal.Size()1099 cols, _ := i.cfg.Terminal.Size()
@@ -4220,7 +4233,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
4220 case "/settings":4233 case "/settings":
4221 i.openSettingsDialog()4234 i.openSettingsDialog()
4222 case "/sessions":4235 case "/sessions":
4223- i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)4236+ i.sessionDialog.Open(i.sessionsRoot(), i.cfg.CWD)
4224 case "/jump":4237 case "/jump":
4225 i.openJumpDialog(parts[1:])4238 i.openJumpDialog(parts[1:])
4226 case "/btw":4239 case "/btw":
@@ -6476,7 +6489,7 @@ func (i *Interactive) doSessionImport(src string) {
6476 i.invalidate()6489 i.invalidate()
6477 return6490 return
6478 }6491 }
6479- newPath, err := core.ImportSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version)6492+ newPath, err := core.ImportSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version)
6480 if err != nil {6493 if err != nil {
6481 i.mu.Lock()6494 i.mu.Lock()
6482 i.statusErr = "import: " + err.Error()6495 i.statusErr = "import: " + err.Error()
@@ -6605,7 +6618,7 @@ func (i *Interactive) doSessionTree() {
6605 if i.cfg.CurrentSessionPath != nil {6618 if i.cfg.CurrentSessionPath != nil {
6606 current = i.cfg.CurrentSessionPath()6619 current = i.cfg.CurrentSessionPath()
6607 }6620 }
6608- if current != "" && i.sessionTreeDialog.OpenSessionFamily(i.cfg.ZotHome, i.cfg.CWD, current) {6621+ if current != "" && i.sessionTreeDialog.OpenSessionFamily(i.sessionsRoot(), i.cfg.CWD, current) {
6609 i.invalidate()6622 i.invalidate()
6610 return6623 return
6611 }6624 }
@@ -6657,7 +6670,7 @@ func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnN
6657 if role == provider.RoleUser {6670 if role == provider.RoleUser {
6658 upTo = msgIdx6671 upTo = msgIdx
6659 }6672 }
6660- newPath, err := core.BranchSessionHidden(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version, upTo)6673+ newPath, err := core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo)
6661 if err != nil {6674 if err != nil {
6662 i.mu.Lock()6675 i.mu.Lock()
6663 i.statusErr = "tree: " + err.Error()6676 i.statusErr = "tree: " + err.Error()
@@ -6712,7 +6725,7 @@ func (i *Interactive) applyForkSelection(msgIdx int) {
6712 // msgIdx is 0-indexed message position; copy msgIdx+1 rows so6725 // msgIdx is 0-indexed message position; copy msgIdx+1 rows so
6713 // the selected user message is included.6726 // the selected user message is included.
6714 upTo := msgIdx + 16727 upTo := msgIdx + 1
6715- newPath, err := core.BranchSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version, upTo)6728+ newPath, err := core.BranchSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo)
6716 if err != nil {6729 if err != nil {
6717 i.mu.Lock()6730 i.mu.Lock()
6718 i.statusErr = "fork: " + err.Error()6731 i.statusErr = "fork: " + err.Error()
A packages/agent/modes/session_agent_root_test.go +50 -0
@@ -0,0 +1,50 @@
1+package modes
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/patriceckhart/zot/packages/core"
8+ "github.com/patriceckhart/zot/packages/provider"
9+)
10+
11+func TestSessionsSlashUsesAgentSessionRoot(t *testing.T) {
12+ zotHome := t.TempDir()
13+ agentRoot := t.TempDir()
14+ cwd := t.TempDir()
15+
16+ session, err := core.NewSession(agentRoot, cwd, "test", "test-model", "test-version")
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ if err := session.AppendMessage(provider.Message{
21+ Role: provider.RoleUser,
22+ Content: []provider.Content{provider.TextBlock{Text: "agent session"}},
23+ }); err != nil {
24+ t.Fatal(err)
25+ }
26+ if err := session.Close(); err != nil {
27+ t.Fatal(err)
28+ }
29+
30+ i := NewInteractive(InteractiveConfig{
31+ ZotHome: zotHome,
32+ SessionsRoot: agentRoot,
33+ CWD: cwd,
34+ })
35+ i.runSlash(context.Background(), "/sessions")
36+
37+ if got := len(i.sessionDialog.sessions); got != 1 {
38+ t.Fatalf("session picker entries = %d, want 1 from agent session root", got)
39+ }
40+ if got := i.sessionDialog.sessions[0].Path; got != session.Path {
41+ t.Fatalf("session picker path = %q, want %q", got, session.Path)
42+ }
43+}
44+
45+func TestSessionsRootDefaultsToZotHome(t *testing.T) {
46+ i := &Interactive{cfg: InteractiveConfig{ZotHome: "/zot/home"}}
47+ if got := i.sessionsRoot(); got != "/zot/home" {
48+ t.Fatalf("sessions root = %q, want ZotHome fallback", got)
49+ }
50+}
@@ -0,0 +1,50 @@
1+package modes
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/patriceckhart/zot/packages/core"
8+ "github.com/patriceckhart/zot/packages/provider"
9+)
10+
11+func TestSessionsSlashUsesAgentSessionRoot(t *testing.T) {
12+ zotHome := t.TempDir()
13+ agentRoot := t.TempDir()
14+ cwd := t.TempDir()
15+
16+ session, err := core.NewSession(agentRoot, cwd, "test", "test-model", "test-version")
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ if err := session.AppendMessage(provider.Message{
21+ Role: provider.RoleUser,
22+ Content: []provider.Content{provider.TextBlock{Text: "agent session"}},
23+ }); err != nil {
24+ t.Fatal(err)
25+ }
26+ if err := session.Close(); err != nil {
27+ t.Fatal(err)
28+ }
29+
30+ i := NewInteractive(InteractiveConfig{
31+ ZotHome: zotHome,
32+ SessionsRoot: agentRoot,
33+ CWD: cwd,
34+ })
35+ i.runSlash(context.Background(), "/sessions")
36+
37+ if got := len(i.sessionDialog.sessions); got != 1 {
38+ t.Fatalf("session picker entries = %d, want 1 from agent session root", got)
39+ }
40+ if got := i.sessionDialog.sessions[0].Path; got != session.Path {
41+ t.Fatalf("session picker path = %q, want %q", got, session.Path)
42+ }
43+}
44+
45+func TestSessionsRootDefaultsToZotHome(t *testing.T) {
46+ i := &Interactive{cfg: InteractiveConfig{ZotHome: "/zot/home"}}
47+ if got := i.sessionsRoot(); got != "/zot/home" {
48+ t.Fatalf("sessions root = %q, want ZotHome fallback", got)
49+ }
50+}

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