/ tools / next-obvious-question
Sign in
tools / next-obvious-question
next-obvious-question / src / extension.ts
154 lines · 5.7 KBBlameHistoryRaw
1import * as path from "path";
2import * as vscode from "vscode";
3import { Backend, nextQuestion } from "./llm";
5const SECRET_KEY = "nextObviousQuestion.openrouterApiKey";
7export 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>();
16 const item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100);
17 item.command = "nextObviousQuestion.menu";
18 context.subscriptions.push(item);
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 };
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;
53 const shown = questions.get(key);
54 const avoid = different && shown ? [...(rejected.get(key) ?? []), shown] : [];
55 if (avoid.length === 0) rejected.delete(key);
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 }
65 const backend = await getBackend(context);
66 if (!backend) return;
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 };
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 };
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();
122export function deactivate() {}
124function 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";
130async 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 };
145async 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;
↑↓ move↵openescclose