Rust · 12081 bytes Raw Blame History
1 //! Session schema — both the permissive input type (`RawEvent`) we parse
2 //! out of Claude Code's JSONL files, and the frontend-facing output types
3 //! (`Project`, `SessionSummary`, `SessionDetail`, `Message`, `ContentBlock`)
4 //! that get sent over the Tauri IPC bridge.
5 //!
6 //! The frontend types are exported to `src/lib/ipc/generated/` via ts-rs
7 //! during `cargo test` runs. The generated files are committed so CI can
8 //! diff them against the Rust definitions and fail on drift.
9
10 use chrono::{DateTime, Utc};
11 use serde::{Deserialize, Serialize};
12 use ts_rs::TS;
13
14 // ============================================================================
15 // Input side — permissive parse of a single JSONL line.
16 // ============================================================================
17
18 /// Catch-all event struct. Every top-level key Claude Code has been
19 /// observed emitting is `Option<T>` so we never fail parse on schema
20 /// drift. Type-specific payloads (`message`, `attachment`) are kept as
21 /// `serde_json::Value` and decoded lazily by the reader.
22 #[derive(Debug, Clone, Deserialize)]
23 #[serde(rename_all = "camelCase")]
24 pub struct RawEvent {
25 #[serde(rename = "type")]
26 pub kind: String,
27
28 #[serde(default)]
29 pub session_id: Option<String>,
30 #[serde(default)]
31 pub uuid: Option<String>,
32 #[serde(default)]
33 pub parent_uuid: Option<String>,
34 #[serde(default)]
35 pub timestamp: Option<DateTime<Utc>>,
36 #[serde(default)]
37 pub cwd: Option<String>,
38 #[serde(default)]
39 pub git_branch: Option<String>,
40 #[serde(default)]
41 pub version: Option<String>,
42 #[serde(default)]
43 pub is_sidechain: Option<bool>,
44 #[serde(default)]
45 pub is_meta: Option<bool>,
46 #[serde(default)]
47 pub slug: Option<String>,
48 /// Which client produced this session — `"cli"`, `"claude-vscode"`,
49 /// `"sdk-ts"`, etc. Present on most events that carry `cwd`.
50 #[serde(default)]
51 pub entrypoint: Option<String>,
52
53 // Session-level metadata events:
54 #[serde(default)]
55 pub custom_title: Option<String>,
56 /// `ai-title` events carry a generated title in an `aiTitle` field.
57 #[serde(default, rename = "aiTitle")]
58 pub ai_title: Option<String>,
59 #[serde(default)]
60 pub agent_name: Option<String>,
61 #[serde(default)]
62 pub permission_mode: Option<String>,
63
64 // Payloads:
65 #[serde(default)]
66 pub message: Option<serde_json::Value>,
67 #[serde(default)]
68 pub attachment: Option<serde_json::Value>,
69
70 // System event:
71 #[serde(default)]
72 pub subtype: Option<String>,
73 }
74
75 // ============================================================================
76 // Output side — what the frontend sees.
77 // ============================================================================
78
79 /// A logical project — one or more encoded-cwd directories collapsed
80 /// together via git-root detection, path-prefix fallback, or (for
81 /// observer sessions) category membership.
82 #[derive(Debug, Clone, Serialize, Deserialize, TS)]
83 #[serde(rename_all = "camelCase")]
84 #[ts(export)]
85 pub struct Project {
86 /// Stable key — the git root path, longest common cwd prefix, or
87 /// (when nothing else is available) a single encoded dir name.
88 pub id: String,
89 /// Canonical working directory for this project.
90 pub cwd: String,
91 /// Basename of `cwd` for rendering.
92 pub display_name: String,
93 pub session_count: u32,
94 /// Most recent `lastActivityAt` across all sessions in the project.
95 pub last_activity: Option<DateTime<Utc>>,
96 /// Sidebar grouping — see [`ProjectCategory`].
97 pub category: ProjectCategory,
98 /// Every encoded `~/.claude/projects/` directory that contributed
99 /// sessions to this project. Useful for debugging unexpected merges.
100 pub source_dirs: Vec<String>,
101 /// All session summaries that belong to this project, sorted newest
102 /// first. Each session carries its own `projectId` which points at
103 /// the **encoded source dir** where the jsonl physically lives,
104 /// distinct from `Project::id` (the merged logical key).
105 pub sessions: Vec<SessionSummary>,
106 }
107
108 /// Sidebar grouping for a [`Project`].
109 #[derive(Debug, Clone, Copy, Serialize, Deserialize, TS, PartialEq, Eq, Hash)]
110 #[serde(rename_all = "snake_case")]
111 #[ts(export)]
112 pub enum ProjectCategory {
113 /// Normal user project — shown at the top of the sidebar.
114 Regular,
115 /// claude-mem observer sessions, collapsed by default.
116 Observer,
117 /// Reconstructed from `~/.claude/history.jsonl` — project has
118 /// prompt-level history but no on-disk transcripts. Rendered in
119 /// a distinct collapsed section at the bottom of the sidebar.
120 Archive,
121 }
122
123 /// Where a session came from. `Disk` sessions have a real `.jsonl`
124 /// file under `~/.claude/projects/`; `Archive` sessions are
125 /// synthesized from `~/.claude/history.jsonl` prompt-input records
126 /// and only carry the user's own prompts (no assistant responses).
127 #[derive(Debug, Clone, Copy, Serialize, Deserialize, TS, PartialEq, Eq)]
128 #[serde(rename_all = "snake_case")]
129 #[ts(export)]
130 pub enum SessionSource {
131 Disk,
132 Archive,
133 }
134
135 impl Default for SessionSource {
136 fn default() -> Self {
137 Self::Disk
138 }
139 }
140
141 /// Cheap metadata for the TOC sidebar — never loads the full file.
142 #[derive(Debug, Clone, Serialize, Deserialize, TS)]
143 #[serde(rename_all = "camelCase")]
144 #[ts(export)]
145 pub struct SessionSummary {
146 /// UUID — the JSONL filename minus the extension.
147 pub id: String,
148 /// The project's encoded-dir id.
149 pub project_id: String,
150 /// customTitle > slug > first real user message (truncated) > "(untitled)"
151 pub title: String,
152 /// First event carrying a timestamp.
153 pub started_at: Option<DateTime<Utc>>,
154 /// Last event carrying a timestamp, or filesystem mtime as fallback.
155 pub last_activity_at: Option<DateTime<Utc>>,
156 /// First assistant message's `message.model`, if observed.
157 pub model: Option<String>,
158 /// Count of events that render as rows in the viewer timeline.
159 /// Excludes session-level metadata events (`permission-mode`,
160 /// `file-history-snapshot`, etc.) and sidechain subagent
161 /// events. Matches `read_session`'s `messages.len()`.
162 pub message_count: u32,
163 /// Count of actual human-typed prompts. A `user` event whose
164 /// `content` is a plain string, or an array that does NOT
165 /// contain a `tool_result` block, counts as a prompt.
166 /// Tool-result return events (which Claude Code writes as
167 /// `user` events after every `tool_use`) are excluded so the
168 /// sidebar reflects turns the human actually drove.
169 #[serde(default)]
170 pub prompt_count: u32,
171 pub git_branch: Option<String>,
172 pub version: Option<String>,
173 pub slug: Option<String>,
174 /// Verified working directory from the first event that carried a
175 /// `cwd` field. Projects use this as the authoritative cwd when
176 /// aggregating.
177 pub cwd: Option<String>,
178 /// Explicit title set via a `custom-title` event inside the jsonl
179 /// (Claude Code's `/title` slash command). When this is `Some`,
180 /// the command layer must **not** override `title` with any
181 /// external source — the user set it deliberately.
182 pub custom_title: Option<String>,
183 /// Which client wrote this session. Common values observed on disk:
184 /// `"cli"` (terminal Claude Code), `"claude-vscode"` (the VS Code
185 /// extension), `"sdk-ts"` (the claude-mem observer agent or any
186 /// other Agent SDK caller).
187 pub entrypoint: Option<String>,
188 /// Disk-backed or archive-reconstructed. Determines which
189 /// dispatch path [`commands::read_session`] takes.
190 #[serde(default)]
191 pub source: SessionSource,
192 }
193
194 /// Full viewer payload — summary + fully decoded message timeline.
195 #[derive(Debug, Clone, Serialize, Deserialize, TS)]
196 #[serde(rename_all = "camelCase")]
197 #[ts(export)]
198 pub struct SessionDetail {
199 pub summary: SessionSummary,
200 pub messages: Vec<Message>,
201 }
202
203 /// One entry in the viewer timeline. Tagged on `kind` so the TypeScript
204 /// side can discriminate exhaustively.
205 #[derive(Debug, Clone, Serialize, Deserialize, TS)]
206 #[serde(tag = "kind", rename_all = "snake_case", rename_all_fields = "camelCase")]
207 #[ts(export)]
208 pub enum Message {
209 User {
210 id: String,
211 at: DateTime<Utc>,
212 text: String,
213 /// `true` when this "user" event is actually a tool-result wrapper
214 /// injected by the agent loop, not something the human typed.
215 is_meta: bool,
216 },
217 Assistant {
218 id: String,
219 at: DateTime<Utc>,
220 model: Option<String>,
221 blocks: Vec<ContentBlock>,
222 stop_reason: Option<String>,
223 usage: Option<Usage>,
224 /// v1 seam: when v1's chat pane streams a response into the same
225 /// viewer, it sets `status: "streaming"` on the in-flight message.
226 /// v0 never sets this.
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 #[ts(optional)]
229 status: Option<StreamStatus>,
230 },
231 System {
232 id: String,
233 at: DateTime<Utc>,
234 text: String,
235 subtype: Option<String>,
236 },
237 Attachment {
238 id: String,
239 at: DateTime<Utc>,
240 attachment_type: String,
241 hook_name: Option<String>,
242 text: String,
243 },
244 /// Forward-compat escape hatch — any `type` we don't yet model is
245 /// rendered as a raw card in the viewer instead of crashing it.
246 Unknown {
247 id: String,
248 at: Option<DateTime<Utc>>,
249 raw_type: String,
250 raw: serde_json::Value,
251 },
252 }
253
254 /// Content block inside `Message::Assistant`. Matches the Anthropic
255 /// Messages API block shape the CLI pipes through.
256 #[derive(Debug, Clone, Serialize, Deserialize, TS)]
257 #[serde(tag = "type", rename_all = "snake_case", rename_all_fields = "camelCase")]
258 #[ts(export)]
259 pub enum ContentBlock {
260 Text {
261 text: String,
262 },
263 Thinking {
264 text: String,
265 },
266 ToolUse {
267 id: String,
268 name: String,
269 input: serde_json::Value,
270 },
271 ToolResult {
272 tool_use_id: String,
273 content: String,
274 is_error: bool,
275 },
276 }
277
278 /// Token accounting passed through from the Anthropic API response.
279 /// Fields are snake_case (unlike the rest of the frontend schema) because
280 /// they're verbatim from the API payload — renaming would require an
281 /// intermediate parse step that has no benefit.
282 #[derive(Debug, Clone, Serialize, Deserialize, TS, Default)]
283 #[ts(export)]
284 pub struct Usage {
285 #[serde(default)]
286 pub input_tokens: u32,
287 #[serde(default)]
288 pub output_tokens: u32,
289 #[serde(default)]
290 pub cache_creation_input_tokens: u32,
291 #[serde(default)]
292 pub cache_read_input_tokens: u32,
293 }
294
295 /// v1 seam: streaming state for an in-flight assistant message.
296 #[derive(Debug, Clone, Copy, Serialize, Deserialize, TS, PartialEq, Eq)]
297 #[serde(rename_all = "snake_case")]
298 #[ts(export)]
299 pub enum StreamStatus {
300 Streaming,
301 Complete,
302 Error,
303 }
304
305 /// Permission-gating mode passed to `claude --permission-mode <mode>`.
306 /// The CLI accepts more values (`default`, `dontAsk`) but we only
307 /// expose the four that make sense for a GUI chat flow. `Auto` is
308 /// the default: non-dangerous tools run without prompting, dangerous
309 /// tools (Bash/Edit/Write) block and fail the turn.
310 #[derive(Debug, Clone, Copy, Serialize, Deserialize, TS, PartialEq, Eq)]
311 #[serde(rename_all = "camelCase")]
312 #[ts(export)]
313 pub enum PermissionMode {
314 Auto,
315 AcceptEdits,
316 BypassPermissions,
317 Plan,
318 }
319
320 impl PermissionMode {
321 /// The exact string the `claude` CLI expects after
322 /// `--permission-mode`. Verified against `claude --help`.
323 pub fn as_cli(self) -> &'static str {
324 match self {
325 PermissionMode::Auto => "auto",
326 PermissionMode::AcceptEdits => "acceptEdits",
327 PermissionMode::BypassPermissions => "bypassPermissions",
328 PermissionMode::Plan => "plan",
329 }
330 }
331 }
332
333 impl Default for PermissionMode {
334 fn default() -> Self {
335 Self::Auto
336 }
337 }
338