diff --git a/README.md b/README.md index 3fa033a..2cd0170 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,23 @@ Adds shell hooks that wrap `claude`, `codex`, and `gemini`. The tools work exact function claude; vibe __wrap claude $argv; end ``` +## Track IDE sessions + +For GUI-first tools like Cursor, start and stop an IDE session manually: + +``` +vibe ide start cursor +# code in Cursor +vibe ide stop cursor +``` + +IDE sessions use the same git stats and scoring as wrapped CLI sessions, so they appear in `vibe status`, `vibe log`, `vibe share`, and the opt-in leaderboard. If you use another editor, pass its name instead: + +``` +vibe ide start code +vibe ide stop code +``` + ## What you get Every time you close a Claude Code, Codex, or Gemini session: @@ -108,6 +125,9 @@ vibe status today's sessions (includes active sessions) vibe log last 20 sessions vibe share weekly summary card vibe share --html shareable HTML card +vibe ide start [tool] start tracking an IDE session (defaults to cursor) +vibe ide stop [tool] stop tracking an IDE session +vibe ide status active IDE sessions vibe login sign in to the leaderboard via github vibe logout sign out of the leaderboard vibe leaderboard shipped sessions, last 7 days diff --git a/package.json b/package.json index d3531f9..7cc88d7 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "sessions", "claude", "codex", - "gemini" + "gemini", + "cursor", + "ide" ], "author": "iamnotstatic", "license": "MIT", diff --git a/src/cli.ts b/src/cli.ts index e970861..8c250a6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,6 +10,7 @@ import { initShellHooks, removeShellHooks } from './init.js'; import { login, logout, readAuth } from './auth.js'; import { fetchLeaderboard } from './leaderboard.js'; import { flushPendingSubmissions } from './submit.js'; +import { refreshActiveIdeSessions, renderIdeStatus, startIdeSession, stopIdeSession } from './ide.js'; import { WEB_BASE } from './api.js'; import chalk from 'chalk'; import open from 'open'; @@ -50,6 +51,7 @@ program .description("today's sessions") .action(async () => { await reapOrphanedSessions(); + await refreshActiveIdeSessions(); const sessions = getSessions(); const today = new Date(); today.setHours(0, 0, 0, 0); @@ -75,6 +77,7 @@ program .description('full session history') .action(async () => { await reapOrphanedSessions(); + await refreshActiveIdeSessions(); const sessions = getSessions(); const recent = sessions.slice(-20).reverse(); console.log(renderLog(recent)); @@ -86,6 +89,7 @@ program .option('--html', 'skip terminal card, open HTML directly') .action(async (opts: { html?: boolean }) => { await reapOrphanedSessions(); + await refreshActiveIdeSessions(); const sessions = getSessions(); if (opts.html) { @@ -160,6 +164,31 @@ program } }); +const ideCmd = program + .command('ide') + .description('track IDE sessions like cursor'); + +ideCmd + .command('start [tool]') + .description('start tracking an IDE session') + .action(async (tool?: string) => { + await startIdeSession(tool); + }); + +ideCmd + .command('stop [tool]') + .description('stop tracking an IDE session') + .action(async (tool?: string) => { + await stopIdeSession(tool); + }); + +ideCmd + .command('status') + .description('active IDE sessions') + .action(async () => { + console.log(await renderIdeStatus()); + }); + const configCmd = program .command('config') .description('manage configuration'); diff --git a/src/db.ts b/src/db.ts index 32585c1..a3cc230 100644 --- a/src/db.ts +++ b/src/db.ts @@ -19,6 +19,9 @@ export interface Session { exitCode: number; lastActivityAt?: string; submittedAt?: string; + sessionKind?: 'cli' | 'ide'; + cwd?: string; + startSha?: string; } interface DbSchema { @@ -149,6 +152,7 @@ export async function reapOrphanedSessions(): Promise { for (const s of data.sessions) { if (s.exitCode !== -1) continue; + if (s.sessionKind === 'ide') continue; const lastMs = new Date(s.lastActivityAt || s.startedAt).getTime(); if (now - lastMs <= INACTIVITY_TIMEOUT_MS) continue; diff --git a/src/ide.ts b/src/ide.ts new file mode 100644 index 0000000..d1c038f --- /dev/null +++ b/src/ide.ts @@ -0,0 +1,140 @@ +import { randomUUID } from 'node:crypto'; +import chalk from 'chalk'; +import { addSession, getSessions, reapOrphanedSessions, updateSession, type Session } from './db.js'; +import { getBranch, getDiffStats, getHeadSha, getProjectName, isGitRepo } from './git.js'; +import { readConfig } from './config.js'; +import { scoreSession } from './score.js'; +import { renderEndcard, renderStatus } from './render.js'; +import { flushPendingSubmissions } from './submit.js'; +import { PURPLE } from './colors.js'; + +const RED = chalk.hex('#EF4444'); +const VALID_TOOL_RE = /^[a-z][a-z0-9_-]{0,31}$/i; + +type SessionSnapshot = Pick; + +function normalizeTool(tool = 'cursor'): string { + const normalized = tool.trim().toLowerCase(); + if (!VALID_TOOL_RE.test(normalized)) { + throw new Error('IDE name must be a single word using letters, numbers, "-" or "_"'); + } + return normalized; +} + +function getActiveIdeSessions(tool?: string): Session[] { + return getSessions().filter((s) => + s.sessionKind === 'ide' && + s.exitCode === -1 && + (!tool || s.tool.toLowerCase() === tool) + ); +} + +function findActiveIdeSession(tool?: string, cwd = process.cwd()): Session | undefined { + const active = getActiveIdeSessions(tool); + return active.find((s) => s.cwd === cwd) || (active.length === 1 ? active[0] : undefined); +} + +function snapshot(session: Session, exitCode: number): SessionSnapshot { + const endedAt = new Date().toISOString(); + const startMs = new Date(session.startedAt).getTime(); + const endMs = new Date(endedAt).getTime(); + const durationSeconds = Math.round(Math.max(endMs - startMs, 0) / 1000); + + let diffStats = { commits: 0, linesAdded: 0, linesRemoved: 0, filesTouched: 0 }; + if (session.branch !== 'unknown' && session.cwd && session.startSha && isGitRepo(session.cwd)) { + const endSha = getHeadSha(session.cwd); + diffStats = getDiffStats(session.startSha, endSha, session.cwd); + } + + const changed = diffStats.commits !== session.commits || + diffStats.linesAdded !== session.linesAdded || + diffStats.linesRemoved !== session.linesRemoved || + diffStats.filesTouched !== session.filesTouched; + const lastActivityAt = changed ? endedAt : session.lastActivityAt; + const momentum = scoreSession({ ...diffStats, exitCode }, readConfig()); + + return { endedAt, durationSeconds, ...diffStats, momentum, exitCode, lastActivityAt }; +} + +async function refreshIdeSession(session: Session): Promise { + const updates = snapshot(session, -1); + await updateSession(session.id, updates); + return { ...session, ...updates }; +} + +export async function refreshActiveIdeSessions(): Promise { + const active = getActiveIdeSessions(); + for (const session of active) { + await refreshIdeSession(session).catch(() => {}); + } +} + +export async function startIdeSession(toolName = 'cursor'): Promise { + const tool = normalizeTool(toolName); + await reapOrphanedSessions(); + + const cwd = process.cwd(); + const existing = getActiveIdeSessions(tool).find((s) => s.cwd === cwd); + if (existing) { + await refreshIdeSession(existing).catch(() => {}); + console.log(`\n ${PURPLE('◆')} already tracking ${tool} in ${existing.project}\n`); + console.log(` stop it with: vibe ide stop ${tool}\n`); + return; + } + + const hasGit = isGitRepo(cwd); + const startedAt = new Date().toISOString(); + const base: Session = { + id: randomUUID(), + tool, + project: hasGit ? getProjectName(cwd) : cwd.split('/').pop() || 'unknown', + branch: hasGit ? getBranch(cwd) : 'unknown', + startedAt, + endedAt: startedAt, + durationSeconds: 0, + commits: 0, + linesAdded: 0, + linesRemoved: 0, + filesTouched: 0, + momentum: 'idle', + exitCode: -1, + lastActivityAt: startedAt, + sessionKind: 'ide', + cwd, + startSha: hasGit ? getHeadSha(cwd) : '', + }; + + await addSession(base); + console.log(`\n ${PURPLE('◆')} tracking ${tool} in ${base.project}\n`); + console.log(` when you're done: vibe ide stop ${tool}\n`); +} + +export async function stopIdeSession(toolName = 'cursor'): Promise { + const tool = normalizeTool(toolName); + const session = findActiveIdeSession(tool); + if (!session) { + console.log(`\n ${RED('✗')} no active ${tool} IDE session found\n`); + console.log(` start one with: vibe ide start ${tool}\n`); + return; + } + + const final = snapshot(session, 0); + await updateSession(session.id, final); + const completed = { ...session, ...final }; + console.log(renderEndcard(completed)); + await flushPendingSubmissions(1500).catch(() => {}); +} + +export async function renderIdeStatus(): Promise { + const active = getActiveIdeSessions(); + if (active.length === 0) { + return `\n ${PURPLE('◆')} no active IDE sessions\n\n start one with: vibe ide start cursor\n`; + } + + const refreshed: Session[] = []; + for (const session of active) { + refreshed.push(await refreshIdeSession(session)); + } + + return renderStatus(refreshed); +}