detached 9b063d8

fix(session): export the full running conversation, handle quoted paths

Two bugs in yesterday's /session export + import:

1. Quoted / tilde paths weren't normalised.
   Drag-drop paste in the tui auto-quotes dropped file paths so
   the shell-style `/session import 'foo bar.zotsession'` stays
   well-formed. But the /session handler's expandTilde checked
   for a leading '~' and the string's first char was a literal
   quote, so the tilde never expanded and stat failed with
   "no such file or directory".

   unquotePath helper now strips a matching pair of surrounding
   single or double quotes before expandTilde runs. Applies to
   both export (dst) and import (src).

2. Export was writing only the meta row when called mid-session.
   The tui's default persistence strategy writes agent messages
   to the session file lazily: WriteNewTranscript runs once
   when the tui exits, NOT after every turn. Meanwhile the
   running agent's messages live in a sync.Mutex-guarded slice
   in core.Agent.messages. /session export was reading the file
   bytes off disk, which at that point only contained the meta
   row plus whatever was there on startup.

   New FlushSession hook on InteractiveConfig: the cli wires it
   to WriteNewTranscript against the current agent, then
   advances sessBaselineMsgs so the tui's own exit-time flush
   doesn't double-write. /session export calls the hook right
   before ExportSession, so the file on disk reflects the full
   running transcript at the moment the user hit enter.

Tests:

- internal/core/session_portable_test.go was already exercising
  ExportSession/ImportSession against on-disk files; this fix
  lives in the cli/modes glue, not in core.

- internal/agent/modes ad-hoc TestUnquotePathThenExpandTilde
  (run locally, not committed) covered the 8 tilde+quote
  combinations.

Verified: create a fresh session, type "hello", reply, "foo",
reply, run /session export. Exported .zotsession now contains
the meta row + 2 user + 2 assistant + 1 usage row. Re-import
into a different cwd via /session import <path>, /sessions to
confirm it lands as a resumable entry.
commit
9b063d871b494854541c1b013d1479a559a5b30e
author
patriceckhart · · 2026-04-20 08:19 UTC
parent
ef80f9c
signature
unsigned
refs
tag: v0.0.58
2 files · +55 -2
M internal/agent/cli.go +18 -0
@@ -570,6 +570,24 @@ func runInteractive(ctx context.Context, args Args, version string) error {
570570 }
571571 return sess.Path
572572 },
573+ FlushSession: func() {
574+ // Append any not-yet-persisted agent messages to the
575+ // current session file, then advance the baseline so
576+ // the final WriteNewTranscript at exit doesn't write
577+ // duplicates. Called by /session export so the
578+ // exported bytes include everything the user has
579+ // said in the running TUI, not just whatever got
580+ // flushed at startup.
581+ if sess == nil {
582+ return
583+ }
584+ currentAg := iv.Agent()
585+ if currentAg == nil {
586+ return
587+ }
588+ WriteNewTranscript(currentAg, sess, sessBaselineMsgs)
589+ sessBaselineMsgs = len(currentAg.Messages())
590+ },
573591 Extensions: extMgr,
574592 ChangelogChan: changelogCh,
575593 OnChangelogDismiss: func() {
@@ -570,6 +570,24 @@ func runInteractive(ctx context.Context, args Args, version string) error {
570 }570 }
571 return sess.Path571 return sess.Path
572 },572 },
573+ FlushSession: func() {
574+ // Append any not-yet-persisted agent messages to the
575+ // current session file, then advance the baseline so
576+ // the final WriteNewTranscript at exit doesn't write
577+ // duplicates. Called by /session export so the
578+ // exported bytes include everything the user has
579+ // said in the running TUI, not just whatever got
580+ // flushed at startup.
581+ if sess == nil {
582+ return
583+ }
584+ currentAg := iv.Agent()
585+ if currentAg == nil {
586+ return
587+ }
588+ WriteNewTranscript(currentAg, sess, sessBaselineMsgs)
589+ sessBaselineMsgs = len(currentAg.Messages())
590+ },
573 Extensions: extMgr,591 Extensions: extMgr,
574 ChangelogChan: changelogCh,592 ChangelogChan: changelogCh,
575 OnChangelogDismiss: func() {593 OnChangelogDismiss: func() {
M internal/agent/modes/interactive.go +37 -2
@@ -83,6 +83,17 @@ type InteractiveConfig struct {
8383 // no session is open.
8484 CurrentSessionPath func() string
8585
86+ // FlushSession writes any in-memory agent messages to the
87+ // session file that haven't been persisted yet. Called by
88+ // /session export right before reading the file so the
89+ // exported bytes reflect the full current conversation, not
90+ // just the rows the agent happened to write synchronously.
91+ // The default WriteNewTranscript-at-exit strategy means most
92+ // of a running session lives only in memory until the tui
93+ // closes; without a flush hook, /session export writes a
94+ // file that only has the meta row.
95+ FlushSession func()
96+
8697 // PersistModel is called whenever the user switches model or provider.
8798 // It should update config.json and (if there's an active session)
8899 // write a new meta row so resume picks up the same model.
@@ -2563,7 +2574,14 @@ func (i *Interactive) doSessionExport(dst string) {
25632574 i.invalidate()
25642575 return
25652576 }
2566- dst = strings.TrimSpace(dst)
2577+ // Persist any in-memory agent messages to the session file so
2578+ // the export carries the full conversation. Without this, the
2579+ // default lazy-flush-at-exit strategy leaves most of a running
2580+ // session unwritten and the export ends up with only the meta.
2581+ if i.cfg.FlushSession != nil {
2582+ i.cfg.FlushSession()
2583+ }
2584+ dst = unquotePath(dst)
25672585 if dst == "" {
25682586 dst = defaultExportDir()
25692587 } else {
@@ -2589,7 +2607,7 @@ func (i *Interactive) doSessionExport(dst string) {
25892607 // session, same as `/sessions` -> pick. When src is empty we ask
25902608 // the user to pass a path (no usable default here).
25912609 func (i *Interactive) doSessionImport(src string) {
2592- src = strings.TrimSpace(src)
2610+ src = unquotePath(src)
25932611 if src == "" {
25942612 i.mu.Lock()
25952613 i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zotsession"
@@ -2668,6 +2686,23 @@ func expandTilde(p string) string {
26682686 return p
26692687 }
26702688
2689+// unquotePath strips a matching pair of surrounding single or
2690+// double quotes. Drag-drop paste in the tui auto-quotes dropped
2691+// file paths so the shell-like `/session import 'foo bar.zs'`
2692+// stays well-formed; when the TUI's own slash handler consumes
2693+// the arg, we want the raw path back.
2694+func unquotePath(p string) string {
2695+ p = strings.TrimSpace(p)
2696+ if len(p) >= 2 {
2697+ first := p[0]
2698+ last := p[len(p)-1]
2699+ if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
2700+ p = p[1 : len(p)-1]
2701+ }
2702+ }
2703+ return p
2704+}
2705+
26712706 // friendlyPath collapses the user's home directory to a leading ~
26722707 // so status messages read cleanly. Falls back to the raw path when
26732708 // the home dir is unknown.
@@ -83,6 +83,17 @@ type InteractiveConfig struct {
83 // no session is open.83 // no session is open.
84 CurrentSessionPath func() string84 CurrentSessionPath func() string
85 85
86+ // FlushSession writes any in-memory agent messages to the
87+ // session file that haven't been persisted yet. Called by
88+ // /session export right before reading the file so the
89+ // exported bytes reflect the full current conversation, not
90+ // just the rows the agent happened to write synchronously.
91+ // The default WriteNewTranscript-at-exit strategy means most
92+ // of a running session lives only in memory until the tui
93+ // closes; without a flush hook, /session export writes a
94+ // file that only has the meta row.
95+ FlushSession func()
96+
86 // PersistModel is called whenever the user switches model or provider.97 // PersistModel is called whenever the user switches model or provider.
87 // It should update config.json and (if there's an active session)98 // It should update config.json and (if there's an active session)
88 // write a new meta row so resume picks up the same model.99 // write a new meta row so resume picks up the same model.
@@ -2563,7 +2574,14 @@ func (i *Interactive) doSessionExport(dst string) {
2563 i.invalidate()2574 i.invalidate()
2564 return2575 return
2565 }2576 }
2566- dst = strings.TrimSpace(dst)2577+ // Persist any in-memory agent messages to the session file so
2578+ // the export carries the full conversation. Without this, the
2579+ // default lazy-flush-at-exit strategy leaves most of a running
2580+ // session unwritten and the export ends up with only the meta.
2581+ if i.cfg.FlushSession != nil {
2582+ i.cfg.FlushSession()
2583+ }
2584+ dst = unquotePath(dst)
2567 if dst == "" {2585 if dst == "" {
2568 dst = defaultExportDir()2586 dst = defaultExportDir()
2569 } else {2587 } else {
@@ -2589,7 +2607,7 @@ func (i *Interactive) doSessionExport(dst string) {
2589 // session, same as `/sessions` -> pick. When src is empty we ask2607 // session, same as `/sessions` -> pick. When src is empty we ask
2590 // the user to pass a path (no usable default here).2608 // the user to pass a path (no usable default here).
2591 func (i *Interactive) doSessionImport(src string) {2609 func (i *Interactive) doSessionImport(src string) {
2592- src = strings.TrimSpace(src)2610+ src = unquotePath(src)
2593 if src == "" {2611 if src == "" {
2594 i.mu.Lock()2612 i.mu.Lock()
2595 i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zotsession"2613 i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zotsession"
@@ -2668,6 +2686,23 @@ func expandTilde(p string) string {
2668 return p2686 return p
2669 }2687 }
2670 2688
2689+// unquotePath strips a matching pair of surrounding single or
2690+// double quotes. Drag-drop paste in the tui auto-quotes dropped
2691+// file paths so the shell-like `/session import 'foo bar.zs'`
2692+// stays well-formed; when the TUI's own slash handler consumes
2693+// the arg, we want the raw path back.
2694+func unquotePath(p string) string {
2695+ p = strings.TrimSpace(p)
2696+ if len(p) >= 2 {
2697+ first := p[0]
2698+ last := p[len(p)-1]
2699+ if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
2700+ p = p[1 : len(p)-1]
2701+ }
2702+ }
2703+ return p
2704+}
2705+
2671 // friendlyPath collapses the user's home directory to a leading ~2706 // friendlyPath collapses the user's home directory to a leading ~
2672 // so status messages read cleanly. Falls back to the raw path when2707 // so status messages read cleanly. Falls back to the raw path when
2673 // the home dir is unknown.2708 // the home dir is unknown.

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