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 ( - - {`Image - - ); - })} +
+
+ {/* 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 ( + + {`Image + + ); + })}
)} -
+
+

+ {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 */}
{/* Search */} -
+
-
- {/* Main button */} - - {/* Dropdown toggle */} - -
+ {/* Single button with integrated dropdown */} + {/* Dropdown menu */} {showImportMenu && ( @@ -394,6 +389,7 @@ export function SessionList({ {/* Sessions in Group */} {groupSessions.map((session) => { const isSelected = selectedId === session.id; + const isUnread = unreadSessionIds.includes(session.id); const prompt = session.first_prompt || 'New session'; return ( @@ -401,16 +397,20 @@ export function SessionList({ key={session.id} onClick={() => onSelect(session.id)} className={` - w-full px-4 py-3 text-left transition-all duration-150 + relative w-full px-4 py-3 text-left transition-all duration-150 ${isSelected ? 'bg-accent border-l-2 border-primary' : 'hover:bg-accent/50 border-l-2 border-transparent' } `} > -
+ {/* Unread indicator dot - top right, aligned with text */} + {isUnread && !isSelected && ( +
+ )} +
-

+

{prompt}

diff --git a/packages/web/src/components/SessionStatusBar.tsx b/packages/web/src/components/SessionStatusBar.tsx new file mode 100644 index 0000000..2e122a4 --- /dev/null +++ b/packages/web/src/components/SessionStatusBar.tsx @@ -0,0 +1,103 @@ +/** + * Session Status Bar Component + * + * Minimal status indicator showing model and context usage. + * Right-aligned to connect with the header actions visually. + */ + +import type { ContextData } from './ContextWindowBar'; + +interface SessionStatusBarProps { + model?: string | null; + contextData?: ContextData | null; + isLoading?: boolean; +} + +function formatModelName(model: string | null | undefined): string { + if (!model) return ''; + + const modelMappings: Record = { + 'claude-opus-4-5-20251101': 'Opus 4.5', + 'claude-sonnet-4-5-20250929': 'Sonnet 4.5', + 'claude-sonnet-4-20250514': 'Sonnet 4', + 'claude-3-5-sonnet-20241022': 'Sonnet 3.5', + 'claude-3-5-haiku-20241022': 'Haiku 3.5', + 'claude-3-opus-20240229': 'Opus 3', + 'claude-3-sonnet-20240229': 'Sonnet 3', + 'claude-3-haiku-20240307': 'Haiku 3', + }; + + if (modelMappings[model]) return modelMappings[model]; + + if (model.includes('opus-4-5') || model.includes('opus-4')) return 'Opus 4.5'; + if (model.includes('sonnet-4-5')) return 'Sonnet 4.5'; + if (model.includes('sonnet-4')) return 'Sonnet 4'; + if (model.includes('3-5-sonnet') || model.includes('sonnet-3.5')) return 'Sonnet 3.5'; + if (model.includes('3-5-haiku') || model.includes('haiku-3.5')) return 'Haiku 3.5'; + if (model.includes('opus')) return 'Opus'; + if (model.includes('sonnet')) return 'Sonnet'; + if (model.includes('haiku')) return 'Haiku'; + + return model.split('-').slice(0, 2).join(' '); +} + +function getBarColor(percentage: number): string { + if (percentage < 50) return 'bg-emerald-500'; + if (percentage < 75) return 'bg-amber-500'; + return 'bg-rose-500'; +} + +function getPercentColor(percentage: number): string { + if (percentage < 50) return 'text-emerald-600 dark:text-emerald-400'; + if (percentage < 75) return 'text-amber-600 dark:text-amber-400'; + return 'text-rose-600 dark:text-rose-400'; +} + +export function SessionStatusBar({ + model, + contextData, + isLoading = false, +}: SessionStatusBarProps) { + const percentage = contextData?.used_percentage ?? 0; + const displayModel = model || contextData?.model; + const modelName = formatModelName(displayModel); + + // Don't render if no data + if (!isLoading && !displayModel && !contextData) { + return null; + } + + return ( +
+ {isLoading ? ( + + + + ) : ( +
+ {/* Model */} + {modelName && ( + + {modelName} + + )} + + {/* Context indicator */} + {contextData && ( +
+
+
+
+ + {percentage.toFixed(0)}% + +
+ )} +
+ )} +
+ ); +} diff --git a/packages/web/src/components/SessionView.tsx b/packages/web/src/components/SessionView.tsx index 92906f6..54f86a3 100644 --- a/packages/web/src/components/SessionView.tsx +++ b/packages/web/src/components/SessionView.tsx @@ -6,11 +6,13 @@ */ import { useEffect, useLayoutEffect, useRef, useCallback, useState, useMemo, type MutableRefObject } from 'react'; -import type { Session, Event } from '../api/client'; +import type { Session, Event, AssistantResponseData } from '../api/client'; import { getExportUrl } from '../api/client'; import { formatDate, formatTime } from '../utils/time'; import { MessageBubble } from './MessageBubble'; import { ToolGroup } from './ToolGroup'; +import { type ContextData } from './ContextWindowBar'; +import { SessionStatusBar } from './SessionStatusBar'; interface SessionViewProps { session: Session | null; @@ -27,6 +29,9 @@ interface SessionViewProps { onClearNewMessages?: () => void; onLoadAllForSearch?: () => Promise; isLoadingAllForSearch?: boolean; + // Context data managed by App.tsx for real-time WebSocket updates + contextData?: ContextData | null; + isLoadingContext?: boolean; } type GroupedItem = @@ -71,6 +76,8 @@ export function SessionView({ onClearNewMessages, onLoadAllForSearch, isLoadingAllForSearch = false, + contextData = null, + isLoadingContext = false, }: SessionViewProps) { const scrollRef = useRef(null); const loadMoreTriggerRef = useRef(null); @@ -82,10 +89,72 @@ export function SessionView({ const [showAllThinking, setShowAllThinking] = useState(false); const searchInputRef = useRef(null); + // Extract model: prefer context data (most recent from hooks), fallback to events + const model = useMemo(() => { + // First check context data - this has the most up-to-date model from hooks + if (contextData?.model) { + return contextData.model; + } + + // Fallback: extract from events (assistant_stop has the model info) + for (let i = events.length - 1; i >= 0; i--) { + const event = events[i]; + if (event.event_type === 'assistant_stop' && event.data) { + const data = event.data as AssistantResponseData; + if (data.model) return data.model; + } + } + return null; + }, [events, contextData]); + + // Derive context data from events when no real-time context available + // Context = input_tokens + cache_read_tokens + cache_create_tokens + const derivedContextData = useMemo((): ContextData | null => { + // If we have real-time context data, use that + if (contextData) return contextData; + + // Find the last assistant_stop event with token_usage + for (let i = events.length - 1; i >= 0; i--) { + const event = events[i]; + if (event.event_type === 'assistant_stop' && event.data) { + const data = event.data as AssistantResponseData; + const tokenUsage = data.token_usage as { + input_tokens?: number; + output_tokens?: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; + } | undefined; + + if (tokenUsage) { + const inputTokens = tokenUsage.input_tokens || 0; + const cacheRead = tokenUsage.cache_read_input_tokens || 0; + const cacheCreate = tokenUsage.cache_creation_input_tokens || 0; + const totalContext = inputTokens + cacheRead + cacheCreate; + + // Context window size based on model (default to 200k) + const contextWindowSize = 200000; + const usedPercentage = (totalContext / contextWindowSize) * 100; + + return { + used_percentage: usedPercentage, + remaining_percentage: 100 - usedPercentage, + context_window_size: contextWindowSize, + total_input_tokens: inputTokens, + total_output_tokens: tokenUsage.output_tokens || 0, + cache_read_tokens: cacheRead, + cache_create_tokens: cacheCreate, + model: data.model, + }; + } + } + } + return null; + }, [events, contextData]); + // Scroll to bottom button state const [isAtBottom, setIsAtBottom] = useState(true); - // Clear event refs and search when session changes + // Clear event refs and search when session changes (context is managed by App.tsx) useEffect(() => { eventRefs.current.clear(); setSearchQuery(''); @@ -539,6 +608,15 @@ export function SessionView({
+ {/* Status Bar - Model, Context (real-time or derived from events) */} + {(model || derivedContextData) && ( + + )} + {/* Messages */}
{/* Refresh loading overlay - centered */} diff --git a/packages/web/src/components/ToolGroup.tsx b/packages/web/src/components/ToolGroup.tsx index d729ad9..ca2c9b0 100644 --- a/packages/web/src/components/ToolGroup.tsx +++ b/packages/web/src/components/ToolGroup.tsx @@ -40,6 +40,7 @@ function getToolStyle(toolName: string) { } function ToolItem({ event, isExpanded, onToggle }: ToolItemProps) { + const [copied, setCopied] = useState(false); const toolName = event.data?.tool_name as string || 'unknown'; const toolInput = event.data?.tool_input as Record; const toolResult = event.data?.tool_result; @@ -50,6 +51,29 @@ function ToolItem({ event, isExpanded, onToggle }: ToolItemProps) { return typeof data === 'string' ? data : JSON.stringify(data, null, 2); }; + // Format tool as copyable text (input + result) + const formatToolText = (): string => { + let output = `## ${toolName}\n`; + if (toolInput && Object.keys(toolInput).length > 0) { + output += `\n### Input\n\`\`\`json\n${formatData(toolInput)}\n\`\`\`\n`; + } + if (toolResult !== null && toolResult !== undefined) { + output += `\n### Result\n\`\`\`json\n${formatData(toolResult)}\n\`\`\`\n`; + } + return output; + }; + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); + try { + await navigator.clipboard.writeText(formatToolText()); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + // Get a preview of the tool input for collapsed view const getInputPreview = (): string => { if (!toolInput) return ''; @@ -80,23 +104,34 @@ function ToolItem({ event, isExpanded, onToggle }: ToolItemProps) { const preview = getInputPreview(); return ( -
- +
+
+ + {/* Copy button for this tool */} + +
{isExpanded && (
@@ -125,6 +160,7 @@ function ToolItem({ event, isExpanded, onToggle }: ToolItemProps) { export function ToolGroup({ events }: ToolGroupProps) { const [isGroupExpanded, setIsGroupExpanded] = useState(false); const [expandedTools, setExpandedTools] = useState>(new Set()); + const [copiedAll, setCopiedAll] = useState(false); const toggleGroup = () => setIsGroupExpanded(!isGroupExpanded); @@ -138,6 +174,40 @@ export function ToolGroup({ events }: ToolGroupProps) { setExpandedTools(newExpanded); }; + // Format all tools into a single copyable string + const formatAllTools = (): string => { + return events.map((event, index) => { + const toolName = event.data?.tool_name as string || 'unknown'; + const toolInput = event.data?.tool_input as Record; + const toolResult = event.data?.tool_result; + + const formatData = (data: unknown): string => { + if (data === null || data === undefined) return ''; + return typeof data === 'string' ? data : JSON.stringify(data, null, 2); + }; + + let output = `## Tool ${index + 1}: ${toolName}\n`; + if (toolInput && Object.keys(toolInput).length > 0) { + output += `\n### Input\n\`\`\`json\n${formatData(toolInput)}\n\`\`\`\n`; + } + if (toolResult !== null && toolResult !== undefined) { + output += `\n### Result\n\`\`\`json\n${formatData(toolResult)}\n\`\`\`\n`; + } + return output; + }).join('\n---\n\n'); + }; + + const handleCopyAll = async (e: React.MouseEvent) => { + e.stopPropagation(); // Prevent toggle when clicking copy + try { + await navigator.clipboard.writeText(formatAllTools()); + setCopiedAll(true); + setTimeout(() => setCopiedAll(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + // Get unique tool names for summary const toolNames = events.map((ev) => ev.data?.tool_name as string || 'unknown'); const toolCounts: Record = {}; @@ -150,13 +220,13 @@ export function ToolGroup({ events }: ToolGroupProps) { const remaining = Object.keys(toolCounts).length - 4; return ( -
+
{/* Group header */} -
- + + + {/* Copy All button */} + +
{/* Tool list */} {isGroupExpanded && ( diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 3b1e02e..40cd80f 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -43,22 +43,41 @@ interface IntentChangedMessage { previous_intents?: string[]; } +interface ContextUpdatedMessage { + type: 'context_updated'; + session_id: string; + data: { + 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 ServerRestartingMessage { type: 'server_restarting'; message: string; new_version: string; } -type WebSocketMessage = SessionImportedMessage | SessionUpdatedMessage | SessionRefreshedMessage | IntentChangedMessage | ServerRestartingMessage; +type WebSocketMessage = SessionImportedMessage | SessionUpdatedMessage | SessionRefreshedMessage | IntentChangedMessage | ContextUpdatedMessage | ServerRestartingMessage; + +export type { ContextUpdatedMessage }; interface UseWebSocketOptions { onSessionImported?: (sessionId: string) => void; onSessionUpdated?: (sessionId: string, newMessages: number) => void; onIntentChanged?: (message: IntentChangedMessage) => void; + onContextUpdated?: (message: ContextUpdatedMessage) => void; } export function useWebSocket(options: UseWebSocketOptions = {}) { - const { onSessionImported, onSessionUpdated, onIntentChanged } = options; + const { onSessionImported, onSessionUpdated, onIntentChanged, onContextUpdated } = options; const [isConnected, setIsConnected] = useState(false); const wsRef = useRef(null); const reconnectTimeoutRef = useRef | null>(null); @@ -71,6 +90,8 @@ export function useWebSocket(options: UseWebSocketOptions = {}) { onSessionUpdatedRef.current = onSessionUpdated; const onIntentChangedRef = useRef(onIntentChanged); onIntentChangedRef.current = onIntentChanged; + const onContextUpdatedRef = useRef(onContextUpdated); + onContextUpdatedRef.current = onContextUpdated; // Connect on mount useEffect(() => { @@ -112,6 +133,9 @@ export function useWebSocket(options: UseWebSocketOptions = {}) { } else if (data.type === 'intent_changed' && onIntentChangedRef.current) { // Intent analysis changed - notify for UI update and notification onIntentChangedRef.current(data); + } else if (data.type === 'context_updated' && onContextUpdatedRef.current) { + // Context window usage updated - notify for UI update + onContextUpdatedRef.current(data); } else if (data.type === 'server_restarting') { // Server is about to restart for update - useVersionCheck handles reconnection console.log('[WebSocket] Server restarting:', data.message);