You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Session transcripts are trapped in JSONL (or backend-specific stores). There is no way to turn a session into something shareable — a post-mortem, a PR description appendix, a blog draft, a handoff document for a teammate. The preview pane already renders transcripts nicely, but only the last N messages and only to a terminal.
This issue adds ccsession export <id>: write the full transcript of one session as Markdown.
UX spec
ccsession export<id># full transcript as Markdown to stdout
ccsession export<id> --out session.md # write to a file
ccsession export<id> --locator <loc># disambiguate, same semantics as preview/resume
Works with every backend (CCSESSION_SOURCE / --source are honored exactly as they are for preview), including all via locator.
Output format (Markdown):
# fix preview highlight regression||||---|---|| Session |`abc123...`|| Source | claude || Directory |`/Users/x/ghq/github.com/x/ccsession`|| Started | 2026-07-01 09:12 || Last activity | 2026-07-01 11:47 || Messages | 84 |---### User — 2026-07-01 09:12
fix the preview highlight ...
---### Assistant — 2026-07-01 09:13
Looking at internal/preview ...
Formatting rules:
Title: the session label (session.Session.Label); fall back to the session id if empty.
One ### <Role> — <local time YYYY-MM-DD HH:MM> heading per message; capitalize the role; omit the time part when the message has a zero timestamp.
Message bodies are emitted verbatim — do NOT wrap them in code fences (bodies routinely contain their own ``` fences, and nesting would corrupt them). Separate messages with --- horizontal rules instead.
No ANSI escapes ever, regardless of tty; export is plain text output, colorEnabled logic does not apply.
No truncation: unlike preview, truncateBody and the message ring buffer must NOT apply.
Implementation plan
1. Unlimited message loading
Preview loads messages through two paths (internal/preview/preview.go, previewData at preview.go:134):
the claude default via loadMessages(s.JSONLPath, limit) (preview.go:331), which keeps a ring buffer of limit items.
Give both paths "no limit" semantics, defined as limit <= 0:
loadMessages: when limit <= 0, append to a growing slice instead of the ring (keep startedAt/total behavior identical). collectRing is bypassed in that mode.
grokSource.Messages / codexSource.Messages: both tail-read with a ring like preview — apply the same limit <= 0 = accumulate-all change where the ring is sized.
opencodeSource.Messages → db.Messages(id, limit) (internal/opencode): make limit <= 0 omit the LIMIT clause (or use a large bound) so all rows return, still in chronological order.
Add/extend tests for each backend proving limit <= 0 returns every message in order.
This also positions CCSESSION_PREVIEW_MESSAGES semantics: messageLimit (preview.go:431) must keep rejecting/ignoring non-positive values for preview so this sentinel can't be triggered from the env var.
2. Message access for export
messageSource is unexported in internal/preview. Export needs the same dispatch, so extract it:
Move the interface (and the claude fallback dispatch) into a small exported helper, e.g. source.LoadMessages(src source.Source, s *session.Session, limit int) ([]session.Message, time.Time, int, error) — preview's previewData and export both call it. The claude fallback needs loadMessages, which lives in preview; move that function (and its helpers readJSONLLine, parseMessageLine, collectRing) to internal/session or a new shared spot rather than importing preview from source. Follow the direction that keeps internal/preview importing internal/source, never the reverse (see the package map in CLAUDE.md; issue refactor: unify JSONL line reading across codex, grok, and preview #91/refactor: extract a shared ring-buffer tail collector for preview and codex #94 touch the same helpers — coordinate if they land first).
3. New package internal/export
export.Options{Out io.Writer, Now func() time.Time} plus a Run(id, locator string, opts Options) error mirroring preview.Run's shape (preview.go:74): resolve the source with source.FromEnv(), the session with source.ResolveSession(src, id, locator) (source.go:37), load all messages, render Markdown per the format spec above.
Rendering must be a pure function func Render(w io.Writer, s *session.Session, msgs []session.Message, startedAt time.Time, total int) error so it is trivially testable.
Times in the metadata table and headings use the session's local time zone (time.Local), formatted 2006-01-02 15:04.
4. CLI wiring
New case "export" in the subcommand switch in cmd/ccsession/main.go (main.go:184); flags --out, --locator, parsed in the same style as cmdPreview.
--out: create/truncate the file with 0644; on any render error, remove the partial file.
Update usage text and README (Usage section).
Edge cases
Session not found / ambiguous id: same error behavior as preview (reuse ResolveSession).
Empty session (ErrSessionEmpty / zero messages): still emit the metadata header with Messages | 0 and no body sections; exit 0.
Message bodies containing --- on their own line or leading # characters: acceptable — verbatim emission is the contract; do not escape Markdown.
A message with an empty body: emit the heading and nothing under it.
Very large sessions (100k+ messages): stream through a bufio.Writer; do not build one giant string.
Testing
Golden-file test for Render in internal/export: fixture messages covering user/assistant roles, zero timestamps, empty bodies, bodies containing ``` fences and --- lines; compare against a checked-in `.golden` file.
Per-backend limit <= 0 tests as described in step 1.
CLI-level test (following cmd/ccsession/main_test.go patterns) that export <id> with a fixture JSONL under a temp CLAUDE_CONFIG_DIR produces the expected Markdown on stdout and honors --out.
Non-goals
HTML/PDF output (--format stays implicit md; add the flag only when a second format exists).
Redaction/anonymization of transcript content.
A picker keybinding for export (e.g. ctrl-e on the highlighted session) — natural follow-up, separate issue.
Exporting multiple sessions at once.
Acceptance criteria
ccsession export <id> emits the complete transcript (message count in the header equals lines rendered) for claude, opencode, grok, and codex sources.
Output is deterministic, fence-safe (a transcript containing code blocks round-trips readably), and free of ANSI escapes.
Preview behavior and its default 30-message limit are unchanged.
gofmt -l . clean, go vet ./... clean, golangci-lint run clean, new tests pass with go test ./....
Problem
Session transcripts are trapped in JSONL (or backend-specific stores). There is no way to turn a session into something shareable — a post-mortem, a PR description appendix, a blog draft, a handoff document for a teammate. The preview pane already renders transcripts nicely, but only the last N messages and only to a terminal.
This issue adds
ccsession export <id>: write the full transcript of one session as Markdown.UX spec
Works with every backend (
CCSESSION_SOURCE/--sourceare honored exactly as they are forpreview), includingallvia locator.Output format (Markdown):
Formatting rules:
session.Session.Label); fall back to the session id if empty.### <Role> — <local time YYYY-MM-DD HH:MM>heading per message; capitalize the role; omit the time part when the message has a zero timestamp.---horizontal rules instead.colorEnabledlogic does not apply.truncateBodyand the message ring buffer must NOT apply.Implementation plan
1. Unlimited message loading
Preview loads messages through two paths (
internal/preview/preview.go,previewDataat preview.go:134):messageSourceinterface (Messages(s, limit)— grok.go:62, codex.go:62, opencode.go:84);loadMessages(s.JSONLPath, limit)(preview.go:331), which keeps a ring buffer oflimititems.Give both paths "no limit" semantics, defined as
limit <= 0:loadMessages: whenlimit <= 0, append to a growing slice instead of the ring (keepstartedAt/totalbehavior identical).collectRingis bypassed in that mode.grokSource.Messages/codexSource.Messages: both tail-read with a ring like preview — apply the samelimit <= 0= accumulate-all change where the ring is sized.opencodeSource.Messages→db.Messages(id, limit)(internal/opencode): makelimit <= 0omit the LIMIT clause (or use a large bound) so all rows return, still in chronological order.limit <= 0returns every message in order.This also positions
CCSESSION_PREVIEW_MESSAGESsemantics:messageLimit(preview.go:431) must keep rejecting/ignoring non-positive values for preview so this sentinel can't be triggered from the env var.2. Message access for export
messageSourceis unexported ininternal/preview. Export needs the same dispatch, so extract it:source.LoadMessages(src source.Source, s *session.Session, limit int) ([]session.Message, time.Time, int, error)— preview'spreviewDataand export both call it. The claude fallback needsloadMessages, which lives in preview; move that function (and its helpersreadJSONLLine,parseMessageLine,collectRing) tointernal/sessionor a new shared spot rather than importing preview from source. Follow the direction that keepsinternal/previewimportinginternal/source, never the reverse (see the package map in CLAUDE.md; issue refactor: unify JSONL line reading across codex, grok, and preview #91/refactor: extract a shared ring-buffer tail collector for preview and codex #94 touch the same helpers — coordinate if they land first).3. New package
internal/exportexport.Options{Out io.Writer, Now func() time.Time}plus aRun(id, locator string, opts Options) errormirroringpreview.Run's shape (preview.go:74): resolve the source withsource.FromEnv(), the session withsource.ResolveSession(src, id, locator)(source.go:37), load all messages, render Markdown per the format spec above.func Render(w io.Writer, s *session.Session, msgs []session.Message, startedAt time.Time, total int) errorso it is trivially testable.time.Local), formatted2006-01-02 15:04.4. CLI wiring
"export"in the subcommand switch incmd/ccsession/main.go(main.go:184); flags--out,--locator, parsed in the same style ascmdPreview.--out: create/truncate the file with0644; on any render error, remove the partial file.Edge cases
preview(reuseResolveSession).ErrSessionEmpty/ zero messages): still emit the metadata header withMessages | 0and no body sections; exit 0.---on their own line or leading#characters: acceptable — verbatim emission is the contract; do not escape Markdown.bufio.Writer; do not build one giant string.Testing
Renderininternal/export: fixture messages covering user/assistant roles, zero timestamps, empty bodies, bodies containing ``` fences and---lines; compare against a checked-in `.golden` file.limit <= 0tests as described in step 1.cmd/ccsession/main_test.gopatterns) thatexport <id>with a fixture JSONL under a tempCLAUDE_CONFIG_DIRproduces the expected Markdown on stdout and honors--out.Non-goals
--formatstays implicitmd; add the flag only when a second format exists).Acceptance criteria
ccsession export <id>emits the complete transcript (message count in the header equals lines rendered) for claude, opencode, grok, and codex sources.gofmt -l .clean,go vet ./...clean,golangci-lint runclean, new tests pass withgo test ./....