import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; // ── Types ────────────────────────────────────────────────────────── type TutorMode = "bilingual" | "english-only"; type Difficulty = "A2" | "B1" | "B2" | "C1"; interface TutorState { mode: TutorMode; strictMode: boolean; difficulty: Difficulty; } // ── Constants ────────────────────────────────────────────────────── const STATE_TYPE = "english-tutor-state"; const DIFFICULTIES: Difficulty[] = ["A2", "B1", "B2", "C1"]; const DEFAULT_STATE: TutorState = { mode: "bilingual", strictMode: false, difficulty: "B1", }; // ── CEFR Guidelines ──────────────────────────────────────────────── const CEFR_GUIDELINES: Record = { A2: `- Use simple sentences (8–15 words) - Stick to common vocabulary (top 1000–1500 words) - Use only basic tenses: present simple, past simple, present continuous - Avoid idioms, phrasal verbs, passive voice, complex clauses - Define any word above A2 level in simple English`, B1: `- Use moderate sentences (12–20 words) with connectors (because, however, although) - Use everyday vocabulary with some topic-specific terms - Add present perfect, type-1 conditionals, and basic modals (should, might) - Introduce 1–2 common phrasal verbs at a time - Explain less common vocabulary`, B2: `- Use natural conversational English with varied sentence length - Broader vocabulary including professional and academic terms - Full grammar range: all conditionals, passive voice, relative clauses - Use phrasal verbs and common idioms naturally - Express opinions, agreement/disagreement, and hypotheticals`, C1: `- Use native-like fluency with sophisticated vocabulary - Complex sentence structures with subtle meaning distinctions - Idiomatic expressions, humor, wordplay, and cultural references - Academic, professional, and literary language as appropriate - Fine shades of meaning: "reluctant" vs "hesitant" vs "unwilling"`, }; // ── Helpers ──────────────────────────────────────────────────────── function hasChinese(text: string): boolean { return /[\u4e00-\u9fff\u3400-\u4dbf]/.test(text); } function extractText(content: unknown): string { if (typeof content === "string") return content; if (Array.isArray(content)) { return (content as Array<{ type: string; text?: string }>) .filter((c) => c.type === "text" && c.text) .map((c) => c.text!) .join("\n"); } return ""; } function loadState(ctx: ExtensionContext): TutorState { for (const entry of ctx.sessionManager.getEntries()) { if (entry.type === "custom" && entry.customType === STATE_TYPE) { const data = entry.data as Partial | undefined; if (data) return { ...DEFAULT_STATE, ...data }; } } return { ...DEFAULT_STATE }; } function buildPrompt(mode: TutorMode, difficulty: Difficulty): string { const cefr = CEFR_GUIDELINES[difficulty]; if (mode === "bilingual") { return ` ## 🇬🇧🇨🇳 English Tutor — BILINGUAL MODE (active until mode changes) You are an English conversation tutor. Follow these rules STRICTLY for EVERY response. ### MAIN RESPONSE FORMAT Every response MUST include BOTH languages in clearly separated sections: Your natural English response here (conversational, friendly, at CEFR ${difficulty}). --- 你的中文翻译(自然的中文,帮助用户理解英文部分) ### DIFFICULTY: ${difficulty} ${cefr} ### CORRECTIONS (when user makes an error) Add after your main response, ONLY for significant errors: 💡 "user's phrase" → "corrected phrase" — brief reason in Chinese ### VOCABULARY (every 3–4 exchanges, suggest 1 word) 📝 "word" (中文释义) — example sentence showing natural usage ### CRITICAL RULES - ALWAYS provide BOTH languages for the main response - NEVER skip the Chinese translation in bilingual mode - Be encouraging and supportive — build confidence, not anxiety - If the user writes in Chinese, gently encourage: "Try saying this in English! I can help you find the right words." - Keep responses concise: 3–8 sentences per language `.trim(); } return ` ## 🇬🇧 English Tutor — ENGLISH-ONLY MODE (active until mode changes) You are an English conversation tutor. Follow these rules STRICTLY for EVERY response. ### RESPONSE RULES - Respond ONLY in English — NO Chinese, not even a single character - Use natural, conversational English at CEFR ${difficulty} ### DIFFICULTY: ${difficulty} ${cefr} ### CORRECTIONS (when user makes an error) Add after your main response, ONLY for significant errors: 💡 "user's phrase" → "corrected phrase" — brief reason in simple English ### VOCABULARY (every 3–4 exchanges, suggest 1 word) 📝 "word" — simple English definition + example sentence ### CRITICAL RULES - NEVER use Chinese — not for definitions, not for explanations - If the user writes in Chinese, gently remind in English: "Please try in English! If you're stuck, tell me what you want to say and I'll help you express it." - Be encouraging and supportive - Keep responses concise: 3–8 sentences `.trim(); } // ── Extension Entry ──────────────────────────────────────────────── export default function (pi: ExtensionAPI) { let state: TutorState = { ...DEFAULT_STATE }; // ── Persist state helper ── function save() { pi.appendEntry(STATE_TYPE, state); } // ── UI helpers ── function updateStatus(ctx: ExtensionContext) { const modeLabel = state.mode === "bilingual" ? "🇬🇧🇨🇳 双语" : "🇬🇧 纯英"; const strictIcon = state.strictMode ? "🔒" : "🔓"; ctx.ui.setStatus("english-tutor", `${strictIcon} ${modeLabel} | ${state.difficulty}`); } function updateWidget(ctx: ExtensionContext) { const modeLabel = state.mode === "bilingual" ? "中英双语" : "纯英文"; const strictLabel = state.strictMode ? "严格" : "宽松"; ctx.ui.setWidget("english-tutor", [ `🇬🇧 English Tutor | ${modeLabel} | ${state.difficulty} | ${strictLabel}`, `Ctrl+Shift+E 切换模式 | Ctrl+Shift+S 严格切换 | /english help`, ], { placement: "aboveEditor" }); } // ── Session Start ──────────────────────────────────────────────── pi.on("session_start", async (_event, ctx) => { state = loadState(ctx); updateStatus(ctx); updateWidget(ctx); ctx.ui.notify( `🇬🇧 English Tutor loaded — ${state.mode} | ${state.difficulty} | ${state.strictMode ? "strict" : "gentle"}`, "info", ); }); // ── Input Guard ────────────────────────────────────────────────── pi.on("input", async (event, ctx) => { // Skip if no Chinese characters if (!hasChinese(event.text)) return { action: "continue" }; // Skip if this is a slash command (starts with /) if (event.text.trim().startsWith("/")) return { action: "continue" }; if (state.strictMode) { const words = event.text.match(/[\u4e00-\u9fff]+/g)?.slice(0, 3).join(", ") ?? ""; ctx.ui.notify( `🚫 Strict mode: please use English! (detected: ${words}...) Use /english strict off to allow Chinese`, "error", ); return { action: "handled" }; } // Gentle mode: remind but allow ctx.ui.notify( "💡 Try English! Not sure how? Just ask: 'How do I say... in English?'", "warning", ); return { action: "continue" }; }); // ── System Prompt Injection ────────────────────────────────────── pi.on("before_agent_start", async (event, _ctx) => { return { systemPrompt: event.systemPrompt + "\n\n" + buildPrompt(state.mode, state.difficulty), }; }); // ── /english Command ───────────────────────────────────────────── pi.registerCommand("english", { description: "English tutor controls: mode, strict, difficulty, stats, export", handler: async (args, ctx) => { const parts = (args ?? "").trim().split(/\s+/); const sub = parts[0]?.toLowerCase() ?? ""; const val = parts.slice(1).join(" "); const showHelp = () => { ctx.ui.notify( [ "/english mode — switch response mode", "/english strict — toggle strict input", "/english difficulty — set CEFR level", "/english stats — show current settings", "/english export — export study report", ].join("\n"), "info", ); }; switch (sub) { // ── Mode ── case "mode": { if (val === "bilingual" || val === "english-only" || val === "en") { const normalized = val === "en" ? "english-only" : val as TutorMode; state.mode = normalized; save(); updateStatus(ctx); updateWidget(ctx); const label = normalized === "bilingual" ? "🇬🇧🇨🇳 Bilingual (中英双语)" : "🇬🇧 English Only (纯英文)"; ctx.ui.notify(`✅ Mode switched to: ${label}`, "info"); } else { ctx.ui.notify("Usage: /english mode bilingual | english-only", "warning"); } break; } // ── Strict ── case "strict": { if (val === "on") { state.strictMode = true; save(); updateStatus(ctx); updateWidget(ctx); ctx.ui.notify("🔒 Strict mode ON — Chinese input will be BLOCKED", "info"); } else if (val === "off") { state.strictMode = false; save(); updateStatus(ctx); updateWidget(ctx); ctx.ui.notify("🔓 Strict mode OFF — Chinese input allowed with reminder", "info"); } else { ctx.ui.notify("Usage: /english strict on | off", "warning"); } break; } // ── Difficulty ── case "difficulty": case "level": { const upper = val.toUpperCase(); if (DIFFICULTIES.includes(upper as Difficulty)) { state.difficulty = upper as Difficulty; save(); updateStatus(ctx); updateWidget(ctx); ctx.ui.notify(`📚 Difficulty set to: ${state.difficulty}`, "info"); } else { ctx.ui.notify("Usage: /english difficulty A2 | B1 | B2 | C1", "warning"); } break; } // ── Stats ── case "stats": case "status": { const modeLabel = state.mode === "bilingual" ? "Bilingual (中英双语)" : "English Only (纯英文)"; const lines = [ `Mode: ${modeLabel}`, `Difficulty: ${state.difficulty}`, `Strict: ${state.strictMode ? "ON (Chinese blocked)" : "OFF (Chinese allowed)"}`, `Shortcuts: Ctrl+Shift+E (mode), Ctrl+Shift+S (strict)`, ]; ctx.ui.notify(lines.join("\n"), "info"); break; } // ── Export ── case "export": { const entries = ctx.sessionManager.getEntries(); const messages: string[] = []; let userCount = 0; let aiCount = 0; for (const entry of entries) { if (entry.type !== "message") continue; const { role, content } = entry.message; const text = extractText(content); if (!text) continue; if (role === "user") { messages.push(`### 👤 You\n\n${text}\n`); userCount++; } else if (role === "assistant") { messages.push(`### 🤖 AI Tutor\n\n${text}\n`); aiCount++; } } const date = new Date().toISOString().split("T")[0]; const modeLabel = state.mode === "bilingual" ? "Bilingual (中英双语)" : "English Only (纯英文)"; const report = [ `# 📚 English Study Report — ${date}`, "", "## Session Info", `- **Mode:** ${modeLabel}`, `- **Difficulty:** ${state.difficulty}`, `- **Your messages:** ${userCount}`, `- **AI responses:** ${aiCount}`, "", "---", "", "## Conversation", "", ...messages, "", "---", "", "*Generated by pi-english-tutor • Happy learning! 🎉*", ].join("\n"); const exportPath = join(ctx.cwd, `english-study-${date}.md`); await writeFile(exportPath, report, "utf-8"); ctx.ui.notify(`📄 Report exported: ${exportPath}`, "info"); break; } // ── Help / unknown ── default: showHelp(); break; } }, }); // ── Keyboard Shortcuts ─────────────────────────────────────────── pi.registerShortcut("ctrl+shift+e", { description: "Toggle English tutor mode (bilingual / english-only)", handler: async (ctx) => { state.mode = state.mode === "bilingual" ? "english-only" : "bilingual"; save(); updateStatus(ctx); updateWidget(ctx); const label = state.mode === "bilingual" ? "🇬🇧🇨🇳 Bilingual" : "🇬🇧 English Only"; ctx.ui.notify(`🔄 ${label}`, "info"); }, }); pi.registerShortcut("ctrl+shift+s", { description: "Toggle strict input mode", handler: async (ctx) => { state.strictMode = !state.strictMode; save(); updateStatus(ctx); updateWidget(ctx); ctx.ui.notify(`🔒 Strict mode: ${state.strictMode ? "ON" : "OFF"}`, "info"); }, }); }