detached ef80f9c

feat(session): /session export + import with portable .zotsession file

Lets one user hand a conversation off to another machine or
user. New slash command:

  /session                    picker with export / import rows
  /session export             defaults to ~/Downloads/<name>.zotsession
  /session export ~/foo       writes ~/foo.zotsession
  /session export ~/bar/x.zs  writes to that exact path (ext added if missing)
  /session import <path>      loads and switches to it

Exported file is the same jsonl the live session writes, with
the meta row rewritten to strip the source user's cwd. The
importer rotates the id and cwd to claim the copy, so the
imported session becomes a first-class entry in the current
user's sessions/ directory and shows up in /sessions,
/jump, and on-disk summaries like any other.

core/session_portable.go (new)
  - ExportSession(src, dst) string  returns the resolved
    output path. dst can be a file, a directory, or a bare
    name missing the .zotsession ext; all three shapes land
    somewhere sensible.
  - ImportSession(src, root, cwd, version) string  returns
    the newly-created session file path, ready for
    OpenSession.
  - firstUserPrompt() + slugify() build descriptive
    "20260420-080305-3f268850-say-hello-in-one-sentence.zotsession"
    filenames when exporting into a directory.

core/session_portable_test.go (new)
  - Full round trip: write → export → import into a
    different cwd → OpenSession → message payloads match.
  - Verifies the exported meta drops the original cwd.
  - Verifies the .zotsession extension is appended when
    missing from dst.

modes/session_ops_dialog.go (new)
  - Tiny picker matching the telegramDialog / logoutDialog
    shape: arrow keys, enter, esc. Two rows (export / import)
    with muted hint text.

modes/interactive.go
  - sessionOpsDialog field + constructor + key dispatch +
    render selector, identical boilerplate to the other small
    dialogs.
  - openSessionOpsDialog, doSessionOp, doSessionExport,
    doSessionImport. Export uses CurrentSessionPath (new
    config hook); import calls core.ImportSession then routes
    through the existing LoadSession so the agent switches to
    the new file.
  - defaultExportDir (~/Downloads → ~ → /tmp fallback),
    expandTilde, friendlyPath helpers.

cli.go
  - CurrentSessionPath: sess.Path getter wired into the
    interactive config.

slash_suggest.go + README
  - /session listed in the slash catalog and the README
    commands table, with a short description of the two
    direct forms.

Not wired into the session_dialog.go picker (which stays
resume-only); a later change could add "export this one"
directly from the picker rows if that's useful.
commit
ef80f9cd806132fddeaf1cc09a9b1acf2e1ef3f9
author
patriceckhart · · 2026-04-20 08:04 UTC
parent
9a32f9c
signature
unsigned
refs
tag: v0.0.57
7 files · +800 -32
M README.md +1 -0
@@ -190,6 +190,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands:
190190 | `/logout [provider]` | Clear credentials for `anthropic`, `openai`, or all when omitted. |
191191 | `/model` | Pick a model from a list (or `/model <id>` to set directly). |
192192 | `/sessions` | Resume a previous session for this directory. |
193+| `/session` | Export the current session to a portable `.zotsession` file, or import one. Opens a picker without an argument; direct form: `/session export [path]` / `/session import <path>`. Default export destination is `~/Downloads`. |
193194 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |
194195 | `/btw` | Side chat with full context that doesn't add to the main thread. |
195196 | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. |
@@ -190,6 +190,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands:
190 | `/logout [provider]` | Clear credentials for `anthropic`, `openai`, or all when omitted. |190 | `/logout [provider]` | Clear credentials for `anthropic`, `openai`, or all when omitted. |
191 | `/model` | Pick a model from a list (or `/model <id>` to set directly). |191 | `/model` | Pick a model from a list (or `/model <id>` to set directly). |
192 | `/sessions` | Resume a previous session for this directory. |192 | `/sessions` | Resume a previous session for this directory. |
193+| `/session` | Export the current session to a portable `.zotsession` file, or import one. Opens a picker without an argument; direct form: `/session export [path]` / `/session import <path>`. Default export destination is `~/Downloads`. |
193 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |194 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |
194 | `/btw` | Side chat with full context that doesn't add to the main thread. |195 | `/btw` | Side chat with full context that doesn't add to the main thread. |
195 | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. |196 | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. |
M internal/agent/cli.go +8 -2
@@ -564,8 +564,14 @@ func runInteractive(ctx context.Context, args Args, version string) error {
564564 BuildAgent: buildAgent,
565565 BuildAgentFor: buildAgentFor,
566566 LoadSession: loadSession,
567- Extensions: extMgr,
568- ChangelogChan: changelogCh,
567+ CurrentSessionPath: func() string {
568+ if sess == nil {
569+ return ""
570+ }
571+ return sess.Path
572+ },
573+ Extensions: extMgr,
574+ ChangelogChan: changelogCh,
569575 OnChangelogDismiss: func() {
570576 _ = MarkChangelogShown(version)
571577 },
@@ -564,8 +564,14 @@ func runInteractive(ctx context.Context, args Args, version string) error {
564 BuildAgent: buildAgent,564 BuildAgent: buildAgent,
565 BuildAgentFor: buildAgentFor,565 BuildAgentFor: buildAgentFor,
566 LoadSession: loadSession,566 LoadSession: loadSession,
567- Extensions: extMgr,567+ CurrentSessionPath: func() string {
568- ChangelogChan: changelogCh,568+ if sess == nil {
569+ return ""
570+ }
571+ return sess.Path
572+ },
573+ Extensions: extMgr,
574+ ChangelogChan: changelogCh,
569 OnChangelogDismiss: func() {575 OnChangelogDismiss: func() {
570 _ = MarkChangelogShown(version)576 _ = MarkChangelogShown(version)
571 },577 },
M internal/agent/modes/interactive.go +237 -30
@@ -4,6 +4,8 @@ import (
44 "context"
55 "encoding/json"
66 "fmt"
7+ "os"
8+ "path/filepath"
79 "strings"
810 "sync"
911 "time"
@@ -74,6 +76,13 @@ type InteractiveConfig struct {
7476 // callback returns the new agent message slice so the TUI can invalidate.
7577 LoadSession func(path string) error
7678
79+ // CurrentSessionPath returns the path of the live session file
80+ // on disk (the one every AppendMessage writes to). Used by
81+ // /session export so the exporter ships the exact bytes on
82+ // disk. Returns an empty string when --no-session is set or
83+ // no session is open.
84+ CurrentSessionPath func() string
85+
7786 // PersistModel is called whenever the user switches model or provider.
7887 // It should update config.json and (if there's an active session)
7988 // write a new meta row so resume picks up the same model.
@@ -169,19 +178,20 @@ type Interactive struct {
169178 // while the check hasn't completed or nothing is available.
170179 updateInfo UpdateInfo
171180
172- dialog *loginDialog
173- modelDialog *modelDialog
174- sessionDialog *sessionDialog
175- jumpDialog *jumpDialog
176- btwDialog *btwDialog
177- skillsDialog *skillsDialog
178- changelogDialog *changelogDialog
179- confirmDialog *confirmDialog
180- logoutDialog *logoutDialog
181- telegramDialog *telegramDialog
182- telegramBridge *telegram.Bridge
183- suggest *slashSuggester
184- spin *spinner
181+ dialog *loginDialog
182+ modelDialog *modelDialog
183+ sessionDialog *sessionDialog
184+ jumpDialog *jumpDialog
185+ btwDialog *btwDialog
186+ skillsDialog *skillsDialog
187+ changelogDialog *changelogDialog
188+ confirmDialog *confirmDialog
189+ logoutDialog *logoutDialog
190+ telegramDialog *telegramDialog
191+ telegramBridge *telegram.Bridge
192+ sessionOpsDialog *sessionOpsDialog
193+ suggest *slashSuggester
194+ spin *spinner
185195
186196 // parkedTurn is the 1-based turn number the viewport is currently
187197 // scrolled to by /jump. 0 = not parked, showing the tail as usual.
@@ -219,22 +229,23 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
219229 Theme: cfg.Theme,
220230 ImageProto: tui.DetectImageProtocol(),
221231 },
222- ed: tui.NewEditor(cfg.Theme.FG256(cfg.Theme.Accent, "▌ ")),
223- rend: tui.NewRenderer(cfg.Terminal),
224- toolCalls: map[string]*tui.ToolCallView{},
225- dirty: make(chan struct{}, 8),
226- dialog: newLoginDialog(),
227- modelDialog: newModelDialog(),
228- sessionDialog: newSessionDialog(),
229- jumpDialog: newJumpDialog(),
230- btwDialog: newBtwDialog(),
231- skillsDialog: newSkillsDialog(),
232- changelogDialog: newChangelogDialog(),
233- confirmDialog: newConfirmDialog(),
234- logoutDialog: newLogoutDialog(),
235- telegramDialog: newTelegramDialog(),
236- suggest: newSlashSuggester(),
237- spin: newSpinner(),
232+ ed: tui.NewEditor(cfg.Theme.FG256(cfg.Theme.Accent, "▌ ")),
233+ rend: tui.NewRenderer(cfg.Terminal),
234+ toolCalls: map[string]*tui.ToolCallView{},
235+ dirty: make(chan struct{}, 8),
236+ dialog: newLoginDialog(),
237+ modelDialog: newModelDialog(),
238+ sessionDialog: newSessionDialog(),
239+ jumpDialog: newJumpDialog(),
240+ btwDialog: newBtwDialog(),
241+ skillsDialog: newSkillsDialog(),
242+ changelogDialog: newChangelogDialog(),
243+ confirmDialog: newConfirmDialog(),
244+ logoutDialog: newLogoutDialog(),
245+ telegramDialog: newTelegramDialog(),
246+ sessionOpsDialog: newSessionOpsDialog(),
247+ suggest: newSlashSuggester(),
248+ spin: newSpinner(),
238249 }
239250 if cfg.Agent != nil {
240251 i.agent = cfg.Agent
@@ -427,7 +438,7 @@ func (i *Interactive) Run(ctx context.Context) error {
427438 // and the AfterFunc-driven invalidate got dropped on a
428439 // full channel.
429440 drainPending()
430- if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() || i.logoutDialog.Active() || i.telegramDialog.Active() {
441+ if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() || i.logoutDialog.Active() || i.telegramDialog.Active() || i.sessionOpsDialog.Active() {
431442 requestRedraw() // keep the spinner / dialog animation moving
432443 }
433444 }
@@ -591,6 +602,8 @@ func (i *Interactive) redraw() {
591602 dialog = i.logoutDialog.Render(i.cfg.Theme, cols)
592603 case i.telegramDialog.Active():
593604 dialog = i.telegramDialog.Render(i.cfg.Theme, cols)
605+ case i.sessionOpsDialog.Active():
606+ dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)
594607 }
595608
596609 // Slash-command autocomplete: popup above the status line, only
@@ -868,6 +881,19 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
868881 i.invalidate()
869882 return false
870883 }
884+ if i.sessionOpsDialog.Active() {
885+ if k.Kind == tui.KeyCtrlC {
886+ i.sessionOpsDialog.Close()
887+ i.invalidate()
888+ return false
889+ }
890+ act := i.sessionOpsDialog.HandleKey(k)
891+ if act.Select {
892+ i.doSessionOp(act.Action, "")
893+ }
894+ i.invalidate()
895+ return false
896+ }
871897 if i.jumpDialog.Active() {
872898 if k.Kind == tui.KeyCtrlC {
873899 i.jumpDialog.Close()
@@ -1342,6 +1368,17 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
13421368 break
13431369 }
13441370 i.openTelegramDialog()
1371+ case "/session":
1372+ if len(parts) >= 2 {
1373+ action := parts[1]
1374+ arg := ""
1375+ if len(parts) >= 3 {
1376+ arg = strings.Join(parts[2:], " ")
1377+ }
1378+ i.doSessionOp(action, arg)
1379+ break
1380+ }
1381+ i.openSessionOpsDialog()
13451382 default:
13461383 // Last-resort fallback: try the extension manager. Built-in
13471384 // cases above always win; this branch only fires for slash
@@ -2474,3 +2511,173 @@ func (h *telegramHost) Notify(level, message string) {
24742511 h.iv.mu.Unlock()
24752512 h.iv.invalidate()
24762513 }
2514+
2515+// openSessionOpsDialog shows the picker for `/session` with no arg.
2516+// Always offers both export and import; the handlers bail with a
2517+// clear status message when the precondition isn't met (empty
2518+// transcript on export; missing file on import).
2519+func (i *Interactive) openSessionOpsDialog() {
2520+ items := []sessionOpsItem{
2521+ {label: "export", action: "export", hint: "write the current session to a .zotsession file"},
2522+ {label: "import", action: "import", hint: "load a .zotsession file into this directory"},
2523+ }
2524+ i.sessionOpsDialog.Open(items)
2525+ i.invalidate()
2526+}
2527+
2528+// doSessionOp dispatches export or import. path is the optional
2529+// positional argument from /session export <path> or /session
2530+// import <path>; when empty, sensible defaults apply (see
2531+// doSessionExport / doSessionImport).
2532+func (i *Interactive) doSessionOp(action, path string) {
2533+ switch action {
2534+ case "export":
2535+ i.doSessionExport(path)
2536+ case "import":
2537+ i.doSessionImport(path)
2538+ default:
2539+ i.mu.Lock()
2540+ i.statusErr = "unknown /session action: " + action + " (use export or import)"
2541+ i.mu.Unlock()
2542+ i.invalidate()
2543+ }
2544+}
2545+
2546+// doSessionExport writes the live session file to destination path
2547+// dst. When dst is empty we default to ~/Downloads (falling back to
2548+// the user's home directory if it doesn't exist). The helper
2549+// expands a leading `~` and creates any missing parent directories.
2550+func (i *Interactive) doSessionExport(dst string) {
2551+ if i.cfg.CurrentSessionPath == nil {
2552+ i.mu.Lock()
2553+ i.statusErr = "export: no session is active (running with --no-session?)"
2554+ i.mu.Unlock()
2555+ i.invalidate()
2556+ return
2557+ }
2558+ src := i.cfg.CurrentSessionPath()
2559+ if src == "" {
2560+ i.mu.Lock()
2561+ i.statusErr = "export: no session is active (running with --no-session?)"
2562+ i.mu.Unlock()
2563+ i.invalidate()
2564+ return
2565+ }
2566+ dst = strings.TrimSpace(dst)
2567+ if dst == "" {
2568+ dst = defaultExportDir()
2569+ } else {
2570+ dst = expandTilde(dst)
2571+ }
2572+ out, err := core.ExportSession(src, dst)
2573+ if err != nil {
2574+ i.mu.Lock()
2575+ i.statusErr = "export: " + err.Error()
2576+ i.mu.Unlock()
2577+ i.invalidate()
2578+ return
2579+ }
2580+ i.mu.Lock()
2581+ i.statusOK = "exported session to " + friendlyPath(out)
2582+ i.statusErr = ""
2583+ i.mu.Unlock()
2584+ i.invalidate()
2585+}
2586+
2587+// doSessionImport copies the .zotsession file at src into the
2588+// running cwd's sessions directory and loads it as the active
2589+// session, same as `/sessions` -> pick. When src is empty we ask
2590+// the user to pass a path (no usable default here).
2591+func (i *Interactive) doSessionImport(src string) {
2592+ src = strings.TrimSpace(src)
2593+ if src == "" {
2594+ i.mu.Lock()
2595+ i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zotsession"
2596+ i.mu.Unlock()
2597+ i.invalidate()
2598+ return
2599+ }
2600+ src = expandTilde(src)
2601+ if _, err := os.Stat(src); err != nil {
2602+ i.mu.Lock()
2603+ i.statusErr = "import: " + err.Error()
2604+ i.mu.Unlock()
2605+ i.invalidate()
2606+ return
2607+ }
2608+ newPath, err := core.ImportSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version)
2609+ if err != nil {
2610+ i.mu.Lock()
2611+ i.statusErr = "import: " + err.Error()
2612+ i.mu.Unlock()
2613+ i.invalidate()
2614+ return
2615+ }
2616+ if i.cfg.LoadSession == nil {
2617+ i.mu.Lock()
2618+ i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)"
2619+ i.statusErr = ""
2620+ i.mu.Unlock()
2621+ i.invalidate()
2622+ return
2623+ }
2624+ if err := i.cfg.LoadSession(newPath); err != nil {
2625+ i.mu.Lock()
2626+ i.statusErr = "import: load failed: " + err.Error()
2627+ i.mu.Unlock()
2628+ i.invalidate()
2629+ return
2630+ }
2631+ i.mu.Lock()
2632+ i.statusOK = "imported and switched to session " + friendlyPath(newPath)
2633+ i.statusErr = ""
2634+ i.mu.Unlock()
2635+ i.invalidate()
2636+}
2637+
2638+// defaultExportDir returns ~/Downloads when it exists, or ~ as a
2639+// fallback, or /tmp on exotic machines with no home dir.
2640+func defaultExportDir() string {
2641+ home, err := os.UserHomeDir()
2642+ if err != nil || home == "" {
2643+ return os.TempDir()
2644+ }
2645+ downloads := filepath.Join(home, "Downloads")
2646+ if fi, err := os.Stat(downloads); err == nil && fi.IsDir() {
2647+ return downloads
2648+ }
2649+ return home
2650+}
2651+
2652+// expandTilde turns a leading ~ into the user's home directory.
2653+// Returns the input unchanged when there's no tilde or no home.
2654+func expandTilde(p string) string {
2655+ if p == "" || p[0] != '~' {
2656+ return p
2657+ }
2658+ home, err := os.UserHomeDir()
2659+ if err != nil || home == "" {
2660+ return p
2661+ }
2662+ if len(p) == 1 {
2663+ return home
2664+ }
2665+ if p[1] == '/' || p[1] == filepath.Separator {
2666+ return filepath.Join(home, p[2:])
2667+ }
2668+ return p
2669+}
2670+
2671+// friendlyPath collapses the user's home directory to a leading ~
2672+// so status messages read cleanly. Falls back to the raw path when
2673+// the home dir is unknown.
2674+func friendlyPath(p string) string {
2675+ home, err := os.UserHomeDir()
2676+ if err != nil || home == "" {
2677+ return p
2678+ }
2679+ if strings.HasPrefix(p, home+string(filepath.Separator)) {
2680+ return "~" + p[len(home):]
2681+ }
2682+ return p
2683+}
@@ -4,6 +4,8 @@ import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6 "fmt"6 "fmt"
7+ "os"
8+ "path/filepath"
7 "strings"9 "strings"
8 "sync"10 "sync"
9 "time"11 "time"
@@ -74,6 +76,13 @@ type InteractiveConfig struct {
74 // callback returns the new agent message slice so the TUI can invalidate.76 // callback returns the new agent message slice so the TUI can invalidate.
75 LoadSession func(path string) error77 LoadSession func(path string) error
76 78
79+ // CurrentSessionPath returns the path of the live session file
80+ // on disk (the one every AppendMessage writes to). Used by
81+ // /session export so the exporter ships the exact bytes on
82+ // disk. Returns an empty string when --no-session is set or
83+ // no session is open.
84+ CurrentSessionPath func() string
85+
77 // PersistModel is called whenever the user switches model or provider.86 // PersistModel is called whenever the user switches model or provider.
78 // It should update config.json and (if there's an active session)87 // It should update config.json and (if there's an active session)
79 // write a new meta row so resume picks up the same model.88 // write a new meta row so resume picks up the same model.
@@ -169,19 +178,20 @@ type Interactive struct {
169 // while the check hasn't completed or nothing is available.178 // while the check hasn't completed or nothing is available.
170 updateInfo UpdateInfo179 updateInfo UpdateInfo
171 180
172- dialog *loginDialog181+ dialog *loginDialog
173- modelDialog *modelDialog182+ modelDialog *modelDialog
174- sessionDialog *sessionDialog183+ sessionDialog *sessionDialog
175- jumpDialog *jumpDialog184+ jumpDialog *jumpDialog
176- btwDialog *btwDialog185+ btwDialog *btwDialog
177- skillsDialog *skillsDialog186+ skillsDialog *skillsDialog
178- changelogDialog *changelogDialog187+ changelogDialog *changelogDialog
179- confirmDialog *confirmDialog188+ confirmDialog *confirmDialog
180- logoutDialog *logoutDialog189+ logoutDialog *logoutDialog
181- telegramDialog *telegramDialog190+ telegramDialog *telegramDialog
182- telegramBridge *telegram.Bridge191+ telegramBridge *telegram.Bridge
183- suggest *slashSuggester192+ sessionOpsDialog *sessionOpsDialog
184- spin *spinner193+ suggest *slashSuggester
194+ spin *spinner
185 195
186 // parkedTurn is the 1-based turn number the viewport is currently196 // parkedTurn is the 1-based turn number the viewport is currently
187 // scrolled to by /jump. 0 = not parked, showing the tail as usual.197 // scrolled to by /jump. 0 = not parked, showing the tail as usual.
@@ -219,22 +229,23 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
219 Theme: cfg.Theme,229 Theme: cfg.Theme,
220 ImageProto: tui.DetectImageProtocol(),230 ImageProto: tui.DetectImageProtocol(),
221 },231 },
222- ed: tui.NewEditor(cfg.Theme.FG256(cfg.Theme.Accent, "▌ ")),232+ ed: tui.NewEditor(cfg.Theme.FG256(cfg.Theme.Accent, "▌ ")),
223- rend: tui.NewRenderer(cfg.Terminal),233+ rend: tui.NewRenderer(cfg.Terminal),
224- toolCalls: map[string]*tui.ToolCallView{},234+ toolCalls: map[string]*tui.ToolCallView{},
225- dirty: make(chan struct{}, 8),235+ dirty: make(chan struct{}, 8),
226- dialog: newLoginDialog(),236+ dialog: newLoginDialog(),
227- modelDialog: newModelDialog(),237+ modelDialog: newModelDialog(),
228- sessionDialog: newSessionDialog(),238+ sessionDialog: newSessionDialog(),
229- jumpDialog: newJumpDialog(),239+ jumpDialog: newJumpDialog(),
230- btwDialog: newBtwDialog(),240+ btwDialog: newBtwDialog(),
231- skillsDialog: newSkillsDialog(),241+ skillsDialog: newSkillsDialog(),
232- changelogDialog: newChangelogDialog(),242+ changelogDialog: newChangelogDialog(),
233- confirmDialog: newConfirmDialog(),243+ confirmDialog: newConfirmDialog(),
234- logoutDialog: newLogoutDialog(),244+ logoutDialog: newLogoutDialog(),
235- telegramDialog: newTelegramDialog(),245+ telegramDialog: newTelegramDialog(),
236- suggest: newSlashSuggester(),246+ sessionOpsDialog: newSessionOpsDialog(),
237- spin: newSpinner(),247+ suggest: newSlashSuggester(),
248+ spin: newSpinner(),
238 }249 }
239 if cfg.Agent != nil {250 if cfg.Agent != nil {
240 i.agent = cfg.Agent251 i.agent = cfg.Agent
@@ -427,7 +438,7 @@ func (i *Interactive) Run(ctx context.Context) error {
427 // and the AfterFunc-driven invalidate got dropped on a438 // and the AfterFunc-driven invalidate got dropped on a
428 // full channel.439 // full channel.
429 drainPending()440 drainPending()
430- if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() || i.logoutDialog.Active() || i.telegramDialog.Active() {441+ if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() || i.logoutDialog.Active() || i.telegramDialog.Active() || i.sessionOpsDialog.Active() {
431 requestRedraw() // keep the spinner / dialog animation moving442 requestRedraw() // keep the spinner / dialog animation moving
432 }443 }
433 }444 }
@@ -591,6 +602,8 @@ func (i *Interactive) redraw() {
591 dialog = i.logoutDialog.Render(i.cfg.Theme, cols)602 dialog = i.logoutDialog.Render(i.cfg.Theme, cols)
592 case i.telegramDialog.Active():603 case i.telegramDialog.Active():
593 dialog = i.telegramDialog.Render(i.cfg.Theme, cols)604 dialog = i.telegramDialog.Render(i.cfg.Theme, cols)
605+ case i.sessionOpsDialog.Active():
606+ dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)
594 }607 }
595 608
596 // Slash-command autocomplete: popup above the status line, only609 // Slash-command autocomplete: popup above the status line, only
@@ -868,6 +881,19 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
868 i.invalidate()881 i.invalidate()
869 return false882 return false
870 }883 }
884+ if i.sessionOpsDialog.Active() {
885+ if k.Kind == tui.KeyCtrlC {
886+ i.sessionOpsDialog.Close()
887+ i.invalidate()
888+ return false
889+ }
890+ act := i.sessionOpsDialog.HandleKey(k)
891+ if act.Select {
892+ i.doSessionOp(act.Action, "")
893+ }
894+ i.invalidate()
895+ return false
896+ }
871 if i.jumpDialog.Active() {897 if i.jumpDialog.Active() {
872 if k.Kind == tui.KeyCtrlC {898 if k.Kind == tui.KeyCtrlC {
873 i.jumpDialog.Close()899 i.jumpDialog.Close()
@@ -1342,6 +1368,17 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
1342 break1368 break
1343 }1369 }
1344 i.openTelegramDialog()1370 i.openTelegramDialog()
1371+ case "/session":
1372+ if len(parts) >= 2 {
1373+ action := parts[1]
1374+ arg := ""
1375+ if len(parts) >= 3 {
1376+ arg = strings.Join(parts[2:], " ")
1377+ }
1378+ i.doSessionOp(action, arg)
1379+ break
1380+ }
1381+ i.openSessionOpsDialog()
1345 default:1382 default:
1346 // Last-resort fallback: try the extension manager. Built-in1383 // Last-resort fallback: try the extension manager. Built-in
1347 // cases above always win; this branch only fires for slash1384 // cases above always win; this branch only fires for slash
@@ -2474,3 +2511,173 @@ func (h *telegramHost) Notify(level, message string) {
2474 h.iv.mu.Unlock()2511 h.iv.mu.Unlock()
2475 h.iv.invalidate()2512 h.iv.invalidate()
2476 }2513 }
2514+
2515+// openSessionOpsDialog shows the picker for `/session` with no arg.
2516+// Always offers both export and import; the handlers bail with a
2517+// clear status message when the precondition isn't met (empty
2518+// transcript on export; missing file on import).
2519+func (i *Interactive) openSessionOpsDialog() {
2520+ items := []sessionOpsItem{
2521+ {label: "export", action: "export", hint: "write the current session to a .zotsession file"},
2522+ {label: "import", action: "import", hint: "load a .zotsession file into this directory"},
2523+ }
2524+ i.sessionOpsDialog.Open(items)
2525+ i.invalidate()
2526+}
2527+
2528+// doSessionOp dispatches export or import. path is the optional
2529+// positional argument from /session export <path> or /session
2530+// import <path>; when empty, sensible defaults apply (see
2531+// doSessionExport / doSessionImport).
2532+func (i *Interactive) doSessionOp(action, path string) {
2533+ switch action {
2534+ case "export":
2535+ i.doSessionExport(path)
2536+ case "import":
2537+ i.doSessionImport(path)
2538+ default:
2539+ i.mu.Lock()
2540+ i.statusErr = "unknown /session action: " + action + " (use export or import)"
2541+ i.mu.Unlock()
2542+ i.invalidate()
2543+ }
2544+}
2545+
2546+// doSessionExport writes the live session file to destination path
2547+// dst. When dst is empty we default to ~/Downloads (falling back to
2548+// the user's home directory if it doesn't exist). The helper
2549+// expands a leading `~` and creates any missing parent directories.
2550+func (i *Interactive) doSessionExport(dst string) {
2551+ if i.cfg.CurrentSessionPath == nil {
2552+ i.mu.Lock()
2553+ i.statusErr = "export: no session is active (running with --no-session?)"
2554+ i.mu.Unlock()
2555+ i.invalidate()
2556+ return
2557+ }
2558+ src := i.cfg.CurrentSessionPath()
2559+ if src == "" {
2560+ i.mu.Lock()
2561+ i.statusErr = "export: no session is active (running with --no-session?)"
2562+ i.mu.Unlock()
2563+ i.invalidate()
2564+ return
2565+ }
2566+ dst = strings.TrimSpace(dst)
2567+ if dst == "" {
2568+ dst = defaultExportDir()
2569+ } else {
2570+ dst = expandTilde(dst)
2571+ }
2572+ out, err := core.ExportSession(src, dst)
2573+ if err != nil {
2574+ i.mu.Lock()
2575+ i.statusErr = "export: " + err.Error()
2576+ i.mu.Unlock()
2577+ i.invalidate()
2578+ return
2579+ }
2580+ i.mu.Lock()
2581+ i.statusOK = "exported session to " + friendlyPath(out)
2582+ i.statusErr = ""
2583+ i.mu.Unlock()
2584+ i.invalidate()
2585+}
2586+
2587+// doSessionImport copies the .zotsession file at src into the
2588+// running cwd's sessions directory and loads it as the active
2589+// session, same as `/sessions` -> pick. When src is empty we ask
2590+// the user to pass a path (no usable default here).
2591+func (i *Interactive) doSessionImport(src string) {
2592+ src = strings.TrimSpace(src)
2593+ if src == "" {
2594+ i.mu.Lock()
2595+ i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zotsession"
2596+ i.mu.Unlock()
2597+ i.invalidate()
2598+ return
2599+ }
2600+ src = expandTilde(src)
2601+ if _, err := os.Stat(src); err != nil {
2602+ i.mu.Lock()
2603+ i.statusErr = "import: " + err.Error()
2604+ i.mu.Unlock()
2605+ i.invalidate()
2606+ return
2607+ }
2608+ newPath, err := core.ImportSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version)
2609+ if err != nil {
2610+ i.mu.Lock()
2611+ i.statusErr = "import: " + err.Error()
2612+ i.mu.Unlock()
2613+ i.invalidate()
2614+ return
2615+ }
2616+ if i.cfg.LoadSession == nil {
2617+ i.mu.Lock()
2618+ i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)"
2619+ i.statusErr = ""
2620+ i.mu.Unlock()
2621+ i.invalidate()
2622+ return
2623+ }
2624+ if err := i.cfg.LoadSession(newPath); err != nil {
2625+ i.mu.Lock()
2626+ i.statusErr = "import: load failed: " + err.Error()
2627+ i.mu.Unlock()
2628+ i.invalidate()
2629+ return
2630+ }
2631+ i.mu.Lock()
2632+ i.statusOK = "imported and switched to session " + friendlyPath(newPath)
2633+ i.statusErr = ""
2634+ i.mu.Unlock()
2635+ i.invalidate()
2636+}
2637+
2638+// defaultExportDir returns ~/Downloads when it exists, or ~ as a
2639+// fallback, or /tmp on exotic machines with no home dir.
2640+func defaultExportDir() string {
2641+ home, err := os.UserHomeDir()
2642+ if err != nil || home == "" {
2643+ return os.TempDir()
2644+ }
2645+ downloads := filepath.Join(home, "Downloads")
2646+ if fi, err := os.Stat(downloads); err == nil && fi.IsDir() {
2647+ return downloads
2648+ }
2649+ return home
2650+}
2651+
2652+// expandTilde turns a leading ~ into the user's home directory.
2653+// Returns the input unchanged when there's no tilde or no home.
2654+func expandTilde(p string) string {
2655+ if p == "" || p[0] != '~' {
2656+ return p
2657+ }
2658+ home, err := os.UserHomeDir()
2659+ if err != nil || home == "" {
2660+ return p
2661+ }
2662+ if len(p) == 1 {
2663+ return home
2664+ }
2665+ if p[1] == '/' || p[1] == filepath.Separator {
2666+ return filepath.Join(home, p[2:])
2667+ }
2668+ return p
2669+}
2670+
2671+// friendlyPath collapses the user's home directory to a leading ~
2672+// so status messages read cleanly. Falls back to the raw path when
2673+// the home dir is unknown.
2674+func friendlyPath(p string) string {
2675+ home, err := os.UserHomeDir()
2676+ if err != nil || home == "" {
2677+ return p
2678+ }
2679+ if strings.HasPrefix(p, home+string(filepath.Separator)) {
2680+ return "~" + p[len(home):]
2681+ }
2682+ return p
2683+}
A internal/agent/modes/session_ops_dialog.go +101 -0
@@ -0,0 +1,101 @@
1+package modes
2+
3+import (
4+ "github.com/patriceckhart/zot/internal/tui"
5+)
6+
7+// sessionOpsDialog is the picker shown when the user runs `/session`
8+// without an argument. Offers the two portable-file operations on
9+// the current conversation: export (write the in-memory transcript
10+// plus meta to a .zotsession file) and import (load a .zotsession
11+// from another machine and swap it in as the active session).
12+//
13+// Shape mirrors telegramDialog and logoutDialog: tiny list, arrow
14+// keys to move, enter to pick, esc to cancel.
15+type sessionOpsDialog struct {
16+ active bool
17+ items []sessionOpsItem
18+ cursor int
19+}
20+
21+type sessionOpsItem struct {
22+ label string
23+ action string // "export" | "import"
24+ hint string
25+}
26+
27+type sessionOpsAction struct {
28+ Select bool
29+ Action string
30+ Close bool
31+}
32+
33+func newSessionOpsDialog() *sessionOpsDialog { return &sessionOpsDialog{} }
34+
35+// Open shows the picker. Items are usually both "export" and
36+// "import" but the caller can suppress either (e.g. hide export
37+// when the session is empty).
38+func (d *sessionOpsDialog) Open(items []sessionOpsItem) bool {
39+ if len(items) == 0 {
40+ return false
41+ }
42+ d.items = items
43+ d.cursor = 0
44+ d.active = true
45+ return true
46+}
47+
48+// Close hides the dialog.
49+func (d *sessionOpsDialog) Close() { d.active = false }
50+
51+// Active reports whether the dialog is consuming input.
52+func (d *sessionOpsDialog) Active() bool { return d != nil && d.active }
53+
54+// HandleKey advances the selection or resolves the dialog.
55+func (d *sessionOpsDialog) HandleKey(k tui.Key) sessionOpsAction {
56+ switch k.Kind {
57+ case tui.KeyUp:
58+ if d.cursor > 0 {
59+ d.cursor--
60+ }
61+ case tui.KeyDown:
62+ if d.cursor < len(d.items)-1 {
63+ d.cursor++
64+ }
65+ case tui.KeyEsc:
66+ d.Close()
67+ return sessionOpsAction{Close: true}
68+ case tui.KeyEnter:
69+ if len(d.items) == 0 {
70+ d.Close()
71+ return sessionOpsAction{Close: true}
72+ }
73+ it := d.items[d.cursor]
74+ d.Close()
75+ return sessionOpsAction{Select: true, Action: it.action}
76+ }
77+ return sessionOpsAction{}
78+}
79+
80+// Render returns the dialog lines.
81+func (d *sessionOpsDialog) Render(th tui.Theme, width int) []string {
82+ if !d.Active() {
83+ return nil
84+ }
85+ var lines []string
86+ lines = append(lines, frameHeader(th, "session", width))
87+ lines = append(lines, th.FG256(th.Muted, "pick an action (\u2191/\u2193, enter, esc to cancel):"))
88+ for i, it := range d.items {
89+ plain := " " + it.label
90+ if it.hint != "" {
91+ plain += " " + th.FG256(th.Muted, "("+it.hint+")")
92+ }
93+ if i == d.cursor {
94+ lines = append(lines, th.PadHighlight(plain, width))
95+ } else {
96+ lines = append(lines, th.FG256(th.Muted, plain))
97+ }
98+ }
99+ lines = append(lines, frameRule(th, width))
100+ return lines
101+}
@@ -0,0 +1,101 @@
1+package modes
2+
3+import (
4+ "github.com/patriceckhart/zot/internal/tui"
5+)
6+
7+// sessionOpsDialog is the picker shown when the user runs `/session`
8+// without an argument. Offers the two portable-file operations on
9+// the current conversation: export (write the in-memory transcript
10+// plus meta to a .zotsession file) and import (load a .zotsession
11+// from another machine and swap it in as the active session).
12+//
13+// Shape mirrors telegramDialog and logoutDialog: tiny list, arrow
14+// keys to move, enter to pick, esc to cancel.
15+type sessionOpsDialog struct {
16+ active bool
17+ items []sessionOpsItem
18+ cursor int
19+}
20+
21+type sessionOpsItem struct {
22+ label string
23+ action string // "export" | "import"
24+ hint string
25+}
26+
27+type sessionOpsAction struct {
28+ Select bool
29+ Action string
30+ Close bool
31+}
32+
33+func newSessionOpsDialog() *sessionOpsDialog { return &sessionOpsDialog{} }
34+
35+// Open shows the picker. Items are usually both "export" and
36+// "import" but the caller can suppress either (e.g. hide export
37+// when the session is empty).
38+func (d *sessionOpsDialog) Open(items []sessionOpsItem) bool {
39+ if len(items) == 0 {
40+ return false
41+ }
42+ d.items = items
43+ d.cursor = 0
44+ d.active = true
45+ return true
46+}
47+
48+// Close hides the dialog.
49+func (d *sessionOpsDialog) Close() { d.active = false }
50+
51+// Active reports whether the dialog is consuming input.
52+func (d *sessionOpsDialog) Active() bool { return d != nil && d.active }
53+
54+// HandleKey advances the selection or resolves the dialog.
55+func (d *sessionOpsDialog) HandleKey(k tui.Key) sessionOpsAction {
56+ switch k.Kind {
57+ case tui.KeyUp:
58+ if d.cursor > 0 {
59+ d.cursor--
60+ }
61+ case tui.KeyDown:
62+ if d.cursor < len(d.items)-1 {
63+ d.cursor++
64+ }
65+ case tui.KeyEsc:
66+ d.Close()
67+ return sessionOpsAction{Close: true}
68+ case tui.KeyEnter:
69+ if len(d.items) == 0 {
70+ d.Close()
71+ return sessionOpsAction{Close: true}
72+ }
73+ it := d.items[d.cursor]
74+ d.Close()
75+ return sessionOpsAction{Select: true, Action: it.action}
76+ }
77+ return sessionOpsAction{}
78+}
79+
80+// Render returns the dialog lines.
81+func (d *sessionOpsDialog) Render(th tui.Theme, width int) []string {
82+ if !d.Active() {
83+ return nil
84+ }
85+ var lines []string
86+ lines = append(lines, frameHeader(th, "session", width))
87+ lines = append(lines, th.FG256(th.Muted, "pick an action (\u2191/\u2193, enter, esc to cancel):"))
88+ for i, it := range d.items {
89+ plain := " " + it.label
90+ if it.hint != "" {
91+ plain += " " + th.FG256(th.Muted, "("+it.hint+")")
92+ }
93+ if i == d.cursor {
94+ lines = append(lines, th.PadHighlight(plain, width))
95+ } else {
96+ lines = append(lines, th.FG256(th.Muted, plain))
97+ }
98+ }
99+ lines = append(lines, frameRule(th, width))
100+ return lines
101+}
M internal/agent/modes/slash_suggest.go +1 -0
@@ -46,6 +46,7 @@ var slashCatalog = []slashCommand{
4646 {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"},
4747 {Name: "/yolo", Desc: "turn off --no-yolo confirmation for the rest of this session"},
4848 {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"},
49+ {Name: "/session", Desc: "export the current session to a .zotsession file, or import one"},
4950 {Name: "/clear", Desc: "clear the chat transcript"},
5051 {Name: "/exit", Desc: "exit zot"},
5152 }
@@ -46,6 +46,7 @@ var slashCatalog = []slashCommand{
46 {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"},46 {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"},
47 {Name: "/yolo", Desc: "turn off --no-yolo confirmation for the rest of this session"},47 {Name: "/yolo", Desc: "turn off --no-yolo confirmation for the rest of this session"},
48 {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"},48 {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"},
49+ {Name: "/session", Desc: "export the current session to a .zotsession file, or import one"},
49 {Name: "/clear", Desc: "clear the chat transcript"},50 {Name: "/clear", Desc: "clear the chat transcript"},
50 {Name: "/exit", Desc: "exit zot"},51 {Name: "/exit", Desc: "exit zot"},
51 }52 }
A internal/core/session_portable.go +320 -0
@@ -0,0 +1,320 @@
1+package core
2+
3+import (
4+ "bufio"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "os"
10+ "path/filepath"
11+ "strings"
12+ "time"
13+
14+ "github.com/google/uuid"
15+)
16+
17+// PortableExt is the filesystem extension used for exported sessions.
18+// A ".zotsession" is just a zot JSONL session file with the meta
19+// header rewritten so the importing user gets fresh ownership.
20+const PortableExt = ".zotsession"
21+
22+// ExportSession writes the session at srcPath to dstPath as a
23+// portable .zotsession file. If dstPath is an existing directory the
24+// file is created inside it with a name derived from the session's
25+// meta ("YYYYMMDD-HHMMSS-<first-prompt-excerpt>.zotsession"). The
26+// destination's directory is created if needed. Returns the final
27+// resolved path so the caller can tell the user where it landed.
28+//
29+// The on-disk format is unchanged from a live session; only the
30+// meta.cwd is stripped of its per-machine prefix (the importing
31+// user doesn't care what directory it came from). Everything else
32+// round-trips byte-for-byte.
33+func ExportSession(srcPath, dstPath string) (string, error) {
34+ if srcPath == "" {
35+ return "", errors.New("export: source path is empty")
36+ }
37+ if dstPath == "" {
38+ return "", errors.New("export: destination path is empty")
39+ }
40+
41+ // Read the source meta up-front so we can name the output sensibly
42+ // when dstPath is a directory, and so we can validate it's a real
43+ // session before starting to write.
44+ src, err := os.Open(srcPath)
45+ if err != nil {
46+ return "", fmt.Errorf("export: open source: %w", err)
47+ }
48+ defer src.Close()
49+
50+ sc := bufio.NewScanner(src)
51+ sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
52+ if !sc.Scan() {
53+ return "", errors.New("export: session file is empty")
54+ }
55+ var head sessionLine
56+ if err := json.Unmarshal(sc.Bytes(), &head); err != nil {
57+ return "", fmt.Errorf("export: parse meta: %w", err)
58+ }
59+ if head.Type != "meta" || head.Meta == nil {
60+ return "", errors.New("export: first line is not a meta row")
61+ }
62+
63+ // Scan the rest of the file for the first user message so we can
64+ // build a humane filename. Only reads if dstPath doesn't already
65+ // end in .zotsession.
66+ firstPrompt := ""
67+ if !strings.HasSuffix(strings.ToLower(dstPath), PortableExt) {
68+ if fi, _ := os.Stat(dstPath); fi == nil || fi.IsDir() {
69+ firstPrompt = firstUserPrompt(sc)
70+ }
71+ }
72+
73+ // Resolve dstPath: if it's a directory, build a name inside it.
74+ outPath := dstPath
75+ if fi, err := os.Stat(dstPath); err == nil && fi.IsDir() {
76+ name := filenameFor(head.Meta.Started, head.Meta.ID, firstPrompt)
77+ outPath = filepath.Join(dstPath, name)
78+ } else if !strings.HasSuffix(strings.ToLower(outPath), PortableExt) {
79+ outPath += PortableExt
80+ }
81+
82+ // Re-open the source from the top since we advanced the scanner.
83+ if _, err := src.Seek(0, io.SeekStart); err != nil {
84+ return "", fmt.Errorf("export: rewind: %w", err)
85+ }
86+
87+ if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
88+ return "", fmt.Errorf("export: mkdir dst: %w", err)
89+ }
90+ dst, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
91+ if err != nil {
92+ return "", fmt.Errorf("export: create dst: %w", err)
93+ }
94+ defer dst.Close()
95+ bw := bufio.NewWriter(dst)
96+
97+ // Rewrite the meta row: strip the cwd (the importing user has
98+ // their own) and keep everything else identical. ID stays so the
99+ // export is traceable; the importer will rotate to a fresh ID.
100+ exportMeta := *head.Meta
101+ exportMeta.CWD = ""
102+ metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &exportMeta})
103+ if err != nil {
104+ return "", fmt.Errorf("export: marshal meta: %w", err)
105+ }
106+ if _, err := bw.Write(metaLine); err != nil {
107+ return "", err
108+ }
109+ if err := bw.WriteByte('\n'); err != nil {
110+ return "", err
111+ }
112+
113+ // Stream every non-meta row verbatim.
114+ sc2 := bufio.NewScanner(src)
115+ sc2.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
116+ for sc2.Scan() {
117+ line := sc2.Bytes()
118+ var h sessionLineHead
119+ if err := json.Unmarshal(line, &h); err != nil {
120+ continue
121+ }
122+ if h.Type == "meta" {
123+ continue // already wrote a rewritten copy above
124+ }
125+ if _, err := bw.Write(line); err != nil {
126+ return "", err
127+ }
128+ if err := bw.WriteByte('\n'); err != nil {
129+ return "", err
130+ }
131+ }
132+ if err := sc2.Err(); err != nil {
133+ return "", fmt.Errorf("export: read source: %w", err)
134+ }
135+ if err := bw.Flush(); err != nil {
136+ return "", err
137+ }
138+ return outPath, nil
139+}
140+
141+// ImportSession copies the .zotsession file at srcPath into the
142+// running user's session store under the given root+cwd, rewriting
143+// the meta's id / cwd / started fields so the imported session is
144+// owned by the current user / directory / clock. Returns the path
145+// of the created session file, ready to pass to OpenSession.
146+//
147+// The imported session is a first-class zot session: it'll show up
148+// in /sessions, /jump, and on-disk summaries just like any other.
149+// Messages and usage rows are preserved verbatim.
150+func ImportSession(srcPath, root, cwd, version string) (string, error) {
151+ if srcPath == "" {
152+ return "", errors.New("import: source path is empty")
153+ }
154+ src, err := os.Open(srcPath)
155+ if err != nil {
156+ return "", fmt.Errorf("import: open source: %w", err)
157+ }
158+ defer src.Close()
159+
160+ // Validate the file header before committing to a destination.
161+ sc := bufio.NewScanner(src)
162+ sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
163+ if !sc.Scan() {
164+ return "", errors.New("import: session file is empty")
165+ }
166+ var head sessionLine
167+ if err := json.Unmarshal(sc.Bytes(), &head); err != nil {
168+ return "", fmt.Errorf("import: parse meta: %w", err)
169+ }
170+ if head.Type != "meta" || head.Meta == nil {
171+ return "", errors.New("import: first line is not a meta row")
172+ }
173+
174+ // Build the destination inside the current cwd's session dir
175+ // with a fresh timestamped name.
176+ dir := SessionsDir(root, cwd)
177+ if err := os.MkdirAll(dir, 0o755); err != nil {
178+ return "", err
179+ }
180+ newID := uuid.NewString()
181+ name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), newID[:8])
182+ outPath := filepath.Join(dir, name)
183+ dst, err := os.OpenFile(outPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
184+ if err != nil {
185+ return "", fmt.Errorf("import: create dst: %w", err)
186+ }
187+ defer dst.Close()
188+ bw := bufio.NewWriter(dst)
189+
190+ // Write a fresh meta row claiming ownership.
191+ importMeta := SessionMeta{
192+ ID: newID,
193+ CWD: cwd,
194+ Model: head.Meta.Model,
195+ Provider: head.Meta.Provider,
196+ Started: time.Now().UTC(),
197+ Version: version,
198+ }
199+ metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta})
200+ if err != nil {
201+ return "", fmt.Errorf("import: marshal meta: %w", err)
202+ }
203+ if _, err := bw.Write(metaLine); err != nil {
204+ return "", err
205+ }
206+ if err := bw.WriteByte('\n'); err != nil {
207+ return "", err
208+ }
209+
210+ // Rewind the source and stream every non-meta row.
211+ if _, err := src.Seek(0, io.SeekStart); err != nil {
212+ return "", fmt.Errorf("import: rewind: %w", err)
213+ }
214+ sc2 := bufio.NewScanner(src)
215+ sc2.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
216+ for sc2.Scan() {
217+ line := sc2.Bytes()
218+ var h sessionLineHead
219+ if err := json.Unmarshal(line, &h); err != nil {
220+ continue
221+ }
222+ if h.Type == "meta" {
223+ continue
224+ }
225+ if _, err := bw.Write(line); err != nil {
226+ return "", err
227+ }
228+ if err := bw.WriteByte('\n'); err != nil {
229+ return "", err
230+ }
231+ }
232+ if err := sc2.Err(); err != nil {
233+ return "", fmt.Errorf("import: read source: %w", err)
234+ }
235+ if err := bw.Flush(); err != nil {
236+ return "", err
237+ }
238+ return outPath, nil
239+}
240+
241+// firstUserPrompt scans forward from the current scanner position
242+// looking for the first user-role message and returns its text
243+// (trimmed, short). Used to build a humane export filename.
244+func firstUserPrompt(sc *bufio.Scanner) string {
245+ for sc.Scan() {
246+ var line sessionLine
247+ if err := json.Unmarshal(sc.Bytes(), &line); err != nil {
248+ continue
249+ }
250+ if line.Type != "message" || line.Message == nil || line.Message.Role != "user" {
251+ continue
252+ }
253+ for _, c := range line.Message.Content {
254+ // Use type name to avoid an import of provider here beyond
255+ // the already-imported alias; TextBlock is the only content
256+ // shape that yields a useful preview, so we just look for
257+ // something with a reasonable string form.
258+ s := fmt.Sprintf("%v", c)
259+ _ = s // formatted value is too noisy; go straight to typed path
260+ }
261+ // Simpler: marshal the message and fish out the first "text"
262+ // we can find. Avoids reaching into the provider package just
263+ // for an interface type check.
264+ b, _ := json.Marshal(line.Message)
265+ var m struct {
266+ Content []struct {
267+ Text string `json:"text"`
268+ } `json:"content"`
269+ }
270+ _ = json.Unmarshal(b, &m)
271+ for _, c := range m.Content {
272+ if c.Text != "" {
273+ return c.Text
274+ }
275+ }
276+ }
277+ return ""
278+}
279+
280+// filenameFor builds a descriptive .zotsession filename from the
281+// session's start time and, when available, an excerpt of the
282+// first user prompt.
283+func filenameFor(started time.Time, id, firstPrompt string) string {
284+ base := started.UTC().Format("20060102-150405")
285+ if id != "" && len(id) >= 8 {
286+ base += "-" + id[:8]
287+ }
288+ slug := slugify(firstPrompt, 40)
289+ if slug != "" {
290+ base += "-" + slug
291+ }
292+ return base + PortableExt
293+}
294+
295+// slugify lowercases, strips punctuation, collapses whitespace to
296+// hyphens, and truncates to max runes so it's safe as a filename.
297+func slugify(s string, max int) string {
298+ s = strings.TrimSpace(strings.ToLower(s))
299+ if s == "" {
300+ return ""
301+ }
302+ var out strings.Builder
303+ prevDash := false
304+ for _, r := range s {
305+ switch {
306+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
307+ out.WriteRune(r)
308+ prevDash = false
309+ default:
310+ if !prevDash && out.Len() > 0 {
311+ out.WriteByte('-')
312+ prevDash = true
313+ }
314+ }
315+ if out.Len() >= max {
316+ break
317+ }
318+ }
319+ return strings.TrimRight(out.String(), "-")
320+}
@@ -0,0 +1,320 @@
1+package core
2+
3+import (
4+ "bufio"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "os"
10+ "path/filepath"
11+ "strings"
12+ "time"
13+
14+ "github.com/google/uuid"
15+)
16+
17+// PortableExt is the filesystem extension used for exported sessions.
18+// A ".zotsession" is just a zot JSONL session file with the meta
19+// header rewritten so the importing user gets fresh ownership.
20+const PortableExt = ".zotsession"
21+
22+// ExportSession writes the session at srcPath to dstPath as a
23+// portable .zotsession file. If dstPath is an existing directory the
24+// file is created inside it with a name derived from the session's
25+// meta ("YYYYMMDD-HHMMSS-<first-prompt-excerpt>.zotsession"). The
26+// destination's directory is created if needed. Returns the final
27+// resolved path so the caller can tell the user where it landed.
28+//
29+// The on-disk format is unchanged from a live session; only the
30+// meta.cwd is stripped of its per-machine prefix (the importing
31+// user doesn't care what directory it came from). Everything else
32+// round-trips byte-for-byte.
33+func ExportSession(srcPath, dstPath string) (string, error) {
34+ if srcPath == "" {
35+ return "", errors.New("export: source path is empty")
36+ }
37+ if dstPath == "" {
38+ return "", errors.New("export: destination path is empty")
39+ }
40+
41+ // Read the source meta up-front so we can name the output sensibly
42+ // when dstPath is a directory, and so we can validate it's a real
43+ // session before starting to write.
44+ src, err := os.Open(srcPath)
45+ if err != nil {
46+ return "", fmt.Errorf("export: open source: %w", err)
47+ }
48+ defer src.Close()
49+
50+ sc := bufio.NewScanner(src)
51+ sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
52+ if !sc.Scan() {
53+ return "", errors.New("export: session file is empty")
54+ }
55+ var head sessionLine
56+ if err := json.Unmarshal(sc.Bytes(), &head); err != nil {
57+ return "", fmt.Errorf("export: parse meta: %w", err)
58+ }
59+ if head.Type != "meta" || head.Meta == nil {
60+ return "", errors.New("export: first line is not a meta row")
61+ }
62+
63+ // Scan the rest of the file for the first user message so we can
64+ // build a humane filename. Only reads if dstPath doesn't already
65+ // end in .zotsession.
66+ firstPrompt := ""
67+ if !strings.HasSuffix(strings.ToLower(dstPath), PortableExt) {
68+ if fi, _ := os.Stat(dstPath); fi == nil || fi.IsDir() {
69+ firstPrompt = firstUserPrompt(sc)
70+ }
71+ }
72+
73+ // Resolve dstPath: if it's a directory, build a name inside it.
74+ outPath := dstPath
75+ if fi, err := os.Stat(dstPath); err == nil && fi.IsDir() {
76+ name := filenameFor(head.Meta.Started, head.Meta.ID, firstPrompt)
77+ outPath = filepath.Join(dstPath, name)
78+ } else if !strings.HasSuffix(strings.ToLower(outPath), PortableExt) {
79+ outPath += PortableExt
80+ }
81+
82+ // Re-open the source from the top since we advanced the scanner.
83+ if _, err := src.Seek(0, io.SeekStart); err != nil {
84+ return "", fmt.Errorf("export: rewind: %w", err)
85+ }
86+
87+ if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
88+ return "", fmt.Errorf("export: mkdir dst: %w", err)
89+ }
90+ dst, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
91+ if err != nil {
92+ return "", fmt.Errorf("export: create dst: %w", err)
93+ }
94+ defer dst.Close()
95+ bw := bufio.NewWriter(dst)
96+
97+ // Rewrite the meta row: strip the cwd (the importing user has
98+ // their own) and keep everything else identical. ID stays so the
99+ // export is traceable; the importer will rotate to a fresh ID.
100+ exportMeta := *head.Meta
101+ exportMeta.CWD = ""
102+ metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &exportMeta})
103+ if err != nil {
104+ return "", fmt.Errorf("export: marshal meta: %w", err)
105+ }
106+ if _, err := bw.Write(metaLine); err != nil {
107+ return "", err
108+ }
109+ if err := bw.WriteByte('\n'); err != nil {
110+ return "", err
111+ }
112+
113+ // Stream every non-meta row verbatim.
114+ sc2 := bufio.NewScanner(src)
115+ sc2.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
116+ for sc2.Scan() {
117+ line := sc2.Bytes()
118+ var h sessionLineHead
119+ if err := json.Unmarshal(line, &h); err != nil {
120+ continue
121+ }
122+ if h.Type == "meta" {
123+ continue // already wrote a rewritten copy above
124+ }
125+ if _, err := bw.Write(line); err != nil {
126+ return "", err
127+ }
128+ if err := bw.WriteByte('\n'); err != nil {
129+ return "", err
130+ }
131+ }
132+ if err := sc2.Err(); err != nil {
133+ return "", fmt.Errorf("export: read source: %w", err)
134+ }
135+ if err := bw.Flush(); err != nil {
136+ return "", err
137+ }
138+ return outPath, nil
139+}
140+
141+// ImportSession copies the .zotsession file at srcPath into the
142+// running user's session store under the given root+cwd, rewriting
143+// the meta's id / cwd / started fields so the imported session is
144+// owned by the current user / directory / clock. Returns the path
145+// of the created session file, ready to pass to OpenSession.
146+//
147+// The imported session is a first-class zot session: it'll show up
148+// in /sessions, /jump, and on-disk summaries just like any other.
149+// Messages and usage rows are preserved verbatim.
150+func ImportSession(srcPath, root, cwd, version string) (string, error) {
151+ if srcPath == "" {
152+ return "", errors.New("import: source path is empty")
153+ }
154+ src, err := os.Open(srcPath)
155+ if err != nil {
156+ return "", fmt.Errorf("import: open source: %w", err)
157+ }
158+ defer src.Close()
159+
160+ // Validate the file header before committing to a destination.
161+ sc := bufio.NewScanner(src)
162+ sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
163+ if !sc.Scan() {
164+ return "", errors.New("import: session file is empty")
165+ }
166+ var head sessionLine
167+ if err := json.Unmarshal(sc.Bytes(), &head); err != nil {
168+ return "", fmt.Errorf("import: parse meta: %w", err)
169+ }
170+ if head.Type != "meta" || head.Meta == nil {
171+ return "", errors.New("import: first line is not a meta row")
172+ }
173+
174+ // Build the destination inside the current cwd's session dir
175+ // with a fresh timestamped name.
176+ dir := SessionsDir(root, cwd)
177+ if err := os.MkdirAll(dir, 0o755); err != nil {
178+ return "", err
179+ }
180+ newID := uuid.NewString()
181+ name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), newID[:8])
182+ outPath := filepath.Join(dir, name)
183+ dst, err := os.OpenFile(outPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
184+ if err != nil {
185+ return "", fmt.Errorf("import: create dst: %w", err)
186+ }
187+ defer dst.Close()
188+ bw := bufio.NewWriter(dst)
189+
190+ // Write a fresh meta row claiming ownership.
191+ importMeta := SessionMeta{
192+ ID: newID,
193+ CWD: cwd,
194+ Model: head.Meta.Model,
195+ Provider: head.Meta.Provider,
196+ Started: time.Now().UTC(),
197+ Version: version,
198+ }
199+ metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta})
200+ if err != nil {
201+ return "", fmt.Errorf("import: marshal meta: %w", err)
202+ }
203+ if _, err := bw.Write(metaLine); err != nil {
204+ return "", err
205+ }
206+ if err := bw.WriteByte('\n'); err != nil {
207+ return "", err
208+ }
209+
210+ // Rewind the source and stream every non-meta row.
211+ if _, err := src.Seek(0, io.SeekStart); err != nil {
212+ return "", fmt.Errorf("import: rewind: %w", err)
213+ }
214+ sc2 := bufio.NewScanner(src)
215+ sc2.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
216+ for sc2.Scan() {
217+ line := sc2.Bytes()
218+ var h sessionLineHead
219+ if err := json.Unmarshal(line, &h); err != nil {
220+ continue
221+ }
222+ if h.Type == "meta" {
223+ continue
224+ }
225+ if _, err := bw.Write(line); err != nil {
226+ return "", err
227+ }
228+ if err := bw.WriteByte('\n'); err != nil {
229+ return "", err
230+ }
231+ }
232+ if err := sc2.Err(); err != nil {
233+ return "", fmt.Errorf("import: read source: %w", err)
234+ }
235+ if err := bw.Flush(); err != nil {
236+ return "", err
237+ }
238+ return outPath, nil
239+}
240+
241+// firstUserPrompt scans forward from the current scanner position
242+// looking for the first user-role message and returns its text
243+// (trimmed, short). Used to build a humane export filename.
244+func firstUserPrompt(sc *bufio.Scanner) string {
245+ for sc.Scan() {
246+ var line sessionLine
247+ if err := json.Unmarshal(sc.Bytes(), &line); err != nil {
248+ continue
249+ }
250+ if line.Type != "message" || line.Message == nil || line.Message.Role != "user" {
251+ continue
252+ }
253+ for _, c := range line.Message.Content {
254+ // Use type name to avoid an import of provider here beyond
255+ // the already-imported alias; TextBlock is the only content
256+ // shape that yields a useful preview, so we just look for
257+ // something with a reasonable string form.
258+ s := fmt.Sprintf("%v", c)
259+ _ = s // formatted value is too noisy; go straight to typed path
260+ }
261+ // Simpler: marshal the message and fish out the first "text"
262+ // we can find. Avoids reaching into the provider package just
263+ // for an interface type check.
264+ b, _ := json.Marshal(line.Message)
265+ var m struct {
266+ Content []struct {
267+ Text string `json:"text"`
268+ } `json:"content"`
269+ }
270+ _ = json.Unmarshal(b, &m)
271+ for _, c := range m.Content {
272+ if c.Text != "" {
273+ return c.Text
274+ }
275+ }
276+ }
277+ return ""
278+}
279+
280+// filenameFor builds a descriptive .zotsession filename from the
281+// session's start time and, when available, an excerpt of the
282+// first user prompt.
283+func filenameFor(started time.Time, id, firstPrompt string) string {
284+ base := started.UTC().Format("20060102-150405")
285+ if id != "" && len(id) >= 8 {
286+ base += "-" + id[:8]
287+ }
288+ slug := slugify(firstPrompt, 40)
289+ if slug != "" {
290+ base += "-" + slug
291+ }
292+ return base + PortableExt
293+}
294+
295+// slugify lowercases, strips punctuation, collapses whitespace to
296+// hyphens, and truncates to max runes so it's safe as a filename.
297+func slugify(s string, max int) string {
298+ s = strings.TrimSpace(strings.ToLower(s))
299+ if s == "" {
300+ return ""
301+ }
302+ var out strings.Builder
303+ prevDash := false
304+ for _, r := range s {
305+ switch {
306+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
307+ out.WriteRune(r)
308+ prevDash = false
309+ default:
310+ if !prevDash && out.Len() > 0 {
311+ out.WriteByte('-')
312+ prevDash = true
313+ }
314+ }
315+ if out.Len() >= max {
316+ break
317+ }
318+ }
319+ return strings.TrimRight(out.String(), "-")
320+}
A internal/core/session_portable_test.go +132 -0
@@ -0,0 +1,132 @@
1+package core
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+
9+ "github.com/patriceckhart/zot/internal/provider"
10+)
11+
12+// TestSessionExportImportRoundTrip writes a few messages to a live
13+// session, exports it, imports the export under a different cwd,
14+// and verifies OpenSession on the imported file yields the same
15+// message payloads.
16+func TestSessionExportImportRoundTrip(t *testing.T) {
17+ root := t.TempDir()
18+ originalCWD := "/path/to/project"
19+ sess, err := NewSession(root, originalCWD, "anthropic", "claude-opus-4-7", "0.0.0-test")
20+ if err != nil {
21+ t.Fatal(err)
22+ }
23+ _ = sess.AppendMessage(provider.Message{
24+ Role: provider.RoleUser,
25+ Content: []provider.Content{provider.TextBlock{Text: "hello from the exporter"}},
26+ })
27+ _ = sess.AppendMessage(provider.Message{
28+ Role: provider.RoleAssistant,
29+ Content: []provider.Content{provider.TextBlock{Text: "hi — reply from the assistant"}},
30+ })
31+ _ = sess.Close()
32+
33+ // Export to a directory — helper should build a name inside it.
34+ exportDir := t.TempDir()
35+ exportPath, err := ExportSession(sess.Path, exportDir)
36+ if err != nil {
37+ t.Fatalf("ExportSession: %v", err)
38+ }
39+ if !strings.HasSuffix(exportPath, PortableExt) {
40+ t.Errorf("exported path should end in %s, got %q", PortableExt, exportPath)
41+ }
42+ if _, err := os.Stat(exportPath); err != nil {
43+ t.Fatalf("exported file doesn't exist: %v", err)
44+ }
45+
46+ // Import into a different root + cwd.
47+ root2 := t.TempDir()
48+ cwd2 := "/some/other/project"
49+ importedPath, err := ImportSession(exportPath, root2, cwd2, "0.0.0-test")
50+ if err != nil {
51+ t.Fatalf("ImportSession: %v", err)
52+ }
53+ if filepath.Dir(importedPath) != SessionsDir(root2, cwd2) {
54+ t.Errorf("imported file should land in SessionsDir, got %q", importedPath)
55+ }
56+
57+ // Reopen and verify message round-trip.
58+ imported, msgs, err := OpenSession(importedPath)
59+ if err != nil {
60+ t.Fatalf("OpenSession: %v", err)
61+ }
62+ defer imported.Close()
63+ if imported.Meta.CWD != cwd2 {
64+ t.Errorf("meta cwd: want %q, got %q", cwd2, imported.Meta.CWD)
65+ }
66+ if imported.Meta.ID == sess.ID {
67+ t.Errorf("imported session kept the original id %q; must be rotated", sess.ID)
68+ }
69+ if imported.Meta.Model != "claude-opus-4-7" {
70+ t.Errorf("model not preserved: %q", imported.Meta.Model)
71+ }
72+ if len(msgs) != 2 {
73+ t.Fatalf("want 2 messages, got %d", len(msgs))
74+ }
75+ // Text should round-trip.
76+ if extractText(msgs[0]) != "hello from the exporter" {
77+ t.Errorf("msg 0 mismatch: %q", extractText(msgs[0]))
78+ }
79+ if extractText(msgs[1]) != "hi — reply from the assistant" {
80+ t.Errorf("msg 1 mismatch: %q", extractText(msgs[1]))
81+ }
82+}
83+
84+// TestExportToFilePath writes to an explicit file path (no
85+// directory guessing) and checks the .zotsession extension is
86+// appended when missing.
87+func TestExportToFilePath(t *testing.T) {
88+ root := t.TempDir()
89+ sess, err := NewSession(root, "/cwd", "anthropic", "claude-opus-4-7", "0.0.0-test")
90+ if err != nil {
91+ t.Fatal(err)
92+ }
93+ _ = sess.AppendMessage(provider.Message{
94+ Role: provider.RoleUser,
95+ Content: []provider.Content{provider.TextBlock{Text: "x"}},
96+ })
97+ _ = sess.Close()
98+
99+ // No extension — should add .zotsession.
100+ dst := filepath.Join(t.TempDir(), "mysession")
101+ out, err := ExportSession(sess.Path, dst)
102+ if err != nil {
103+ t.Fatal(err)
104+ }
105+ if !strings.HasSuffix(out, PortableExt) {
106+ t.Errorf("want .zotsession suffix on %q", out)
107+ }
108+}
109+
110+// TestExportStripsCWDFromMeta verifies the exported meta no longer
111+// carries the source user's cwd (not useful to the recipient).
112+func TestExportStripsCWDFromMeta(t *testing.T) {
113+ root := t.TempDir()
114+ sess, err := NewSession(root, "/original/cwd", "anthropic", "claude-opus-4-7", "0.0.0-test")
115+ if err != nil {
116+ t.Fatal(err)
117+ }
118+ _ = sess.AppendMessage(provider.Message{
119+ Role: provider.RoleUser,
120+ Content: []provider.Content{provider.TextBlock{Text: "x"}},
121+ })
122+ _ = sess.Close()
123+
124+ out, err := ExportSession(sess.Path, filepath.Join(t.TempDir(), "x"+PortableExt))
125+ if err != nil {
126+ t.Fatal(err)
127+ }
128+ b, _ := os.ReadFile(out)
129+ if strings.Contains(string(b), "/original/cwd") {
130+ t.Errorf("exported file leaks the source cwd: %s", string(b))
131+ }
132+}
@@ -0,0 +1,132 @@
1+package core
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+
9+ "github.com/patriceckhart/zot/internal/provider"
10+)
11+
12+// TestSessionExportImportRoundTrip writes a few messages to a live
13+// session, exports it, imports the export under a different cwd,
14+// and verifies OpenSession on the imported file yields the same
15+// message payloads.
16+func TestSessionExportImportRoundTrip(t *testing.T) {
17+ root := t.TempDir()
18+ originalCWD := "/path/to/project"
19+ sess, err := NewSession(root, originalCWD, "anthropic", "claude-opus-4-7", "0.0.0-test")
20+ if err != nil {
21+ t.Fatal(err)
22+ }
23+ _ = sess.AppendMessage(provider.Message{
24+ Role: provider.RoleUser,
25+ Content: []provider.Content{provider.TextBlock{Text: "hello from the exporter"}},
26+ })
27+ _ = sess.AppendMessage(provider.Message{
28+ Role: provider.RoleAssistant,
29+ Content: []provider.Content{provider.TextBlock{Text: "hi — reply from the assistant"}},
30+ })
31+ _ = sess.Close()
32+
33+ // Export to a directory — helper should build a name inside it.
34+ exportDir := t.TempDir()
35+ exportPath, err := ExportSession(sess.Path, exportDir)
36+ if err != nil {
37+ t.Fatalf("ExportSession: %v", err)
38+ }
39+ if !strings.HasSuffix(exportPath, PortableExt) {
40+ t.Errorf("exported path should end in %s, got %q", PortableExt, exportPath)
41+ }
42+ if _, err := os.Stat(exportPath); err != nil {
43+ t.Fatalf("exported file doesn't exist: %v", err)
44+ }
45+
46+ // Import into a different root + cwd.
47+ root2 := t.TempDir()
48+ cwd2 := "/some/other/project"
49+ importedPath, err := ImportSession(exportPath, root2, cwd2, "0.0.0-test")
50+ if err != nil {
51+ t.Fatalf("ImportSession: %v", err)
52+ }
53+ if filepath.Dir(importedPath) != SessionsDir(root2, cwd2) {
54+ t.Errorf("imported file should land in SessionsDir, got %q", importedPath)
55+ }
56+
57+ // Reopen and verify message round-trip.
58+ imported, msgs, err := OpenSession(importedPath)
59+ if err != nil {
60+ t.Fatalf("OpenSession: %v", err)
61+ }
62+ defer imported.Close()
63+ if imported.Meta.CWD != cwd2 {
64+ t.Errorf("meta cwd: want %q, got %q", cwd2, imported.Meta.CWD)
65+ }
66+ if imported.Meta.ID == sess.ID {
67+ t.Errorf("imported session kept the original id %q; must be rotated", sess.ID)
68+ }
69+ if imported.Meta.Model != "claude-opus-4-7" {
70+ t.Errorf("model not preserved: %q", imported.Meta.Model)
71+ }
72+ if len(msgs) != 2 {
73+ t.Fatalf("want 2 messages, got %d", len(msgs))
74+ }
75+ // Text should round-trip.
76+ if extractText(msgs[0]) != "hello from the exporter" {
77+ t.Errorf("msg 0 mismatch: %q", extractText(msgs[0]))
78+ }
79+ if extractText(msgs[1]) != "hi — reply from the assistant" {
80+ t.Errorf("msg 1 mismatch: %q", extractText(msgs[1]))
81+ }
82+}
83+
84+// TestExportToFilePath writes to an explicit file path (no
85+// directory guessing) and checks the .zotsession extension is
86+// appended when missing.
87+func TestExportToFilePath(t *testing.T) {
88+ root := t.TempDir()
89+ sess, err := NewSession(root, "/cwd", "anthropic", "claude-opus-4-7", "0.0.0-test")
90+ if err != nil {
91+ t.Fatal(err)
92+ }
93+ _ = sess.AppendMessage(provider.Message{
94+ Role: provider.RoleUser,
95+ Content: []provider.Content{provider.TextBlock{Text: "x"}},
96+ })
97+ _ = sess.Close()
98+
99+ // No extension — should add .zotsession.
100+ dst := filepath.Join(t.TempDir(), "mysession")
101+ out, err := ExportSession(sess.Path, dst)
102+ if err != nil {
103+ t.Fatal(err)
104+ }
105+ if !strings.HasSuffix(out, PortableExt) {
106+ t.Errorf("want .zotsession suffix on %q", out)
107+ }
108+}
109+
110+// TestExportStripsCWDFromMeta verifies the exported meta no longer
111+// carries the source user's cwd (not useful to the recipient).
112+func TestExportStripsCWDFromMeta(t *testing.T) {
113+ root := t.TempDir()
114+ sess, err := NewSession(root, "/original/cwd", "anthropic", "claude-opus-4-7", "0.0.0-test")
115+ if err != nil {
116+ t.Fatal(err)
117+ }
118+ _ = sess.AppendMessage(provider.Message{
119+ Role: provider.RoleUser,
120+ Content: []provider.Content{provider.TextBlock{Text: "x"}},
121+ })
122+ _ = sess.Close()
123+
124+ out, err := ExportSession(sess.Path, filepath.Join(t.TempDir(), "x"+PortableExt))
125+ if err != nil {
126+ t.Fatal(err)
127+ }
128+ b, _ := os.ReadFile(out)
129+ if strings.Contains(string(b), "/original/cwd") {
130+ t.Errorf("exported file leaks the source cwd: %s", string(b))
131+ }
132+}

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