Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
"sessions",
"claude",
"codex",
"gemini"
"gemini",
"cursor",
"ide"
],
"author": "iamnotstatic",
"license": "MIT",
Expand Down
29 changes: 29 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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));
Expand All @@ -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) {
Expand Down Expand Up @@ -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');
Expand Down
4 changes: 4 additions & 0 deletions src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export interface Session {
exitCode: number;
lastActivityAt?: string;
submittedAt?: string;
sessionKind?: 'cli' | 'ide';
cwd?: string;
startSha?: string;
}

interface DbSchema {
Expand Down Expand Up @@ -149,6 +152,7 @@ export async function reapOrphanedSessions(): Promise<void> {

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;
Expand Down
140 changes: 140 additions & 0 deletions src/ide.ts
Original file line number Diff line number Diff line change
@@ -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<Session, 'endedAt' | 'durationSeconds' | 'commits' | 'linesAdded' | 'linesRemoved' | 'filesTouched' | 'momentum' | 'exitCode' | 'lastActivityAt'>;

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<Session> {
const updates = snapshot(session, -1);
await updateSession(session.id, updates);
return { ...session, ...updates };
}

export async function refreshActiveIdeSessions(): Promise<void> {
const active = getActiveIdeSessions();
for (const session of active) {
await refreshIdeSession(session).catch(() => {});
}
}

export async function startIdeSession(toolName = 'cursor'): Promise<void> {
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<void> {
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<string> {
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);
}