10 Commits

Author SHA1 Message Date
overcuriousity
dc16a16d49 Merge pull request #1 from overcuriousity/claude/add-install-instructions-015g7n4vPZWeuAUYrHmM74hU
Add binary installation instructions to README
2025-12-12 12:30:51 +01:00
Claude
e4976c81f9 Add optional ultra-fast alias setup for quick logging 2025-12-12 11:28:08 +00:00
Claude
b627f92172 Add installation instructions for latest release binaries 2025-12-12 11:24:52 +00:00
overcuriousity
4c99013426 disclaimer
Added a disclaimer about the coding process and agents used.
2025-12-12 10:21:12 +00:00
overcuriousity
f80a343610 clearer readme
Updated README to reflect new project name and features.
2025-12-12 10:13:41 +00:00
overcuriousity
e1886edee1 bug fixes 2025-12-12 10:26:27 +01:00
overcuriousity
aa0f67f1fc visual improvements, readme file update 2025-12-12 00:00:35 +01:00
overcuriousity
dc8bd777ef bug fixes 2025-12-11 23:40:16 +01:00
overcuriousity
89e7b20694 bug fixes 2025-12-11 23:25:06 +01:00
overcuriousity
ba1fff36f2 rename binaries 2025-12-11 23:09:04 +01:00
6 changed files with 686 additions and 292 deletions

View File

@@ -27,14 +27,14 @@ jobs:
- name: Build Linux binary
run: |
pyinstaller --onefile --name trace-linux main.py
pyinstaller --onefile --name trace main.py
- name: Upload Linux binary to release
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
files: ./dist/trace-linux
files: ./dist/trace
build-windows:
runs-on: windows-latest
@@ -55,11 +55,11 @@ jobs:
- name: Build Windows executable
run: |
pyinstaller --onefile --name trace-windows main.py
pyinstaller --onefile --name trace main.py
- name: Upload Windows executable to release
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
files: ./dist/trace-windows.exe
files: ./dist/trace.exe

242
README.md
View File

@@ -1,152 +1,172 @@
# trace - Forensic Notes
# trace - Digital Evidence Log Utility
`trace` is a minimal, terminal-based forensic note-taking application designed for digital investigators and incident responders. It provides a secure, integrity-focused environment for case management and evidence logging.
`trace` is a bare-bones, terminal-centric note-taking utility for digital forensics and incident response. It is designed for maximum operational efficiency, ensuring that the integrity of your log data is never compromised by the need to slow down.
## Features
This tool mandates minimal system overhead, relying solely on standard libraries where possible.
* **Integrity Focused:**
* **Hashing:** Every note is automatically SHA256 hashed (content + timestamp).
* **Signing:** Optional GPG signing of notes for non-repudiation (requires system `gpg`).
* **Minimal Dependencies:** Written in Python using only the standard library (`curses`, `json`, `sqlite3` avoided, etc.) + `pyinstaller` for packaging.
* **Dual Interface:**
* **TUI (Text User Interface):** Interactive browsing of Cases and Evidence hierarchies with multi-line note editor.
* **CLI Shorthand:** Quickly append notes to the currently active Case/Evidence from your shell (`trace "Found a USB key"`).
* **Multi-Line Notes:** Full-featured text editor supports detailed forensic observations with multiple lines, arrow key navigation, and scrolling.
* **Evidence Source Hashing:** Optionally store source hash values (e.g., SHA256) as metadata when creating evidence items for chain of custody tracking.
* **Tag System:** Organize notes with hashtags (e.g., `#malware #windows #critical`). View tags sorted by usage, filter notes by tag, and navigate tagged notes with full context.
* **IOC Detection:** Automatically extracts Indicators of Compromise (IPs, domains, URLs, hashes, emails) from notes. View, filter, and export IOCs with occurrence counts and context separators.
* **Context Awareness:** Set an "Active" context in the TUI, which persists across sessions for CLI note taking. Recent notes displayed inline for reference.
* **Filtering:** Quickly filter Cases and Evidence lists (press `/`).
* **Export:** Export all data to a formatted Markdown report with verification details, including evidence source hashes.
## ⚡ Key Feature: Hot Logging (CLI Shorthand)
## Installation
The primary operational benefit of `trace` is its ability to accept input directly from the command line, bypassing the full interface. Once your active target context is set, you can drop notes instantly.
### From Source
**Configuration:** Use the TUI to set a Case or Evidence ID as "Active" (`a`). This state persists across sessions.
Requires Python 3.x.
**Syntax for Data Injection:**
```bash
git clone <repository_url>
cd trace
# Run directly
python3 main.py
# Log an immediate status update
trace "IR team gained shell access. Initial persistence checks running."
# Log data and tag it for later triage
trace "Observed outbound connection to 192.168.1.55 on port 80. #suspicious #network"
```
### Building Binary
**System Integrity Chain:** Each command-line note is immediately stamped, concatenated with its content, and hashed using SHA256 before storage. This ensures a non-repudiable log entry.
You can build a single-file executable using PyInstaller.
## Installation & Deployment
#### Linux/macOS
### Quick Install from Latest Release
**Linux / macOS:**
```bash
pip install -r requirements.txt
./build_binary.sh
# Binary will be in dist/trace
./dist/trace
curl -L https://github.com/overcuriousity/trace/releases/latest/download/trace -o trace && sudo mv trace /usr/local/bin/ && sudo chmod +x /usr/local/bin/trace
```
#### Windows
**Windows (PowerShell):**
```powershell
# Install dependencies (includes windows-curses)
pip install -r requirements.txt
# Build the executable
pyinstaller --onefile --name trace --clean --paths . --hidden-import curses main.py
# Binary will be in dist\trace.exe
.\dist\trace.exe
Invoke-WebRequest -Uri "https://github.com/overcuriousity/trace/releases/latest/download/trace.exe" -OutFile "$env:USERPROFILE\bin\trace.exe"; [Environment]::SetEnvironmentVariable("Path", $env:Path + ";$env:USERPROFILE\bin", "User")
```
*Note: Create `$env:USERPROFILE\bin` directory first if it doesn't exist, then restart your shell.*
### Installing to PATH
**Optional: Create Ultra-Fast Alias**
After building the binary, you can install it to your system PATH for easy access:
#### Linux/macOS
For maximum speed when logging, create a single-character alias:
**Linux / macOS (Bash):**
```bash
# Option 1: Copy to /usr/local/bin (requires sudo)
sudo cp dist/trace /usr/local/bin/
# Option 2: Copy to ~/.local/bin (user-only, ensure ~/.local/bin is in PATH)
mkdir -p ~/.local/bin
cp dist/trace ~/.local/bin/
# Add to PATH if not already (add to ~/.bashrc or ~/.zshrc)
export PATH="$HOME/.local/bin:$PATH"
echo 'alias t="trace"' >> ~/.bashrc && source ~/.bashrc
```
#### Windows
**Linux / macOS (Zsh):**
```bash
echo 'alias t="trace"' >> ~/.zshrc && source ~/.zshrc
```
**Windows (PowerShell):**
```powershell
# Option 1: Copy to a directory in PATH (e.g., C:\Windows\System32 - requires admin)
Copy-Item dist\trace.exe C:\Windows\System32\
# Option 2: Create a local bin directory and add to PATH
# 1. Create directory
New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\bin"
Copy-Item dist\trace.exe "$env:USERPROFILE\bin\"
# 2. Add to PATH permanently (run as admin or use GUI: System Properties > Environment Variables)
[Environment]::SetEnvironmentVariable("Path", $env:Path + ";$env:USERPROFILE\bin", "User")
# 3. Restart terminal/PowerShell for changes to take effect
New-Item -ItemType File -Force $PROFILE; Add-Content $PROFILE 'function t { trace $args }'; . $PROFILE
```
## Usage
After this, you can log with just: `t "Your note here"`
### TUI Mode
Run `trace` without arguments to open the interface.
---
**Navigation:**
* `Arrow Keys`: Navigate lists.
* `Enter`: Select Case / View Evidence details.
* `b`: Back.
* `q`: Quit.
### Platform: Linux / UNIX (including macOS)
**Management:**
* `n`: Add a Note to the current context (works in any view).
* **Multi-line support**: Notes can span multiple lines - press `Enter` for new lines.
* **Tagging**: Use hashtags in your notes (e.g., `#malware #critical`) for organization.
* Press `Ctrl+G` to submit the note, or `Esc` to cancel.
* Recent notes are displayed inline for context (non-blocking).
* `N` (Shift+n): New Case (in Case List) or New Evidence (in Case Detail).
* `t`: **Tags View**. Browse all tags in the current context (case or evidence), sorted by usage count.
* Press `Enter` on a tag to see all notes with that tag.
* Press `Enter` on a note to view full details with tag highlighting.
* Navigate back with `b`.
* `i`: **IOCs View**. View all Indicators of Compromise extracted from notes in the current context.
* Shows IOC types (IPv4, domain, URL, hash, email) with occurrence counts.
* Press `Enter` on an IOC to see all notes containing it.
* Press `e` to export IOCs to `~/.trace/exports/` in plain text format.
* IOC counts are displayed in red in case and evidence views.
* `a`: **Set Active**. Sets the currently selected Case or Evidence as the global "Active" context.
* `d`: Delete the selected Case or Evidence (with confirmation).
* `v`: View all notes for the current Case (in Case Detail view).
* `/`: Filter list (type to search, `Esc` or `Enter` to exit filter mode).
* `s`: Settings menu (in Case List view).
* `Esc`: Cancel during input dialogs.
**Prerequisites:** Python 3.x and the binary build utility (PyInstaller).
### CLI Mode
Once a Case or Evidence is set as **Active** in the TUI, you can add notes directly from the command line:
**Deployment:**
```bash
trace "Suspect system is powered on, attempting live memory capture."
```
1. **Build Binary:** Execute the build script in the source directory.
This note is automatically timestamped, hashed, signed, and appended to the active context.
```bash
./build_binary.sh
```
### Exporting
To generate a report:
*The output executable will land in `dist/trace`.*
2. **Path Integration:** For universal access, the binary must reside in a directory referenced by your `$PATH` environment variable (e.g., `/usr/local/bin`).
```bash
# Place executable in system path
sudo mv dist/trace /usr/local/bin/
# Ensure execute bit is set
sudo chmod +x /usr/local/bin/trace
```
You are now cleared to run `trace` from any shell prompt.
### Platform: Windows
**Prerequisites:** Python 3.x, `pyinstaller`, and the `windows-curses` library.
**Deployment:**
1. **Build Binary:** Run the build command in a PowerShell or CMD environment.
```powershell
pyinstaller --onefile --name trace --clean --paths . --hidden-import curses main.py
```
*The executable is located at `dist\trace.exe`.*
2. **Path Integration:** The executable must be accessible via your user or system `%PATH%` variable for the hot-logging feature to function correctly.
*Option A: System Directory (Requires Administrator Privilege)*
```powershell
Copy-Item dist\trace.exe C:\Windows\System32\
```
*Option B: User-Defined Bin Directory (Recommended)*
```powershell
# Create the user bin location
New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\bin"
Copy-Item dist\trace.exe "$env:USERPROFILE\bin\"
# Inject the directory into the User PATH variable
[Environment]::SetEnvironmentVariable("Path", $env:Path + ";$env:USERPROFILE\bin", "User")
```
**ATTENTION:** You must cycle your command shell (exit and reopen) before the `trace` command will resolve correctly.
## Core Feature Breakdown
| Feature | Description | Operational Impact |
| :--- | :--- | :--- |
| **Integrity Hashing** | SHA256 applied to every log entry (content + timestamp). | **Guaranteed log integrity.** No modification possible post-entry. |
| **GPG Signing** | Optional PGP/GPG signature applied to notes. | **Non-repudiation** for formal evidence handling. |
| **IOC Extraction** | Automatic parsing of IPv4, FQDNs, URLs, hashes, and email addresses. | **Immediate intelligence gathering** from raw text. |
| **Tag System** | Supports `#hashtags` for classification and filtering. | **Efficient triage** of large log sets. |
| **Minimal Footprint** | Built solely on Python standard library modules. | **Maximum portability** on restricted forensic environments. |
## TUI Reference (Management Console)
Execute `trace` (no arguments) to enter the Text User Interface. This environment is used for setup, review, and reporting.
| Key | Function | Detail |
| :--- | :--- | :--- |
| `a` | **Set Active** | Designate the current item as the target for CLI injection (hot-logging). |
| `n` | **New Note** | Enter the multi-line log editor. Use $\text{Ctrl+G}$ to save block. |
| `i` | **IOC Index** | View extracted indicators. Option to export IOC list (`e`). |
| `t` | **Tag Index** | View classification tags and filter notes by frequency. |
| `v` | **Full View** | Scrollable screen showing all log entries with automatic IOC/Tag highlighting. |
| `/` | **Filter** | Initiate text-based search/filter on lists. |
| $\text{Enter}$ | **Drill Down** | Access details for Case or Evidence. |
| `q` | **Exit** | Close the application. |
## Report Generation
To generate the Markdown report package, use the `--export` flag.
```bash
trace --export
# Creates trace_export.md
# Creates trace_export.md in the current directory.
```
## Data Storage
Data is stored in JSON format at `~/.trace/data.json`.
Application state (active context) is stored at `~/.trace/state`.
## Data Persistence
## License
MIT
Trace maintains a simple flat-file structure in the user's home directory.
* `~/.trace/data.json`: Case log repository.
* `~/.trace/state`: Active context pointer.
-----
*License: MIT*
**DISCLAIMER**
This program was mostly vibe-coded. This was a deliberate decision as I wanted to focus on producing a usable result with okay user experience rather than implementation details and educating myself by lengthy coding sessions.
I reviewed sections of the code manually and found no issues. The application should be safe to use from a integrity, security and admissability standpoint, while I wont ever make any warranties on this.
The coding agents I mostly used were in this order: Claude Sonnett 45 (CLI), Claude Haiku 4.5 (VSCode Copilot), Google Jules (version unknown).

View File

@@ -42,11 +42,14 @@ def quick_add_note(content: str):
signature = None
if settings.get("pgp_enabled", True):
gpg_key_id = settings.get("gpg_key_id", None)
if gpg_key_id:
signature = Crypto.sign_content(f"Hash: {note.content_hash}\nContent: {note.content}", key_id=gpg_key_id)
if signature:
note.signature = signature
else:
print("Warning: GPG signature failed (GPG not found or no key). Note saved without signature.")
else:
print("Warning: No GPG key ID configured. Note saved without signature.")
# Attach to evidence or case
if target_evidence:

View File

@@ -96,6 +96,112 @@ class Note:
data = f"{self.timestamp}:{self.content}".encode('utf-8')
self.content_hash = hashlib.sha256(data).hexdigest()
@staticmethod
def extract_iocs_from_text(text):
"""Extract IOCs from text and return as list of (ioc, type) tuples"""
iocs = []
seen = set()
# IPv4 addresses
ipv4_pattern = r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b'
for match in re.findall(ipv4_pattern, text):
if match not in seen:
seen.add(match)
iocs.append((match, 'ipv4'))
# IPv6 addresses (simplified)
ipv6_pattern = r'\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b'
for match in re.findall(ipv6_pattern, text):
if match not in seen:
seen.add(match)
iocs.append((match, 'ipv6'))
# URLs (check before domains to avoid double-matching)
url_pattern = r'https?://[^\s]+'
for match in re.findall(url_pattern, text):
if match not in seen:
seen.add(match)
iocs.append((match, 'url'))
# Domain names (basic pattern)
domain_pattern = r'\b(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\b'
for match in re.findall(domain_pattern, text):
# Filter out common false positives and already seen URLs
if match not in seen and not match.startswith('example.'):
seen.add(match)
iocs.append((match, 'domain'))
# SHA256 hashes (64 hex chars) - check before SHA1 and MD5
sha256_pattern = r'\b[a-fA-F0-9]{64}\b'
for match in re.findall(sha256_pattern, text):
if match not in seen:
seen.add(match)
iocs.append((match, 'sha256'))
# SHA1 hashes (40 hex chars) - check before MD5
sha1_pattern = r'\b[a-fA-F0-9]{40}\b'
for match in re.findall(sha1_pattern, text):
if match not in seen:
seen.add(match)
iocs.append((match, 'sha1'))
# MD5 hashes (32 hex chars)
md5_pattern = r'\b[a-fA-F0-9]{32}\b'
for match in re.findall(md5_pattern, text):
if match not in seen:
seen.add(match)
iocs.append((match, 'md5'))
# Email addresses
email_pattern = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b'
for match in re.findall(email_pattern, text):
if match not in seen:
seen.add(match)
iocs.append((match, 'email'))
return iocs
@staticmethod
def extract_iocs_with_positions(text):
"""Extract IOCs with their positions for highlighting. Returns list of (text, start, end, type) tuples"""
import re
highlights = []
# IPv4 addresses
for match in re.finditer(r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b', text):
highlights.append((match.group(), match.start(), match.end(), 'ipv4'))
# IPv6 addresses
for match in re.finditer(r'\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b', text):
highlights.append((match.group(), match.start(), match.end(), 'ipv6'))
# URLs (check before domains)
for match in re.finditer(r'https?://[^\s]+', text):
highlights.append((match.group(), match.start(), match.end(), 'url'))
# Domain names
for match in re.finditer(r'\b(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\b', text):
if not match.group().startswith('example.'):
highlights.append((match.group(), match.start(), match.end(), 'domain'))
# SHA256 hashes
for match in re.finditer(r'\b[a-fA-F0-9]{64}\b', text):
highlights.append((match.group(), match.start(), match.end(), 'sha256'))
# SHA1 hashes
for match in re.finditer(r'\b[a-fA-F0-9]{40}\b', text):
highlights.append((match.group(), match.start(), match.end(), 'sha1'))
# MD5 hashes
for match in re.finditer(r'\b[a-fA-F0-9]{32}\b', text):
highlights.append((match.group(), match.start(), match.end(), 'md5'))
# Email addresses
for match in re.finditer(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b', text):
highlights.append((match.group(), match.start(), match.end(), 'email'))
return highlights
def to_dict(self):
return {
"note_id": self.note_id,

View File

@@ -23,7 +23,6 @@ class Storage:
def _create_demo_case(self):
"""Create a demo case with evidence showcasing all features"""
# Create demo case
demo_case = Case(
case_number="DEMO-2024-001",
name="Sample Investigation",

View File

@@ -57,6 +57,10 @@ class TUI:
curses.init_pair(7, curses.COLOR_BLUE, curses.COLOR_BLACK)
# Tags (magenta)
curses.init_pair(8, curses.COLOR_MAGENTA, curses.COLOR_BLACK)
# IOCs on selected background (red on cyan)
curses.init_pair(9, curses.COLOR_RED, curses.COLOR_CYAN)
# Tags on selected background (yellow on cyan)
curses.init_pair(10, curses.COLOR_YELLOW, curses.COLOR_CYAN)
self.height, self.width = stdscr.getmaxyx()
@@ -257,6 +261,102 @@ class TUI:
return ellipsis[:max_width]
def _display_line_with_highlights(self, y, x_start, line, is_selected=False, win=None):
"""
Display a line with intelligent highlighting.
- IOCs are highlighted with color_pair(4) (red)
- Tags are highlighted with color_pair(3) (yellow)
- Selection background is color_pair(1) (cyan) for non-IOC text
- IOC highlighting takes priority over selection
"""
import re
from .models import Note
# Use provided window or default to main screen
screen = win if win is not None else self.stdscr
# Extract IOCs and tags
highlights = []
# Get IOCs with positions
for text, start, end, ioc_type in Note.extract_iocs_with_positions(line):
highlights.append((text, start, end, 'ioc'))
# Get tags
for match in re.finditer(r'#\w+', line):
highlights.append((match.group(), match.start(), match.end(), 'tag'))
# Sort by position and remove overlaps (IOCs take priority over tags)
highlights.sort(key=lambda x: x[1])
deduplicated = []
last_end = -1
for text, start, end, htype in highlights:
if start >= last_end:
deduplicated.append((text, start, end, htype))
last_end = end
highlights = deduplicated
if not highlights:
# No highlights - use selection color if selected
if is_selected:
screen.attron(curses.color_pair(1))
screen.addstr(y, x_start, line)
screen.attroff(curses.color_pair(1))
else:
screen.addstr(y, x_start, line)
return
# Display with intelligent highlighting
x_pos = x_start
last_pos = 0
for text, start, end, htype in highlights:
# Add text before this highlight
if start > last_pos:
text_before = line[last_pos:start]
if is_selected:
screen.attron(curses.color_pair(1))
screen.addstr(y, x_pos, text_before)
screen.attroff(curses.color_pair(1))
else:
screen.addstr(y, x_pos, text_before)
x_pos += len(text_before)
# Add highlighted text
if htype == 'ioc':
# IOC highlighting: red on cyan if selected, red on black otherwise
if is_selected:
screen.attron(curses.color_pair(9) | curses.A_BOLD)
screen.addstr(y, x_pos, text)
screen.attroff(curses.color_pair(9) | curses.A_BOLD)
else:
screen.attron(curses.color_pair(4) | curses.A_BOLD)
screen.addstr(y, x_pos, text)
screen.attroff(curses.color_pair(4) | curses.A_BOLD)
else: # tag
# Tag highlighting: yellow on cyan if selected, yellow on black otherwise
if is_selected:
screen.attron(curses.color_pair(10))
screen.addstr(y, x_pos, text)
screen.attroff(curses.color_pair(10))
else:
screen.attron(curses.color_pair(3))
screen.addstr(y, x_pos, text)
screen.attroff(curses.color_pair(3))
x_pos += len(text)
last_pos = end
# Add remaining text
if last_pos < len(line):
text_after = line[last_pos:]
if is_selected:
screen.attron(curses.color_pair(1))
screen.addstr(y, x_pos, text_after)
screen.attroff(curses.color_pair(1))
else:
screen.addstr(y, x_pos, text_after)
def draw_header(self):
# Modern header with icon and better styling
title = "◆ trace"
@@ -490,67 +590,21 @@ class TUI:
self.stdscr.attroff(note_color)
y_pos += 1
# Split screen between case notes and evidence
# Allocate space: half for case notes, half for evidence (if both exist)
# Split screen between evidence and case notes
# Allocate space: half for evidence, half for case notes (if both exist)
available_space = self.content_h - 5
case_notes = self.active_case.notes
evidence_list = self._get_filtered_list(self.active_case.evidence, "name", "description")
# Determine context: are we selecting notes or evidence?
# If there are case notes, treat indices 0 to len(notes)-1 as notes
# If there is evidence, treat indices len(notes) to len(notes)+len(evidence)-1 as evidence
total_items = len(case_notes) + len(evidence_list)
# Determine context: are we selecting evidence or notes?
# Evidence items are indices 0 to len(evidence)-1
# Case notes are indices len(evidence) to len(evidence)+len(notes)-1
total_items = len(evidence_list) + len(case_notes)
# Determine what's selected
selecting_note = self.selected_index < len(case_notes)
# Case Notes section
if case_notes:
if y_pos < self.height - 3:
self.stdscr.attron(curses.color_pair(5) | curses.A_BOLD)
self.stdscr.addstr(y_pos, 2, "▪ Case Notes")
self.stdscr.attroff(curses.color_pair(5) | curses.A_BOLD)
self.stdscr.attron(curses.color_pair(6) | curses.A_DIM)
self.stdscr.addstr(y_pos, 16, f"({len(case_notes)} notes)")
self.stdscr.attroff(curses.color_pair(6) | curses.A_DIM)
y_pos += 1
# Calculate space for case notes
notes_space = min(len(case_notes), available_space // 2) if evidence_list else available_space
# Update scroll position if needed
self._update_scroll(total_items)
# Display notes
for i in range(notes_space):
note_idx = self.scroll_offset + i
if note_idx >= len(case_notes):
break
note = case_notes[note_idx]
y = y_pos + 1 + i
# Check if we're out of bounds
if y >= self.height - 3:
break
# Format note content
note_content = note.content.replace('\n', ' ').replace('\r', ' ')
display_str = f"- {note_content}"
display_str = self._safe_truncate(display_str, self.width - 6)
# Highlight if selected
if note_idx == self.selected_index:
self.stdscr.attron(curses.color_pair(1))
self.stdscr.addstr(y, 4, display_str)
self.stdscr.attroff(curses.color_pair(1))
else:
self.stdscr.addstr(y, 4, display_str)
y_pos += notes_space + 1
selecting_evidence = self.selected_index < len(evidence_list)
# Evidence section header
y_pos += 1
if y_pos < self.height - 3:
self.stdscr.attron(curses.color_pair(5) | curses.A_BOLD)
self.stdscr.addstr(y_pos, 2, "▪ Evidence")
@@ -578,14 +632,33 @@ class TUI:
self._update_scroll(total_items)
for i in range(list_h):
# Evidence indices start after case notes
evidence_idx = self.scroll_offset + i - len(case_notes)
# Calculate space for evidence
evidence_space = min(len(evidence_list), available_space // 2) if case_notes else available_space
self._update_scroll(total_items)
# Calculate which evidence items to display
# If selecting evidence, scroll just enough to keep it visible
# If selecting a case note, show evidence from the beginning
if selecting_evidence:
# Keep selection visible: scroll up if needed, scroll down if needed
if self.selected_index < 0:
evidence_scroll_offset = 0
elif self.selected_index >= evidence_space:
# Scroll down only as much as needed to show the selected item at the bottom
evidence_scroll_offset = self.selected_index - evidence_space + 1
else:
evidence_scroll_offset = 0
else:
evidence_scroll_offset = 0
for i in range(evidence_space):
evidence_idx = evidence_scroll_offset + i
if evidence_idx < 0 or evidence_idx >= len(evidence_list):
continue
ev = evidence_list[evidence_idx]
y = y_pos + 2 + i
y = y_pos + i
if y >= self.height - 3: # Don't overflow into status bar
break
@@ -628,8 +701,7 @@ class TUI:
base_display = self._safe_truncate(display_str, self.width - 6)
# Check if this evidence item is selected
item_idx = len(case_notes) + evidence_idx
if item_idx == self.selected_index:
if evidence_idx == self.selected_index:
# Highlighted selection
self.stdscr.attron(curses.color_pair(1))
self.stdscr.addstr(y, 4, base_display)
@@ -662,6 +734,52 @@ class TUI:
else:
self.stdscr.addstr(y, 4, base_display)
y_pos += evidence_space
# Case Notes section
if case_notes:
y_pos += 2
if y_pos < self.height - 3:
self.stdscr.attron(curses.color_pair(5) | curses.A_BOLD)
self.stdscr.addstr(y_pos, 2, "▪ Case Notes")
self.stdscr.attroff(curses.color_pair(5) | curses.A_BOLD)
self.stdscr.attron(curses.color_pair(6) | curses.A_DIM)
self.stdscr.addstr(y_pos, 16, f"({len(case_notes)} notes)")
self.stdscr.attroff(curses.color_pair(6) | curses.A_DIM)
y_pos += 1
# Calculate remaining space for case notes
remaining_space = self.content_h - (y_pos - 2)
notes_space = max(1, remaining_space)
# Calculate which notes to display
if selecting_evidence:
notes_scroll_offset = 0
else:
notes_scroll_offset = max(0, (self.selected_index - len(evidence_list)) - notes_space // 2)
for i in range(notes_space):
note_idx = notes_scroll_offset + i
if note_idx >= len(case_notes):
break
note = case_notes[note_idx]
y = y_pos + i
# Check if we're out of bounds
if y >= self.height - 3:
break
# Format note content
note_content = note.content.replace('\n', ' ').replace('\r', ' ')
display_str = f"- {note_content}"
display_str = self._safe_truncate(display_str, self.width - 6)
# Display with smart highlighting (IOCs take priority over selection)
item_idx = len(evidence_list) + note_idx
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))
def draw_evidence_detail(self):
@@ -718,13 +836,9 @@ class TUI:
# Truncate safely for Unicode
display_str = self._safe_truncate(display_str, self.width - 6)
# Highlight selected note
if idx == self.selected_index:
self.stdscr.attron(curses.color_pair(1))
self.stdscr.addstr(start_y + i, 4, display_str)
self.stdscr.attroff(curses.color_pair(1))
else:
self.stdscr.addstr(start_y + i, 4, display_str)
# Display with smart highlighting (IOCs take priority over selection)
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))
@@ -899,11 +1013,11 @@ class TUI:
current_y += 1
# Content with tag highlighting
# Content with tag and IOC highlighting
self.stdscr.addstr(current_y, 2, "Content:", curses.A_BOLD)
current_y += 1
# Display content with highlighted tags
# Display content with highlighted tags and IOCs
content_lines = self.current_note.content.split('\n')
max_content_lines = self.content_h - (current_y - 2) - 6 # Reserve space for hash/sig
@@ -911,27 +1025,14 @@ class TUI:
if current_y >= self.height - 6:
break
# Highlight tags in the content
# Highlight both tags and IOCs in the content
display_line = self._safe_truncate(line, self.width - 6)
x_pos = 4
# Simple tag highlighting - split by words and color tags
import re
parts = re.split(r'(#\w+)', display_line)
for part in parts:
if part.startswith('#'):
# Display with highlighting (no selection in detail view)
try:
self.stdscr.addstr(current_y, x_pos, part, curses.color_pair(3))
self._display_line_with_highlights(current_y, 4, display_line, is_selected=False)
except curses.error:
pass
x_pos += len(part)
else:
if x_pos < self.width - 2:
try:
self.stdscr.addstr(current_y, x_pos, part[:self.width - x_pos - 2])
except curses.error:
pass
x_pos += len(part)
current_y += 1
@@ -984,11 +1085,11 @@ class TUI:
help_lines.append((" n Add note to case", curses.A_NORMAL))
help_lines.append((" t View tags across case and all evidence", curses.A_NORMAL))
help_lines.append((" i View IOCs across case and all evidence", curses.A_NORMAL))
help_lines.append((" v View all case notes", curses.A_NORMAL))
help_lines.append((" v View all case notes with IOC highlighting", curses.A_NORMAL))
help_lines.append((" a Set case (or selected evidence) as active", curses.A_NORMAL))
help_lines.append((" d Delete selected evidence item", curses.A_NORMAL))
help_lines.append((" d Delete selected evidence item or note", curses.A_NORMAL))
help_lines.append((" / Filter evidence by name or description", curses.A_NORMAL))
help_lines.append((" Enter Open evidence details", curses.A_NORMAL))
help_lines.append((" Enter Open evidence details or jump to note", curses.A_NORMAL))
help_lines.append(("", curses.A_NORMAL))
# Evidence Detail View
@@ -996,9 +1097,10 @@ class TUI:
help_lines.append((" n Add note to evidence", curses.A_NORMAL))
help_lines.append((" t View tags for this evidence", curses.A_NORMAL))
help_lines.append((" i View IOCs for this evidence", curses.A_NORMAL))
help_lines.append((" v View all evidence notes", curses.A_NORMAL))
help_lines.append((" v View all evidence notes with IOC highlighting", curses.A_NORMAL))
help_lines.append((" a Set evidence as active context", curses.A_NORMAL))
help_lines.append((" d Delete selected note", curses.A_NORMAL))
help_lines.append((" Enter Jump to selected note in full view", curses.A_NORMAL))
help_lines.append(("", curses.A_NORMAL))
# Tags View
@@ -1028,7 +1130,11 @@ class TUI:
help_lines.append((" Active Context Set with 'a' key - enables CLI quick notes", curses.A_NORMAL))
help_lines.append((" Run: trace \"your note text\"", curses.A_DIM))
help_lines.append((" Tags Use #hashtag in notes for auto-tagging", curses.A_NORMAL))
help_lines.append((" Highlighted in cyan throughout the interface", curses.A_DIM))
help_lines.append((" IOCs Auto-extracts IPs, domains, URLs, hashes, emails", curses.A_NORMAL))
help_lines.append((" Highlighted in red in full note views", curses.A_DIM))
help_lines.append((" Note Navigation Press Enter on any note to view with highlighting", curses.A_NORMAL))
help_lines.append((" Selected note auto-centered and highlighted", curses.A_DIM))
help_lines.append((" Integrity All notes SHA256 hashed + optional GPG signing", curses.A_NORMAL))
help_lines.append((" GPG Settings Press 's' to toggle signing & select GPG key", curses.A_NORMAL))
help_lines.append((" Source Hash Store evidence file hashes for chain of custody", curses.A_NORMAL))
@@ -1148,10 +1254,10 @@ class TUI:
filtered = self._get_filtered_list(self.cases, "case_number", "name")
max_idx = len(filtered) - 1
elif self.current_view == "case_detail" and self.active_case:
# Total items = case notes + evidence
# Total items = evidence + case notes
case_notes = self.active_case.notes
filtered = self._get_filtered_list(self.active_case.evidence, "name", "description")
max_idx = len(case_notes) + len(filtered) - 1
max_idx = len(filtered) + len(case_notes) - 1
elif self.current_view == "evidence_detail" and self.active_evidence:
# Navigate through notes in evidence detail view
max_idx = len(self.active_evidence.notes) - 1
@@ -1185,26 +1291,42 @@ class TUI:
self.current_view = "case_detail"
self.selected_index = 0
self.scroll_offset = 0
self.filter_query = "" # Reset filter on view change
self.filter_query = ""
elif self.current_view == "evidence_detail" and self.active_evidence:
# Check if a note is selected
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
elif self.current_view == "case_detail":
if self.active_case:
case_notes = self.active_case.notes
filtered = self._get_filtered_list(self.active_case.evidence, "name", "description")
# Check if selecting a note or evidence
if self.selected_index < len(case_notes):
# Selected a note - show note detail view
self.current_note = case_notes[self.selected_index]
self.previous_view = "case_detail"
self.current_view = "note_detail"
self.filter_query = ""
elif filtered and self.selected_index - len(case_notes) < len(filtered):
# Check if selecting evidence or note
# Evidence items come first (indices 0 to len(filtered)-1)
# Case notes come second (indices len(filtered) to len(filtered)+len(case_notes)-1)
if self.selected_index < len(filtered):
# Selected evidence - navigate to evidence detail
evidence_idx = self.selected_index - len(case_notes)
self.active_evidence = filtered[evidence_idx]
self.active_evidence = filtered[self.selected_index]
self.current_view = "evidence_detail"
self.selected_index = 0
self.filter_query = ""
elif case_notes and self.selected_index - len(filtered) < len(case_notes):
# Selected a note - show note detail view
note_idx = self.selected_index - len(filtered)
self.current_note = case_notes[note_idx]
self.previous_view = "case_detail"
self.current_view = "note_detail"
self.filter_query = ""
elif self.current_view == "tags_list":
# Enter tag -> show notes with that tag
if self.current_tags and self.selected_index < len(self.current_tags):
@@ -1376,21 +1498,21 @@ class TUI:
case_notes = self.active_case.notes
filtered = self._get_filtered_list(self.active_case.evidence, "name", "description")
# Only allow setting active for evidence, not notes
if self.selected_index < len(case_notes):
# Evidence is displayed first (indices 0 to len(evidence)-1)
# Case notes are displayed second (indices len(evidence) to len(evidence)+len(notes)-1)
if self.selected_index < len(filtered):
# Selected evidence - set it as active
ev = filtered[self.selected_index]
self.state_manager.set_active(case_id=self.active_case.case_id, evidence_id=ev.evidence_id)
self.global_active_case_id = self.active_case.case_id
self.global_active_evidence_id = ev.evidence_id
self.show_message(f"Active: {ev.name}")
elif case_notes and self.selected_index - len(filtered) < len(case_notes):
# Selected a note - set case as active (not evidence)
self.state_manager.set_active(case_id=self.active_case.case_id, evidence_id=None)
self.global_active_case_id = self.active_case.case_id
self.global_active_evidence_id = None
self.show_message(f"Active: Case {self.active_case.case_number}")
elif filtered and self.selected_index - len(case_notes) < len(filtered):
# Selected evidence - set it as active
evidence_idx = self.selected_index - len(case_notes)
ev = filtered[evidence_idx]
self.state_manager.set_active(case_id=self.active_case.case_id, evidence_id=ev.evidence_id)
self.global_active_case_id = self.active_case.case_id
self.global_active_evidence_id = ev.evidence_id
self.show_message(f"Active: {ev.name}")
else:
# Nothing selected - set case as active
self.state_manager.set_active(case_id=self.active_case.case_id, evidence_id=None)
@@ -1653,7 +1775,7 @@ class TUI:
win.addstr(y, 2, " " * input_width)
if line_idx < len(lines):
# Show line content
# Show line content (truncated if too long)
display_text = lines[line_idx][:input_width]
win.addstr(y, 2, display_text)
@@ -1776,6 +1898,18 @@ class TUI:
lines[cursor_line] = line[:cursor_col] + chr(ch) + line[cursor_col:]
cursor_col += 1
# Auto-wrap to next line if cursor exceeds visible width
if cursor_col >= input_width:
# Always ensure there's a next line to move to
if cursor_line >= len(lines) - 1:
# We're on the last line, add a new line
lines.append("")
cursor_line += 1
cursor_col = 0
# Adjust scroll if needed
if cursor_line >= scroll_offset + input_height:
scroll_offset = cursor_line - input_height + 1
def dialog_confirm(self, message):
curses.curs_set(0)
h = 5
@@ -1818,7 +1952,7 @@ class TUI:
x = (self.width - w) // 2
win = curses.newwin(h, w, y, x)
win.keypad(1) # Enable keypad mode for arrow keys
win.keypad(True) # Enable keypad mode for arrow keys
while True:
win.clear()
@@ -1915,7 +2049,7 @@ class TUI:
x = (self.width - w) // 2
win = curses.newwin(h, w, y, x)
win.keypad(1) # Enable keypad mode for arrow keys
win.keypad(True) # Enable keypad mode for arrow keys
scroll_offset = 0
while True:
@@ -2009,14 +2143,10 @@ class TUI:
return
name = self._input_dialog("New Case - Step 2/3", "Enter descriptive name (optional):")
if name is None:
self.show_message("Case creation cancelled.")
return
# For optional fields, treat None as empty string (user pressed Enter on empty field)
investigator = self._input_dialog("New Case - Step 3/3", "Enter investigator name (optional):")
if investigator is None:
self.show_message("Case creation cancelled.")
return
# For optional fields, treat None as empty string (user pressed Enter on empty field)
case = Case(case_number=case_num, name=name or "", investigator=investigator or "")
self.storage.add_case(case)
@@ -2036,14 +2166,10 @@ class TUI:
return
desc = self._input_dialog("New Evidence - Step 2/3", "Enter description (optional):")
if desc is None:
self.show_message("Evidence creation cancelled.")
return
# For optional fields, treat None as empty string (user pressed Enter on empty field)
source_hash = self._input_dialog("New Evidence - Step 3/3", "Enter source hash (optional, e.g. SHA256):")
if source_hash is None:
self.show_message("Evidence creation cancelled.")
return
# For optional fields, treat None as empty string (user pressed Enter on empty field)
ev = Evidence(name=name, description=desc or "")
if source_hash:
@@ -2062,12 +2188,12 @@ class TUI:
if self.current_view == "evidence_detail" and self.active_evidence:
context_title = f"Add Note → Evidence: {self.active_evidence.name}"
context_prompt = f"Case: {self.active_case.case_number if self.active_case else '?'}\nEvidence: {self.active_evidence.name}\n\nNote will be added to this evidence."
context_prompt = f"Case: {self.active_case.case_number if self.active_case else '?'}\nEvidence: {self.active_evidence.name}\n"
recent_notes = self.active_evidence.notes[-5:] if len(self.active_evidence.notes) > 0 else []
target_evidence = self.active_evidence
elif self.current_view == "case_detail" and self.active_case:
context_title = f"Add Note → Case: {self.active_case.case_number}"
context_prompt = f"Case: {self.active_case.case_number}\n{self.active_case.name if self.active_case.name else ''}\n\nNote will be added to case notes."
context_prompt = f"Case: {self.active_case.case_number}\n{self.active_case.name if self.active_case.name else ''}\nNote will be added to case notes."
recent_notes = self.active_case.notes[-5:] if len(self.active_case.notes) > 0 else []
target_case = self.active_case
elif self.current_view == "case_list":
@@ -2080,14 +2206,14 @@ class TUI:
for ev in active_case.evidence:
if ev.evidence_id == self.global_active_evidence_id:
context_title = f"Add Note → Evidence: {ev.name}"
context_prompt = f"Case: {active_case.case_number}\nEvidence: {ev.name}\n\nNote will be added to this evidence."
context_prompt = f"Case: {active_case.case_number}\nEvidence: {ev.name}\n"
recent_notes = ev.notes[-5:] if len(ev.notes) > 0 else []
target_case = active_case
target_evidence = ev
break
else:
context_title = f"Add Note → Case: {active_case.case_number}"
context_prompt = f"Case: {active_case.case_number}\n\nNote will be added to case notes."
context_prompt = f"Case: {active_case.case_number}\nNote will be added to case notes."
recent_notes = active_case.notes[-5:] if len(active_case.notes) > 0 else []
target_case = active_case
@@ -2120,7 +2246,7 @@ class TUI:
signed = False
if pgp_enabled:
sig = Crypto.sign_content(f"Hash: {note.content_hash}\nContent: {note.content}", key_id=gpg_key_id)
sig = Crypto.sign_content(f"Hash: {note.content_hash}\nContent: {note.content}", key_id=gpg_key_id or "")
if sig:
note.signature = sig
signed = True
@@ -2219,7 +2345,7 @@ class TUI:
self.scroll_offset = 0
self.show_message("Note deleted.")
def view_case_notes(self):
def view_case_notes(self, highlight_note_index=None):
if not self.active_case: return
h = int(self.height * 0.8)
@@ -2227,45 +2353,115 @@ class TUI:
y = int(self.height * 0.1)
x = int(self.width * 0.1)
scroll_offset = 0
highlight_idx = highlight_note_index # Store for persistent highlighting
while True:
win = curses.newwin(h, w, y, x)
win.keypad(True)
win.timeout(25) # 25ms timeout makes ESC responsive
win.box()
win.addstr(1, 2, f"Notes: {self.active_case.case_number}", curses.A_BOLD)
win.addstr(1, 2, f"Notes: {self.active_case.case_number} ({len(self.active_case.notes)} total)", curses.A_BOLD)
notes = self.active_case.notes
max_lines = h - 4
content_lines = []
note_line_ranges = [] # Track which lines belong to which note
# Scroll last notes
display_notes = notes[-max_lines:] if len(notes) > max_lines else notes
# Build all content lines with separators between notes
for note_idx, note in enumerate(notes):
start_line = len(content_lines)
timestamp_str = time.ctime(note.timestamp)
content_lines.append(f"[{timestamp_str}]")
# Split multi-line notes and wrap long lines
for line in note.content.split('\n'):
# Wrap long lines
while len(line) > w - 6:
content_lines.append(" " + line[:w-6])
line = line[w-6:]
content_lines.append(" " + line)
content_lines.append("") # Blank line between notes
end_line = len(content_lines) - 1
note_line_ranges.append((start_line, end_line, note_idx))
for i, note in enumerate(display_notes):
# Replace newlines with spaces for single-line display
note_content = note.content.replace('\n', ' ').replace('\r', ' ')
display_str = f"- [{time.ctime(note.timestamp)}] {note_content}"
# Truncate safely for Unicode
display_str = self._safe_truncate(display_str, w - 4)
win.addstr(3 + i, 2, display_str)
max_display_lines = h - 5
total_lines = len(content_lines)
win.addstr(h-2, 2, "[n] Add Note [b/q/Esc] Close", curses.color_pair(3))
# Jump to highlighted note on first render
if highlight_note_index is not None and note_line_ranges:
for start, end, idx in note_line_ranges:
if idx == highlight_note_index:
# Center the note in the view
note_middle = (start + end) // 2
scroll_offset = max(0, note_middle - max_display_lines // 2)
highlight_note_index = None # Only jump once
break
# Adjust scroll bounds
max_scroll = max(0, total_lines - max_display_lines)
scroll_offset = max(0, min(scroll_offset, max_scroll))
# Display lines with highlighting
for i in range(max_display_lines):
line_idx = scroll_offset + i
if line_idx >= total_lines:
break
display_line = self._safe_truncate(content_lines[line_idx], w - 4)
# Check if this line belongs to the highlighted note
is_highlighted = False
if highlight_idx is not None:
for start, end, idx in note_line_ranges:
if start <= line_idx <= end and idx == highlight_idx:
is_highlighted = True
break
try:
y_pos = 3 + i
# Use unified highlighting function
self._display_line_with_highlights(y_pos, 2, display_line, is_highlighted, win)
except curses.error:
pass
# Show scroll indicator
if total_lines > max_display_lines:
scroll_info = f"[{scroll_offset + 1}-{min(scroll_offset + max_display_lines, total_lines)}/{total_lines}]"
try:
win.addstr(2, w - len(scroll_info) - 3, scroll_info, curses.A_DIM)
except curses.error:
pass
win.addstr(h-2, 2, "[↑↓] Scroll [n] Add Note [b/q/Esc] Close", curses.color_pair(3))
win.refresh()
key = win.getch()
if key == -1: # timeout, redraw
del win
continue
del win
# Handle key presses
if key == ord('n') or key == ord('N'):
if key == curses.KEY_UP:
scroll_offset = max(0, scroll_offset - 1)
elif key == curses.KEY_DOWN:
scroll_offset = min(max_scroll, scroll_offset + 1)
elif key == curses.KEY_PPAGE: # Page Up
scroll_offset = max(0, scroll_offset - max_display_lines)
elif key == curses.KEY_NPAGE: # Page Down
scroll_offset = min(max_scroll, scroll_offset + max_display_lines)
elif key == curses.KEY_HOME:
scroll_offset = 0
elif key == curses.KEY_END:
scroll_offset = max_scroll
elif key == ord('n') or key == ord('N'):
# Save current view and switch to case_detail temporarily for context
saved_view = self.current_view
self.current_view = "case_detail"
self.dialog_add_note()
self.current_view = saved_view
# Continue loop to refresh with new note
scroll_offset = max_scroll # Jump to bottom to show new note
elif key == ord('b') or key == ord('B') or key == ord('q') or key == ord('Q') or key == 27: # 27 is Esc
break
else:
# Any other key also closes (backwards compatibility)
break
def view_evidence_notes(self):
def view_evidence_notes(self, highlight_note_index=None):
if not self.active_evidence: return
h = int(self.height * 0.8)
@@ -2273,43 +2469,113 @@ class TUI:
y = int(self.height * 0.1)
x = int(self.width * 0.1)
scroll_offset = 0
highlight_idx = highlight_note_index # Store for persistent highlighting
while True:
win = curses.newwin(h, w, y, x)
win.keypad(True)
win.timeout(25) # 25ms timeout makes ESC responsive
win.box()
win.addstr(1, 2, f"Notes: {self.active_evidence.name}", curses.A_BOLD)
win.addstr(1, 2, f"Notes: {self.active_evidence.name} ({len(self.active_evidence.notes)} total)", curses.A_BOLD)
notes = self.active_evidence.notes
max_lines = h - 4
content_lines = []
note_line_ranges = [] # Track which lines belong to which note
# Scroll last notes
display_notes = notes[-max_lines:] if len(notes) > max_lines else notes
# Build all content lines with separators between notes
for note_idx, note in enumerate(notes):
start_line = len(content_lines)
timestamp_str = time.ctime(note.timestamp)
content_lines.append(f"[{timestamp_str}]")
# Split multi-line notes and wrap long lines
for line in note.content.split('\n'):
# Wrap long lines
while len(line) > w - 6:
content_lines.append(" " + line[:w-6])
line = line[w-6:]
content_lines.append(" " + line)
content_lines.append("") # Blank line between notes
end_line = len(content_lines) - 1
note_line_ranges.append((start_line, end_line, note_idx))
for i, note in enumerate(display_notes):
# Replace newlines with spaces for single-line display
note_content = note.content.replace('\n', ' ').replace('\r', ' ')
display_str = f"- [{time.ctime(note.timestamp)}] {note_content}"
# Truncate safely for Unicode
display_str = self._safe_truncate(display_str, w - 4)
win.addstr(3 + i, 2, display_str)
max_display_lines = h - 5
total_lines = len(content_lines)
win.addstr(h-2, 2, "[n] Add Note [b/q/Esc] Close", curses.color_pair(3))
# Jump to highlighted note on first render
if highlight_note_index is not None and note_line_ranges:
for start, end, idx in note_line_ranges:
if idx == highlight_note_index:
# Center the note in the view
note_middle = (start + end) // 2
scroll_offset = max(0, note_middle - max_display_lines // 2)
highlight_note_index = None # Only jump once
break
# Adjust scroll bounds
max_scroll = max(0, total_lines - max_display_lines)
scroll_offset = max(0, min(scroll_offset, max_scroll))
# Display lines with highlighting
for i in range(max_display_lines):
line_idx = scroll_offset + i
if line_idx >= total_lines:
break
display_line = self._safe_truncate(content_lines[line_idx], w - 4)
# Check if this line belongs to the highlighted note
is_highlighted = False
if highlight_idx is not None:
for start, end, idx in note_line_ranges:
if start <= line_idx <= end and idx == highlight_idx:
is_highlighted = True
break
try:
y_pos = 3 + i
# Use unified highlighting function
self._display_line_with_highlights(y_pos, 2, display_line, is_highlighted, win)
except curses.error:
pass
# Show scroll indicator
if total_lines > max_display_lines:
scroll_info = f"[{scroll_offset + 1}-{min(scroll_offset + max_display_lines, total_lines)}/{total_lines}]"
try:
win.addstr(2, w - len(scroll_info) - 3, scroll_info, curses.A_DIM)
except curses.error:
pass
win.addstr(h-2, 2, "[↑↓] Scroll [n] Add Note [b/q/Esc] Close", curses.color_pair(3))
win.refresh()
key = win.getch()
if key == -1: # timeout, redraw
del win
continue
del win
# Handle key presses
if key == ord('n') or key == ord('N'):
if key == curses.KEY_UP:
scroll_offset = max(0, scroll_offset - 1)
elif key == curses.KEY_DOWN:
scroll_offset = min(max_scroll, scroll_offset + 1)
elif key == curses.KEY_PPAGE: # Page Up
scroll_offset = max(0, scroll_offset - max_display_lines)
elif key == curses.KEY_NPAGE: # Page Down
scroll_offset = min(max_scroll, scroll_offset + max_display_lines)
elif key == curses.KEY_HOME:
scroll_offset = 0
elif key == curses.KEY_END:
scroll_offset = max_scroll
elif key == ord('n') or key == ord('N'):
# Save current view and switch to evidence_detail temporarily for context
saved_view = self.current_view
self.current_view = "evidence_detail"
self.dialog_add_note()
self.current_view = saved_view
# Continue loop to refresh with new note
scroll_offset = max_scroll # Jump to bottom to show new note
elif key == ord('b') or key == ord('B') or key == ord('q') or key == ord('Q') or key == 27: # 27 is Esc
break
else:
# Any other key also closes (backwards compatibility)
break
def export_iocs(self):
"""Export IOCs from current context to a text file"""