diff --git a/README.md b/README.md index ff6b4b6..65e8a01 100644 --- a/README.md +++ b/README.md @@ -356,6 +356,7 @@ Once configured, ccstatusline automatically formats your Claude Code status line - **Session Clock** - Shows elapsed time since session start (e.g., "2hr 15m") - **Session Cost** - Shows total session cost in USD (e.g., "$1.23") - **Block Timer** - Shows time elapsed in current 5-hour block or progress bar +- **Task Timer** - Shows real-time task execution time (requires hook installation, e.g., "执行中:1分23秒" or "执行完成:2分45秒") - **Current Working Directory** - Shows current working directory with configurable path segments - **Version** - Shows Claude Code version - **Output Style** - Shows the currently set output style in Claude Code @@ -432,12 +433,67 @@ The Block Timer widget helps you track your progress through Claude Code's 5-hou - Progress bars show completion percentage (e.g., "[████████████████████████░░░░░░░░] 73.9%") - Toggle between modes with the **(p)** key in the widgets editor +### ⏱️ Task Timer Widget + +The Task Timer widget provides real-time tracking of how long Claude has been working on the current task: + +**Installation Required:** + +Before using the Task Timer widget, you need to install the required hooks: + +1. Run ccstatusline: `npx ccstatusline@latest` or `bunx ccstatusline@latest` +2. From the main menu, select **"⏱️ Task Timer Setup"** +3. Press **(i)** to install hooks + - This copies `timing_hook.sh` to `~/.claude/hooks/` + - Updates your `~/.claude/settings.json` with hook configurations + - Adds hooks for: `UserPromptSubmit`, `Stop`, and `SessionEnd` + +**Display Modes:** +- **Executing** - Shows "执行中:1分23秒" while Claude is working +- **Completed** - Shows "执行完成:2分45秒" after task finishes +- **Raw Value** - Shows just the time (e.g., "1分23秒") without prefix + +**Features:** +- **Real-time Updates** - Timer updates during task execution (refresh rate depends on Claude Code) +- **Multi-session Support** - Each Claude Code instance has an independent timer +- **Automatic Cleanup** - State is cleaned up when sessions end +- **Smart Formatting** - Time automatically scales: + - Less than 60s: "42秒" + - 60s to 1hr: "5分30秒" + - Over 1hr: "2时15分30秒" +- **Persistent State** - Timer state survives terminal restarts +- **Cross-platform** - Works on Linux, macOS, and Windows (requires bash) + +**How It Works:** + +The Task Timer uses Claude Code hooks to track task execution: + +1. **UserPromptSubmit Hook** - Records start time when you submit a prompt +2. **Stop Hook** - Calculates duration when Claude finishes the task +3. **SessionEnd Hook** - Cleans up timer state when session ends +4. **Display Mode** - ccstatusline queries the hook script to get current status + +**Uninstalling:** + +To remove the Task Timer hooks: +1. Run ccstatusline and go to **"⏱️ Task Timer Setup"** +2. Press **(u)** to uninstall + - Removes `timing_hook.sh` from `~/.claude/hooks/` + - Cleans up hook configurations from `settings.json` + +**Troubleshooting:** + +- **Timer not showing:** Ensure hooks are installed via Task Timer Setup +- **Permission errors:** Run `chmod +x ~/.claude/hooks/timing_hook.sh` on Unix systems +- **Incorrect times:** Clear state with `rm -rf ~/.claude/.timing/*` + ### 🔤 Raw Value Mode Some widgets support "raw value" mode which displays just the value without a label: - Normal: `Model: Claude 3.5 Sonnet` → Raw: `Claude 3.5 Sonnet` - Normal: `Session: 2hr 15m` → Raw: `2hr 15m` - Normal: `Block: 3hr 45m` → Raw: `3hr 45m` +- Normal: `执行中:1分23秒` → Raw: `1分23秒` - Normal: `Ctx: 18.6k` → Raw: `18.6k` --- diff --git a/package.json b/package.json index 99408ff..033f659 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "ccstatusline": "dist/ccstatusline.js" }, "files": [ - "dist/" + "dist/", + "templates/" ], "scripts": { "start": "bun run src/ccstatusline.ts", diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 7cbef15..ab3c68d 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -46,6 +46,7 @@ import { MainMenu, PowerlineSetup, StatusLinePreview, + TaskTimerSetup, TerminalOptionsMenu, TerminalWidthMenu } from './components'; @@ -55,7 +56,7 @@ export const App: React.FC = () => { const [settings, setSettings] = useState(null); const [originalSettings, setOriginalSettings] = useState(null); const [hasChanges, setHasChanges] = useState(false); - const [screen, setScreen] = useState<'main' | 'lines' | 'items' | 'colorLines' | 'colors' | 'terminalWidth' | 'terminalConfig' | 'globalOverrides' | 'confirm' | 'powerline' | 'install'>('main'); + const [screen, setScreen] = useState<'main' | 'lines' | 'items' | 'colorLines' | 'colors' | 'terminalWidth' | 'terminalConfig' | 'globalOverrides' | 'confirm' | 'powerline' | 'taskTimer' | 'install'>('main'); const [selectedLine, setSelectedLine] = useState(0); const [menuSelections, setMenuSelections] = useState>({}); const [confirmDialog, setConfirmDialog] = useState<{ message: string; action: () => Promise } | null>(null); @@ -208,6 +209,9 @@ export const App: React.FC = () => { case 'powerline': setScreen('powerline'); break; + case 'taskTimer': + setScreen('taskTimer'); + break; case 'install': handleInstallUninstall(); break; @@ -273,9 +277,10 @@ export const App: React.FC = () => { lines: 0, colors: 1, powerline: 2, - terminalConfig: 3, - globalOverrides: 4, - install: 5 + taskTimer: 3, + terminalConfig: 4, + globalOverrides: 5, + install: 6 }; setMenuSelections({ ...menuSelections, main: menuMap[value] ?? 0 }); } @@ -451,6 +456,13 @@ export const App: React.FC = () => { onClearMessage={() => { setFontInstallMessage(null); }} /> )} + {screen === 'taskTimer' && ( + { + setScreen('main'); + }} + /> + )} ); diff --git a/src/tui/components/MainMenu.tsx b/src/tui/components/MainMenu.tsx index 78a8b5b..2d1de2c 100644 --- a/src/tui/components/MainMenu.tsx +++ b/src/tui/components/MainMenu.tsx @@ -26,6 +26,7 @@ export const MainMenu: React.FC = ({ onSelect, isClaudeInstalled, { label: '📝 Edit Lines', value: 'lines', selectable: true }, { label: '🎨 Edit Colors', value: 'colors', selectable: true }, { label: '⚡ Powerline Setup', value: 'powerline', selectable: true }, + { label: '⏱️ Task Timer Setup', value: 'taskTimer', selectable: true }, { label: '', value: '_gap1', selectable: false }, // Visual gap { label: '💻 Terminal Options', value: 'terminalConfig', selectable: true }, { label: '🌐 Global Overrides', value: 'globalOverrides', selectable: true }, @@ -64,6 +65,7 @@ export const MainMenu: React.FC = ({ onSelect, isClaudeInstalled, lines: 'Configure any number of status lines with various widgets like model info, git status, and token usage', colors: 'Customize colors for each widget including foreground, background, and bold styling', powerline: 'Install Powerline fonts for enhanced visual separators and symbols in your status line', + taskTimer: 'Install hooks to track and display task execution time in your status line', globalOverrides: 'Set global padding, separators, and color overrides that apply to all widgets', install: isClaudeInstalled ? 'Remove ccstatusline from your Claude Code settings' diff --git a/src/tui/components/TaskTimerSetup.tsx b/src/tui/components/TaskTimerSetup.tsx new file mode 100644 index 0000000..36a59fd --- /dev/null +++ b/src/tui/components/TaskTimerSetup.tsx @@ -0,0 +1,252 @@ +import { + Box, + Text, + useInput +} from 'ink'; +import React, { + useEffect, + useState +} from 'react'; + +import { + getTimingHookPath, + installTaskTimer, + isTaskTimerInstalled, + uninstallTaskTimer +} from '../../utils/claude-settings'; + +import { ConfirmDialog } from './ConfirmDialog'; + +export interface TaskTimerSetupProps { onBack: () => void } + +export const TaskTimerSetup: React.FC = ({ onBack }) => { + const [isInstalled, setIsInstalled] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [confirmingInstall, setConfirmingInstall] = useState(false); + const [confirmingUninstall, setConfirmingUninstall] = useState(false); + const [installing, setInstalling] = useState(false); + const [installMessage, setInstallMessage] = useState(null); + + // Check installation status on mount + useEffect(() => { + const checkStatus = async () => { + setIsLoading(true); + const installed = await isTaskTimerInstalled(); + setIsInstalled(installed); + setIsLoading(false); + }; + void checkStatus(); + }, []); + + useInput((input, key) => { + // Block input when showing install message + if (installMessage) { + // Clear message on any key press + if (!key.escape) { + setInstallMessage(null); + } + return; + } + + // Skip input handling when confirmations are active + if (confirmingInstall || confirmingUninstall || installing) { + return; + } + + if (key.escape) { + onBack(); + } else if (input === 'i' || input === 'I') { + if (!isInstalled) { + setConfirmingInstall(true); + } + } else if (input === 'u' || input === 'U') { + if (isInstalled) { + setConfirmingUninstall(true); + } + } + }); + + const handleInstall = async () => { + setConfirmingInstall(false); + setInstalling(true); + try { + await installTaskTimer(); + setIsInstalled(true); + setInstallMessage('Task Timer hooks installed successfully! The widget will now work in ccstatusline.'); + } catch (error) { + setInstallMessage( + `Failed to install Task Timer hooks: ${error instanceof Error ? error.message : String(error)}` + ); + } finally { + setInstalling(false); + } + }; + + const handleUninstall = async () => { + setConfirmingUninstall(false); + setInstalling(true); + try { + await uninstallTaskTimer(); + setIsInstalled(false); + setInstallMessage('Task Timer hooks uninstalled successfully.'); + } catch (error) { + setInstallMessage( + `Failed to uninstall Task Timer hooks: ${error instanceof Error ? error.message : String(error)}` + ); + } finally { + setInstalling(false); + } + }; + + if (isLoading) { + return ( + + Checking Task Timer installation status... + + ); + } + + return ( + + {!confirmingInstall && !confirmingUninstall && !installing && !installMessage && ( + Task Timer Setup + )} + + {confirmingInstall ? ( + + + Task Timer Installation + + + + What will happen: + + • Copy timing_hook.sh to + {getTimingHookPath()} + + • Update Claude Code settings.json with hooks configuration + • Add UserPromptSubmit, Stop, and SessionEnd hooks + + + + Features: + • Real-time task execution timer in status line + • Shows elapsed time while Claude is working + • Displays total duration when task completes + • Multi-session support (each Claude instance has independent timer) + + + + Requirements: + Bash shell, Write permissions to ~/.claude/ + + + + Proceed with installation? + + + { void handleInstall(); }} + onCancel={() => { + setConfirmingInstall(false); + }} + /> + + + ) : confirmingUninstall ? ( + + + Uninstall Task Timer + + + + This will remove the timing_hook.sh script and clean up hooks configuration. + + + + Are you sure you want to uninstall? + + + { void handleUninstall(); }} + onCancel={() => { + setConfirmingUninstall(false); + }} + /> + + + ) : installing ? ( + + + {isInstalled ? 'Uninstalling' : 'Installing'} + {' '} + Task Timer hooks... + + + ) : installMessage ? ( + + + {installMessage} + + + Press any key to continue... + + + ) : ( + <> + + + {' Installation Status: '} + {isInstalled ? ( + <> + ✓ Installed + + ) : ( + <> + ✗ Not Installed + + )} + + + + {isInstalled ? ( + + + Hook location: + {getTimingHookPath()} + + Hooks configured: UserPromptSubmit, Stop, SessionEnd + + ) : null} + + + {isInstalled ? ( + <> + + (i) + Task Timer is ready to use + + + (u) + Uninstall Task Timer hooks + + + ) : ( + <> + + (i) + Install Task Timer hooks + + + )} + + Press ESC to go back + + + + )} + + ); +}; \ No newline at end of file diff --git a/src/tui/components/index.ts b/src/tui/components/index.ts index 34afc2e..1b271a9 100644 --- a/src/tui/components/index.ts +++ b/src/tui/components/index.ts @@ -8,5 +8,6 @@ export * from './LineSelector'; export * from './MainMenu'; export * from './PowerlineSetup'; export * from './StatusLinePreview'; +export * from './TaskTimerSetup'; export * from './TerminalOptionsMenu'; export * from './TerminalWidthMenu'; \ No newline at end of file diff --git a/src/utils/claude-settings.ts b/src/utils/claude-settings.ts index 43c4620..998c13e 100644 --- a/src/utils/claude-settings.ts +++ b/src/utils/claude-settings.ts @@ -19,6 +19,27 @@ export const CCSTATUSLINE_COMMANDS = { SELF_MANAGED: 'ccstatusline' }; +/** + * Finds the package root directory by searching upward for package.json. + * This works whether running from source or from a bundled package. + */ +function findPackageRoot(): string { + let currentDir = __dirname; + + // Search upward until we find package.json + while (currentDir !== path.dirname(currentDir)) { + const packageJsonPath = path.join(currentDir, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + return currentDir; + } + currentDir = path.dirname(currentDir); + } + + // Fallback: if we can't find package.json, assume we're in a standard npm package structure + // This handles the case where the code is bundled and __dirname points to dist/ + return path.join(__dirname, '..'); +} + /** * Determines the Claude config directory, checking CLAUDE_CONFIG_DIR environment variable first, * then falling back to the default ~/.claude directory. @@ -136,4 +157,196 @@ export async function uninstallStatusLine(): Promise { export async function getExistingStatusLine(): Promise { const settings = await loadClaudeSettings(); return settings.statusLine?.command ?? null; +} + +/** + * Gets the full path to the Claude hooks directory. + */ +export function getClaudeHooksDir(): string { + return path.join(getClaudeConfigDir(), 'hooks'); +} + +/** + * Gets the full path to the timing hook script. + */ +export function getTimingHookPath(): string { + return path.join(getClaudeHooksDir(), 'timing_hook.sh'); +} + +/** + * Checks if the task timer hooks are installed. + */ +export async function isTaskTimerInstalled(): Promise { + // Check if timing_hook.sh exists + const hookPath = getTimingHookPath(); + if (!fs.existsSync(hookPath)) { + return false; + } + + // Check if hooks are configured in settings.json + const settings = await loadClaudeSettings(); + const hooks = settings.hooks as Record | undefined; + + if (!hooks) { + return false; + } + + // Check for UserPromptSubmit, Stop, and SessionEnd hooks + const requiredHooks = ['UserPromptSubmit', 'Stop', 'SessionEnd']; + for (const hookName of requiredHooks) { + const hookConfig = hooks[hookName] as { hooks: { command: string }[] }[] | undefined; + if (!hookConfig || !Array.isArray(hookConfig)) { + return false; + } + + // Check if timing_hook.sh is present in the hook configuration + const hasTimingHook = hookConfig.some((config) => { + const hooksList = config.hooks; + if (!Array.isArray(hooksList)) { + return false; + } + return hooksList.some((hook) => { + return hook.command.includes('timing_hook.sh'); + }); + }); + + if (!hasTimingHook) { + return false; + } + } + + return true; +} + +/** + * Installs the task timer hooks. + * Copies timing_hook.sh to ~/.claude/hooks/ and updates settings.json + */ +export async function installTaskTimer(): Promise { + // Create hooks directory + const hooksDir = getClaudeHooksDir(); + await mkdir(hooksDir, { recursive: true }); + + // Copy timing_hook.sh from templates + // Use findPackageRoot() to locate templates directory reliably + const packageRoot = findPackageRoot(); + const templatePath = path.join(packageRoot, 'templates', 'hooks', 'timing_hook.sh'); + const hookPath = getTimingHookPath(); + + // Read template and write to hooks directory + const templateContent = await readFile(templatePath, 'utf-8'); + await writeFile(hookPath, templateContent, 'utf-8'); + + // Make script executable on Unix-like systems + if (process.platform !== 'win32') { + fs.chmodSync(hookPath, 0o755); + } + + // Update settings.json with hooks configuration + const settings = await loadClaudeSettings(); + + // Get platform-specific hook command + const hookCommand = getTimingHookCommand(); + + // Initialize hooks object if it doesn't exist + settings.hooks ??= {}; + + const hooks = settings.hooks as Record; + + // Helper to add hook if not already present + const addHook = (hookName: string) => { + hooks[hookName] ??= []; + + // Check if timing_hook.sh already exists + const hasTimingHook = hooks[hookName].some((config) => { + return config.hooks.some(hook => hook.command.includes('timing_hook.sh')); + }); + + if (!hasTimingHook) { + hooks[hookName].push({ + hooks: [ + { + command: hookCommand, + type: 'command' + } + ] + }); + } + }; + + // Add hooks for UserPromptSubmit, Stop, and SessionEnd + addHook('UserPromptSubmit'); + addHook('Stop'); + addHook('SessionEnd'); + + await saveClaudeSettings(settings); +} + +/** + * Uninstalls the task timer hooks. + * Removes timing_hook.sh and cleans up hooks configuration + */ +export async function uninstallTaskTimer(): Promise { + // Remove timing_hook.sh + const hookPath = getTimingHookPath(); + if (fs.existsSync(hookPath)) { + await fs.promises.unlink(hookPath); + } + + // Update settings.json to remove hooks + const settings = await loadClaudeSettings(); + if (!settings.hooks) { + return; + } + + const hooks = settings.hooks as Record; + + // Remove timing_hook.sh from all hooks + const hookNames = ['UserPromptSubmit', 'Stop', 'SessionEnd']; + for (const hookName of hookNames) { + if (!hooks[hookName]) { + continue; + } + + const filtered = hooks[hookName].filter((config) => { + config.hooks = config.hooks.filter((hook) => { + return !hook.command.includes('timing_hook.sh'); + }); + return config.hooks.length > 0; + }); + + // Remove empty hook arrays or update + if (filtered.length === 0) { + hooks[hookName] = undefined as unknown as { hooks: { command: string }[] }[]; + } else { + hooks[hookName] = filtered; + } + } + + // Remove hooks object if empty + if (Object.keys(hooks).length === 0) { + delete settings.hooks; + } + + await saveClaudeSettings(settings); +} + +/** + * Gets the platform-specific command to execute timing_hook.sh + */ +function getTimingHookCommand(): string { + const hookPath = getTimingHookPath(); + + if (process.platform === 'win32') { + // Windows: Use %USERPROFILE% environment variable + const relativePath = hookPath.replace( + path.join(os.homedir(), '.claude'), + '%USERPROFILE%\\.claude' + ).replace(/\\/g, '\\\\'); + return `bash "${relativePath}"`; + } else { + // Unix-like systems: Use $HOME environment variable + const relativePath = hookPath.replace(os.homedir(), '$HOME'); + return `bash "${relativePath}"`; + } } \ No newline at end of file diff --git a/src/utils/widgets.ts b/src/utils/widgets.ts index 15a2510..757acab 100644 --- a/src/utils/widgets.ts +++ b/src/utils/widgets.ts @@ -27,7 +27,8 @@ const widgetRegistry = new Map([ ['version', new widgets.VersionWidget()], ['custom-text', new widgets.CustomTextWidget()], ['custom-command', new widgets.CustomCommandWidget()], - ['claude-session-id', new widgets.ClaudeSessionIdWidget()] + ['claude-session-id', new widgets.ClaudeSessionIdWidget()], + ['task-timer', new widgets.TaskTimerWidget()] ]); export function getWidget(type: WidgetItemType): Widget | null { diff --git a/src/widgets/TaskTimer.ts b/src/widgets/TaskTimer.ts new file mode 100644 index 0000000..2d7e3b1 --- /dev/null +++ b/src/widgets/TaskTimer.ts @@ -0,0 +1,80 @@ +import { execSync } from 'child_process'; +import { existsSync } from 'fs'; +import os from 'os'; +import path from 'path'; + +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; + +export class TaskTimerWidget implements Widget { + getDefaultColor(): string { return 'cyan'; } + getDescription(): string { return 'Shows current task execution time (requires claude-code-task-timer hooks)'; } + getDisplayName(): string { return 'Task Timer'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { displayText: this.getDisplayName() }; + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + if (context.isPreview) { + return item.rawValue ? '1分23秒' : '执行中:1分23秒'; + } + + // Get timing hook script path + const hookPath = this.getTimingHookPath(); + + if (!existsSync(hookPath)) { + return '[Timer: Hook not installed]'; + } + + try { + const timeout = 2000; // 2 second timeout + const jsonInput = JSON.stringify(context.data ?? {}); + + // Execute the timing hook script + let output = execSync(`bash "${hookPath}"`, { + encoding: 'utf8', + input: jsonInput, + timeout: timeout, + stdio: ['pipe', 'pipe', 'ignore'], + env: process.env + }).trim(); + + // Strip ANSI codes + output = output.replace(/\x1b\[[0-9;]*m/g, ''); + + if (!output) { + return null; + } + + // If rawValue is true, strip the prefix (e.g., "执行中:" or "执行完成:") + if (item.rawValue) { + const match = /[::]\s*(.+)$/.exec(output); + if (match?.[1]) { + return match[1]; + } + } + + return output; + } catch { + // Silent failure - return null if hook execution fails + return null; + } + } + + /** + * Get the path to the timing hook script + */ + private getTimingHookPath(): string { + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), '.claude'); + return path.join(claudeConfigDir, 'hooks', 'timing_hook.sh'); + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/index.ts b/src/widgets/index.ts index faaa705..52ef872 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -18,4 +18,5 @@ export { CustomTextWidget } from './CustomText'; export { CustomCommandWidget } from './CustomCommand'; export { BlockTimerWidget } from './BlockTimer'; export { CurrentWorkingDirWidget } from './CurrentWorkingDir'; -export { ClaudeSessionIdWidget } from './ClaudeSessionId'; \ No newline at end of file +export { ClaudeSessionIdWidget } from './ClaudeSessionId'; +export { TaskTimerWidget } from './TaskTimer'; \ No newline at end of file diff --git a/templates/hooks/timing_hook.sh b/templates/hooks/timing_hook.sh new file mode 100644 index 0000000..f3aeb88 --- /dev/null +++ b/templates/hooks/timing_hook.sh @@ -0,0 +1,117 @@ +#!/bin/bash +# Claude Code Task Timer Hook +# 功能:统计 Claude Code 任务执行时间并在状态栏实时显示 +# +# 双模式设计: +# - Hook 模式:被 Claude Code 调用时,处理事件并记录时间 +# - Display 模式:被 ccstatusline 调用时,显示当前任务耗时 +# +# 支持多实例并行运行,使用 session_id 区分不同实例 + +TIMING_DIR="$HOME/.claude/.timing" + +# 从 stdin 读取 JSON 数据(Claude Code 会传入事件数据) +INPUT=$(cat) + +# 解析 hook 事件类型 +HOOK_EVENT=$(echo "$INPUT" | grep -o '"hook_event_name"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"\([^"]*\)"$/\1/') + +# 解析 session_id 用于区分不同的 Claude Code 实例 +SESSION_ID=$(echo "$INPUT" | grep -o '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"\([^"]*\)"$/\1/') + +# 解析 stop_hook_active 字段(防止无限循环) +STOP_HOOK_ACTIVE=$(echo "$INPUT" | grep -o '"stop_hook_active"[[:space:]]*:[[:space:]]*[a-z]*' | sed 's/.*:[[:space:]]*//') + +# 如果没有 session_id,使用 fallback(兼容旧版本或单实例场景) +if [ -z "$SESSION_ID" ]; then + SESSION_ID="default" +fi + +# 确保目录存在 +mkdir -p "$TIMING_DIR" + +# 基于 session_id 的独立文件,支持多实例并行 +TIMING_FILE="$TIMING_DIR/timing_${SESSION_ID}" +DURATION_FILE="$TIMING_DIR/duration_${SESSION_ID}" + +# 时间格式化函数 +format_duration() { + local ELAPSED=$1 + if [ $ELAPSED -ge 3600 ]; then + HOURS=$((ELAPSED / 3600)) + MINUTES=$(((ELAPSED % 3600) / 60)) + SECONDS=$((ELAPSED % 60)) + echo "${HOURS}时${MINUTES}分${SECONDS}秒" + elif [ $ELAPSED -ge 60 ]; then + MINUTES=$((ELAPSED / 60)) + SECONDS=$((ELAPSED % 60)) + echo "${MINUTES}分${SECONDS}秒" + else + echo "${ELAPSED}秒" + fi +} + +# 判断调用模式:如果没有 HOOK_EVENT,说明是 ccstatusline 调用(Display 模式) +if [ -z "$HOOK_EVENT" ]; then + # Display 模式:实时显示耗时 + if [ -f "$TIMING_FILE" ]; then + # 任务进行中:计算实时耗时 + START_TIME=$(cat "$TIMING_FILE") + CURRENT_TIME=$(date +%s) + ELAPSED=$((CURRENT_TIME - START_TIME)) + DURATION_STR=$(format_duration $ELAPSED) + echo "执行中:${DURATION_STR}" + elif [ -f "$DURATION_FILE" ]; then + # 任务已结束:显示最终耗时 + DURATION_STR=$(cat "$DURATION_FILE") + echo "执行完成:${DURATION_STR}" + else + # 没有任务记录 + echo "" + fi + exit 0 +fi + +# Hook 模式:处理各种事件 + +if [ "$HOOK_EVENT" = "UserPromptSubmit" ]; then + # 用户提交 prompt:只有当缓存文件不存在时才记录开始时间 + # 这确保了连续对话不会重置计时 + if [ ! -f "$TIMING_FILE" ]; then + date +%s > "$TIMING_FILE" + fi + # 清除上一次的完成时间显示 + rm -f "$DURATION_FILE" + +elif [ "$HOOK_EVENT" = "Stop" ]; then + # Agent 停止:检查是否是第二次触发(防止无限循环) + if [ "$STOP_HOOK_ACTIVE" = "true" ]; then + exit 0 + fi + + # 第一次触发:计算耗时 + if [ -f "$TIMING_FILE" ]; then + START_TIME=$(cat "$TIMING_FILE") + END_TIME=$(date +%s) + DURATION=$((END_TIME - START_TIME)) + DURATION_STR=$(format_duration $DURATION) + + # 保存耗时供 ccstatusline 显示 + echo "$DURATION_STR" > "$DURATION_FILE" + + # 清理开始时间文件 + rm -f "$TIMING_FILE" + + # NOTE: Uncomment the following line to enable decision:block mode + # This will make Claude output the task duration when tasks complete + # echo "{\"decision\": \"block\", \"reason\": \"本次任务耗时: ${DURATION_STR}\"}" + exit 0 + fi + +elif [ "$HOOK_EVENT" = "SessionEnd" ]; then + # 会话结束:清理所有缓存文件 + rm -f "$TIMING_FILE" + rm -f "$DURATION_FILE" +fi + +exit 0