/ tools / next-obvious-question
Sign in
tools / next-obvious-question
VS Code extension that shows the next obvious question a reader would ask
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 169170039612 Browse files
9 changed files+449−0
.gitignoreadded+3−0View file
@@ -0,0 +1,3 @@
1+node_modules
2+dist
3+*.vsix
.vscodeignoreadded+5−0View file
@@ -0,0 +1,5 @@
1+**
2+!dist/**
3+!package.json
4+!README.md
5+!LICENSE
LICENSEadded+21−0View file
@@ -0,0 +1,21 @@
1+MIT License
2+
3+Copyright (c) 2026 Jeremy Magland
4+
5+Permission is hereby granted, free of charge, to any person obtaining a copy
6+of this software and associated documentation files (the "Software"), to deal
7+in the Software without restriction, including without limitation the rights
8+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+copies of the Software, and to permit persons to whom the Software is
10+furnished to do so, subject to the following conditions:
11+
12+The above copyright notice and this permission notice shall be included in all
13+copies or substantial portions of the Software.
14+
15+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+SOFTWARE.
README.mdadded+31−0View file
@@ -0,0 +1,31 @@
1+# Next Obvious Question
2+
3+A VS Code extension that shows, in the status bar, the next obvious question a reader would ask about the Markdown (`.md`) or LaTeX (`.tex`) document you are editing. The question is meant to be simple: the most basic thing a reader would want to know next.
4+
5+## Use
6+
7+Click the status bar item (or run "Next Obvious Question: Ask the Next Obvious Question") to ask. The question is only updated when you ask. An empty document gives "What is this?" without calling a model.
8+
9+Once a question is showing, clicking the status bar opens a menu:
10+
11+- "Different question" (also the command "Ask a Different Question") asks for another question, excluding every question rejected this way since the last edit or fresh ask.
12+- "Ask again" starts over with no exclusions.
13+
14+Editing the document clears the list of rejected questions.
15+
16+Placeholders such as TODO, TBD, FIXME or `\todo{...}` are treated as planned content: the model imagines them written as described and asks the next question a reader of that completed draft would have, rather than asking for what the placeholder will provide.
17+
18+## Backends
19+
20+Set `nextObviousQuestion.backend`:
21+
22+- `claudeCode` (default): runs the local Claude Code CLI (`claude -p`) with tools, settings and MCP servers disabled, billed to your Claude subscription. Model: `nextObviousQuestion.claudeCodeModel` (default `sonnet`). If VS Code cannot find `claude`, set `nextObviousQuestion.claudeCodePath`.
23+- `openrouter`: calls the OpenRouter API. Model: `nextObviousQuestion.openrouterModel` (default `anthropic/claude-sonnet-5.5`). The key is asked for on first use and kept in VS Code's secret storage; `OPENROUTER_API_KEY` in the environment also works. Change it with "Next Obvious Question: Set OpenRouter API Key".
24+
25+## Build and install
26+
27+```
28+npm install
29+npm run package
30+code --install-extension next-obvious-question-0.0.1.vsix
31+```
package-lock.jsonadded+59−0View file
@@ -0,0 +1,59 @@
1+{
2+ "name": "next-obvious-question",
3+ "version": "0.0.1",
4+ "lockfileVersion": 3,
5+ "requires": true,
6+ "packages": {
7+ "": {
8+ "name": "next-obvious-question",
9+ "version": "0.0.1",
10+ "license": "MIT",
11+ "devDependencies": {
12+ "@types/node": "^20.19.43",
13+ "@types/vscode": "~1.97.0",
14+ "typescript": "^5.9.0"
15+ },
16+ "engines": {
17+ "vscode": "^1.97.0"
18+ }
19+ },
20+ "node_modules/@types/node": {
21+ "version": "20.19.43",
22+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
23+ "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
24+ "dev": true,
25+ "license": "MIT",
26+ "dependencies": {
27+ "undici-types": "~6.21.0"
28+ }
29+ },
30+ "node_modules/@types/vscode": {
31+ "version": "1.97.0",
32+ "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.97.0.tgz",
33+ "integrity": "sha512-ueE73loeOTe7olaVyqP9mrRI54kVPJifUPjblZo9fYcv1CuVLPOEKEkqW0GkqPC454+nCEoigLWnC2Pp7prZ9w==",
34+ "dev": true,
35+ "license": "MIT"
36+ },
37+ "node_modules/typescript": {
38+ "version": "5.9.3",
39+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
40+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
41+ "dev": true,
42+ "license": "Apache-2.0",
43+ "bin": {
44+ "tsc": "bin/tsc",
45+ "tsserver": "bin/tsserver"
46+ },
47+ "engines": {
48+ "node": ">=14.17"
49+ }
50+ },
51+ "node_modules/undici-types": {
52+ "version": "6.21.0",
53+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
54+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
55+ "dev": true,
56+ "license": "MIT"
57+ }
58+ }
59+}
package.jsonadded+61−0View file
@@ -0,0 +1,61 @@
1+{
2+ "name": "next-obvious-question",
3+ "displayName": "Next Obvious Question",
4+ "description": "Shows the next obvious question a reader would ask about the Markdown or LaTeX document you are writing.",
5+ "version": "0.0.1",
6+ "publisher": "magland",
7+ "license": "MIT",
8+ "private": true,
9+ "engines": { "vscode": "^1.97.0" },
10+ "categories": ["Other"],
11+ "main": "./dist/extension.js",
12+ "activationEvents": ["onLanguage:markdown", "onLanguage:latex", "onLanguage:tex"],
13+ "contributes": {
14+ "commands": [
15+ { "command": "nextObviousQuestion.ask", "title": "Ask the Next Obvious Question", "category": "Next Obvious Question" },
16+ { "command": "nextObviousQuestion.askDifferent", "title": "Ask a Different Question", "category": "Next Obvious Question" },
17+ { "command": "nextObviousQuestion.menu", "title": "Question Menu", "category": "Next Obvious Question" },
18+ { "command": "nextObviousQuestion.setOpenRouterKey", "title": "Set OpenRouter API Key", "category": "Next Obvious Question" }
19+ ],
20+ "menus": {
21+ "commandPalette": [{ "command": "nextObviousQuestion.menu", "when": "false" }]
22+ },
23+ "configuration": {
24+ "title": "Next Obvious Question",
25+ "properties": {
26+ "nextObviousQuestion.backend": {
27+ "type": "string",
28+ "enum": ["claudeCode", "openrouter"],
29+ "enumDescriptions": ["Run the local Claude Code CLI (`claude -p`), billed to your Claude subscription.", "Call the OpenRouter API with your OpenRouter key."],
30+ "default": "claudeCode",
31+ "description": "Which LLM backend to use."
32+ },
33+ "nextObviousQuestion.claudeCodeModel": {
34+ "type": "string",
35+ "default": "sonnet",
36+ "description": "Model passed to `claude --model` (an alias such as sonnet or haiku, or a full model id)."
37+ },
38+ "nextObviousQuestion.claudeCodePath": {
39+ "type": "string",
40+ "default": "claude",
41+ "description": "Path to the Claude Code CLI, if `claude` is not on the PATH that VS Code sees."
42+ },
43+ "nextObviousQuestion.openrouterModel": {
44+ "type": "string",
45+ "default": "anthropic/claude-sonnet-5.5",
46+ "description": "OpenRouter model id."
47+ }
48+ }
49+ }
50+ },
51+ "scripts": {
52+ "build": "tsc -p .",
53+ "watch": "tsc -p . --watch",
54+ "package": "npm run build && npx @vscode/vsce package --no-dependencies"
55+ },
56+ "devDependencies": {
57+ "@types/node": "^20.19.43",
58+ "@types/vscode": "~1.97.0",
59+ "typescript": "^5.9.0"
60+ }
61+}
src/extension.tsadded+154−0View file
@@ -0,0 +1,154 @@
1+import * as path from "path";
2+import * as vscode from "vscode";
3+import { Backend, nextQuestion } from "./llm";
4+
5+const SECRET_KEY = "nextObviousQuestion.openrouterApiKey";
6+
7+export function activate(context: vscode.ExtensionContext) {
8+ // Last question per document, so switching files keeps each one's question.
9+ const questions = new Map<string, string>();
10+ // Document version each question was asked for, to tell whether it has been edited since.
11+ const askedAt = new Map<string, number>();
12+ // Questions rejected with "Different question" since the last fresh ask or edit.
13+ const rejected = new Map<string, string[]>();
14+ const pending = new Set<string>();
15+
16+ const item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100);
17+ item.command = "nextObviousQuestion.menu";
18+ context.subscriptions.push(item);
19+
20+ const render = () => {
21+ const doc = vscode.window.activeTextEditor?.document;
22+ if (!doc || !isSupported(doc)) {
23+ item.hide();
24+ return;
25+ }
26+ const key = doc.uri.toString();
27+ const q = questions.get(key);
28+ if (pending.has(key)) {
29+ item.text = "$(sync~spin) Thinking of the next question…";
30+ item.tooltip = undefined;
31+ } else if (q) {
32+ item.text = `$(question) ${q}`;
33+ item.tooltip =
34+ askedAt.get(key) === doc.version
35+ ? `${q}\n\nClick to ask again or get a different question.`
36+ : `${q}\n\nThe document has changed. Click for a new question.`;
37+ } else {
38+ item.text = "$(question) Ask next question";
39+ item.tooltip = "Ask what a reader would want to know next.";
40+ }
41+ item.show();
42+ };
43+
44+ const ask = async (different: boolean) => {
45+ const doc = vscode.window.activeTextEditor?.document;
46+ if (!doc || !isSupported(doc)) {
47+ vscode.window.showInformationMessage("Open a Markdown (.md) or LaTeX (.tex) file first.");
48+ return;
49+ }
50+ const key = doc.uri.toString();
51+ if (pending.has(key)) return;
52+
53+ const shown = questions.get(key);
54+ const avoid = different && shown ? [...(rejected.get(key) ?? []), shown] : [];
55+ if (avoid.length === 0) rejected.delete(key);
56+
57+ const text = doc.getText();
58+ if (text.trim() === "" && avoid.length === 0) {
59+ questions.set(key, "What is this?");
60+ askedAt.set(key, doc.version);
61+ render();
62+ return;
63+ }
64+
65+ const backend = await getBackend(context);
66+ if (!backend) return;
67+
68+ const version = doc.version;
69+ pending.add(key);
70+ render();
71+ try {
72+ const q = await nextQuestion(backend, path.basename(doc.fileName), text, avoid);
73+ if (q) {
74+ questions.set(key, q);
75+ askedAt.set(key, version);
76+ // An edit while waiting starts a new cycle, so only keep the list if the text is unchanged.
77+ if (avoid.length > 0 && doc.version === version) rejected.set(key, avoid);
78+ }
79+ } catch (err) {
80+ vscode.window.showErrorMessage(`Next Obvious Question: ${(err as Error).message}`);
81+ } finally {
82+ pending.delete(key);
83+ render();
84+ }
85+ };
86+
87+ const menu = async () => {
88+ const doc = vscode.window.activeTextEditor?.document;
89+ if (!doc || pending.has(doc.uri.toString())) return;
90+ // No question yet, or the document was edited since: just ask, no menu.
91+ if (askedAt.get(doc.uri.toString()) !== doc.version) return ask(false);
92+ const choice = await vscode.window.showQuickPick(
93+ [
94+ { label: "$(arrow-swap) Different question", different: true },
95+ { label: "$(refresh) Ask again", description: "start over", different: false },
96+ ],
97+ { placeHolder: questions.get(doc.uri.toString()) },
98+ );
99+ if (choice) await ask(choice.different);
100+ };
101+
102+ context.subscriptions.push(
103+ vscode.commands.registerCommand("nextObviousQuestion.ask", () => ask(false)),
104+ vscode.commands.registerCommand("nextObviousQuestion.askDifferent", () => ask(true)),
105+ vscode.commands.registerCommand("nextObviousQuestion.menu", menu),
106+ vscode.commands.registerCommand("nextObviousQuestion.setOpenRouterKey", () => promptForKey(context)),
107+ vscode.window.onDidChangeActiveTextEditor(render),
108+ vscode.workspace.onDidChangeTextDocument((e) => {
109+ if (e.contentChanges.length === 0) return;
110+ rejected.delete(e.document.uri.toString());
111+ if (e.document === vscode.window.activeTextEditor?.document) render();
112+ }),
113+ vscode.workspace.onDidCloseTextDocument((doc) => {
114+ questions.delete(doc.uri.toString());
115+ askedAt.delete(doc.uri.toString());
116+ rejected.delete(doc.uri.toString());
117+ }),
118+ );
119+ render();
120+}
121+
122+export function deactivate() {}
123+
124+function isSupported(doc: vscode.TextDocument): boolean {
125+ if (["markdown", "latex", "tex"].includes(doc.languageId)) return true;
126+ const ext = path.extname(doc.fileName).toLowerCase();
127+ return ext === ".md" || ext === ".tex";
128+}
129+
130+async function getBackend(context: vscode.ExtensionContext): Promise<Backend | undefined> {
131+ const config = vscode.workspace.getConfiguration("nextObviousQuestion");
132+ if (config.get<string>("backend") === "openrouter") {
133+ const apiKey =
134+ (await context.secrets.get(SECRET_KEY)) || process.env.OPENROUTER_API_KEY || (await promptForKey(context));
135+ if (!apiKey) return undefined;
136+ return { kind: "openrouter", apiKey, model: config.get<string>("openrouterModel") || "anthropic/claude-sonnet-5.5" };
137+ }
138+ return {
139+ kind: "claudeCode",
140+ path: config.get<string>("claudeCodePath") || "claude",
141+ model: config.get<string>("claudeCodeModel") || "sonnet",
142+ };
143+}
144+
145+async function promptForKey(context: vscode.ExtensionContext): Promise<string | undefined> {
146+ const key = await vscode.window.showInputBox({
147+ title: "OpenRouter API key",
148+ prompt: "Stored in VS Code's secret storage.",
149+ password: true,
150+ ignoreFocusOut: true,
151+ });
152+ if (key) await context.secrets.store(SECRET_KEY, key.trim());
153+ return key?.trim() || undefined;
154+}
src/llm.tsadded+101−0View file
@@ -0,0 +1,101 @@
1+import { spawn } from "child_process";
2+import * as os from "os";
3+
4+const SYSTEM_PROMPT = `You read a draft document as a reader would. Reply with the single next obvious question that a reader would ask after reading it: the most basic thing they would want to know next that the document does not yet answer.
5+
6+Keep the question short and plain, ideally under ten words. Ask exactly one thing: never join two questions with "and" or "or". Do not ask about details when a more basic question is still open. If the document is empty or nearly empty, ask something like "What is this?".
7+
8+The writer may leave placeholders for planned content, such as TODO, TBD, FIXME or \\todo{...}. Treat each one as already written: imagine it filled in with what it describes (or, for a bare TODO, with what a reader would expect at that spot), and ask the next question a reader of that completed draft would have. Do not ask for what a placeholder will provide. A question may build on what a placeholder will contain, when that is what the reader would most want to know next.
9+
10+Reply with the question only, with no preamble, quotes or explanation.`;
11+
12+export type Backend =
13+ | { kind: "claudeCode"; path: string; model: string }
14+ | { kind: "openrouter"; apiKey: string; model: string };
15+
16+export async function nextQuestion(
17+ backend: Backend,
18+ fileName: string,
19+ text: string,
20+ avoid: string[] = [],
21+): Promise<string> {
22+ let userPrompt = `Document (${fileName}):\n\n<document>\n${text}\n</document>`;
23+ if (avoid.length > 0) {
24+ userPrompt +=
25+ `\n\nThe reader has already been shown these questions and wants a different one. ` +
26+ `Ask the next most obvious question that is not one of these or a rewording of one:\n` +
27+ avoid.map((q) => `- ${q}`).join("\n");
28+ }
29+ const raw =
30+ backend.kind === "claudeCode"
31+ ? await runClaudeCode(backend.path, backend.model, userPrompt)
32+ : await runOpenRouter(backend.apiKey, backend.model, userPrompt);
33+ return cleanup(raw);
34+}
35+
36+function cleanup(raw: string): string {
37+ // Keep the first non-empty line and drop surrounding quotes.
38+ const line = raw.split("\n").map((s) => s.trim()).find((s) => s.length > 0) ?? "";
39+ return line.replace(/^["'“]+|["'”]+$/g, "").trim();
40+}
41+
42+function runClaudeCode(path: string, model: string, prompt: string): Promise<string> {
43+ // No tools, settings, MCP servers or skills, and no saved session: just one model call.
44+ const args = [
45+ "-p",
46+ "--model", model,
47+ "--system-prompt", SYSTEM_PROMPT,
48+ "--tools", "",
49+ "--setting-sources", "",
50+ "--strict-mcp-config",
51+ "--disable-slash-commands",
52+ "--no-session-persistence",
53+ ];
54+ return new Promise((resolve, reject) => {
55+ // Run outside any workspace so no project CLAUDE.md is picked up.
56+ const proc = spawn(path, args, { cwd: os.tmpdir() });
57+ let stdout = "";
58+ let stderr = "";
59+ const timer = setTimeout(() => {
60+ proc.kill();
61+ reject(new Error("Claude Code timed out after 60 s"));
62+ }, 60_000);
63+ proc.stdout.on("data", (d) => (stdout += d));
64+ proc.stderr.on("data", (d) => (stderr += d));
65+ proc.on("error", (err) => {
66+ clearTimeout(timer);
67+ reject(new Error(`Could not run "${path}": ${err.message}. Set nextObviousQuestion.claudeCodePath.`));
68+ });
69+ proc.on("close", (code) => {
70+ clearTimeout(timer);
71+ if (code === 0) resolve(stdout);
72+ else reject(new Error(`Claude Code exited with code ${code}: ${(stderr || stdout).trim()}`));
73+ });
74+ proc.stdin.end(prompt);
75+ });
76+}
77+
78+async function runOpenRouter(apiKey: string, model: string, prompt: string): Promise<string> {
79+ const resp = await fetch("https://openrouter.ai/api/v1/chat/completions", {
80+ method: "POST",
81+ headers: {
82+ Authorization: `Bearer ${apiKey}`,
83+ "Content-Type": "application/json",
84+ "X-Title": "Next Obvious Question",
85+ },
86+ body: JSON.stringify({
87+ model,
88+ max_tokens: 100,
89+ messages: [
90+ { role: "system", content: SYSTEM_PROMPT },
91+ { role: "user", content: prompt },
92+ ],
93+ }),
94+ signal: AbortSignal.timeout(60_000),
95+ });
96+ if (!resp.ok) {
97+ throw new Error(`OpenRouter returned ${resp.status}: ${(await resp.text()).slice(0, 300)}`);
98+ }
99+ const data = (await resp.json()) as { choices?: { message?: { content?: string } }[] };
100+ return data.choices?.[0]?.message?.content ?? "";
101+}
tsconfig.jsonadded+14−0View file
@@ -0,0 +1,14 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "lib": ["ES2022"],
5+ "module": "commonjs",
6+ "outDir": "dist",
7+ "rootDir": "src",
8+ "strict": true,
9+ "skipLibCheck": true,
10+ "noUnusedLocals": true,
11+ "types": ["vscode", "node"]
12+ },
13+ "include": ["src"]
14+}
↑↓ move↵openescclose