diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 44cdf77..6f6058e 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -330,6 +330,14 @@ pub struct PostCompactEvent { pub conversation_id: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PreCompactEvent { + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CwdChangedEvent { pub cwd: String, @@ -790,6 +798,30 @@ mod tests { assert!(!serialized.contains("conversation_id")); } + #[test] + fn test_pre_compact_event_serialization() { + let event = PreCompactEvent { + session_id: Some("sess-abc".to_string()), + conversation_id: Some("conv-123".to_string()), + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(serialized.contains("\"session_id\":\"sess-abc\"")); + assert!(serialized.contains("\"conversation_id\":\"conv-123\"")); + } + + #[test] + fn test_pre_compact_event_omits_none_fields() { + let event = PreCompactEvent { + session_id: None, + conversation_id: None, + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(!serialized.contains("session_id")); + assert!(!serialized.contains("conversation_id")); + } + #[test] fn test_cwd_changed_event_serialization() { let event = CwdChangedEvent { diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 2bba6de..9b023c5 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -17,7 +17,7 @@ use crate::types::{ AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent, ConnectionStatus, ContentBlock, CwdChangedEvent, ElicitationEvent, ElicitationResultEvent, FileChangedEvent, MessageCost, OutputEvent, PermissionDeniedEvent, PermissionPromptEvent, - PermissionPromptEventItem, PostCompactEvent, QuestionOption, SessionEvent, StateChangeEvent, + PermissionPromptEventItem, PostCompactEvent, PreCompactEvent, QuestionOption, SessionEvent, StateChangeEvent, StopFailureEvent, TaskCreatedEvent, TodoItem, TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent, WorktreeEvent, WorktreeInfo, }; @@ -1173,6 +1173,7 @@ fn handle_stderr( let is_elicitation = line.contains("[Elicitation Hook]"); let is_elicitation_result = line.contains("[ElicitationResult Hook]"); let is_stop_failure = line.contains("[StopFailure Hook]"); + let is_pre_compact = line.contains("[PreCompact Hook]"); let is_post_compact = line.contains("[PostCompact Hook]"); let is_cwd_changed = line.contains("[CwdChanged Hook]"); let is_file_changed = line.contains("[FileChanged Hook]"); @@ -1187,7 +1188,7 @@ fn handle_stderr( "elicitation" } else if is_stop_failure { "error" - } else if is_post_compact { + } else if is_pre_compact || is_post_compact { "compact-prompt" } else if is_cwd_changed { "cwd-changed" @@ -1313,6 +1314,28 @@ fn handle_stderr( parent_tool_use_id: None, }, ); + } else if is_pre_compact { + let data = parse_pre_compact_hook(&line); + + let _ = app.emit( + "claude:pre-compact", + PreCompactEvent { + session_id: data.session_id, + conversation_id: conversation_id.clone(), + }, + ); + + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "compact-prompt".to_string(), + content: "Compacting context — conversation history is being summarised to free up space.".to_string(), + tool_name: None, + conversation_id: conversation_id.clone(), + cost: None, + parent_tool_use_id: None, + }, + ); } else if is_post_compact { let data = parse_post_compact_hook(&line); @@ -1656,6 +1679,16 @@ fn build_stop_failure_message(data: &StopFailureData) -> String { } } +#[derive(Debug)] +struct PreCompactData { + session_id: Option, +} + +fn parse_pre_compact_hook(line: &str) -> PreCompactData { + let session_id = extract_debug_string_value(line, "session_id"); + PreCompactData { session_id } +} + #[derive(Debug)] struct PostCompactData { session_id: Option, @@ -3920,6 +3953,28 @@ mod tests { assert_eq!(data.session_id, None); } + #[test] + fn test_parse_pre_compact_hook_with_session_id() { + let line = + r#"[PreCompact Hook] session_id=Some("sess-abc123"), conversation_id=Some("conv-xyz")"#; + let data = parse_pre_compact_hook(line); + assert_eq!(data.session_id, Some("sess-abc123".to_string())); + } + + #[test] + fn test_parse_pre_compact_hook_without_session_id() { + let line = "[PreCompact Hook] session_id=None"; + let data = parse_pre_compact_hook(line); + assert_eq!(data.session_id, None); + } + + #[test] + fn test_parse_pre_compact_hook_empty_line() { + let line = "[PreCompact Hook]"; + let data = parse_pre_compact_hook(line); + assert_eq!(data.session_id, None); + } + #[test] fn test_parse_cwd_changed_hook_with_cwd_key() { let line = r#"[CwdChanged Hook] cwd="/home/naomi/code/my-project""#; diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index ac33c02..d35d249 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -11,6 +11,7 @@ import type { ElicitationEvent, PermissionPromptEvent, PostCompactEvent, + PreCompactEvent, StopFailureEvent, UserQuestionEvent, } from "$lib/types/messages"; @@ -661,6 +662,12 @@ export async function initializeTauriListeners() { }); unlisteners.push(stopFailureUnlisten); + const preCompactUnlisten = await listen("claude:pre-compact", () => { + toastStore.addInfo("Compacting context...", "🗜️"); + characterState.setTemporaryState("thinking", 3000); + }); + unlisteners.push(preCompactUnlisten); + const postCompactUnlisten = await listen("claude:post-compact", () => { toastStore.addInfo("Context compacted", "🗜️"); characterState.setTemporaryState("success", 2000); diff --git a/src/lib/types/messages.ts b/src/lib/types/messages.ts index 754f94c..020bb87 100644 --- a/src/lib/types/messages.ts +++ b/src/lib/types/messages.ts @@ -187,6 +187,11 @@ export interface PostCompactEvent { conversation_id?: string; } +export interface PreCompactEvent { + session_id?: string; + conversation_id?: string; +} + export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error"; export interface Attachment {