From b61b81895282bd064b135230a7c996def8e75df1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Dec 2025 15:06:16 +0000 Subject: [PATCH 1/5] Fix inconsistent note detail access in evidence_detail view Previously, pressing Enter on a note in evidence_detail view would open the notes modal (same as 'v' key), while in other views (case_detail, tag_notes_list, ioc_notes_list) it would open the note_detail view. This created confusing and inconsistent behavior. Now Enter consistently opens note_detail view across all contexts: - Enter: Opens note detail view (full note content) - 'v': Opens notes modal (scrollable list of all notes) This aligns the implementation with the help text which already documented the correct behavior. --- trace/tui.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/trace/tui.py b/trace/tui.py index b892ae9..6064082 100644 --- a/trace/tui.py +++ b/trace/tui.py @@ -1298,15 +1298,14 @@ class TUI: notes = self.active_evidence.notes list_h = self.content_h - 5 display_notes = notes[-list_h:] if len(notes) > list_h else notes - + if display_notes and self.selected_index < len(display_notes): - # Calculate the actual note index in the full list - note_offset = len(notes) - len(display_notes) - actual_note_index = note_offset + self.selected_index - # Open notes view and jump to selected note - self._highlight_note_idx = actual_note_index - self.view_evidence_notes(highlight_note_index=actual_note_index) - delattr(self, '_highlight_note_idx') # Reset filter on view change + # Show note detail view (consistent with other views) + self.current_note = display_notes[self.selected_index] + self.previous_view = "evidence_detail" + self.current_view = "note_detail" + self.selected_index = 0 + self.scroll_offset = 0 elif self.current_view == "case_detail": if self.active_case: case_notes = self.active_case.notes From 461da25c93a16d6ff08cb62b0046cf76e8c1ea78 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Dec 2025 15:20:59 +0000 Subject: [PATCH 2/5] Add dual note navigation options in evidence_detail view Now provides two ways to access notes in evidence_detail view: - Enter: Opens note_detail view (single note focus) - 'v': Opens notes modal with selected note highlighted (all notes) The 'v' key now intelligently jumps to the selected note when opening the modal, providing context while still showing all notes. This gives users flexibility in how they want to view their notes. --- trace/tui.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/trace/tui.py b/trace/tui.py index 6064082..415649b 100644 --- a/trace/tui.py +++ b/trace/tui.py @@ -1454,7 +1454,19 @@ class TUI: if self.current_view == "case_detail": self.view_case_notes() elif self.current_view == "evidence_detail": - self.view_evidence_notes() + # Open notes modal with selected note highlighted + if self.active_evidence: + notes = self.active_evidence.notes + list_h = self.content_h - 5 + display_notes = notes[-list_h:] if len(notes) > list_h else notes + + if display_notes and self.selected_index < len(display_notes): + # Calculate the actual note index in the full list + note_offset = len(notes) - len(display_notes) + actual_note_index = note_offset + self.selected_index + self.view_evidence_notes(highlight_note_index=actual_note_index) + else: + self.view_evidence_notes() # Delete elif key == ord('d'): From b973aa1009b9329cdecb0ecf4b6c7be5f6ad2ef5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Dec 2025 15:22:59 +0000 Subject: [PATCH 3/5] Make note navigation consistent across case_detail and evidence_detail Both views now support dual navigation options: - Enter: Opens note_detail view (single note focus) - 'v': Opens notes modal with selected note highlighted Previously, case_detail would open the notes modal from the beginning even when a case note was selected. Now it intelligently jumps to the selected case note, matching the behavior in evidence_detail view. This provides a consistent, predictable user experience across both views where notes can be selected and viewed. --- trace/tui.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/trace/tui.py b/trace/tui.py index 415649b..a87ed49 100644 --- a/trace/tui.py +++ b/trace/tui.py @@ -1452,7 +1452,20 @@ class TUI: self.handle_open_iocs() elif key == ord('v'): if self.current_view == "case_detail": - self.view_case_notes() + # Open notes modal with selected case note highlighted (if applicable) + if self.active_case: + case_notes = self.active_case.notes + filtered = self._get_filtered_list(self.active_case.evidence, "name", "description") + + # Check if a case note is selected (not an evidence item) + if case_notes and self.selected_index >= len(filtered): + note_idx = self.selected_index - len(filtered) + if note_idx < len(case_notes): + self.view_case_notes(highlight_note_index=note_idx) + else: + self.view_case_notes() + else: + self.view_case_notes() elif self.current_view == "evidence_detail": # Open notes modal with selected note highlighted if self.active_evidence: From ac7e4429703837ce035a773e2e4ddcdf17f91269 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Dec 2025 15:35:39 +0000 Subject: [PATCH 4/5] Add UX consistency analysis documentation Documents findings from comprehensive menu interface review across all 9 views in the TUI. Identifies inconsistencies with filter, delete, and export functionality. Clarifications from user: - 'n' and 'a' keys correctly limited to case/evidence contexts - Filter should work everywhere (context-sensitive) - Delete should work for all note views, not tag/IOC lists - Export should extend to case/evidence markdown exports --- CONSISTENCY_ANALYSIS.md | 220 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 CONSISTENCY_ANALYSIS.md diff --git a/CONSISTENCY_ANALYSIS.md b/CONSISTENCY_ANALYSIS.md new file mode 100644 index 0000000..1654df2 --- /dev/null +++ b/CONSISTENCY_ANALYSIS.md @@ -0,0 +1,220 @@ +# Menu Interface Consistency Analysis + +## Summary +Investigation of key bindings and behavior across all views in the trace TUI application to identify inconsistencies and improve user experience. + +## Views in the Application +1. **case_list** - Main list of cases +2. **case_detail** - Case details with evidence list and case notes +3. **evidence_detail** - Evidence details with notes +4. **tags_list** - List of tags with usage counts +5. **tag_notes_list** - List of notes containing a specific tag +6. **ioc_list** - List of IOCs with usage counts +7. **ioc_notes_list** - List of notes containing a specific IOC +8. **note_detail** - Full view of a single note +9. **help** - Help screen + +--- + +## ✅ What's CONSISTENT and Working Well + +### Global Keys (work everywhere) +- **`?` or `h`**: Open help - Works from any view ✓ +- **`q`**: Quit (or close help if in help view) ✓ +- **`b`**: Back/navigate up hierarchy - Works consistently ✓ +- **Arrow keys**: Navigate lists - Works consistently ✓ + +### Navigation Pattern +- **Enter**: Consistently opens/selects items or dives deeper ✓ +- **`b`**: Consistently goes back up the hierarchy ✓ +- Hierarchy navigation is logical and predictable ✓ + +### Note Actions in Main Views +- **`n`**: Add note - Works in case_list, case_detail, evidence_detail ✓ +- **Enter on note**: Opens note_detail view - Now consistent across case_detail, evidence_detail, tag_notes_list, ioc_notes_list ✓ +- **`v`**: View notes modal with highlight - Now consistent in case_detail and evidence_detail ✓ + +--- + +## ❌ INCONSISTENCIES Found + +### 1. **'n' Key (Add Note) - Incomplete Coverage** ⚠️ +**Issue**: The 'n' key only works in `case_list`, `case_detail`, and `evidence_detail`. + +**Missing in**: +- `tags_list` - Pressing 'n' does nothing useful +- `tag_notes_list` - Pressing 'n' does nothing useful +- `ioc_list` - Pressing 'n' does nothing useful +- `ioc_notes_list` - Pressing 'n' does nothing useful +- `note_detail` - Pressing 'n' does nothing useful +- `help` - (OK to not work here) + +**Expected behavior**: User should be able to add notes from tag/IOC views since they're actively working with a case/evidence context. + +**Recommendation**: +- Make 'n' work in `tag_notes_list` and `ioc_notes_list` by using the parent context (active_case or active_evidence) +- Consider whether it should work in `tags_list` and `ioc_list` as well +- In `note_detail`, 'n' could add a new note to the same context as the currently viewed note + +**Code location**: `trace/tui.py:2242` - `dialog_add_note()` only handles 3 views + +--- + +### 2. **'a' Key (Set Active) - Incomplete Implementation** ⚠️ +**Issue**: The global handler `key == ord('a')` calls `_handle_set_active()`, but that method only handles: +- `case_list` +- `case_detail` +- `evidence_detail` + +**Missing in**: +- `tags_list` - Could set active on the parent case/evidence +- `tag_notes_list` - Silent failure when pressed +- `ioc_list` - Could set active on the parent case/evidence +- `ioc_notes_list` - Silent failure when pressed +- `note_detail` - Silent failure when pressed + +**Expected behavior**: Either the key should work (set active on the parent context) or show a message that it's not applicable. + +**Recommendation**: +- Option A: Make 'a' work in tag/IOC list views by setting active on the parent case/evidence +- Option B: Show message "Not applicable in this view" when pressed in unsupported views +- Prefer Option A for consistency + +**Code location**: `trace/tui.py:1512` - `_handle_set_active()` + +--- + +### 3. **'/' Key (Filter) - Limited Availability** ℹ️ +**Issue**: Filter only works in `case_list` and `case_detail`. + +**Missing in**: +- `evidence_detail` - Would be useful to filter notes! +- `tags_list` - Would be useful to filter tags +- `tag_notes_list` - Would be useful to filter notes +- `ioc_list` - Would be useful to filter IOCs +- `ioc_notes_list` - Would be useful to filter notes + +**Expected behavior**: Users might expect to filter long lists of notes, tags, or IOCs. + +**Recommendation**: +- High priority: Add filtering to `evidence_detail` (filter notes by content) +- Medium priority: Add filtering to `tags_list`, `ioc_list` (filter by name/value) +- Lower priority: Add filtering to `tag_notes_list`, `ioc_notes_list` (filter notes by content) + +**Code location**: `trace/tui.py:1236-1240` - Filter toggle only checks two views + +--- + +### 4. **'t' and 'i' Keys (Tags/IOCs) - Context-Dependent Availability** ℹ️ +**Issue**: These keys only work in `case_detail` and `evidence_detail`. + +**Current behavior**: Silent no-op in other views + +**Expected behavior**: This is actually reasonable - tags and IOCs are context-specific. However, it might be confusing when users press them in `tag_notes_list` or `ioc_notes_list` and nothing happens. + +**Recommendation**: +- Low priority fix: Consider allowing 't' in `ioc_notes_list` to switch to tags view +- Low priority fix: Consider allowing 'i' in `tag_notes_list` to switch to IOCs view +- This would allow quick toggling between tag and IOC exploration +- Alternative: Show helpful message if pressed in unsupported views + +**Code location**: `trace/tui.py:1449-1452` - No view restrictions, but handlers at lines 182 and 206 check view + +--- + +### 5. **'d' Key (Delete) - Incomplete Coverage** ⚠️ +**Issue**: Delete functionality is not implemented for all views where deletion makes sense. + +**Works in**: +- `case_list` - Delete case ✓ +- `case_detail` - Delete evidence or note ✓ +- `evidence_detail` - Delete note ✓ + +**Missing in**: +- `tag_notes_list` - Could delete the selected note +- `ioc_notes_list` - Could delete the selected note +- `note_detail` - Could delete the currently viewed note + +**Expected behavior**: Users viewing a note should be able to delete it from any view. + +**Recommendation**: +- Add delete support for `tag_notes_list` and `ioc_notes_list` +- Add delete support for `note_detail` (delete current note and return to previous view) +- Be careful with confirmation dialogs to prevent accidental deletion + +**Code location**: `trace/tui.py:2331` - `handle_delete()` method + +--- + +### 6. **'v' Key (View Notes Modal) - Limited Availability** ℹ️ +**Issue**: View notes modal only works in `case_detail` and `evidence_detail`. + +**Missing in**: +- `tag_notes_list` - Could show all notes with the tag in a modal +- `ioc_notes_list` - Could show all notes with the IOC in a modal + +**Expected behavior**: Might be nice to have a modal view option from tag/IOC note lists, but not critical since they're already list views. + +**Recommendation**: +- Low priority: This might be redundant since tag_notes_list and ioc_notes_list are already list views +- Consider if there's a different modal that would be useful (e.g., showing just the tag/IOC highlights) + +--- + +### 7. **'e' Key (Export) - Very Limited** ℹ️ +**Issue**: Export only works for IOCs in `ioc_list` and `ioc_notes_list`. + +**Missing export options**: +- Export tags from `tags_list` +- Export notes from various views +- Export case summary from `case_detail` + +**Expected behavior**: Users might expect export functionality for other data types. + +**Recommendation**: +- Medium priority: Add 'e' to export tags from `tags_list` +- Lower priority: Consider export options for notes, cases, evidence + +--- + +## Priority Recommendations + +### High Priority (User expects it to work) +1. **Fix 'n' (add note) in tag/IOC note lists** - Users actively working with notes should be able to add new ones +2. **Fix 'a' (set active) to work or give feedback** - Silent failures are confusing +3. **Add filtering to evidence_detail** - Natural extension of existing filter functionality + +### Medium Priority (Nice to have) +4. **Add delete support for tag/IOC note lists and note_detail** - Complete the delete functionality +5. **Add filter to tag and IOC lists** - Helpful for large numbers of items +6. **Make 't' and 'i' keys provide feedback** - Better UX than silent failure + +### Low Priority (Edge cases) +7. **Cross-navigation between tags and IOCs** - Allow 't' in IOC views and 'i' in tag views +8. **Export tags** - Complement the export IOCs functionality + +--- + +## General Observations + +### Strengths +- Core navigation is very consistent (Enter, b, arrows) +- The hierarchy is logical and predictable +- Global keys work well everywhere +- Recent fixes made note navigation consistent ✓ + +### Areas for Improvement +- Feature keys ('n', 'a', 'd', 't', 'i', 'v') should either work everywhere sensible OR provide clear feedback when not applicable +- Filtering could be more universally available +- Delete functionality should be available wherever items can be viewed +- Silent failures (pressing a key and nothing happening) should be minimized + +--- + +## Testing Recommendations + +Create a testing matrix: +- Test each key in each view +- Document expected behavior +- Mark any silent failures +- Ensure error messages are helpful when actions aren't available From ec5a3d9f31662186cb9a7cb36f3f180d0b94cccd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Dec 2025 15:43:32 +0000 Subject: [PATCH 5/5] Add comprehensive menu consistency improvements Based on UX analysis, added three major improvements: 1. Context-sensitive filtering everywhere ('/' key): - evidence_detail: Filter notes by content - tags_list: Filter tags by name - tag_notes_list: Filter notes by content - ioc_list: Filter IOCs by value or type - ioc_notes_list: Filter notes by content - All views show active filter in footer 2. Extended delete support ('d' key): - note_detail: Delete current note and return to previous view - tag_notes_list: Delete selected note from filtered view - ioc_notes_list: Delete selected note from filtered view - Finds and removes note from parent case/evidence 3. Markdown export for case/evidence ('e' key): - case_detail: Export entire case + all evidence to markdown - evidence_detail: Export single evidence item to markdown - Files saved to ~/.trace/exports/ with timestamps - Complements existing IOC export functionality All changes maintain consistent UX patterns across views and provide clear feedback through updated footers showing available actions in each context. --- trace/tui.py | 365 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 327 insertions(+), 38 deletions(-) diff --git a/trace/tui.py b/trace/tui.py index a87ed49..acc7114 100644 --- a/trace/tui.py +++ b/trace/tui.py @@ -781,7 +781,7 @@ class TUI: is_selected = (item_idx == self.selected_index) self._display_line_with_highlights(y, 4, display_str, is_selected) - self.stdscr.addstr(self.height - 3, 2, "[N] New Evidence [n] Add Note [t] Tags [i] IOCs [v] View Notes [a] Active [d] Delete [?] Help", curses.color_pair(3)) + self.stdscr.addstr(self.height - 3, 2, "[N] New Evidence [n] Add Note [t] Tags [i] IOCs [v] View [e] Export [a] Active [d] Delete [?] Help", curses.color_pair(3)) def draw_evidence_detail(self): if not self.active_evidence: return @@ -812,14 +812,17 @@ class TUI: current_y += 1 current_y += 1 # Blank line before notes - self.stdscr.addstr(current_y, 2, f"Notes ({len(self.active_evidence.notes)}):", curses.A_UNDERLINE) + + # Apply filter if active + notes = self._get_filtered_list(self.active_evidence.notes, "content") if self.filter_query else self.active_evidence.notes + + self.stdscr.addstr(current_y, 2, f"Notes ({len(notes)}):", curses.A_UNDERLINE) current_y += 1 # Just show last N notes that fit list_h = self.content_h - (current_y - 2) # Adjust for dynamic header start_y = current_y - notes = self.active_evidence.notes display_notes = notes[-list_h:] if len(notes) > list_h else notes # Update scroll for note selection @@ -841,7 +844,10 @@ class TUI: is_selected = (idx == self.selected_index) self._display_line_with_highlights(start_y + i, 4, display_str, is_selected) - self.stdscr.addstr(self.height - 3, 2, "[n] Add Note [t] Tags [i] IOCs [v] View Notes [a] Active [d] Delete Note [?] Help", curses.color_pair(3)) + footer = "[n] Add Note [t] Tags [i] IOCs [v] View [e] Export [a] Active [d] Delete [/] Filter [?] Help" + if self.filter_query: + footer += f" Filter: {self.filter_query}" + self.stdscr.addstr(self.height - 3, 2, footer[:self.width - 4], curses.color_pair(3)) def draw_tags_list(self): """Draw the tags list view showing all tags sorted by occurrence count""" @@ -851,19 +857,29 @@ class TUI: self.stdscr.addstr(2, 2, f"Tags for {context}: {context_name}", curses.A_BOLD) self.stdscr.addstr(3, 2, "─" * (self.width - 4)) - if not self.current_tags: - self.stdscr.addstr(5, 4, "No tags found.", curses.color_pair(3)) - self.stdscr.addstr(self.height - 3, 2, "[b] Back", curses.color_pair(3)) + # Apply filter if active (filter by tag name) + tags_to_show = self.current_tags + if self.filter_query: + q = self.filter_query.lower() + tags_to_show = [(tag, count) for tag, count in self.current_tags if q in tag.lower()] + + if not tags_to_show: + msg = "No tags match filter." if self.filter_query else "No tags found." + self.stdscr.addstr(5, 4, msg, curses.color_pair(3)) + footer = "[b] Back [/] Filter" + if self.filter_query: + footer += f" Filter: {self.filter_query}" + self.stdscr.addstr(self.height - 3, 2, footer[:self.width - 4], curses.color_pair(3)) return - list_h = self._update_scroll(len(self.current_tags)) + list_h = self._update_scroll(len(tags_to_show)) for i in range(list_h): idx = self.scroll_offset + i - if idx >= len(self.current_tags): + if idx >= len(tags_to_show): break - tag, count = self.current_tags[idx] + tag, count = tags_to_show[idx] y = 5 + i display_str = f"#{tag}".ljust(30) + f"({count} notes)" @@ -876,26 +892,36 @@ class TUI: else: self.stdscr.addstr(y, 4, display_str) - self.stdscr.addstr(self.height - 3, 2, "[Enter] View Notes [b] Back", curses.color_pair(3)) + footer = "[Enter] View Notes [b] Back [/] Filter" + if self.filter_query: + footer += f" Filter: {self.filter_query}" + self.stdscr.addstr(self.height - 3, 2, footer[:self.width - 4], curses.color_pair(3)) def draw_tag_notes_list(self): """Draw compact list of notes containing the selected tag""" - self.stdscr.addstr(2, 2, f"Notes tagged with #{self.current_tag} ({len(self.tag_notes)})", curses.A_BOLD) + # Apply filter if active + notes_to_show = self._get_filtered_list(self.tag_notes, "content") if self.filter_query else self.tag_notes + + self.stdscr.addstr(2, 2, f"Notes tagged with #{self.current_tag} ({len(notes_to_show)})", curses.A_BOLD) self.stdscr.addstr(3, 2, "─" * (self.width - 4)) - if not self.tag_notes: - self.stdscr.addstr(5, 4, "No notes found.", curses.color_pair(3)) - self.stdscr.addstr(self.height - 3, 2, "[b] Back", curses.color_pair(3)) + if not notes_to_show: + msg = "No notes match filter." if self.filter_query else "No notes found." + self.stdscr.addstr(5, 4, msg, curses.color_pair(3)) + footer = "[b] Back [/] Filter" + if self.filter_query: + footer += f" Filter: {self.filter_query}" + self.stdscr.addstr(self.height - 3, 2, footer[:self.width - 4], curses.color_pair(3)) return - list_h = self._update_scroll(len(self.tag_notes)) + list_h = self._update_scroll(len(notes_to_show)) for i in range(list_h): idx = self.scroll_offset + i - if idx >= len(self.tag_notes): + if idx >= len(notes_to_show): break - note = self.tag_notes[idx] + note = notes_to_show[idx] y = 5 + i timestamp_str = time.ctime(note.timestamp) @@ -914,7 +940,10 @@ class TUI: else: self.stdscr.addstr(y, 4, display_str) - self.stdscr.addstr(self.height - 3, 2, "[Enter] Expand [b] Back", curses.color_pair(3)) + footer = "[Enter] Expand [d] Delete [b] Back [/] Filter" + if self.filter_query: + footer += f" Filter: {self.filter_query}" + self.stdscr.addstr(self.height - 3, 2, footer[:self.width - 4], curses.color_pair(3)) def draw_ioc_list(self): """Draw the IOC list view showing all IOCs sorted by occurrence count""" @@ -924,19 +953,30 @@ class TUI: self.stdscr.addstr(2, 2, f"IOCs for {context}: {context_name}", curses.A_BOLD) self.stdscr.addstr(3, 2, "─" * (self.width - 4)) - if not self.current_iocs: - self.stdscr.addstr(5, 4, "No IOCs found.", curses.color_pair(3)) - self.stdscr.addstr(self.height - 3, 2, "[b] Back [e] Export", curses.color_pair(3)) + # Apply filter if active (filter by IOC value or type) + iocs_to_show = self.current_iocs + if self.filter_query: + q = self.filter_query.lower() + iocs_to_show = [(ioc, count, ioc_type) for ioc, count, ioc_type in self.current_iocs + if q in ioc.lower() or q in ioc_type.lower()] + + if not iocs_to_show: + msg = "No IOCs match filter." if self.filter_query else "No IOCs found." + self.stdscr.addstr(5, 4, msg, curses.color_pair(3)) + footer = "[b] Back [e] Export [/] Filter" + if self.filter_query: + footer += f" Filter: {self.filter_query}" + self.stdscr.addstr(self.height - 3, 2, footer[:self.width - 4], curses.color_pair(3)) return - list_h = self._update_scroll(len(self.current_iocs)) + list_h = self._update_scroll(len(iocs_to_show)) for i in range(list_h): idx = self.scroll_offset + i - if idx >= len(self.current_iocs): + if idx >= len(iocs_to_show): break - ioc, count, ioc_type = self.current_iocs[idx] + ioc, count, ioc_type = iocs_to_show[idx] y = 5 + i # Show IOC with type indicator and count in red @@ -953,26 +993,36 @@ class TUI: self.stdscr.addstr(y, 4, display_str) self.stdscr.attroff(curses.color_pair(4)) - self.stdscr.addstr(self.height - 3, 2, "[Enter] View Notes [e] Export [b] Back", curses.color_pair(3)) + footer = "[Enter] View Notes [e] Export [b] Back [/] Filter" + if self.filter_query: + footer += f" Filter: {self.filter_query}" + self.stdscr.addstr(self.height - 3, 2, footer[:self.width - 4], curses.color_pair(3)) def draw_ioc_notes_list(self): """Draw compact list of notes containing the selected IOC""" - self.stdscr.addstr(2, 2, f"Notes with IOC: {self.current_ioc} ({len(self.ioc_notes)})", curses.A_BOLD) + # Apply filter if active + notes_to_show = self._get_filtered_list(self.ioc_notes, "content") if self.filter_query else self.ioc_notes + + self.stdscr.addstr(2, 2, f"Notes with IOC: {self.current_ioc} ({len(notes_to_show)})", curses.A_BOLD) self.stdscr.addstr(3, 2, "─" * (self.width - 4)) - if not self.ioc_notes: - self.stdscr.addstr(5, 4, "No notes found.", curses.color_pair(3)) - self.stdscr.addstr(self.height - 3, 2, "[b] Back", curses.color_pair(3)) + if not notes_to_show: + msg = "No notes match filter." if self.filter_query else "No notes found." + self.stdscr.addstr(5, 4, msg, curses.color_pair(3)) + footer = "[b] Back [e] Export [/] Filter" + if self.filter_query: + footer += f" Filter: {self.filter_query}" + self.stdscr.addstr(self.height - 3, 2, footer[:self.width - 4], curses.color_pair(3)) return - list_h = self._update_scroll(len(self.ioc_notes)) + list_h = self._update_scroll(len(notes_to_show)) for i in range(list_h): idx = self.scroll_offset + i - if idx >= len(self.ioc_notes): + if idx >= len(notes_to_show): break - note = self.ioc_notes[idx] + note = notes_to_show[idx] y = 5 + i timestamp_str = time.ctime(note.timestamp) @@ -988,7 +1038,10 @@ class TUI: else: self.stdscr.addstr(y, 4, display_str) - self.stdscr.addstr(self.height - 3, 2, "[Enter] Expand [b] Back", curses.color_pair(3)) + footer = "[Enter] Expand [d] Delete [e] Export [b] Back [/] Filter" + if self.filter_query: + footer += f" Filter: {self.filter_query}" + self.stdscr.addstr(self.height - 3, 2, footer[:self.width - 4], curses.color_pair(3)) def draw_note_detail(self): """Draw expanded view of a single note with all details""" @@ -1050,7 +1103,7 @@ class TUI: self.stdscr.addstr(current_y, 2, "Signature: [GPG signed]", curses.color_pair(2)) current_y += 1 - self.stdscr.addstr(self.height - 3, 2, "[b] Back", curses.color_pair(3)) + self.stdscr.addstr(self.height - 3, 2, "[d] Delete [b] Back", curses.color_pair(3)) def draw_help(self): """Draw the help screen with keyboard shortcuts and features""" @@ -1234,8 +1287,8 @@ class TUI: # Filter toggle if key == ord('/'): - # Filter works on list views: case_list and case_detail (evidence list) - if self.current_view in ["case_list", "case_detail"]: + # Filter works on all list views (context-sensitive) + if self.current_view in ["case_list", "case_detail", "evidence_detail", "tags_list", "tag_notes_list", "ioc_list", "ioc_notes_list"]: self.filter_mode = True return True @@ -1426,10 +1479,14 @@ class TUI: self.scroll_offset = 0 self.filter_query = "" - # Export IOCs + # Export elif key == ord('e'): if self.current_view in ["ioc_list", "ioc_notes_list"]: self.export_iocs() + elif self.current_view == "case_detail": + self.export_case_markdown() + elif self.current_view == "evidence_detail": + self.export_evidence_markdown() # Set Active elif key == ord('a'): @@ -2406,6 +2463,106 @@ class TUI: self.scroll_offset = 0 self.show_message("Note deleted.") + elif self.current_view == "note_detail": + # Delete the currently viewed note + if not self.current_note: + return + + preview = self.current_note.content[:50] + "..." if len(self.current_note.content) > 50 else self.current_note.content + if self.dialog_confirm(f"Delete note: '{preview}'?"): + # Find and delete the note from its parent (case or evidence) + deleted = False + # Check all cases and their evidence for this note + for case in self.cases: + if self.current_note in case.notes: + case.notes.remove(self.current_note) + deleted = True + break + for ev in case.evidence: + if self.current_note in ev.notes: + ev.notes.remove(self.current_note) + deleted = True + break + if deleted: + break + + if deleted: + self.storage.save_data() + self.show_message("Note deleted.") + # Return to previous view + self.current_view = getattr(self, 'previous_view', 'case_detail') + self.current_note = None + self.selected_index = 0 + self.scroll_offset = 0 + else: + self.show_message("Error: Note not found.") + + elif self.current_view == "tag_notes_list": + # Delete selected note from tag notes list + if not self.tag_notes or self.selected_index >= len(self.tag_notes): + return + + note_to_del = self.tag_notes[self.selected_index] + preview = note_to_del.content[:50] + "..." if len(note_to_del.content) > 50 else note_to_del.content + if self.dialog_confirm(f"Delete note: '{preview}'?"): + # Find and delete the note from its parent + deleted = False + for case in self.cases: + if note_to_del in case.notes: + case.notes.remove(note_to_del) + deleted = True + break + for ev in case.evidence: + if note_to_del in ev.notes: + ev.notes.remove(note_to_del) + deleted = True + break + if deleted: + break + + if deleted: + self.storage.save_data() + # Remove from tag_notes list as well + self.tag_notes.remove(note_to_del) + self.selected_index = min(self.selected_index, len(self.tag_notes) - 1) if self.tag_notes else 0 + self.scroll_offset = 0 + self.show_message("Note deleted.") + else: + self.show_message("Error: Note not found.") + + elif self.current_view == "ioc_notes_list": + # Delete selected note from IOC notes list + if not self.ioc_notes or self.selected_index >= len(self.ioc_notes): + return + + note_to_del = self.ioc_notes[self.selected_index] + preview = note_to_del.content[:50] + "..." if len(note_to_del.content) > 50 else note_to_del.content + if self.dialog_confirm(f"Delete note: '{preview}'?"): + # Find and delete the note from its parent + deleted = False + for case in self.cases: + if note_to_del in case.notes: + case.notes.remove(note_to_del) + deleted = True + break + for ev in case.evidence: + if note_to_del in ev.notes: + ev.notes.remove(note_to_del) + deleted = True + break + if deleted: + break + + if deleted: + self.storage.save_data() + # Remove from ioc_notes list as well + self.ioc_notes.remove(note_to_del) + self.selected_index = min(self.selected_index, len(self.ioc_notes) - 1) if self.ioc_notes else 0 + self.scroll_offset = 0 + self.show_message("Note deleted.") + else: + self.show_message("Error: Note not found.") + def view_case_notes(self, highlight_note_index=None): if not self.active_case: return @@ -2709,6 +2866,138 @@ class TUI: except Exception as e: self.show_message(f"Export failed: {str(e)}") + def export_case_markdown(self): + """Export current case (and all its evidence) to markdown""" + if not self.active_case: + self.show_message("No active case to export.") + return + + import os + import datetime + from pathlib import Path + + # Create exports directory if it doesn't exist + export_dir = Path.home() / ".trace" / "exports" + export_dir.mkdir(parents=True, exist_ok=True) + + # Generate filename + case_name = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in self.active_case.case_number) + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"case_{case_name}_{timestamp}.md" + filepath = export_dir / filename + + try: + with open(filepath, 'w', encoding='utf-8') as f: + f.write("# Forensic Notes Export\n\n") + f.write(f"Generated on: {time.ctime()}\n\n") + + # Write case info + f.write(f"## Case: {self.active_case.case_number}\n") + if self.active_case.name: + f.write(f"**Name:** {self.active_case.name}\n") + if self.active_case.investigator: + f.write(f"**Investigator:** {self.active_case.investigator}\n") + f.write(f"**Case ID:** {self.active_case.case_id}\n\n") + + # Case notes + f.write("### Case Notes\n") + if not self.active_case.notes: + f.write("_No notes._\n") + for note in self.active_case.notes: + self._write_note_markdown(f, note) + f.write("\n") + + # Evidence + f.write("### Evidence\n") + if not self.active_case.evidence: + f.write("_No evidence items._\n") + for ev in self.active_case.evidence: + f.write(f"#### {ev.name}\n") + if ev.description: + f.write(f"**Description:** {ev.description}\n") + if ev.metadata.get("source_hash"): + f.write(f"**Source Hash:** `{ev.metadata['source_hash']}`\n") + f.write(f"**Evidence ID:** {ev.evidence_id}\n\n") + + f.write("**Notes:**\n") + if not ev.notes: + f.write("_No notes._\n") + for note in ev.notes: + self._write_note_markdown(f, note) + f.write("\n") + + self.show_message(f"Case exported to: {filepath}") + except Exception as e: + self.show_message(f"Export failed: {str(e)}") + + def export_evidence_markdown(self): + """Export current evidence to markdown""" + if not self.active_evidence: + self.show_message("No active evidence to export.") + return + + import os + import datetime + from pathlib import Path + + # Create exports directory if it doesn't exist + export_dir = Path.home() / ".trace" / "exports" + export_dir.mkdir(parents=True, exist_ok=True) + + # Generate filename + case_name = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in self.active_case.case_number) if self.active_case else "unknown" + ev_name = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in self.active_evidence.name) + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"evidence_{case_name}_{ev_name}_{timestamp}.md" + filepath = export_dir / filename + + try: + with open(filepath, 'w', encoding='utf-8') as f: + f.write("# Forensic Evidence Export\n\n") + f.write(f"Generated on: {time.ctime()}\n\n") + + # Case context + if self.active_case: + f.write(f"**Case:** {self.active_case.case_number}\n") + if self.active_case.name: + f.write(f"**Case Name:** {self.active_case.name}\n") + f.write("\n") + + # Evidence info + f.write(f"## Evidence: {self.active_evidence.name}\n") + if self.active_evidence.description: + f.write(f"**Description:** {self.active_evidence.description}\n") + if self.active_evidence.metadata.get("source_hash"): + f.write(f"**Source Hash:** `{self.active_evidence.metadata['source_hash']}`\n") + f.write(f"**Evidence ID:** {self.active_evidence.evidence_id}\n\n") + + # Notes + f.write("### Notes\n") + if not self.active_evidence.notes: + f.write("_No notes._\n") + for note in self.active_evidence.notes: + self._write_note_markdown(f, note) + + self.show_message(f"Evidence exported to: {filepath}") + except Exception as e: + self.show_message(f"Export failed: {str(e)}") + + def _write_note_markdown(self, f, note): + """Helper to write a note in markdown format""" + f.write(f"- **{time.ctime(note.timestamp)}**\n") + f.write(f" - Content: {note.content}\n") + if note.tags: + tags_str = " ".join([f"#{tag}" for tag in note.tags]) + f.write(f" - Tags: {tags_str}\n") + f.write(f" - Hash: `{note.content_hash}`\n") + if note.signature: + f.write(" - **Signature Verified:**\n") + f.write(" ```\n") + for line in note.signature.splitlines(): + f.write(f" {line}\n") + f.write(" ```\n") + f.write("\n") + def run_tui(open_active=False): """ Run the TUI application.