Local Cursor session viewer (read-only). Tauri 2 + React 19 + Rust, UI inspired by cc-switch.
Current repo tag: v0.3.7d. The embedded app version fields in
package.json / Cargo.toml / tauri.conf.json are 0.3.7, so
the v0.3.7c / v0.3.7d maintenance line builds artifacts on the
current 0.3.7 app version instead of the old 0.3.6 package line.
bettercursor is a desktop app that views every AI conversation
Cursor IDE stores on disk. It scans the three SQLite + JSONL layers under
~/.config/Cursor (Linux) / ~/Library/Application Support/Cursor (macOS),
deduplicates across layers, and renders a single merged session list.
Design goals:
- Read-only by default — v0.2.1+ added opt-in writes (
fix_orphans/delete_session/sync_session_layer23) but the app never touches Cursor's working files except through these explicit commands - cc-switch UI — left project-grouped tree + right conversation detail
- Byte-identical to a Python reference implementation — MD5
chat_rootparity tests pass
- Self-discovery filter — nearby mDNS browse now hides the local machine itself instead of offering a bogus self-pair target
- Trusted-peer cleanup — legacy
trusted_peers.jsonentries with wrong/self device names are repaired on load and reconciled against fresh discovery results when available - Stable pairing identity — LAN pairing now prefers a
PAIR2handshake that exchanges stabledevice_id+ real host name and falls back to legacyPAIRfor backward compatibility
- Version-line alignment —
package.json/Cargo.toml/tauri.conf.jsonnow all report0.3.7, so packaged app metadata finally matches thev0.3.7*release line - Release-doc cleanup — README / roadmap / download notes now
distinguish historical
v0.3.7a/v0.3.7bartifacts from the new0.3.7package line
- LAN discovery fix — mDNS publish now uses
ServiceInfo::enable_addr_auto()so peers advertise reachable local addresses instead of empty address sets - Browse lifecycle fix —
discovery_browsenow consumesServiceResolvedevents in-process and explicitlystop_browse/shutdowns the daemon - Reachable host preference — discovered peers prefer non-loopback IPv4,
then IPv6, avoiding
127.0.0.1false positives and malformedhost:port
- Interaction latency cut-down — heavy
sync_now/ conversation parse / write-path work moved off the UI thread - Single authoritative refresh — write actions emit one backend refresh;
removed extra frontend
sync_nowloops - Watcher self-write suppression — fs watcher ignores a short self-write window to avoid duplicate full rescans
- Settings-load unblock — LAN browse no longer runs eagerly on settings open; nearby peer scan is manual
- LAN-only pairing flow refresh — promote manual
host:port + codepairing to the primary path for virtual LANs such as Tailscale / ZeroTier / Headscale - Local address picker — choose which local interface/address to advertise and display in settings (physical LAN + virtual overlays)
- Stable listen port — keep auto-assign by default, but allow an advanced fixed port for firewall / overlay reuse
- mDNS as convenience, not prerequisite — nearby scan stays available on multicast-friendly networks, but failure to discover peers no longer blocks the sync flow
- SSH/T2b path removed from active roadmap — no new SSH peer UI; the v0.2.6 SSH config remains historical/reference only
- v4 snapshot enrichment —
images+blob_refs/raw_blobs(agentKv); restored on apply - Mac↔Linux path rewrite —
path_rewrite+ optionalpath_mappingsin~/.bettercursor/config.json - Identical → apply missing L2/L3 — when unified.db matches but Cursor layers are absent locally
- Pull result UI —
PullReport.results+ per-session status in<SyncPeersPanel> - Background auto-pull — periodic pull from trusted LAN peers; toggle + interval in settings
- Optional L3 soft delete — Desktop-aligned sidebar archive +
purge
bubbleId/checkpointIdrows; keepcomposerDatashell - Subagent sessions — read L2
meta[0].subagentInfo; nest underrootParentAgentIdin the sidebar tree; collapsed by default - Hide empty Desktop ghosts —
Untitled · uuid, zero bubbles, no CLI source: filtered out of the session list (disk untouched) - Conversation read fixes — L3 header chain + L2 enrichment;
trim bad L3 prefixes; strip context envelopes /
[REDACTED] - Delete tombstones —
deleted_sessionsstops L3-only rows from resurrecting after bettercursor delete
- L2→L3 bubble enrichment —
layer2_messageswalks the CLIstore.dbDAG and replaces L1[REDACTED]stubs with full assistant text + tool-call metadata beforebubbleIdinject - User image attachments — L2
imageblobs decode toimages[]data URLs on userbubbleIdrows (Desktop replay) - Re-inject detection — sessions with CLI envelopes, redacted assistant text, or missing images trigger Layer 3 rewrite
- 补 Layer 3 playbook — see SYNC_DESIGN §0.5 (quit Cursor → sync in bettercursor → restart Cursor)
-
<SettingsDialog>— gear icon in the sidebar header; consolidates language switch, cross-device sync (<SyncPeersPanel>), and conflict resolution (<ConflictResolvePanel>) - i18n fix — merged duplicate
synckeys in locale JSON (status badge no longer shows rawsync.autoSync) - Dark-theme language switcher — segmented buttons replace native
<select>;color-scheme: darkon root - Sidebar polish — product name
BetterCursorin header; collapse/expand all project groups; removed non-functional back button - Conflict UX — neutral copy + badge on settings when conflicts pending
- LAN cross-device sync — mDNS discovery, 6-digit pairing, trusted peers, outbox, background sync loop
-
<SyncPeersDialog>/<ConflictResolveDialog>— shipped in v0.3.1; superseded by<SettingsDialog>in v0.3.2
-
~/.bettercursor/unified.db(PR-1): 8 tables + FTS5 +rebuild_from_cursor_state+ archive / conflicts / sync_runs - pre-PR-2 read-path parity: full L3 bubble text extraction, Cursor 3.0+ session discovery, timestamp gap fill, cursor-history parity fixtures
- snapshot codec v4 (
core/snapshot.rs): bubble-level JSON; push still uses 8-fieldsnapshot_meta - Conflict 5-way (
core/conflict.rs): classify / bubble_diff / auto_merge;transport_pullwrites back into unified.db - Transport async (
tokio+async-trait); Tauri commands stay sync, backend usesblock_on - agentKv minimal slice:
write_layer3copies agent blobs referenced byconversationState - 126 Rust unit tests (
cargo test --lib)
Inspect unified.db:
sqlite3 ~/.bettercursor/unified.db "SELECT uuid, bubble_count, content_hash FROM sessions LIMIT 5;"- Cross-device sync — Transport trait first cut:
core::transport::Transporttrait (4 methods:push/pull/list_remote/endpoint_id, sync — deliberately diverging from theasync_traitin SYNC_DESIGN §4.4 until v0.3.0). One impl:SshRsyncTransport(T2), shelling out to systemssh/rsync(no new Cargo deps, notokio, norussh). - Minimum v0.2.6 snapshot carrier:
SessionSnapshot(8 metadata fields — uuid /last_updated_at_ms/ host /project_slug/project_path/source_path/text_previewcapped 280 chars /bubble_count). No bubbles / blobs yet — that's v0.3.0 unified.db territory. - Peer config file:
~/.bettercursor/transports.json(separate from the prefsconfig.json). Atomic save (*.tmp+ rename). - 4 Tauri commands:
transport_list_peers/transport_test/transport_push/transport_pull. Plus 4 typed IPC wrappers insrc/lib/tauri.ts(PeerSummary/TestReport/PushReport/PullReport/RemoteSession). - No UI yet — usage is via
invoke('transport_*')from dev console + manually editingtransports.json. SyncPeersDialog is a v0.3.0 milestone. - 20 Rust unit tests for snapshot codec, config serde,
ssh_cmdsafety flags, push failure stderr, etc. Plustests/fixtures/fake-{ssh,rsync}.shmock binaries for CI-friendly testing without a real SSH peer. - v0.2.6 housekeeping (shipped together): CI matrix gains
macos-13(Intel x64 dmg alongside Apple Silicon dmg), Node 20 → 22, vitest 2 + jsdom 25 +@testing-library/react16 + 15-case test suite for<SyncStatusBadge>/<BrokenBadge>i18n-aware fallback. Zero business-code change.
- Cross-platform packaging: Linux
.deb/.AppImage+ macOS unsigned.dmg(macOS 10.15+, Apple Silicon) + Windows.msi/.exe(NSIS). All built by GitHub Actions matrix (release.yml) - i18n (zh-CN / en): react-i18next +
src/locales/{zh-CN,en}.json(~110 UI strings) +<LanguageSwitcher>header<select>+ localStorage persistence (i18nextLng) - Three-file version bump:
package.json/Cargo.toml/tauri.conf.jsonall on0.2.5;productName: "BetterCursor"(PascalCase for Mac.app) - Background sync loop wrap-up (v0.2.3):
<SyncNowButton>(instant rescan) +<SyncStatusBadge>("● Auto-sync · 12s ago", 1Hz tick + 5s backend poll) - Conversation record expansion (v0.2.2): L1+L2+L3 three-way merge +
<MessageList>thin wrapper - Repair orphan + delete session (v0.2.1): native
<dialog>confirm - Scans 3 Cursor layers on startup (Layer 1 JSONL / Layer 2
store.db/ Layer 3state.vscdb) - Cross-layer dedup; project grouping; full-text search by name / project / content / UUID
- MD5
chat_rootbyte-identical to the Python reference
v0.3.8+ (planned, see SYNC_DESIGN.md)
- T3/T4/T5 — Git / S3 / specialized overlay discovery adapters (TBD)
- PR-2b Doctor — deferred; observe orphan cases first (SYNC_DESIGN §10.4.3)
Before the v0.3.7 LAN-only plan, advanced users could configure
SSH/rsync peers in ~/.bettercursor/transports.json:
{
"peers": [
{
"id": "linux-desktop",
"kind": "ssh",
"host": "[email protected]",
"port": 22,
"identity_file": "~/.ssh/id_ed25519",
"remote_snap_dir": "~/.bettercursor/peers/bettercursor-main",
"remote_hostname": "linux-desktop"
}
]
}This path is now historical/reference only. The active roadmap favors LAN manual-address pairing over an SSH settings UI.
Every git tag (v*.*.*) triggers
.github/workflows/release.yml to build
on three platforms. Artifacts end up on the
Releases page.
Current repo config is already on the 0.3.7 app version line. Older
artifacts built from v0.3.7a / v0.3.7b may still carry 0.3.6
filenames because those tags were cut before the version bump landed.
# Debian / Ubuntu (.deb — declares libwebkit2gtk-4.1 / libgtk-3 /
# libayatana-appindicator3 in Depends:)
sudo dpkg -i BetterCursor_0.2.6_amd64.deb
sudo apt-get install -f # satisfy missing deps if dpkg complains
# Portable AppImage (no install, but first run downloads linuxdeploy
# binaries from tauri-apps/binary-releases — needs network)
chmod +x BetterCursor_0.2.6_amd64.AppImage
./BetterCursor_0.2.6_amd64.AppImage-
Download
BetterCursor_0.2.6_aarch64.dmg(Apple Silicon) orBetterCursor_0.2.6_x64.dmg(Intel). Both are unsigned.dmgbuilt by themacos-latest+macos-13CI matrix entries. -
Mount, drag
BetterCursor.appinto/Applications. -
Bypass Gatekeeper for an unsigned app (one-shot, cleaner than right-click → Open):
xattr -dr com.apple.quarantine /Applications/BetterCursor.app
com.apple.quarantineis the extended attribute Finder drops on anything downloaded from the internet.-drrecurses through the whole.appbundle (including nested binaries and frameworks) and strips every quarantine flag, so subsequent double-clicks work like an App Store install.Fallback (if
-drdoesn't stick): right-clickBetterCursor.app→ Open → Open. Same effect, but you'll have to repeat for every new build.Sweep everything in /Applications in one go:
find /Applications -name "*.app" -exec xattr -dr com.apple.quarantine {} \; 2>/dev/null
# .msi (MSI installer — good for managed deployment)
msiexec /i BetterCursor_0.2.6_x64_en-US.msi
# or .exe (NSIS — better for personal installs)
.\BetterCursor_0.2.6_x64-setup.exe- Node 20+ + pnpm 9+ (lockfile is
lockfileVersion: '9.0') - Rust 1.77+ (
rustup install stable) - Linux:
webkit2gtk-4.1,libsoup-3.0,libgtk-3,libjavascriptcoregtk-4.1, optionalxdg-desktop-portal-gnome - macOS: Xcode Command Line Tools
- Windows: WebView2 runtime (preinstalled on Win 11)
git clone https://github.com/par4d15e/bettercursor.git
cd bettercursor
pnpm install
# Dev (HMR + WebKit devtools available)
pnpm tauri dev
# Production build
pnpm tauri buildOn launch a 1280×800 window opens; in the background the app scans Cursor's storage and renders ~37 sessions (Linux desktop + Linux CLI
- macOS sources mix, in a typical setup).
Some compositors need fallback env vars under WebKitGTK:
WEBKIT_DISABLE_DMABUF_RENDERER=1 \
LIBGL_ALWAYS_SOFTWARE=1 \
pnpm tauri devbettercursor/
├── src/ # React + TS frontend
│ ├── components/ # SessionTree, SessionDetail, SourceBadge, ...
│ ├── store/ # Zustand store + selectors
│ ├── lib/ # tauri.ts (IPC wrapper), types.ts
│ ├── i18n/ # i18next init (zh-CN, en)
│ ├── locales/ # zh-CN.json / en.json
│ ├── App.tsx · main.tsx · index.css
├── src-tauri/ # Rust backend
│ ├── src/
│ │ ├── core/
│ │ │ ├── paths.rs # 4-layer path resolution
│ │ │ ├── storage.rs # WAL-safe SQLite reader
│ │ │ ├── canonical.rs # three-layer merge
│ │ ├── lib.rs # Tauri commands + setup
│ │ ├── main.rs
│ ├── capabilities/ # default.json (ACL whitelist)
│ ├── icons/ # Tauri bundle icons
│ ├── tauri.conf.json
│ ├── Cargo.toml · Cargo.lock
├── tests/ # Python compatibility tests (parity check)
├── bettercursor/, adapter/# Old Python daemon reference impl (archive)
├── vendored/ # Upstream Cursor parsing library (subrepo)
├── PRD.md · SYNC_DESIGN.md · AGENTS.md · docs/
└── .github/workflows/ # release.yml (3-OS matrix)
Session reads happen in three layers (see SYNC_DESIGN.md §2.5 Q6 for UUID identity):
| Layer | Storage | Path (Linux) | Role |
|---|---|---|---|
| L1 | JSONL | ~/.cursor/projects/<slug>/agent-transcripts/<uuid>/<uuid>.jsonl |
Transcript; CLI + Desktop both write; same uuid as L2 when CLI session is valid |
| L2 | SQLite | ~/.cursor/chats/<md5(cwd)>/<uuid>/store.db |
CLI only (cursor-agent); Sidebar resume list on CLI |
| L3 | SQLite KV | ~/.config/Cursor/User/globalStorage/state.vscdb (cursorDiskKV: composerData:*, bubbleId:*; plus per-workspace workspaceStorage/*/state.vscdb) |
Desktop composer index + bubble bodies |
Always quit Cursor Desktop first — writing state.vscdb while Cursor
is running can corrupt the WAL. Then in bettercursor open the CLI session
and run sync Layer 2/3; restart Cursor. v0.3.4+ rewrites stub bubbles
when it detects CLI envelopes, [REDACTED] assistant text, or missing
images that exist in Layer 2. Full playbook: SYNC_DESIGN §0.5.
Rust side (src-tauri/src/core/) handles:
paths.rs— parse cursor user dir / chat_root MD5 etc.storage.rs— WAL-safe read: copy totempfile::tempdir(),PRAGMA wal_checkpoint(TRUNCATE), then read-only open.canonical.rs— cross-layer merge, emitCanonicalSession.
Tauri commands exposed to the frontend:
| Command | Args | Returns |
|---|---|---|
list_sessions |
— | All sessions in the cache |
sync_now |
— | usize count, fires sessions-updated event |
get_conversation |
uuid |
Parsed Conversation with merged bubbles + source_path |
get_resume_command |
uuid, source |
open -a Cursor --args --resume <uuid> or cursor-agent --resume <uuid> |
sync_session_layer23 |
uuid, cwd |
SyncReport (wrote_layer2 / wrote_layer3 / skipped / duration_ms) |
fix_orphans |
— | FixOrphansReport (scanned / fixed / skipped, auto-backups store.db) |
delete_session |
uuid, cwd, slug |
DeleteReport (cursor_running / removed_l1 / removed_l2 / skipped_*) |
watcher_status |
— | { active, dirs[], last_scan_at_ms } |
platform_info |
— | <os>: <cursor_user_dir> (debug) |
transport_list_peers |
— | PeerSummary[] from ~/.bettercursor/transports.json |
transport_test |
peerId |
TestReport (ok / latency_ms / error?) |
transport_push |
uuid, peerId |
PushReport (uuid / bytes_written / duration_ms) |
transport_pull |
peerId, sinceMs? |
PullReport (peer_id / count / snapshots[]) |
v0.2.6 shipped the Transport trait first cut around SSH/rsync.
That design remains useful as implementation history, but it is no longer
the recommended user path after the v0.3.7 LAN-only product decision.
Configure one or more peers in ~/.bettercursor/transports.json:
{
"peers": [
{
"id": "macbook",
"kind": "ssh",
"host": "[email protected]",
"port": 22,
"identity_file": "~/.ssh/id_ed25519",
"remote_snap_dir": "~/.bettercursor/peers/bettercursor-main",
"remote_hostname": "macbook-pro-m1"
}
]
}Then from devtools console:
await __TAURI__.invoke('transport_list_peers') // → [{id:"macbook",...}]
await __TAURI__.invoke('transport_test', { peerId: 'macbook' }) // → {ok:true, latency_ms:42}
await __TAURI__.invoke('transport_push', { uuid: '<a session>', peerId: 'macbook' })
// ~/.bettercursor/peers/bettercursor-main/<host>/<uuid>.json now exists on the peer.
await __TAURI__.invoke('transport_pull', { peerId: 'macbook', sinceMs: 0 })
// → { peer_id: "macbook", count: 1, snapshots: [...] }SSH safety flags baked in: BatchMode=yes (no interactive prompts) +
StrictHostKeyChecking=accept-new (auto-trust new hosts, fail loud
on key mismatch). The Transport trait is sync (not async_trait)
in v0.2.6 — it migrates to async in v0.3.0 when the offline outbox
lands. This section is kept for release-history context rather than
current product guidance.
useShallow((s) => derived(s)) looks shallow-stable, but when the
derived function does [...arr].sort(...) the inner array refs differ
and the comparison falls through, triggering React 19's
Maximum update depth exceeded bail-out.
Fix: move the derived value out of the selector, memoize in
useMemo at the component level:
// ❌ Don't:
useStore(useShallow((s) => groupByProject(s.items)))
// ✅
const items = useStore((s) => s.items)
const grouped = useMemo(() => groupByProject(items), [items])Without plugin-specific permissions, a frontend
invoke('plugin:foo|bar') is rejected with a TypeError. Add the
permissions explicitly in capabilities/default.json:
{
"permissions": [
"core:default",
"opener:default",
"fs:default",
"clipboard-manager:allow-write-text",
"clipboard-manager:allow-read-text"
]
}You must opt in via a Cargo feature, otherwise the right-click menu only shows "Reload", no "Inspect":
tauri = { version = "2", features = ["devtools"] }Some compositors (Mutter / Hyprland) crash on the GPU composition path. Known workaround:
WEBKIT_DISABLE_DMABUF_RENDERER=1 \
WEBKIT_DISABLE_COMPOSITING_MODE=1 \
LIBGL_ALWAYS_SOFTWARE=1 \
pnpm tauri devIf a pnpm-workspace.yaml exists at the repo root (even just for
allowBuilds), it must declare a packages field. Without it, pnpm
aborts with ERROR packages field missing or empty. Add at minimum:
packages:
- "."This bit us on the first v0.2.5 release run — all matrix jobs failed
in 30 s.
| File | Content |
|---|---|
| PRD.md | Product requirements v0.1 feature matrix + acceptance criteria |
| SYNC_DESIGN.md | v0.2+ sync / cross-device design |
| docs/README.md | Doc layout; local archive in docs/local/ (gitignored) |
v0.2.5 (✅ done) Cross-platform packaging · i18n · background sync ·
conversation records · repair orphan · delete
v0.2.6 (✅ done) Cross-device sync — Transport trait first cut ·
SSH/rsync (T2) impl · 4 Tauri commands
v0.3.0 (✅ done) ~/.bettercursor/unified.db · snapshot codec v4 ·
async Transport · Conflict 5-way
v0.3.1 (✅ done) LAN mDNS pairing · outbox · sync loop
v0.3.2–v0.3.5 (✅ done) Settings UI · L2/L3 enrichment · L3 soft delete
v0.3.6 (✅ done) Cross-device sync hardening — see SYNC_DESIGN §10.4
v0.3.7a (✅ done) Interaction performance cut-down
v0.3.7d (✅ latest tag) Self-discovery filter · trusted-peer cleanup
v0.3.7c (✅ done) Version-line alignment · release metadata cleanup
v0.3.7b (✅ done) LAN discovery stabilization
v0.3.7 (⚪ next) LAN manual-address pairing · virtual LAN support
v0.3.8+ T3/T4/T5 adapters · Doctor (deferred)
- UI paradigm: farion1231/cc-switch
- Old Python daemon (
bettercursor/,adapter/) provided the parsing algorithm reference vendored/cursaves/(AGPL, read-only) andvendored/cursor-history/(MIT, read-only) are upstream Cursor parsing library snapshots; borrowable algorithms are indexed in SYNC_DESIGN.md §11.5
Currently a personal/early-stage project. v0.2.6 is the first release that ships cross-device sync; the latest maintenance tag is
v0.3.7d.