feat: add PreCompact hook support (#267)

Parses [PreCompact Hook] events from the CLI stream and emits a claude:pre-compact Tauri event. Shows a "Compacting context..." toast and thinking state on the frontend, complementing the existing PostCompact handling.
This commit is contained in:
2026-05-06 14:18:57 -07:00
committed by Naomi Carrigan
parent 38692391e0
commit f3a2f8a491
4 changed files with 101 additions and 2 deletions
+32
View File
@@ -330,6 +330,14 @@ pub struct PostCompactEvent {
pub conversation_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PreCompactEvent {
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
#[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 {
+57 -2
View File
@@ -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<String>,
}
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<String>,
@@ -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""#;
+7
View File
@@ -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<PreCompactEvent>("claude:pre-compact", () => {
toastStore.addInfo("Compacting context...", "🗜️");
characterState.setTemporaryState("thinking", 3000);
});
unlisteners.push(preCompactUnlisten);
const postCompactUnlisten = await listen<PostCompactEvent>("claude:post-compact", () => {
toastStore.addInfo("Context compacted", "🗜️");
characterState.setTemporaryState("success", 2000);
+5
View File
@@ -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 {