From c395b42d2c967cc09e3957c2fb986e7d147ff090 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 07:40:28 -0700 Subject: [PATCH] feat(ecc2): persist file activity diff previews --- ecc2/src/session/mod.rs | 1 + ecc2/src/session/store.rs | 64 ++++++++++++++ ecc2/src/tui/dashboard.rs | 15 +++- scripts/hooks/session-activity-tracker.js | 88 ++++++++++++++++++-- tests/hooks/session-activity-tracker.test.js | 77 +++++++++++++++++ 5 files changed, 234 insertions(+), 11 deletions(-) diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 188324ed..0482e057 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -134,6 +134,7 @@ pub struct FileActivityEntry { pub action: FileActivityAction, pub path: String, pub summary: String, + pub diff_preview: Option, pub timestamp: DateTime, } diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index a237aa87..b800ca65 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -759,6 +759,8 @@ impl StateStore { struct ToolActivityFileEvent { path: String, action: String, + #[serde(default)] + diff_preview: Option, } let file = File::open(metrics_path) @@ -800,6 +802,7 @@ impl StateStore { .map(|path| PersistedFileEvent { path, action: infer_file_activity_action(&row.tool_name), + diff_preview: None, }) .collect() } else { @@ -814,6 +817,7 @@ impl StateStore { path, action: parse_file_activity_action(&event.action) .unwrap_or_else(|| infer_file_activity_action(&row.tool_name)), + diff_preview: normalize_optional_string(event.diff_preview), }) }) .collect() @@ -1594,6 +1598,7 @@ impl StateStore { Some(PersistedFileEvent { path, action: infer_file_activity_action(&tool_name), + diff_preview: None, }) }) .collect() @@ -1605,6 +1610,7 @@ impl StateStore { action: event.action, path: event.path, summary: summary.clone(), + diff_preview: event.diff_preview, timestamp: occurred_at, }); if events.len() >= limit { @@ -1621,6 +1627,8 @@ impl StateStore { struct PersistedFileEvent { path: String, action: FileActivityAction, + #[serde(default, skip_serializing_if = "Option::is_none")] + diff_preview: Option, } fn parse_persisted_file_events(value: &str) -> Option> { @@ -1635,6 +1643,7 @@ fn parse_persisted_file_events(value: &str) -> Option> { Some(PersistedFileEvent { path, action: event.action, + diff_preview: normalize_optional_string(event.diff_preview), }) }) .collect(); @@ -1656,6 +1665,17 @@ fn parse_file_activity_action(value: &str) -> Option { } } +fn normalize_optional_string(value: Option) -> Option { + value.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + fn infer_file_activity_action(tool_name: &str) -> FileActivityAction { let tool_name = tool_name.trim().to_ascii_lowercase(); if tool_name.contains("read") { @@ -1938,6 +1958,50 @@ mod tests { Ok(()) } + #[test] + fn list_file_activity_preserves_diff_previews() -> Result<()> { + let tempdir = TestDir::new("store-file-activity-diffs")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "session-1".to_string(), + task: "sync tools".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + + let metrics_dir = tempdir.path().join("metrics"); + fs::create_dir_all(&metrics_dir)?; + let metrics_path = metrics_dir.join("tool-usage.jsonl"); + fs::write( + &metrics_path, + concat!( + "{\"id\":\"evt-1\",\"session_id\":\"session-1\",\"tool_name\":\"Edit\",\"input_summary\":\"Edit src/config.ts\",\"output_summary\":\"updated config\",\"file_paths\":[\"src/config.ts\"],\"file_events\":[{\"path\":\"src/config.ts\",\"action\":\"modify\",\"diff_preview\":\"API_URL=http://localhost:3000 -> API_URL=https://api.example.com\"}],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n" + ), + )?; + + db.sync_tool_activity_metrics(&metrics_path)?; + + let activity = db.list_file_activity("session-1", 10)?; + assert_eq!(activity.len(), 1); + assert_eq!(activity[0].action, FileActivityAction::Modify); + assert_eq!(activity[0].path, "src/config.ts"); + assert_eq!( + activity[0].diff_preview.as_deref(), + Some("API_URL=http://localhost:3000 -> API_URL=https://api.example.com") + ); + + Ok(()) + } + #[test] fn refresh_session_durations_updates_running_and_terminal_sessions() -> Result<()> { let tempdir = TestDir::new("store-duration-metrics")?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index a708a8a0..809ae926 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -5398,11 +5398,18 @@ fn session_state_color(state: &SessionState) -> Color { } fn file_activity_summary(entry: &FileActivityEntry) -> String { - format!( + let mut summary = format!( "{} {}", file_activity_verb(entry.action.clone()), truncate_for_dashboard(&entry.path, 72) - ) + ); + + if let Some(diff_preview) = entry.diff_preview.as_ref() { + summary.push_str(" | "); + summary.push_str(&truncate_for_dashboard(diff_preview, 56)); + } + + summary } fn file_activity_verb(action: crate::session::FileActivityAction) -> &'static str { @@ -6085,7 +6092,7 @@ mod tests { &metrics_path, concat!( "{\"id\":\"evt-1\",\"session_id\":\"focus-12345678\",\"tool_name\":\"Read\",\"input_summary\":\"Read src/lib.rs\",\"output_summary\":\"ok\",\"file_paths\":[\"src/lib.rs\"],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n", - "{\"id\":\"evt-2\",\"session_id\":\"focus-12345678\",\"tool_name\":\"Write\",\"input_summary\":\"Write README.md\",\"output_summary\":\"updated readme\",\"file_paths\":[\"README.md\"],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n" + "{\"id\":\"evt-2\",\"session_id\":\"focus-12345678\",\"tool_name\":\"Write\",\"input_summary\":\"Write README.md\",\"output_summary\":\"updated readme\",\"file_paths\":[\"README.md\"],\"file_events\":[{\"path\":\"README.md\",\"action\":\"create\",\"diff_preview\":\"+ # ECC 2.0\"}],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n" ), )?; dashboard.db.sync_tool_activity_metrics(&metrics_path)?; @@ -6095,11 +6102,13 @@ mod tests { let rendered = dashboard.rendered_output_text(180, 30); assert!(rendered.contains("read src/lib.rs")); assert!(rendered.contains("create README.md")); + assert!(rendered.contains("+ # ECC 2.0")); assert!(!rendered.contains("files touched 2")); let metrics_text = dashboard.selected_session_metrics_text(); assert!(metrics_text.contains("Recent file activity")); assert!(metrics_text.contains("create README.md")); + assert!(metrics_text.contains("+ # ECC 2.0")); assert!(metrics_text.contains("read src/lib.rs")); let _ = fs::remove_dir_all(root); diff --git a/scripts/hooks/session-activity-tracker.js b/scripts/hooks/session-activity-tracker.js index 8627de00..917217c0 100644 --- a/scripts/hooks/session-activity-tracker.js +++ b/scripts/hooks/session-activity-tracker.js @@ -62,7 +62,7 @@ function pushPathCandidate(paths, value) { } } -function pushFileEvent(events, value, action) { +function pushFileEvent(events, value, action, diffPreview) { const candidate = String(value || '').trim(); if (!candidate) { return; @@ -70,11 +70,52 @@ function pushFileEvent(events, value, action) { if (/^(https?:\/\/|app:\/\/|plugin:\/\/|mcp:\/\/)/i.test(candidate)) { return; } - if (!events.some(event => event.path === candidate && event.action === action)) { - events.push({ path: candidate, action }); + const normalizedDiffPreview = typeof diffPreview === 'string' && diffPreview.trim() + ? diffPreview.trim() + : undefined; + if (!events.some(event => + event.path === candidate + && event.action === action + && (event.diff_preview || undefined) === normalizedDiffPreview + )) { + const event = { path: candidate, action }; + if (normalizedDiffPreview) { + event.diff_preview = normalizedDiffPreview; + } + events.push(event); } } +function sanitizeDiffText(value, maxLength = 96) { + if (typeof value !== 'string' || !value.trim()) { + return ''; + } + return truncateSummary(value, maxLength); +} + +function buildReplacementPreview(oldValue, newValue) { + const before = sanitizeDiffText(oldValue); + const after = sanitizeDiffText(newValue); + if (!before && !after) { + return undefined; + } + if (!before) { + return `-> ${after}`; + } + if (!after) { + return `${before} ->`; + } + return `${before} -> ${after}`; +} + +function buildCreationPreview(content) { + const normalized = sanitizeDiffText(content); + if (!normalized) { + return undefined; + } + return `+ ${normalized}`; +} + function inferDefaultFileAction(toolName) { const normalized = String(toolName || '').trim().toLowerCase(); if (normalized.includes('read')) { @@ -129,6 +170,11 @@ function collectFilePaths(value, paths) { for (const [key, nested] of Object.entries(value)) { if (FILE_PATH_KEYS.has(key)) { collectFilePaths(nested, paths); + continue; + } + + if (nested && (Array.isArray(nested) || typeof nested === 'object')) { + collectFilePaths(nested, paths); } } } @@ -142,20 +188,39 @@ function extractFilePaths(toolInput) { return paths; } -function collectFileEvents(toolName, value, events, key = null) { +function fileEventDiffPreview(toolName, value, action) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + + if (typeof value.old_string === 'string' || typeof value.new_string === 'string') { + return buildReplacementPreview(value.old_string, value.new_string); + } + + if (action === 'create') { + return buildCreationPreview(value.content || value.file_text || value.text); + } + + return undefined; +} + +function collectFileEvents(toolName, value, events, key = null, parentValue = null) { if (!value) { return; } if (Array.isArray(value)) { for (const entry of value) { - collectFileEvents(toolName, entry, events, key); + collectFileEvents(toolName, entry, events, key, parentValue); } return; } if (typeof value === 'string') { - pushFileEvent(events, value, actionForFileKey(toolName, key)); + if (key && FILE_PATH_KEYS.has(key)) { + const action = actionForFileKey(toolName, key); + pushFileEvent(events, value, action, fileEventDiffPreview(toolName, parentValue, action)); + } return; } @@ -165,7 +230,12 @@ function collectFileEvents(toolName, value, events, key = null) { for (const [nestedKey, nested] of Object.entries(value)) { if (FILE_PATH_KEYS.has(nestedKey)) { - collectFileEvents(toolName, nested, events, nestedKey); + collectFileEvents(toolName, nested, events, nestedKey, value); + continue; + } + + if (nested && (Array.isArray(nested) || typeof nested === 'object')) { + collectFileEvents(toolName, nested, events, null, nested); } } } @@ -234,8 +304,10 @@ function buildActivityRow(input, env = process.env) { } const toolInput = input?.tool_input || {}; - const filePaths = extractFilePaths(toolInput); const fileEvents = extractFileEvents(toolName, toolInput); + const filePaths = fileEvents.length > 0 + ? [...new Set(fileEvents.map(event => event.path))] + : extractFilePaths(toolInput); return { id: `tool-${Date.now()}-${crypto.randomBytes(6).toString('hex')}`, diff --git a/tests/hooks/session-activity-tracker.test.js b/tests/hooks/session-activity-tracker.test.js index c2f01507..d1af7b3d 100644 --- a/tests/hooks/session-activity-tracker.test.js +++ b/tests/hooks/session-activity-tracker.test.js @@ -130,6 +130,83 @@ function runTests() { fs.rmSync(tmpHome, { recursive: true, force: true }); }) ? passed++ : failed++); + (test('captures replacement diff previews for edit tool input', () => { + const tmpHome = makeTempDir(); + const input = { + tool_name: 'Edit', + tool_input: { + file_path: 'src/config.ts', + old_string: 'API_URL=http://localhost:3000', + new_string: 'API_URL=https://api.example.com', + }, + tool_output: { output: 'updated config' }, + }; + const result = runScript(input, { + ...withTempHome(tmpHome), + CLAUDE_HOOK_EVENT_NAME: 'PostToolUse', + ECC_SESSION_ID: 'ecc-session-edit', + }); + assert.strictEqual(result.code, 0); + + const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl'); + const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim()); + assert.deepStrictEqual(row.file_events, [ + { + path: 'src/config.ts', + action: 'modify', + diff_preview: 'API_URL=http://localhost:3000 -> API_URL=https://api.example.com', + }, + ]); + + fs.rmSync(tmpHome, { recursive: true, force: true }); + }) ? passed++ : failed++); + + (test('captures MultiEdit nested edits with typed diff previews', () => { + const tmpHome = makeTempDir(); + const input = { + tool_name: 'MultiEdit', + tool_input: { + edits: [ + { + file_path: 'src/a.ts', + old_string: 'const a = 1;', + new_string: 'const a = 2;', + }, + { + file_path: 'src/b.ts', + old_string: 'old name', + new_string: 'new name', + }, + ], + }, + tool_output: { output: 'updated two files' }, + }; + const result = runScript(input, { + ...withTempHome(tmpHome), + CLAUDE_HOOK_EVENT_NAME: 'PostToolUse', + ECC_SESSION_ID: 'ecc-session-multiedit', + }); + assert.strictEqual(result.code, 0); + + const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl'); + const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim()); + assert.deepStrictEqual(row.file_paths, ['src/a.ts', 'src/b.ts']); + assert.deepStrictEqual(row.file_events, [ + { + path: 'src/a.ts', + action: 'modify', + diff_preview: 'const a = 1; -> const a = 2;', + }, + { + path: 'src/b.ts', + action: 'modify', + diff_preview: 'old name -> new name', + }, + ]); + + fs.rmSync(tmpHome, { recursive: true, force: true }); + }) ? passed++ : failed++); + (test('prefers ECC_SESSION_ID over CLAUDE_SESSION_ID and redacts bash summaries', () => { const tmpHome = makeTempDir(); const input = {