diff --git a/.gitignore b/.gitignore index fc1005d..890ea11 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ dist/ # Generated plugin skills (copied from shared/skills/ at build time) plugins/*/skills/ +# Generated HUD scripts (copied from dist/hud/ at build time) +scripts/hud/ + # Generated shared agents (copied from shared/agents/ at build time) # Note: plugin-specific agents (catch-up.md, devlog.md) are committed in their plugins plugins/*/agents/git.md diff --git a/docs/reference/file-organization.md b/docs/reference/file-organization.md index 4064bc0..35b549c 100644 --- a/docs/reference/file-organization.md +++ b/docs/reference/file-organization.md @@ -39,7 +39,9 @@ devflow/ │ └── reference/ # Extracted reference docs ├── scripts/ │ ├── build-plugins.ts -│ ├── statusline.sh +│ ├── build-hud.js # Copies dist/hud/ → scripts/hud/ +│ ├── hud.sh # Thin wrapper: exec node hud/index.js +│ ├── hud/ # GENERATED — compiled HUD module (gitignored) │ └── hooks/ # Working Memory + ambient hooks │ ├── stop-update-memory # Stop hook: writes WORKING-MEMORY.md │ ├── session-start-memory # SessionStart hook: injects memory + git state @@ -51,7 +53,14 @@ devflow/ │ ├── init.ts │ ├── list.ts │ ├── memory.ts + │ ├── hud.ts │ └── uninstall.ts + ├── hud/ # HUD module (TypeScript source) + │ ├── index.ts # Entry point: stdin → gather → render → stdout + │ ├── types.ts # StdinData, HudConfig, ComponentId, etc. + │ ├── config.ts # PRESETS, loadConfig, saveConfig + │ ├── render.ts # Smart multi-line layout assembly + │ └── components/ # 14 individual component renderers └── cli.ts ``` @@ -127,7 +136,7 @@ Skills and agents are **not duplicated** in git. Instead: `devflow init --override-settings` replaces `~/.claude/settings.json`. Included settings: -- `statusLine` - Smart statusline with context percentage +- `statusLine` - Configurable HUD with presets (replaces legacy statusline.sh) - `hooks` - Working Memory hooks (Stop, SessionStart, PreCompact) - `env.ENABLE_TOOL_SEARCH` - Deferred MCP tool loading (~85% token savings) - `env.ENABLE_LSP_TOOL` - Language Server Protocol support @@ -160,11 +169,17 @@ Knowledge files in `.memory/knowledge/` capture decisions and pitfalls that agen Each file has a `` comment on line 1. SessionStart injects TL;DR headers only (~30-50 tokens). Agents read full files when relevant to their work. Cap: 50 entries per file. -## Statusline Script +## HUD (Heads-Up Display) -The statusline (`scripts/statusline.sh`) displays: -- Directory name and model -- Git branch with dirty indicator (`*`) -- Context usage percentage (green <50%, yellow 50-80%, red >80%) +The HUD (`scripts/hud.sh` → `scripts/hud/index.js`) is a configurable TypeScript status line with 14 components and 4 presets: -Data source: `context_window.current_usage` from Claude Code's JSON stdin. +| Preset | Components | Layout | +|--------|-----------|--------| +| Minimal | directory, git branch, model, context % | Single line | +| Classic | + ahead/behind, diff stats, version badge | Single line | +| Standard (default) | + session duration, usage quota | 2 lines | +| Full | + tool/agent activity, todos, speed, config counts | 3-4 lines | + +Configuration: `~/.devflow/hud.json` (preset + component toggles). Manage via `devflow hud --configure`. + +Data source: `context_window.current_usage` from Claude Code's JSON stdin. Git data gathered with 1s per-command timeout. Overall 2s timeout with graceful degradation. diff --git a/package.json b/package.json index 127003b..8913e67 100644 --- a/package.json +++ b/package.json @@ -12,15 +12,18 @@ "plugins/", "shared/", "scripts/hooks/", + "scripts/hud.sh", + "scripts/hud/", "src/templates/", "README.md", "LICENSE", "CHANGELOG.md" ], "scripts": { - "build": "npm run build:cli && npm run build:plugins", + "build": "npm run build:cli && npm run build:plugins && npm run build:hud", "build:cli": "tsc", "build:plugins": "npx tsx scripts/build-plugins.ts", + "build:hud": "node scripts/build-hud.js", "dev": "tsc --watch", "cli": "node dist/cli.js", "prepublishOnly": "npm run build", diff --git a/scripts/build-hud.js b/scripts/build-hud.js new file mode 100755 index 0000000..fbcaee9 --- /dev/null +++ b/scripts/build-hud.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node + +/** + * Copy compiled HUD scripts from dist/hud/ to scripts/hud/ + * for distribution alongside the shell wrapper. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const src = path.join(__dirname, '..', 'dist', 'hud'); +const dest = path.join(__dirname, 'hud'); + +// Clean destination +if (fs.existsSync(dest)) fs.rmSync(dest, { recursive: true }); + +function copyDir(s, d) { + fs.mkdirSync(d, { recursive: true }); + for (const entry of fs.readdirSync(s, { withFileTypes: true })) { + const sp = path.join(s, entry.name); + const dp = path.join(d, entry.name); + if (entry.isDirectory()) copyDir(sp, dp); + else fs.copyFileSync(sp, dp); + } +} + +if (fs.existsSync(src)) { + copyDir(src, dest); + console.log(`\u2713 HUD scripts copied to ${dest}`); +} else { + console.warn('\u26A0 dist/hud not found \u2014 run tsc first'); +} diff --git a/scripts/hud.sh b/scripts/hud.sh new file mode 100755 index 0000000..27956e2 --- /dev/null +++ b/scripts/hud.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# DevFlow HUD — configurable TypeScript status line +# Receives JSON via stdin from Claude Code, outputs ANSI-formatted HUD +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec node "${SCRIPT_DIR}/hud/index.js" diff --git a/scripts/statusline.sh b/scripts/statusline.sh deleted file mode 100755 index 580754f..0000000 --- a/scripts/statusline.sh +++ /dev/null @@ -1,240 +0,0 @@ -#!/bin/bash - -# Claude Code Status Line Script -# Receives JSON input via stdin with session context -# Displays: directory, git branch, diff stats, model, context usage, update badge - -# Read JSON input -INPUT=$(cat) - -# Derive devflow directory from script location (scripts/ → parent is ~/.devflow/) -SCRIPT_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)" -DEVFLOW_DIR="$(dirname "$SCRIPT_DIR")" - -# Portable mtime helper (works on macOS + Linux) -get_mtime() { - if stat --version &>/dev/null 2>&1; then - stat -c %Y "$1" # Linux (GNU stat) - else - stat -f %m "$1" # macOS (BSD stat) - fi -} - -# Parse values using jq (with fallbacks if jq is not available or fields are missing) -if command -v jq &> /dev/null; then - MODEL=$(echo "$INPUT" | jq -r '.model.display_name // .model.id // "claude"' 2>/dev/null) - CWD=$(echo "$INPUT" | jq -r '.cwd // "~"' 2>/dev/null) - - # Context window info - CONTEXT_SIZE=$(echo "$INPUT" | jq -r '.context_window.context_window_size // 0' 2>/dev/null) - USAGE=$(echo "$INPUT" | jq '.context_window.current_usage // null' 2>/dev/null) -else - MODEL="claude" - CWD=$(pwd) - CONTEXT_SIZE=0 - USAGE="null" -fi - -# Detect base branch via layered detection (most precise → least precise) -detect_base_branch() { - cd "$CWD" 2>/dev/null || return - - # Layer 1: Branch reflog — explicit "Created from " - local CREATED_FROM - CREATED_FROM=$(git reflog show "$GIT_BRANCH" --format='%gs' 2>/dev/null \ - | grep -m1 'branch: Created from' \ - | sed 's/branch: Created from //') - if [ -n "$CREATED_FROM" ] && [ "$CREATED_FROM" != "HEAD" ]; then - local CANDIDATE="${CREATED_FROM#refs/heads/}" - if git rev-parse --verify "$CANDIDATE" &>/dev/null; then - echo "$CANDIDATE" - return - fi - fi - - # Layer 2: HEAD reflog — "checkout: moving from X to " - local MOVED_FROM - MOVED_FROM=$(git reflog show HEAD --format='%gs' 2>/dev/null \ - | grep -m1 "checkout: moving from .* to $GIT_BRANCH\$" \ - | sed "s/checkout: moving from \(.*\) to $GIT_BRANCH/\1/") - if [ -n "$MOVED_FROM" ]; then - if git rev-parse --verify "$MOVED_FROM" &>/dev/null; then - echo "$MOVED_FROM" - return - fi - fi - - # Layer 3: GitHub PR base branch (cached, 5-min TTL) - if command -v gh &>/dev/null; then - local REPO_NAME - REPO_NAME=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)") - local CACHE_DIR="${HOME}/.cache/devflow" - local CACHE_FILE="${CACHE_DIR}/base-${REPO_NAME}-${GIT_BRANCH}" - if [ -f "$CACHE_FILE" ] && [ $(($(date +%s) - $(get_mtime "$CACHE_FILE"))) -lt 300 ]; then - cat "$CACHE_FILE" - return - else - local PR_BASE - PR_BASE=$(gh pr view --json baseRefName -q .baseRefName 2>/dev/null) - if [ -n "$PR_BASE" ]; then - mkdir -p "$CACHE_DIR" 2>/dev/null - echo "$PR_BASE" > "$CACHE_FILE" - echo "$PR_BASE" - return - fi - fi - fi - - # Layer 4: Fallback to main/master - if git rev-parse --verify main &>/dev/null; then - echo "main" - elif git rev-parse --verify master &>/dev/null; then - echo "master" - fi -} - -# Get current directory name -DIR_NAME=$(basename "$CWD") - -# Get git branch if in a git repo -GIT_BRANCH=$(cd "$CWD" 2>/dev/null && git branch --show-current 2>/dev/null || echo "") -if [ -z "$GIT_BRANCH" ]; then - GIT_INFO="" - DIFF_STATS="" -else - # Dirty indicator based on uncommitted changes - if [ -n "$(cd "$CWD" 2>/dev/null && git status --porcelain 2>/dev/null)" ]; then - GIT_INFO=" \033[33m$GIT_BRANCH*\033[0m" - else - GIT_INFO=" \033[32m$GIT_BRANCH\033[0m" - fi - - BASE_BRANCH=$(detect_base_branch) - - BRANCH_STATS="" - if [ -n "$BASE_BRANCH" ] && [ "$GIT_BRANCH" != "$BASE_BRANCH" ]; then - # Total commits on branch (local + remote, since fork from base) - TOTAL_COMMITS=$(cd "$CWD" 2>/dev/null && git rev-list --count "$BASE_BRANCH"..HEAD 2>/dev/null || echo "0") - [ "$TOTAL_COMMITS" -gt 0 ] 2>/dev/null && BRANCH_STATS=" ${TOTAL_COMMITS}↑" - - # Unpushed commits (local-only, ahead of remote tracking branch) - UPSTREAM=$(cd "$CWD" 2>/dev/null && git rev-parse --abbrev-ref '@{upstream}' 2>/dev/null) - if [ -n "$UPSTREAM" ]; then - UNPUSHED=$(cd "$CWD" 2>/dev/null && git rev-list --count "$UPSTREAM"..HEAD 2>/dev/null || echo "0") - [ "$UNPUSHED" -gt 0 ] 2>/dev/null && BRANCH_STATS="$BRANCH_STATS \033[33m${UNPUSHED}⇡\033[0m" - elif [ "$TOTAL_COMMITS" -gt 0 ] 2>/dev/null; then - # No upstream at all — everything is unpushed - BRANCH_STATS="$BRANCH_STATS \033[33m${TOTAL_COMMITS}⇡\033[0m" - fi - - MERGE_BASE=$(cd "$CWD" 2>/dev/null && git merge-base "$BASE_BRANCH" HEAD 2>/dev/null) - DIFF_OUTPUT=$(cd "$CWD" 2>/dev/null && git diff --shortstat "$MERGE_BASE" 2>/dev/null) - else - DIFF_OUTPUT=$(cd "$CWD" 2>/dev/null && git diff --shortstat HEAD 2>/dev/null) - fi - - if [ -n "$DIFF_OUTPUT" ]; then - FILES_CHANGED=$(echo "$DIFF_OUTPUT" | grep -oE '[0-9]+ file' | grep -oE '[0-9]+' || echo "0") - ADDITIONS=$(echo "$DIFF_OUTPUT" | grep -oE '[0-9]+ insertion' | grep -oE '[0-9]+' || echo "0") - DELETIONS=$(echo "$DIFF_OUTPUT" | grep -oE '[0-9]+ deletion' | grep -oE '[0-9]+' || echo "0") - [ -z "$FILES_CHANGED" ] && FILES_CHANGED=0 - [ -z "$ADDITIONS" ] && ADDITIONS=0 - [ -z "$DELETIONS" ] && DELETIONS=0 - DIFF_STATS=" ${FILES_CHANGED} \033[32m+$ADDITIONS\033[0m \033[31m-$DELETIONS\033[0m" - else - DIFF_STATS="" - fi -fi - -# Build status line with colors (regular, not bold) -STATUS_LINE="\033[34m$DIR_NAME\033[0m$GIT_INFO$BRANCH_STATS$DIFF_STATS" - -# Add model name -STATUS_LINE="$STATUS_LINE \033[36m$MODEL\033[0m" - -# Calculate and display context usage -if [ "$USAGE" != "null" ] && [ "$CONTEXT_SIZE" != "0" ] && [ -n "$CONTEXT_SIZE" ]; then - # Calculate total tokens used (input + cache tokens) - INPUT_TOKENS=$(echo "$USAGE" | jq -r '.input_tokens // 0' 2>/dev/null) - CACHE_CREATE=$(echo "$USAGE" | jq -r '.cache_creation_input_tokens // 0' 2>/dev/null) - CACHE_READ=$(echo "$USAGE" | jq -r '.cache_read_input_tokens // 0' 2>/dev/null) - - # Handle null values - [ "$INPUT_TOKENS" = "null" ] && INPUT_TOKENS=0 - [ "$CACHE_CREATE" = "null" ] && CACHE_CREATE=0 - [ "$CACHE_READ" = "null" ] && CACHE_READ=0 - - CURRENT_TOKENS=$((INPUT_TOKENS + CACHE_CREATE + CACHE_READ)) - - if [ "$CURRENT_TOKENS" -gt 0 ]; then - PERCENT=$((CURRENT_TOKENS * 100 / CONTEXT_SIZE)) - - # Color based on usage: green < 50%, yellow 50-80%, red > 80% - if [ "$PERCENT" -gt 80 ]; then - # Red - high usage - STATUS_LINE="$STATUS_LINE \033[91m${PERCENT}%\033[0m" - elif [ "$PERCENT" -gt 50 ]; then - # Yellow - moderate usage - STATUS_LINE="$STATUS_LINE \033[33m${PERCENT}%\033[0m" - else - # Green - low usage - STATUS_LINE="$STATUS_LINE \033[32m${PERCENT}%\033[0m" - fi - fi -fi - -# Version update notification (24h cached check) -get_version_badge() { - local MANIFEST_FILE="${DEVFLOW_DIR}/manifest.json" - local VERSION_CACHE_DIR="${HOME}/.cache/devflow" - local VERSION_CACHE_FILE="${VERSION_CACHE_DIR}/latest-version" - local SEMVER_RE='^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$' - - [ -f "$MANIFEST_FILE" ] && command -v jq &>/dev/null || return - - local LOCAL_VERSION - LOCAL_VERSION=$(jq -r '.version // empty' "$MANIFEST_FILE" 2>/dev/null) - [ -n "$LOCAL_VERSION" ] || return - - # Read cached latest version (fast path — no network) - local LATEST_VERSION="" - if [ -f "$VERSION_CACHE_FILE" ]; then - LATEST_VERSION=$(cat "$VERSION_CACHE_FILE" 2>/dev/null) - fi - - # Compare versions if cache exists and passes semver validation - if [ -n "$LATEST_VERSION" ] && [[ "$LATEST_VERSION" =~ $SEMVER_RE ]] && [ "$LOCAL_VERSION" != "$LATEST_VERSION" ]; then - # sort -V: lowest version first. If local sorts before latest, update available. - local LOWEST - LOWEST=$(printf '%s\n%s' "$LOCAL_VERSION" "$LATEST_VERSION" | sort -V | head -n1) - if [ "$LOWEST" = "$LOCAL_VERSION" ]; then - echo " \033[35m⬆ ${LATEST_VERSION}\033[0m" - fi - fi - - # Background refresh if cache is missing or older than 24h - local REFRESH=false - if [ ! -f "$VERSION_CACHE_FILE" ]; then - REFRESH=true - else - local CACHE_AGE=$(($(date +%s) - $(get_mtime "$VERSION_CACHE_FILE"))) - [ "$CACHE_AGE" -ge 86400 ] && REFRESH=true - fi - - if [ "$REFRESH" = true ] && command -v npm &>/dev/null; then - ( - mkdir -p "$VERSION_CACHE_DIR" 2>/dev/null - local FETCHED - FETCHED=$(npm view devflow-kit version 2>/dev/null) - if [ -n "$FETCHED" ] && [[ "$FETCHED" =~ $SEMVER_RE ]]; then - echo "$FETCHED" > "$VERSION_CACHE_FILE" - fi - ) & - disown 2>/dev/null - fi -} - -VERSION_BADGE=$(get_version_badge) -STATUS_LINE="${STATUS_LINE}${VERSION_BADGE}" - -echo -e "$STATUS_LINE" diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 92c63ce..b864b5c 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -10,6 +10,7 @@ import { listCommand } from './commands/list.js'; import { ambientCommand } from './commands/ambient.js'; import { memoryCommand } from './commands/memory.js'; import { skillsCommand } from './commands/skills.js'; +import { hudCommand } from './commands/hud.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -26,7 +27,7 @@ program .description('Agentic Development Toolkit for Claude Code\n\nEnhance your AI-assisted development with intelligent commands and workflows.') .version(packageJson.version, '-v, --version', 'Display version number') .helpOption('-h, --help', 'Display help information') - .addHelpText('after', '\nExamples:\n $ devflow init Install all DevFlow plugins\n $ devflow init --plugin=implement Install specific plugin\n $ devflow init --plugin=implement,review Install multiple plugins\n $ devflow list List available plugins\n $ devflow ambient --enable Enable always-on ambient mode\n $ devflow memory --status Check working memory state\n $ devflow uninstall Remove DevFlow from Claude Code\n $ devflow --version Show version\n $ devflow --help Show help\n\nDocumentation:\n https://github.com/dean0x/devflow#readme'); + .addHelpText('after', '\nExamples:\n $ devflow init Install all DevFlow plugins\n $ devflow init --plugin=implement Install specific plugin\n $ devflow init --plugin=implement,review Install multiple plugins\n $ devflow list List available plugins\n $ devflow ambient --enable Enable always-on ambient mode\n $ devflow memory --status Check working memory state\n $ devflow hud --configure Configure HUD preset\n $ devflow uninstall Remove DevFlow from Claude Code\n $ devflow --version Show version\n $ devflow --help Show help\n\nDocumentation:\n https://github.com/dean0x/devflow#readme'); // Register commands program.addCommand(initCommand); @@ -35,6 +36,7 @@ program.addCommand(listCommand); program.addCommand(ambientCommand); program.addCommand(memoryCommand); program.addCommand(skillsCommand); +program.addCommand(hudCommand); // Handle no command program.action(() => { diff --git a/src/cli/commands/hud.ts b/src/cli/commands/hud.ts new file mode 100644 index 0000000..485845d --- /dev/null +++ b/src/cli/commands/hud.ts @@ -0,0 +1,233 @@ +import { Command } from 'commander'; +import { promises as fs } from 'node:fs'; +import * as path from 'node:path'; +import * as p from '@clack/prompts'; +import color from 'picocolors'; +import { getClaudeDirectory, getDevFlowDirectory } from '../utils/paths.js'; +import { + HUD_COMPONENTS, + loadConfig, + saveConfig, +} from '../hud/config.js'; + +interface StatusLine { + type: string; + command: string; +} + +interface Settings { + statusLine?: StatusLine; + [key: string]: unknown; +} + +/** + * Add the HUD statusLine to settings JSON. + * Idempotent — returns unchanged JSON if HUD already set. + * Upgrades legacy statusline.sh to hud.sh automatically. + */ +export function addHudStatusLine( + settingsJson: string, + devflowDir: string, +): string { + const settings: Settings = JSON.parse(settingsJson); + const hudCommand = path.join(devflowDir, 'scripts', 'hud.sh'); + + // Already pointing to this exact HUD — nothing to do + if (settings.statusLine?.command === hudCommand) { + return settingsJson; + } + + // If there's a non-DevFlow statusLine, don't overwrite (caller should check first) + if (settings.statusLine && !isDevFlowStatusLine(settings.statusLine)) { + return settingsJson; + } + + settings.statusLine = { + type: 'command', + command: hudCommand, + }; + + return JSON.stringify(settings, null, 2) + '\n'; +} + +/** + * Remove the HUD statusLine from settings JSON. + * Idempotent — returns unchanged JSON if statusLine not present or not DevFlow. + */ +export function removeHudStatusLine(settingsJson: string): string { + const settings: Settings = JSON.parse(settingsJson); + + if (!settings.statusLine) { + return settingsJson; + } + + // Only remove if it's a DevFlow HUD/statusline + if (!isDevFlowStatusLine(settings.statusLine)) { + return settingsJson; + } + + delete settings.statusLine; + + return JSON.stringify(settings, null, 2) + '\n'; +} + +/** + * Check if the statusLine in settings JSON points to the DevFlow HUD. + */ +export function hasHudStatusLine(settingsJson: string): boolean { + const settings: Settings = JSON.parse(settingsJson); + if (!settings.statusLine) return false; + return isDevFlowStatusLine(settings.statusLine); +} + +/** + * Check if an existing statusLine belongs to DevFlow (HUD or legacy statusline). + * Matches paths containing 'hud.sh', 'statusline.sh', or a '/devflow/' directory segment. + */ +function isDevFlowStatusLine(statusLine: StatusLine): boolean { + const cmd = statusLine.command ?? ''; + return ( + cmd.includes('hud.sh') || + cmd.includes('statusline.sh') || + cmd.includes('/devflow/') || + cmd.includes('\\devflow\\') + ); +} + +/** + * Check if an existing statusLine belongs to a non-DevFlow tool. + */ +export function hasNonDevFlowStatusLine(settingsJson: string): boolean { + const settings: Settings = JSON.parse(settingsJson); + if (!settings.statusLine?.command) return false; + return !isDevFlowStatusLine(settings.statusLine); +} + +export const hudCommand = new Command('hud') + .description('Configure the HUD (status line)') + .option('--status', 'Show current HUD config') + .option('--detail', 'Show tool/agent descriptions in HUD') + .option('--no-detail', 'Hide tool/agent descriptions') + .option('--enable', 'Enable HUD in settings') + .option('--disable', 'Disable HUD (remove statusLine)') + .action(async (options) => { + const hasFlag = + options.status || + options.enable || + options.disable || + options.detail !== undefined; + if (!hasFlag) { + p.intro(color.bgCyan(color.white(' HUD '))); + p.note( + `${color.cyan('devflow hud --detail')} Show tool/agent descriptions\n` + + `${color.cyan('devflow hud --no-detail')} Hide tool/agent descriptions\n` + + `${color.cyan('devflow hud --status')} Show current config\n` + + `${color.cyan('devflow hud --enable')} Enable HUD in settings\n` + + `${color.cyan('devflow hud --disable')} Remove HUD from settings`, + 'Usage', + ); + p.note( + `${HUD_COMPONENTS.length} components: ${HUD_COMPONENTS.join(', ')}`, + 'Components', + ); + p.outro(color.dim('Toggle with --enable / --disable')); + return; + } + + if (options.status) { + const config = loadConfig(); + p.intro(color.bgCyan(color.white(' HUD Status '))); + p.note( + `${color.dim('Enabled:')} ${config.enabled ? color.green('yes') : color.dim('no')}\n` + + `${color.dim('Detail:')} ${config.detail ? color.green('on') : color.dim('off')}\n` + + `${color.dim('Components:')} ${HUD_COMPONENTS.length}`, + 'Current config', + ); + + // Check settings.json + const claudeDir = getClaudeDirectory(); + const settingsPath = path.join(claudeDir, 'settings.json'); + try { + const content = await fs.readFile(settingsPath, 'utf-8'); + const enabled = hasHudStatusLine(content); + p.log.info( + `Status line: ${enabled ? color.green('enabled') : color.dim('disabled')}`, + ); + } catch { + p.log.info(`Status line: ${color.dim('no settings.json found')}`); + } + return; + } + + if (options.detail !== undefined) { + const config = loadConfig(); + config.detail = options.detail; + saveConfig(config); + p.log.success(`HUD detail ${config.detail ? 'enabled' : 'disabled'}`); + return; + } + + if (options.enable) { + const claudeDir = getClaudeDirectory(); + const settingsPath = path.join(claudeDir, 'settings.json'); + let settingsContent: string; + try { + settingsContent = await fs.readFile(settingsPath, 'utf-8'); + } catch { + settingsContent = '{}'; + } + + // Ensure statusLine is registered + if (!hasHudStatusLine(settingsContent)) { + // Check for non-DevFlow statusLine + if (hasNonDevFlowStatusLine(settingsContent)) { + const settings = JSON.parse(settingsContent) as Settings; + p.log.warn( + `Existing statusLine found: ${color.dim(settings.statusLine?.command ?? 'unknown')}`, + ); + if (process.stdin.isTTY) { + const overwrite = await p.confirm({ + message: + 'Replace existing statusLine with DevFlow HUD?', + initialValue: false, + }); + if (p.isCancel(overwrite) || !overwrite) { + p.log.info('HUD not enabled — existing statusLine preserved'); + return; + } + } else { + p.log.info( + 'Non-interactive mode — skipping (existing statusLine would be overwritten)', + ); + return; + } + } + + const devflowDir = getDevFlowDirectory(); + const updated = addHudStatusLine(settingsContent, devflowDir); + await fs.writeFile(settingsPath, updated, 'utf-8'); + } + + // Update config + const config = loadConfig(); + if (config.enabled) { + p.log.info('HUD already enabled'); + return; + } + saveConfig({ ...config, enabled: true }); + + p.log.success('HUD enabled'); + p.log.info(color.dim('Restart Claude Code to see the HUD')); + } + + if (options.disable) { + const config = loadConfig(); + if (!config.enabled) { + p.log.info('HUD already disabled'); + return; + } + saveConfig({ ...config, enabled: false }); + p.log.success('HUD disabled'); + p.log.info(color.dim('Version upgrade notifications will still appear')); + } + }); diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 8330ca6..70ea7e9 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -8,7 +8,7 @@ import color from 'picocolors'; import { getInstallationPaths } from '../utils/paths.js'; import { getGitRoot } from '../utils/git.js'; import { isClaudeCliAvailable } from '../utils/cli.js'; -import { installViaCli, installViaFileCopy } from '../utils/installer.js'; +import { installViaCli, installViaFileCopy, copyDirectory } from '../utils/installer.js'; import { installSettings, installManagedSettings, @@ -24,12 +24,15 @@ import { detectPlatform, detectShell, getProfilePath, getSafeDeleteInfo, hasSafe import { generateSafeDeleteBlock, isAlreadyInstalled, installToProfile, removeFromProfile, getInstalledVersion, SAFE_DELETE_BLOCK_VERSION } from '../utils/safe-delete-install.js'; import { addAmbientHook, removeAmbientHook, hasAmbientHook } from './ambient.js'; import { addMemoryHooks, removeMemoryHooks, hasMemoryHooks } from './memory.js'; +import { addHudStatusLine, removeHudStatusLine, hasHudStatusLine } from './hud.js'; +import { loadConfig as loadHudConfig, saveConfig as saveHudConfig } from '../hud/config.js'; import { readManifest, writeManifest, resolvePluginList, detectUpgrade } from '../utils/manifest.js'; // Re-export pure functions for tests (canonical source is post-install.ts) export { substituteSettingsTemplate, computeGitignoreAppend, applyTeamsConfig, stripTeamsConfig, mergeDenyList } from '../utils/post-install.js'; export { addAmbientHook, removeAmbientHook, hasAmbientHook } from './ambient.js'; export { addMemoryHooks, removeMemoryHooks, hasMemoryHooks } from './memory.js'; +export { addHudStatusLine, removeHudStatusLine, hasHudStatusLine } from './hud.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -96,6 +99,8 @@ interface InitOptions { teams?: boolean; ambient?: boolean; memory?: boolean; + hud?: boolean; + hudOnly?: boolean; } export const initCommand = new Command('init') @@ -109,6 +114,8 @@ export const initCommand = new Command('init') .option('--no-ambient', 'Disable ambient mode') .option('--memory', 'Enable working memory (session context preservation)') .option('--no-memory', 'Disable working memory hooks') + .option('--no-hud', 'Disable HUD status line') + .option('--hud-only', 'Install only the HUD (no plugins, hooks, or extras)') .action(async (options: InitOptions) => { // Get package version const packageJsonPath = path.resolve(__dirname, '../../package.json'); @@ -128,7 +135,10 @@ export const initCommand = new Command('init') // Determine installation scope let scope: 'user' | 'local' = 'user'; - if (options.scope) { + if (options.hudOnly) { + // --hud-only: skip scope prompt, always user scope + scope = 'user'; + } else if (options.scope) { const normalizedScope = options.scope.toLowerCase(); if (normalizedScope !== 'user' && normalizedScope !== 'local') { p.log.error('Invalid scope. Use "user" or "local"'); @@ -155,6 +165,75 @@ export const initCommand = new Command('init') scope = selected as 'user' | 'local'; } + // --hud-only: install only HUD (skip plugins, hooks, extras) + if (options.hudOnly) { + // Resolve paths + const paths = await getInstallationPaths(scope); + const claudeDir = paths.claudeDir; + const devflowDir = paths.devflowDir; + + // Save HUD config + const existingHud = loadHudConfig(); + saveHudConfig({ enabled: true, detail: existingHud.detail }); + + // Update statusLine in settings.json + const settingsPath = path.join(claudeDir, 'settings.json'); + try { + let content: string; + try { + content = await fs.readFile(settingsPath, 'utf-8'); + } catch { + content = '{}'; + } + const updated = addHudStatusLine(content, devflowDir); + await fs.writeFile(settingsPath, updated, 'utf-8'); + } catch (error) { + p.log.error(`Failed to update settings: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + + // Copy HUD scripts to devflow dir + const rootDir = path.resolve(__dirname, '..', '..'); + const scriptsSource = path.join(rootDir, 'scripts'); + const scriptsTarget = path.join(devflowDir, 'scripts'); + try { + await fs.mkdir(scriptsTarget, { recursive: true }); + // Copy hud.sh + await fs.copyFile( + path.join(scriptsSource, 'hud.sh'), + path.join(scriptsTarget, 'hud.sh'), + ); + // Copy hud/ directory + const hudSource = path.join(scriptsSource, 'hud'); + const hudTarget = path.join(scriptsTarget, 'hud'); + await copyDirectory(hudSource, hudTarget); + if (process.platform !== 'win32') { + await fs.chmod(path.join(scriptsTarget, 'hud.sh'), 0o755); + } + } catch (error) { + p.log.error(`Failed to copy HUD scripts: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + + // Write minimal manifest + const now = new Date().toISOString(); + try { + await writeManifest(devflowDir, { + version, + plugins: [], + scope, + features: { teams: false, ambient: false, memory: false, hud: true }, + installedAt: now, + updatedAt: now, + }); + } catch { /* non-fatal */ } + + p.log.success('HUD installed'); + p.log.info(`Configure later: ${color.cyan('devflow hud --status')}`); + p.outro(color.green('HUD-only install complete.')); + return; + } + // Select plugins to install let selectedPlugins: string[] = []; if (options.plugin) { @@ -254,6 +333,24 @@ export const initCommand = new Command('init') memoryEnabled = memoryChoice; } + // HUD selection (yes/no) + let hudEnabled: boolean; + if (options.hud !== undefined) { + hudEnabled = options.hud; + } else if (!process.stdin.isTTY) { + hudEnabled = true; + } else { + const hudChoice = await p.confirm({ + message: 'Enable HUD status line?', + initialValue: true, + }); + if (p.isCancel(hudChoice)) { + p.cancel('Installation cancelled.'); + process.exit(0); + } + hudEnabled = hudChoice; + } + // Security deny list placement (user scope + TTY only) let securityMode: SecurityMode = 'user'; if (scope === 'user' && process.stdin.isTTY) { @@ -512,6 +609,24 @@ export const initCommand = new Command('init') await createMemoryDir(verbose); await migrateMemoryFiles(verbose); } + + // Configure HUD + const existingHud = loadHudConfig(); + saveHudConfig({ enabled: hudEnabled, detail: existingHud.detail }); + + // Update statusLine in settings.json (add or remove based on choice) + try { + const hudContent = await fs.readFile(settingsPath, 'utf-8'); + const hudUpdated = hudEnabled + ? addHudStatusLine(hudContent, devflowDir) + : removeHudStatusLine(hudContent); + if (hudUpdated !== hudContent) { + await fs.writeFile(settingsPath, hudUpdated, 'utf-8'); + if (verbose) { + p.log.info(`HUD ${hudEnabled ? 'enabled' : 'disabled'}`); + } + } + } catch { /* settings.json may not exist yet */ } } const fileExtras = selectedExtras.filter(e => e !== 'settings' && e !== 'safe-delete'); @@ -620,7 +735,7 @@ export const initCommand = new Command('init') version, plugins: resolvePluginList(installedPluginNames, existingManifest, !!options.plugin), scope, - features: { teams: teamsEnabled, ambient: ambientEnabled, memory: memoryEnabled }, + features: { teams: teamsEnabled, ambient: ambientEnabled, memory: memoryEnabled, hud: hudEnabled }, installedAt: existingManifest?.installedAt ?? now, updatedAt: now, }; diff --git a/src/cli/commands/list.ts b/src/cli/commands/list.ts index 6b5c97a..19c6d76 100644 --- a/src/cli/commands/list.ts +++ b/src/cli/commands/list.ts @@ -16,6 +16,7 @@ export function formatFeatures(features: ManifestData['features']): string { features.teams ? 'teams' : null, features.ambient ? 'ambient' : null, features.memory ? 'memory' : null, + features.hud ? 'hud' : null, ].filter(Boolean).join(', ') || 'none'; } diff --git a/src/cli/commands/uninstall.ts b/src/cli/commands/uninstall.ts index 7fcc881..d467dd1 100644 --- a/src/cli/commands/uninstall.ts +++ b/src/cli/commands/uninstall.ts @@ -11,6 +11,7 @@ import { isClaudeCliAvailable } from '../utils/cli.js'; import { DEVFLOW_PLUGINS, getAllSkillNames, LEGACY_SKILL_NAMES, type PluginDefinition } from '../plugins.js'; import { removeAmbientHook } from './ambient.js'; import { removeMemoryHooks } from './memory.js'; +import { removeHudStatusLine } from './hud.js'; import { listShadowed } from './skills.js'; import { detectShell, getProfilePath } from '../utils/safe-delete.js'; import { isAlreadyInstalled, removeFromProfile } from '../utils/safe-delete-install.js'; @@ -390,25 +391,17 @@ export const uninstallCommand = new Command('uninstall') try { const paths = await getInstallationPaths(scope); const settingsPath = path.join(paths.claudeDir, 'settings.json'); - let settingsContent = await fs.readFile(settingsPath, 'utf-8'); + const originalContent = await fs.readFile(settingsPath, 'utf-8'); - // Always remove ambient hook on full uninstall (idempotent) - const withoutAmbient = removeAmbientHook(settingsContent); - if (withoutAmbient !== settingsContent) { - await fs.writeFile(settingsPath, withoutAmbient, 'utf-8'); - settingsContent = withoutAmbient; - if (verbose) { - p.log.success(`Ambient mode hook removed from settings.json (${scope})`); - } - } + // Remove all DevFlow hooks in one pass (idempotent) + let settingsContent = removeAmbientHook(originalContent); + settingsContent = removeMemoryHooks(settingsContent); + settingsContent = removeHudStatusLine(settingsContent); - // Always remove memory hooks on full uninstall (idempotent) - const withoutMemory = removeMemoryHooks(settingsContent); - if (withoutMemory !== settingsContent) { - await fs.writeFile(settingsPath, withoutMemory, 'utf-8'); - settingsContent = withoutMemory; + if (settingsContent !== originalContent) { + await fs.writeFile(settingsPath, settingsContent, 'utf-8'); if (verbose) { - p.log.success(`Memory hooks removed from settings.json (${scope})`); + p.log.success(`DevFlow hooks removed from settings.json (${scope})`); } } diff --git a/src/cli/hud/cache.ts b/src/cli/hud/cache.ts new file mode 100644 index 0000000..2906baf --- /dev/null +++ b/src/cli/hud/cache.ts @@ -0,0 +1,63 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { homedir } from 'node:os'; + +interface CacheEntry { + data: T; + timestamp: number; + ttl: number; +} + +export function getCacheDir(): string { + const devflowDir = + process.env.DEVFLOW_DIR || path.join(process.env.HOME || homedir(), '.devflow'); + return path.join(devflowDir, 'cache'); +} + +/** + * Read a cached value. Returns null if missing or expired. + * When `ignoreExpiry` is true, returns data regardless of TTL (stale read). + */ +function readCacheEntry(key: string, ignoreExpiry: boolean): T | null { + try { + const filePath = path.join(getCacheDir(), `${key}.json`); + const raw = fs.readFileSync(filePath, 'utf-8'); + const entry = JSON.parse(raw) as CacheEntry; + if (ignoreExpiry || Date.now() - entry.timestamp < entry.ttl) { + return entry.data; + } + return null; + } catch { + return null; + } +} + +/** + * Read a cached value. Returns null if missing or expired. + */ +export function readCache(key: string): T | null { + return readCacheEntry(key, false); +} + +/** + * Read a cached value regardless of TTL (stale data). Returns null if missing. + */ +export function readCacheStale(key: string): T | null { + return readCacheEntry(key, true); +} + +/** + * Write a value to cache with a TTL in milliseconds. + */ +export function writeCache(key: string, data: T, ttlMs: number): void { + try { + const dir = getCacheDir(); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + const entry: CacheEntry = { data, timestamp: Date.now(), ttl: ttlMs }; + fs.writeFileSync(path.join(dir, `${key}.json`), JSON.stringify(entry)); + } catch { + // Cache write failure is non-fatal + } +} diff --git a/src/cli/hud/colors.ts b/src/cli/hud/colors.ts new file mode 100644 index 0000000..d5386e8 --- /dev/null +++ b/src/cli/hud/colors.ts @@ -0,0 +1,66 @@ +/** + * ANSI color helpers — no dependencies, precompiled escape sequences. + * Used by HUD components for direct terminal output (not @clack/prompts). + */ + +const ESC = '\x1b['; +const RESET = `${ESC}0m`; + +export function bold(s: string): string { + return `${ESC}1m${s}${RESET}`; +} +export function dim(s: string): string { + return `${ESC}2m${s}${RESET}`; +} +export function red(s: string): string { + return `${ESC}31m${s}${RESET}`; +} +export function green(s: string): string { + return `${ESC}32m${s}${RESET}`; +} +export function yellow(s: string): string { + return `${ESC}33m${s}${RESET}`; +} +export function blue(s: string): string { + return `${ESC}34m${s}${RESET}`; +} +export function magenta(s: string): string { + return `${ESC}35m${s}${RESET}`; +} +export function cyan(s: string): string { + return `${ESC}36m${s}${RESET}`; +} +export function gray(s: string): string { + return `${ESC}90m${s}${RESET}`; +} +export function white(s: string): string { + return `${ESC}37m${s}${RESET}`; +} +export function orange(s: string): string { + return `${ESC}38;5;208m${s}${RESET}`; +} +export function brightRed(s: string): string { + return `${ESC}91m${s}${RESET}`; +} +export function boldRed(s: string): string { + return `${ESC}1;31m${s}${RESET}`; +} +export function bgGreen(s: string): string { + return `${ESC}42m${s}${RESET}`; +} +export function bgYellow(s: string): string { + return `${ESC}43m${s}${RESET}`; +} +export function bgRed(s: string): string { + return `${ESC}41m${s}${RESET}`; +} + +export function truncate(s: string, max: number): string { + return s.length > max ? s.slice(0, max - 1) + '\u2026' : s; +} + +const ANSI_PATTERN = /\x1b\[[0-9;]*m/g; + +export function stripAnsi(s: string): string { + return s.replace(ANSI_PATTERN, ''); +} diff --git a/src/cli/hud/components/config-counts.ts b/src/cli/hud/components/config-counts.ts new file mode 100644 index 0000000..0534c49 --- /dev/null +++ b/src/cli/hud/components/config-counts.ts @@ -0,0 +1,100 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { homedir } from 'node:os'; +import type { ComponentResult, GatherContext, ConfigCountsData } from '../types.js'; +import { dim } from '../colors.js'; + +function countClaudeMdFiles(cwd: string): number { + let count = 0; + // Check project CLAUDE.md + if (fs.existsSync(path.join(cwd, 'CLAUDE.md'))) count++; + // Check user CLAUDE.md + const claudeDir = + process.env.CLAUDE_CONFIG_DIR || + path.join(process.env.HOME || homedir(), '.claude'); + if (fs.existsSync(path.join(claudeDir, 'CLAUDE.md'))) count++; + return count; +} + +function countFromSettings(settingsPath: string): { + mcpServers: number; + hooks: number; +} { + try { + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as Record; + const mcpServers = settings.mcpServers + ? Object.keys(settings.mcpServers as Record).length + : 0; + let hooks = 0; + if (settings.hooks) { + const hooksObj = settings.hooks as Record; + for (const event of Object.values(hooksObj)) { + if (Array.isArray(event)) hooks += event.length; + } + } + return { mcpServers, hooks }; + } catch { + return { mcpServers: 0, hooks: 0 }; + } +} + +/** + * Gather configuration counts for the configCounts component. + * Exported for use by the main HUD entry point. + */ +export function gatherConfigCounts(cwd: string): ConfigCountsData { + const claudeDir = + process.env.CLAUDE_CONFIG_DIR || + path.join(process.env.HOME || homedir(), '.claude'); + const claudeMdFiles = countClaudeMdFiles(cwd); + + // Count rules (.md/.mdc files in .claude/rules) + let rules = 0; + for (const rulesDir of [ + path.join(cwd, '.claude', 'rules'), + path.join(claudeDir, 'rules'), + ]) { + try { + const files = fs.readdirSync(rulesDir); + rules += files.filter( + (f) => f.endsWith('.md') || f.endsWith('.mdc'), + ).length; + } catch { + /* ignore */ + } + } + + // Aggregate settings from user and project + const userSettings = countFromSettings( + path.join(claudeDir, 'settings.json'), + ); + const projectSettings = countFromSettings( + path.join(cwd, '.claude', 'settings.json'), + ); + + return { + claudeMdFiles, + rules, + mcpServers: userSettings.mcpServers + projectSettings.mcpServers, + hooks: userSettings.hooks + projectSettings.hooks, + }; +} + +export default async function configCounts( + ctx: GatherContext, +): Promise { + if (!ctx.configCounts) return null; + const { claudeMdFiles, rules: ruleCount, mcpServers, hooks: hookCount } = + ctx.configCounts; + const skillCount = ctx.transcript?.skills.length ?? 0; + const parts: string[] = []; + if (claudeMdFiles > 0) parts.push(`${claudeMdFiles} CLAUDE.md`); + if (ruleCount > 0) parts.push(`${ruleCount} rules`); + if (mcpServers > 0) parts.push(`${mcpServers} MCPs`); + if (hookCount > 0) parts.push(`${hookCount} hooks`); + if (skillCount > 0) parts.push(`${skillCount} skills`); + if (parts.length === 0) return null; + const raw = parts.join(' \u00B7 '); + const text = parts.map((p) => dim(p)).join(dim(' \u00B7 ')); + return { text, raw }; +} diff --git a/src/cli/hud/components/context-usage.ts b/src/cli/hud/components/context-usage.ts new file mode 100644 index 0000000..479e7de --- /dev/null +++ b/src/cli/hud/components/context-usage.ts @@ -0,0 +1,57 @@ +import type { ComponentResult, GatherContext } from '../types.js'; +import { dim, green, yellow, red } from '../colors.js'; + +const BAR_WIDTH = 8; + +/** + * Context window usage component. + * Visual bar with 3-tier color gradient matching usage-quota: green / yellow / red. + * At >85% appends token breakdown: (in: Nk). + */ +export default async function contextUsage( + ctx: GatherContext, +): Promise { + const cw = ctx.stdin.context_window; + if (!cw) return null; + + let pct: number | null = null; + if (cw.used_percentage !== undefined) { + pct = Math.round(cw.used_percentage); + } else if (cw.context_window_size && cw.current_usage?.input_tokens !== undefined) { + pct = Math.round((cw.current_usage.input_tokens / cw.context_window_size) * 100); + } + + if (pct === null) return null; + + const filled = Math.round((pct / 100) * BAR_WIDTH); + const empty = BAR_WIDTH - filled; + + let colorFn: (s: string) => string; + if (pct < 50) { + colorFn = green; + } else if (pct < 80) { + colorFn = yellow; + } else { + colorFn = red; + } + + const filledBar = '\u2588'.repeat(filled); + const emptyBar = '\u2591'.repeat(empty); + + let suffix = ''; + if (pct > 85 && cw.current_usage?.input_tokens !== undefined) { + const inK = Math.round(cw.current_usage.input_tokens / 1000); + suffix = ` (in: ${inK}k)`; + } + + const raw = `Current Session ${filledBar}${emptyBar} ${pct}%${suffix}`; + const text = + dim('Current Session ') + + colorFn(filledBar) + + dim(emptyBar) + + ' ' + + colorFn(`${pct}%`) + + (suffix ? dim(suffix) : ''); + + return { text, raw }; +} diff --git a/src/cli/hud/components/diff-stats.ts b/src/cli/hud/components/diff-stats.ts new file mode 100644 index 0000000..7a5b2b9 --- /dev/null +++ b/src/cli/hud/components/diff-stats.ts @@ -0,0 +1,37 @@ +import type { ComponentResult, GatherContext } from '../types.js'; +import { dim, green, red } from '../colors.js'; + +export default async function diffStats( + ctx: GatherContext, +): Promise { + if (!ctx.git) return null; + const { filesChanged, additions, deletions } = ctx.git; + if (filesChanged === 0 && additions === 0 && deletions === 0) return null; + const filePart = filesChanged > 0 + ? `${filesChanged} file${filesChanged === 1 ? '' : 's'}` + : ''; + const lineParts: string[] = []; + if (additions > 0) lineParts.push(`+${additions}`); + if (deletions > 0) lineParts.push(`-${deletions}`); + + const sections: string[] = []; + const rawSections: string[] = []; + + if (filePart) { + sections.push(dim(filePart)); + rawSections.push(filePart); + } + if (lineParts.length > 0) { + const lineText = lineParts + .map((p) => (p.startsWith('+') ? green(p) : red(p))) + .join(' '); + sections.push(lineText); + rawSections.push(lineParts.join(' ')); + } + + if (sections.length === 0) return null; + return { + text: sections.join(dim(' \u00B7 ')), + raw: rawSections.join(' \u00B7 '), + }; +} diff --git a/src/cli/hud/components/directory.ts b/src/cli/hud/components/directory.ts new file mode 100644 index 0000000..87ffeab --- /dev/null +++ b/src/cli/hud/components/directory.ts @@ -0,0 +1,12 @@ +import type { ComponentResult, GatherContext } from '../types.js'; +import { bold, white } from '../colors.js'; +import * as path from 'node:path'; + +export default async function directory( + ctx: GatherContext, +): Promise { + const cwd = ctx.stdin.cwd; + if (!cwd) return null; + const name = path.basename(cwd); + return { text: bold(white(name)), raw: name }; +} diff --git a/src/cli/hud/components/git-ahead-behind.ts b/src/cli/hud/components/git-ahead-behind.ts new file mode 100644 index 0000000..31066d1 --- /dev/null +++ b/src/cli/hud/components/git-ahead-behind.ts @@ -0,0 +1,15 @@ +import type { ComponentResult, GatherContext } from '../types.js'; +import { dim } from '../colors.js'; + +export default async function gitAheadBehind( + ctx: GatherContext, +): Promise { + if (!ctx.git) return null; + const { ahead, behind } = ctx.git; + if (ahead === 0 && behind === 0) return null; + const rawParts: string[] = []; + if (ahead > 0) rawParts.push(`${ahead}\u2191`); + if (behind > 0) rawParts.push(`${behind}\u2193`); + const raw = rawParts.join(' '); + return { text: dim(raw), raw }; +} diff --git a/src/cli/hud/components/git-branch.ts b/src/cli/hud/components/git-branch.ts new file mode 100644 index 0000000..4e90506 --- /dev/null +++ b/src/cli/hud/components/git-branch.ts @@ -0,0 +1,17 @@ +import type { ComponentResult, GatherContext } from '../types.js'; +import { white, yellow, green } from '../colors.js'; + +export default async function gitBranch( + ctx: GatherContext, +): Promise { + if (!ctx.git) return null; + const dirtyMark = ctx.git.dirty ? '*' : ''; + const stagedMark = ctx.git.staged ? '+' : ''; + const indicator = dirtyMark + stagedMark; + const text = + white(ctx.git.branch) + + (dirtyMark ? yellow(dirtyMark) : '') + + (stagedMark ? green(stagedMark) : ''); + const raw = ctx.git.branch + indicator; + return { text, raw }; +} diff --git a/src/cli/hud/components/model.ts b/src/cli/hud/components/model.ts new file mode 100644 index 0000000..7f494e5 --- /dev/null +++ b/src/cli/hud/components/model.ts @@ -0,0 +1,25 @@ +import type { ComponentResult, GatherContext } from '../types.js'; +import { dim, white } from '../colors.js'; + +export default async function model( + ctx: GatherContext, +): Promise { + const name = ctx.stdin.model?.display_name; + if (!name) return null; + // Strip "Claude " prefix and trailing context info like "(1M context)" for brevity + const short = name + .replace(/^Claude\s+/i, '') + .replace(/\s*\(\d+[KkMm]\s*context\)\s*$/, ''); + + const cwSize = ctx.stdin.context_window?.context_window_size; + let sizeStr = ''; + if (cwSize) { + sizeStr = + cwSize >= 1_000_000 + ? ` [${Math.round(cwSize / 1_000_000)}m]` + : ` [${Math.round(cwSize / 1000)}k]`; + } + + const raw = short + sizeStr; + return { text: white(short) + (sizeStr ? dim(sizeStr) : ''), raw }; +} diff --git a/src/cli/hud/components/release-info.ts b/src/cli/hud/components/release-info.ts new file mode 100644 index 0000000..f68f48e --- /dev/null +++ b/src/cli/hud/components/release-info.ts @@ -0,0 +1,11 @@ +import type { ComponentResult, GatherContext } from '../types.js'; +import { dim } from '../colors.js'; + +export default async function releaseInfo( + ctx: GatherContext, +): Promise { + if (!ctx.git?.lastTag) return null; + const { lastTag, commitsSinceTag } = ctx.git; + const raw = commitsSinceTag > 0 ? `${lastTag} +${commitsSinceTag}` : lastTag; + return { text: dim(raw), raw }; +} diff --git a/src/cli/hud/components/session-cost.ts b/src/cli/hud/components/session-cost.ts new file mode 100644 index 0000000..16cff05 --- /dev/null +++ b/src/cli/hud/components/session-cost.ts @@ -0,0 +1,11 @@ +import type { ComponentResult, GatherContext } from '../types.js'; +import { dim } from '../colors.js'; + +export default async function sessionCost( + ctx: GatherContext, +): Promise { + const cost = ctx.stdin.cost?.total_cost_usd; + if (cost == null) return null; + const formatted = `$${cost.toFixed(2)}`; + return { text: dim(formatted), raw: formatted }; +} diff --git a/src/cli/hud/components/session-duration.ts b/src/cli/hud/components/session-duration.ts new file mode 100644 index 0000000..9145f85 --- /dev/null +++ b/src/cli/hud/components/session-duration.ts @@ -0,0 +1,19 @@ +import type { ComponentResult, GatherContext } from '../types.js'; +import { dim } from '../colors.js'; + +export default async function sessionDuration( + ctx: GatherContext, +): Promise { + if (!ctx.sessionStartTime) return null; + const elapsed = Math.floor((Date.now() - ctx.sessionStartTime) / 1000); + const minutes = Math.floor(elapsed / 60); + const hours = Math.floor(minutes / 60); + let label: string; + if (hours > 0) { + label = `${hours}h ${minutes % 60}m`; + } else { + label = `${minutes}m`; + } + const text = `\u23F1 ${label}`; + return { text: dim(text), raw: text }; +} diff --git a/src/cli/hud/components/todo-progress.ts b/src/cli/hud/components/todo-progress.ts new file mode 100644 index 0000000..856096c --- /dev/null +++ b/src/cli/hud/components/todo-progress.ts @@ -0,0 +1,12 @@ +import type { ComponentResult, GatherContext } from '../types.js'; +import { dim } from '../colors.js'; + +export default async function todoProgress( + ctx: GatherContext, +): Promise { + if (!ctx.transcript) return null; + const { todos } = ctx.transcript; + if (todos.total === 0) return null; + const label = `${todos.completed}/${todos.total} todos`; + return { text: dim(label), raw: label }; +} diff --git a/src/cli/hud/components/usage-quota.ts b/src/cli/hud/components/usage-quota.ts new file mode 100644 index 0000000..8c634ed --- /dev/null +++ b/src/cli/hud/components/usage-quota.ts @@ -0,0 +1,53 @@ +import type { ComponentResult, GatherContext } from '../types.js'; +import { green, yellow, red, dim } from '../colors.js'; + +const BAR_WIDTH = 8; + +function renderBar(percent: number): { text: string; raw: string } { + const filled = Math.round((percent / 100) * BAR_WIDTH); + const empty = BAR_WIDTH - filled; + + let colorFn: (s: string) => string; + if (percent < 50) { + colorFn = green; + } else if (percent < 80) { + colorFn = yellow; + } else { + colorFn = red; + } + + const filledBar = '\u2588'.repeat(filled); + const emptyBar = '\u2591'.repeat(empty); + const text = + colorFn(filledBar) + + dim(emptyBar) + + ' ' + + colorFn(`${percent}%`); + const raw = `${filledBar}${emptyBar} ${percent}%`; + return { text, raw }; +} + +export default async function usageQuota( + ctx: GatherContext, +): Promise { + if (!ctx.usage) return null; + + const { fiveHourPercent, sevenDayPercent } = ctx.usage; + const parts: { text: string; raw: string }[] = []; + + if (fiveHourPercent !== null) { + const bar = renderBar(Math.round(fiveHourPercent)); + parts.push({ text: dim('5h ') + bar.text, raw: `5h ${bar.raw}` }); + } + if (sevenDayPercent !== null) { + const bar = renderBar(Math.round(sevenDayPercent)); + parts.push({ text: dim('7d ') + bar.text, raw: `7d ${bar.raw}` }); + } + + if (parts.length === 0) return null; + + const sep = dim(' \u00B7 '); + const text = dim('Session ') + parts.map((p) => p.text).join(sep); + const raw = 'Session ' + parts.map((p) => p.raw).join(' \u00B7 '); + return { text, raw }; +} diff --git a/src/cli/hud/components/version-badge.ts b/src/cli/hud/components/version-badge.ts new file mode 100644 index 0000000..b2a14e4 --- /dev/null +++ b/src/cli/hud/components/version-badge.ts @@ -0,0 +1,100 @@ +import { execFile } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { ComponentResult, GatherContext } from '../types.js'; +import { yellow } from '../colors.js'; +import { readCache, writeCache } from '../cache.js'; + +const VERSION_CACHE_KEY = 'version-check'; +const VERSION_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours + +interface VersionInfo { + current: string; + latest: string; +} + +function getCurrentVersion(devflowDir: string): string | null { + // Try manifest.json first (most reliable for installed version) + try { + const manifestPath = path.join(devflowDir, 'manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as Record; + if (typeof manifest.version === 'string') return manifest.version; + } catch { + // Fall through + } + + // Try package.json as fallback + try { + const pkgPath = path.join( + path.dirname(new URL(import.meta.url).pathname), + '..', + '..', + '..', + 'package.json', + ); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) as Record; + if (typeof pkg.version === 'string') return pkg.version; + } catch { + // Fall through + } + + return null; +} + +function fetchLatestVersion(): Promise { + return new Promise((resolve) => { + execFile( + 'npm', + ['view', 'devflow-kit', 'version', '--json'], + { timeout: 5000 }, + (err, stdout) => { + if (err) { + resolve(null); + return; + } + try { + const parsed = JSON.parse(stdout.trim()); + resolve(typeof parsed === 'string' ? parsed : null); + } catch { + const trimmed = stdout.trim(); + resolve(trimmed || null); + } + }, + ); + }); +} + +function compareVersions(current: string, latest: string): number { + const a = current.split('.').map(Number); + const b = latest.split('.').map(Number); + for (let i = 0; i < 3; i++) { + if ((a[i] || 0) < (b[i] || 0)) return -1; + if ((a[i] || 0) > (b[i] || 0)) return 1; + } + return 0; +} + +export default async function versionBadge( + ctx: GatherContext, +): Promise { + const current = getCurrentVersion(ctx.devflowDir); + if (!current) return null; + + // Check cache + let info = readCache(VERSION_CACHE_KEY); + if (!info) { + const latest = await fetchLatestVersion(); + if (latest) { + info = { current, latest }; + writeCache(VERSION_CACHE_KEY, info, VERSION_CACHE_TTL); + } + } + + if (info && compareVersions(info.current, info.latest) < 0) { + const badge = `\u2726 Devflow v${info.latest} \u00B7 update: npx devflow-kit init`; + return { text: yellow(badge), raw: badge }; + } + + // Don't show version when up to date + return null; +} diff --git a/src/cli/hud/components/worktree-count.ts b/src/cli/hud/components/worktree-count.ts new file mode 100644 index 0000000..7595464 --- /dev/null +++ b/src/cli/hud/components/worktree-count.ts @@ -0,0 +1,10 @@ +import type { ComponentResult, GatherContext } from '../types.js'; +import { dim } from '../colors.js'; + +export default async function worktreeCount( + ctx: GatherContext, +): Promise { + if (!ctx.git || ctx.git.worktreeCount <= 1) return null; + const raw = `${ctx.git.worktreeCount} worktrees`; + return { text: dim(raw), raw }; +} diff --git a/src/cli/hud/config.ts b/src/cli/hud/config.ts new file mode 100644 index 0000000..c1ebc3a --- /dev/null +++ b/src/cli/hud/config.ts @@ -0,0 +1,59 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { homedir } from 'node:os'; +import type { HudConfig, ComponentId } from './types.js'; + +/** + * All 14 HUD components in display order. + */ +export const HUD_COMPONENTS: readonly ComponentId[] = [ + 'directory', + 'gitBranch', + 'gitAheadBehind', + 'diffStats', + 'releaseInfo', + 'worktreeCount', + 'model', + 'contextUsage', + 'versionBadge', + 'sessionDuration', + 'sessionCost', + 'usageQuota', + 'todoProgress', + 'configCounts', +]; + +export function getConfigPath(): string { + const devflowDir = + process.env.DEVFLOW_DIR || path.join(process.env.HOME || homedir(), '.devflow'); + return path.join(devflowDir, 'hud.json'); +} + +export function loadConfig(): HudConfig { + const configPath = getConfigPath(); + try { + const raw = fs.readFileSync(configPath, 'utf-8'); + const parsed = JSON.parse(raw) as Partial; + return { + enabled: parsed.enabled !== false, + detail: parsed.detail === true, + }; + } catch { + return { enabled: true, detail: false }; + } +} + +export function saveConfig(config: HudConfig): void { + const configPath = getConfigPath(); + const dir = path.dirname(configPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n'); +} + +export function resolveComponents(config: HudConfig): ComponentId[] { + if (config.enabled) return [...HUD_COMPONENTS]; + // Version badge always renders so users see upgrade notifications + return ['versionBadge']; +} diff --git a/src/cli/hud/credentials.ts b/src/cli/hud/credentials.ts new file mode 100644 index 0000000..2545f66 --- /dev/null +++ b/src/cli/hud/credentials.ts @@ -0,0 +1,115 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { homedir } from 'node:os'; +import { execFile } from 'node:child_process'; + +const KEYCHAIN_TIMEOUT = 3000; // 3s + +export interface OAuthCredentials { + accessToken: string; + subscriptionType?: string; +} + +const DEBUG = !!process.env.DEVFLOW_HUD_DEBUG; + +function debugLog(msg: string, data?: Record): void { + if (!DEBUG) return; + const entry = { ts: new Date().toISOString(), source: 'credentials', msg, ...data }; + fs.appendFileSync('/tmp/hud-debug.log', JSON.stringify(entry) + '\n'); +} + +/** Resolve the Claude config directory, respecting CLAUDE_CONFIG_DIR. */ +export function getClaudeDir(): string { + return ( + process.env.CLAUDE_CONFIG_DIR || + path.join(process.env.HOME || homedir(), '.claude') + ); +} + +/** Read OAuth credentials from ~/.claude/.credentials.json. Injectable claudeDir for tests. */ +export function readCredentialsFile(claudeDir?: string): OAuthCredentials | null { + try { + const dir = claudeDir ?? getClaudeDir(); + const filePath = path.join(dir, '.credentials.json'); + const raw = fs.readFileSync(filePath, 'utf-8'); + const creds = JSON.parse(raw) as Record; + const oauth = creds.claudeAiOauth as Record | undefined; + const accessToken = oauth?.accessToken; + if (typeof accessToken !== 'string' || !accessToken) return null; + const subscriptionType = + typeof oauth?.subscriptionType === 'string' ? oauth.subscriptionType : undefined; + debugLog('credentials file read', { filePath, hasSubscriptionType: !!subscriptionType }); + return { accessToken, subscriptionType }; + } catch { + return null; + } +} + +/** Read OAuth token from macOS Keychain. Returns null on non-darwin or failure. */ +export function readKeychainToken(): Promise { + if (process.platform !== 'darwin') return Promise.resolve(null); + + return new Promise((resolve) => { + execFile( + '/usr/bin/security', + ['find-generic-password', '-s', 'Claude Code-credentials', '-w'], + { timeout: KEYCHAIN_TIMEOUT }, + (err, stdout) => { + if (err || !stdout.trim()) { + debugLog('keychain read failed', { error: err?.message }); + resolve(null); + return; + } + try { + const parsed = JSON.parse(stdout.trim()) as Record; + const oauth = parsed.claudeAiOauth as Record | undefined; + const token = oauth?.accessToken; + if (typeof token === 'string' && token) { + debugLog('keychain token found'); + resolve(token); + } else { + debugLog('keychain: no accessToken in parsed data'); + resolve(null); + } + } catch { + // Keychain value might be the raw token string + const trimmed = stdout.trim(); + if (trimmed.length > 20) { + debugLog('keychain: raw token string'); + resolve(trimmed); + } else { + debugLog('keychain: unparseable value'); + resolve(null); + } + } + }, + ); + }); +} + +/** + * Get OAuth credentials using platform-appropriate strategy. + * macOS: Keychain first, then file fallback. Other platforms: file only. + * Hybrid: if Keychain has token but no subscriptionType, merge from file. + */ +export async function getCredentials(): Promise { + const fileCreds = readCredentialsFile(); + + if (process.platform !== 'darwin') { + debugLog('non-darwin: file credentials only', { found: !!fileCreds }); + return fileCreds; + } + + // macOS: try Keychain first + const keychainToken = await readKeychainToken(); + if (keychainToken) { + // Merge subscriptionType from file if Keychain doesn't have it + const subscriptionType = fileCreds?.subscriptionType; + debugLog('using keychain token', { hasSubscriptionType: !!subscriptionType }); + return { accessToken: keychainToken, subscriptionType }; + } + + // Fallback to file + debugLog('keychain failed, falling back to file', { found: !!fileCreds }); + return fileCreds; +} diff --git a/src/cli/hud/git.ts b/src/cli/hud/git.ts new file mode 100644 index 0000000..655f14d --- /dev/null +++ b/src/cli/hud/git.ts @@ -0,0 +1,179 @@ +import { execFile } from 'node:child_process'; +import type { GitStatus } from './types.js'; + +const GIT_TIMEOUT = 1000; // 1s per command + +function shellExec(cmd: string, args: string[], cwd: string): Promise { + return new Promise((resolve) => { + execFile(cmd, args, { cwd, timeout: GIT_TIMEOUT }, (err, stdout) => { + resolve(err ? '' : stdout.trim()); + }); + }); +} + +function gitExec(args: string[], cwd: string): Promise { + return shellExec('git', args, cwd); +} + +/** + * Gather git status for the given working directory. + * Returns null if not in a git repo or on error. + */ +export async function gatherGitStatus(cwd: string): Promise { + // Check if in a git repo + const topLevel = await gitExec(['rev-parse', '--show-toplevel'], cwd); + if (!topLevel) return null; + + // Branch name + const branch = await gitExec(['rev-parse', '--abbrev-ref', 'HEAD'], cwd); + if (!branch) return null; + + // Dirty check + const statusOutput = await gitExec( + ['status', '--porcelain', '--no-optional-locks'], + cwd, + ); + let dirty = false; + let staged = false; + for (const line of statusOutput.split('\n')) { + if (line.length < 2) continue; + const index = line[0]; + const worktree = line[1]; + // Index column: staged change (A/M/D/R/C) + if (index !== ' ' && index !== '?') staged = true; + // Worktree column: unstaged change (M/D), or untracked (??) + if (worktree !== ' ' || index === '?') dirty = true; + } + + // Ahead/behind — detect base branch with layered fallback (ported from statusline.sh) + const baseBranch = await detectBaseBranch(branch, cwd); + let ahead = 0; + let behind = 0; + if (baseBranch) { + const revList = await gitExec( + ['rev-list', '--left-right', '--count', `${baseBranch}...HEAD`], + cwd, + ); + const parts = revList.split(/\s+/); + if (parts.length === 2) { + behind = parseInt(parts[0], 10) || 0; + ahead = parseInt(parts[1], 10) || 0; + } + } + + // Diff stats against base + let filesChanged = 0; + let additions = 0; + let deletions = 0; + if (baseBranch) { + const diffStat = await gitExec(['diff', '--shortstat', baseBranch], cwd); + const filesMatch = diffStat.match(/(\d+)\s+file/); + const addMatch = diffStat.match(/(\d+)\s+insertion/); + const delMatch = diffStat.match(/(\d+)\s+deletion/); + filesChanged = filesMatch ? parseInt(filesMatch[1], 10) : 0; + additions = addMatch ? parseInt(addMatch[1], 10) : 0; + deletions = delMatch ? parseInt(delMatch[1], 10) : 0; + } + + // Tag and worktree info (parallel) + const [tagOutput, worktreeOutput] = await Promise.all([ + gitExec(['describe', '--tags', '--abbrev=0'], cwd), + gitExec(['worktree', 'list'], cwd), + ]); + + const lastTag = tagOutput || null; + let commitsSinceTag = 0; + if (lastTag) { + const countOutput = await gitExec(['rev-list', `${lastTag}..HEAD`, '--count'], cwd); + commitsSinceTag = parseInt(countOutput, 10) || 0; + } + + const worktreeCount = worktreeOutput + ? worktreeOutput.split('\n').filter(l => l.trim().length > 0).length + : 1; + + return { + branch, + dirty, + staged, + ahead, + behind, + filesChanged, + additions, + deletions, + lastTag, + commitsSinceTag, + worktreeCount, + }; +} + +/** + * Detect the base branch for ahead/behind calculations. + * Uses a 4-layer fallback (ported from statusline.sh): + * 1. Branch reflog ("Created from") + * 2. HEAD reflog ("checkout: moving from X to branch") + * 3. GitHub PR base branch (gh pr view, cached) + * 4. main/master fallback + */ +async function detectBaseBranch( + branch: string, + cwd: string, +): Promise { + // Layer 1: branch reflog — look for "branch: Created from" + const branchLog = await gitExec( + ['reflog', 'show', branch, '--format=%gs', '-n', '1'], + cwd, + ); + const createdMatch = branchLog.match(/branch: Created from (.+)/); + if (createdMatch) { + const candidate = createdMatch[1]; + if (candidate !== 'HEAD' && !candidate.includes('~')) { + const exists = await gitExec( + ['rev-parse', '--verify', candidate], + cwd, + ); + if (exists) return candidate; + } + } + + // Layer 2: HEAD reflog — look for "checkout: moving from X to branch" + const headLog = await gitExec( + ['reflog', 'show', 'HEAD', '--format=%gs'], + cwd, + ); + const escapedBranch = branch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const checkoutPattern = new RegExp( + `checkout: moving from (\\S+) to ${escapedBranch}`, + ); + for (const line of headLog.split('\n')) { + const match = line.match(checkoutPattern); + if (match) { + const candidate = match[1]; + if (candidate !== branch) { + const exists = await gitExec( + ['rev-parse', '--verify', candidate], + cwd, + ); + if (exists) return candidate; + } + } + } + + // Layer 3: GitHub PR base branch via gh CLI + const prBase = await shellExec( + 'gh', ['pr', 'view', '--json', 'baseRefName', '-q', '.baseRefName'], + cwd, + ); + if (prBase) { + const exists = await gitExec(['rev-parse', '--verify', prBase], cwd); + if (exists) return prBase; + } + + // Layer 4: main/master fallback + for (const candidate of ['main', 'master']) { + const exists = await gitExec(['rev-parse', '--verify', candidate], cwd); + if (exists) return candidate; + } + + return null; +} diff --git a/src/cli/hud/index.ts b/src/cli/hud/index.ts new file mode 100644 index 0000000..8ff3c03 --- /dev/null +++ b/src/cli/hud/index.ts @@ -0,0 +1,98 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { homedir } from 'node:os'; +import { readStdin } from './stdin.js'; +import { loadConfig, resolveComponents } from './config.js'; +import { gatherGitStatus } from './git.js'; +import { parseTranscript } from './transcript.js'; +import { fetchUsageData } from './usage-api.js'; +import { gatherConfigCounts } from './components/config-counts.js'; +import { render } from './render.js'; +import type { GatherContext } from './types.js'; + +const OVERALL_TIMEOUT = 2000; // 2 second overall timeout + +async function main(): Promise { + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('timeout')), OVERALL_TIMEOUT), + ); + + try { + const result = await Promise.race([run(), timeoutPromise]); + process.stdout.write(result); + } catch { + // Timeout or error — output nothing (graceful degradation) + } +} + +async function run(): Promise { + const stdin = await readStdin(); + + // Debug: dump raw stdin to file when DEVFLOW_HUD_DEBUG is set + if (process.env.DEVFLOW_HUD_DEBUG) { + fs.writeFileSync(process.env.DEVFLOW_HUD_DEBUG, JSON.stringify(stdin, null, 2)); + } + + const config = loadConfig(); + const resolved = resolveComponents(config); + const components = new Set(resolved); + const cwd = stdin.cwd || process.cwd(); + const devflowDir = + process.env.DEVFLOW_DIR || + path.join(process.env.HOME || homedir(), '.devflow'); + + // Determine what data to gather based on enabled components + const needsGit = + components.has('gitBranch') || + components.has('gitAheadBehind') || + components.has('diffStats') || + components.has('releaseInfo') || + components.has('worktreeCount'); + const needsTranscript = + components.has('todoProgress') || + components.has('configCounts'); + const needsUsage = components.has('usageQuota'); + const needsConfigCounts = components.has('configCounts'); + + // Parallel data gathering — only fetch what's needed + const [git, transcript, usage] = await Promise.all([ + needsGit ? gatherGitStatus(cwd) : Promise.resolve(null), + needsTranscript && stdin.transcript_path + ? parseTranscript(stdin.transcript_path) + : Promise.resolve(null), + needsUsage ? fetchUsageData() : Promise.resolve(null), + ]); + + // Session start time from transcript file creation time + let sessionStartTime: number | null = null; + if (stdin.transcript_path) { + try { + const stat = fs.statSync(stdin.transcript_path); + sessionStartTime = stat.birthtime.getTime(); + } catch { /* file may not exist yet */ } + } + + // Config counts (fast, synchronous filesystem reads) + const configCountsData = needsConfigCounts + ? gatherConfigCounts(cwd) + : null; + + // Terminal width via stderr (stdout is piped to Claude Code) + const terminalWidth = process.stderr.columns || 120; + + const ctx: GatherContext = { + stdin, + git, + transcript, + usage, + configCounts: configCountsData, + config: { ...config, components: resolved } as GatherContext['config'], + devflowDir, + sessionStartTime, + terminalWidth, + }; + + return render(ctx); +} + +main(); diff --git a/src/cli/hud/render.ts b/src/cli/hud/render.ts new file mode 100644 index 0000000..d337009 --- /dev/null +++ b/src/cli/hud/render.ts @@ -0,0 +1,127 @@ +import type { + ComponentId, + ComponentResult, + GatherContext, + ComponentFn, +} from './types.js'; +import { dim } from './colors.js'; + +import directory from './components/directory.js'; +import gitBranch from './components/git-branch.js'; +import gitAheadBehind from './components/git-ahead-behind.js'; +import diffStats from './components/diff-stats.js'; +import model from './components/model.js'; +import contextUsage from './components/context-usage.js'; +import versionBadge from './components/version-badge.js'; +import sessionDuration from './components/session-duration.js'; +import usageQuota from './components/usage-quota.js'; +import todoProgress from './components/todo-progress.js'; +import configCounts from './components/config-counts.js'; +import sessionCost from './components/session-cost.js'; +import releaseInfo from './components/release-info.js'; +import worktreeCount from './components/worktree-count.js'; + +const COMPONENT_MAP: Record = { + directory, + gitBranch, + gitAheadBehind, + diffStats, + model, + contextUsage, + versionBadge, + sessionDuration, + usageQuota, + todoProgress, + configCounts, + sessionCost, + releaseInfo, + worktreeCount, +}; + +/** + * Line groupings for smart layout. + * Components are assigned to lines and only rendered if enabled. + * null entries denote section breaks (blank line between sections). + */ +const LINE_GROUPS: (ComponentId[] | null)[] = [ + // Section 1: Info (3 lines) + ['directory', 'gitBranch', 'gitAheadBehind', 'releaseInfo', 'worktreeCount', 'diffStats'], + ['contextUsage', 'usageQuota'], + ['model', 'sessionDuration', 'sessionCost', 'configCounts'], + // --- section break --- + null, + // Section 2: Activity + ['todoProgress'], + ['versionBadge'], +]; + +const SEPARATOR = dim(' \u00B7 '); + +/** + * Render all enabled components into a multi-line HUD string. + * Components that return null are excluded. Empty lines are skipped. + */ +export async function render(ctx: GatherContext): Promise { + const enabled = new Set(ctx.config.components); + + // Render all enabled components in parallel + const results = new Map(); + const promises: Promise[] = []; + + for (const id of enabled) { + const fn = COMPONENT_MAP[id]; + if (!fn) continue; + promises.push( + fn(ctx) + .then((result) => { + if (result) results.set(id, result); + }) + .catch(() => { + /* Component failure is non-fatal */ + }), + ); + } + + await Promise.all(promises); + + // Assemble lines using smart layout with section breaks + const lines: string[] = []; + let pendingBreak = false; + + for (const entry of LINE_GROUPS) { + if (entry === null) { + if (lines.length > 0) pendingBreak = true; + continue; + } + + const lineResults = entry + .filter((id) => enabled.has(id) && results.has(id)) + .map((id) => results.get(id)!); + + if (lineResults.length > 0) { + if (pendingBreak) { + lines.push(''); + pendingBreak = false; + } + // Separate multi-line results (containing newlines) from single-line + const singleLine: string[] = []; + for (const r of lineResults) { + if (r.text.includes('\n')) { + // Flush any accumulated single-line parts first + if (singleLine.length > 0) { + lines.push(singleLine.join(SEPARATOR)); + singleLine.length = 0; + } + lines.push(r.text); + } else { + singleLine.push(r.text); + } + } + if (singleLine.length > 0) { + lines.push(singleLine.join(SEPARATOR)); + } + } + } + + return lines.join('\n'); +} diff --git a/src/cli/hud/stdin.ts b/src/cli/hud/stdin.ts new file mode 100644 index 0000000..714d4b8 --- /dev/null +++ b/src/cli/hud/stdin.ts @@ -0,0 +1,25 @@ +import type { StdinData } from './types.js'; + +/** + * Read and parse JSON from stdin. Returns empty object on parse failure. + */ +export function readStdin(): Promise { + return new Promise((resolve) => { + let data = ''; + process.stdin.setEncoding('utf-8'); + process.stdin.on('data', (chunk: string) => { + data += chunk; + }); + process.stdin.on('end', () => { + try { + resolve(JSON.parse(data) as StdinData); + } catch { + resolve({}); + } + }); + process.stdin.on('error', () => { + resolve({}); + }); + process.stdin.resume(); + }); +} diff --git a/src/cli/hud/transcript.ts b/src/cli/hud/transcript.ts new file mode 100644 index 0000000..bb3aa1c --- /dev/null +++ b/src/cli/hud/transcript.ts @@ -0,0 +1,158 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as readline from 'node:readline'; +import type { TranscriptData } from './types.js'; + +// Precompiled patterns +const TODO_WRITE_NAME = /^TodoWrite$/i; + +/** + * Parse a Claude Code session transcript (JSONL) to extract tool/agent activity and todo progress. + * Returns null if the file doesn't exist or can't be parsed. + */ +export async function parseTranscript( + transcriptPath: string, +): Promise { + try { + if (!fs.existsSync(transcriptPath)) return null; + + const tools = new Map< + string, + { name: string; status: 'running' | 'completed'; target?: string; description?: string } + >(); + const agents = new Map< + string, + { name: string; model?: string; status: 'running' | 'completed'; description?: string } + >(); + const skills = new Set(); + let todosCompleted = 0; + let todosTotal = 0; + + const stream = fs.createReadStream(transcriptPath, { encoding: 'utf-8' }); + const rl = readline.createInterface({ + input: stream, + crlfDelay: Infinity, + }); + + for await (const line of rl) { + if (!line.trim()) continue; + try { + const entry = JSON.parse(line) as Record; + + // Turn boundary: clear tools and agents on each human message + // so only the current turn's activity shows in the HUD + if (entry.type === 'human') { + tools.clear(); + agents.clear(); + continue; + } + + const todoResult = processEntry(entry, tools, agents, skills); + if (todoResult) { + todosCompleted = todoResult.completed; + todosTotal = todoResult.total; + } + } catch { + // Skip malformed lines + } + } + + return { + tools: Array.from(tools.values()), + agents: Array.from(agents.values()), + todos: { completed: todosCompleted, total: todosTotal }, + skills: Array.from(skills), + }; + } catch { + return null; + } +} + +interface TodoResult { + completed: number; + total: number; +} + +function processEntry( + entry: Record, + tools: Map, + agents: Map< + string, + { name: string; model?: string; status: 'running' | 'completed'; description?: string } + >, + skills: Set, +): TodoResult | null { + if (entry.type !== 'assistant' || !entry.message) return null; + const message = entry.message as { + content?: Array>; + }; + if (!Array.isArray(message.content)) return null; + + let todoResult: TodoResult | null = null; + + for (const block of message.content) { + const blockType = block.type as string; + + if (blockType === 'tool_use') { + const name = block.name as string; + const id = block.id as string; + + if (name === 'Agent' || name === 'Task') { + // Agent spawn + const input = block.input as Record | undefined; + const agentType = (input?.subagent_type as string) || 'Agent'; + const agentDesc = typeof input?.description === 'string' ? input.description : undefined; + agents.set(id, { + name: agentType, + model: input?.model as string | undefined, + status: 'running', + description: agentDesc, + }); + } else if (name === 'Skill') { + // Track loaded skills + const input = block.input as Record | undefined; + if (input?.skill && typeof input.skill === 'string') { + skills.add(input.skill); + } + } else if (TODO_WRITE_NAME.test(name)) { + // Track todos + const input = block.input as Record | undefined; + const todos = input?.todos; + if (Array.isArray(todos)) { + const total = todos.length; + const completed = todos.filter( + (t: Record) => t.status === 'completed', + ).length; + todoResult = { completed, total }; + } + } else { + // Regular tool — extract file target for Read/Edit/Write + const input = block.input as Record | undefined; + let target: string | undefined; + if (input?.file_path && typeof input.file_path === 'string') { + target = path.basename(input.file_path); + } + // Extract description: prefer input.description, fallback to first 4 words of command (Bash) + let description: string | undefined; + if (typeof input?.description === 'string') { + description = input.description; + } else if (name === 'Bash' && typeof input?.command === 'string') { + description = input.command.split(/\s+/).slice(0, 4).join(' '); + } + tools.set(id, { name, status: 'running', target, description }); + } + } else if (blockType === 'tool_result') { + const toolUseId = block.tool_use_id as string; + const toolEntry = tools.get(toolUseId); + if (toolEntry) { + toolEntry.status = 'completed'; + } + const agentEntry = agents.get(toolUseId); + if (agentEntry) { + agentEntry.status = 'completed'; + } + } + } + + return todoResult; +} diff --git a/src/cli/hud/types.ts b/src/cli/hud/types.ts new file mode 100644 index 0000000..1bddf52 --- /dev/null +++ b/src/cli/hud/types.ts @@ -0,0 +1,115 @@ +/** + * StdinData — the JSON that Claude Code pipes to statusLine commands. + */ +export interface StdinData { + model?: { display_name?: string; id?: string }; + cwd?: string; + context_window?: { + context_window_size?: number; + current_usage?: { input_tokens?: number; output_tokens?: number }; + used_percentage?: number; + }; + cost?: { total_cost_usd?: number }; + session_id?: string; + transcript_path?: string; +} + +/** + * Component IDs — the 14 HUD components. + */ +export type ComponentId = + | 'directory' + | 'gitBranch' + | 'gitAheadBehind' + | 'diffStats' + | 'model' + | 'contextUsage' + | 'versionBadge' + | 'sessionDuration' + | 'usageQuota' + | 'todoProgress' + | 'configCounts' + | 'sessionCost' + | 'releaseInfo' + | 'worktreeCount'; + +/** + * HUD config persisted to ~/.devflow/hud.json. + */ +export interface HudConfig { + enabled: boolean; + detail: boolean; +} + +/** + * Component render result. + */ +export interface ComponentResult { + text: string; // ANSI-formatted + raw: string; // plain text (for width calculation) +} + +/** + * Component function signature. + */ +export type ComponentFn = (ctx: GatherContext) => Promise; + +/** + * Git status data gathered from the working directory. + */ +export interface GitStatus { + branch: string; + dirty: boolean; + staged: boolean; + ahead: number; + behind: number; + filesChanged: number; + additions: number; + deletions: number; + lastTag: string | null; + commitsSinceTag: number; + worktreeCount: number; +} + +/** + * Transcript data parsed from session JSONL. + */ +export interface TranscriptData { + tools: Array<{ name: string; status: 'running' | 'completed'; target?: string; description?: string }>; + agents: Array<{ name: string; model?: string; status: 'running' | 'completed'; description?: string }>; + todos: { completed: number; total: number }; + skills: string[]; +} + +/** + * Usage API data. + */ +export interface UsageData { + fiveHourPercent: number | null; + sevenDayPercent: number | null; +} + +/** + * Config counts data for the configCounts component. + */ +export interface ConfigCountsData { + claudeMdFiles: number; + rules: number; + mcpServers: number; + hooks: number; +} + +/** + * Gather context passed to all component render functions. + */ +export interface GatherContext { + stdin: StdinData; + git: GitStatus | null; + transcript: TranscriptData | null; + usage: UsageData | null; + configCounts: ConfigCountsData | null; + config: HudConfig & { components: ComponentId[] }; + devflowDir: string; + sessionStartTime: number | null; + terminalWidth: number; +} diff --git a/src/cli/hud/usage-api.ts b/src/cli/hud/usage-api.ts new file mode 100644 index 0000000..fbfa88a --- /dev/null +++ b/src/cli/hud/usage-api.ts @@ -0,0 +1,110 @@ +import * as fs from 'node:fs'; +import { readCache, writeCache, readCacheStale } from './cache.js'; +import { getCredentials } from './credentials.js'; +import type { UsageData } from './types.js'; + +const USAGE_CACHE_KEY = 'usage'; +const USAGE_CACHE_TTL = 5 * 60 * 1000; // 5 minutes +const USAGE_FAIL_TTL = 15 * 1000; // 15 seconds +const API_TIMEOUT = 1_500; // Must fit within 2s overall HUD timeout +const BACKOFF_CACHE_KEY = 'usage-backoff'; + +interface BackoffState { + retryAfter: number; + delay: number; +} + +const DEBUG = !!process.env.DEVFLOW_HUD_DEBUG; + +function debugLog(msg: string, data?: Record): void { + if (!DEBUG) return; + const entry = { ts: new Date().toISOString(), source: 'usage-api', msg, ...data }; + fs.appendFileSync('/tmp/hud-debug.log', JSON.stringify(entry) + '\n'); +} + +/** + * Fetch usage quota data from the Anthropic API. + * Uses caching with backoff for rate limiting. Returns null on failure. + */ +export async function fetchUsageData(): Promise { + // Check backoff + const backoff = readCache(BACKOFF_CACHE_KEY); + if (backoff && Date.now() < backoff.retryAfter) { + debugLog('skipped: backoff active', { retryAfter: backoff.retryAfter }); + return readCacheStale(USAGE_CACHE_KEY); + } + + // Check cache + const cached = readCache(USAGE_CACHE_KEY); + if (cached) return cached; + + const creds = await getCredentials(); + if (!creds) { + debugLog('no OAuth credentials found'); + return null; + } + const token = creds.accessToken; + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), API_TIMEOUT); + + debugLog('fetching usage', { timeout: API_TIMEOUT }); + + const response = await fetch('https://api.anthropic.com/api/oauth/usage', { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'anthropic-beta': 'oauth-2025-04-20', + }, + signal: controller.signal, + }); + + clearTimeout(timeout); + + if (response.status === 429) { + const retryAfter = parseInt( + response.headers.get('Retry-After') || '60', + 10, + ); + const delay = Math.min(retryAfter * 1000, 5 * 60 * 1000); + writeCache( + BACKOFF_CACHE_KEY, + { retryAfter: Date.now() + delay, delay }, + delay, + ); + debugLog('rate limited (429)', { retryAfter, delay }); + return readCacheStale(USAGE_CACHE_KEY); + } + + if (!response.ok) { + debugLog('non-200 response', { status: response.status, statusText: response.statusText }); + writeCache(USAGE_CACHE_KEY, null, USAGE_FAIL_TTL); + return readCacheStale(USAGE_CACHE_KEY); + } + + const body = (await response.json()) as Record; + const fiveHour = body.five_hour as Record | undefined; + const sevenDay = body.seven_day as Record | undefined; + + const data: UsageData = { + fiveHourPercent: + typeof fiveHour?.utilization === 'number' + ? Math.round(Math.max(0, Math.min(100, fiveHour.utilization))) + : null, + sevenDayPercent: + typeof sevenDay?.utilization === 'number' + ? Math.round(Math.max(0, Math.min(100, sevenDay.utilization))) + : null, + }; + + debugLog('usage fetched', { fiveHour: data.fiveHourPercent, sevenDay: data.sevenDayPercent }); + writeCache(USAGE_CACHE_KEY, data, USAGE_CACHE_TTL); + return data; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + debugLog('fetch failed', { error: message }); + writeCache(USAGE_CACHE_KEY, null, USAGE_FAIL_TTL); + return readCacheStale(USAGE_CACHE_KEY); + } +} diff --git a/src/cli/utils/manifest.ts b/src/cli/utils/manifest.ts index 229925c..71b3068 100644 --- a/src/cli/utils/manifest.ts +++ b/src/cli/utils/manifest.ts @@ -12,6 +12,7 @@ export interface ManifestData { teams: boolean; ambient: boolean; memory: boolean; + hud?: boolean; }; installedAt: string; updatedAt: string; diff --git a/src/templates/settings.json b/src/templates/settings.json index 1ddb2f6..a6db585 100644 --- a/src/templates/settings.json +++ b/src/templates/settings.json @@ -1,7 +1,7 @@ { "statusLine": { "type": "command", - "command": "${DEVFLOW_DIR}/scripts/statusline.sh" + "command": "${DEVFLOW_DIR}/scripts/hud.sh" }, "hooks": { "Stop": [ diff --git a/tests/credentials.test.ts b/tests/credentials.test.ts new file mode 100644 index 0000000..5233d05 --- /dev/null +++ b/tests/credentials.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { readCredentialsFile, readKeychainToken, getCredentials, getClaudeDir } from '../src/cli/hud/credentials.js'; + +vi.mock('node:child_process', () => ({ + execFile: vi.fn(), +})); + +describe('getClaudeDir', () => { + const originalEnv = process.env.CLAUDE_CONFIG_DIR; + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.CLAUDE_CONFIG_DIR = originalEnv; + } else { + delete process.env.CLAUDE_CONFIG_DIR; + } + }); + + it('respects CLAUDE_CONFIG_DIR', () => { + process.env.CLAUDE_CONFIG_DIR = '/custom/claude'; + expect(getClaudeDir()).toBe('/custom/claude'); + }); + + it('falls back to ~/.claude', () => { + delete process.env.CLAUDE_CONFIG_DIR; + const result = getClaudeDir(); + expect(result).toContain('.claude'); + }); +}); + +describe('readCredentialsFile', () => { + it('returns credentials from valid file', () => { + const dir = '/tmp/test-creds-' + Date.now(); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, '.credentials.json'), + JSON.stringify({ + claudeAiOauth: { accessToken: 'test-token-123', subscriptionType: 'pro' }, + }), + ); + const result = readCredentialsFile(dir); + expect(result).toEqual({ accessToken: 'test-token-123', subscriptionType: 'pro' }); + fs.rmSync(dir, { recursive: true }); + }); + + it('returns null for missing file', () => { + const result = readCredentialsFile('/tmp/nonexistent-' + Date.now()); + expect(result).toBeNull(); + }); + + it('returns null for malformed JSON', () => { + const dir = '/tmp/test-creds-bad-' + Date.now(); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, '.credentials.json'), 'not-json'); + const result = readCredentialsFile(dir); + expect(result).toBeNull(); + fs.rmSync(dir, { recursive: true }); + }); + + it('returns null when accessToken is missing', () => { + const dir = '/tmp/test-creds-notoken-' + Date.now(); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, '.credentials.json'), + JSON.stringify({ claudeAiOauth: { subscriptionType: 'pro' } }), + ); + const result = readCredentialsFile(dir); + expect(result).toBeNull(); + fs.rmSync(dir, { recursive: true }); + }); + + it('omits subscriptionType when not present', () => { + const dir = '/tmp/test-creds-nosub-' + Date.now(); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, '.credentials.json'), + JSON.stringify({ claudeAiOauth: { accessToken: 'tok' } }), + ); + const result = readCredentialsFile(dir); + expect(result).toEqual({ accessToken: 'tok', subscriptionType: undefined }); + fs.rmSync(dir, { recursive: true }); + }); +}); + +describe('readKeychainToken', () => { + it('returns null on non-darwin platforms', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + try { + const result = await readKeychainToken(); + expect(result).toBeNull(); + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform); + } + } + }); +}); + +describe('getCredentials', () => { + it('returns file credentials on non-darwin', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + + const dir = '/tmp/test-getcreds-' + Date.now(); + const originalEnv = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = dir; + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, '.credentials.json'), + JSON.stringify({ claudeAiOauth: { accessToken: 'file-token' } }), + ); + + try { + const result = await getCredentials(); + expect(result).toEqual({ accessToken: 'file-token', subscriptionType: undefined }); + } finally { + Object.defineProperty(process, 'platform', originalPlatform!); + if (originalEnv !== undefined) { + process.env.CLAUDE_CONFIG_DIR = originalEnv; + } else { + delete process.env.CLAUDE_CONFIG_DIR; + } + fs.rmSync(dir, { recursive: true }); + } + }); + + it('returns null when no credentials available on non-darwin', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + + const originalEnv = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = '/tmp/nonexistent-' + Date.now(); + + try { + const result = await getCredentials(); + expect(result).toBeNull(); + } finally { + Object.defineProperty(process, 'platform', originalPlatform!); + if (originalEnv !== undefined) { + process.env.CLAUDE_CONFIG_DIR = originalEnv; + } else { + delete process.env.CLAUDE_CONFIG_DIR; + } + } + }); +}); diff --git a/tests/hud-components.test.ts b/tests/hud-components.test.ts new file mode 100644 index 0000000..becfbb4 --- /dev/null +++ b/tests/hud-components.test.ts @@ -0,0 +1,542 @@ +import { describe, it, expect } from 'vitest'; +import type { GatherContext, GitStatus, TranscriptData } from '../src/cli/hud/types.js'; + +// Import components +import directory from '../src/cli/hud/components/directory.js'; +import gitBranch from '../src/cli/hud/components/git-branch.js'; +import gitAheadBehind from '../src/cli/hud/components/git-ahead-behind.js'; +import diffStats from '../src/cli/hud/components/diff-stats.js'; +import model from '../src/cli/hud/components/model.js'; +import contextUsage from '../src/cli/hud/components/context-usage.js'; +import sessionDuration from '../src/cli/hud/components/session-duration.js'; +import usageQuota from '../src/cli/hud/components/usage-quota.js'; +import todoProgress from '../src/cli/hud/components/todo-progress.js'; +import sessionCost from '../src/cli/hud/components/session-cost.js'; +import releaseInfo from '../src/cli/hud/components/release-info.js'; +import worktreeCount from '../src/cli/hud/components/worktree-count.js'; +import configCounts from '../src/cli/hud/components/config-counts.js'; +import { stripAnsi } from '../src/cli/hud/colors.js'; + +function makeCtx(overrides: Partial = {}): GatherContext { + return { + stdin: {}, + git: null, + transcript: null, + usage: null, + configCounts: null, + config: { enabled: true, detail: false, components: [] }, + devflowDir: '/test/.devflow', + sessionStartTime: null, + terminalWidth: 120, + ...overrides, + }; +} + +function makeGit(overrides: Partial = {}): GitStatus { + return { + branch: 'main', + dirty: false, + staged: false, + ahead: 0, + behind: 0, + filesChanged: 0, + additions: 0, + deletions: 0, + lastTag: null, + commitsSinceTag: 0, + worktreeCount: 1, + ...overrides, + }; +} + +function makeTranscript(overrides: Partial = {}): TranscriptData { + return { + tools: [], + agents: [], + todos: { completed: 0, total: 0 }, + skills: [], + ...overrides, + }; +} + +describe('directory component', () => { + it('returns directory name from cwd', async () => { + const ctx = makeCtx({ stdin: { cwd: '/home/user/projects/my-app' } }); + const result = await directory(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toBe('my-app'); + }); + + it('returns null when no cwd', async () => { + const ctx = makeCtx(); + const result = await directory(ctx); + expect(result).toBeNull(); + }); +}); + +describe('gitBranch component', () => { + it('returns branch name', async () => { + const ctx = makeCtx({ git: makeGit({ branch: 'feat/my-feature' }) }); + const result = await gitBranch(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toBe('feat/my-feature'); + }); + + it('shows dirty indicator for unstaged changes', async () => { + const ctx = makeCtx({ + git: makeGit({ branch: 'main', dirty: true }), + }); + const result = await gitBranch(ctx); + expect(result!.raw).toBe('main*'); + }); + + it('shows staged indicator', async () => { + const ctx = makeCtx({ + git: makeGit({ branch: 'main', staged: true }), + }); + const result = await gitBranch(ctx); + expect(result!.raw).toBe('main+'); + // Green for staged + expect(result!.text).toContain('\x1b[32m'); + }); + + it('shows both dirty and staged indicators', async () => { + const ctx = makeCtx({ + git: makeGit({ branch: 'main', dirty: true, staged: true }), + }); + const result = await gitBranch(ctx); + expect(result!.raw).toBe('main*+'); + }); + + it('returns null when no git', async () => { + const ctx = makeCtx(); + const result = await gitBranch(ctx); + expect(result).toBeNull(); + }); +}); + +describe('gitAheadBehind component', () => { + it('shows ahead arrow', async () => { + const ctx = makeCtx({ git: makeGit({ ahead: 3 }) }); + const result = await gitAheadBehind(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toContain('3\u2191'); + }); + + it('shows behind arrow', async () => { + const ctx = makeCtx({ git: makeGit({ behind: 2 }) }); + const result = await gitAheadBehind(ctx); + expect(result!.raw).toContain('2\u2193'); + }); + + it('shows both arrows', async () => { + const ctx = makeCtx({ git: makeGit({ ahead: 1, behind: 4 }) }); + const result = await gitAheadBehind(ctx); + expect(result!.raw).toContain('1\u2191'); + expect(result!.raw).toContain('4\u2193'); + }); + + it('returns null when both zero', async () => { + const ctx = makeCtx({ git: makeGit() }); + const result = await gitAheadBehind(ctx); + expect(result).toBeNull(); + }); + + it('returns null when no git', async () => { + const ctx = makeCtx(); + const result = await gitAheadBehind(ctx); + expect(result).toBeNull(); + }); +}); + +describe('diffStats component', () => { + it('shows file count and additions/deletions', async () => { + const ctx = makeCtx({ + git: makeGit({ filesChanged: 5, additions: 100, deletions: 30 }), + }); + const result = await diffStats(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toContain('5 files'); + expect(result!.raw).toContain('+100'); + expect(result!.raw).toContain('-30'); + // File count separated from line stats by dot + expect(result!.raw).toContain('5 files \u00B7 +100'); + }); + + it('returns null when all zeros', async () => { + const ctx = makeCtx({ git: makeGit() }); + const result = await diffStats(ctx); + expect(result).toBeNull(); + }); + + it('returns null when no git', async () => { + const ctx = makeCtx(); + const result = await diffStats(ctx); + expect(result).toBeNull(); + }); +}); + +describe('model component', () => { + it('strips Claude prefix and shows name', async () => { + const ctx = makeCtx({ + stdin: { model: { display_name: 'Claude Sonnet 4' } }, + }); + const result = await model(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toBe('Sonnet 4'); + }); + + it('shows context window size in parens', async () => { + const ctx = makeCtx({ + stdin: { + model: { display_name: 'Opus 4.6' }, + context_window: { context_window_size: 1000000 }, + }, + }); + const result = await model(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toBe('Opus 4.6 [1m]'); + }); + + it('shows sub-million context as k', async () => { + const ctx = makeCtx({ + stdin: { + model: { display_name: 'Claude Haiku 4.5' }, + context_window: { context_window_size: 200000 }, + }, + }); + const result = await model(ctx); + expect(result!.raw).toBe('Haiku 4.5 [200k]'); + }); + + it('strips existing context info from display_name', async () => { + const ctx = makeCtx({ + stdin: { + model: { display_name: 'Opus 4.6 (1M context)' }, + context_window: { context_window_size: 1000000 }, + }, + }); + const result = await model(ctx); + expect(result!.raw).toBe('Opus 4.6 [1m]'); + }); + + it('handles names without Claude prefix', async () => { + const ctx = makeCtx({ + stdin: { model: { display_name: 'Opus 4.6' } }, + }); + const result = await model(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toBe('Opus 4.6'); + }); + + it('returns null when no model', async () => { + const ctx = makeCtx(); + const result = await model(ctx); + expect(result).toBeNull(); + }); +}); + +describe('contextUsage component', () => { + it('shows green for low usage (<50%)', async () => { + const ctx = makeCtx({ + stdin: { + context_window: { + context_window_size: 200000, + current_usage: { input_tokens: 50000 }, + }, + }, + }); + const result = await contextUsage(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toContain('Current Session '); + expect(result!.raw).toContain('25%'); + expect(result!.raw).toContain('\u2588'); // filled bar + expect(result!.raw).toContain('\u2591'); // empty bar + // Green for < 50% + expect(result!.text).toContain('\x1b[32m'); + }); + + it('shows yellow for 50-79% usage', async () => { + const ctx = makeCtx({ + stdin: { + context_window: { + context_window_size: 200000, + current_usage: { input_tokens: 110000 }, + }, + }, + }); + const result = await contextUsage(ctx); + expect(result!.raw).toContain('55%'); + // Yellow for 50-79% + expect(result!.text).toContain('\x1b[33m'); + }); + + it('shows red for 80%+ with token breakdown', async () => { + const ctx = makeCtx({ + stdin: { + context_window: { + context_window_size: 200000, + current_usage: { input_tokens: 180000 }, + }, + }, + }); + const result = await contextUsage(ctx); + expect(result!.raw).toContain('90%'); + expect(result!.raw).toContain('(in: 180k)'); + // Red for 80%+ + expect(result!.text).toContain('\x1b[31m'); + }); + + it('prefers used_percentage over computed value', async () => { + const ctx = makeCtx({ + stdin: { + context_window: { + context_window_size: 200000, + current_usage: { input_tokens: 50000 }, + used_percentage: 42, + }, + }, + }); + const result = await contextUsage(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toContain('42%'); + }); + + it('renders 0% as valid green bar', async () => { + const ctx = makeCtx({ + stdin: { + context_window: { + context_window_size: 200000, + used_percentage: 0, + }, + }, + }); + const result = await contextUsage(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toContain('0%'); + }); + + it('returns null when no context window data', async () => { + const ctx = makeCtx(); + const result = await contextUsage(ctx); + expect(result).toBeNull(); + }); +}); + +describe('sessionDuration component', () => { + it('shows minutes', async () => { + const tenMinutesAgo = Date.now() - 10 * 60 * 1000; + const ctx = makeCtx({ sessionStartTime: tenMinutesAgo }); + const result = await sessionDuration(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toContain('10m'); + }); + + it('shows hours and minutes', async () => { + const ninetyMinutesAgo = Date.now() - 90 * 60 * 1000; + const ctx = makeCtx({ sessionStartTime: ninetyMinutesAgo }); + const result = await sessionDuration(ctx); + expect(result!.raw).toContain('1h 30m'); + }); + + it('returns null when no start time', async () => { + const ctx = makeCtx(); + const result = await sessionDuration(ctx); + expect(result).toBeNull(); + }); +}); + +describe('usageQuota component', () => { + it('shows Session label with both windows when available', async () => { + const ctx = makeCtx({ usage: { fiveHourPercent: 45, sevenDayPercent: 70 } }); + const result = await usageQuota(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toContain('Session'); + expect(result!.raw).toContain('5h'); + expect(result!.raw).toContain('45%'); + expect(result!.raw).toContain('7d'); + expect(result!.raw).toContain('70%'); + expect(result!.raw).toContain('\u2588'); // filled bar + }); + + it('shows only 5h window when 7d is null', async () => { + const ctx = makeCtx({ usage: { fiveHourPercent: 30, sevenDayPercent: null } }); + const result = await usageQuota(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toContain('Session'); + expect(result!.raw).toContain('5h'); + expect(result!.raw).toContain('30%'); + expect(result!.raw).not.toContain('7d'); + }); + + it('shows only 7d window when 5h is null', async () => { + const ctx = makeCtx({ usage: { fiveHourPercent: null, sevenDayPercent: 70 } }); + const result = await usageQuota(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toContain('Session'); + expect(result!.raw).toContain('7d'); + expect(result!.raw).toContain('70%'); + expect(result!.raw).not.toContain('5h'); + }); + + it('returns null when no usage data', async () => { + const ctx = makeCtx(); + const result = await usageQuota(ctx); + expect(result).toBeNull(); + }); + + it('returns null when both percentages are null', async () => { + const ctx = makeCtx({ usage: { fiveHourPercent: null, sevenDayPercent: null } }); + const result = await usageQuota(ctx); + expect(result).toBeNull(); + }); +}); + +describe('todoProgress component', () => { + it('shows progress in dim', async () => { + const transcript = makeTranscript({ + todos: { completed: 3, total: 5 }, + }); + const ctx = makeCtx({ transcript }); + const result = await todoProgress(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toBe('3/5 todos'); + // Dim styling + expect(result!.text).toContain('\x1b[2m'); + }); + + it('returns null when no todos', async () => { + const transcript = makeTranscript(); + const ctx = makeCtx({ transcript }); + const result = await todoProgress(ctx); + expect(result).toBeNull(); + }); +}); + +describe('sessionCost component', () => { + it('shows cost formatted as dollars', async () => { + const ctx = makeCtx({ + stdin: { cost: { total_cost_usd: 0.42 } }, + }); + const result = await sessionCost(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toBe('$0.42'); + // Dim styling + expect(result!.text).toContain('\x1b[2m'); + }); + + it('formats to two decimal places', async () => { + const ctx = makeCtx({ + stdin: { cost: { total_cost_usd: 1.5 } }, + }); + const result = await sessionCost(ctx); + expect(result!.raw).toBe('$1.50'); + }); + + it('returns null when no cost data', async () => { + const ctx = makeCtx(); + const result = await sessionCost(ctx); + expect(result).toBeNull(); + }); + + it('returns null when cost field exists but total_cost_usd is missing', async () => { + const ctx = makeCtx({ + stdin: { cost: {} }, + }); + const result = await sessionCost(ctx); + expect(result).toBeNull(); + }); +}); + +describe('releaseInfo component', () => { + it('shows tag and commits since', async () => { + const ctx = makeCtx({ + git: makeGit({ lastTag: 'v1.7.0', commitsSinceTag: 5 }), + }); + const result = await releaseInfo(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toBe('v1.7.0 +5'); + }); + + it('shows tag only when count is 0', async () => { + const ctx = makeCtx({ + git: makeGit({ lastTag: 'v1.7.0', commitsSinceTag: 0 }), + }); + const result = await releaseInfo(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toBe('v1.7.0'); + }); + + it('returns null when no tags', async () => { + const ctx = makeCtx({ git: makeGit() }); + const result = await releaseInfo(ctx); + expect(result).toBeNull(); + }); + + it('returns null when no git', async () => { + const ctx = makeCtx(); + const result = await releaseInfo(ctx); + expect(result).toBeNull(); + }); +}); + +describe('worktreeCount component', () => { + it('shows count when greater than 1', async () => { + const ctx = makeCtx({ + git: makeGit({ worktreeCount: 3 }), + }); + const result = await worktreeCount(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toBe('3 worktrees'); + }); + + it('returns null when only 1 worktree', async () => { + const ctx = makeCtx({ git: makeGit() }); + const result = await worktreeCount(ctx); + expect(result).toBeNull(); + }); + + it('returns null when no git', async () => { + const ctx = makeCtx(); + const result = await worktreeCount(ctx); + expect(result).toBeNull(); + }); +}); + +describe('configCounts component', () => { + it('includes skills count from transcript', async () => { + const transcript = makeTranscript({ + skills: ['core-patterns', 'test-patterns', 'implementation-patterns'], + }); + const ctx = makeCtx({ + transcript, + configCounts: { claudeMdFiles: 1, rules: 0, mcpServers: 2, hooks: 3 }, + }); + const result = await configCounts(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toContain('3 skills'); + expect(result!.raw).toContain('1 CLAUDE.md'); + expect(result!.raw).toContain('2 MCPs'); + expect(result!.raw).toContain('3 hooks'); + }); + + it('omits skills when transcript has none', async () => { + const ctx = makeCtx({ + configCounts: { claudeMdFiles: 1, rules: 0, mcpServers: 0, hooks: 0 }, + }); + const result = await configCounts(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).not.toContain('skills'); + }); +}); + +describe('stripAnsi', () => { + it('removes ANSI escape codes', () => { + const colored = '\x1b[31mred\x1b[0m text'; + expect(stripAnsi(colored)).toBe('red text'); + }); + + it('handles strings without ANSI codes', () => { + expect(stripAnsi('plain text')).toBe('plain text'); + }); +}); diff --git a/tests/hud-render.test.ts b/tests/hud-render.test.ts new file mode 100644 index 0000000..c658a33 --- /dev/null +++ b/tests/hud-render.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect } from 'vitest'; +import { render } from '../src/cli/hud/render.js'; +import { + HUD_COMPONENTS, + loadConfig, + resolveComponents, +} from '../src/cli/hud/config.js'; +import { stripAnsi } from '../src/cli/hud/colors.js'; +import type { GatherContext, HudConfig, ComponentId } from '../src/cli/hud/types.js'; + +function makeCtx( + overrides: Partial & { config?: Partial & { components?: ComponentId[] } } = {}, +): GatherContext { + const { config: configOverride, ...rest } = overrides; + return { + stdin: { + cwd: '/home/user/project', + model: { display_name: 'Claude Opus 4' }, + context_window: { + context_window_size: 200000, + current_usage: { input_tokens: 40000 }, + }, + }, + git: { + branch: 'feat/test', + dirty: false, + staged: false, + ahead: 2, + behind: 0, + filesChanged: 3, + additions: 50, + deletions: 10, + lastTag: null, + commitsSinceTag: 0, + worktreeCount: 1, + }, + transcript: null, + usage: null, + configCounts: null, + config: { + enabled: true, + detail: false, + components: [...HUD_COMPONENTS], + ...configOverride, + }, + devflowDir: '/test/.devflow', + sessionStartTime: null, + terminalWidth: 120, + ...rest, + }; +} + +describe('render', () => { + it('all components produces three lines', async () => { + const ctx = makeCtx(); + const output = await render(ctx); + const lines = output.split('\n').filter((l) => l.length > 0); + + // Line 1: git, Line 2: context, Line 3: model + expect(lines).toHaveLength(3); + const raw = stripAnsi(output); + expect(raw).toContain('project'); + expect(raw).toContain('feat/test'); + expect(raw).toContain('Opus 4 [200k]'); + expect(raw).toContain('20%'); + }); + + it('uses dot separator between components', async () => { + const ctx = makeCtx(); + const output = await render(ctx); + const raw = stripAnsi(output); + expect(raw).toContain('\u00B7'); + }); + + it('shows session data when available', async () => { + const ctx = makeCtx({ + sessionStartTime: Date.now() - 15 * 60 * 1000, + usage: { fiveHourPercent: 30, sevenDayPercent: null }, + }); + const output = await render(ctx); + const lines = output.split('\n').filter((l) => l.length > 0); + + expect(lines).toHaveLength(3); + const raw = stripAnsi(output); + expect(raw).toContain('15m'); + expect(raw).toContain('Session'); + expect(raw).toContain('30%'); + }); + + it('shows activity section with todos and config counts', async () => { + const ctx = makeCtx({ + sessionStartTime: Date.now() - 5 * 60 * 1000, + usage: { fiveHourPercent: 20, sevenDayPercent: null }, + transcript: { + tools: [], + agents: [], + todos: { completed: 2, total: 4 }, + skills: [], + }, + configCounts: { + claudeMdFiles: 2, + rules: 3, + mcpServers: 1, + hooks: 4, + }, + }); + const output = await render(ctx); + const lines = output.split('\n').filter((l) => l.length > 0); + + // 3 info lines + blank + todo line = 4+ + expect(lines.length).toBeGreaterThanOrEqual(4); + const raw = stripAnsi(output); + expect(raw).toContain('2/4 todos'); + expect(raw).toContain('2 CLAUDE.md'); + expect(raw).toContain('3 rules'); + expect(raw).toContain('1 MCPs'); + expect(raw).toContain('4 hooks'); + expect(raw).toContain('Session'); + }); + + it('components that return null are excluded', async () => { + const ctx = makeCtx({ + stdin: {}, + git: null, + }); + const output = await render(ctx); + + expect(output).toBe(''); + }); + + it('handles subset of components', async () => { + const ctx = makeCtx({ + config: { enabled: true, detail: false, components: ['directory', 'model'] }, + }); + const output = await render(ctx); + const raw = stripAnsi(output); + + expect(raw).toContain('project'); + expect(raw).toContain('Opus 4 [200k]'); + // Should not contain git info + expect(raw).not.toContain('feat/test'); + }); + + it('inserts blank line between info and activity sections', async () => { + const ctx = makeCtx({ + sessionStartTime: Date.now() - 5 * 60 * 1000, + usage: { fiveHourPercent: 20, sevenDayPercent: null }, + transcript: { + tools: [], + agents: [], + todos: { completed: 1, total: 3 }, + skills: [], + }, + }); + const output = await render(ctx); + const lines = output.split('\n'); + + // Should contain an empty line between info and activity sections + expect(lines).toContain(''); + // Empty line should be between non-empty lines + const emptyIdx = lines.indexOf(''); + expect(emptyIdx).toBeGreaterThan(0); + expect(emptyIdx).toBeLessThan(lines.length - 1); + }); + + it('no blank line when activity section is empty', async () => { + const ctx = makeCtx({ + sessionStartTime: Date.now() - 5 * 60 * 1000, + usage: { fiveHourPercent: 20, sevenDayPercent: null }, + }); + const output = await render(ctx); + const lines = output.split('\n'); + + // No empty lines — no activity components have data + expect(lines.every((l) => l.length > 0)).toBe(true); + }); +}); + +describe('config', () => { + it('loadConfig returns default when no file exists', () => { + // Point to a non-existent directory + const originalEnv = process.env.DEVFLOW_DIR; + process.env.DEVFLOW_DIR = '/tmp/nonexistent-devflow-test-dir'; + try { + const config = loadConfig(); + expect(config.enabled).toBe(true); + expect(config.detail).toBe(false); + } finally { + if (originalEnv !== undefined) { + process.env.DEVFLOW_DIR = originalEnv; + } else { + delete process.env.DEVFLOW_DIR; + } + } + }); + + it('resolveComponents returns all components when enabled', () => { + const config: HudConfig = { enabled: true, detail: false }; + expect(resolveComponents(config)).toEqual([...HUD_COMPONENTS]); + }); + + it('resolveComponents returns only versionBadge when disabled', () => { + const config: HudConfig = { enabled: false, detail: false }; + expect(resolveComponents(config)).toEqual(['versionBadge']); + }); + + it('HUD_COMPONENTS has 14 components', () => { + expect(HUD_COMPONENTS).toHaveLength(14); + }); +}); diff --git a/tests/hud.test.ts b/tests/hud.test.ts new file mode 100644 index 0000000..bb98968 --- /dev/null +++ b/tests/hud.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect } from 'vitest'; +import { + addHudStatusLine, + removeHudStatusLine, + hasHudStatusLine, + hasNonDevFlowStatusLine, +} from '../src/cli/commands/hud.js'; + +describe('addHudStatusLine', () => { + it('adds statusLine to empty settings', () => { + const result = addHudStatusLine('{}', '/home/user/.devflow'); + const settings = JSON.parse(result); + + expect(settings.statusLine).toBeDefined(); + expect(settings.statusLine.type).toBe('command'); + expect(settings.statusLine.command).toContain('hud.sh'); + expect(settings.statusLine.command).toContain('/home/user/.devflow'); + }); + + it('uses correct devflowDir path', () => { + const result = addHudStatusLine('{}', '/custom/path/.devflow'); + const settings = JSON.parse(result); + + expect(settings.statusLine.command).toBe( + '/custom/path/.devflow/scripts/hud.sh', + ); + }); + + it('is idempotent — does not duplicate', () => { + const first = addHudStatusLine('{}', '/home/user/.devflow'); + const second = addHudStatusLine(first, '/home/user/.devflow'); + + expect(second).toBe(first); + }); + + it('preserves other settings', () => { + const input = JSON.stringify({ + hooks: { + Stop: [{ hooks: [{ type: 'command', command: 'stop.sh' }] }], + }, + env: { SOME_VAR: '1' }, + }); + const result = addHudStatusLine(input, '/home/user/.devflow'); + const settings = JSON.parse(result); + + expect(settings.hooks.Stop).toHaveLength(1); + expect(settings.env.SOME_VAR).toBe('1'); + expect(settings.statusLine.command).toContain('hud.sh'); + }); + + it('replaces existing DevFlow statusline.sh with HUD', () => { + const input = JSON.stringify({ + statusLine: { type: 'command', command: '/old/path/statusline.sh' }, + }); + const result = addHudStatusLine(input, '/home/user/.devflow'); + const settings = JSON.parse(result); + + expect(settings.statusLine.command).toContain('hud.sh'); + expect(settings.statusLine.command).not.toContain('statusline.sh'); + }); +}); + +describe('removeHudStatusLine', () => { + it('removes HUD statusLine', () => { + const withHud = addHudStatusLine('{}', '/home/user/.devflow'); + const result = removeHudStatusLine(withHud); + const settings = JSON.parse(result); + + expect(settings.statusLine).toBeUndefined(); + }); + + it('removes legacy statusline.sh', () => { + const input = JSON.stringify({ + statusLine: { type: 'command', command: '/path/statusline.sh' }, + }); + const result = removeHudStatusLine(input); + const settings = JSON.parse(result); + + expect(settings.statusLine).toBeUndefined(); + }); + + it('does not remove non-DevFlow statusLine', () => { + const input = JSON.stringify({ + statusLine: { + type: 'command', + command: '/some/other/tool/status.sh', + }, + }); + const result = removeHudStatusLine(input); + + expect(result).toBe(input); + }); + + it('is idempotent — safe when no statusLine', () => { + const input = JSON.stringify({ hooks: {} }); + const result = removeHudStatusLine(input); + + expect(result).toBe(input); + }); + + it('preserves other settings', () => { + const input = JSON.stringify({ + statusLine: { type: 'command', command: '/path/hud.sh' }, + hooks: { + Stop: [{ hooks: [{ type: 'command', command: 'stop.sh' }] }], + }, + }); + const result = removeHudStatusLine(input); + const settings = JSON.parse(result); + + expect(settings.statusLine).toBeUndefined(); + expect(settings.hooks.Stop).toHaveLength(1); + }); +}); + +describe('hasHudStatusLine', () => { + it('returns true when HUD is present', () => { + const withHud = addHudStatusLine('{}', '/home/user/.devflow'); + expect(hasHudStatusLine(withHud)).toBe(true); + }); + + it('returns true for legacy statusline.sh', () => { + const input = JSON.stringify({ + statusLine: { type: 'command', command: '/path/statusline.sh' }, + }); + expect(hasHudStatusLine(input)).toBe(true); + }); + + it('returns false when absent', () => { + expect(hasHudStatusLine('{}')).toBe(false); + }); + + it('returns false for non-DevFlow statusLine', () => { + const input = JSON.stringify({ + statusLine: { + type: 'command', + command: '/other/tool/status.sh', + }, + }); + expect(hasHudStatusLine(input)).toBe(false); + }); +}); + +describe('hasNonDevFlowStatusLine', () => { + it('returns true for external statusLine', () => { + const input = JSON.stringify({ + statusLine: { + type: 'command', + command: '/other/tool/my-status.sh', + }, + }); + expect(hasNonDevFlowStatusLine(input)).toBe(true); + }); + + it('returns false for DevFlow HUD', () => { + const withHud = addHudStatusLine('{}', '/home/user/.devflow'); + expect(hasNonDevFlowStatusLine(withHud)).toBe(false); + }); + + it('returns false when no statusLine', () => { + expect(hasNonDevFlowStatusLine('{}')).toBe(false); + }); +});