From fab96d1e2d60a0c9cfc18583232ebe71db1a5abe Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Sat, 21 Mar 2026 03:45:17 +0200 Subject: [PATCH 1/4] feat: configurable HUD replacing bash statusline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace scripts/statusline.sh with a configurable TypeScript HUD system that supports 14 components across 4 presets (Minimal, Classic, Standard, Full) with smart multi-line layout. Core infrastructure: - src/cli/hud/ — types, config, stdin, colors, git, transcript, cache, usage-api, render, and index (entry point with 2s timeout) - 14 component files in src/cli/hud/components/ - scripts/hud.sh shell wrapper + scripts/build-hud.js distribution CLI integration: - devflow hud --configure/--preset/--status/--enable/--disable - Init phase: HUD preset picker between memory and security - Uninstall: HUD statusLine cleanup - List: shows HUD preset in features Settings: - Template updated: statusline.sh -> hud.sh - addHudStatusLine/removeHudStatusLine/hasHudStatusLine pattern - Automatic upgrade from legacy statusline.sh - Conflict detection for non-DevFlow statusLine Tests: 68 new tests across 3 test files (341 total, all passing) --- .gitignore | 3 + package.json | 5 +- scripts/build-hud.js | 36 ++ scripts/hud.sh | 5 + scripts/statusline.sh | 240 ------------- src/cli/cli.ts | 4 +- src/cli/commands/hud.ts | 308 ++++++++++++++++ src/cli/commands/init.ts | 72 +++- src/cli/commands/list.ts | 1 + src/cli/commands/uninstall.ts | 11 + src/cli/hud/cache.ts | 61 ++++ src/cli/hud/colors.ts | 53 +++ src/cli/hud/components/agent-activity.ts | 27 ++ src/cli/hud/components/config-counts.ts | 96 +++++ src/cli/hud/components/context-usage.ts | 22 ++ src/cli/hud/components/diff-stats.ts | 25 ++ src/cli/hud/components/directory.ts | 12 + src/cli/hud/components/git-ahead-behind.ts | 21 ++ src/cli/hud/components/git-branch.ts | 12 + src/cli/hud/components/model.ts | 10 + src/cli/hud/components/session-duration.ts | 19 + src/cli/hud/components/speed.ts | 11 + src/cli/hud/components/todo-progress.ts | 12 + src/cli/hud/components/tool-activity.ts | 39 ++ src/cli/hud/components/usage-quota.ts | 25 ++ src/cli/hud/components/version-badge.ts | 101 ++++++ src/cli/hud/config.ts | 94 +++++ src/cli/hud/git.ts | 122 +++++++ src/cli/hud/index.ts | 89 +++++ src/cli/hud/render.ts | 108 ++++++ src/cli/hud/stdin.ts | 25 ++ src/cli/hud/transcript.ts | 124 +++++++ src/cli/hud/types.ts | 121 +++++++ src/cli/hud/usage-api.ts | 104 ++++++ src/cli/utils/manifest.ts | 1 + src/templates/settings.json | 2 +- tests/hud-components.test.ts | 396 +++++++++++++++++++++ tests/hud-render.test.ts | 218 ++++++++++++ tests/hud.test.ts | 163 +++++++++ 39 files changed, 2554 insertions(+), 244 deletions(-) create mode 100755 scripts/build-hud.js create mode 100755 scripts/hud.sh delete mode 100755 scripts/statusline.sh create mode 100644 src/cli/commands/hud.ts create mode 100644 src/cli/hud/cache.ts create mode 100644 src/cli/hud/colors.ts create mode 100644 src/cli/hud/components/agent-activity.ts create mode 100644 src/cli/hud/components/config-counts.ts create mode 100644 src/cli/hud/components/context-usage.ts create mode 100644 src/cli/hud/components/diff-stats.ts create mode 100644 src/cli/hud/components/directory.ts create mode 100644 src/cli/hud/components/git-ahead-behind.ts create mode 100644 src/cli/hud/components/git-branch.ts create mode 100644 src/cli/hud/components/model.ts create mode 100644 src/cli/hud/components/session-duration.ts create mode 100644 src/cli/hud/components/speed.ts create mode 100644 src/cli/hud/components/todo-progress.ts create mode 100644 src/cli/hud/components/tool-activity.ts create mode 100644 src/cli/hud/components/usage-quota.ts create mode 100644 src/cli/hud/components/version-badge.ts create mode 100644 src/cli/hud/config.ts create mode 100644 src/cli/hud/git.ts create mode 100644 src/cli/hud/index.ts create mode 100644 src/cli/hud/render.ts create mode 100644 src/cli/hud/stdin.ts create mode 100644 src/cli/hud/transcript.ts create mode 100644 src/cli/hud/types.ts create mode 100644 src/cli/hud/usage-api.ts create mode 100644 tests/hud-components.test.ts create mode 100644 tests/hud-render.test.ts create mode 100644 tests/hud.test.ts 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/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..6c32977 --- /dev/null +++ b/src/cli/commands/hud.ts @@ -0,0 +1,308 @@ +import { Command } from 'commander'; +import { promises as fs } from 'fs'; +import * as path from 'path'; +import * as p from '@clack/prompts'; +import color from 'picocolors'; +import { getClaudeDirectory, getDevFlowDirectory } from '../utils/paths.js'; +import { + PRESETS, + DEFAULT_PRESET, + loadConfig, + saveConfig, + resolveComponents, +} from '../hud/config.js'; +import type { HudConfig, PresetName } from '../hud/types.js'; + +/** + * Marker to identify DevFlow HUD in settings.json statusLine. + */ +const HUD_MARKER = 'hud'; + +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). + */ +function isDevFlowStatusLine(statusLine: StatusLine): boolean { + return ( + statusLine.command?.includes(HUD_MARKER) || + statusLine.command?.includes('statusline') || + statusLine.command?.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); +} + +/** + * Format a preset preview for interactive display. + */ +function formatPresetPreview(preset: PresetName): string { + const components = PRESETS[preset]; + return components.join(', '); +} + +export const hudCommand = new Command('hud') + .description('Configure the HUD (status line)') + .option('--configure', 'Interactive preset picker') + .option( + '--preset ', + 'Quick preset switch (minimal, classic, standard, full)', + ) + .option('--status', 'Show current HUD config') + .option('--enable', 'Enable HUD in settings') + .option('--disable', 'Disable HUD (remove statusLine)') + .action(async (options) => { + const hasFlag = + options.configure || + options.preset || + options.status || + options.enable || + options.disable; + if (!hasFlag) { + p.intro(color.bgCyan(color.white(' HUD '))); + p.note( + `${color.cyan('devflow hud --configure')} Interactive preset picker\n` + + `${color.cyan('devflow hud --preset=')} Quick preset switch\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( + `${color.yellow('minimal')} ${formatPresetPreview('minimal')}\n` + + `${color.yellow('classic')} ${formatPresetPreview('classic')}\n` + + `${color.yellow('standard')} ${formatPresetPreview('standard')}\n` + + `${color.yellow('full')} ${formatPresetPreview('full')}`, + 'Presets', + ); + p.outro(color.dim('Default preset: standard')); + return; + } + + if (options.status) { + const config = loadConfig(); + const components = resolveComponents(config); + p.intro(color.bgCyan(color.white(' HUD Status '))); + p.note( + `${color.dim('Preset:')} ${color.cyan(config.preset)}\n` + + `${color.dim('Components:')} ${components.join(', ')}`, + '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.preset) { + const preset = options.preset as string; + if (!(preset in PRESETS)) { + p.log.error( + `Unknown preset: ${preset}. Valid: ${Object.keys(PRESETS).join(', ')}`, + ); + process.exit(1); + } + const config: HudConfig = { + preset: preset as PresetName, + components: PRESETS[preset as PresetName], + }; + saveConfig(config); + p.log.success(`HUD preset set to ${color.cyan(preset)}`); + p.log.info( + color.dim(`Components: ${config.components.join(', ')}`), + ); + return; + } + + if (options.configure) { + const currentConfig = loadConfig(); + const presetChoice = await p.select({ + message: 'Choose HUD preset', + options: [ + { + value: 'minimal', + label: 'Minimal', + hint: formatPresetPreview('minimal'), + }, + { + value: 'classic', + label: 'Classic', + hint: formatPresetPreview('classic'), + }, + { + value: 'standard', + label: 'Standard (Recommended)', + hint: formatPresetPreview('standard'), + }, + { + value: 'full', + label: 'Full', + hint: formatPresetPreview('full'), + }, + ], + initialValue: currentConfig.preset === 'custom' ? DEFAULT_PRESET : currentConfig.preset, + }); + + if (p.isCancel(presetChoice)) { + p.cancel('Configuration cancelled.'); + process.exit(0); + } + + const preset = presetChoice as PresetName; + const config: HudConfig = { + preset, + components: PRESETS[preset], + }; + saveConfig(config); + p.log.success(`HUD preset set to ${color.cyan(preset)}`); + p.log.info( + color.dim(`Components: ${config.components.join(', ')}`), + ); + return; + } + + const claudeDir = getClaudeDirectory(); + const settingsPath = path.join(claudeDir, 'settings.json'); + + let settingsContent: string; + try { + settingsContent = await fs.readFile(settingsPath, 'utf-8'); + } catch { + if (options.status) { + p.log.info('HUD: disabled (no settings.json found)'); + return; + } + settingsContent = '{}'; + } + + if (options.enable) { + // 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); + if (updated === settingsContent) { + p.log.info('HUD already enabled'); + return; + } + await fs.writeFile(settingsPath, updated, 'utf-8'); + p.log.success('HUD enabled — statusLine registered'); + p.log.info(color.dim('Restart Claude Code to see the HUD')); + } + + if (options.disable) { + const updated = removeHudStatusLine(settingsContent); + if (updated === settingsContent) { + p.log.info('HUD already disabled'); + return; + } + await fs.writeFile(settingsPath, updated, 'utf-8'); + p.log.success('HUD disabled — statusLine removed'); + } + }); diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 8330ca6..2a5ff7e 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -24,12 +24,16 @@ 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 { PRESETS, DEFAULT_PRESET, saveConfig as saveHudConfig } from '../hud/config.js'; +import type { PresetName } from '../hud/types.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 +100,7 @@ interface InitOptions { teams?: boolean; ambient?: boolean; memory?: boolean; + hud?: string; } export const initCommand = new Command('init') @@ -109,6 +114,7 @@ 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('--hud ', 'HUD preset (minimal, classic, standard, full, off)') .action(async (options: InitOptions) => { // Get package version const packageJsonPath = path.resolve(__dirname, '../../package.json'); @@ -254,6 +260,37 @@ export const initCommand = new Command('init') memoryEnabled = memoryChoice; } + // HUD preset selection + let hudPreset: PresetName | 'off' = DEFAULT_PRESET; + if (options.hud !== undefined) { + const val = options.hud; + if (val === 'off') { + hudPreset = 'off'; + } else if (val in PRESETS) { + hudPreset = val as PresetName; + } else { + p.log.error(`Unknown HUD preset: ${val}. Valid: minimal, classic, standard, full, off`); + process.exit(1); + } + } else if (process.stdin.isTTY) { + const hudChoice = await p.select({ + message: 'Choose HUD preset', + options: [ + { value: 'standard', label: 'Standard (Recommended)', hint: 'directory, git, model, context, version, session, usage' }, + { value: 'minimal', label: 'Minimal', hint: 'directory, git branch, model, context usage' }, + { value: 'classic', label: 'Classic', hint: 'Like statusline.sh with version badge' }, + { value: 'full', label: 'Full', hint: 'All 14 components' }, + { value: 'off', label: 'No HUD', hint: 'Disable status line entirely' }, + ], + initialValue: 'standard', + }); + if (p.isCancel(hudChoice)) { + p.cancel('Installation cancelled.'); + process.exit(0); + } + hudPreset = hudChoice as PresetName | 'off'; + } + // Security deny list placement (user scope + TTY only) let securityMode: SecurityMode = 'user'; if (scope === 'user' && process.stdin.isTTY) { @@ -512,6 +549,39 @@ export const initCommand = new Command('init') await createMemoryDir(verbose); await migrateMemoryFiles(verbose); } + + // Configure HUD + if (hudPreset !== 'off') { + // Save HUD config + saveHudConfig({ preset: hudPreset, components: PRESETS[hudPreset] }); + + // Ensure statusLine points to HUD (the settings template already has it, + // but upgrade from old statusline.sh may need this) + const hudSettingsPath = path.join(claudeDir, 'settings.json'); + try { + const hudContent = await fs.readFile(hudSettingsPath, 'utf-8'); + const hudUpdated = addHudStatusLine(hudContent, devflowDir); + if (hudUpdated !== hudContent) { + await fs.writeFile(hudSettingsPath, hudUpdated, 'utf-8'); + if (verbose) { + p.log.success(`HUD enabled (preset: ${hudPreset})`); + } + } + } catch { /* settings.json may not exist yet */ } + } else { + // HUD disabled — remove statusLine if it points to DevFlow + const hudSettingsPath = path.join(claudeDir, 'settings.json'); + try { + const hudContent = await fs.readFile(hudSettingsPath, 'utf-8'); + const hudUpdated = removeHudStatusLine(hudContent); + if (hudUpdated !== hudContent) { + await fs.writeFile(hudSettingsPath, hudUpdated, 'utf-8'); + if (verbose) { + p.log.info('HUD disabled'); + } + } + } catch { /* settings.json may not exist yet */ } + } } const fileExtras = selectedExtras.filter(e => e !== 'settings' && e !== 'safe-delete'); @@ -620,7 +690,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: hudPreset === 'off' ? false as const : String(hudPreset) }, installedAt: existingManifest?.installedAt ?? now, updatedAt: now, }; diff --git a/src/cli/commands/list.ts b/src/cli/commands/list.ts index 6b5c97a..4273558 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:${features.hud}` : null, ].filter(Boolean).join(', ') || 'none'; } diff --git a/src/cli/commands/uninstall.ts b/src/cli/commands/uninstall.ts index 7fcc881..1ee7c37 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'; @@ -412,6 +413,16 @@ export const uninstallCommand = new Command('uninstall') } } + // Always remove HUD statusLine on full uninstall (idempotent) + const withoutHud = removeHudStatusLine(settingsContent); + if (withoutHud !== settingsContent) { + await fs.writeFile(settingsPath, withoutHud, 'utf-8'); + settingsContent = withoutHud; + if (verbose) { + p.log.success(`HUD statusLine removed from settings.json (${scope})`); + } + } + const settings = JSON.parse(settingsContent); if (settings.hooks) { diff --git a/src/cli/hud/cache.ts b/src/cli/hud/cache.ts new file mode 100644 index 0000000..84524f1 --- /dev/null +++ b/src/cli/hud/cache.ts @@ -0,0 +1,61 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +interface CacheEntry { + data: T; + timestamp: number; + ttl: number; +} + +export function getCacheDir(): string { + const devflowDir = + process.env.DEVFLOW_DIR || path.join(process.env.HOME || '~', '.devflow'); + return path.join(devflowDir, 'cache'); +} + +/** + * Read a cached value. Returns null if missing or expired. + */ +export function readCache(key: string): 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 (Date.now() - entry.timestamp < entry.ttl) { + return entry.data; + } + return null; + } catch { + return null; + } +} + +/** + * 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 + } +} + +/** + * Read a cached value regardless of TTL (stale data). Returns null if missing. + */ +export function readCacheStale(key: string): T | null { + try { + const filePath = path.join(getCacheDir(), `${key}.json`); + const raw = fs.readFileSync(filePath, 'utf-8'); + const entry = JSON.parse(raw) as CacheEntry; + return entry.data; + } catch { + return null; + } +} diff --git a/src/cli/hud/colors.ts b/src/cli/hud/colors.ts new file mode 100644 index 0000000..34f357b --- /dev/null +++ b/src/cli/hud/colors.ts @@ -0,0 +1,53 @@ +/** + * 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 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}`; +} + +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/agent-activity.ts b/src/cli/hud/components/agent-activity.ts new file mode 100644 index 0000000..fbea5fd --- /dev/null +++ b/src/cli/hud/components/agent-activity.ts @@ -0,0 +1,27 @@ +import type { ComponentResult, GatherContext } from '../types.js'; +import { green, yellow, dim, gray } from '../colors.js'; + +export default async function agentActivity( + ctx: GatherContext, +): Promise { + if (!ctx.transcript) return null; + const { agents } = ctx.transcript; + if (agents.length === 0) return null; + + const parts: string[] = []; + const rawParts: string[] = []; + + for (const a of agents.slice(-4)) { + const modelTag = a.model ? gray(` [${a.model}]`) : ''; + const modelRaw = a.model ? ` [${a.model}]` : ''; + if (a.status === 'completed') { + parts.push(green(`\u2713 ${a.name}`) + modelTag); + rawParts.push(`\u2713 ${a.name}${modelRaw}`); + } else { + parts.push(yellow(`\u25D0 ${a.name}`) + modelTag); + rawParts.push(`\u25D0 ${a.name}${modelRaw}`); + } + } + + return { text: parts.join(dim(' ')), raw: rawParts.join(' ') }; +} diff --git a/src/cli/hud/components/config-counts.ts b/src/cli/hud/components/config-counts.ts new file mode 100644 index 0000000..d31e0ef --- /dev/null +++ b/src/cli/hud/components/config-counts.ts @@ -0,0 +1,96 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +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 || '~', '.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 || '~', '.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 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 (parts.length === 0) return null; + const label = parts.join(', '); + return { text: dim(label), raw: label }; +} diff --git a/src/cli/hud/components/context-usage.ts b/src/cli/hud/components/context-usage.ts new file mode 100644 index 0000000..a09b1b4 --- /dev/null +++ b/src/cli/hud/components/context-usage.ts @@ -0,0 +1,22 @@ +import type { ComponentResult, GatherContext } from '../types.js'; +import { green, yellow, red } from '../colors.js'; + +/** + * Context window usage component. + * Color thresholds ported from statusline.sh: green < 50%, yellow 50-80%, red > 80%. + */ +export default async function contextUsage( + ctx: GatherContext, +): Promise { + const cw = ctx.stdin.context_window; + if (!cw?.context_window_size || !cw?.current_usage?.input_tokens) return null; + + const pct = Math.round( + (cw.current_usage.input_tokens / cw.context_window_size) * 100, + ); + const label = `${pct}%`; + + // Color thresholds from statusline.sh + const colorFn = pct < 50 ? green : pct < 80 ? yellow : red; + return { text: colorFn(label), raw: label }; +} diff --git a/src/cli/hud/components/diff-stats.ts b/src/cli/hud/components/diff-stats.ts new file mode 100644 index 0000000..3c36578 --- /dev/null +++ b/src/cli/hud/components/diff-stats.ts @@ -0,0 +1,25 @@ +import type { ComponentResult, GatherContext } from '../types.js'; +import { yellow, 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 parts: string[] = []; + const rawParts: string[] = []; + if (filesChanged > 0) { + parts.push(yellow(`${filesChanged}`)); + rawParts.push(`${filesChanged}`); + } + if (additions > 0) { + parts.push(green(`+${additions}`)); + rawParts.push(`+${additions}`); + } + if (deletions > 0) { + parts.push(red(`-${deletions}`)); + rawParts.push(`-${deletions}`); + } + return { text: parts.join(' '), raw: rawParts.join(' ') }; +} diff --git a/src/cli/hud/components/directory.ts b/src/cli/hud/components/directory.ts new file mode 100644 index 0000000..425788b --- /dev/null +++ b/src/cli/hud/components/directory.ts @@ -0,0 +1,12 @@ +import type { ComponentResult, GatherContext } from '../types.js'; +import { bold, blue } 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(blue(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..642fd76 --- /dev/null +++ b/src/cli/hud/components/git-ahead-behind.ts @@ -0,0 +1,21 @@ +import type { ComponentResult, GatherContext } from '../types.js'; +import { green, red } 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 parts: string[] = []; + const rawParts: string[] = []; + if (ahead > 0) { + parts.push(green(`${ahead}\u2191`)); + rawParts.push(`${ahead}\u2191`); + } + if (behind > 0) { + parts.push(red(`${behind}\u2193`)); + rawParts.push(`${behind}\u2193`); + } + return { text: parts.join(' '), raw: rawParts.join(' ') }; +} diff --git a/src/cli/hud/components/git-branch.ts b/src/cli/hud/components/git-branch.ts new file mode 100644 index 0000000..ad6922e --- /dev/null +++ b/src/cli/hud/components/git-branch.ts @@ -0,0 +1,12 @@ +import type { ComponentResult, GatherContext } from '../types.js'; +import { cyan, yellow } from '../colors.js'; + +export default async function gitBranch( + ctx: GatherContext, +): Promise { + if (!ctx.git) return null; + const indicator = ctx.git.dirty ? '*' : ''; + const text = cyan(ctx.git.branch) + (indicator ? yellow(indicator) : ''); + 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..45ef6d2 --- /dev/null +++ b/src/cli/hud/components/model.ts @@ -0,0 +1,10 @@ +import type { ComponentResult, GatherContext } from '../types.js'; +import { magenta } from '../colors.js'; + +export default async function model( + ctx: GatherContext, +): Promise { + const name = ctx.stdin.model?.display_name; + if (!name) return null; + return { text: magenta(name), raw: name }; +} 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/speed.ts b/src/cli/hud/components/speed.ts new file mode 100644 index 0000000..67e1d74 --- /dev/null +++ b/src/cli/hud/components/speed.ts @@ -0,0 +1,11 @@ +import type { ComponentResult, GatherContext } from '../types.js'; +import { dim } from '../colors.js'; + +export default async function speed( + ctx: GatherContext, +): Promise { + if (!ctx.speed?.tokensPerSecond) return null; + const tps = Math.round(ctx.speed.tokensPerSecond); + const label = `${tps} tok/s`; + return { text: dim(label), raw: label }; +} diff --git a/src/cli/hud/components/todo-progress.ts b/src/cli/hud/components/todo-progress.ts new file mode 100644 index 0000000..2930b11 --- /dev/null +++ b/src/cli/hud/components/todo-progress.ts @@ -0,0 +1,12 @@ +import type { ComponentResult, GatherContext } from '../types.js'; +import { cyan } 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: cyan(label), raw: label }; +} diff --git a/src/cli/hud/components/tool-activity.ts b/src/cli/hud/components/tool-activity.ts new file mode 100644 index 0000000..2b9c69f --- /dev/null +++ b/src/cli/hud/components/tool-activity.ts @@ -0,0 +1,39 @@ +import type { ComponentResult, GatherContext } from '../types.js'; +import { green, yellow, dim } from '../colors.js'; + +export default async function toolActivity( + ctx: GatherContext, +): Promise { + if (!ctx.transcript) return null; + const { tools } = ctx.transcript; + if (tools.length === 0) return null; + + const running = tools.filter((t) => t.status === 'running'); + const completed = tools.filter((t) => t.status === 'completed'); + + // Count by name for completed + const counts = new Map(); + for (const t of completed) { + counts.set(t.name, (counts.get(t.name) || 0) + 1); + } + + const parts: string[] = []; + const rawParts: string[] = []; + + // Show completed summary (top 3) + for (const [name, count] of Array.from(counts.entries()).slice(0, 3)) { + const s = `\u2713 ${name} \u00D7${count}`; + parts.push(green(s)); + rawParts.push(s); + } + + // Show running (top 2) + for (const t of running.slice(0, 2)) { + const s = `\u25D0 ${t.name}`; + parts.push(yellow(s)); + rawParts.push(s); + } + + if (parts.length === 0) return null; + return { text: parts.join(dim(' ')), raw: rawParts.join(' ') }; +} diff --git a/src/cli/hud/components/usage-quota.ts b/src/cli/hud/components/usage-quota.ts new file mode 100644 index 0000000..001dfac --- /dev/null +++ b/src/cli/hud/components/usage-quota.ts @@ -0,0 +1,25 @@ +import type { ComponentResult, GatherContext } from '../types.js'; +import { green, yellow, red, dim } from '../colors.js'; + +function renderBar(percent: number): { text: string; raw: string } { + const blocks = 3; + const filled = Math.round((percent / 100) * blocks); + const empty = blocks - filled; + const colorFn = percent < 50 ? green : percent < 80 ? yellow : red; + const filledBar = '\u258B'.repeat(filled); + const emptyBar = '\u258B'.repeat(empty); + const text = colorFn(filledBar) + dim(emptyBar) + ` ${percent}%`; + const raw = '\u258B'.repeat(blocks) + ` ${percent}%`; + return { text, raw }; +} + +export default async function usageQuota( + ctx: GatherContext, +): Promise { + if (!ctx.usage) return null; + // Prefer daily, fallback to weekly + const pct = ctx.usage.dailyUsagePercent ?? ctx.usage.weeklyUsagePercent; + if (pct === null || pct === undefined) return null; + const bar = renderBar(Math.round(pct)); + return { text: bar.text, raw: bar.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..fc57139 --- /dev/null +++ b/src/cli/hud/components/version-badge.ts @@ -0,0 +1,101 @@ +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 { green, cyan } 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 = `\u2B06 ${info.latest}`; + return { text: green(badge), raw: badge }; + } + + // Show current version as dim badge when up to date + const badge = `v${current}`; + return { text: cyan(badge), raw: badge }; +} diff --git a/src/cli/hud/config.ts b/src/cli/hud/config.ts new file mode 100644 index 0000000..f019a3a --- /dev/null +++ b/src/cli/hud/config.ts @@ -0,0 +1,94 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { HudConfig, PresetName, ComponentId } from './types.js'; + +/** + * Preset definitions mapping preset names to their component lists. + */ +export const PRESETS: Record = { + minimal: ['directory', 'gitBranch', 'model', 'contextUsage'], + classic: [ + 'directory', + 'gitBranch', + 'gitAheadBehind', + 'diffStats', + 'model', + 'contextUsage', + 'versionBadge', + ], + standard: [ + 'directory', + 'gitBranch', + 'gitAheadBehind', + 'diffStats', + 'model', + 'contextUsage', + 'versionBadge', + 'sessionDuration', + 'usageQuota', + ], + full: [ + 'directory', + 'gitBranch', + 'gitAheadBehind', + 'diffStats', + 'model', + 'contextUsage', + 'versionBadge', + 'sessionDuration', + 'usageQuota', + 'toolActivity', + 'agentActivity', + 'todoProgress', + 'speed', + 'configCounts', + ], +}; + +export const DEFAULT_PRESET: PresetName = 'standard'; + +/** + * All valid component IDs for validation. + */ +export const ALL_COMPONENT_IDS: ReadonlySet = new Set(PRESETS.full); + +export function getConfigPath(): string { + const devflowDir = + process.env.DEVFLOW_DIR || path.join(process.env.HOME || '~', '.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; + const preset = + parsed.preset && (parsed.preset in PRESETS || parsed.preset === 'custom') + ? parsed.preset + : DEFAULT_PRESET; + const components = + preset === 'custom' && Array.isArray(parsed.components) + ? parsed.components.filter((c): c is ComponentId => ALL_COMPONENT_IDS.has(c)) + : PRESETS[preset as PresetName] ?? PRESETS[DEFAULT_PRESET]; + return { preset, components }; + } catch { + return { preset: DEFAULT_PRESET, components: PRESETS[DEFAULT_PRESET] }; + } +} + +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.preset === 'custom') { + return config.components; + } + return PRESETS[config.preset as PresetName] ?? PRESETS[DEFAULT_PRESET]; +} diff --git a/src/cli/hud/git.ts b/src/cli/hud/git.ts new file mode 100644 index 0000000..f1338b7 --- /dev/null +++ b/src/cli/hud/git.ts @@ -0,0 +1,122 @@ +import { execFile } from 'node:child_process'; +import type { GitStatus } from './types.js'; + +const GIT_TIMEOUT = 1000; // 1s per command + +function gitExec(args: string[], cwd: string): Promise { + return new Promise((resolve) => { + execFile('git', args, { cwd, timeout: GIT_TIMEOUT }, (err, stdout) => { + resolve(err ? '' : stdout.trim()); + }); + }); +} + +/** + * 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, + ); + const dirty = statusOutput.length > 0; + + // 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; + } + + return { branch, dirty, ahead, behind, filesChanged, additions, deletions }; +} + +/** + * Detect the base branch for ahead/behind calculations. + * Uses a 3-layer fallback: branch reflog, HEAD reflog, main/master. + */ +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: 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..76f903a --- /dev/null +++ b/src/cli/hud/index.ts @@ -0,0 +1,89 @@ +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(); + const config = loadConfig(); + const components = new Set(resolveComponents(config)); + const cwd = stdin.cwd || process.cwd(); + const devflowDir = + process.env.DEVFLOW_DIR || + (await import('node:path')).join( + process.env.HOME || '~', + '.devflow', + ); + + // Determine what data to gather based on enabled components + const needsGit = + components.has('gitBranch') || + components.has('gitAheadBehind') || + components.has('diffStats'); + const needsTranscript = + components.has('toolActivity') || + components.has('agentActivity') || + components.has('todoProgress') || + components.has('speed'); + 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 session_id presence (approximate via process uptime) + let sessionStartTime: number | null = null; + if (stdin.session_id) { + sessionStartTime = Date.now() - process.uptime() * 1000; + } + + // 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, + speed: null, // Speed requires cross-render state tracking (future enhancement) + configCounts: configCountsData, + config: { ...config, components: resolveComponents(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..5108e15 --- /dev/null +++ b/src/cli/hud/render.ts @@ -0,0 +1,108 @@ +import type { + ComponentId, + ComponentResult, + GatherContext, + ComponentFn, +} from './types.js'; +import { dim } from './colors.js'; + +// Import all components +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 toolActivity from './components/tool-activity.js'; +import agentActivity from './components/agent-activity.js'; +import todoProgress from './components/todo-progress.js'; +import speed from './components/speed.js'; +import configCounts from './components/config-counts.js'; + +const COMPONENT_MAP: Record = { + directory, + gitBranch, + gitAheadBehind, + diffStats, + model, + contextUsage, + versionBadge, + sessionDuration, + usageQuota, + toolActivity, + agentActivity, + todoProgress, + speed, + configCounts, +}; + +/** + * Line groupings for smart layout. + * Components are assigned to lines and only rendered if enabled. + */ +const LINE_GROUPS: ComponentId[][] = [ + // Line 1: core info + [ + 'directory', + 'gitBranch', + 'gitAheadBehind', + 'diffStats', + 'model', + 'contextUsage', + 'versionBadge', + ], + // Line 2: session + quota + ['sessionDuration', 'usageQuota', 'speed'], + // Line 3: tool activity + ['toolActivity'], + // Line 4: agents + todos + config + ['agentActivity', 'todoProgress', 'configCounts'], +]; + +const SEPARATOR = dim(' '); + +/** + * 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 + const lines: string[] = []; + + for (const lineGroup of LINE_GROUPS) { + const lineResults = lineGroup + .filter((id) => enabled.has(id) && results.has(id)) + .map((id) => results.get(id)!); + + if (lineResults.length > 0) { + lines.push(lineResults.map((r) => r.text).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..b678aec --- /dev/null +++ b/src/cli/hud/transcript.ts @@ -0,0 +1,124 @@ +import * as fs from 'node:fs'; +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' } + >(); + const agents = new Map< + string, + { name: string; model?: string; status: 'running' | 'completed' } + >(); + 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; + const todoResult = processEntry(entry, tools, agents); + 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 }, + }; + } 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' } + >, +): 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'; + agents.set(id, { + name: agentType, + model: input?.model as string | undefined, + status: 'running', + }); + } 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 { + tools.set(id, { name, status: 'running' }); + } + } 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..4fd43d4 --- /dev/null +++ b/src/cli/hud/types.ts @@ -0,0 +1,121 @@ +/** + * 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 }; + }; + session_id?: string; + transcript_path?: string; +} + +/** + * Component IDs — the 14 HUD components. + */ +export type ComponentId = + | 'directory' + | 'gitBranch' + | 'gitAheadBehind' + | 'diffStats' + | 'model' + | 'contextUsage' + | 'versionBadge' + | 'sessionDuration' + | 'usageQuota' + | 'toolActivity' + | 'agentActivity' + | 'todoProgress' + | 'speed' + | 'configCounts'; + +/** + * Preset names. + */ +export type PresetName = 'minimal' | 'classic' | 'standard' | 'full'; + +/** + * HUD config persisted to ~/.devflow/hud.json. + */ +export interface HudConfig { + preset: PresetName | 'custom'; + components: ComponentId[]; +} + +/** + * 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; + ahead: number; + behind: number; + filesChanged: number; + additions: number; + deletions: number; +} + +/** + * Transcript data parsed from session JSONL. + */ +export interface TranscriptData { + tools: Array<{ name: string; status: 'running' | 'completed' }>; + agents: Array<{ name: string; model?: string; status: 'running' | 'completed' }>; + todos: { completed: number; total: number }; +} + +/** + * Usage API data. + */ +export interface UsageData { + dailyUsagePercent: number | null; + weeklyUsagePercent: number | null; +} + +/** + * Speed tracking data. + */ +export interface SpeedData { + tokensPerSecond: 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; + speed: SpeedData | null; + configCounts: ConfigCountsData | null; + config: HudConfig; + 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..a0c8d98 --- /dev/null +++ b/src/cli/hud/usage-api.ts @@ -0,0 +1,104 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { readCache, writeCache, readCacheStale } from './cache.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 = 15_000; +const BACKOFF_CACHE_KEY = 'usage-backoff'; + +interface BackoffState { + retryAfter: number; + delay: number; +} + +function getCredentialsPath(): string { + const claudeDir = + process.env.CLAUDE_CONFIG_DIR || + path.join(process.env.HOME || '~', '.claude'); + return path.join(claudeDir, '.credentials.json'); +} + +function getOAuthToken(): string | null { + try { + const raw = fs.readFileSync(getCredentialsPath(), 'utf-8'); + const creds = JSON.parse(raw) as Record; + const oauth = creds.claudeAiOauth as Record | undefined; + return (oauth?.accessToken as string) || null; + } catch { + return null; + } +} + +/** + * 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) { + return readCacheStale(USAGE_CACHE_KEY); + } + + // Check cache + const cached = readCache(USAGE_CACHE_KEY); + if (cached) return cached; + + const token = getOAuthToken(); + if (!token) return null; + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), API_TIMEOUT); + + const response = await fetch('https://api.anthropic.com/api/oauth/usage', { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + 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, + ); + return readCacheStale(USAGE_CACHE_KEY); + } + + if (!response.ok) { + writeCache(USAGE_CACHE_KEY, null, USAGE_FAIL_TTL); + return readCacheStale(USAGE_CACHE_KEY); + } + + const body = (await response.json()) as Record; + const data: UsageData = { + dailyUsagePercent: + typeof body.daily_usage_percent === 'number' + ? body.daily_usage_percent + : null, + weeklyUsagePercent: + typeof body.weekly_usage_percent === 'number' + ? body.weekly_usage_percent + : null, + }; + + writeCache(USAGE_CACHE_KEY, data, USAGE_CACHE_TTL); + return data; + } catch { + 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..3124d60 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?: string | false; }; 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/hud-components.test.ts b/tests/hud-components.test.ts new file mode 100644 index 0000000..20062d6 --- /dev/null +++ b/tests/hud-components.test.ts @@ -0,0 +1,396 @@ +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 toolActivity from '../src/cli/hud/components/tool-activity.js'; +import agentActivity from '../src/cli/hud/components/agent-activity.js'; +import todoProgress from '../src/cli/hud/components/todo-progress.js'; +import speed from '../src/cli/hud/components/speed.js'; +import { stripAnsi } from '../src/cli/hud/colors.js'; + +function makeCtx(overrides: Partial = {}): GatherContext { + return { + stdin: {}, + git: null, + transcript: null, + usage: null, + speed: null, + configCounts: null, + config: { preset: 'standard', components: [] }, + devflowDir: '/test/.devflow', + sessionStartTime: null, + terminalWidth: 120, + ...overrides, + }; +} + +function makeGit(overrides: Partial = {}): GitStatus { + return { + branch: 'main', + dirty: false, + ahead: 0, + behind: 0, + filesChanged: 0, + additions: 0, + deletions: 0, + ...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', async () => { + const ctx = makeCtx({ + git: makeGit({ branch: 'main', dirty: 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'); + expect(result!.raw).toContain('+100'); + expect(result!.raw).toContain('-30'); + }); + + 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('returns model display 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('Claude Sonnet 4'); + }); + + it('returns null when no model', async () => { + const ctx = makeCtx(); + const result = await model(ctx); + expect(result).toBeNull(); + }); +}); + +describe('contextUsage component', () => { + it('shows percentage with green for low usage', 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).toBe('25%'); + // Green color for < 50% + expect(result!.text).toContain('\x1b[32m'); + }); + + it('shows yellow for medium usage', async () => { + const ctx = makeCtx({ + stdin: { + context_window: { + context_window_size: 200000, + current_usage: { input_tokens: 120000 }, + }, + }, + }); + const result = await contextUsage(ctx); + expect(result!.raw).toBe('60%'); + // Yellow for 50-80% + expect(result!.text).toContain('\x1b[33m'); + }); + + it('shows red for high usage', async () => { + const ctx = makeCtx({ + stdin: { + context_window: { + context_window_size: 200000, + current_usage: { input_tokens: 180000 }, + }, + }, + }); + const result = await contextUsage(ctx); + expect(result!.raw).toBe('90%'); + // Red for > 80% + expect(result!.text).toContain('\x1b[31m'); + }); + + 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 bar with percentage', async () => { + const ctx = makeCtx({ usage: { dailyUsagePercent: 45, weeklyUsagePercent: null } }); + const result = await usageQuota(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toContain('45%'); + }); + + it('falls back to weekly when daily is null', async () => { + const ctx = makeCtx({ usage: { dailyUsagePercent: null, weeklyUsagePercent: 70 } }); + const result = await usageQuota(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toContain('70%'); + }); + + 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: { dailyUsagePercent: null, weeklyUsagePercent: null } }); + const result = await usageQuota(ctx); + expect(result).toBeNull(); + }); +}); + +describe('toolActivity component', () => { + it('shows completed tools', async () => { + const transcript: TranscriptData = { + tools: [ + { name: 'Read', status: 'completed' }, + { name: 'Read', status: 'completed' }, + { name: 'Bash', status: 'completed' }, + ], + agents: [], + todos: { completed: 0, total: 0 }, + }; + const ctx = makeCtx({ transcript }); + const result = await toolActivity(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toContain('Read'); + expect(result!.raw).toContain('Bash'); + }); + + it('shows running tools', async () => { + const transcript: TranscriptData = { + tools: [{ name: 'Bash', status: 'running' }], + agents: [], + todos: { completed: 0, total: 0 }, + }; + const ctx = makeCtx({ transcript }); + const result = await toolActivity(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toContain('\u25D0 Bash'); + }); + + it('returns null when no tools', async () => { + const transcript: TranscriptData = { + tools: [], + agents: [], + todos: { completed: 0, total: 0 }, + }; + const ctx = makeCtx({ transcript }); + const result = await toolActivity(ctx); + expect(result).toBeNull(); + }); + + it('returns null when no transcript', async () => { + const ctx = makeCtx(); + const result = await toolActivity(ctx); + expect(result).toBeNull(); + }); +}); + +describe('agentActivity component', () => { + it('shows agent status', async () => { + const transcript: TranscriptData = { + tools: [], + agents: [ + { name: 'Reviewer', status: 'completed' }, + { name: 'Coder', model: 'haiku', status: 'running' }, + ], + todos: { completed: 0, total: 0 }, + }; + const ctx = makeCtx({ transcript }); + const result = await agentActivity(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toContain('\u2713 Reviewer'); + expect(result!.raw).toContain('\u25D0 Coder'); + expect(result!.raw).toContain('[haiku]'); + }); + + it('returns null when no agents', async () => { + const transcript: TranscriptData = { + tools: [], + agents: [], + todos: { completed: 0, total: 0 }, + }; + const ctx = makeCtx({ transcript }); + const result = await agentActivity(ctx); + expect(result).toBeNull(); + }); +}); + +describe('todoProgress component', () => { + it('shows progress', async () => { + const transcript: TranscriptData = { + tools: [], + agents: [], + 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'); + }); + + it('returns null when no todos', async () => { + const transcript: TranscriptData = { + tools: [], + agents: [], + todos: { completed: 0, total: 0 }, + }; + const ctx = makeCtx({ transcript }); + const result = await todoProgress(ctx); + expect(result).toBeNull(); + }); +}); + +describe('speed component', () => { + it('shows tokens per second', async () => { + const ctx = makeCtx({ + speed: { tokensPerSecond: 42 }, + }); + const result = await speed(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toBe('42 tok/s'); + }); + + it('returns null when no speed data', async () => { + const ctx = makeCtx(); + const result = await speed(ctx); + expect(result).toBeNull(); + }); +}); + +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..d80a2db --- /dev/null +++ b/tests/hud-render.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect } from 'vitest'; +import { render } from '../src/cli/hud/render.js'; +import { + PRESETS, + loadConfig, + resolveComponents, +} from '../src/cli/hud/config.js'; +import { stripAnsi } from '../src/cli/hud/colors.js'; +import type { GatherContext, GitStatus, HudConfig } from '../src/cli/hud/types.js'; + +function makeCtx( + config: HudConfig, + overrides: Partial = {}, +): GatherContext { + 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, + ahead: 2, + behind: 0, + filesChanged: 3, + additions: 50, + deletions: 10, + }, + transcript: null, + usage: null, + speed: null, + configCounts: null, + config, + devflowDir: '/test/.devflow', + sessionStartTime: null, + terminalWidth: 120, + ...overrides, + }; +} + +describe('render', () => { + it('minimal preset produces single line', async () => { + const config: HudConfig = { + preset: 'minimal', + components: PRESETS.minimal, + }; + const ctx = makeCtx(config); + const output = await render(ctx); + const lines = output.split('\n').filter((l) => l.length > 0); + + expect(lines).toHaveLength(1); + const raw = stripAnsi(output); + expect(raw).toContain('project'); + expect(raw).toContain('feat/test'); + expect(raw).toContain('Claude Opus 4'); + expect(raw).toContain('20%'); + }); + + it('classic preset produces single line', async () => { + const config: HudConfig = { + preset: 'classic', + components: PRESETS.classic, + }; + const ctx = makeCtx(config); + const output = await render(ctx); + const lines = output.split('\n').filter((l) => l.length > 0); + + // All classic components are in LINE_1 + expect(lines).toHaveLength(1); + const raw = stripAnsi(output); + expect(raw).toContain('project'); + expect(raw).toContain('feat/test'); + expect(raw).toContain('2\u2191'); // ahead arrows + expect(raw).toContain('+50'); + expect(raw).toContain('-10'); + expect(raw).toContain('Claude Opus 4'); + expect(raw).toContain('20%'); + }); + + it('standard preset produces 2 lines with session data', async () => { + const config: HudConfig = { + preset: 'standard', + components: PRESETS.standard, + }; + const ctx = makeCtx(config, { + sessionStartTime: Date.now() - 15 * 60 * 1000, + usage: { dailyUsagePercent: 30, weeklyUsagePercent: null }, + }); + const output = await render(ctx); + const lines = output.split('\n').filter((l) => l.length > 0); + + // Line 1: core info, Line 2: session + usage + expect(lines).toHaveLength(2); + const raw = stripAnsi(output); + expect(raw).toContain('15m'); + expect(raw).toContain('30%'); + }); + + it('full preset produces 2+ lines with transcript data', async () => { + const config: HudConfig = { + preset: 'full', + components: PRESETS.full, + }; + const ctx = makeCtx(config, { + sessionStartTime: Date.now() - 5 * 60 * 1000, + usage: { dailyUsagePercent: 20, weeklyUsagePercent: null }, + transcript: { + tools: [ + { name: 'Read', status: 'completed' }, + { name: 'Bash', status: 'running' }, + ], + agents: [{ name: 'Reviewer', status: 'completed' }], + todos: { completed: 2, total: 4 }, + }, + configCounts: { + claudeMdFiles: 2, + rules: 3, + mcpServers: 1, + hooks: 4, + }, + }); + const output = await render(ctx); + const lines = output.split('\n').filter((l) => l.length > 0); + + // Should have multiple lines + expect(lines.length).toBeGreaterThanOrEqual(3); + const raw = stripAnsi(output); + expect(raw).toContain('Read'); + expect(raw).toContain('Reviewer'); + 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'); + }); + + it('components that return null are excluded', async () => { + const config: HudConfig = { + preset: 'minimal', + components: PRESETS.minimal, + }; + // No cwd, no model, no context — all components return null + const ctx = makeCtx(config, { + stdin: {}, + git: null, + }); + const output = await render(ctx); + + expect(output).toBe(''); + }); + + it('handles custom preset with subset of components', async () => { + const config: HudConfig = { + preset: 'custom', + components: ['directory', 'model'], + }; + const ctx = makeCtx(config); + const output = await render(ctx); + const raw = stripAnsi(output); + + expect(raw).toContain('project'); + expect(raw).toContain('Claude Opus 4'); + // Should not contain git info + expect(raw).not.toContain('feat/test'); + }); +}); + +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.preset).toBe('standard'); + expect(config.components).toEqual(PRESETS.standard); + } finally { + if (originalEnv !== undefined) { + process.env.DEVFLOW_DIR = originalEnv; + } else { + delete process.env.DEVFLOW_DIR; + } + } + }); + + it('resolveComponents returns preset components', () => { + const config: HudConfig = { preset: 'minimal', components: [] }; + expect(resolveComponents(config)).toEqual(PRESETS.minimal); + }); + + it('resolveComponents returns custom components', () => { + const config: HudConfig = { + preset: 'custom', + components: ['directory', 'model'], + }; + expect(resolveComponents(config)).toEqual(['directory', 'model']); + }); + + it('PRESETS.minimal has 4 components', () => { + expect(PRESETS.minimal).toHaveLength(4); + }); + + it('PRESETS.classic has 7 components', () => { + expect(PRESETS.classic).toHaveLength(7); + }); + + it('PRESETS.standard has 9 components', () => { + expect(PRESETS.standard).toHaveLength(9); + }); + + it('PRESETS.full has 14 components', () => { + expect(PRESETS.full).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); + }); +}); From d1ddedf6cccb436e8e7552536f4863250015cb79 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Sat, 21 Mar 2026 03:50:11 +0200 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20simplify=20HUD=20code=20?= =?UTF-8?q?=E2=80=94=20consolidate=20cache,=20eliminate=20nested=20ternari?= =?UTF-8?q?es,=20reduce=20duplication?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Consolidate readCache/readCacheStale into shared readCacheEntry helper - Replace nested ternaries with if/else chains in context-usage and usage-quota - Remove dead `=== undefined` check in usage-quota (nullish coalescing never produces undefined) - Simplify agent-activity loop by extracting icon/colorFn before push - Merge duplicate HUD settings.json branches in init.ts (add/remove unified) - Consolidate three separate settings.json writes into single pass in uninstall.ts - Reuse existing settingsPath variable instead of redeclaring hudSettingsPath - Use const for sessionStartTime (conditional expression instead of let + if) - Replace dynamic import('node:path') with static import in index.ts - Use homedir() instead of '~' literal for reliable home directory fallback - Tighten isDevFlowStatusLine matching to avoid false positives on partial substrings - Reduce API_TIMEOUT from 15s to 1.5s to fit within 2s overall HUD timeout - Remove unused GitStatus import from hud-render.test.ts --- src/cli/commands/hud.ts | 14 ++++----- src/cli/commands/init.ts | 38 +++++++++--------------- src/cli/commands/uninstall.ts | 34 +++++---------------- src/cli/hud/cache.ts | 36 +++++++++++----------- src/cli/hud/components/agent-activity.ts | 11 +++---- src/cli/hud/components/config-counts.ts | 5 ++-- src/cli/hud/components/context-usage.ts | 9 +++++- src/cli/hud/components/usage-quota.ts | 13 ++++++-- src/cli/hud/config.ts | 3 +- src/cli/hud/index.ts | 14 ++++----- src/cli/hud/usage-api.ts | 5 ++-- tests/hud-render.test.ts | 2 +- 12 files changed, 85 insertions(+), 99 deletions(-) diff --git a/src/cli/commands/hud.ts b/src/cli/commands/hud.ts index 6c32977..76f18c2 100644 --- a/src/cli/commands/hud.ts +++ b/src/cli/commands/hud.ts @@ -13,11 +13,6 @@ import { } from '../hud/config.js'; import type { HudConfig, PresetName } from '../hud/types.js'; -/** - * Marker to identify DevFlow HUD in settings.json statusLine. - */ -const HUD_MARKER = 'hud'; - interface StatusLine { type: string; command: string; @@ -90,12 +85,15 @@ export function hasHudStatusLine(settingsJson: string): boolean { /** * 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 ( - statusLine.command?.includes(HUD_MARKER) || - statusLine.command?.includes('statusline') || - statusLine.command?.includes('devflow') + cmd.includes('hud.sh') || + cmd.includes('statusline.sh') || + cmd.includes('/devflow/') || + cmd.includes('\\devflow\\') ); } diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 2a5ff7e..d5e82d5 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -552,36 +552,26 @@ export const initCommand = new Command('init') // Configure HUD if (hudPreset !== 'off') { - // Save HUD config saveHudConfig({ preset: hudPreset, components: PRESETS[hudPreset] }); + } - // Ensure statusLine points to HUD (the settings template already has it, - // but upgrade from old statusline.sh may need this) - const hudSettingsPath = path.join(claudeDir, 'settings.json'); - try { - const hudContent = await fs.readFile(hudSettingsPath, 'utf-8'); - const hudUpdated = addHudStatusLine(hudContent, devflowDir); - if (hudUpdated !== hudContent) { - await fs.writeFile(hudSettingsPath, hudUpdated, 'utf-8'); - if (verbose) { + // Update statusLine in settings.json (add or remove based on preset) + try { + const hudContent = await fs.readFile(settingsPath, 'utf-8'); + const hudUpdated = hudPreset !== 'off' + ? addHudStatusLine(hudContent, devflowDir) + : removeHudStatusLine(hudContent); + if (hudUpdated !== hudContent) { + await fs.writeFile(settingsPath, hudUpdated, 'utf-8'); + if (verbose) { + if (hudPreset !== 'off') { p.log.success(`HUD enabled (preset: ${hudPreset})`); - } - } - } catch { /* settings.json may not exist yet */ } - } else { - // HUD disabled — remove statusLine if it points to DevFlow - const hudSettingsPath = path.join(claudeDir, 'settings.json'); - try { - const hudContent = await fs.readFile(hudSettingsPath, 'utf-8'); - const hudUpdated = removeHudStatusLine(hudContent); - if (hudUpdated !== hudContent) { - await fs.writeFile(hudSettingsPath, hudUpdated, 'utf-8'); - if (verbose) { + } else { p.log.info('HUD disabled'); } } - } catch { /* settings.json may not exist yet */ } - } + } + } catch { /* settings.json may not exist yet */ } } const fileExtras = selectedExtras.filter(e => e !== 'settings' && e !== 'safe-delete'); diff --git a/src/cli/commands/uninstall.ts b/src/cli/commands/uninstall.ts index 1ee7c37..d467dd1 100644 --- a/src/cli/commands/uninstall.ts +++ b/src/cli/commands/uninstall.ts @@ -391,35 +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})`); - } - } - - // 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 (verbose) { - p.log.success(`Memory hooks 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 HUD statusLine on full uninstall (idempotent) - const withoutHud = removeHudStatusLine(settingsContent); - if (withoutHud !== settingsContent) { - await fs.writeFile(settingsPath, withoutHud, 'utf-8'); - settingsContent = withoutHud; + if (settingsContent !== originalContent) { + await fs.writeFile(settingsPath, settingsContent, 'utf-8'); if (verbose) { - p.log.success(`HUD statusLine 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 index 84524f1..2906baf 100644 --- a/src/cli/hud/cache.ts +++ b/src/cli/hud/cache.ts @@ -1,5 +1,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; +import { homedir } from 'node:os'; interface CacheEntry { data: T; @@ -9,19 +10,20 @@ interface CacheEntry { export function getCacheDir(): string { const devflowDir = - process.env.DEVFLOW_DIR || path.join(process.env.HOME || '~', '.devflow'); + 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). */ -export function readCache(key: string): T | null { +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 (Date.now() - entry.timestamp < entry.ttl) { + if (ignoreExpiry || Date.now() - entry.timestamp < entry.ttl) { return entry.data; } return null; @@ -30,6 +32,20 @@ export function readCache(key: string): T | 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. */ @@ -45,17 +61,3 @@ export function writeCache(key: string, data: T, ttlMs: number): void { // Cache write failure is non-fatal } } - -/** - * Read a cached value regardless of TTL (stale data). Returns null if missing. - */ -export function readCacheStale(key: string): T | null { - try { - const filePath = path.join(getCacheDir(), `${key}.json`); - const raw = fs.readFileSync(filePath, 'utf-8'); - const entry = JSON.parse(raw) as CacheEntry; - return entry.data; - } catch { - return null; - } -} diff --git a/src/cli/hud/components/agent-activity.ts b/src/cli/hud/components/agent-activity.ts index fbea5fd..6f6559e 100644 --- a/src/cli/hud/components/agent-activity.ts +++ b/src/cli/hud/components/agent-activity.ts @@ -12,15 +12,12 @@ export default async function agentActivity( const rawParts: string[] = []; for (const a of agents.slice(-4)) { + const icon = a.status === 'completed' ? '\u2713' : '\u25D0'; + const colorFn = a.status === 'completed' ? green : yellow; const modelTag = a.model ? gray(` [${a.model}]`) : ''; const modelRaw = a.model ? ` [${a.model}]` : ''; - if (a.status === 'completed') { - parts.push(green(`\u2713 ${a.name}`) + modelTag); - rawParts.push(`\u2713 ${a.name}${modelRaw}`); - } else { - parts.push(yellow(`\u25D0 ${a.name}`) + modelTag); - rawParts.push(`\u25D0 ${a.name}${modelRaw}`); - } + parts.push(colorFn(`${icon} ${a.name}`) + modelTag); + rawParts.push(`${icon} ${a.name}${modelRaw}`); } return { text: parts.join(dim(' ')), raw: rawParts.join(' ') }; diff --git a/src/cli/hud/components/config-counts.ts b/src/cli/hud/components/config-counts.ts index d31e0ef..e36b394 100644 --- a/src/cli/hud/components/config-counts.ts +++ b/src/cli/hud/components/config-counts.ts @@ -1,5 +1,6 @@ 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'; @@ -10,7 +11,7 @@ function countClaudeMdFiles(cwd: string): number { // Check user CLAUDE.md const claudeDir = process.env.CLAUDE_CONFIG_DIR || - path.join(process.env.HOME || '~', '.claude'); + path.join(process.env.HOME || homedir(), '.claude'); if (fs.existsSync(path.join(claudeDir, 'CLAUDE.md'))) count++; return count; } @@ -44,7 +45,7 @@ function countFromSettings(settingsPath: string): { export function gatherConfigCounts(cwd: string): ConfigCountsData { const claudeDir = process.env.CLAUDE_CONFIG_DIR || - path.join(process.env.HOME || '~', '.claude'); + path.join(process.env.HOME || homedir(), '.claude'); const claudeMdFiles = countClaudeMdFiles(cwd); // Count rules (.md/.mdc files in .claude/rules) diff --git a/src/cli/hud/components/context-usage.ts b/src/cli/hud/components/context-usage.ts index a09b1b4..29afc2b 100644 --- a/src/cli/hud/components/context-usage.ts +++ b/src/cli/hud/components/context-usage.ts @@ -17,6 +17,13 @@ export default async function contextUsage( const label = `${pct}%`; // Color thresholds from statusline.sh - const colorFn = pct < 50 ? green : pct < 80 ? yellow : red; + let colorFn: (s: string) => string; + if (pct < 50) { + colorFn = green; + } else if (pct < 80) { + colorFn = yellow; + } else { + colorFn = red; + } return { text: colorFn(label), raw: label }; } diff --git a/src/cli/hud/components/usage-quota.ts b/src/cli/hud/components/usage-quota.ts index 001dfac..e388f79 100644 --- a/src/cli/hud/components/usage-quota.ts +++ b/src/cli/hud/components/usage-quota.ts @@ -5,7 +5,16 @@ function renderBar(percent: number): { text: string; raw: string } { const blocks = 3; const filled = Math.round((percent / 100) * blocks); const empty = blocks - filled; - const colorFn = percent < 50 ? green : percent < 80 ? yellow : red; + + let colorFn: (s: string) => string; + if (percent < 50) { + colorFn = green; + } else if (percent < 80) { + colorFn = yellow; + } else { + colorFn = red; + } + const filledBar = '\u258B'.repeat(filled); const emptyBar = '\u258B'.repeat(empty); const text = colorFn(filledBar) + dim(emptyBar) + ` ${percent}%`; @@ -19,7 +28,7 @@ export default async function usageQuota( if (!ctx.usage) return null; // Prefer daily, fallback to weekly const pct = ctx.usage.dailyUsagePercent ?? ctx.usage.weeklyUsagePercent; - if (pct === null || pct === undefined) return null; + if (pct === null) return null; const bar = renderBar(Math.round(pct)); return { text: bar.text, raw: bar.raw }; } diff --git a/src/cli/hud/config.ts b/src/cli/hud/config.ts index f019a3a..09e2587 100644 --- a/src/cli/hud/config.ts +++ b/src/cli/hud/config.ts @@ -1,5 +1,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; +import { homedir } from 'node:os'; import type { HudConfig, PresetName, ComponentId } from './types.js'; /** @@ -54,7 +55,7 @@ export const ALL_COMPONENT_IDS: ReadonlySet = new Set(PRESETS.fu export function getConfigPath(): string { const devflowDir = - process.env.DEVFLOW_DIR || path.join(process.env.HOME || '~', '.devflow'); + process.env.DEVFLOW_DIR || path.join(process.env.HOME || homedir(), '.devflow'); return path.join(devflowDir, 'hud.json'); } diff --git a/src/cli/hud/index.ts b/src/cli/hud/index.ts index 76f903a..1a57df7 100644 --- a/src/cli/hud/index.ts +++ b/src/cli/hud/index.ts @@ -1,3 +1,5 @@ +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'; @@ -29,10 +31,7 @@ async function run(): Promise { const cwd = stdin.cwd || process.cwd(); const devflowDir = process.env.DEVFLOW_DIR || - (await import('node:path')).join( - process.env.HOME || '~', - '.devflow', - ); + path.join(process.env.HOME || homedir(), '.devflow'); // Determine what data to gather based on enabled components const needsGit = @@ -57,10 +56,9 @@ async function run(): Promise { ]); // Session start time from session_id presence (approximate via process uptime) - let sessionStartTime: number | null = null; - if (stdin.session_id) { - sessionStartTime = Date.now() - process.uptime() * 1000; - } + const sessionStartTime = stdin.session_id + ? Date.now() - process.uptime() * 1000 + : null; // Config counts (fast, synchronous filesystem reads) const configCountsData = needsConfigCounts diff --git a/src/cli/hud/usage-api.ts b/src/cli/hud/usage-api.ts index a0c8d98..a1eacf3 100644 --- a/src/cli/hud/usage-api.ts +++ b/src/cli/hud/usage-api.ts @@ -1,12 +1,13 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; +import { homedir } from 'node:os'; import { readCache, writeCache, readCacheStale } from './cache.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 = 15_000; +const API_TIMEOUT = 1_500; // Must fit within 2s overall HUD timeout const BACKOFF_CACHE_KEY = 'usage-backoff'; interface BackoffState { @@ -17,7 +18,7 @@ interface BackoffState { function getCredentialsPath(): string { const claudeDir = process.env.CLAUDE_CONFIG_DIR || - path.join(process.env.HOME || '~', '.claude'); + path.join(process.env.HOME || homedir(), '.claude'); return path.join(claudeDir, '.credentials.json'); } diff --git a/tests/hud-render.test.ts b/tests/hud-render.test.ts index d80a2db..ceee19d 100644 --- a/tests/hud-render.test.ts +++ b/tests/hud-render.test.ts @@ -6,7 +6,7 @@ import { resolveComponents, } from '../src/cli/hud/config.js'; import { stripAnsi } from '../src/cli/hud/colors.js'; -import type { GatherContext, GitStatus, HudConfig } from '../src/cli/hud/types.js'; +import type { GatherContext, HudConfig } from '../src/cli/hud/types.js'; function makeCtx( config: HudConfig, From 9f23f7939c8eaa12ae16c980b957da6735614c67 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Sat, 21 Mar 2026 03:56:19 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20address=20Shepherd=20misalignments?= =?UTF-8?q?=20=E2=80=94=20add=20--hud-only,=204th=20base=20branch=20layer,?= =?UTF-8?q?=20update=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/file-organization.md | 31 +++++++--- src/cli/commands/init.ts | 91 ++++++++++++++++++++++++++++- src/cli/hud/git.ts | 26 +++++++-- 3 files changed, 135 insertions(+), 13 deletions(-) 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/src/cli/commands/init.ts b/src/cli/commands/init.ts index d5e82d5..53deef4 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -101,6 +101,7 @@ interface InitOptions { ambient?: boolean; memory?: boolean; hud?: string; + hudOnly?: boolean; } export const initCommand = new Command('init') @@ -115,6 +116,7 @@ export const initCommand = new Command('init') .option('--memory', 'Enable working memory (session context preservation)') .option('--no-memory', 'Disable working memory hooks') .option('--hud ', 'HUD preset (minimal, classic, standard, full, off)') + .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'); @@ -134,7 +136,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"'); @@ -161,6 +166,90 @@ export const initCommand = new Command('init') scope = selected as 'user' | 'local'; } + // --hud-only: install only HUD (skip plugins, hooks, extras) + if (options.hudOnly) { + // Resolve HUD preset + let hudPreset: PresetName | 'off' = DEFAULT_PRESET; + if (options.hud !== undefined) { + const val = options.hud; + if (val === 'off') { + hudPreset = 'off'; + } else if (val in PRESETS) { + hudPreset = val as PresetName; + } else { + p.log.error(`Unknown HUD preset: ${val}. Valid: minimal, classic, standard, full, off`); + process.exit(1); + } + } else if (process.stdin.isTTY) { + const hudChoice = await p.select({ + message: 'Choose HUD preset', + options: [ + { value: 'standard', label: 'Standard (Recommended)', hint: 'directory, git, model, context, version, session, usage' }, + { value: 'minimal', label: 'Minimal', hint: 'directory, git branch, model, context usage' }, + { value: 'classic', label: 'Classic', hint: 'Like statusline.sh with version badge' }, + { value: 'full', label: 'Full', hint: 'All 14 components' }, + { value: 'off', label: 'No HUD', hint: 'Disable status line entirely' }, + ], + initialValue: 'standard', + }); + if (p.isCancel(hudChoice)) { + p.cancel('Installation cancelled.'); + process.exit(0); + } + hudPreset = hudChoice as PresetName | 'off'; + } + + // Resolve paths + const paths = await getInstallationPaths(scope); + const claudeDir = paths.claudeDir; + const devflowDir = paths.devflowDir; + + // Save HUD config + if (hudPreset !== 'off') { + saveHudConfig({ preset: hudPreset, components: PRESETS[hudPreset] }); + } + + // 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 = hudPreset !== 'off' + ? addHudStatusLine(content, devflowDir) + : removeHudStatusLine(content); + 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); + } + + // Write minimal manifest + const now = new Date().toISOString(); + try { + await writeManifest(devflowDir, { + version, + plugins: [], + scope, + features: { teams: false, ambient: false, memory: false, hud: hudPreset === 'off' ? false as const : String(hudPreset) }, + installedAt: now, + updatedAt: now, + }); + } catch { /* non-fatal */ } + + if (hudPreset !== 'off') { + p.log.success(`HUD installed (preset: ${hudPreset})`); + } else { + p.log.info('HUD disabled'); + } + p.log.info(`Configure later: ${color.cyan('devflow hud --configure')}`); + p.outro(color.green('HUD-only install complete.')); + return; + } + // Select plugins to install let selectedPlugins: string[] = []; if (options.plugin) { diff --git a/src/cli/hud/git.ts b/src/cli/hud/git.ts index f1338b7..5992cc7 100644 --- a/src/cli/hud/git.ts +++ b/src/cli/hud/git.ts @@ -3,14 +3,18 @@ import type { GitStatus } from './types.js'; const GIT_TIMEOUT = 1000; // 1s per command -function gitExec(args: string[], cwd: string): Promise { +function shellExec(cmd: string, args: string[], cwd: string): Promise { return new Promise((resolve) => { - execFile('git', args, { cwd, timeout: GIT_TIMEOUT }, (err, stdout) => { + 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. @@ -66,7 +70,11 @@ export async function gatherGitStatus(cwd: string): Promise { /** * Detect the base branch for ahead/behind calculations. - * Uses a 3-layer fallback: branch reflog, HEAD reflog, main/master. + * 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, @@ -112,7 +120,17 @@ async function detectBaseBranch( } } - // Layer 3: main/master fallback + // 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; From 578a08aff105cc3905c4f69858104a03bc86bb55 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Sun, 22 Mar 2026 01:50:28 +0200 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20simplify=20HUD=20to=20yes/no=20?= =?UTF-8?q?=E2=80=94=20remove=20preset=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace 4-preset system (minimal/classic/standard/full) with a single component list. HUD is now on or off — no preset picker in init flow. - Collapse PRESETS into HUD_COMPONENTS (14 components) - Simplify HudConfig to { enabled, detail } - Remove --configure, --preset, --hud flags - Init flow: p.confirm("Enable HUD?") instead of 5-option selector - --hud-only installs directly without preset question - --disable keeps statusLine registered (version badge still renders) - Version badge always included so upgrade notifications show even when HUD is disabled - Remove agent-activity, speed, tool-activity components - Add release-info, session-cost, worktree-count components - Manifest hud field: string|false → boolean --- src/cli/commands/hud.ts | 201 ++++-------- src/cli/commands/init.ts | 132 +++----- src/cli/commands/list.ts | 2 +- src/cli/hud/colors.ts | 13 + src/cli/hud/components/agent-activity.ts | 24 -- src/cli/hud/components/config-counts.ts | 7 +- src/cli/hud/components/context-usage.ts | 46 ++- src/cli/hud/components/diff-stats.ts | 40 ++- src/cli/hud/components/directory.ts | 4 +- src/cli/hud/components/git-ahead-behind.ts | 16 +- src/cli/hud/components/git-branch.ts | 11 +- src/cli/hud/components/model.ts | 19 +- src/cli/hud/components/release-info.ts | 11 + src/cli/hud/components/session-cost.ts | 11 + src/cli/hud/components/speed.ts | 11 - src/cli/hud/components/todo-progress.ts | 4 +- src/cli/hud/components/tool-activity.ts | 39 --- src/cli/hud/components/usage-quota.ts | 43 ++- src/cli/hud/components/version-badge.ts | 11 +- src/cli/hud/components/worktree-count.ts | 10 + src/cli/hud/config.ts | 88 ++--- src/cli/hud/credentials.ts | 115 +++++++ src/cli/hud/git.ts | 43 ++- src/cli/hud/index.ts | 33 +- src/cli/hud/render.ts | 77 +++-- src/cli/hud/transcript.ts | 46 ++- src/cli/hud/types.ts | 44 ++- src/cli/hud/usage-api.ts | 57 ++-- src/cli/utils/manifest.ts | 2 +- tests/credentials.test.ts | 149 +++++++++ tests/hud-components.test.ts | 360 +++++++++++++++------ tests/hud-render.test.ts | 182 +++++------ 32 files changed, 1129 insertions(+), 722 deletions(-) delete mode 100644 src/cli/hud/components/agent-activity.ts create mode 100644 src/cli/hud/components/release-info.ts create mode 100644 src/cli/hud/components/session-cost.ts delete mode 100644 src/cli/hud/components/speed.ts delete mode 100644 src/cli/hud/components/tool-activity.ts create mode 100644 src/cli/hud/components/worktree-count.ts create mode 100644 src/cli/hud/credentials.ts create mode 100644 tests/credentials.test.ts diff --git a/src/cli/commands/hud.ts b/src/cli/commands/hud.ts index 76f18c2..485845d 100644 --- a/src/cli/commands/hud.ts +++ b/src/cli/commands/hud.ts @@ -1,17 +1,14 @@ import { Command } from 'commander'; -import { promises as fs } from 'fs'; -import * as path from 'path'; +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 { - PRESETS, - DEFAULT_PRESET, + HUD_COMPONENTS, loadConfig, saveConfig, - resolveComponents, } from '../hud/config.js'; -import type { HudConfig, PresetName } from '../hud/types.js'; interface StatusLine { type: string; @@ -106,59 +103,44 @@ export function hasNonDevFlowStatusLine(settingsJson: string): boolean { return !isDevFlowStatusLine(settings.statusLine); } -/** - * Format a preset preview for interactive display. - */ -function formatPresetPreview(preset: PresetName): string { - const components = PRESETS[preset]; - return components.join(', '); -} - export const hudCommand = new Command('hud') .description('Configure the HUD (status line)') - .option('--configure', 'Interactive preset picker') - .option( - '--preset ', - 'Quick preset switch (minimal, classic, standard, full)', - ) .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.configure || - options.preset || options.status || options.enable || - options.disable; + options.disable || + options.detail !== undefined; if (!hasFlag) { p.intro(color.bgCyan(color.white(' HUD '))); p.note( - `${color.cyan('devflow hud --configure')} Interactive preset picker\n` + - `${color.cyan('devflow hud --preset=')} Quick preset switch\n` + + `${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( - `${color.yellow('minimal')} ${formatPresetPreview('minimal')}\n` + - `${color.yellow('classic')} ${formatPresetPreview('classic')}\n` + - `${color.yellow('standard')} ${formatPresetPreview('standard')}\n` + - `${color.yellow('full')} ${formatPresetPreview('full')}`, - 'Presets', + `${HUD_COMPONENTS.length} components: ${HUD_COMPONENTS.join(', ')}`, + 'Components', ); - p.outro(color.dim('Default preset: standard')); + p.outro(color.dim('Toggle with --enable / --disable')); return; } if (options.status) { const config = loadConfig(); - const components = resolveComponents(config); p.intro(color.bgCyan(color.white(' HUD Status '))); p.note( - `${color.dim('Preset:')} ${color.cyan(config.preset)}\n` + - `${color.dim('Components:')} ${components.join(', ')}`, + `${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', ); @@ -177,130 +159,75 @@ export const hudCommand = new Command('hud') return; } - if (options.preset) { - const preset = options.preset as string; - if (!(preset in PRESETS)) { - p.log.error( - `Unknown preset: ${preset}. Valid: ${Object.keys(PRESETS).join(', ')}`, - ); - process.exit(1); - } - const config: HudConfig = { - preset: preset as PresetName, - components: PRESETS[preset as PresetName], - }; - saveConfig(config); - p.log.success(`HUD preset set to ${color.cyan(preset)}`); - p.log.info( - color.dim(`Components: ${config.components.join(', ')}`), - ); - return; - } - - if (options.configure) { - const currentConfig = loadConfig(); - const presetChoice = await p.select({ - message: 'Choose HUD preset', - options: [ - { - value: 'minimal', - label: 'Minimal', - hint: formatPresetPreview('minimal'), - }, - { - value: 'classic', - label: 'Classic', - hint: formatPresetPreview('classic'), - }, - { - value: 'standard', - label: 'Standard (Recommended)', - hint: formatPresetPreview('standard'), - }, - { - value: 'full', - label: 'Full', - hint: formatPresetPreview('full'), - }, - ], - initialValue: currentConfig.preset === 'custom' ? DEFAULT_PRESET : currentConfig.preset, - }); - - if (p.isCancel(presetChoice)) { - p.cancel('Configuration cancelled.'); - process.exit(0); - } - - const preset = presetChoice as PresetName; - const config: HudConfig = { - preset, - components: PRESETS[preset], - }; + if (options.detail !== undefined) { + const config = loadConfig(); + config.detail = options.detail; saveConfig(config); - p.log.success(`HUD preset set to ${color.cyan(preset)}`); - p.log.info( - color.dim(`Components: ${config.components.join(', ')}`), - ); + p.log.success(`HUD detail ${config.detail ? 'enabled' : 'disabled'}`); return; } - const claudeDir = getClaudeDirectory(); - const settingsPath = path.join(claudeDir, 'settings.json'); - - let settingsContent: string; - try { - settingsContent = await fs.readFile(settingsPath, 'utf-8'); - } catch { - if (options.status) { - p.log.info('HUD: disabled (no settings.json found)'); - 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 = '{}'; } - settingsContent = '{}'; - } - if (options.enable) { - // 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'); + // 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; } - } 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'); } - const devflowDir = getDevFlowDirectory(); - const updated = addHudStatusLine(settingsContent, devflowDir); - if (updated === settingsContent) { + // Update config + const config = loadConfig(); + if (config.enabled) { p.log.info('HUD already enabled'); return; } - await fs.writeFile(settingsPath, updated, 'utf-8'); - p.log.success('HUD enabled — statusLine registered'); + 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 updated = removeHudStatusLine(settingsContent); - if (updated === settingsContent) { + const config = loadConfig(); + if (!config.enabled) { p.log.info('HUD already disabled'); return; } - await fs.writeFile(settingsPath, updated, 'utf-8'); - p.log.success('HUD disabled — statusLine removed'); + 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 53deef4..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, @@ -25,8 +25,7 @@ import { generateSafeDeleteBlock, isAlreadyInstalled, installToProfile, removeFr import { addAmbientHook, removeAmbientHook, hasAmbientHook } from './ambient.js'; import { addMemoryHooks, removeMemoryHooks, hasMemoryHooks } from './memory.js'; import { addHudStatusLine, removeHudStatusLine, hasHudStatusLine } from './hud.js'; -import { PRESETS, DEFAULT_PRESET, saveConfig as saveHudConfig } from '../hud/config.js'; -import type { PresetName } from '../hud/types.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) @@ -100,7 +99,7 @@ interface InitOptions { teams?: boolean; ambient?: boolean; memory?: boolean; - hud?: string; + hud?: boolean; hudOnly?: boolean; } @@ -115,7 +114,7 @@ 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('--hud ', 'HUD preset (minimal, classic, standard, full, off)') + .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 @@ -168,46 +167,14 @@ export const initCommand = new Command('init') // --hud-only: install only HUD (skip plugins, hooks, extras) if (options.hudOnly) { - // Resolve HUD preset - let hudPreset: PresetName | 'off' = DEFAULT_PRESET; - if (options.hud !== undefined) { - const val = options.hud; - if (val === 'off') { - hudPreset = 'off'; - } else if (val in PRESETS) { - hudPreset = val as PresetName; - } else { - p.log.error(`Unknown HUD preset: ${val}. Valid: minimal, classic, standard, full, off`); - process.exit(1); - } - } else if (process.stdin.isTTY) { - const hudChoice = await p.select({ - message: 'Choose HUD preset', - options: [ - { value: 'standard', label: 'Standard (Recommended)', hint: 'directory, git, model, context, version, session, usage' }, - { value: 'minimal', label: 'Minimal', hint: 'directory, git branch, model, context usage' }, - { value: 'classic', label: 'Classic', hint: 'Like statusline.sh with version badge' }, - { value: 'full', label: 'Full', hint: 'All 14 components' }, - { value: 'off', label: 'No HUD', hint: 'Disable status line entirely' }, - ], - initialValue: 'standard', - }); - if (p.isCancel(hudChoice)) { - p.cancel('Installation cancelled.'); - process.exit(0); - } - hudPreset = hudChoice as PresetName | 'off'; - } - // Resolve paths const paths = await getInstallationPaths(scope); const claudeDir = paths.claudeDir; const devflowDir = paths.devflowDir; // Save HUD config - if (hudPreset !== 'off') { - saveHudConfig({ preset: hudPreset, components: PRESETS[hudPreset] }); - } + const existingHud = loadHudConfig(); + saveHudConfig({ enabled: true, detail: existingHud.detail }); // Update statusLine in settings.json const settingsPath = path.join(claudeDir, 'settings.json'); @@ -218,15 +185,36 @@ export const initCommand = new Command('init') } catch { content = '{}'; } - const updated = hudPreset !== 'off' - ? addHudStatusLine(content, devflowDir) - : removeHudStatusLine(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 { @@ -234,18 +222,14 @@ export const initCommand = new Command('init') version, plugins: [], scope, - features: { teams: false, ambient: false, memory: false, hud: hudPreset === 'off' ? false as const : String(hudPreset) }, + features: { teams: false, ambient: false, memory: false, hud: true }, installedAt: now, updatedAt: now, }); } catch { /* non-fatal */ } - if (hudPreset !== 'off') { - p.log.success(`HUD installed (preset: ${hudPreset})`); - } else { - p.log.info('HUD disabled'); - } - p.log.info(`Configure later: ${color.cyan('devflow hud --configure')}`); + p.log.success('HUD installed'); + p.log.info(`Configure later: ${color.cyan('devflow hud --status')}`); p.outro(color.green('HUD-only install complete.')); return; } @@ -349,35 +333,22 @@ export const initCommand = new Command('init') memoryEnabled = memoryChoice; } - // HUD preset selection - let hudPreset: PresetName | 'off' = DEFAULT_PRESET; + // HUD selection (yes/no) + let hudEnabled: boolean; if (options.hud !== undefined) { - const val = options.hud; - if (val === 'off') { - hudPreset = 'off'; - } else if (val in PRESETS) { - hudPreset = val as PresetName; - } else { - p.log.error(`Unknown HUD preset: ${val}. Valid: minimal, classic, standard, full, off`); - process.exit(1); - } - } else if (process.stdin.isTTY) { - const hudChoice = await p.select({ - message: 'Choose HUD preset', - options: [ - { value: 'standard', label: 'Standard (Recommended)', hint: 'directory, git, model, context, version, session, usage' }, - { value: 'minimal', label: 'Minimal', hint: 'directory, git branch, model, context usage' }, - { value: 'classic', label: 'Classic', hint: 'Like statusline.sh with version badge' }, - { value: 'full', label: 'Full', hint: 'All 14 components' }, - { value: 'off', label: 'No HUD', hint: 'Disable status line entirely' }, - ], - initialValue: 'standard', + 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); } - hudPreset = hudChoice as PresetName | 'off'; + hudEnabled = hudChoice; } // Security deny list placement (user scope + TTY only) @@ -640,24 +611,19 @@ export const initCommand = new Command('init') } // Configure HUD - if (hudPreset !== 'off') { - saveHudConfig({ preset: hudPreset, components: PRESETS[hudPreset] }); - } + const existingHud = loadHudConfig(); + saveHudConfig({ enabled: hudEnabled, detail: existingHud.detail }); - // Update statusLine in settings.json (add or remove based on preset) + // Update statusLine in settings.json (add or remove based on choice) try { const hudContent = await fs.readFile(settingsPath, 'utf-8'); - const hudUpdated = hudPreset !== 'off' + const hudUpdated = hudEnabled ? addHudStatusLine(hudContent, devflowDir) : removeHudStatusLine(hudContent); if (hudUpdated !== hudContent) { await fs.writeFile(settingsPath, hudUpdated, 'utf-8'); if (verbose) { - if (hudPreset !== 'off') { - p.log.success(`HUD enabled (preset: ${hudPreset})`); - } else { - p.log.info('HUD disabled'); - } + p.log.info(`HUD ${hudEnabled ? 'enabled' : 'disabled'}`); } } } catch { /* settings.json may not exist yet */ } @@ -769,7 +735,7 @@ export const initCommand = new Command('init') version, plugins: resolvePluginList(installedPluginNames, existingManifest, !!options.plugin), scope, - features: { teams: teamsEnabled, ambient: ambientEnabled, memory: memoryEnabled, hud: hudPreset === 'off' ? false as const : String(hudPreset) }, + 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 4273558..19c6d76 100644 --- a/src/cli/commands/list.ts +++ b/src/cli/commands/list.ts @@ -16,7 +16,7 @@ export function formatFeatures(features: ManifestData['features']): string { features.teams ? 'teams' : null, features.ambient ? 'ambient' : null, features.memory ? 'memory' : null, - features.hud ? `hud:${features.hud}` : null, + features.hud ? 'hud' : null, ].filter(Boolean).join(', ') || 'none'; } diff --git a/src/cli/hud/colors.ts b/src/cli/hud/colors.ts index 34f357b..d5386e8 100644 --- a/src/cli/hud/colors.ts +++ b/src/cli/hud/colors.ts @@ -36,6 +36,15 @@ export function gray(s: string): string { 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}`; } @@ -46,6 +55,10 @@ 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 { diff --git a/src/cli/hud/components/agent-activity.ts b/src/cli/hud/components/agent-activity.ts deleted file mode 100644 index 6f6559e..0000000 --- a/src/cli/hud/components/agent-activity.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { ComponentResult, GatherContext } from '../types.js'; -import { green, yellow, dim, gray } from '../colors.js'; - -export default async function agentActivity( - ctx: GatherContext, -): Promise { - if (!ctx.transcript) return null; - const { agents } = ctx.transcript; - if (agents.length === 0) return null; - - const parts: string[] = []; - const rawParts: string[] = []; - - for (const a of agents.slice(-4)) { - const icon = a.status === 'completed' ? '\u2713' : '\u25D0'; - const colorFn = a.status === 'completed' ? green : yellow; - const modelTag = a.model ? gray(` [${a.model}]`) : ''; - const modelRaw = a.model ? ` [${a.model}]` : ''; - parts.push(colorFn(`${icon} ${a.name}`) + modelTag); - rawParts.push(`${icon} ${a.name}${modelRaw}`); - } - - return { text: parts.join(dim(' ')), raw: rawParts.join(' ') }; -} diff --git a/src/cli/hud/components/config-counts.ts b/src/cli/hud/components/config-counts.ts index e36b394..0534c49 100644 --- a/src/cli/hud/components/config-counts.ts +++ b/src/cli/hud/components/config-counts.ts @@ -86,12 +86,15 @@ export default async function configCounts( 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 label = parts.join(', '); - return { text: dim(label), raw: label }; + 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 index 29afc2b..479e7de 100644 --- a/src/cli/hud/components/context-usage.ts +++ b/src/cli/hud/components/context-usage.ts @@ -1,22 +1,31 @@ import type { ComponentResult, GatherContext } from '../types.js'; -import { green, yellow, red } from '../colors.js'; +import { dim, green, yellow, red } from '../colors.js'; + +const BAR_WIDTH = 8; /** * Context window usage component. - * Color thresholds ported from statusline.sh: green < 50%, yellow 50-80%, red > 80%. + * 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?.context_window_size || !cw?.current_usage?.input_tokens) return null; + 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); + } - const pct = Math.round( - (cw.current_usage.input_tokens / cw.context_window_size) * 100, - ); - const label = `${pct}%`; + if (pct === null) return null; + + const filled = Math.round((pct / 100) * BAR_WIDTH); + const empty = BAR_WIDTH - filled; - // Color thresholds from statusline.sh let colorFn: (s: string) => string; if (pct < 50) { colorFn = green; @@ -25,5 +34,24 @@ export default async function contextUsage( } else { colorFn = red; } - return { text: colorFn(label), raw: label }; + + 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 index 3c36578..7a5b2b9 100644 --- a/src/cli/hud/components/diff-stats.ts +++ b/src/cli/hud/components/diff-stats.ts @@ -1,5 +1,5 @@ import type { ComponentResult, GatherContext } from '../types.js'; -import { yellow, green, red } from '../colors.js'; +import { dim, green, red } from '../colors.js'; export default async function diffStats( ctx: GatherContext, @@ -7,19 +7,31 @@ export default async function diffStats( if (!ctx.git) return null; const { filesChanged, additions, deletions } = ctx.git; if (filesChanged === 0 && additions === 0 && deletions === 0) return null; - const parts: string[] = []; - const rawParts: string[] = []; - if (filesChanged > 0) { - parts.push(yellow(`${filesChanged}`)); - rawParts.push(`${filesChanged}`); - } - if (additions > 0) { - parts.push(green(`+${additions}`)); - rawParts.push(`+${additions}`); + 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 (deletions > 0) { - parts.push(red(`-${deletions}`)); - rawParts.push(`-${deletions}`); + if (lineParts.length > 0) { + const lineText = lineParts + .map((p) => (p.startsWith('+') ? green(p) : red(p))) + .join(' '); + sections.push(lineText); + rawSections.push(lineParts.join(' ')); } - return { text: parts.join(' '), raw: rawParts.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 index 425788b..87ffeab 100644 --- a/src/cli/hud/components/directory.ts +++ b/src/cli/hud/components/directory.ts @@ -1,5 +1,5 @@ import type { ComponentResult, GatherContext } from '../types.js'; -import { bold, blue } from '../colors.js'; +import { bold, white } from '../colors.js'; import * as path from 'node:path'; export default async function directory( @@ -8,5 +8,5 @@ export default async function directory( const cwd = ctx.stdin.cwd; if (!cwd) return null; const name = path.basename(cwd); - return { text: bold(blue(name)), raw: name }; + 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 index 642fd76..31066d1 100644 --- a/src/cli/hud/components/git-ahead-behind.ts +++ b/src/cli/hud/components/git-ahead-behind.ts @@ -1,5 +1,5 @@ import type { ComponentResult, GatherContext } from '../types.js'; -import { green, red } from '../colors.js'; +import { dim } from '../colors.js'; export default async function gitAheadBehind( ctx: GatherContext, @@ -7,15 +7,9 @@ export default async function gitAheadBehind( if (!ctx.git) return null; const { ahead, behind } = ctx.git; if (ahead === 0 && behind === 0) return null; - const parts: string[] = []; const rawParts: string[] = []; - if (ahead > 0) { - parts.push(green(`${ahead}\u2191`)); - rawParts.push(`${ahead}\u2191`); - } - if (behind > 0) { - parts.push(red(`${behind}\u2193`)); - rawParts.push(`${behind}\u2193`); - } - return { text: parts.join(' '), raw: rawParts.join(' ') }; + 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 index ad6922e..4e90506 100644 --- a/src/cli/hud/components/git-branch.ts +++ b/src/cli/hud/components/git-branch.ts @@ -1,12 +1,17 @@ import type { ComponentResult, GatherContext } from '../types.js'; -import { cyan, yellow } from '../colors.js'; +import { white, yellow, green } from '../colors.js'; export default async function gitBranch( ctx: GatherContext, ): Promise { if (!ctx.git) return null; - const indicator = ctx.git.dirty ? '*' : ''; - const text = cyan(ctx.git.branch) + (indicator ? yellow(indicator) : ''); + 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 index 45ef6d2..7f494e5 100644 --- a/src/cli/hud/components/model.ts +++ b/src/cli/hud/components/model.ts @@ -1,10 +1,25 @@ import type { ComponentResult, GatherContext } from '../types.js'; -import { magenta } from '../colors.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; - return { text: magenta(name), raw: name }; + // 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/speed.ts b/src/cli/hud/components/speed.ts deleted file mode 100644 index 67e1d74..0000000 --- a/src/cli/hud/components/speed.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { ComponentResult, GatherContext } from '../types.js'; -import { dim } from '../colors.js'; - -export default async function speed( - ctx: GatherContext, -): Promise { - if (!ctx.speed?.tokensPerSecond) return null; - const tps = Math.round(ctx.speed.tokensPerSecond); - const label = `${tps} tok/s`; - return { text: dim(label), raw: label }; -} diff --git a/src/cli/hud/components/todo-progress.ts b/src/cli/hud/components/todo-progress.ts index 2930b11..856096c 100644 --- a/src/cli/hud/components/todo-progress.ts +++ b/src/cli/hud/components/todo-progress.ts @@ -1,5 +1,5 @@ import type { ComponentResult, GatherContext } from '../types.js'; -import { cyan } from '../colors.js'; +import { dim } from '../colors.js'; export default async function todoProgress( ctx: GatherContext, @@ -8,5 +8,5 @@ export default async function todoProgress( const { todos } = ctx.transcript; if (todos.total === 0) return null; const label = `${todos.completed}/${todos.total} todos`; - return { text: cyan(label), raw: label }; + return { text: dim(label), raw: label }; } diff --git a/src/cli/hud/components/tool-activity.ts b/src/cli/hud/components/tool-activity.ts deleted file mode 100644 index 2b9c69f..0000000 --- a/src/cli/hud/components/tool-activity.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { ComponentResult, GatherContext } from '../types.js'; -import { green, yellow, dim } from '../colors.js'; - -export default async function toolActivity( - ctx: GatherContext, -): Promise { - if (!ctx.transcript) return null; - const { tools } = ctx.transcript; - if (tools.length === 0) return null; - - const running = tools.filter((t) => t.status === 'running'); - const completed = tools.filter((t) => t.status === 'completed'); - - // Count by name for completed - const counts = new Map(); - for (const t of completed) { - counts.set(t.name, (counts.get(t.name) || 0) + 1); - } - - const parts: string[] = []; - const rawParts: string[] = []; - - // Show completed summary (top 3) - for (const [name, count] of Array.from(counts.entries()).slice(0, 3)) { - const s = `\u2713 ${name} \u00D7${count}`; - parts.push(green(s)); - rawParts.push(s); - } - - // Show running (top 2) - for (const t of running.slice(0, 2)) { - const s = `\u25D0 ${t.name}`; - parts.push(yellow(s)); - rawParts.push(s); - } - - if (parts.length === 0) return null; - return { text: parts.join(dim(' ')), raw: rawParts.join(' ') }; -} diff --git a/src/cli/hud/components/usage-quota.ts b/src/cli/hud/components/usage-quota.ts index e388f79..8c634ed 100644 --- a/src/cli/hud/components/usage-quota.ts +++ b/src/cli/hud/components/usage-quota.ts @@ -1,10 +1,11 @@ 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 blocks = 3; - const filled = Math.round((percent / 100) * blocks); - const empty = blocks - filled; + const filled = Math.round((percent / 100) * BAR_WIDTH); + const empty = BAR_WIDTH - filled; let colorFn: (s: string) => string; if (percent < 50) { @@ -15,10 +16,14 @@ function renderBar(percent: number): { text: string; raw: string } { colorFn = red; } - const filledBar = '\u258B'.repeat(filled); - const emptyBar = '\u258B'.repeat(empty); - const text = colorFn(filledBar) + dim(emptyBar) + ` ${percent}%`; - const raw = '\u258B'.repeat(blocks) + ` ${percent}%`; + 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 }; } @@ -26,9 +31,23 @@ export default async function usageQuota( ctx: GatherContext, ): Promise { if (!ctx.usage) return null; - // Prefer daily, fallback to weekly - const pct = ctx.usage.dailyUsagePercent ?? ctx.usage.weeklyUsagePercent; - if (pct === null) return null; - const bar = renderBar(Math.round(pct)); - return { text: bar.text, raw: bar.raw }; + + 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 index fc57139..b2a14e4 100644 --- a/src/cli/hud/components/version-badge.ts +++ b/src/cli/hud/components/version-badge.ts @@ -2,7 +2,7 @@ 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 { green, cyan } from '../colors.js'; +import { yellow } from '../colors.js'; import { readCache, writeCache } from '../cache.js'; const VERSION_CACHE_KEY = 'version-check'; @@ -91,11 +91,10 @@ export default async function versionBadge( } if (info && compareVersions(info.current, info.latest) < 0) { - const badge = `\u2B06 ${info.latest}`; - return { text: green(badge), raw: badge }; + const badge = `\u2726 Devflow v${info.latest} \u00B7 update: npx devflow-kit init`; + return { text: yellow(badge), raw: badge }; } - // Show current version as dim badge when up to date - const badge = `v${current}`; - return { text: cyan(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 index 09e2587..c1ebc3a 100644 --- a/src/cli/hud/config.ts +++ b/src/cli/hud/config.ts @@ -1,57 +1,27 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { homedir } from 'node:os'; -import type { HudConfig, PresetName, ComponentId } from './types.js'; +import type { HudConfig, ComponentId } from './types.js'; /** - * Preset definitions mapping preset names to their component lists. + * All 14 HUD components in display order. */ -export const PRESETS: Record = { - minimal: ['directory', 'gitBranch', 'model', 'contextUsage'], - classic: [ - 'directory', - 'gitBranch', - 'gitAheadBehind', - 'diffStats', - 'model', - 'contextUsage', - 'versionBadge', - ], - standard: [ - 'directory', - 'gitBranch', - 'gitAheadBehind', - 'diffStats', - 'model', - 'contextUsage', - 'versionBadge', - 'sessionDuration', - 'usageQuota', - ], - full: [ - 'directory', - 'gitBranch', - 'gitAheadBehind', - 'diffStats', - 'model', - 'contextUsage', - 'versionBadge', - 'sessionDuration', - 'usageQuota', - 'toolActivity', - 'agentActivity', - 'todoProgress', - 'speed', - 'configCounts', - ], -}; - -export const DEFAULT_PRESET: PresetName = 'standard'; - -/** - * All valid component IDs for validation. - */ -export const ALL_COMPONENT_IDS: ReadonlySet = new Set(PRESETS.full); +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 = @@ -64,17 +34,12 @@ export function loadConfig(): HudConfig { try { const raw = fs.readFileSync(configPath, 'utf-8'); const parsed = JSON.parse(raw) as Partial; - const preset = - parsed.preset && (parsed.preset in PRESETS || parsed.preset === 'custom') - ? parsed.preset - : DEFAULT_PRESET; - const components = - preset === 'custom' && Array.isArray(parsed.components) - ? parsed.components.filter((c): c is ComponentId => ALL_COMPONENT_IDS.has(c)) - : PRESETS[preset as PresetName] ?? PRESETS[DEFAULT_PRESET]; - return { preset, components }; + return { + enabled: parsed.enabled !== false, + detail: parsed.detail === true, + }; } catch { - return { preset: DEFAULT_PRESET, components: PRESETS[DEFAULT_PRESET] }; + return { enabled: true, detail: false }; } } @@ -88,8 +53,7 @@ export function saveConfig(config: HudConfig): void { } export function resolveComponents(config: HudConfig): ComponentId[] { - if (config.preset === 'custom') { - return config.components; - } - return PRESETS[config.preset as PresetName] ?? PRESETS[DEFAULT_PRESET]; + 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 index 5992cc7..655f14d 100644 --- a/src/cli/hud/git.ts +++ b/src/cli/hud/git.ts @@ -33,7 +33,17 @@ export async function gatherGitStatus(cwd: string): Promise { ['status', '--porcelain', '--no-optional-locks'], cwd, ); - const dirty = statusOutput.length > 0; + 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); @@ -65,7 +75,36 @@ export async function gatherGitStatus(cwd: string): Promise { deletions = delMatch ? parseInt(delMatch[1], 10) : 0; } - return { branch, dirty, ahead, behind, filesChanged, additions, deletions }; + // 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, + }; } /** diff --git a/src/cli/hud/index.ts b/src/cli/hud/index.ts index 1a57df7..8ff3c03 100644 --- a/src/cli/hud/index.ts +++ b/src/cli/hud/index.ts @@ -1,3 +1,4 @@ +import * as fs from 'node:fs'; import * as path from 'node:path'; import { homedir } from 'node:os'; import { readStdin } from './stdin.js'; @@ -26,8 +27,15 @@ async function main(): Promise { 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 components = new Set(resolveComponents(config)); + const resolved = resolveComponents(config); + const components = new Set(resolved); const cwd = stdin.cwd || process.cwd(); const devflowDir = process.env.DEVFLOW_DIR || @@ -37,12 +45,12 @@ async function run(): Promise { const needsGit = components.has('gitBranch') || components.has('gitAheadBehind') || - components.has('diffStats'); + components.has('diffStats') || + components.has('releaseInfo') || + components.has('worktreeCount'); const needsTranscript = - components.has('toolActivity') || - components.has('agentActivity') || components.has('todoProgress') || - components.has('speed'); + components.has('configCounts'); const needsUsage = components.has('usageQuota'); const needsConfigCounts = components.has('configCounts'); @@ -55,10 +63,14 @@ async function run(): Promise { needsUsage ? fetchUsageData() : Promise.resolve(null), ]); - // Session start time from session_id presence (approximate via process uptime) - const sessionStartTime = stdin.session_id - ? Date.now() - process.uptime() * 1000 - : 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 @@ -73,9 +85,8 @@ async function run(): Promise { git, transcript, usage, - speed: null, // Speed requires cross-render state tracking (future enhancement) configCounts: configCountsData, - config: { ...config, components: resolveComponents(config) }, + config: { ...config, components: resolved } as GatherContext['config'], devflowDir, sessionStartTime, terminalWidth, diff --git a/src/cli/hud/render.ts b/src/cli/hud/render.ts index 5108e15..d337009 100644 --- a/src/cli/hud/render.ts +++ b/src/cli/hud/render.ts @@ -6,7 +6,6 @@ import type { } from './types.js'; import { dim } from './colors.js'; -// Import all components import directory from './components/directory.js'; import gitBranch from './components/git-branch.js'; import gitAheadBehind from './components/git-ahead-behind.js'; @@ -16,11 +15,11 @@ 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 toolActivity from './components/tool-activity.js'; -import agentActivity from './components/agent-activity.js'; import todoProgress from './components/todo-progress.js'; -import speed from './components/speed.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, @@ -32,37 +31,31 @@ const COMPONENT_MAP: Record = { versionBadge, sessionDuration, usageQuota, - toolActivity, - agentActivity, todoProgress, - speed, 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[][] = [ - // Line 1: core info - [ - 'directory', - 'gitBranch', - 'gitAheadBehind', - 'diffStats', - 'model', - 'contextUsage', - 'versionBadge', - ], - // Line 2: session + quota - ['sessionDuration', 'usageQuota', 'speed'], - // Line 3: tool activity - ['toolActivity'], - // Line 4: agents + todos + config - ['agentActivity', 'todoProgress', 'configCounts'], +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(' '); +const SEPARATOR = dim(' \u00B7 '); /** * Render all enabled components into a multi-line HUD string. @@ -91,16 +84,42 @@ export async function render(ctx: GatherContext): Promise { await Promise.all(promises); - // Assemble lines using smart layout + // Assemble lines using smart layout with section breaks const lines: string[] = []; + let pendingBreak = false; - for (const lineGroup of LINE_GROUPS) { - const lineResults = lineGroup + 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) { - lines.push(lineResults.map((r) => r.text).join(SEPARATOR)); + 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)); + } } } diff --git a/src/cli/hud/transcript.ts b/src/cli/hud/transcript.ts index b678aec..bb3aa1c 100644 --- a/src/cli/hud/transcript.ts +++ b/src/cli/hud/transcript.ts @@ -1,4 +1,5 @@ import * as fs from 'node:fs'; +import * as path from 'node:path'; import * as readline from 'node:readline'; import type { TranscriptData } from './types.js'; @@ -17,12 +18,13 @@ export async function parseTranscript( const tools = new Map< string, - { name: string; status: 'running' | 'completed' } + { name: string; status: 'running' | 'completed'; target?: string; description?: string } >(); const agents = new Map< string, - { name: string; model?: string; status: 'running' | 'completed' } + { name: string; model?: string; status: 'running' | 'completed'; description?: string } >(); + const skills = new Set(); let todosCompleted = 0; let todosTotal = 0; @@ -36,7 +38,16 @@ export async function parseTranscript( if (!line.trim()) continue; try { const entry = JSON.parse(line) as Record; - const todoResult = processEntry(entry, tools, agents); + + // 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; @@ -50,6 +61,7 @@ export async function parseTranscript( tools: Array.from(tools.values()), agents: Array.from(agents.values()), todos: { completed: todosCompleted, total: todosTotal }, + skills: Array.from(skills), }; } catch { return null; @@ -63,11 +75,12 @@ interface TodoResult { function processEntry( entry: Record, - tools: Map, + tools: Map, agents: Map< string, - { name: string; model?: string; status: 'running' | 'completed' } + { 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 { @@ -88,11 +101,19 @@ function processEntry( // 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; @@ -105,7 +126,20 @@ function processEntry( todoResult = { completed, total }; } } else { - tools.set(id, { name, status: 'running' }); + // 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; diff --git a/src/cli/hud/types.ts b/src/cli/hud/types.ts index 4fd43d4..1bddf52 100644 --- a/src/cli/hud/types.ts +++ b/src/cli/hud/types.ts @@ -6,8 +6,10 @@ export interface StdinData { cwd?: string; context_window?: { context_window_size?: number; - current_usage?: { input_tokens?: number }; + current_usage?: { input_tokens?: number; output_tokens?: number }; + used_percentage?: number; }; + cost?: { total_cost_usd?: number }; session_id?: string; transcript_path?: string; } @@ -25,23 +27,18 @@ export type ComponentId = | 'versionBadge' | 'sessionDuration' | 'usageQuota' - | 'toolActivity' - | 'agentActivity' | 'todoProgress' - | 'speed' - | 'configCounts'; - -/** - * Preset names. - */ -export type PresetName = 'minimal' | 'classic' | 'standard' | 'full'; + | 'configCounts' + | 'sessionCost' + | 'releaseInfo' + | 'worktreeCount'; /** * HUD config persisted to ~/.devflow/hud.json. */ export interface HudConfig { - preset: PresetName | 'custom'; - components: ComponentId[]; + enabled: boolean; + detail: boolean; } /** @@ -63,35 +60,33 @@ export type ComponentFn = (ctx: GatherContext) => Promise; - agents: Array<{ name: string; model?: string; status: 'running' | 'completed' }>; + 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 { - dailyUsagePercent: number | null; - weeklyUsagePercent: number | null; -} - -/** - * Speed tracking data. - */ -export interface SpeedData { - tokensPerSecond: number | null; + fiveHourPercent: number | null; + sevenDayPercent: number | null; } /** @@ -112,9 +107,8 @@ export interface GatherContext { git: GitStatus | null; transcript: TranscriptData | null; usage: UsageData | null; - speed: SpeedData | null; configCounts: ConfigCountsData | null; - config: HudConfig; + 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 index a1eacf3..fbfa88a 100644 --- a/src/cli/hud/usage-api.ts +++ b/src/cli/hud/usage-api.ts @@ -1,7 +1,6 @@ import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { homedir } from 'node:os'; import { readCache, writeCache, readCacheStale } from './cache.js'; +import { getCredentials } from './credentials.js'; import type { UsageData } from './types.js'; const USAGE_CACHE_KEY = 'usage'; @@ -15,22 +14,12 @@ interface BackoffState { delay: number; } -function getCredentialsPath(): string { - const claudeDir = - process.env.CLAUDE_CONFIG_DIR || - path.join(process.env.HOME || homedir(), '.claude'); - return path.join(claudeDir, '.credentials.json'); -} +const DEBUG = !!process.env.DEVFLOW_HUD_DEBUG; -function getOAuthToken(): string | null { - try { - const raw = fs.readFileSync(getCredentialsPath(), 'utf-8'); - const creds = JSON.parse(raw) as Record; - const oauth = creds.claudeAiOauth as Record | undefined; - return (oauth?.accessToken as string) || null; - } catch { - return null; - } +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'); } /** @@ -41,6 +30,7 @@ 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); } @@ -48,17 +38,24 @@ export async function fetchUsageData(): Promise { const cached = readCache(USAGE_CACHE_KEY); if (cached) return cached; - const token = getOAuthToken(); - if (!token) return null; + 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, }); @@ -76,29 +73,37 @@ export async function fetchUsageData(): Promise { { 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 = { - dailyUsagePercent: - typeof body.daily_usage_percent === 'number' - ? body.daily_usage_percent + fiveHourPercent: + typeof fiveHour?.utilization === 'number' + ? Math.round(Math.max(0, Math.min(100, fiveHour.utilization))) : null, - weeklyUsagePercent: - typeof body.weekly_usage_percent === 'number' - ? body.weekly_usage_percent + 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 { + } 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 3124d60..71b3068 100644 --- a/src/cli/utils/manifest.ts +++ b/src/cli/utils/manifest.ts @@ -12,7 +12,7 @@ export interface ManifestData { teams: boolean; ambient: boolean; memory: boolean; - hud?: string | false; + hud?: boolean; }; installedAt: string; updatedAt: string; 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 index 20062d6..becfbb4 100644 --- a/tests/hud-components.test.ts +++ b/tests/hud-components.test.ts @@ -10,10 +10,11 @@ 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 toolActivity from '../src/cli/hud/components/tool-activity.js'; -import agentActivity from '../src/cli/hud/components/agent-activity.js'; import todoProgress from '../src/cli/hud/components/todo-progress.js'; -import speed from '../src/cli/hud/components/speed.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 { @@ -22,9 +23,8 @@ function makeCtx(overrides: Partial = {}): GatherContext { git: null, transcript: null, usage: null, - speed: null, configCounts: null, - config: { preset: 'standard', components: [] }, + config: { enabled: true, detail: false, components: [] }, devflowDir: '/test/.devflow', sessionStartTime: null, terminalWidth: 120, @@ -36,11 +36,25 @@ 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, }; } @@ -68,7 +82,7 @@ describe('gitBranch component', () => { expect(result!.raw).toBe('feat/my-feature'); }); - it('shows dirty indicator', async () => { + it('shows dirty indicator for unstaged changes', async () => { const ctx = makeCtx({ git: makeGit({ branch: 'main', dirty: true }), }); @@ -76,6 +90,24 @@ describe('gitBranch component', () => { 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); @@ -124,9 +156,11 @@ describe('diffStats component', () => { }); const result = await diffStats(ctx); expect(result).not.toBeNull(); - expect(result!.raw).toContain('5'); + 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 () => { @@ -143,13 +177,56 @@ describe('diffStats component', () => { }); describe('model component', () => { - it('returns model display name', async () => { + 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('Claude Sonnet 4'); + 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 () => { @@ -160,7 +237,7 @@ describe('model component', () => { }); describe('contextUsage component', () => { - it('shows percentage with green for low usage', async () => { + it('shows green for low usage (<50%)', async () => { const ctx = makeCtx({ stdin: { context_window: { @@ -171,27 +248,30 @@ describe('contextUsage component', () => { }); const result = await contextUsage(ctx); expect(result).not.toBeNull(); - expect(result!.raw).toBe('25%'); - // Green color for < 50% + 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 medium usage', async () => { + it('shows yellow for 50-79% usage', async () => { const ctx = makeCtx({ stdin: { context_window: { context_window_size: 200000, - current_usage: { input_tokens: 120000 }, + current_usage: { input_tokens: 110000 }, }, }, }); const result = await contextUsage(ctx); - expect(result!.raw).toBe('60%'); - // Yellow for 50-80% + expect(result!.raw).toContain('55%'); + // Yellow for 50-79% expect(result!.text).toContain('\x1b[33m'); }); - it('shows red for high usage', async () => { + it('shows red for 80%+ with token breakdown', async () => { const ctx = makeCtx({ stdin: { context_window: { @@ -201,11 +281,41 @@ describe('contextUsage component', () => { }, }); const result = await contextUsage(ctx); - expect(result!.raw).toBe('90%'); - // Red for > 80% + 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); @@ -237,18 +347,36 @@ describe('sessionDuration component', () => { }); describe('usageQuota component', () => { - it('shows bar with percentage', async () => { - const ctx = makeCtx({ usage: { dailyUsagePercent: 45, weeklyUsagePercent: null } }); + 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('falls back to weekly when daily is null', async () => { - const ctx = makeCtx({ usage: { dailyUsagePercent: null, weeklyUsagePercent: 70 } }); + 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 () => { @@ -258,129 +386,147 @@ describe('usageQuota component', () => { }); it('returns null when both percentages are null', async () => { - const ctx = makeCtx({ usage: { dailyUsagePercent: null, weeklyUsagePercent: null } }); + const ctx = makeCtx({ usage: { fiveHourPercent: null, sevenDayPercent: null } }); const result = await usageQuota(ctx); expect(result).toBeNull(); }); }); -describe('toolActivity component', () => { - it('shows completed tools', async () => { - const transcript: TranscriptData = { - tools: [ - { name: 'Read', status: 'completed' }, - { name: 'Read', status: 'completed' }, - { name: 'Bash', status: 'completed' }, - ], - agents: [], - todos: { completed: 0, total: 0 }, - }; +describe('todoProgress component', () => { + it('shows progress in dim', async () => { + const transcript = makeTranscript({ + todos: { completed: 3, total: 5 }, + }); const ctx = makeCtx({ transcript }); - const result = await toolActivity(ctx); + const result = await todoProgress(ctx); expect(result).not.toBeNull(); - expect(result!.raw).toContain('Read'); - expect(result!.raw).toContain('Bash'); + expect(result!.raw).toBe('3/5 todos'); + // Dim styling + expect(result!.text).toContain('\x1b[2m'); }); - it('shows running tools', async () => { - const transcript: TranscriptData = { - tools: [{ name: 'Bash', status: 'running' }], - agents: [], - todos: { completed: 0, total: 0 }, - }; + it('returns null when no todos', async () => { + const transcript = makeTranscript(); const ctx = makeCtx({ transcript }); - const result = await toolActivity(ctx); + 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).toContain('\u25D0 Bash'); + expect(result!.raw).toBe('$0.42'); + // Dim styling + expect(result!.text).toContain('\x1b[2m'); }); - it('returns null when no tools', async () => { - const transcript: TranscriptData = { - tools: [], - agents: [], - todos: { completed: 0, total: 0 }, - }; - const ctx = makeCtx({ transcript }); - const result = await toolActivity(ctx); - expect(result).toBeNull(); + 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 transcript', async () => { + it('returns null when no cost data', async () => { const ctx = makeCtx(); - const result = await toolActivity(ctx); + 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('agentActivity component', () => { - it('shows agent status', async () => { - const transcript: TranscriptData = { - tools: [], - agents: [ - { name: 'Reviewer', status: 'completed' }, - { name: 'Coder', model: 'haiku', status: 'running' }, - ], - todos: { completed: 0, total: 0 }, - }; - const ctx = makeCtx({ transcript }); - const result = await agentActivity(ctx); +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).toContain('\u2713 Reviewer'); - expect(result!.raw).toContain('\u25D0 Coder'); - expect(result!.raw).toContain('[haiku]'); + expect(result!.raw).toBe('v1.7.0 +5'); }); - it('returns null when no agents', async () => { - const transcript: TranscriptData = { - tools: [], - agents: [], - todos: { completed: 0, total: 0 }, - }; - const ctx = makeCtx({ transcript }); - const result = await agentActivity(ctx); + 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('todoProgress component', () => { - it('shows progress', async () => { - const transcript: TranscriptData = { - tools: [], - agents: [], - todos: { completed: 3, total: 5 }, - }; - const ctx = makeCtx({ transcript }); - const result = await todoProgress(ctx); +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/5 todos'); + expect(result!.raw).toBe('3 worktrees'); }); - it('returns null when no todos', async () => { - const transcript: TranscriptData = { - tools: [], - agents: [], - todos: { completed: 0, total: 0 }, - }; - const ctx = makeCtx({ transcript }); - const result = await todoProgress(ctx); + 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('speed component', () => { - it('shows tokens per second', async () => { +describe('configCounts component', () => { + it('includes skills count from transcript', async () => { + const transcript = makeTranscript({ + skills: ['core-patterns', 'test-patterns', 'implementation-patterns'], + }); const ctx = makeCtx({ - speed: { tokensPerSecond: 42 }, + transcript, + configCounts: { claudeMdFiles: 1, rules: 0, mcpServers: 2, hooks: 3 }, }); - const result = await speed(ctx); + const result = await configCounts(ctx); expect(result).not.toBeNull(); - expect(result!.raw).toBe('42 tok/s'); + 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('returns null when no speed data', async () => { - const ctx = makeCtx(); - const result = await speed(ctx); - expect(result).toBeNull(); + 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'); }); }); diff --git a/tests/hud-render.test.ts b/tests/hud-render.test.ts index ceee19d..c658a33 100644 --- a/tests/hud-render.test.ts +++ b/tests/hud-render.test.ts @@ -1,17 +1,17 @@ import { describe, it, expect } from 'vitest'; import { render } from '../src/cli/hud/render.js'; import { - PRESETS, + HUD_COMPONENTS, loadConfig, resolveComponents, } from '../src/cli/hud/config.js'; import { stripAnsi } from '../src/cli/hud/colors.js'; -import type { GatherContext, HudConfig } from '../src/cli/hud/types.js'; +import type { GatherContext, HudConfig, ComponentId } from '../src/cli/hud/types.js'; function makeCtx( - config: HudConfig, - overrides: Partial = {}, + overrides: Partial & { config?: Partial & { components?: ComponentId[] } } = {}, ): GatherContext { + const { config: configOverride, ...rest } = overrides; return { stdin: { cwd: '/home/user/project', @@ -24,97 +24,78 @@ function makeCtx( 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, - speed: null, configCounts: null, - config, + config: { + enabled: true, + detail: false, + components: [...HUD_COMPONENTS], + ...configOverride, + }, devflowDir: '/test/.devflow', sessionStartTime: null, terminalWidth: 120, - ...overrides, + ...rest, }; } describe('render', () => { - it('minimal preset produces single line', async () => { - const config: HudConfig = { - preset: 'minimal', - components: PRESETS.minimal, - }; - const ctx = makeCtx(config); + 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); - expect(lines).toHaveLength(1); + // 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('Claude Opus 4'); + expect(raw).toContain('Opus 4 [200k]'); expect(raw).toContain('20%'); }); - it('classic preset produces single line', async () => { - const config: HudConfig = { - preset: 'classic', - components: PRESETS.classic, - }; - const ctx = makeCtx(config); + it('uses dot separator between components', async () => { + const ctx = makeCtx(); const output = await render(ctx); - const lines = output.split('\n').filter((l) => l.length > 0); - - // All classic components are in LINE_1 - expect(lines).toHaveLength(1); const raw = stripAnsi(output); - expect(raw).toContain('project'); - expect(raw).toContain('feat/test'); - expect(raw).toContain('2\u2191'); // ahead arrows - expect(raw).toContain('+50'); - expect(raw).toContain('-10'); - expect(raw).toContain('Claude Opus 4'); - expect(raw).toContain('20%'); + expect(raw).toContain('\u00B7'); }); - it('standard preset produces 2 lines with session data', async () => { - const config: HudConfig = { - preset: 'standard', - components: PRESETS.standard, - }; - const ctx = makeCtx(config, { + it('shows session data when available', async () => { + const ctx = makeCtx({ sessionStartTime: Date.now() - 15 * 60 * 1000, - usage: { dailyUsagePercent: 30, weeklyUsagePercent: null }, + usage: { fiveHourPercent: 30, sevenDayPercent: null }, }); const output = await render(ctx); const lines = output.split('\n').filter((l) => l.length > 0); - // Line 1: core info, Line 2: session + usage - expect(lines).toHaveLength(2); + expect(lines).toHaveLength(3); const raw = stripAnsi(output); expect(raw).toContain('15m'); + expect(raw).toContain('Session'); expect(raw).toContain('30%'); }); - it('full preset produces 2+ lines with transcript data', async () => { - const config: HudConfig = { - preset: 'full', - components: PRESETS.full, - }; - const ctx = makeCtx(config, { + it('shows activity section with todos and config counts', async () => { + const ctx = makeCtx({ sessionStartTime: Date.now() - 5 * 60 * 1000, - usage: { dailyUsagePercent: 20, weeklyUsagePercent: null }, + usage: { fiveHourPercent: 20, sevenDayPercent: null }, transcript: { - tools: [ - { name: 'Read', status: 'completed' }, - { name: 'Bash', status: 'running' }, - ], - agents: [{ name: 'Reviewer', status: 'completed' }], + tools: [], + agents: [], todos: { completed: 2, total: 4 }, + skills: [], }, configCounts: { claudeMdFiles: 2, @@ -126,25 +107,19 @@ describe('render', () => { const output = await render(ctx); const lines = output.split('\n').filter((l) => l.length > 0); - // Should have multiple lines - expect(lines.length).toBeGreaterThanOrEqual(3); + // 3 info lines + blank + todo line = 4+ + expect(lines.length).toBeGreaterThanOrEqual(4); const raw = stripAnsi(output); - expect(raw).toContain('Read'); - expect(raw).toContain('Reviewer'); 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 config: HudConfig = { - preset: 'minimal', - components: PRESETS.minimal, - }; - // No cwd, no model, no context — all components return null - const ctx = makeCtx(config, { + const ctx = makeCtx({ stdin: {}, git: null, }); @@ -153,20 +128,52 @@ describe('render', () => { expect(output).toBe(''); }); - it('handles custom preset with subset of components', async () => { - const config: HudConfig = { - preset: 'custom', - components: ['directory', 'model'], - }; - const ctx = makeCtx(config); + 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('Claude Opus 4'); + 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', () => { @@ -176,8 +183,8 @@ describe('config', () => { process.env.DEVFLOW_DIR = '/tmp/nonexistent-devflow-test-dir'; try { const config = loadConfig(); - expect(config.preset).toBe('standard'); - expect(config.components).toEqual(PRESETS.standard); + expect(config.enabled).toBe(true); + expect(config.detail).toBe(false); } finally { if (originalEnv !== undefined) { process.env.DEVFLOW_DIR = originalEnv; @@ -187,32 +194,17 @@ describe('config', () => { } }); - it('resolveComponents returns preset components', () => { - const config: HudConfig = { preset: 'minimal', components: [] }; - expect(resolveComponents(config)).toEqual(PRESETS.minimal); - }); - - it('resolveComponents returns custom components', () => { - const config: HudConfig = { - preset: 'custom', - components: ['directory', 'model'], - }; - expect(resolveComponents(config)).toEqual(['directory', 'model']); - }); - - it('PRESETS.minimal has 4 components', () => { - expect(PRESETS.minimal).toHaveLength(4); - }); - - it('PRESETS.classic has 7 components', () => { - expect(PRESETS.classic).toHaveLength(7); + it('resolveComponents returns all components when enabled', () => { + const config: HudConfig = { enabled: true, detail: false }; + expect(resolveComponents(config)).toEqual([...HUD_COMPONENTS]); }); - it('PRESETS.standard has 9 components', () => { - expect(PRESETS.standard).toHaveLength(9); + it('resolveComponents returns only versionBadge when disabled', () => { + const config: HudConfig = { enabled: false, detail: false }; + expect(resolveComponents(config)).toEqual(['versionBadge']); }); - it('PRESETS.full has 14 components', () => { - expect(PRESETS.full).toHaveLength(14); + it('HUD_COMPONENTS has 14 components', () => { + expect(HUD_COMPONENTS).toHaveLength(14); }); });