From eee9768cd88cda6543482a549c4f8de407ed3a77 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 07:45:37 -0700 Subject: [PATCH] feat(ecc2): persist file activity patch previews --- ecc2/src/session/mod.rs | 1 + ecc2/src/session/store.rs | 17 ++++- ecc2/src/tui/dashboard.rs | 45 ++++++++++-- scripts/hooks/session-activity-tracker.js | 75 +++++++++++++++++++- tests/hooks/session-activity-tracker.test.js | 3 + 5 files changed, 133 insertions(+), 8 deletions(-) diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 0482e057..21a10e79 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -135,6 +135,7 @@ pub struct FileActivityEntry { pub path: String, pub summary: String, pub diff_preview: Option, + pub patch_preview: Option, pub timestamp: DateTime, } diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index b800ca65..b6de51b7 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -761,6 +761,8 @@ impl StateStore { action: String, #[serde(default)] diff_preview: Option, + #[serde(default)] + patch_preview: Option, } let file = File::open(metrics_path) @@ -803,6 +805,7 @@ impl StateStore { path, action: infer_file_activity_action(&row.tool_name), diff_preview: None, + patch_preview: None, }) .collect() } else { @@ -818,6 +821,7 @@ impl StateStore { 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), + patch_preview: normalize_optional_string(event.patch_preview), }) }) .collect() @@ -1599,6 +1603,7 @@ impl StateStore { path, action: infer_file_activity_action(&tool_name), diff_preview: None, + patch_preview: None, }) }) .collect() @@ -1611,6 +1616,7 @@ impl StateStore { path: event.path, summary: summary.clone(), diff_preview: event.diff_preview, + patch_preview: event.patch_preview, timestamp: occurred_at, }); if events.len() >= limit { @@ -1629,6 +1635,8 @@ struct PersistedFileEvent { action: FileActivityAction, #[serde(default, skip_serializing_if = "Option::is_none")] diff_preview: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + patch_preview: Option, } fn parse_persisted_file_events(value: &str) -> Option> { @@ -1644,6 +1652,7 @@ fn parse_persisted_file_events(value: &str) -> Option> { path, action: event.action, diff_preview: normalize_optional_string(event.diff_preview), + patch_preview: normalize_optional_string(event.patch_preview), }) }) .collect(); @@ -1959,7 +1968,7 @@ mod tests { } #[test] - fn list_file_activity_preserves_diff_previews() -> Result<()> { + fn list_file_activity_preserves_diff_and_patch_previews() -> Result<()> { let tempdir = TestDir::new("store-file-activity-diffs")?; let db = StateStore::open(&tempdir.path().join("state.db"))?; let now = Utc::now(); @@ -1984,7 +1993,7 @@ mod tests { 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" + "{\"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\",\"patch_preview\":\"@@\\n- API_URL=http://localhost:3000\\n+ API_URL=https://api.example.com\"}],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n" ), )?; @@ -1998,6 +2007,10 @@ mod tests { activity[0].diff_preview.as_deref(), Some("API_URL=http://localhost:3000 -> API_URL=https://api.example.com") ); + assert_eq!( + activity[0].patch_preview.as_deref(), + Some("@@\n- API_URL=http://localhost:3000\n+ API_URL=https://api.example.com") + ); Ok(()) } diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 809ae926..cdb2271b 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -34,6 +34,7 @@ const PANE_RESIZE_STEP_PERCENT: u16 = 5; const MAX_LOG_ENTRIES: u64 = 12; const MAX_DIFF_PREVIEW_LINES: usize = 6; const MAX_DIFF_PATCH_LINES: usize = 80; +const MAX_FILE_ACTIVITY_PATCH_LINES: usize = 3; #[derive(Debug, Clone, PartialEq, Eq)] struct WorktreeDiffColumns { @@ -203,6 +204,7 @@ struct TimelineEvent { session_id: String, event_type: TimelineEventType, summary: String, + detail_lines: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -3410,19 +3412,26 @@ impl Dashboard { .into_iter() .filter(|event| self.timeline_event_filter.matches(event.event_type)) .filter(|event| self.output_time_filter.matches_timestamp(event.occurred_at)) - .map(|event| { + .flat_map(|event| { let prefix = if show_session_label { format!("{} ", format_session_id(&event.session_id)) } else { String::new() }; - Line::from(format!( + let mut lines = vec![Line::from(format!( "[{}] {}{:<11} {}", event.occurred_at.format("%H:%M:%S"), prefix, event.event_type.label(), event.summary - )) + ))]; + lines.extend( + event + .detail_lines + .into_iter() + .map(|line| Line::from(format!(" {}", line))), + ); + lines }) .collect() } @@ -3459,6 +3468,7 @@ impl Dashboard { session.agent_type, truncate_for_dashboard(&session.task, 64) ), + detail_lines: Vec::new(), }]; if session.updated_at > session.created_at { @@ -3467,6 +3477,7 @@ impl Dashboard { session_id: session.id.clone(), event_type: TimelineEventType::Lifecycle, summary: format!("state {} | updated session metadata", session.state), + detail_lines: Vec::new(), }); } @@ -3479,6 +3490,7 @@ impl Dashboard { "attached worktree {} from {}", worktree.branch, worktree.base_branch ), + detail_lines: Vec::new(), }); } @@ -3492,6 +3504,7 @@ impl Dashboard { session_id: session.id.clone(), event_type: TimelineEventType::FileChange, summary: format!("files touched {}", session.metrics.files_changed), + detail_lines: Vec::new(), }); } else { events.extend(file_activity.into_iter().map(|entry| TimelineEvent { @@ -3499,6 +3512,7 @@ impl Dashboard { session_id: session.id.clone(), event_type: TimelineEventType::FileChange, summary: file_activity_summary(&entry), + detail_lines: file_activity_patch_lines(&entry, MAX_FILE_ACTIVITY_PATCH_LINES), })); } @@ -3525,6 +3539,7 @@ impl Dashboard { 64 ) ), + detail_lines: Vec::new(), } })); @@ -3544,6 +3559,7 @@ impl Dashboard { entry.duration_ms, truncate_for_dashboard(&entry.input_summary, 56) ), + detail_lines: Vec::new(), }) })); events @@ -4148,6 +4164,9 @@ impl Dashboard { self.short_timestamp(&entry.timestamp.to_rfc3339()), file_activity_summary(&entry) )); + for detail in file_activity_patch_lines(&entry, 2) { + lines.push(format!(" {}", detail)); + } } } lines.push(format!( @@ -5412,6 +5431,22 @@ fn file_activity_summary(entry: &FileActivityEntry) -> String { summary } +fn file_activity_patch_lines(entry: &FileActivityEntry, max_lines: usize) -> Vec { + entry + .patch_preview + .as_deref() + .map(|patch| { + patch + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && *line != "@@" && *line != "+" && *line != "-") + .take(max_lines) + .map(|line| truncate_for_dashboard(line, 72)) + .collect() + }) + .unwrap_or_default() +} + fn file_activity_verb(action: crate::session::FileActivityAction) -> &'static str { match action { crate::session::FileActivityAction::Read => "read", @@ -6092,7 +6127,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\"],\"file_events\":[{\"path\":\"README.md\",\"action\":\"create\",\"diff_preview\":\"+ # ECC 2.0\"}],\"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\",\"patch_preview\":\"+ # ECC 2.0\\n+ \\n+ A richer dashboard\"}],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n" ), )?; dashboard.db.sync_tool_activity_metrics(&metrics_path)?; @@ -6103,12 +6138,14 @@ mod tests { assert!(rendered.contains("read src/lib.rs")); assert!(rendered.contains("create README.md")); assert!(rendered.contains("+ # ECC 2.0")); + assert!(rendered.contains("+ A richer dashboard")); 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("+ A richer dashboard")); 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 917217c0..554700f7 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, diffPreview) { +function pushFileEvent(events, value, action, diffPreview, patchPreview) { const candidate = String(value || '').trim(); if (!candidate) { return; @@ -73,15 +73,22 @@ function pushFileEvent(events, value, action, diffPreview) { const normalizedDiffPreview = typeof diffPreview === 'string' && diffPreview.trim() ? diffPreview.trim() : undefined; + const normalizedPatchPreview = typeof patchPreview === 'string' && patchPreview.trim() + ? patchPreview.trim() + : undefined; if (!events.some(event => event.path === candidate && event.action === action && (event.diff_preview || undefined) === normalizedDiffPreview + && (event.patch_preview || undefined) === normalizedPatchPreview )) { const event = { path: candidate, action }; if (normalizedDiffPreview) { event.diff_preview = normalizedDiffPreview; } + if (normalizedPatchPreview) { + event.patch_preview = normalizedPatchPreview; + } events.push(event); } } @@ -93,6 +100,19 @@ function sanitizeDiffText(value, maxLength = 96) { return truncateSummary(value, maxLength); } +function sanitizePatchLines(value, maxLines = 4, maxLineLength = 120) { + if (typeof value !== 'string' || !value.trim()) { + return []; + } + + return stripAnsi(redactSecrets(value)) + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean) + .slice(0, maxLines) + .map(line => line.length <= maxLineLength ? line : `${line.slice(0, maxLineLength - 3)}...`); +} + function buildReplacementPreview(oldValue, newValue) { const before = sanitizeDiffText(oldValue); const after = sanitizeDiffText(newValue); @@ -116,6 +136,31 @@ function buildCreationPreview(content) { return `+ ${normalized}`; } +function buildPatchPreviewFromReplacement(oldValue, newValue) { + const beforeLines = sanitizePatchLines(oldValue); + const afterLines = sanitizePatchLines(newValue); + if (beforeLines.length === 0 && afterLines.length === 0) { + return undefined; + } + + const lines = ['@@']; + for (const line of beforeLines) { + lines.push(`- ${line}`); + } + for (const line of afterLines) { + lines.push(`+ ${line}`); + } + return lines.join('\n'); +} + +function buildPatchPreviewFromContent(content, prefix) { + const lines = sanitizePatchLines(content); + if (lines.length === 0) { + return undefined; + } + return lines.map(line => `${prefix} ${line}`).join('\n'); +} + function inferDefaultFileAction(toolName) { const normalized = String(toolName || '').trim().toLowerCase(); if (normalized.includes('read')) { @@ -204,6 +249,26 @@ function fileEventDiffPreview(toolName, value, action) { return undefined; } +function fileEventPatchPreview(value, action) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + + if (typeof value.old_string === 'string' || typeof value.new_string === 'string') { + return buildPatchPreviewFromReplacement(value.old_string, value.new_string); + } + + if (action === 'create') { + return buildPatchPreviewFromContent(value.content || value.file_text || value.text, '+'); + } + + if (action === 'delete') { + return buildPatchPreviewFromContent(value.content || value.old_string || value.file_text, '-'); + } + + return undefined; +} + function collectFileEvents(toolName, value, events, key = null, parentValue = null) { if (!value) { return; @@ -219,7 +284,13 @@ function collectFileEvents(toolName, value, events, key = null, parentValue = nu if (typeof value === 'string') { if (key && FILE_PATH_KEYS.has(key)) { const action = actionForFileKey(toolName, key); - pushFileEvent(events, value, action, fileEventDiffPreview(toolName, parentValue, action)); + pushFileEvent( + events, + value, + action, + fileEventDiffPreview(toolName, parentValue, action), + fileEventPatchPreview(parentValue, action) + ); } return; } diff --git a/tests/hooks/session-activity-tracker.test.js b/tests/hooks/session-activity-tracker.test.js index d1af7b3d..84620c7f 100644 --- a/tests/hooks/session-activity-tracker.test.js +++ b/tests/hooks/session-activity-tracker.test.js @@ -155,6 +155,7 @@ function runTests() { path: 'src/config.ts', action: 'modify', diff_preview: 'API_URL=http://localhost:3000 -> API_URL=https://api.example.com', + patch_preview: '@@\n- API_URL=http://localhost:3000\n+ API_URL=https://api.example.com', }, ]); @@ -196,11 +197,13 @@ function runTests() { path: 'src/a.ts', action: 'modify', diff_preview: 'const a = 1; -> const a = 2;', + patch_preview: '@@\n- const a = 1;\n+ const a = 2;', }, { path: 'src/b.ts', action: 'modify', diff_preview: 'old name -> new name', + patch_preview: '@@\n- old name\n+ new name', }, ]);