Skip to content

feat: add ccsession export — write a full session transcript as Markdown #114

Description

@sorafujitani

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

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):

  • backends implementing the unexported messageSource interface (Messages(s, limit) — grok.go:62, codex.go:62, opencode.go:84);
  • 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.Messagesdb.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 ./....

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions