diff --git a/README.md b/README.md
index b59c001..efa4146 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,7 @@
Install |
Features |
+ Context Tracking |
Configuration |
Docker |
Troubleshooting
@@ -33,7 +34,7 @@
## Install
```bash
-curl -fsSL https://quickcall.dev/supertrace/install.sh | sh
+curl -fsSL https://quickcall.dev/supertrace/install.sh | bash
```
Then run:
@@ -55,6 +56,46 @@ Open http://localhost:7845 in your browser.
- **Full-text search** - Find anything across all sessions
- **Export** - Download sessions as JSON or Markdown
- **WebSocket updates** - Live updates without page refresh
+- **Context window tracking** - Real-time context usage with color-coded progress bar
+
+## Context Window Tracking
+
+Real-time context window tracking is **automatically enabled** when you run SuperTrace.
+
+### How It Works
+
+1. When `quickcall-supertrace` starts, it automatically configures Claude Code hooks
+2. After each Claude response, the hook captures token usage
+3. Context data is sent to the SuperTrace server
+4. The UI displays a real-time progress bar:
+ - **Green** - Under 50% usage
+ - **Yellow** - 50-75% usage
+ - **Red** - Over 75% usage
+
+### Setup
+
+Just run SuperTrace - hooks are configured automatically:
+
+```bash
+quickcall-supertrace
+```
+
+Then **restart Claude Code** to load the hooks.
+
+### Disable Auto-Registration
+
+If you don't want automatic hook registration:
+
+```bash
+QUICKCALL_SUPERTRACE_AUTO_HOOKS=false quickcall-supertrace
+```
+
+### Environment Variables
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `QUICKCALL_SUPERTRACE_AUTO_HOOKS` | true | Auto-register Claude Code hooks |
+| `QUICKCALL_SUPERTRACE_DEBUG` | false | Enable debug logging for hooks |
## Dashboard Metrics
diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md
index 3cfde6a..3634589 100644
--- a/docs/concepts/architecture.md
+++ b/docs/concepts/architecture.md
@@ -6,6 +6,11 @@ How QuickCall SuperTrace captures and displays Claude Code sessions.
```mermaid
flowchart TB
+ subgraph "Real-time (Hooks)"
+ H1[Claude Code] -->|Stop event| H2[Hook CLI]
+ H2 -->|POST /context| API
+ end
+
subgraph Ingestion
A[~/.claude/projects/] --> B[Scanner]
B --> C[Parser]
@@ -14,6 +19,7 @@ flowchart TB
subgraph Storage
D --> E[(SQLite WAL)]
+ API --> E
end
subgraph API
@@ -41,7 +47,51 @@ Each line is a JSON object representing a message:
{"type":"assistant","message":{"content":[...],"usage":{...}},"timestamp":"2026-01-14T10:00:10Z"}
```
-### 2. Ingestion Pipeline
+### 2. Hooks System (Real-time Context Tracking)
+
+Claude Code supports hooks that fire on specific events. SuperTrace uses the **Stop hook** to track context window usage in real-time.
+
+**How it works:**
+1. On server startup, SuperTrace registers hooks in `~/.claude/settings.json`
+2. When Claude finishes responding, the Stop hook fires
+3. Hook CLI reads the transcript and extracts token usage
+4. Context data is POSTed to SuperTrace server
+5. Server broadcasts via WebSocket to update UI instantly
+
+**Components:**
+
+| File | Purpose |
+|------|---------|
+| `hooks/setup.py` | Auto-registers hooks on server startup |
+| `hooks/cli.py` | CLI entry point (`quickcall-supertrace-hook`) |
+| `hooks/handlers.py` | Processes hook events, extracts context data |
+| `hooks/models.py` | Pydantic models for hook input/output |
+
+**Context Calculation:**
+```
+Total Context = input_tokens + cache_read_tokens + cache_create_tokens
+Context % = (Total Context / Model Context Window) × 100
+```
+
+Note: Output tokens are NOT included in context window calculation.
+
+**Hook Registration** (in `~/.claude/settings.json`):
+```json
+{
+ "hooks": {
+ "Stop": [{
+ "matcher": "*",
+ "hooks": [{
+ "type": "command",
+ "command": "/path/to/quickcall-supertrace-hook stop",
+ "timeout": 5
+ }]
+ }]
+ }
+}
+```
+
+### 3. Ingestion Pipeline (Batch Import)
**Scanner** (`packages/server/quickcall_supertrace/ingest/scanner.py`)
- Finds all JSONL files in `~/.claude/projects/`
@@ -64,7 +114,7 @@ Each line is a JSON object representing a message:
- Triggers incremental imports
- Broadcasts updates via WebSocket
-### 3. Database (SQLite)
+### 4. Database (SQLite)
Location: `~/.quickcall-supertrace/data.db`
@@ -75,6 +125,7 @@ Location: `~/.quickcall-supertrace/data.db`
| `sessions` | Session metadata (id, project_path, timestamps) |
| `messages` | Parsed JSONL messages with extracted fields |
| `transcript_files` | Tracks ingested files (mtime, byte offset) |
+| `session_context` | Context window snapshots (from hooks) |
| `session_metrics` | Pre-computed aggregates |
| `messages_fts` | Full-text search index |
@@ -83,7 +134,7 @@ Location: `~/.quickcall-supertrace/data.db`
- Denormalized fields in `messages` for query performance
- FTS5 for full-text search across content
-### 4. REST API (FastAPI)
+### 5. REST API (FastAPI)
**Routes:**
@@ -91,13 +142,15 @@ Location: `~/.quickcall-supertrace/data.db`
|----------|---------|
| `GET /api/sessions` | List sessions (paginated) |
| `GET /api/sessions/{id}` | Get session with events |
+| `GET /api/sessions/{id}/context` | Get context window snapshots |
+| `POST /api/sessions/{id}/context` | Store context update (from hooks) |
| `GET /api/sessions/{id}/export` | Export as JSON/Markdown |
| `GET /api/metrics/session/{id}` | Compute session metrics |
| `POST /api/ingest` | Trigger manual import |
| `GET /api/ingest/status` | Show tracked files |
| `WS /ws` | Real-time updates |
-### 5. Metrics System
+### 6. Metrics System
**Architecture:** Decorator-based plugin system
@@ -116,7 +169,7 @@ def estimated_cost(events: PreprocessedEvents) -> float:
**Preprocessing:** Single-pass extraction of commonly-needed data for efficiency.
-### 6. React Frontend
+### 7. React Frontend
**Tech Stack:** React 19, TypeScript, Tailwind CSS, Vite
@@ -139,7 +192,29 @@ def estimated_cost(events: PreprocessedEvents) -> float:
## Data Flow
-### Import Flow
+### Context Tracking Flow (Real-time)
+
+```mermaid
+sequenceDiagram
+ participant CC as Claude Code
+ participant Hook as Hook CLI
+ participant API as SuperTrace API
+ participant WS as WebSocket
+ participant UI as Frontend
+
+ CC->>CC: User sends prompt
+ CC->>CC: Claude responds
+ CC->>Hook: Stop event (stdin JSON)
+ Hook->>Hook: Read transcript JSONL
+ Hook->>Hook: Extract token usage
+ Hook->>Hook: Calculate context %
+ Hook->>API: POST /sessions/{id}/context
+ API->>API: Store snapshot
+ API->>WS: Broadcast context_updated
+ WS->>UI: Update status bar
+```
+
+### Import Flow (Batch)
```mermaid
sequenceDiagram
diff --git a/install/hooks/context-tracker.sh b/install/hooks/context-tracker.sh
new file mode 100644
index 0000000..84b7a7d
--- /dev/null
+++ b/install/hooks/context-tracker.sh
@@ -0,0 +1,148 @@
+#!/usr/bin/env bash
+#
+# SuperTrace Context Tracker Hook for Claude Code
+#
+# This hook captures context window usage data from Claude Code sessions
+# and sends it to the SuperTrace backend for real-time tracking.
+#
+# Hook Event: PostToolUse (fires after each tool call)
+#
+# Installation:
+# 1. Copy hooks directory to ~/.claude/plugins/supertrace/
+# 2. Claude Code will automatically load hooks from the plugin
+#
+# Environment:
+# SUPERTRACE_URL - SuperTrace backend URL (default: http://localhost:7845)
+# SUPERTRACE_DEBUG - Enable debug logging (set to "true")
+# SUPERTRACE_TIMEOUT - Request timeout in seconds (default: 2)
+#
+
+set -euo pipefail
+
+# Configuration with defaults
+SUPERTRACE_URL="${SUPERTRACE_URL:-http://localhost:7845}"
+SUPERTRACE_DEBUG="${SUPERTRACE_DEBUG:-false}"
+SUPERTRACE_TIMEOUT="${SUPERTRACE_TIMEOUT:-2}"
+
+# Debug logging function
+debug() {
+ if [ "$SUPERTRACE_DEBUG" = "true" ]; then
+ echo "[SuperTrace Debug] $*" >&2
+ fi
+}
+
+# Log errors but don't fail - hooks must be non-blocking
+log_error() {
+ echo "[SuperTrace Error] $*" >&2
+}
+
+# Read stdin (Claude Code passes JSON input)
+input=$(cat 2>/dev/null || echo "{}")
+
+debug "Received hook input"
+
+# Extract session_id from hook input
+session_id=$(echo "$input" | jq -r '.session_id // empty' 2>/dev/null || true)
+
+if [ -z "$session_id" ]; then
+ debug "No session_id found in hook input, skipping"
+ exit 0
+fi
+
+debug "Session ID: $session_id"
+
+# Get transcript path from hook input
+transcript_path=$(echo "$input" | jq -r '.transcript_path // empty' 2>/dev/null || true)
+
+if [ -z "$transcript_path" ] || [ ! -f "$transcript_path" ]; then
+ debug "No valid transcript_path found, skipping"
+ exit 0
+fi
+
+debug "Transcript path: $transcript_path"
+
+# Parse the latest API response from transcript to get token usage
+# The transcript is JSONL format with API responses containing usage data
+# Look for the most recent entry with usage data
+
+# Get the last line with usage data from transcript
+latest_usage=$(tail -100 "$transcript_path" 2>/dev/null | \
+ grep -o '"usage":{[^}]*}' 2>/dev/null | \
+ tail -1 || true)
+
+if [ -z "$latest_usage" ]; then
+ debug "No usage data found in recent transcript entries"
+ exit 0
+fi
+
+debug "Found usage data: $latest_usage"
+
+# Extract token counts from usage data
+# Format: "usage":{"input_tokens":1234,"output_tokens":567,"cache_creation_input_tokens":0,"cache_read_input_tokens":890}
+input_tokens=$(echo "{$latest_usage}" | jq -r '.usage.input_tokens // 0' 2>/dev/null || echo "0")
+output_tokens=$(echo "{$latest_usage}" | jq -r '.usage.output_tokens // 0' 2>/dev/null || echo "0")
+cache_read_tokens=$(echo "{$latest_usage}" | jq -r '.usage.cache_read_input_tokens // 0' 2>/dev/null || echo "0")
+cache_creation_tokens=$(echo "{$latest_usage}" | jq -r '.usage.cache_creation_input_tokens // 0' 2>/dev/null || echo "0")
+
+# Calculate totals
+total_input_tokens=$((input_tokens + cache_read_tokens + cache_creation_tokens))
+total_output_tokens=$output_tokens
+total_tokens=$((total_input_tokens + total_output_tokens))
+
+debug "Input tokens: $total_input_tokens, Output tokens: $total_output_tokens"
+
+# Estimate context window size (default to 200k for Claude)
+# This could be enhanced to detect model type from transcript
+context_window_size=200000
+
+# Calculate percentages
+used_percentage=$(echo "scale=2; ($total_tokens * 100) / $context_window_size" | bc 2>/dev/null || echo "0")
+remaining_percentage=$(echo "scale=2; 100 - $used_percentage" | bc 2>/dev/null || echo "100")
+
+debug "Used: ${used_percentage}%, Remaining: ${remaining_percentage}%"
+
+# Build the payload for SuperTrace API
+payload=$(jq -n \
+ --argjson used_percentage "$used_percentage" \
+ --argjson remaining_percentage "$remaining_percentage" \
+ --argjson context_window_size "$context_window_size" \
+ --argjson total_input_tokens "$total_input_tokens" \
+ --argjson total_output_tokens "$total_output_tokens" \
+ --argjson cache_read_tokens "$cache_read_tokens" \
+ --argjson cache_creation_tokens "$cache_creation_tokens" \
+ '{
+ used_percentage: $used_percentage,
+ remaining_percentage: $remaining_percentage,
+ context_window_size: $context_window_size,
+ total_input_tokens: $total_input_tokens,
+ total_output_tokens: $total_output_tokens,
+ cache_read_tokens: $cache_read_tokens,
+ cache_creation_tokens: $cache_creation_tokens
+ }' 2>/dev/null || true)
+
+if [ -z "$payload" ]; then
+ log_error "Failed to build payload"
+ exit 0
+fi
+
+debug "Payload: $payload"
+
+# POST to SuperTrace API with timeout
+# Use || true to ensure hook never blocks Claude Code
+api_url="${SUPERTRACE_URL}/api/sessions/${session_id}/context"
+
+debug "POSTing to: $api_url"
+
+response=$(curl -s -X POST "$api_url" \
+ -H "Content-Type: application/json" \
+ -d "$payload" \
+ --connect-timeout "$SUPERTRACE_TIMEOUT" \
+ --max-time "$SUPERTRACE_TIMEOUT" \
+ 2>/dev/null || true)
+
+if [ -n "$response" ]; then
+ debug "Response: $response"
+fi
+
+# Always exit successfully - hooks must be non-blocking
+exit 0
diff --git a/install/hooks/hooks.json b/install/hooks/hooks.json
new file mode 100644
index 0000000..3e36108
--- /dev/null
+++ b/install/hooks/hooks.json
@@ -0,0 +1,17 @@
+{
+ "description": "SuperTrace context tracking hook - sends context window usage to SuperTrace backend",
+ "hooks": {
+ "PostToolUse": [
+ {
+ "matcher": "*",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "bash ${CLAUDE_PLUGIN_ROOT}/context-tracker.sh",
+ "timeout": 5
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/install/install-no-curl.sh b/install/install-no-curl.sh
new file mode 100644
index 0000000..e168c8b
--- /dev/null
+++ b/install/install-no-curl.sh
@@ -0,0 +1,146 @@
+#!/bin/sh
+# ------------------------------------------------------------------------------
+# QuickCall SuperTrace Installer (No curl required)
+# ------------------------------------------------------------------------------
+#
+# Usage: wget -qO- https://quickcall.dev/supertrace/install-no-curl.sh | sh
+# OR: wget -qO- https://quickcall.dev/supertrace/install-no-curl.sh | bash
+# OR: sh install-no-curl.sh
+#
+# What this script does:
+# 1. Installs uv (Python package manager) if not present (using wget or pip)
+# 2. Detects your shell config file (.zshrc or .bashrc)
+# 3. Adds config block with markers (>>> quickcall-supertrace >>>)
+# 4. Re-running updates the config block (safe to run multiple times)
+#
+# After install, run: quickcall-supertrace
+# Then open: http://localhost:7845
+#
+# ------------------------------------------------------------------------------
+set -e
+
+echo ""
+echo "QuickCall SuperTrace Installer"
+echo "=============================="
+echo ""
+
+# --- Pre-flight checks ---
+
+# Warn if running as root
+if [ "$(id -u)" = "0" ]; then
+ echo "[!] Warning: Running as root is not recommended."
+ echo " Consider running as a regular user."
+ echo ""
+fi
+
+# --- Step 1: Install uv ---
+
+echo "[1/3] Checking uv package manager..."
+
+if command -v uv >/dev/null 2>&1; then
+ echo " uv is already installed"
+else
+ echo " Installing uv..."
+
+ # Try wget first
+ if command -v wget >/dev/null 2>&1; then
+ echo " Using wget to download uv installer..."
+ if ! wget -qO- https://astral.sh/uv/install.sh | sh; then
+ echo "[!] Error: Failed to install uv with wget"
+ exit 1
+ fi
+ # Try pip as fallback
+ elif command -v pip >/dev/null 2>&1 || command -v pip3 >/dev/null 2>&1; then
+ echo " Using pip to install uv..."
+ PIP_CMD="pip"
+ if ! command -v pip >/dev/null 2>&1; then
+ PIP_CMD="pip3"
+ fi
+ if ! $PIP_CMD install uv; then
+ echo "[!] Error: Failed to install uv with pip"
+ exit 1
+ fi
+ else
+ echo "[!] Error: Neither wget nor pip is available."
+ echo ""
+ echo "Please install uv manually:"
+ echo " 1. Visit: https://github.com/astral-sh/uv/releases"
+ echo " 2. Download the binary for your system"
+ echo " 3. Extract and move to ~/.local/bin/uv"
+ echo " 4. Run: chmod +x ~/.local/bin/uv"
+ echo ""
+ echo "Then run this script again."
+ exit 1
+ fi
+
+ export PATH="$HOME/.local/bin:$PATH"
+ echo " uv installed successfully"
+fi
+
+# --- Step 2: Configure shell ---
+
+echo "[2/3] Configuring shell..."
+
+SHELL_CONFIG=""
+if [ -f "$HOME/.zshrc" ]; then
+ SHELL_CONFIG="$HOME/.zshrc"
+elif [ -f "$HOME/.bashrc" ]; then
+ SHELL_CONFIG="$HOME/.bashrc"
+fi
+
+START_MARKER="# >>> quickcall-supertrace >>>"
+END_MARKER="# <<< quickcall-supertrace <<<"
+
+CONFIG_BLOCK="$START_MARKER
+export PATH=\"\$HOME/.local/bin:\$PATH\"
+alias quickcall-supertrace=\"uv cache clean quickcall-supertrace >/dev/null 2>&1; uvx quickcall-supertrace@latest\"
+$END_MARKER"
+
+if [ -n "$SHELL_CONFIG" ]; then
+ # Check write permission
+ if [ ! -w "$SHELL_CONFIG" ]; then
+ echo "[!] Error: Cannot write to $SHELL_CONFIG"
+ echo " Check file permissions and try again."
+ exit 1
+ fi
+
+ if grep -q "$START_MARKER" "$SHELL_CONFIG" 2>/dev/null; then
+ # Markers exist - replace content between them
+ awk -v start="$START_MARKER" -v end="$END_MARKER" '
+ $0 == start { skip=1; next }
+ $0 == end { skip=0; next }
+ !skip { print }
+ ' "$SHELL_CONFIG" > "$SHELL_CONFIG.tmp"
+ mv "$SHELL_CONFIG.tmp" "$SHELL_CONFIG"
+ echo "$CONFIG_BLOCK" >> "$SHELL_CONFIG"
+ echo " Updated config in $SHELL_CONFIG"
+ else
+ # Fresh install - add with markers
+ echo '' >> "$SHELL_CONFIG"
+ echo "$CONFIG_BLOCK" >> "$SHELL_CONFIG"
+ echo " Added config to $SHELL_CONFIG"
+ fi
+else
+ echo "[!] No .zshrc or .bashrc found."
+ echo " Add this to your shell config manually:"
+ echo ""
+ echo "$CONFIG_BLOCK"
+ echo ""
+fi
+
+# --- Step 3: Done ---
+
+echo "[3/3] Installation complete!"
+echo ""
+echo "=============================="
+echo ""
+echo "Usage:"
+echo " quickcall-supertrace"
+echo ""
+echo "Then open: http://localhost:7845"
+echo ""
+if [ -n "$SHELL_CONFIG" ]; then
+ echo "NOTE: Restart your terminal or run:"
+ echo " source $SHELL_CONFIG"
+ echo ""
+fi
diff --git a/install/install.sh b/install/install.sh
index 041346a..f15d7da 100755
--- a/install/install.sh
+++ b/install/install.sh
@@ -1,9 +1,10 @@
-#!/bin/bash
+#!/bin/sh
# ------------------------------------------------------------------------------
# QuickCall SuperTrace Installer
# ------------------------------------------------------------------------------
#
# Usage: curl -fsSL https://quickcall.dev/supertrace/install.sh | sh
+# OR: curl -fsSL https://quickcall.dev/supertrace/install.sh | bash
#
# What this script does:
# 1. Installs uv (Python package manager) if not present
@@ -25,7 +26,7 @@ echo ""
# --- Pre-flight checks ---
# Check for curl
-if ! command -v curl &> /dev/null; then
+if ! command -v curl >/dev/null 2>&1; then
echo "[!] Error: curl is required but not installed."
echo " Install curl and try again."
exit 1
@@ -42,7 +43,7 @@ fi
echo "[1/3] Checking uv package manager..."
-if command -v uv &> /dev/null; then
+if command -v uv >/dev/null 2>&1; then
echo " uv is already installed"
else
echo " Installing uv..."
diff --git a/install/supertrace-plugin/.claude-plugin/plugin.json b/install/supertrace-plugin/.claude-plugin/plugin.json
new file mode 100644
index 0000000..1a8d474
--- /dev/null
+++ b/install/supertrace-plugin/.claude-plugin/plugin.json
@@ -0,0 +1,13 @@
+{
+ "name": "supertrace",
+ "version": "1.0.0",
+ "description": "SuperTrace context tracking - sends real-time context window usage data to SuperTrace backend for monitoring",
+ "author": {
+ "name": "QuickCall",
+ "email": "hello@quickcall.dev"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/quickcall-dev/quickcall-supertrace"
+ }
+}
diff --git a/install/supertrace-plugin/context-tracker.sh b/install/supertrace-plugin/context-tracker.sh
new file mode 100644
index 0000000..4dfa3dd
--- /dev/null
+++ b/install/supertrace-plugin/context-tracker.sh
@@ -0,0 +1,148 @@
+#!/usr/bin/env bash
+#
+# SuperTrace Context Tracker Hook for Claude Code
+#
+# This hook captures context window usage data from Claude Code sessions
+# and sends it to the SuperTrace backend for real-time tracking.
+#
+# Hook Event: PostToolUse (fires after each tool call)
+#
+# Installation:
+# Option 1 (Plugin): Copy supertrace-plugin/ to ~/.claude/plugins/supertrace/
+# Option 2 (Manual): Add hook config to ~/.claude/settings.json
+#
+# Environment:
+# SUPERTRACE_URL - SuperTrace backend URL (default: http://localhost:7845)
+# SUPERTRACE_DEBUG - Enable debug logging (set to "true")
+# SUPERTRACE_TIMEOUT - Request timeout in seconds (default: 2)
+#
+
+set -euo pipefail
+
+# Configuration with defaults
+SUPERTRACE_URL="${SUPERTRACE_URL:-http://localhost:7845}"
+SUPERTRACE_DEBUG="${SUPERTRACE_DEBUG:-false}"
+SUPERTRACE_TIMEOUT="${SUPERTRACE_TIMEOUT:-2}"
+
+# Debug logging function
+debug() {
+ if [ "$SUPERTRACE_DEBUG" = "true" ]; then
+ echo "[SuperTrace Debug] $*" >&2
+ fi
+}
+
+# Log errors but don't fail - hooks must be non-blocking
+log_error() {
+ echo "[SuperTrace Error] $*" >&2
+}
+
+# Read stdin (Claude Code passes JSON input)
+input=$(cat 2>/dev/null || echo "{}")
+
+debug "Received hook input"
+
+# Extract session_id from hook input
+session_id=$(echo "$input" | jq -r '.session_id // empty' 2>/dev/null || true)
+
+if [ -z "$session_id" ]; then
+ debug "No session_id found in hook input, skipping"
+ exit 0
+fi
+
+debug "Session ID: $session_id"
+
+# Get transcript path from hook input
+transcript_path=$(echo "$input" | jq -r '.transcript_path // empty' 2>/dev/null || true)
+
+if [ -z "$transcript_path" ] || [ ! -f "$transcript_path" ]; then
+ debug "No valid transcript_path found, skipping"
+ exit 0
+fi
+
+debug "Transcript path: $transcript_path"
+
+# Parse the latest API response from transcript to get token usage
+# The transcript is JSONL format with API responses containing usage data
+# Look for the most recent entry with usage data
+
+# Get the last line with usage data from transcript
+latest_usage=$(tail -100 "$transcript_path" 2>/dev/null | \
+ grep -o '"usage":{[^}]*}' 2>/dev/null | \
+ tail -1 || true)
+
+if [ -z "$latest_usage" ]; then
+ debug "No usage data found in recent transcript entries"
+ exit 0
+fi
+
+debug "Found usage data: $latest_usage"
+
+# Extract token counts from usage data
+# Format: "usage":{"input_tokens":1234,"output_tokens":567,"cache_creation_input_tokens":0,"cache_read_input_tokens":890}
+input_tokens=$(echo "{$latest_usage}" | jq -r '.usage.input_tokens // 0' 2>/dev/null || echo "0")
+output_tokens=$(echo "{$latest_usage}" | jq -r '.usage.output_tokens // 0' 2>/dev/null || echo "0")
+cache_read_tokens=$(echo "{$latest_usage}" | jq -r '.usage.cache_read_input_tokens // 0' 2>/dev/null || echo "0")
+cache_creation_tokens=$(echo "{$latest_usage}" | jq -r '.usage.cache_creation_input_tokens // 0' 2>/dev/null || echo "0")
+
+# Calculate totals
+total_input_tokens=$((input_tokens + cache_read_tokens + cache_creation_tokens))
+total_output_tokens=$output_tokens
+total_tokens=$((total_input_tokens + total_output_tokens))
+
+debug "Input tokens: $total_input_tokens, Output tokens: $total_output_tokens"
+
+# Estimate context window size (default to 200k for Claude)
+# This could be enhanced to detect model type from transcript
+context_window_size=200000
+
+# Calculate percentages
+used_percentage=$(echo "scale=2; ($total_tokens * 100) / $context_window_size" | bc 2>/dev/null || echo "0")
+remaining_percentage=$(echo "scale=2; 100 - $used_percentage" | bc 2>/dev/null || echo "100")
+
+debug "Used: ${used_percentage}%, Remaining: ${remaining_percentage}%"
+
+# Build the payload for SuperTrace API
+payload=$(jq -n \
+ --argjson used_percentage "$used_percentage" \
+ --argjson remaining_percentage "$remaining_percentage" \
+ --argjson context_window_size "$context_window_size" \
+ --argjson total_input_tokens "$total_input_tokens" \
+ --argjson total_output_tokens "$total_output_tokens" \
+ --argjson cache_read_tokens "$cache_read_tokens" \
+ --argjson cache_creation_tokens "$cache_creation_tokens" \
+ '{
+ used_percentage: $used_percentage,
+ remaining_percentage: $remaining_percentage,
+ context_window_size: $context_window_size,
+ total_input_tokens: $total_input_tokens,
+ total_output_tokens: $total_output_tokens,
+ cache_read_tokens: $cache_read_tokens,
+ cache_creation_tokens: $cache_creation_tokens
+ }' 2>/dev/null || true)
+
+if [ -z "$payload" ]; then
+ log_error "Failed to build payload"
+ exit 0
+fi
+
+debug "Payload: $payload"
+
+# POST to SuperTrace API with timeout
+# Use || true to ensure hook never blocks Claude Code
+api_url="${SUPERTRACE_URL}/api/sessions/${session_id}/context"
+
+debug "POSTing to: $api_url"
+
+response=$(curl -s -X POST "$api_url" \
+ -H "Content-Type: application/json" \
+ -d "$payload" \
+ --connect-timeout "$SUPERTRACE_TIMEOUT" \
+ --max-time "$SUPERTRACE_TIMEOUT" \
+ 2>/dev/null || true)
+
+if [ -n "$response" ]; then
+ debug "Response: $response"
+fi
+
+# Always exit successfully - hooks must be non-blocking
+exit 0
diff --git a/install/supertrace-plugin/hooks/hooks.json b/install/supertrace-plugin/hooks/hooks.json
new file mode 100644
index 0000000..fd62248
--- /dev/null
+++ b/install/supertrace-plugin/hooks/hooks.json
@@ -0,0 +1,17 @@
+{
+ "description": "SuperTrace context tracking hook - sends context window usage to SuperTrace backend after each tool call",
+ "hooks": {
+ "PostToolUse": [
+ {
+ "matcher": "*",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "bash ${CLAUDE_PLUGIN_ROOT}/context-tracker.sh",
+ "timeout": 5
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/packages/server/pyproject.toml b/packages/server/pyproject.toml
index 4fb5f3c..c0680fe 100644
--- a/packages/server/pyproject.toml
+++ b/packages/server/pyproject.toml
@@ -7,7 +7,7 @@
[project]
name = "quickcall-supertrace"
-version = "0.2.10"
+version = "0.2.11"
description = "QuickCall SuperTrace - Tracing server for AI coding assistant sessions"
readme = "README.md"
license = "Apache-2.0"
@@ -43,6 +43,8 @@ Issues = "https://github.com/quickcall-dev/quickcall-supertrace/issues"
[project.scripts]
quickcall-supertrace = "quickcall_supertrace.main:run"
+quickcall-supertrace-hook = "quickcall_supertrace.hooks.cli:main"
+quickcall-supertrace-statusline = "quickcall_supertrace.hooks.statusline:main"
[build-system]
requires = ["hatchling"]
diff --git a/packages/server/src/quickcall_supertrace/db/client.py b/packages/server/src/quickcall_supertrace/db/client.py
index bf7e642..b36cbd9 100644
--- a/packages/server/src/quickcall_supertrace/db/client.py
+++ b/packages/server/src/quickcall_supertrace/db/client.py
@@ -333,6 +333,187 @@ async def delete_session_intents(self, session_id: str) -> None:
)
await self.conn.commit()
+ # =====================
+ # Context Window operations
+ # =====================
+ #
+ # Context window tracking stores snapshots of token usage during sessions.
+ # Data is sent from Claude Code hooks and stored for real-time visualization.
+ #
+ # Schema: session_context table (see schema.py migration v6)
+ # Expected hook payload format:
+ # {"used_percentage": 42.5, "remaining_percentage": 57.5, "context_window_size": 200000,
+ # "total_input_tokens": 85000, "total_output_tokens": 15000}
+
+ async def save_session_context(
+ self,
+ session_id: str,
+ timestamp: str,
+ used_percentage: float = 0.0,
+ remaining_percentage: float | None = None,
+ context_window_size: int = 200000,
+ total_input_tokens: int = 0,
+ total_output_tokens: int = 0,
+ cache_read_tokens: int = 0,
+ cache_create_tokens: int = 0,
+ model: str | None = None,
+ ) -> int:
+ """
+ Save a context window snapshot for a session.
+
+ Args:
+ session_id: Session this context belongs to
+ timestamp: ISO timestamp of when this snapshot was taken
+ used_percentage: Percentage of context window used (0-100)
+ remaining_percentage: Percentage remaining (computed if not provided)
+ context_window_size: Maximum context window size for the model
+ total_input_tokens: Total input tokens consumed
+ total_output_tokens: Total output tokens generated
+ cache_read_tokens: Tokens read from cache (optional, for detailed stats)
+ cache_create_tokens: Tokens written to cache (optional, for detailed stats)
+ model: Model being used (optional, for reference)
+
+ Returns:
+ ID of the inserted context record
+ """
+ # Compute remaining percentage if not provided
+ if remaining_percentage is None:
+ remaining_percentage = 100.0 - used_percentage
+
+ cursor = await self.conn.execute(
+ """
+ INSERT INTO session_context (
+ session_id, timestamp, used_percentage, remaining_percentage,
+ context_window_size, total_input_tokens, total_output_tokens,
+ cache_read_tokens, cache_create_tokens, model
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ session_id,
+ timestamp,
+ used_percentage,
+ remaining_percentage,
+ context_window_size,
+ total_input_tokens,
+ total_output_tokens,
+ cache_read_tokens,
+ cache_create_tokens,
+ model,
+ ),
+ )
+ await self.conn.commit()
+ return cursor.lastrowid or 0
+
+ async def get_session_context(
+ self, session_id: str, limit: int = 100
+ ) -> list[dict[str, Any]]:
+ """
+ Get context window snapshots for a session.
+
+ Returns most recent snapshots first (for charts showing context growth).
+
+ Args:
+ session_id: Session to get context for
+ limit: Maximum number of snapshots to return
+
+ Returns:
+ List of context snapshots with all fields
+ """
+ cursor = await self.conn.execute(
+ """
+ SELECT id, session_id, timestamp, used_percentage, remaining_percentage,
+ context_window_size, total_input_tokens, total_output_tokens,
+ cache_read_tokens, cache_create_tokens, model, created_at
+ FROM session_context
+ WHERE session_id = ?
+ ORDER BY timestamp DESC
+ LIMIT ?
+ """,
+ (session_id, limit),
+ )
+ rows = await cursor.fetchall()
+ return [
+ {
+ "id": row["id"],
+ "session_id": row["session_id"],
+ "timestamp": row["timestamp"],
+ "used_percentage": row["used_percentage"],
+ "remaining_percentage": row["remaining_percentage"],
+ "context_window_size": row["context_window_size"],
+ "total_input_tokens": row["total_input_tokens"],
+ "total_output_tokens": row["total_output_tokens"],
+ "cache_read_tokens": row["cache_read_tokens"],
+ "cache_create_tokens": row["cache_create_tokens"],
+ "model": row["model"],
+ "created_at": row["created_at"],
+ }
+ for row in rows
+ ]
+
+ async def get_latest_session_context(
+ self, session_id: str
+ ) -> dict[str, Any] | None:
+ """
+ Get the most recent context snapshot for a session.
+
+ Used for displaying current context window status in the UI.
+
+ Args:
+ session_id: Session to get context for
+
+ Returns:
+ Latest context snapshot or None if no context data exists
+ """
+ cursor = await self.conn.execute(
+ """
+ SELECT id, session_id, timestamp, used_percentage, remaining_percentage,
+ context_window_size, total_input_tokens, total_output_tokens,
+ cache_read_tokens, cache_create_tokens, model, created_at
+ FROM session_context
+ WHERE session_id = ?
+ ORDER BY timestamp DESC
+ LIMIT 1
+ """,
+ (session_id,),
+ )
+ row = await cursor.fetchone()
+ if not row:
+ return None
+ return {
+ "id": row["id"],
+ "session_id": row["session_id"],
+ "timestamp": row["timestamp"],
+ "used_percentage": row["used_percentage"],
+ "remaining_percentage": row["remaining_percentage"],
+ "context_window_size": row["context_window_size"],
+ "total_input_tokens": row["total_input_tokens"],
+ "total_output_tokens": row["total_output_tokens"],
+ "cache_read_tokens": row["cache_read_tokens"],
+ "cache_create_tokens": row["cache_create_tokens"],
+ "model": row["model"],
+ "created_at": row["created_at"],
+ }
+
+ async def delete_session_context(self, session_id: str) -> int:
+ """
+ Delete all context snapshots for a session.
+
+ Used when clearing session data.
+
+ Args:
+ session_id: Session to delete context for
+
+ Returns:
+ Number of deleted records
+ """
+ cursor = await self.conn.execute(
+ "DELETE FROM session_context WHERE session_id = ?",
+ (session_id,),
+ )
+ await self.conn.commit()
+ return cursor.rowcount
+
async def clear_all_data(self) -> dict[str, int]:
"""
Clear all session data from the database for force reimport.
@@ -351,6 +532,7 @@ async def clear_all_data(self) -> dict[str, int]:
"messages",
"session_intents",
"session_metrics",
+ "session_context",
"transcript_files",
"sessions",
]
diff --git a/packages/server/src/quickcall_supertrace/db/schema.py b/packages/server/src/quickcall_supertrace/db/schema.py
index cfe46ce..2068e8d 100644
--- a/packages/server/src/quickcall_supertrace/db/schema.py
+++ b/packages/server/src/quickcall_supertrace/db/schema.py
@@ -242,6 +242,30 @@
# v5: Backfill thinking_content from raw_data (handled by Python, not SQL)
# This is a marker migration - actual work done in _backfill_thinking_content()
(5, "backfill_thinking_content", []),
+ # v6: Add session_context table for real-time context window tracking
+ # Stores snapshots of context window usage (tokens) from Claude Code hooks
+ # Columns match the expected API payload from hooks:
+ # {"used_percentage": 42.5, "remaining_percentage": 57.5, "context_window_size": 200000,
+ # "total_input_tokens": 85000, "total_output_tokens": 15000}
+ (6, "add_session_context_table", [
+ """CREATE TABLE IF NOT EXISTS session_context (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ session_id TEXT NOT NULL,
+ timestamp TEXT NOT NULL,
+ used_percentage REAL DEFAULT 0.0,
+ remaining_percentage REAL DEFAULT 100.0,
+ context_window_size INTEGER DEFAULT 200000,
+ total_input_tokens INTEGER DEFAULT 0,
+ total_output_tokens INTEGER DEFAULT 0,
+ cache_read_tokens INTEGER DEFAULT 0,
+ cache_create_tokens INTEGER DEFAULT 0,
+ model TEXT,
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (session_id) REFERENCES sessions(id)
+ )""",
+ "CREATE INDEX IF NOT EXISTS idx_context_session ON session_context(session_id)",
+ "CREATE INDEX IF NOT EXISTS idx_context_session_time ON session_context(session_id, timestamp DESC)",
+ ]),
# Add future migrations here with incrementing version numbers
]
diff --git a/packages/server/src/quickcall_supertrace/hooks/__init__.py b/packages/server/src/quickcall_supertrace/hooks/__init__.py
new file mode 100644
index 0000000..173eac3
--- /dev/null
+++ b/packages/server/src/quickcall_supertrace/hooks/__init__.py
@@ -0,0 +1,16 @@
+"""
+Hooks module for Claude Code integration.
+
+Provides CLI entry point that receives hook events from Claude Code
+and forwards them to the QuickCall SuperTrace server.
+
+Usage:
+ supertrace-hook
+
+Claude Code calls this with JSON on stdin containing session context,
+model info, and token usage data.
+"""
+
+from .cli import main
+
+__all__ = ["main"]
diff --git a/packages/server/src/quickcall_supertrace/hooks/cli.py b/packages/server/src/quickcall_supertrace/hooks/cli.py
new file mode 100644
index 0000000..17510ed
--- /dev/null
+++ b/packages/server/src/quickcall_supertrace/hooks/cli.py
@@ -0,0 +1,124 @@
+"""
+CLI entry point for QuickCall SuperTrace hooks.
+
+Reads JSON from stdin (passed by Claude Code hooks), parses it,
+and dispatches to the appropriate handler based on the command.
+
+Usage:
+ quickcall-supertrace-hook
+
+Commands:
+ stop - Handle Stop event (Claude finished responding)
+ tool - Handle PostToolUse event (tool finished)
+ session-start - Handle SessionStart event
+ session-end - Handle SessionEnd event
+ prompt - Handle UserPromptSubmit event
+ notification - Handle Notification event
+ precompact - Handle PreCompact event
+
+Example Claude Code hooks.json configuration:
+{
+ "hooks": {
+ "Stop": [{
+ "matcher": "*",
+ "hooks": [{
+ "type": "command",
+ "command": "quickcall-supertrace-hook stop",
+ "timeout": 5
+ }]
+ }],
+ "PostToolUse": [{
+ "matcher": "*",
+ "hooks": [{
+ "type": "command",
+ "command": "quickcall-supertrace-hook tool",
+ "timeout": 5
+ }]
+ }]
+ }
+}
+"""
+
+import json
+import sys
+
+from .handlers import (
+ handle_notification,
+ handle_precompact,
+ handle_prompt,
+ handle_session_end,
+ handle_session_start,
+ handle_stop,
+ handle_tool,
+ debug,
+)
+from .models import HookInput
+
+# Command to handler mapping
+COMMANDS = {
+ "stop": handle_stop,
+ "tool": handle_tool,
+ "session-start": handle_session_start,
+ "session-end": handle_session_end,
+ "prompt": handle_prompt,
+ "notification": handle_notification,
+ "precompact": handle_precompact,
+}
+
+
+def read_stdin() -> dict | None:
+ """Read and parse JSON from stdin."""
+ try:
+ data = sys.stdin.read()
+ if not data.strip():
+ return None
+ return json.loads(data)
+ except json.JSONDecodeError as e:
+ debug(f"Invalid JSON input: {e}")
+ return None
+
+
+def main() -> None:
+ """Main CLI entry point."""
+ if len(sys.argv) < 2:
+ print("Usage: quickcall-supertrace-hook ", file=sys.stderr)
+ print(f"Commands: {', '.join(COMMANDS.keys())}", file=sys.stderr)
+ sys.exit(1)
+
+ command = sys.argv[1]
+
+ if command in ("--help", "-h"):
+ print(__doc__)
+ sys.exit(0)
+
+ if command not in COMMANDS:
+ print(f"Unknown command: {command}", file=sys.stderr)
+ print(f"Commands: {', '.join(COMMANDS.keys())}", file=sys.stderr)
+ sys.exit(1)
+
+ # Read hook input from stdin
+ stdin_data = read_stdin()
+ if stdin_data is None:
+ # No input is okay - some hooks might not pass data
+ debug("No stdin data received")
+ sys.exit(0)
+
+ try:
+ hook_input = HookInput(**stdin_data)
+ except Exception as e:
+ debug(f"Failed to parse hook input: {e}")
+ # Don't fail hard - hooks should not block Claude Code
+ sys.exit(0)
+
+ # Dispatch to handler
+ try:
+ handler = COMMANDS[command]
+ handler(hook_input)
+ except Exception as e:
+ debug(f"Handler error: {e}")
+ # Don't fail hard - hooks should not block Claude Code
+ sys.exit(0)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/packages/server/src/quickcall_supertrace/hooks/handlers.py b/packages/server/src/quickcall_supertrace/hooks/handlers.py
new file mode 100644
index 0000000..79ca8a6
--- /dev/null
+++ b/packages/server/src/quickcall_supertrace/hooks/handlers.py
@@ -0,0 +1,281 @@
+"""
+Event handlers for Claude Code hooks.
+
+Each handler processes a specific hook event type and sends
+relevant data to the QuickCall SuperTrace server.
+
+Related: models.py (data structures), cli.py (dispatches here)
+"""
+
+import json
+import os
+import sys
+from pathlib import Path
+from typing import Any
+from urllib.request import Request, urlopen
+from urllib.error import URLError
+
+from .models import HookInput, ContextData
+
+# Server configuration
+DEFAULT_SERVER_URL = "http://localhost:7845"
+TIMEOUT_SECONDS = 5
+
+# Context window sizes by model
+MODEL_CONTEXT_SIZES = {
+ "claude-3-opus": 200000,
+ "claude-3-sonnet": 200000,
+ "claude-3-haiku": 200000,
+ "claude-3-5-sonnet": 200000,
+ "claude-3-5-haiku": 200000,
+ "claude-opus-4": 200000,
+ "claude-sonnet-4": 200000,
+}
+DEFAULT_CONTEXT_SIZE = 200000
+
+
+def get_server_url() -> str:
+ """Get server URL from env or use default."""
+ return os.environ.get("QUICKCALL_SUPERTRACE_URL", DEFAULT_SERVER_URL)
+
+
+def is_debug() -> bool:
+ """Check if debug mode is enabled."""
+ return os.environ.get("QUICKCALL_SUPERTRACE_DEBUG", "").lower() in ("true", "1", "yes")
+
+
+def debug(msg: str) -> None:
+ """Print debug message if debug mode is enabled."""
+ if is_debug():
+ print(f"[QuickCall SuperTrace] {msg}", file=sys.stderr)
+
+
+def log_error(msg: str) -> None:
+ """Log error message."""
+ print(f"[QuickCall SuperTrace Error] {msg}", file=sys.stderr)
+
+
+def send_context_update(session_id: str, context_data: ContextData) -> bool:
+ """
+ Send context update to QuickCall SuperTrace server.
+
+ Returns True if successful, False otherwise.
+ Fails silently to avoid blocking Claude Code.
+ """
+ url = f"{get_server_url()}/api/sessions/{session_id}/context"
+
+ try:
+ data = json.dumps(context_data.model_dump()).encode("utf-8")
+ request = Request(
+ url,
+ data=data,
+ headers={"Content-Type": "application/json"},
+ method="POST",
+ )
+ with urlopen(request, timeout=TIMEOUT_SECONDS) as response:
+ debug(f"Context update sent: {response.status}")
+ return response.status == 200
+ except URLError as e:
+ debug(f"Failed to send context update: {e}")
+ return False
+ except Exception as e:
+ debug(f"Unexpected error sending context: {e}")
+ return False
+
+
+def get_context_size_for_model(model: str | None) -> int:
+ """Get context window size for a model."""
+ if not model:
+ return DEFAULT_CONTEXT_SIZE
+
+ # Normalize model name
+ model_lower = model.lower()
+ for key, size in MODEL_CONTEXT_SIZES.items():
+ if key in model_lower:
+ return size
+
+ return DEFAULT_CONTEXT_SIZE
+
+
+def read_transcript(path: str | None) -> list[dict] | None:
+ """Read and parse the JSONL transcript file."""
+ if not path:
+ return None
+
+ transcript_path = Path(path)
+ if not transcript_path.exists():
+ return None
+
+ messages = []
+ try:
+ with open(transcript_path) as f:
+ for line in f:
+ line = line.strip()
+ if line:
+ messages.append(json.loads(line))
+ except (json.JSONDecodeError, IOError) as e:
+ debug(f"Error reading transcript: {e}")
+ return None
+
+ return messages
+
+
+def extract_usage_from_transcript(transcript: list[dict] | None) -> dict[str, Any] | None:
+ """
+ Extract token usage from the LAST assistant message in the transcript.
+
+ Returns dict with input_tokens, output_tokens, cache tokens, and model.
+ """
+ if not transcript:
+ return None
+
+ # Find the last assistant message (iterate backwards)
+ for entry in reversed(transcript):
+ if entry.get("type") == "assistant":
+ message = entry.get("message", {})
+ usage = message.get("usage", {})
+ model = message.get("model")
+
+ if usage:
+ return {
+ "input_tokens": usage.get("input_tokens", 0),
+ "output_tokens": usage.get("output_tokens", 0),
+ "cache_creation_input_tokens": usage.get("cache_creation_input_tokens", 0),
+ "cache_read_input_tokens": usage.get("cache_read_input_tokens", 0),
+ "model": model,
+ }
+
+ return None
+
+
+def handle_stop(hook_input: HookInput) -> None:
+ """
+ Handle Stop hook - Claude finished responding.
+
+ This is the main hook for context tracking since it fires
+ after each complete response with full usage data.
+
+ Context window % is calculated from input_tokens only (not output).
+ The input_tokens field represents the total context sent to the model.
+ """
+ debug(f"Handle stop for session: {hook_input.session_id}")
+
+ # Read transcript to get usage data
+ transcript = read_transcript(hook_input.transcript_path)
+ usage = extract_usage_from_transcript(transcript)
+
+ if not usage:
+ debug("No usage data found in transcript")
+ return
+
+ # Get model and context size
+ model = usage.get("model") or hook_input.model
+ context_size = get_context_size_for_model(model)
+
+ # Extract token counts from the LAST message
+ # From Anthropic API:
+ # - input_tokens: FRESH (non-cached) input tokens
+ # - cache_read_input_tokens: tokens read from cache
+ # - cache_creation_input_tokens: tokens written to cache
+ # - output_tokens: tokens generated by model (NOT part of context window)
+ #
+ # Total context = input_tokens + cache_read + cache_create
+ input_tokens = usage.get("input_tokens", 0)
+ output_tokens = usage.get("output_tokens", 0)
+ cache_read = usage.get("cache_read_input_tokens", 0)
+ cache_create = usage.get("cache_creation_input_tokens", 0)
+
+ # Context window = total INPUT tokens (fresh + cached)
+ # Output tokens do NOT count toward context window
+ total_context = input_tokens + cache_read + cache_create
+ used_pct = min(100.0, (total_context / context_size) * 100)
+ remaining_pct = max(0.0, 100.0 - used_pct)
+
+ debug(f"Usage: {total_context}/{context_size} tokens ({used_pct:.1f}%)")
+
+ # Build context data
+ context_data = ContextData(
+ used_percentage=round(used_pct, 2),
+ remaining_percentage=round(remaining_pct, 2),
+ context_window_size=context_size,
+ total_input_tokens=total_context, # Total context (fresh + cached)
+ total_output_tokens=output_tokens,
+ cache_read_tokens=cache_read,
+ cache_create_tokens=cache_create,
+ model=model,
+ )
+
+ # Send to server
+ send_context_update(hook_input.session_id, context_data)
+
+
+def handle_tool(hook_input: HookInput) -> None:
+ """
+ Handle PostToolUse hook - tool finished executing.
+
+ We also capture context here for more granular tracking.
+ """
+ debug(f"Handle tool for session: {hook_input.session_id}, tool: {hook_input.tool_name}")
+
+ # Same logic as stop - extract usage and send
+ transcript = read_transcript(hook_input.transcript_path)
+ usage = extract_usage_from_transcript(transcript)
+
+ if not usage:
+ debug("No usage data found")
+ return
+
+ model = usage.get("model") or hook_input.model
+ context_size = get_context_size_for_model(model)
+
+ # Context window = total input (fresh + cached), NOT output
+ input_tokens = usage.get("input_tokens", 0)
+ output_tokens = usage.get("output_tokens", 0)
+ cache_read = usage.get("cache_read_input_tokens", 0)
+ cache_create = usage.get("cache_creation_input_tokens", 0)
+
+ total_context = input_tokens + cache_read + cache_create
+ used_pct = min(100.0, (total_context / context_size) * 100)
+ remaining_pct = max(0.0, 100.0 - used_pct)
+
+ context_data = ContextData(
+ used_percentage=round(used_pct, 2),
+ remaining_percentage=round(remaining_pct, 2),
+ context_window_size=context_size,
+ total_input_tokens=total_context,
+ total_output_tokens=output_tokens,
+ cache_read_tokens=cache_read,
+ cache_create_tokens=cache_create,
+ model=model,
+ )
+
+ send_context_update(hook_input.session_id, context_data)
+
+
+def handle_session_start(hook_input: HookInput) -> None:
+ """Handle SessionStart hook - new session began."""
+ debug(f"Session started: {hook_input.session_id}")
+ # Could notify server of new session if needed
+
+
+def handle_session_end(hook_input: HookInput) -> None:
+ """Handle SessionEnd hook - session ended."""
+ debug(f"Session ended: {hook_input.session_id}")
+ # Could notify server of session end if needed
+
+
+def handle_prompt(hook_input: HookInput) -> None:
+ """Handle UserPromptSubmit hook - user sent a message."""
+ debug(f"User prompt for session: {hook_input.session_id}")
+ # Could track prompts if needed
+
+
+def handle_notification(hook_input: HookInput) -> None:
+ """Handle Notification hook - Claude sent a notification."""
+ debug(f"Notification for session: {hook_input.session_id}: {hook_input.reason}")
+
+
+def handle_precompact(hook_input: HookInput) -> None:
+ """Handle PreCompact hook - context compaction about to happen."""
+ debug(f"PreCompact for session: {hook_input.session_id}")
+ # Context is about to be compressed - could log current state
diff --git a/packages/server/src/quickcall_supertrace/hooks/models.py b/packages/server/src/quickcall_supertrace/hooks/models.py
new file mode 100644
index 0000000..b1e85ee
--- /dev/null
+++ b/packages/server/src/quickcall_supertrace/hooks/models.py
@@ -0,0 +1,74 @@
+"""
+Data models for Claude Code hook input.
+
+Claude Code passes JSON to hooks via stdin with session context,
+tool information, and other metadata.
+
+See: https://docs.anthropic.com/en/docs/claude-code/hooks
+"""
+
+from typing import Any
+
+from pydantic import BaseModel, Field
+
+
+class HookInput(BaseModel):
+ """
+ Input data passed by Claude Code to hooks via stdin.
+
+ Common fields across all hook events:
+ - session_id: Unique session identifier
+ - transcript_path: Path to the JSONL transcript file
+ - cwd: Current working directory
+ - hook_event_name: Name of the hook event
+
+ Event-specific fields vary by hook type.
+ """
+ # Common fields
+ session_id: str = Field(..., description="Unique session identifier")
+ transcript_path: str | None = Field(default=None, description="Path to transcript JSONL file")
+ cwd: str | None = Field(default=None, description="Current working directory")
+ hook_event_name: str | None = Field(default=None, description="Hook event type")
+ permission_mode: str | None = Field(default=None, description="Permission mode (ask/allow)")
+
+ # Tool-related fields (PreToolUse, PostToolUse)
+ tool_name: str | None = Field(default=None, description="Name of the tool being used")
+ tool_input: dict[str, Any] | None = Field(default=None, description="Tool input parameters")
+ tool_result: Any | None = Field(default=None, description="Tool execution result")
+ tool_response: Any | None = Field(default=None, description="Tool response (alias for result)")
+
+ # User prompt fields (UserPromptSubmit)
+ prompt: str | None = Field(default=None, alias="user_prompt", description="User's prompt text")
+
+ # Stop/completion fields
+ reason: str | None = Field(default=None, description="Stop reason or notification text")
+
+ # Context/usage fields (may be present in various events)
+ model: str | None = Field(default=None, description="Model identifier (e.g., claude-3-opus)")
+ usage: dict[str, Any] | None = Field(default=None, description="Token usage data")
+
+ # Image fields
+ images: list[dict[str, Any]] | None = Field(default=None, description="Images in the message")
+ imagePasteIds: list[int] | None = Field(default=None, description="Image paste IDs")
+
+ # Thinking metadata
+ thinkingMetadata: dict[str, Any] | None = Field(default=None, description="Extended thinking metadata")
+
+ class Config:
+ extra = "allow" # Allow additional fields we don't explicitly model
+
+
+class ContextData(BaseModel):
+ """
+ Context window data to send to QuickCall SuperTrace server.
+
+ Matches the ContextUpdateRequest schema in routes/sessions.py.
+ """
+ used_percentage: float = Field(..., ge=0, le=100)
+ remaining_percentage: float = Field(default=0, ge=0, le=100)
+ context_window_size: int = Field(default=200000, gt=0)
+ total_input_tokens: int = Field(default=0, ge=0)
+ total_output_tokens: int = Field(default=0, ge=0)
+ cache_read_tokens: int = Field(default=0, ge=0)
+ cache_create_tokens: int = Field(default=0, ge=0)
+ model: str | None = Field(default=None)
diff --git a/packages/server/src/quickcall_supertrace/hooks/setup.py b/packages/server/src/quickcall_supertrace/hooks/setup.py
new file mode 100644
index 0000000..1a38659
--- /dev/null
+++ b/packages/server/src/quickcall_supertrace/hooks/setup.py
@@ -0,0 +1,260 @@
+"""
+Auto-registration of Claude Code hooks.
+
+When QuickCall SuperTrace starts, this module configures Claude Code
+to send hook events to the supertrace-hook CLI.
+
+This eliminates manual setup - users just run quickcall-supertrace
+and hooks are automatically configured.
+"""
+
+import json
+import logging
+import shutil
+import sys
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+# Claude Code settings path
+CLAUDE_SETTINGS_PATH = Path.home() / ".claude" / "settings.json"
+
+# Marker to identify our hooks
+SUPERTRACE_HOOK_MARKER = "quickcall-supertrace-hook"
+
+
+def get_hook_command_path() -> str:
+ """
+ Find the full path to quickcall-supertrace-hook command.
+
+ This is needed because Claude Code runs hooks in a shell that
+ may not have the same PATH as the user's terminal.
+ """
+ # The hook CLI is installed alongside the main package
+ # Find it relative to the current Python executable
+ python_bin_dir = Path(sys.executable).parent
+ hook_path = python_bin_dir / "quickcall-supertrace-hook"
+
+ if hook_path.exists():
+ return str(hook_path)
+
+ # Fallback: try to find via shutil.which
+ import shutil as sh
+ found = sh.which("quickcall-supertrace-hook")
+ if found:
+ return found
+
+ # Last resort: assume it's in PATH (may fail but worth trying)
+ logger.warning("Could not find quickcall-supertrace-hook path, using bare command")
+ return "quickcall-supertrace-hook"
+
+
+def get_statusline_command_path() -> str:
+ """
+ Find the full path to quickcall-supertrace-statusline command.
+ """
+ python_bin_dir = Path(sys.executable).parent
+ statusline_path = python_bin_dir / "quickcall-supertrace-statusline"
+
+ if statusline_path.exists():
+ return str(statusline_path)
+
+ # Fallback: try to find via shutil.which
+ found = shutil.which("quickcall-supertrace-statusline")
+ if found:
+ return found
+
+ logger.warning("Could not find quickcall-supertrace-statusline path")
+ return "quickcall-supertrace-statusline"
+
+
+def get_supertrace_hooks() -> dict:
+ """Get hook configuration (kept for backwards compatibility)."""
+ hook_cmd = get_hook_command_path()
+ return {
+ "Stop": [
+ {
+ "matcher": "*",
+ "hooks": [
+ {
+ "type": "command",
+ "command": f"{hook_cmd} stop",
+ "timeout": 5
+ }
+ ]
+ }
+ ]
+ }
+
+
+def get_statusline_config() -> dict:
+ """Get statusline configuration with the correct command path."""
+ return {
+ "command": get_statusline_command_path()
+ }
+
+
+def get_claude_settings() -> dict:
+ """Read Claude Code settings, return empty dict if not found."""
+ if not CLAUDE_SETTINGS_PATH.exists():
+ return {}
+
+ try:
+ with open(CLAUDE_SETTINGS_PATH) as f:
+ return json.load(f)
+ except (json.JSONDecodeError, IOError) as e:
+ logger.warning(f"Failed to read Claude settings: {e}")
+ return {}
+
+
+def save_claude_settings(settings: dict) -> bool:
+ """Save Claude Code settings with backup."""
+ try:
+ # Ensure directory exists
+ CLAUDE_SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
+
+ # Backup existing settings
+ if CLAUDE_SETTINGS_PATH.exists():
+ backup_path = CLAUDE_SETTINGS_PATH.with_suffix(".json.bak")
+ shutil.copy2(CLAUDE_SETTINGS_PATH, backup_path)
+
+ # Write new settings
+ with open(CLAUDE_SETTINGS_PATH, "w") as f:
+ json.dump(settings, f, indent=2)
+
+ return True
+ except IOError as e:
+ logger.error(f"Failed to save Claude settings: {e}")
+ return False
+
+
+def is_supertrace_hook(hook_config: dict) -> bool:
+ """Check if a hook configuration is from SuperTrace."""
+ hooks = hook_config.get("hooks", [])
+ for hook in hooks:
+ command = hook.get("command", "")
+ if SUPERTRACE_HOOK_MARKER in command:
+ return True
+ return False
+
+
+def is_supertrace_statusline(command: str) -> bool:
+ """Check if a statusline command is from SuperTrace."""
+ return "quickcall-supertrace-statusline" in command
+
+
+def register_hooks() -> bool:
+ """
+ Register SuperTrace hooks in Claude Code settings.
+
+ Registers Stop hook to capture context/cost data after each response.
+ Note: Cost calculation is approximate since hooks don't receive
+ pre-calculated values from Claude Code.
+
+ Returns True if registered or already present.
+ """
+ settings = get_claude_settings()
+
+ if "hooks" not in settings:
+ settings["hooks"] = {}
+
+ hooks = settings["hooks"]
+ supertrace_hooks = get_supertrace_hooks()
+ modified = False
+
+ for event_type, hook_configs in supertrace_hooks.items():
+ if event_type not in hooks:
+ hooks[event_type] = []
+
+ # Check if our hook is already registered
+ already_registered = any(is_supertrace_hook(h) for h in hooks[event_type])
+
+ if not already_registered:
+ hooks[event_type].extend(hook_configs)
+ modified = True
+ logger.info(f"Registered {event_type} hook for QuickCall SuperTrace")
+
+ if modified:
+ if save_claude_settings(settings):
+ logger.info("Claude Code hooks registered successfully")
+ logger.info("Restart Claude Code to activate hooks")
+ return True
+ else:
+ logger.error("Failed to save Claude Code settings")
+ return False
+
+ return True
+
+
+def unregister_hooks() -> bool:
+ """
+ Remove SuperTrace statusline and hooks from Claude Code settings.
+
+ Returns True if removed.
+ """
+ settings = get_claude_settings()
+ modified = False
+
+ # Remove statusline if it's ours
+ statusline = settings.get("statusline", {})
+ if is_supertrace_statusline(statusline.get("command", "")):
+ del settings["statusline"]
+ modified = True
+ logger.info("Removed SuperTrace statusline command")
+
+ # Also remove any legacy hooks
+ if "hooks" not in settings:
+ if modified:
+ return save_claude_settings(settings)
+ return True
+
+ hooks = settings["hooks"]
+
+ for event_type in list(hooks.keys()):
+ original_count = len(hooks[event_type])
+ hooks[event_type] = [h for h in hooks[event_type] if not is_supertrace_hook(h)]
+
+ if len(hooks[event_type]) < original_count:
+ modified = True
+ logger.info(f"Removed {event_type} hook for QuickCall SuperTrace")
+
+ # Remove empty event types
+ if not hooks[event_type]:
+ del hooks[event_type]
+
+ if modified:
+ return save_claude_settings(settings)
+
+ return True
+
+
+def check_hooks_status() -> dict:
+ """
+ Check current status of SuperTrace statusline and hooks.
+
+ Returns dict with status info.
+ """
+ settings = get_claude_settings()
+
+ # Check statusline (primary method)
+ statusline = settings.get("statusline", {})
+ statusline_registered = is_supertrace_statusline(statusline.get("command", ""))
+
+ # Check legacy hooks
+ hooks = settings.get("hooks", {})
+ supertrace_hooks = get_supertrace_hooks()
+
+ hooks_registered = {}
+ for event_type in supertrace_hooks.keys():
+ event_hooks = hooks.get(event_type, [])
+ hooks_registered[event_type] = any(is_supertrace_hook(h) for h in event_hooks)
+
+ return {
+ "settings_path": str(CLAUDE_SETTINGS_PATH),
+ "settings_exists": CLAUDE_SETTINGS_PATH.exists(),
+ "statusline_registered": statusline_registered,
+ "statusline_command": get_statusline_command_path(),
+ "hooks_registered": hooks_registered,
+ "all_registered": all(hooks_registered.values()),
+ "hook_command": get_hook_command_path(),
+ }
diff --git a/packages/server/src/quickcall_supertrace/hooks/statusline.py b/packages/server/src/quickcall_supertrace/hooks/statusline.py
new file mode 100644
index 0000000..46aecf2
--- /dev/null
+++ b/packages/server/src/quickcall_supertrace/hooks/statusline.py
@@ -0,0 +1,138 @@
+#!/usr/bin/env python3
+"""
+Statusline command for Claude Code.
+
+This receives the REAL context and cost data from Claude Code
+and sends it to SuperTrace. Much more accurate than recalculating
+from transcripts.
+
+Usage in ~/.claude/settings.json:
+{
+ "statusline": {
+ "command": "/path/to/quickcall-supertrace-statusline"
+ }
+}
+"""
+
+import json
+import sys
+from pathlib import Path
+from urllib.request import Request, urlopen
+from urllib.error import URLError
+
+# Server configuration
+DEFAULT_SERVER_URL = "http://localhost:7845"
+TIMEOUT_SECONDS = 2
+
+
+def get_server_url() -> str:
+ """Get server URL from env or use default."""
+ import os
+ return os.environ.get("QUICKCALL_SUPERTRACE_URL", DEFAULT_SERVER_URL)
+
+
+def send_context_update(session_id: str, data: dict) -> bool:
+ """Send context update to SuperTrace server."""
+ url = f"{get_server_url()}/api/sessions/{session_id}/context"
+
+ try:
+ payload = json.dumps(data).encode("utf-8")
+ request = Request(
+ url,
+ data=payload,
+ headers={"Content-Type": "application/json"},
+ method="POST",
+ )
+ with urlopen(request, timeout=TIMEOUT_SECONDS) as response:
+ return response.status == 200
+ except (URLError, Exception):
+ return False
+
+
+def format_statusline(data: dict) -> str:
+ """Format the statusline output for Claude Code CLI display."""
+ model = data.get("model", {}).get("display_name", "")
+ ctx = data.get("context_window", {})
+ cost = data.get("cost", {})
+ workspace = data.get("workspace", {})
+
+ used_pct = ctx.get("used_percentage", 0)
+ total_cost = cost.get("total_cost_usd", 0)
+ cwd = workspace.get("current_dir", "")
+
+ # Directory basename
+ dir_base = Path(cwd).name if cwd else ""
+
+ # Progress bar (8 segments)
+ used_int = int(used_pct)
+ filled = min(8, (used_int + 12) // 13)
+ bar = "█" * filled + "░" * (8 - filled)
+
+ # Color codes
+ if used_int < 50:
+ bar_color = "\033[32m" # Green
+ elif used_int < 75:
+ bar_color = "\033[33m" # Yellow
+ else:
+ bar_color = "\033[31m" # Red
+
+ reset = "\033[0m"
+ dim = "\033[90m"
+ blue = "\033[34m"
+ magenta = "\033[35m"
+ yellow = "\033[33m"
+
+ # Build status line
+ parts = []
+ if dir_base:
+ parts.append(f"{blue}{dir_base}{reset}")
+ if model:
+ parts.append(f"{magenta}{model}{reset}")
+ parts.append(f"{bar_color}[{bar}]{reset} {dim}{used_int}%{reset}")
+ if total_cost > 0:
+ parts.append(f"{yellow}${total_cost:.2f}{reset}")
+
+ return f" {dim}•{reset} ".join(parts)
+
+
+def main():
+ """Main entry point for statusline command."""
+ # Read JSON from stdin
+ try:
+ input_data = json.load(sys.stdin)
+ except json.JSONDecodeError:
+ return
+
+ # Extract session ID from transcript path
+ transcript_path = input_data.get("transcript_path", "")
+ if transcript_path:
+ # Session ID is the filename without extension
+ session_id = Path(transcript_path).stem
+ else:
+ session_id = input_data.get("session_id", "")
+
+ # Extract the real data from Claude Code
+ ctx = input_data.get("context_window", {})
+ cost = input_data.get("cost", {})
+ model_info = input_data.get("model", {})
+
+ # Send to SuperTrace if we have a session ID
+ if session_id:
+ context_data = {
+ "used_percentage": ctx.get("used_percentage", 0),
+ "remaining_percentage": ctx.get("remaining_percentage", 100),
+ "context_window_size": ctx.get("context_window_size", 200000),
+ "total_input_tokens": ctx.get("total_input_tokens", 0),
+ "total_output_tokens": ctx.get("total_output_tokens", 0),
+ "cache_read_tokens": ctx.get("current_usage", {}).get("cache_read_input_tokens", 0),
+ "cache_create_tokens": ctx.get("current_usage", {}).get("cache_creation_input_tokens", 0),
+ "model": model_info.get("id") or model_info.get("display_name"),
+ }
+ send_context_update(session_id, context_data)
+
+ # Output the formatted statusline for CLI display
+ print(format_statusline(input_data))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/packages/server/src/quickcall_supertrace/ingest/poller.py b/packages/server/src/quickcall_supertrace/ingest/poller.py
index 5e3e8fc..a8feaa4 100644
--- a/packages/server/src/quickcall_supertrace/ingest/poller.py
+++ b/packages/server/src/quickcall_supertrace/ingest/poller.py
@@ -120,10 +120,9 @@ async def _process_session_files(
)
results.append(result)
- # Broadcast update only to clients subscribed to this session
- # (prevents UI disruption for users viewing other sessions)
+ # Broadcast update to ALL clients so they can track unread sessions
if options.broadcast_updates and result.messages_imported > 0 and not result.error:
- await manager.broadcast_to_session(result.session_id, {
+ await manager.broadcast_to_all({
"type": "session_updated",
"session_id": result.session_id,
"new_messages": result.messages_imported,
diff --git a/packages/server/src/quickcall_supertrace/main.py b/packages/server/src/quickcall_supertrace/main.py
index ff67bd1..9e600e0 100644
--- a/packages/server/src/quickcall_supertrace/main.py
+++ b/packages/server/src/quickcall_supertrace/main.py
@@ -20,6 +20,7 @@
from fastapi.responses import FileResponse
from .db import get_db
+from .hooks.setup import register_hooks
# Static files directory (bundled frontend)
STATIC_DIR = Path(__file__).parent / "static"
@@ -49,6 +50,11 @@ async def lifespan(app: FastAPI):
logger.info("Starting QuickCall SuperTrace server...")
await get_db()
+ # Auto-register Claude Code hooks (unless disabled)
+ auto_hooks = os.environ.get("QUICKCALL_SUPERTRACE_AUTO_HOOKS", "true").lower() == "true"
+ if auto_hooks:
+ register_hooks()
+
# Start background poller if enabled
enable_poller = os.environ.get("QUICKCALL_SUPERTRACE_ENABLE_POLLER", "true").lower() == "true"
if enable_poller:
diff --git a/packages/server/src/quickcall_supertrace/routes/sessions.py b/packages/server/src/quickcall_supertrace/routes/sessions.py
index 88b4c5d..c592c00 100644
--- a/packages/server/src/quickcall_supertrace/routes/sessions.py
+++ b/packages/server/src/quickcall_supertrace/routes/sessions.py
@@ -2,22 +2,68 @@
Session API routes.
Provides endpoints for listing sessions, getting session details,
-fetching session events, and exporting sessions.
+fetching session events, exporting sessions, and context window tracking.
-Related: db/client.py (queries), export.py (export logic)
+Related: db/client.py (queries), export.py (export logic), ws/broadcast.py (WebSocket)
"""
import json
+from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import APIRouter, HTTPException, Response
+from pydantic import BaseModel, Field
from ..db import get_db
+from ..ws import manager
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
+# =============================================================================
+# Context Window Tracking Models
+# =============================================================================
+
+class ContextUpdateRequest(BaseModel):
+ """
+ Request body for POST /api/sessions/{session_id}/context.
+
+ Expected payload from Claude Code hooks:
+ {
+ "used_percentage": 42.5,
+ "remaining_percentage": 57.5,
+ "context_window_size": 200000,
+ "total_input_tokens": 85000,
+ "total_output_tokens": 15000
+ }
+ """
+ used_percentage: float = Field(..., ge=0, le=100, description="Percentage of context window used (0-100)")
+ remaining_percentage: float = Field(default=None, ge=0, le=100, description="Percentage remaining")
+ context_window_size: int = Field(default=200000, gt=0, description="Max context window size")
+ total_input_tokens: int = Field(default=0, ge=0, description="Total input tokens consumed")
+ total_output_tokens: int = Field(default=0, ge=0, description="Total output tokens generated")
+ cache_read_tokens: int = Field(default=0, ge=0, description="Cache read tokens (optional)")
+ cache_create_tokens: int = Field(default=0, ge=0, description="Cache creation tokens (optional)")
+ model: str | None = Field(default=None, description="Model identifier (optional)")
+
+
+class ContextResponse(BaseModel):
+ """Response model for context window data."""
+ id: int
+ session_id: str
+ timestamp: str
+ used_percentage: float
+ remaining_percentage: float
+ context_window_size: int
+ total_input_tokens: int
+ total_output_tokens: int
+ cache_read_tokens: int
+ cache_create_tokens: int
+ model: str | None
+ created_at: str
+
+
@router.get("")
async def list_sessions(limit: int = 50, offset: int = 0) -> dict[str, Any]:
"""List all sessions, most recent first."""
@@ -75,6 +121,7 @@ def _slim_event(event: dict) -> dict:
transcript = data.get("transcript", [])
slimmed_transcript = _slim_transcript(transcript)
slim["data"] = {
+ "model": data.get("model"), # Model name for status bar
"token_usage": data.get("token_usage"),
"stop_reason": data.get("stop_reason"),
"transcript": slimmed_transcript,
@@ -339,3 +386,107 @@ def _export_markdown(session: dict, events: list[dict]) -> str:
lines.append("")
return "\n".join(lines)
+
+
+# =============================================================================
+# Context Window Tracking Endpoints
+# =============================================================================
+
+
+@router.post("/{session_id}/context")
+async def store_context_snapshot(
+ session_id: str,
+ context: ContextUpdateRequest,
+) -> dict[str, Any]:
+ """
+ Store a context window snapshot for a session.
+
+ Called by Claude Code hooks to report current context window usage.
+ Broadcasts the update via WebSocket to subscribed clients.
+
+ Args:
+ session_id: Session to store context for
+ context: Context window data including token counts and percentages
+
+ Returns:
+ Stored context record with ID and timestamp
+ """
+ db = await get_db()
+
+ # Generate timestamp for this snapshot
+ timestamp = datetime.now(timezone.utc).isoformat()
+
+ # Compute remaining percentage if not provided
+ remaining = context.remaining_percentage
+ if remaining is None:
+ remaining = 100.0 - context.used_percentage
+
+ # Store in database
+ context_id = await db.save_session_context(
+ session_id=session_id,
+ timestamp=timestamp,
+ used_percentage=context.used_percentage,
+ remaining_percentage=remaining,
+ context_window_size=context.context_window_size,
+ total_input_tokens=context.total_input_tokens,
+ total_output_tokens=context.total_output_tokens,
+ cache_read_tokens=context.cache_read_tokens,
+ cache_create_tokens=context.cache_create_tokens,
+ model=context.model,
+ )
+
+ # Prepare response data
+ context_data = {
+ "id": context_id,
+ "session_id": session_id,
+ "timestamp": timestamp,
+ "used_percentage": context.used_percentage,
+ "remaining_percentage": remaining,
+ "context_window_size": context.context_window_size,
+ "total_input_tokens": context.total_input_tokens,
+ "total_output_tokens": context.total_output_tokens,
+ "cache_read_tokens": context.cache_read_tokens,
+ "cache_create_tokens": context.cache_create_tokens,
+ "model": context.model,
+ }
+
+ # Broadcast update via WebSocket to subscribed clients
+ await manager.broadcast_to_session(
+ session_id,
+ {
+ "type": "context_updated",
+ "session_id": session_id,
+ "data": context_data,
+ }
+ )
+
+ return {"status": "ok", "context": context_data}
+
+
+@router.get("/{session_id}/context")
+async def get_context_snapshots(
+ session_id: str,
+ limit: int = 100,
+ latest_only: bool = False,
+) -> dict[str, Any]:
+ """
+ Get context window snapshots for a session.
+
+ Args:
+ session_id: Session to get context for
+ limit: Maximum number of snapshots to return (default 100)
+ latest_only: If True, return only the most recent snapshot
+
+ Returns:
+ List of context snapshots or single latest snapshot
+ """
+ db = await get_db()
+
+ if latest_only:
+ context = await db.get_latest_session_context(session_id)
+ if not context:
+ return {"context": None, "count": 0}
+ return {"context": context, "count": 1}
+
+ snapshots = await db.get_session_context(session_id, limit=limit)
+ return {"snapshots": snapshots, "count": len(snapshots)}
diff --git a/packages/server/tests/test_context_tracking.py b/packages/server/tests/test_context_tracking.py
new file mode 100644
index 0000000..744a4f9
--- /dev/null
+++ b/packages/server/tests/test_context_tracking.py
@@ -0,0 +1,491 @@
+"""
+Tests for context window tracking (Issue #90).
+
+Tests:
+- Database CRUD operations for session_context table
+- POST /api/sessions/{session_id}/context endpoint
+- GET /api/sessions/{session_id}/context endpoint
+- WebSocket broadcasting on context updates
+"""
+
+import asyncio
+import tempfile
+from pathlib import Path
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from fastapi.testclient import TestClient
+
+from quickcall_supertrace.db.schema import init_db
+from quickcall_supertrace.db.client import Database
+
+
+def run_async(coro):
+ """Helper to run async functions in sync tests."""
+ return asyncio.get_event_loop().run_until_complete(coro)
+
+
+@pytest.fixture
+def temp_db_path():
+ """Create a temporary database path."""
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
+ path = Path(f.name)
+ yield path
+ # Cleanup
+ if path.exists():
+ path.unlink()
+ for suffix in ["-wal", "-shm"]:
+ wal_path = Path(str(path) + suffix)
+ if wal_path.exists():
+ wal_path.unlink()
+
+
+# =============================================================================
+# Database Client Tests
+# =============================================================================
+
+
+class TestSaveSessionContext:
+ """Tests for save_session_context method."""
+
+ def test_saves_context_snapshot(self, temp_db_path):
+ """Should save context snapshot and return ID."""
+ async def _test():
+ await init_db(str(temp_db_path))
+ db = Database(temp_db_path)
+ await db.connect()
+
+ context_id = await db.save_session_context(
+ session_id="test-session",
+ timestamp="2026-01-22T10:00:00.000Z",
+ used_percentage=42.5,
+ context_window_size=200000,
+ total_input_tokens=85000,
+ total_output_tokens=15000,
+ )
+
+ assert context_id > 0
+
+ # Verify data was saved
+ cursor = await db.conn.execute(
+ "SELECT * FROM session_context WHERE id = ?", (context_id,)
+ )
+ row = await cursor.fetchone()
+
+ assert row is not None
+ assert row["session_id"] == "test-session"
+ assert row["used_percentage"] == 42.5
+ assert row["remaining_percentage"] == 57.5 # 100 - 42.5
+ assert row["context_window_size"] == 200000
+ assert row["total_input_tokens"] == 85000
+ assert row["total_output_tokens"] == 15000
+
+ await db.close()
+
+ run_async(_test())
+
+ def test_computes_remaining_percentage(self, temp_db_path):
+ """Should compute remaining_percentage when not provided."""
+ async def _test():
+ await init_db(str(temp_db_path))
+ db = Database(temp_db_path)
+ await db.connect()
+
+ await db.save_session_context(
+ session_id="test-session",
+ timestamp="2026-01-22T10:00:00.000Z",
+ used_percentage=75.0,
+ )
+
+ result = await db.get_latest_session_context("test-session")
+ assert result["remaining_percentage"] == 25.0
+
+ await db.close()
+
+ run_async(_test())
+
+ def test_stores_cache_tokens(self, temp_db_path):
+ """Should store cache read and create tokens."""
+ async def _test():
+ await init_db(str(temp_db_path))
+ db = Database(temp_db_path)
+ await db.connect()
+
+ await db.save_session_context(
+ session_id="test-session",
+ timestamp="2026-01-22T10:00:00.000Z",
+ used_percentage=50.0,
+ cache_read_tokens=5000,
+ cache_create_tokens=1000,
+ )
+
+ result = await db.get_latest_session_context("test-session")
+ assert result["cache_read_tokens"] == 5000
+ assert result["cache_create_tokens"] == 1000
+
+ await db.close()
+
+ run_async(_test())
+
+ def test_stores_model_name(self, temp_db_path):
+ """Should store model name when provided."""
+ async def _test():
+ await init_db(str(temp_db_path))
+ db = Database(temp_db_path)
+ await db.connect()
+
+ await db.save_session_context(
+ session_id="test-session",
+ timestamp="2026-01-22T10:00:00.000Z",
+ used_percentage=30.0,
+ model="claude-opus-4-5-20251101",
+ )
+
+ result = await db.get_latest_session_context("test-session")
+ assert result["model"] == "claude-opus-4-5-20251101"
+
+ await db.close()
+
+ run_async(_test())
+
+
+class TestGetSessionContext:
+ """Tests for get_session_context method."""
+
+ def test_returns_snapshots_ordered_by_timestamp_desc(self, temp_db_path):
+ """Should return snapshots ordered by timestamp (newest first)."""
+ async def _test():
+ await init_db(str(temp_db_path))
+ db = Database(temp_db_path)
+ await db.connect()
+
+ # Insert multiple snapshots
+ for i in range(5):
+ await db.save_session_context(
+ session_id="test-session",
+ timestamp=f"2026-01-22T10:0{i}:00.000Z",
+ used_percentage=10.0 * (i + 1),
+ )
+
+ snapshots = await db.get_session_context("test-session")
+
+ assert len(snapshots) == 5
+ # Should be newest first
+ assert snapshots[0]["used_percentage"] == 50.0
+ assert snapshots[4]["used_percentage"] == 10.0
+
+ await db.close()
+
+ run_async(_test())
+
+ def test_respects_limit(self, temp_db_path):
+ """Should respect limit parameter."""
+ async def _test():
+ await init_db(str(temp_db_path))
+ db = Database(temp_db_path)
+ await db.connect()
+
+ # Insert 10 snapshots
+ for i in range(10):
+ await db.save_session_context(
+ session_id="test-session",
+ timestamp=f"2026-01-22T10:{i:02d}:00.000Z",
+ used_percentage=float(i),
+ )
+
+ snapshots = await db.get_session_context("test-session", limit=3)
+
+ assert len(snapshots) == 3
+
+ await db.close()
+
+ run_async(_test())
+
+ def test_returns_empty_for_unknown_session(self, temp_db_path):
+ """Should return empty list for session with no context."""
+ async def _test():
+ await init_db(str(temp_db_path))
+ db = Database(temp_db_path)
+ await db.connect()
+
+ snapshots = await db.get_session_context("unknown-session")
+ assert len(snapshots) == 0
+
+ await db.close()
+
+ run_async(_test())
+
+
+class TestGetLatestSessionContext:
+ """Tests for get_latest_session_context method."""
+
+ def test_returns_most_recent_snapshot(self, temp_db_path):
+ """Should return only the most recent snapshot."""
+ async def _test():
+ await init_db(str(temp_db_path))
+ db = Database(temp_db_path)
+ await db.connect()
+
+ # Insert multiple snapshots
+ for i in range(5):
+ await db.save_session_context(
+ session_id="test-session",
+ timestamp=f"2026-01-22T10:0{i}:00.000Z",
+ used_percentage=10.0 * (i + 1),
+ )
+
+ result = await db.get_latest_session_context("test-session")
+
+ assert result is not None
+ assert result["used_percentage"] == 50.0 # Last one inserted
+
+ await db.close()
+
+ run_async(_test())
+
+ def test_returns_none_for_unknown_session(self, temp_db_path):
+ """Should return None for session with no context."""
+ async def _test():
+ await init_db(str(temp_db_path))
+ db = Database(temp_db_path)
+ await db.connect()
+
+ result = await db.get_latest_session_context("unknown-session")
+ assert result is None
+
+ await db.close()
+
+ run_async(_test())
+
+
+class TestDeleteSessionContext:
+ """Tests for delete_session_context method."""
+
+ def test_deletes_all_context_for_session(self, temp_db_path):
+ """Should delete all context snapshots for a session."""
+ async def _test():
+ await init_db(str(temp_db_path))
+ db = Database(temp_db_path)
+ await db.connect()
+
+ # Insert multiple snapshots
+ for i in range(5):
+ await db.save_session_context(
+ session_id="test-session",
+ timestamp=f"2026-01-22T10:0{i}:00.000Z",
+ used_percentage=float(i),
+ )
+
+ # Delete
+ deleted_count = await db.delete_session_context("test-session")
+ assert deleted_count == 5
+
+ # Verify gone
+ snapshots = await db.get_session_context("test-session")
+ assert len(snapshots) == 0
+
+ await db.close()
+
+ run_async(_test())
+
+ def test_returns_zero_for_unknown_session(self, temp_db_path):
+ """Should return 0 when deleting from unknown session."""
+ async def _test():
+ await init_db(str(temp_db_path))
+ db = Database(temp_db_path)
+ await db.connect()
+
+ deleted_count = await db.delete_session_context("unknown-session")
+ assert deleted_count == 0
+
+ await db.close()
+
+ run_async(_test())
+
+
+# =============================================================================
+# API Endpoint Tests
+# =============================================================================
+
+
+class TestContextEndpoints:
+ """Tests for context tracking API endpoints."""
+
+ @pytest.fixture
+ def client(self, temp_db_path):
+ """Create test client with mocked database."""
+ from quickcall_supertrace.main import app
+ from quickcall_supertrace.routes import sessions as sessions_module
+
+ # Create mock database
+ async def _setup():
+ await init_db(str(temp_db_path))
+ test_db = Database(temp_db_path)
+ await test_db.connect()
+ return test_db
+
+ test_db = run_async(_setup())
+
+ # Mock get_db at the point of use (in sessions module)
+ async def mock_get_db():
+ return test_db
+
+ # Patch in the sessions module where it's imported
+ with patch.object(sessions_module, 'get_db', mock_get_db):
+ with TestClient(app) as client:
+ yield client
+
+ # Cleanup
+ run_async(test_db.close())
+
+ def test_post_context_creates_snapshot(self, client):
+ """POST /api/sessions/{id}/context should create snapshot."""
+ response = client.post(
+ "/api/sessions/test-session/context",
+ json={
+ "used_percentage": 42.5,
+ "context_window_size": 200000,
+ "total_input_tokens": 85000,
+ "total_output_tokens": 15000,
+ },
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["status"] == "ok"
+ assert "context" in data
+ assert data["context"]["used_percentage"] == 42.5
+ assert data["context"]["remaining_percentage"] == 57.5
+ assert data["context"]["session_id"] == "test-session"
+
+ def test_post_context_computes_remaining(self, client):
+ """POST should compute remaining_percentage if not provided."""
+ response = client.post(
+ "/api/sessions/test-session/context",
+ json={"used_percentage": 75.0},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["context"]["remaining_percentage"] == 25.0
+
+ def test_post_context_validates_percentage_range(self, client):
+ """POST should reject percentage outside 0-100 range."""
+ response = client.post(
+ "/api/sessions/test-session/context",
+ json={"used_percentage": 150.0},
+ )
+
+ assert response.status_code == 422 # Validation error
+
+ def test_get_context_returns_snapshots(self, client):
+ """GET /api/sessions/{id}/context should return snapshots."""
+ # Create some snapshots first
+ for i in range(3):
+ client.post(
+ "/api/sessions/test-session/context",
+ json={"used_percentage": 10.0 * (i + 1)},
+ )
+
+ response = client.get("/api/sessions/test-session/context")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "snapshots" in data
+ assert data["count"] == 3
+
+ def test_get_context_latest_only(self, client):
+ """GET with latest_only=true should return single snapshot."""
+ # Create multiple snapshots
+ for i in range(3):
+ client.post(
+ "/api/sessions/test-session/context",
+ json={"used_percentage": 10.0 * (i + 1)},
+ )
+
+ response = client.get("/api/sessions/test-session/context?latest_only=true")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "context" in data
+ assert data["count"] == 1
+ assert data["context"]["used_percentage"] == 30.0 # Last one
+
+ def test_get_context_respects_limit(self, client):
+ """GET should respect limit parameter."""
+ # Create 10 snapshots
+ for i in range(10):
+ client.post(
+ "/api/sessions/test-session/context",
+ json={"used_percentage": float(i)},
+ )
+
+ response = client.get("/api/sessions/test-session/context?limit=5")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["count"] == 5
+
+ def test_get_context_empty_session(self, client):
+ """GET should return empty list for session with no context."""
+ response = client.get("/api/sessions/unknown-session/context")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["snapshots"] == []
+ assert data["count"] == 0
+
+
+# =============================================================================
+# Schema Migration Tests
+# =============================================================================
+
+
+class TestContextMigration:
+ """Tests for session_context table migration."""
+
+ def test_migration_creates_table(self, temp_db_path):
+ """Migration v6 should create session_context table."""
+ async def _test():
+ await init_db(str(temp_db_path))
+
+ import aiosqlite
+ async with aiosqlite.connect(str(temp_db_path)) as conn:
+ # Check table exists
+ cursor = await conn.execute(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='session_context'"
+ )
+ result = await cursor.fetchone()
+ assert result is not None, "session_context table should exist"
+
+ # Check columns
+ cursor = await conn.execute("PRAGMA table_info(session_context)")
+ columns = {row[1] for row in await cursor.fetchall()}
+
+ expected_columns = {
+ "id", "session_id", "timestamp", "used_percentage",
+ "remaining_percentage", "context_window_size",
+ "total_input_tokens", "total_output_tokens",
+ "cache_read_tokens", "cache_create_tokens",
+ "model", "created_at"
+ }
+ assert expected_columns.issubset(columns)
+
+ run_async(_test())
+
+ def test_migration_creates_indexes(self, temp_db_path):
+ """Migration v6 should create indexes."""
+ async def _test():
+ await init_db(str(temp_db_path))
+
+ import aiosqlite
+ async with aiosqlite.connect(str(temp_db_path)) as conn:
+ cursor = await conn.execute(
+ "SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_context%'"
+ )
+ indexes = {row[0] for row in await cursor.fetchall()}
+
+ assert "idx_context_session" in indexes
+ assert "idx_context_session_time" in indexes
+
+ run_async(_test())
diff --git a/packages/web/package.json b/packages/web/package.json
index 6264b55..0e8bd37 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -1,7 +1,7 @@
{
"name": "quickcall-supertrace-web",
"private": true,
- "version": "0.2.10",
+ "version": "0.2.11",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx
index 5033e0a..fdd2108 100644
--- a/packages/web/src/App.tsx
+++ b/packages/web/src/App.tsx
@@ -23,6 +23,7 @@ import {
getSession,
getSessionEvents,
getSessionMetrics,
+ getSessionContext,
searchEvents,
triggerIngest,
type Session,
@@ -30,6 +31,8 @@ import {
type MetricsResponse,
type IntentResponse,
} from './api/client';
+import type { ContextUpdatedMessage } from './hooks/useWebSocket';
+import type { ContextData } from './components/ContextWindowBar';
function App() {
const [sessions, setSessions] = useState([]);
@@ -42,6 +45,9 @@ function App() {
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [analyticsExpanded, setAnalyticsExpanded] = useLocalStorage('supertrace-analytics-expanded', true);
+ // Unread session tracking - persisted to localStorage
+ const [unreadSessionIds, setUnreadSessionIds] = useLocalStorage('supertrace-unread-sessions', []);
+
// Panel widths (persisted) - responsive to viewport
// Default: analytics and chat split remaining space roughly equally
const getDefaultSessionListWidth = () => typeof window !== 'undefined' && window.innerWidth < 1024 ? 180 : 224;
@@ -125,6 +131,27 @@ function App() {
handleIntentChanged(response);
}, [selectedSessionId, handleIntentChanged]);
+ // Handle WebSocket context updated - update context data for current session
+ const handleContextUpdated = useCallback((message: ContextUpdatedMessage) => {
+ console.log('[App] Context updated via WebSocket:', message.session_id, message.data);
+ // Only update if it's for the currently selected session
+ if (message.session_id !== selectedSessionId) return;
+
+ // Update context data from WebSocket message
+ setContextData(message.data as ContextData);
+ }, [selectedSessionId]);
+
+ // Handle session selection - also clears unread status
+ const handleSelectSession = useCallback((sessionId: string | null) => {
+ // Track if this session was unread (used in useEffect to set hasNewMessages)
+ selectedSessionWasUnreadRef.current = sessionId ? unreadSessionIds.includes(sessionId) : false;
+ setSelectedSessionId(sessionId);
+ // Clear unread status when session is selected
+ if (sessionId) {
+ setUnreadSessionIds(prev => prev.filter(id => id !== sessionId));
+ }
+ }, [setSelectedSessionId, setUnreadSessionIds, unreadSessionIds]);
+
// Resize handlers
const handleSessionListResize = useCallback((deltaX: number) => {
const maxWidth = window.innerWidth < 1024 ? 200 : 400;
@@ -151,6 +178,9 @@ function App() {
const [hasNewMessages, setHasNewMessages] = useState(false);
const [isLoadingAllForSearch, setIsLoadingAllForSearch] = useState(false);
+ // Track if the session being selected was unread (to show "New messages" button)
+ const selectedSessionWasUnreadRef = useRef(false);
+
// Handle scroll to event from analytics panel
// If event not loaded, load all events first then scroll
const handleScrollToEvent = useCallback(async (eventId: number) => {
@@ -194,6 +224,10 @@ function App() {
const [metricsLoading, setMetricsLoading] = useState(false);
const [metricsHoursBack, setMetricsHoursBack] = useState(0); // Default: all time
+ // Context window data - managed here to receive WebSocket updates
+ const [contextData, setContextData] = useState(null);
+ const [isLoadingContext, setIsLoadingContext] = useState(false);
+
// Handle new session imported via WebSocket - refresh session list
const handleSessionImported = useCallback(async (sessionId: string) => {
console.log('[App] Session imported:', sessionId);
@@ -209,40 +243,52 @@ function App() {
const handleSessionUpdated = useCallback(async (sessionId: string, newMessages: number) => {
console.log('[App] Session updated:', sessionId, 'new messages:', newMessages);
- // Only refresh session list if it's the current session or no session selected
- // This prevents UI disruption when viewing an inactive session while another is active
- if (sessionId === selectedSessionId || !selectedSessionId) {
- try {
- const data = await getSessions();
- setSessions(data.sessions);
- } catch (error) {
- console.error('Failed to refresh sessions:', error);
- }
+ // Mark session as unread if it's NOT the currently selected session
+ if (sessionId !== selectedSessionId && newMessages > 0) {
+ setUnreadSessionIds(prev => {
+ if (prev.includes(sessionId)) return prev;
+ return [...prev, sessionId];
+ });
+ }
+
+ // Always refresh session list to update order
+ try {
+ const data = await getSessions();
+ setSessions(data.sessions);
+ } catch (error) {
+ console.error('Failed to refresh sessions:', error);
}
- // If this is the currently selected session, reload events and metrics
+ // If this is the currently selected session, reload events, metrics, and context
if (sessionId === selectedSessionId) {
console.log('[App] Reloading current session data...');
setHasNewMessages(true); // Signal to SessionView that new messages arrived
try {
- const [sessionData, metricsData] = await Promise.all([
+ const [sessionData, metricsData, contextResponse] = await Promise.all([
getSession(sessionId, 30),
getSessionMetrics(sessionId, metricsHoursBack),
+ getSessionContext(sessionId),
]);
setSelectedSession(sessionData.session);
setEvents(sessionData.events);
setTotalEvents(sessionData.total_events || sessionData.events.length);
setMetrics(metricsData.metrics);
+
+ // Update context from session update
+ if (contextResponse.snapshots && contextResponse.snapshots.length > 0) {
+ setContextData(contextResponse.snapshots[0]);
+ }
} catch (error) {
console.error('Failed to reload session:', error);
}
}
- }, [selectedSessionId, metricsHoursBack]);
+ }, [selectedSessionId, metricsHoursBack, setUnreadSessionIds]);
const { subscribe } = useWebSocket({
onSessionImported: handleSessionImported,
onSessionUpdated: handleSessionUpdated,
onIntentChanged: handleWsIntentChanged,
+ onContextUpdated: handleContextUpdated,
});
// Load sessions on mount (don't auto-select - let user choose from homepage)
@@ -270,32 +316,39 @@ function App() {
return () => clearInterval(interval);
}, []);
- // Load session details and metrics in parallel when selected
+ // Load session details, metrics, and context in parallel when selected
useEffect(() => {
if (!selectedSessionId) {
setSelectedSession(null);
setEvents([]);
setMetrics(null);
+ setContextData(null);
setHasMoreEvents(true);
return;
}
// Reset state when selecting a new session
setHasMoreEvents(true);
- setHasNewMessages(false);
+ // If the selected session was unread, show "New messages" button; otherwise reset
+ setHasNewMessages(selectedSessionWasUnreadRef.current);
+ selectedSessionWasUnreadRef.current = false; // Reset the ref
+ setContextData(null);
+ setIsLoadingContext(true); // Set loading before async starts
// Subscribe to this session's WebSocket updates
subscribe(selectedSessionId);
let cancelled = false;
- // Load session and metrics in parallel
+ // Load session, metrics, and context in parallel
const loadData = async () => {
setIsLoading(true);
setMetricsLoading(true);
- // First, get session to check if it's old
+ // Load all data in parallel
const sessionPromise = getSession(selectedSessionId, 30);
+ const metricsPromise = getSessionMetrics(selectedSessionId, metricsHoursBack);
+ const contextPromise = getSessionContext(selectedSessionId);
// Handle session data
try {
@@ -318,9 +371,6 @@ function App() {
}
}
- // Now load metrics
- const metricsPromise = getSessionMetrics(selectedSessionId, metricsHoursBack);
-
// Handle metrics data
try {
const metricsData = await metricsPromise;
@@ -337,6 +387,28 @@ function App() {
setMetricsLoading(false);
}
}
+
+ // Handle context data
+ try {
+ const contextResponse = await contextPromise;
+ if (!cancelled) {
+ // API returns { snapshots: [...], count: N } - use latest snapshot
+ if (contextResponse.snapshots && contextResponse.snapshots.length > 0) {
+ setContextData(contextResponse.snapshots[0]);
+ } else {
+ setContextData(null);
+ }
+ }
+ } catch (error) {
+ console.debug('Failed to load context:', error);
+ if (!cancelled) {
+ setContextData(null);
+ }
+ } finally {
+ if (!cancelled) {
+ setIsLoadingContext(false);
+ }
+ }
};
loadData();
@@ -398,16 +470,22 @@ function App() {
// Trigger ingest to import latest data
await triggerIngest(50);
- // Reload current session data and metrics
- const [sessionData, metricsData] = await Promise.all([
+ // Reload current session data, metrics, and context
+ const [sessionData, metricsData, contextResponse] = await Promise.all([
getSession(selectedSessionId, 30),
getSessionMetrics(selectedSessionId, metricsHoursBack),
+ getSessionContext(selectedSessionId),
]);
setSelectedSession(sessionData.session);
setEvents(sessionData.events);
setTotalEvents(sessionData.total_events || sessionData.events.length);
setMetrics(metricsData.metrics);
+
+ // Update context from refresh
+ if (contextResponse.snapshots && contextResponse.snapshots.length > 0) {
+ setContextData(contextResponse.snapshots[0]);
+ }
} catch (error) {
console.error('Failed to refresh session:', error);
} finally {
@@ -463,12 +541,13 @@ function App() {
handleSessionImported('')}
isDark={isDark}
onToggleTheme={toggleTheme}
- />
+ unreadSessionIds={unreadSessionIds}
+ />
{/* Welcome screen spanning main area */}
@@ -557,11 +636,12 @@ function App() {
handleSessionImported('')}
isDark={isDark}
onToggleTheme={toggleTheme}
+ unreadSessionIds={unreadSessionIds}
/>
@@ -604,6 +684,8 @@ function App() {
onClearNewMessages={() => setHasNewMessages(false)}
onLoadAllForSearch={handleLoadAllForSearch}
isLoadingAllForSearch={isLoadingAllForSearch}
+ contextData={contextData}
+ isLoadingContext={isLoadingContext}
/>
diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts
index 7668b55..245a22f 100644
--- a/packages/web/src/api/client.ts
+++ b/packages/web/src/api/client.ts
@@ -263,3 +263,30 @@ export async function getSessionIntents(
const url = `${BASE_URL}/sessions/${sessionId}/intents${queryString ? `?${queryString}` : ''}`;
return fetchJson(url);
}
+
+// Context Window API types
+export interface SessionContextData {
+ id?: number;
+ session_id?: string;
+ used_percentage: number;
+ remaining_percentage: number;
+ context_window_size: number;
+ total_input_tokens: number;
+ total_output_tokens: number;
+ cache_read_tokens?: number;
+ cache_create_tokens?: number;
+ model?: string | null;
+ timestamp?: string;
+ created_at?: string;
+}
+
+export interface SessionContextResponse {
+ snapshots: SessionContextData[];
+ count: number;
+}
+
+export async function getSessionContext(
+ sessionId: string
+): Promise {
+ return fetchJson(`${BASE_URL}/sessions/${sessionId}/context`);
+}
diff --git a/packages/web/src/components/ContextWindowBar.tsx b/packages/web/src/components/ContextWindowBar.tsx
new file mode 100644
index 0000000..aa85b97
--- /dev/null
+++ b/packages/web/src/components/ContextWindowBar.tsx
@@ -0,0 +1,188 @@
+/**
+ * Context Window Bar Component
+ *
+ * Battery-style progress bar showing context window usage.
+ * Color coding: green (<50%), yellow (50-75%), red (>75%).
+ * Shows tooltip with detailed token breakdown on hover.
+ *
+ * Related: api/client.ts (getSessionContext), hooks/useWebSocket.ts (real-time updates)
+ */
+
+import { useState } from 'react';
+
+export interface ContextData {
+ used_percentage: number;
+ remaining_percentage: number;
+ context_window_size: number;
+ total_input_tokens: number;
+ total_output_tokens: number;
+ cache_read_tokens?: number;
+ cache_create_tokens?: number;
+ model?: string | null;
+ timestamp?: string;
+}
+
+interface ContextWindowBarProps {
+ contextData?: ContextData | null;
+ isLoading?: boolean;
+}
+
+function formatNumber(num: number): string {
+ if (num >= 1000000) {
+ return `${(num / 1000000).toFixed(1)}M`;
+ }
+ if (num >= 1000) {
+ return `${(num / 1000).toFixed(1)}K`;
+ }
+ return num.toString();
+}
+
+function getColorClasses(percentage: number): {
+ bar: string;
+ text: string;
+ bg: string;
+ icon: string;
+} {
+ if (percentage < 50) {
+ return {
+ bar: 'bg-[color:var(--success)]',
+ text: 'text-[color:var(--success)]',
+ bg: 'bg-[color:var(--success)]/10',
+ icon: 'ri-battery-2-line',
+ };
+ }
+ if (percentage < 75) {
+ return {
+ bar: 'bg-yellow-500',
+ text: 'text-yellow-500',
+ bg: 'bg-yellow-500/10',
+ icon: 'ri-battery-low-line',
+ };
+ }
+ return {
+ bar: 'bg-red-500',
+ text: 'text-red-500',
+ bg: 'bg-red-500/10',
+ icon: 'ri-battery-fill',
+ };
+}
+
+export function ContextWindowBar({
+ contextData,
+ isLoading = false,
+}: ContextWindowBarProps) {
+ const [showTooltip, setShowTooltip] = useState(false);
+
+ // Don't render if no context data
+ if (!contextData) {
+ return null;
+ }
+
+ const {
+ used_percentage,
+ remaining_percentage,
+ context_window_size,
+ total_input_tokens,
+ total_output_tokens,
+ } = contextData;
+
+ const colors = getColorClasses(used_percentage);
+ const totalUsedTokens = total_input_tokens + total_output_tokens;
+
+ return (
+ setShowTooltip(true)}
+ onMouseLeave={() => setShowTooltip(false)}
+ >
+ {/* Battery icon */}
+
+
+ {/* Battery bar container */}
+
+ {/* Progress fill */}
+
+ {/* Battery nub (right edge) */}
+
+
+
+ {/* Percentage text */}
+
+ {used_percentage.toFixed(0)}%
+
+
+ {/* Tooltip */}
+ {showTooltip && (
+
+
+ {/* Header */}
+
+ Context Window Usage
+
+ {used_percentage.toFixed(1)}% used
+
+
+
+ {/* Progress bar (larger version) */}
+
+
+ {/* Token breakdown */}
+
+
+
+ Input tokens:
+ {formatNumber(total_input_tokens)}
+
+
+ Output tokens:
+ {formatNumber(total_output_tokens)}
+
+
+
+
+ Total used:
+ {formatNumber(totalUsedTokens)}
+
+
+ Window size:
+ {formatNumber(context_window_size)}
+
+
+
+
+ {/* Remaining capacity */}
+
+
+ Remaining capacity:
+
+ {formatNumber(context_window_size - totalUsedTokens)} tokens ({remaining_percentage.toFixed(1)}%)
+
+
+
+
+ {/* Warning for high usage */}
+ {used_percentage >= 75 && (
+
+
+ Context window nearly full. Consider summarizing or starting a new session.
+
+ )}
+
+
+ )}
+
+ {/* Loading indicator */}
+ {isLoading && (
+
+ )}
+
+ );
+}
diff --git a/packages/web/src/components/MessageBubble.tsx b/packages/web/src/components/MessageBubble.tsx
index d037afc..c418494 100644
--- a/packages/web/src/components/MessageBubble.tsx
+++ b/packages/web/src/components/MessageBubble.tsx
@@ -36,9 +36,36 @@ function highlightText(text: string, query: string | undefined): React.ReactNode
);
}
+// Reusable copy button component
+function CopyButton({ text, copied, onCopy }: { text: string; copied: boolean; onCopy: (text: string) => void }) {
+ return (
+
+ );
+}
+
export function MessageBubble({ event, searchQuery, showAllThinking = false }: MessageBubbleProps) {
const [expanded, setExpanded] = useState(false);
const [thinkingExpanded, setThinkingExpanded] = useState(showAllThinking);
+ const [copied, setCopied] = useState(false);
+
+ // Copy text to clipboard with visual feedback
+ const handleCopy = async (text: string) => {
+ if (!text) return;
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch (err) {
+ console.error('Failed to copy:', err);
+ }
+ };
// Sync with global toggle
useEffect(() => {
@@ -61,78 +88,84 @@ export function MessageBubble({ event, searchQuery, showAllThinking = false }: M
: prompt?.slice(0, MAX_COLLAPSED_LENGTH);
return (
-
- {/* Prompt number badge - from backend to ensure correct numbering */}
- {promptIndex && (
-
- {promptIndex}
-
- )}
-
- {/* Render images if present */}
- {images && images.length > 0 && (
-
- {images.map((img, idx) => {
- const src = img.url
- ? img.url
- : img.base64
- ? `data:${img.media_type || 'image/png'};base64,${img.base64}`
- : null;
-
- if (!src) return null;
-
- return (
-
-
-
- );
- })}
+
+
+ {/* Prompt number badge - from backend to ensure correct numbering */}
+ {promptIndex && (
+
+ {promptIndex}
)}
-
-
- {highlightText(displayPrompt || 'User message', searchQuery)}
-
-
- {/* Gradient fade + Show more button */}
- {isLong && !expanded && (
-
-
+
+ {/* Render images if present */}
+ {images && images.length > 0 && (
+
+ {images.map((img, idx) => {
+ const src = img.url
+ ? img.url
+ : img.base64
+ ? `data:${img.media_type || 'image/png'};base64,${img.base64}`
+ : null;
+
+ if (!src) return null;
+
+ return (
+
+
+
+ );
+ })}
)}
-
+
+
+ {highlightText(displayPrompt || 'User message', searchQuery)}
+
+
+ {/* Gradient fade + Show more button */}
+ {isLong && !expanded && (
+
+
+
+ )}
+
- {/* Show less button when expanded */}
- {isLong && expanded && (
-
- )}
+ {/* Show less button when expanded */}
+ {isLong && expanded && (
+
+ )}
-
-
- {formatTime(event.timestamp)}
-
+
+
+ {formatTime(event.timestamp)}
+
+
+ {/* Copy button below bubble */}
+
+
+
);
};
@@ -187,117 +220,141 @@ export function MessageBubble({ event, searchQuery, showAllThinking = false }: M
const hasContent = content.trim().length > 0;
const hasThinking = !!thinkingContent;
+ // Build copyable text: content first, then thinking
+ const copyableText = [content, thinkingContent].filter(Boolean).join('\n\n---\n\n');
+
return (
-
-
- {/* Thinking section - collapsible dropdown */}
- {hasThinking && (
-
-
-
-
+
+
+
+ {/* Thinking section - collapsible dropdown */}
+ {hasThinking && (
+
+
- {thinkingExpanded && (
-
-
- {thinkingContent}
-
-
- )}
-
-
-
- )}
-
- {/* Content section - only show if there's actual content */}
- {hasContent ? (
- <>
-
-
-
- {highlightText(displayContent, searchQuery)}
-
-
-
- {/* Gradient fade + Show more button */}
- {isLong && !expanded && (
-
+
+ {thinkingExpanded && (
+
+
+ {thinkingContent}
+
+
+ )}
- )}
+
+ )}
- {/* Show less button when expanded */}
- {isLong && expanded && (
-
- )}
- >
- ) : !hasThinking ? (
-
- No text output
-
- ) : null}
+ {/* Content section - only show if there's actual content */}
+ {hasContent ? (
+ <>
+
+
+
+ {highlightText(displayContent, searchQuery)}
+
+
- {/* Footer with time and tokens */}
-
-
- {formatTime(event.timestamp)}
-
+ {/* Gradient fade + Show more button */}
+ {isLong && !expanded && (
+
+
+
+ )}
+
- {tokenUsage && tokenUsage.total_tokens && tokenUsage.total_tokens > 0 && (
-
-
-
- {formatTokens(tokenUsage.input_tokens || 0)}
-
-
-
- {formatTokens(tokenUsage.output_tokens || 0)}
-
- {tokenUsage.cache_read_input_tokens && tokenUsage.cache_read_input_tokens > 0 && (
-
-
- {formatTokens(tokenUsage.cache_read_input_tokens)}
-
+ {/* Show less button when expanded */}
+ {isLong && expanded && (
+
)}
+ >
+ ) : !hasThinking ? (
+
+ No text output
- )}
+ ) : null}
+
+ {/* Footer with time and tokens */}
+
+
+ {formatTime(event.timestamp)}
+
+
+ {tokenUsage && tokenUsage.total_tokens && tokenUsage.total_tokens > 0 && (
+
+
+
+ {formatTokens(tokenUsage.input_tokens || 0)}
+
+
+
+ {formatTokens(tokenUsage.output_tokens || 0)}
+
+ {tokenUsage.cache_read_input_tokens && tokenUsage.cache_read_input_tokens > 0 && (
+
+
+ {formatTokens(tokenUsage.cache_read_input_tokens)}
+
+ )}
+
+ )}
+
+ {/* Copy button below bubble */}
+ {(hasContent || hasThinking) && (
+
+
+
+ )}
);
};
const renderToolUse = () => {
const toolName = event.data?.tool_name as string;
+ const toolInput = event.data?.tool_input as Record
| undefined;
+
+ // Build copyable text for tool use
+ const copyableText = toolInput
+ ? `Tool: ${toolName}\nInput: ${JSON.stringify(toolInput, null, 2)}`
+ : `Tool: ${toolName}`;
+
return (
-
-
-
-
{toolName || 'unknown'}
-
·
-
{formatTime(event.timestamp)}
+
+
+
+
+ {toolName || 'unknown'}
+ ·
+ {formatTime(event.timestamp)}
+
+
+ {/* Copy button below bubble */}
+
+
);
@@ -305,14 +362,18 @@ export function MessageBubble({ event, searchQuery, showAllThinking = false }: M
const renderSessionEvent = () => {
const isStart = event.event_type === 'session_start';
+ const text = isStart ? 'Session started' : 'Session ended';
+
return (
-
+
- {isStart ? 'Session started' : 'Session ended'}
+ {text}
·
{formatTime(event.timestamp)}
+ {/* Copy button below bubble */}
+
);
};
@@ -330,8 +391,12 @@ export function MessageBubble({ event, searchQuery, showAllThinking = false }: M
return n.toString();
};
+ const copyableText = tokenUsage?.total_tokens
+ ? `${command} - ${formatTokens(tokenUsage.total_tokens)} tokens`
+ : command;
+
return (
-
+
{command}
@@ -343,20 +408,25 @@ export function MessageBubble({ event, searchQuery, showAllThinking = false }: M
·
{formatTime(event.timestamp)}
+ {/* Copy button below bubble */}
+
);
};
const renderNotification = () => {
const notification = event.data?.notification as string || 'Notification';
+
return (
-
+
{notification}
·
{formatTime(event.timestamp)}
+ {/* Copy button below bubble */}
+
);
};
diff --git a/packages/web/src/components/SessionList.tsx b/packages/web/src/components/SessionList.tsx
index 9bad6e3..aeff51e 100644
--- a/packages/web/src/components/SessionList.tsx
+++ b/packages/web/src/components/SessionList.tsx
@@ -20,6 +20,7 @@ interface SessionListProps {
onSessionsImported: () => void;
isDark: boolean;
onToggleTheme: () => void;
+ unreadSessionIds?: string[];
}
type DateGroup = 'Today' | 'Yesterday' | 'This Week' | 'Older';
@@ -83,6 +84,7 @@ export function SessionList({
onSessionsImported,
isDark,
onToggleTheme,
+ unreadSessionIds = [],
}: SessionListProps) {
const [searchQuery, setSearchQuery] = useState('');
const [copiedId, setCopiedId] = useState
(null);
@@ -109,6 +111,7 @@ export function SessionList({
console.log('[SessionList] versionInfo:', versionInfo?.currentVersion, 'displayVersion:', displayVersion);
const handleImportSessions = async () => {
+ console.log('[SessionList] Import button clicked, isImporting:', isImporting);
if (isImporting) return;
setShowImportMenu(false);
@@ -240,7 +243,7 @@ export function SessionList({
{/* Search & Import - below header */}