quizbase
Skip to content
Command Palette
Search for a command to run...
QuizBase · Docs
by Maciej Dzierżek · published May 15, 2026 · 35 min read · Beginner
cursor-pomodoro-quiz hero illustration
Illustration for: Cursor side-panel pomodoro with QuizBase trivia breaks · Generated with Nano Banana, brand style

Cursor side-panel pomodoro with QuizBase trivia breaks#

Pomodoro is 25 minutes of deep focus followed by 5 minutes of context-switch rest. The rest period is where most people fall back into the same code or Slack — defeating the purpose. This guide builds a Cursor side-panel extension that enforces the break by serving one QuizBase trivia question per 5-minute window. You can’t keep coding (the focus session ended), and now the panel is showing a question that engages a different cognitive lane.

Cursor extensions are technically VS Code extensions (Cursor is a VS Code fork) — anything that runs in VS Code 1.85+ runs in Cursor. The webview API gives us a side panel where we render HTML/JS, and the extension activates qb.startPomodoro from the command palette.

What you’ll build#

A Cursor extension contributing a side-panel webview with:

  • Start button triggers a 25-minute focus countdown (silent, just a tray badge)
  • At minute 25 the panel fetches one QuizBase question and renders it with four answer buttons
  • 5-minute break window — pick your answer, see correct + attribution, optionally tap “another”
  • Streak tracking persisted in extension state — survives editor restarts
  • Stable ID memo — last 50 question IDs are tracked client-side so back-to-back breaks never repeat (uses the same dedup pattern as the MCP Slack trivia bot)

The mechanic — seenIds for break dedup#

The streak only works if breaks present fresh questions. We keep seenIds: Set<string> in extension globalState; every fetch checks the set, refetches on collision (up to 3 retries), then adds the new ID. The Set is capped at 50 entries (FIFO) so we don’t grow unboundedly — after 50 breaks, oldest IDs roll out and become eligible again.

const SEEN_LIMIT = 50;

async function fetchUniqueQuestion(seenIds: string[], retries = 3) {
	const seen = new Set(seenIds);
	for (let i = 0; i < retries; i++) {
		const q = await fetchRandomQuestion();
		if (!seen.has(q.id)) {
			seen.add(q.id);
			// Trim FIFO if oversized
			const trimmed = [...seen].slice(-SEEN_LIMIT);
			return { q, seenIds: trimmed };
		}
	}
	throw new Error('Could not find an unseen question in 3 retries');
}

Same pattern documented at /docs/api/questions-by-id — stable UUIDs make client-side dedup trivial without backend state. The MCP Slack bot uses per-channel Map<channelId, Set<questionId>>; the Cursor extension uses a single workspace-wide Set.

Stack#

  • Cursor 0.x or VS Code 1.85+ — both consume the same extension API
  • @types/vscode — VS Code typings
  • yo + generator-code — Microsoft’s extension scaffolder (npm install -g yo generator-code)
  • QuizBase publishable keyqb_pk_*, free tier covers ~125 breaks/day per user (well above pomodoro cadence)

Step 1 — Get a QuizBase key and scaffold the extension#

Create a qb_pk_* key. The publishable kind is fine — the extension runs locally, key never leaves the user’s machine, and the free tier doesn’t bill on usage.

Scaffold the extension:

yo code
# Choose: New Extension (TypeScript)
# Name: cursor-quiz-pomodoro
# Identifier: cursor-quiz-pomodoro
# Bundler: webpack (default)

You get src/extension.ts with an activate() function and a package.json manifest. The webview panel goes here.

Step 2 — Wire the webview side panel#

In package.json contributes:

{
	"viewsContainers": {
		"activitybar": [
			{
				"id": "cursor-quiz-pomodoro",
				"title": "Pomodoro Quiz",
				"icon": "$(watch)"
			}
		]
	},
	"views": {
		"cursor-quiz-pomodoro": [
			{
				"id": "cursor-quiz-pomodoro.panel",
				"name": "Focus + Trivia",
				"type": "webview"
			}
		]
	},
	"commands": [{ "command": "qb.startPomodoro", "title": "Start pomodoro" }]
}

In src/extension.ts, register a WebviewViewProvider:

// src/extension.ts (excerpt)
import * as vscode from 'vscode';

export function activate(context: vscode.ExtensionContext) {
	const provider = new QuizPomodoroProvider(context);
	context.subscriptions.push(
		vscode.window.registerWebviewViewProvider('cursor-quiz-pomodoro.panel', provider)
	);
}

class QuizPomodoroProvider implements vscode.WebviewViewProvider {
	constructor(private context: vscode.ExtensionContext) {}

	resolveWebviewView(
		webviewView: vscode.WebviewView,
		context: vscode.WebviewViewResolveContext<unknown>,
		token: vscode.CancellationToken
	): void | Thenable<void> {
		webviewView.webview.options = { enableScripts: true };
		webviewView.webview.html = this.getHtml();
		webviewView.webview.onDidReceiveMessage((msg) => this.handleMessage(webviewView, msg));
	}

	private getHtml() {
		return `<!doctype html><html><body>
			<button id="start">Start 25-min focus</button>
			<div id="timer"></div>
			<div id="question"></div>
			<script>
				const vscode = acquireVsCodeApi();
				document.getElementById('start').onclick = () => vscode.postMessage({ cmd: 'start' });
				window.addEventListener('message', (e) => {
					const m = e.data;
					if (m.cmd === 'tick') document.getElementById('timer').innerText = m.remaining;
					if (m.cmd === 'show') renderQuestion(m.question);
				});
				function renderQuestion(q) { /* render Q + 4 buttons + attribution */ }
			</script>
		</body></html>`;
	}
}

Step 3 — Focus countdown + state persistence#

The Provider stores streak + seenIds in context.globalState (persists across restarts):

async function handleMessage(view: vscode.WebviewView, msg: any) {
	if (msg.cmd === 'start') {
		await this.startFocus(view);
	}
}

private async startFocus(view: vscode.WebviewView) {
	const FOCUS_MS = 25 * 60 * 1000;
	const startedAt = Date.now();
	const interval = setInterval(() => {
		const remaining = Math.max(0, FOCUS_MS - (Date.now() - startedAt));
		view.webview.postMessage({ cmd: 'tick', remaining: Math.ceil(remaining / 1000) });
		if (remaining === 0) {
			clearInterval(interval);
			this.startBreak(view);
		}
	}, 1000);
}

Step 4 — Break: fetch unique question and surface attribution#

private async startBreak(view: vscode.WebviewView) {
	const seenIds = this.context.globalState.get<string[]>('seenIds', []);
	try {
		const { q, seenIds: nextSeen } = await fetchUniqueQuestion(seenIds);
		await this.context.globalState.update('seenIds', nextSeen);
		view.webview.postMessage({ cmd: 'show', question: q });
	} catch (e) {
		view.webview.postMessage({ cmd: 'error', message: 'Could not fetch fresh question' });
	}
}

Where fetchRandomQuestion is a plain fetch to QuizBase REST:

async function fetchRandomQuestion() {
	const key = vscode.workspace.getConfiguration('quizPomodoro').get<string>('apiKey');
	const r = await fetch('https://quizbase.runriva.com/api/v1/questions/random', {
		headers: { 'X-API-Key': key! }
	});
	return r.json();
}

User sets the key once via VS Code Settings (quizPomodoro.apiKey). The webview HTML renders q.text, four shuffled choices, and the attribution line:

<small style="opacity: 0.6">Source: ${q.attribution.author} (${q.attribution.license})</small>

The attribution line is required by QuizBase content licenses — see /data for full rules. Don’t ship without it.

Step 5 — Streak tracking + answer feedback#

When the user clicks a choice button, the webview posts back { cmd: 'answer', correct: true | false }. Provider updates the streak:

if (msg.cmd === 'answer') {
	const streak = this.context.globalState.get<number>('streak', 0);
	const newStreak = msg.correct ? streak + 1 : 0;
	await this.context.globalState.update('streak', newStreak);
	view.webview.postMessage({ cmd: 'streakUpdate', streak: newStreak });
}

Persist across editor restarts. Show the streak in the panel header — gamifies the break, encourages users to come back for the next pomodoro.

Pitfalls#

  • Webview state is per-panel — if user closes the panel mid-focus, the countdown evaporates. Persist startedAt to globalState to recover on reopen.
  • API key in workspace settings is visible to teammates in a shared .vscode/settings.json. Use User Settings (quizPomodoro.apiKey in user-level config) instead.
  • vscode.workspace.getConfiguration is sync, no await — easy to forget.
  • Cursor’s Composer side panel takes precedence if user has Composer open in the same view container. Drop your view in a custom container (see Step 2 viewsContainers).

What next#

  • Categories preference — let user filter to specific categories via Settings UI
  • Multi-language mode — surface ?lang= so non-English speakers get questions in their language. See languages and translations for the language matrix
  • Daily streak goal — count breaks per day, set a goal of 5/day, surface streak status in the status bar
  • Pair with Claude.ai Project for daily quizzes — same trivia content, different surface (mobile commute + desktop coding session)

Ready to wire it? Grab a free publishable key, yo code to scaffold, copy the snippets, publish to Open VSX (or just install locally with code --install-extension cursor-quiz-pomodoro-0.1.0.vsix). Bug? POST /api/v1/report accepts question feedback by stable ID.