diff --git a/docs/task-objective.md b/docs/task-objective.md new file mode 100644 index 00000000..cdb81943 --- /dev/null +++ b/docs/task-objective.md @@ -0,0 +1,196 @@ +# Task Objective Widget & Terminal Title + +Two new features that display what Claude is currently working on. + +## Overview + +The **task-objective widget** shows the current task in the status line with a status emoji and elapsed timer. The **terminal title** feature sets the terminal tab title using a configurable template. Both read from the same task file, written by Claude during the session. + +``` +Model: Opus 4.6 | Ctx: 42k | βŽ‡ main | (+12,-3) | πŸ”„ Implement auth flow (3m) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + task-objective widget +``` + +Terminal tab: `Implement auth flow | my-repo/main` + +## How It Works + +The task-objective system bridges a gap: Claude Code doesn't expose its `session_id` to the conversation, but the status line command receives it in its stdin JSON. The system has three moving parts: + +### 1. Session ID Discovery + +**Problem:** Claude needs to write a task file keyed by session ID, but doesn't know its own session ID. + +**Solution:** ccstatusline acts as a bridge. On every status line render: + +1. ccstatusline receives `session_id` in its stdin JSON from Claude Code +2. It walks up the process tree to find the Claude Code CLI PID (the process named `claude`) +3. It writes the session ID to `~/.cache/ccstatusline/sessions/` + +Claude can then discover its session ID by: + +1. Running `echo $PPID` β€” this returns the Claude CLI PID (the Bash tool runs under Claude Code as its parent) +2. Reading `~/.cache/ccstatusline/sessions/` β€” this file contains the session ID + +This works because ccstatusline and Claude's Bash tool share the same ancestor process (the Claude CLI), so the PID ccstatusline discovers via the process tree walk matches Claude's `$PPID`. + +``` +Claude Code CLI (PID 12345) ← Both find this PID +β”œβ”€β”€ ccstatusline (walks tree up) ← Writes sessions/12345 +└── bash (echo $PPID β†’ 12345) ← Claude reads sessions/12345 +``` + +**Files:** `src/utils/session-discovery.ts` + +### 2. Task File + +Claude writes a minimal JSON file at `~/.cache/ccstatusline/tasks/claude-task-`: + +```json +{"task": "Implement auth flow", "status": "in_progress"} +``` + +The `task` field is the only required field β€” a short description of the current objective. The `status` field is optional and controls the emoji indicator (defaults to `in_progress`). Plain text (first line) is also accepted as a fallback format. + +**Status indicators:** + +| Status | Indicator | Meaning | +|--------|-----------|---------| +| `in_progress` | πŸ”„ | Actively working (default) | +| `complete` | βœ… | Task finished successfully | +| `failed` | ❌ | Task failed | +| `blocked` | πŸ›‘ | Waiting on user input or external dependency | +| `paused` | ⏸️ | Work paused, will resume | +| `reviewing` | πŸ” | Reviewing code or waiting for review | + +**Elapsed timer:** The widget tracks how long the current task has been active. The timer uses the task file's modification time (`mtime`) as the start time, so it persists across ccstatusline process restarts. The timer resets automatically when the task text changes (new task). Claude does not need to manage timing β€” ccstatusline handles it entirely. + +**Files:** `src/widgets/TaskObjective.ts` + +### 3. Terminal Title + +A configurable template that resolves placeholders from the current context: + +| Placeholder | Source | +|-------------|--------| +| `{task}` | Task file (same as widget) | +| `{repo}` | Git repository name | +| `{branch}` | Current git branch | +| `{model}` | Claude model display name | +| `{dir}` | Working directory basename | + +Template segments separated by ` | ` are dropped when all their placeholders are empty. For example, `{task} | {repo}/{branch}` gracefully falls back to `{repo}/{branch}` when no task is set. + +The title is emitted as an OSC 1 escape sequence via stderr after each status line render. + +**Files:** `src/utils/terminal-title.ts` + +## Install Flow + +When the task-objective widget is present in the ccstatusline config, `installStatusLine()` configures three things in Claude Code: + +### Permissions (`~/.claude/settings.json`) + +```json +{ + "permissions": { + "allow": [ + "Bash(echo $PPID)", + "Read(//Users/alice/.cache/ccstatusline/sessions/*)" + ] + } +} +``` + +- `Bash(echo $PPID)` β€” allows Claude to discover its parent PID without prompting +- `Read(//...)` β€” allows Claude to read the session discovery file (uses `//` absolute path prefix per Claude Code conventions) + +### PreToolUse Hook (`~/.claude/settings.json`) + +```json +{ + "hooks": { + "PreToolUse": [{ + "_tag": "ccstatusline-task-objective", + "matcher": "Write", + "hooks": [{ + "type": "command", + "command": "jq -r 'if .tool_name == \"Write\" and (.tool_input.file_path | test(\"claude-task-\")) then {\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\"}} else {} end'" + }] + }] + } +} +``` + +This hook auto-approves Write tool calls to task files without prompting the user. It only matches files containing `claude-task-` in the path; all other Write calls pass through normally. + +**Why a hook instead of permissions?** Claude Code's Write permission system doesn't reliably match paths outside the project directory, regardless of format (`//absolute`, `~`, relative, `**` globs). This appears to be a [Claude Code limitation](https://github.com/anthropics/claude-code/issues/38391). The `PreToolUse` hook with `permissionDecision: "allow"` bypasses this. + +### CLAUDE.md Instructions (`~/.claude/CLAUDE.md`) + +The installer appends instructions (wrapped in `` markers) that tell Claude how to: + +1. Discover its session ID via `echo $PPID` + reading the session file +2. Write the task file using the Write tool +3. Update the task when objectives change + +The instructions include the full list of available status values. They are idempotent β€” re-running the installer replaces the section in place. The installer pre-creates the `~/.cache/ccstatusline/tasks/` directory. + +**Files:** `src/utils/claude-md.ts`, `src/utils/claude-settings.ts` + +## Uninstall + +`uninstallStatusLine()` removes: +- The `ccstatusline-task-objective` tagged hooks from settings.json +- The CLAUDE.md instruction section (matched by marker comments) +- Other ccstatusline-managed hooks + +## Configuration + +### ccstatusline settings (`~/.config/ccstatusline/settings.json`) + +Task-objective widget: +```json +{ + "id": "9", + "type": "task-objective", + "color": "green", + "rawValue": true, + "maxWidth": 50 +} +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `rawValue` | boolean | false | If true, shows just the task text; if false, prefixes with "Task: " | +| `maxWidth` | number | - | Truncate with `...` if longer | +| `color` | string | green | Widget foreground color | +| `metadata.showElapsed` | string | `"true"` | Set to `"false"` to hide the elapsed timer | + +Terminal title: +```json +{ + "terminalTitle": { + "enabled": true, + "template": "{task} | {repo}/{branch}" + } +} +``` + +## Concurrent Sessions + +The system supports multiple concurrent Claude Code sessions, even in the same project directory: + +- Each session has a unique `session_id` (assigned by Claude Code, survives session resume) +- Each Claude CLI process has a unique PID β†’ unique session discovery file +- Each session writes to a unique task file (`claude-task-`) +- The status line command runs per-session, so each session's widget reads its own task file + +## Known Limitations + +- **Session ID discovery is Unix-only** β€” uses `ps -o ppid=,comm=` to walk the process tree. Windows would need `wmic` or `tasklist`. +- **Claude Code doesn't expose session_id** β€” the process tree workaround is necessary until [this is resolved](https://github.com/anthropics/claude-code/issues/38390). +- **Write permissions don't work outside the project** β€” the PreToolUse hook workaround is necessary until [this is resolved](https://github.com/anthropics/claude-code/issues/38391). +- **Elapsed timer resets on ccstatusline process restart** β€” since ccstatusline launches as a fresh process per render, the timer falls back to the file's mtime. This is accurate for the initial task write but doesn't track status changes (e.g., if Claude changes status from `in_progress` to `blocked` and back, the timer reflects the last file modification, not the original task start). +- **Terminal title** uses OSC 1 escape sequences β€” supported by most modern terminals (iTerm2, Terminal.app, Windows Terminal) but not all. diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index 54b70113..05542a61 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -28,6 +28,7 @@ import { renderStatusLine } from './utils/renderer'; import { advanceGlobalSeparatorIndex } from './utils/separator-index'; +import { writeSessionId } from './utils/session-discovery'; import { getSkillsFilePath, getSkillsMetrics @@ -86,6 +87,12 @@ async function ensureWindowsUtf8CodePage() { } async function renderMultipleLines(data: StatusJSON) { + // Claude Code doesn't expose session_id to the conversation context, but + // the task-objective widget needs Claude to write files keyed by session ID. + // We bridge this gap by writing the session ID to a file keyed by the Claude + // CLI PID, which Claude can discover via echo $PPID. See session-discovery.ts. + writeSessionId(data.session_id); + const settings = await loadSettings(); // Set global chalk level based on settings diff --git a/src/types/ClaudeSettings.ts b/src/types/ClaudeSettings.ts index 5fbf3664..1072842d 100644 --- a/src/types/ClaudeSettings.ts +++ b/src/types/ClaudeSettings.ts @@ -3,6 +3,7 @@ export interface ClaudeSettings { permissions?: { allow?: string[]; deny?: string[]; + additionalDirectories?: string[]; }; statusLine?: { type: string; diff --git a/src/utils/claude-md.ts b/src/utils/claude-md.ts new file mode 100644 index 00000000..61e7e93a --- /dev/null +++ b/src/utils/claude-md.ts @@ -0,0 +1,118 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { getClaudeConfigDir } from './claude-settings'; + +const CLAUDE_MD_MARKER_START = ''; +const CLAUDE_MD_MARKER_END = ''; + +function buildTaskInstructions(): string { + const cacheDir = path.join(os.homedir(), '.cache', 'ccstatusline'); + const sessionsDir = path.join(cacheDir, 'sessions'); + const tasksDir = path.join(cacheDir, 'tasks'); + return `## Task Objective (ccstatusline) + +When starting work, write a task file so the terminal status line shows what this session is doing. This helps the user distinguish between concurrent sessions. + +**Step 1 β€” Discover your session ID** (once per session): +\`\`\`bash +echo $PPID +\`\`\` +Then use the Read tool to read \`${sessionsDir}/\`. The file contains the session ID (written automatically by ccstatusline). + +**Step 2 β€” Write the task file** using the Write tool (do not read the file first, just overwrite it): + +Path: \`${tasksDir}/claude-task-\` + +\`\`\`json +{"task":"Brief description of current objective","status":"in_progress"} +\`\`\` + +**Status values** (shown as emoji indicators in the status line): +| Status | Indicator | When to use | +|--------|-----------|-------------| +| \`in_progress\` | \u{1F504} | Actively working (default) | +| \`complete\` | \u2705 | Task finished successfully | +| \`failed\` | \u274C | Task failed | +| \`blocked\` | \u{1F6D1} | Waiting on user input or external dependency | +| \`paused\` | \u23F8\uFE0F | Work paused, will resume | +| \`reviewing\` | \u{1F50D} | Reviewing code or waiting for review | + +**When to update:** +- At session start (set status to \`"in_progress"\`) +- When the objective changes significantly +- When work is complete (set status to \`"complete"\`) + +Keep the task description concise β€” under 40 characters is ideal. Longer text will be truncated with an ellipsis in the status line.`; +} + +function getClaudeMdPath(): string { + return path.join(getClaudeConfigDir(), 'CLAUDE.md'); +} + +/** + * Ensures the global CLAUDE.md contains task-objective instructions. + * Uses marker comments so the section can be updated or removed cleanly. + */ +export function ensureTaskInstructions(): void { + const mdPath = getClaudeMdPath(); + let content = ''; + + try { + content = fs.readFileSync(mdPath, 'utf8'); + } catch { + // File doesn't exist yet + } + + // Already present? + if (content.includes(CLAUDE_MD_MARKER_START)) { + // Replace existing section in case instructions were updated + const regex = new RegExp( + `${escapeRegex(CLAUDE_MD_MARKER_START)}[\\s\\S]*?${escapeRegex(CLAUDE_MD_MARKER_END)}`, + 'm' + ); + content = content.replace(regex, buildSection()); + } else { + // Append + const separator = content.length > 0 && !content.endsWith('\n') ? '\n\n' : content.length > 0 ? '\n' : ''; + content = content + separator + buildSection() + '\n'; + } + + fs.mkdirSync(path.dirname(mdPath), { recursive: true }); + fs.writeFileSync(mdPath, content, 'utf8'); +} + +/** + * Removes the task-objective instructions from CLAUDE.md. + */ +export function removeTaskInstructions(): void { + const mdPath = getClaudeMdPath(); + + let content: string; + try { + content = fs.readFileSync(mdPath, 'utf8'); + } catch { + return; // Nothing to remove + } + + if (!content.includes(CLAUDE_MD_MARKER_START)) { + return; + } + + const regex = new RegExp( + `\\n?${escapeRegex(CLAUDE_MD_MARKER_START)}[\\s\\S]*?${escapeRegex(CLAUDE_MD_MARKER_END)}\\n?`, + 'm' + ); + content = content.replace(regex, '\n'); + + fs.writeFileSync(mdPath, content.trimEnd() + '\n', 'utf8'); +} + +function buildSection(): string { + return `${CLAUDE_MD_MARKER_START}\n${buildTaskInstructions()}\n${CLAUDE_MD_MARKER_END}`; +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/src/utils/claude-settings.ts b/src/utils/claude-settings.ts index e12e10f4..664b37c7 100644 --- a/src/utils/claude-settings.ts +++ b/src/utils/claude-settings.ts @@ -221,6 +221,71 @@ async function loadSavedSettingsForHookSync(): Promise { } } +/** + * Ensures Claude Code permissions for the task-objective widget: + * - Read session discovery files (to learn the session ID via PPID) + * - Write task files (to update the task objective) + */ +const TASK_HOOK_TAG = 'ccstatusline-task-objective'; + +/** + * Auto-approve Write tool calls to task files via a PreToolUse hook. + * + * Claude Code's permission system doesn't reliably match Write permissions + * for paths outside the project directory. A PreToolUse hook with + * permissionDecision: "allow" bypasses this entirely. + */ +function ensureTaskObjectivePermissions(settings: ClaudeSettings): void { + const cacheDir = path.join(os.homedir(), '.cache', 'ccstatusline'); + const toAbsolute = (p: string) => p.startsWith('/') ? `/${p}` : `//${p}`; + const permissions = [ + 'Bash(echo $PPID)', + `Read(${toAbsolute(path.join(cacheDir, 'sessions', '*'))})` + ]; + + if (!settings.permissions) { + settings.permissions = {}; + } + if (!settings.permissions.allow) { + settings.permissions.allow = []; + } + for (const permission of permissions) { + if (!settings.permissions.allow.includes(permission)) { + settings.permissions.allow.push(permission); + } + } + + // Add a PreToolUse hook that auto-approves Write to task files + interface TaskHookEntry { + _tag?: string; + matcher?: string; + hooks?: { type: string; command: string }[]; + } + const hooks = ((settings as Record).hooks ?? {}) as Record; + + // Remove any existing task-objective hooks + for (const event of Object.keys(hooks)) { + hooks[event] = (hooks[event] ?? []).filter(entry => entry._tag !== TASK_HOOK_TAG); + if (hooks[event].length === 0) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete hooks[event]; + } + } + + // Hook command: check if the Write file_path contains 'claude-task-', + // if so return permissionDecision allow, otherwise pass through + const hookCommand = `jq -r 'if .tool_name == "Write" and (.tool_input.file_path | test("claude-task-")) then {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}} else {} end'`; + + const list = hooks['PreToolUse'] ??= []; + list.push({ + _tag: TASK_HOOK_TAG, + matcher: 'Write', + hooks: [{ type: 'command', command: hookCommand }] + }); + + (settings as Record).hooks = hooks; +} + export async function installStatusLine(useBunx = false): Promise { let settings: ClaudeSettings; @@ -244,12 +309,28 @@ export async function installStatusLine(useBunx = false): Promise { padding: 0 }; + // If the task-objective widget is configured, ensure Write permission + // for task files and CLAUDE.md instructions + const savedCcSettings = await loadSavedSettingsForHookSync(); + if (savedCcSettings) { + const hasTaskWidget = savedCcSettings.lines.some( + line => line.some(item => item.type === 'task-objective') + ); + if (hasTaskWidget) { + ensureTaskObjectivePermissions(settings); + // Pre-create tasks directory so Claude doesn't need mkdir at runtime + const fs = await import('fs'); + fs.mkdirSync(path.join(os.homedir(), '.cache', 'ccstatusline', 'tasks'), { recursive: true }); + const { ensureTaskInstructions } = await import('./claude-md'); + ensureTaskInstructions(); + } + } + await saveClaudeSettings(settings); - const savedSettings = await loadSavedSettingsForHookSync(); - if (savedSettings) { + if (savedCcSettings) { const { syncWidgetHooks } = await import('./hooks'); - await syncWidgetHooks(savedSettings); + await syncWidgetHooks(savedCcSettings); } } @@ -274,6 +355,13 @@ export async function uninstallStatusLine(): Promise { } catch { // Ignore hook cleanup failures during uninstall } + + try { + const { removeTaskInstructions } = await import('./claude-md'); + removeTaskInstructions(); + } catch { + // Ignore CLAUDE.md cleanup failures during uninstall + } } export async function getExistingStatusLine(): Promise { diff --git a/src/utils/session-discovery.ts b/src/utils/session-discovery.ts new file mode 100644 index 00000000..ae1de891 --- /dev/null +++ b/src/utils/session-discovery.ts @@ -0,0 +1,71 @@ +import { execSync } from 'child_process'; +import { mkdirSync, writeFileSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; + +const SESSIONS_DIR = join(homedir(), '.cache', 'ccstatusline', 'sessions'); + +/** + * Finds the Claude Code CLI PID by walking up the process tree. + * + * When Claude Code runs ccstatusline as a statusLine command, the process + * tree looks like: claude β†’ /bin/bash β†’ node ccstatusline.js + * + * We walk up from our own parent to find the ancestor named "claude". + * This PID matches what Claude's Bash tool sees as $PPID, making it + * a reliable shared key for session discovery. + */ +function findClaudePid(): number | null { + let pid = process.ppid; + for (let i = 0; i < 10; i++) { + try { + const line = execSync(`ps -o ppid=,comm= -p ${pid}`, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'] + }).trim(); + const parts = line.split(/\s+/); + const ppid = parseInt(parts[0] ?? '', 10); + const comm = parts.slice(1).join(' '); + if (comm === 'claude') { + return pid; + } + if (isNaN(ppid) || ppid <= 1) break; + pid = ppid; + } catch { + break; + } + } + return null; +} + +/** + * Writes the session ID to a discovery file keyed by the Claude CLI PID. + * + * Problem: Claude Code doesn't expose session_id as an environment variable + * or through any tool β€” it only appears in the JSON piped to statusLine + * commands and hooks. But the task-objective widget needs Claude to write + * task files keyed by session ID. + * + * Solution: ccstatusline receives session_id on every status line update. + * We find the Claude CLI PID by walking the process tree, then write the + * session ID to a file keyed by that PID. Claude discovers the file by + * running `echo $PPID` (which returns the same Claude CLI PID) and reading + * the corresponding file. + */ +export function writeSessionId(sessionId: string | undefined): void { + if (!sessionId) return; + + const claudePid = findClaudePid(); + if (!claudePid) return; + + try { + mkdirSync(SESSIONS_DIR, { recursive: true }); + writeFileSync( + join(SESSIONS_DIR, String(claudePid)), + sessionId, + 'utf8' + ); + } catch { + // Non-fatal β€” status line rendering should never fail due to this + } +} diff --git a/src/utils/widget-manifest.ts b/src/utils/widget-manifest.ts index 890abd9f..b35dc24c 100644 --- a/src/utils/widget-manifest.ts +++ b/src/utils/widget-manifest.ts @@ -54,7 +54,8 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [ { type: 'context-bar', create: () => new widgets.ContextBarWidget() }, { type: 'skills', create: () => new widgets.SkillsWidget() }, { type: 'thinking-effort', create: () => new widgets.ThinkingEffortWidget() }, - { type: 'vim-mode', create: () => new widgets.VimModeWidget() } + { type: 'vim-mode', create: () => new widgets.VimModeWidget() }, + { type: 'task-objective', create: () => new widgets.TaskObjectiveWidget() } ]; export const LAYOUT_WIDGET_MANIFEST: LayoutWidgetManifestEntry[] = [ diff --git a/src/widgets/TaskObjective.ts b/src/widgets/TaskObjective.ts new file mode 100644 index 00000000..2440a6f9 --- /dev/null +++ b/src/widgets/TaskObjective.ts @@ -0,0 +1,206 @@ +import { readFileSync, statSync, writeFileSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; + +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; + +const TASK_DIR = join(homedir(), '.cache', 'ccstatusline', 'tasks'); +const TASK_FILE_PREFIX = 'claude-task-'; + +// Status indicators shown as emoji prefixes +const STATUS_INDICATORS: Record = { + 'in_progress': '\u{1F504}', // πŸ”„ + 'complete': '\u2705', // βœ… + 'failed': '\u274C', // ❌ + 'blocked': '\u{1F6D1}', // πŸ›‘ + 'paused': '\u23F8\uFE0F', // ⏸️ + 'reviewing': '\u{1F50D}', // πŸ” +}; + +// Terminal statuses β€” elapsed time freezes when the task reaches one of these +const TERMINAL_STATUSES = new Set(['complete', 'failed']); + +interface TaskFileData { + task: string | null; + status: string | null; +} + +interface SidecarData { + task: string; + startedAt: number; + frozenElapsedMs?: number; +} + +function formatElapsed(ms: number): string { + if (ms < 0) return '0s'; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; +} + +export function getTaskDir(): string { + return TASK_DIR; +} + +function getTaskFilePath(sessionId: string): string { + return join(TASK_DIR, `${TASK_FILE_PREFIX}${sessionId}`); +} + +function readTaskFile(sessionId: string): TaskFileData { + const filePath = getTaskFilePath(sessionId); + try { + const content = readFileSync(filePath, 'utf8').trim(); + if (!content) return { task: null, status: null }; + + try { + const data = JSON.parse(content) as { task?: string; status?: string }; + return { task: data.task ?? null, status: data.status ?? null }; + } catch { + return { task: content.split('\n')[0] ?? null, status: null }; + } + } catch { + return { task: null, status: null }; + } +} + +/** + * Get elapsed ms since the current task started. + * + * Persists the start time in a sidecar file (.started) so it + * survives across ccstatusline process restarts (ccstatusline launches as + * a fresh process per render). The sidecar stores the task text and a + * timestamp; if the task text changes, the timer resets. + * + * When the task reaches a terminal status (complete, failed), the elapsed + * time is frozen β€” subsequent renders show the same duration. + */ +function getElapsedMs(sessionId: string, task: string, status: string | null): number { + const sidecarPath = getTaskFilePath(sessionId) + '.started'; + + // Check for existing sidecar + let sidecar: SidecarData | null = null; + try { + const content = readFileSync(sidecarPath, 'utf8').trim(); + const data = JSON.parse(content) as SidecarData; + if (data.task === task && typeof data.startedAt === 'number') { + sidecar = data; + } + } catch { + // No sidecar or invalid β€” will create one below + } + + if (sidecar) { + // If already frozen, return the frozen value + if (typeof sidecar.frozenElapsedMs === 'number') { + return sidecar.frozenElapsedMs; + } + + const elapsed = Date.now() - sidecar.startedAt; + + // Freeze if we just reached a terminal status + if (status && TERMINAL_STATUSES.has(status)) { + try { + writeFileSync(sidecarPath, JSON.stringify({ + ...sidecar, + frozenElapsedMs: elapsed + }), 'utf8'); + } catch { /* non-fatal */ } + } + + return elapsed; + } + + // New task or first sighting β€” use the task file's birthtime if + // available (creation time), falling back to mtime, then now. + let startedAt = Date.now(); + try { + const stats = statSync(getTaskFilePath(sessionId)); + // birthtimeMs is 0 on filesystems that don't support it + startedAt = stats.birthtimeMs > 0 ? stats.birthtimeMs : stats.mtimeMs; + } catch { + // Fall back to now + } + + const newSidecar: SidecarData = { task, startedAt }; + const elapsed = Date.now() - startedAt; + + // If already terminal on first sighting, freeze immediately + if (status && TERMINAL_STATUSES.has(status)) { + newSidecar.frozenElapsedMs = elapsed; + } + + try { + writeFileSync(sidecarPath, JSON.stringify(newSidecar), 'utf8'); + } catch { /* non-fatal */ } + + return elapsed; +} + +export class TaskObjectiveWidget implements Widget { + getDefaultColor(): string { return 'green'; } + getDescription(): string { return 'Displays the current task objective from a session-keyed file'; } + getDisplayName(): string { return 'Task Objective'; } + getCategory(): string { return 'Core'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const modifiers: string[] = []; + if (item.maxWidth) { + modifiers.push(`max:${item.maxWidth}`); + } + return { + displayText: this.getDisplayName(), + modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined + }; + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + if (context.isPreview) { + return item.rawValue + ? `${STATUS_INDICATORS['in_progress']} Implement auth flow (3m)` + : `Task: ${STATUS_INDICATORS['in_progress']} Implement auth flow (3m)`; + } + + const sessionId = context.data?.session_id; + if (!sessionId) return null; + + const { task, status } = readTaskFile(sessionId); + if (!task) return null; + + const indicator = STATUS_INDICATORS[status ?? 'in_progress'] ?? ''; + const prefix = indicator ? `${indicator} ` : ''; + + const showElapsed = item.metadata?.showElapsed !== 'false'; + const elapsedMs = getElapsedMs(sessionId, task, status); + const suffix = showElapsed ? ` (${formatElapsed(elapsedMs)})` : ''; + + let display = item.rawValue + ? `${prefix}${task}${suffix}` + : `Task: ${prefix}${task}${suffix}`; + + if (item.maxWidth && display.length > item.maxWidth) { + display = display.substring(0, item.maxWidth - 3) + '...'; + } + + return display; + } + + getCustomKeybinds(): CustomKeybind[] { + return [ + { key: 'w', label: '(w)idth', action: 'edit-width' } + ]; + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } +} diff --git a/src/widgets/__tests__/TaskObjective.test.ts b/src/widgets/__tests__/TaskObjective.test.ts new file mode 100644 index 00000000..bdb35d42 --- /dev/null +++ b/src/widgets/__tests__/TaskObjective.test.ts @@ -0,0 +1,185 @@ +import { mkdirSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import type { + RenderContext, + WidgetItem +} from '../../types'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import { TaskObjectiveWidget } from '../TaskObjective'; + +// Use a temp directory for tests instead of the real cache +const TEST_DIR = join(tmpdir(), 'ccstatusline-test-tasks'); + +// Mock homedir to redirect task files to temp dir +vi.mock('os', async () => { + const actual = await vi.importActual('os'); + return { + ...actual, + homedir: () => join(tmpdir(), 'ccstatusline-test-home') + }; +}); + +const widget = new TaskObjectiveWidget(); + +function makeContext(sessionId?: string): RenderContext { + return { + data: sessionId ? { session_id: sessionId } : undefined + }; +} + +function makeItem(overrides?: Partial): WidgetItem { + return { + id: 'task', + type: 'task-objective', + rawValue: true, + ...overrides + }; +} + +function taskDir(): string { + return join(tmpdir(), 'ccstatusline-test-home', '.cache', 'ccstatusline', 'tasks'); +} + +function writeTask(sessionId: string, data: Record): void { + const dir = taskDir(); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `claude-task-${sessionId}`), JSON.stringify(data), 'utf8'); +} + +function writeSidecar(sessionId: string, data: Record): void { + const dir = taskDir(); + writeFileSync(join(dir, `claude-task-${sessionId}.started`), JSON.stringify(data), 'utf8'); +} + +describe('TaskObjectiveWidget', () => { + beforeEach(() => { + mkdirSync(taskDir(), { recursive: true }); + }); + + afterEach(() => { + try { rmSync(taskDir(), { recursive: true, force: true }); } catch { /* ignore */ } + }); + + it('returns null when no session_id', () => { + const result = widget.render(makeItem(), makeContext(), DEFAULT_SETTINGS); + expect(result).toBeNull(); + }); + + it('returns null when no task file exists', () => { + const result = widget.render(makeItem(), makeContext('no-such-session'), DEFAULT_SETTINGS); + expect(result).toBeNull(); + }); + + it('renders task text from JSON file', () => { + writeTask('test-1', { task: 'Fix login bug', status: 'in_progress' }); + const result = widget.render(makeItem(), makeContext('test-1'), DEFAULT_SETTINGS); + expect(result).toContain('Fix login bug'); + }); + + it('shows in_progress indicator by default', () => { + writeTask('test-2', { task: 'Build API' }); + const result = widget.render(makeItem(), makeContext('test-2'), DEFAULT_SETTINGS); + expect(result).toContain('\u{1F504}'); // πŸ”„ + }); + + it('shows complete indicator', () => { + writeTask('test-3', { task: 'Done task', status: 'complete' }); + const result = widget.render(makeItem(), makeContext('test-3'), DEFAULT_SETTINGS); + expect(result).toContain('\u2705'); // βœ… + }); + + it('shows failed indicator', () => { + writeTask('test-4', { task: 'Bad task', status: 'failed' }); + const result = widget.render(makeItem(), makeContext('test-4'), DEFAULT_SETTINGS); + expect(result).toContain('\u274C'); // ❌ + }); + + it('shows blocked indicator', () => { + writeTask('test-5', { task: 'Waiting', status: 'blocked' }); + const result = widget.render(makeItem(), makeContext('test-5'), DEFAULT_SETTINGS); + expect(result).toContain('\u{1F6D1}'); // πŸ›‘ + }); + + it('prefixes with Task: when rawValue is false', () => { + writeTask('test-6', { task: 'My task', status: 'in_progress' }); + const result = widget.render(makeItem({ rawValue: false }), makeContext('test-6'), DEFAULT_SETTINGS); + expect(result).toMatch(/^Task: /); + }); + + it('truncates with ellipsis when exceeding maxWidth', () => { + writeTask('test-7', { task: 'A very long task description that exceeds the limit', status: 'in_progress' }); + const result = widget.render(makeItem({ maxWidth: 20 }), makeContext('test-7'), DEFAULT_SETTINGS); + expect(result!.length).toBeLessThanOrEqual(20); + expect(result).toContain('...'); + }); + + it('shows elapsed time by default', () => { + writeTask('test-8', { task: 'Timed task', status: 'in_progress' }); + const result = widget.render(makeItem(), makeContext('test-8'), DEFAULT_SETTINGS); + // Should contain parenthesized time like (0s) or (1s) + expect(result).toMatch(/\(\d+[smh]/); + }); + + it('hides elapsed time when showElapsed is false', () => { + writeTask('test-9', { task: 'No timer', status: 'in_progress' }); + const result = widget.render( + makeItem({ metadata: { showElapsed: 'false' } }), + makeContext('test-9'), + DEFAULT_SETTINGS + ); + expect(result).not.toMatch(/\(\d+[smh]/); + }); + + it('freezes elapsed time on complete status', () => { + writeTask('test-10', { task: 'Freeze test', status: 'in_progress' }); + // Create a sidecar with a known start time (10 minutes ago) + const tenMinAgo = Date.now() - 10 * 60 * 1000; + writeSidecar('test-10', { task: 'Freeze test', startedAt: tenMinAgo }); + + // First render while in_progress β€” should show ~10m + const result1 = widget.render(makeItem(), makeContext('test-10'), DEFAULT_SETTINGS); + expect(result1).toContain('10m'); + + // Update status to complete + writeTask('test-10', { task: 'Freeze test', status: 'complete' }); + const result2 = widget.render(makeItem(), makeContext('test-10'), DEFAULT_SETTINGS); + // Should still show ~10m, not reset to 0s + expect(result2).toContain('10m'); + }); + + it('shows preview text', () => { + const result = widget.render(makeItem(), { isPreview: true }, DEFAULT_SETTINGS); + expect(result).toContain('Implement auth flow'); + expect(result).toContain('\u{1F504}'); + }); + + it('reads plain text fallback', () => { + const dir = taskDir(); + writeFileSync(join(dir, 'claude-task-plain'), 'Just plain text\nsecond line', 'utf8'); + const result = widget.render(makeItem(), makeContext('plain'), DEFAULT_SETTINGS); + expect(result).toContain('Just plain text'); + expect(result).not.toContain('second line'); + }); + + describe('getEditorDisplay', () => { + it('shows display name', () => { + const display = widget.getEditorDisplay(makeItem()); + expect(display.displayText).toBe('Task Objective'); + }); + + it('shows maxWidth modifier', () => { + const display = widget.getEditorDisplay(makeItem({ maxWidth: 30 })); + expect(display.modifierText).toBe('(max:30)'); + }); + }); +}); diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 74d92bce..b0ab846e 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -35,4 +35,5 @@ export { ContextBarWidget } from './ContextBar'; export { LinkWidget } from './Link'; export { SkillsWidget } from './Skills'; export { ThinkingEffortWidget } from './ThinkingEffort'; -export { VimModeWidget } from './VimMode'; \ No newline at end of file +export { VimModeWidget } from './VimMode'; +export { TaskObjectiveWidget } from './TaskObjective'; \ No newline at end of file