A single-page, multi-language programming tutor. You chat with your chosen AI provider (Anthropic Claude, OpenAI ChatGPT, or Google Gemini), write code in an inline editor with full IDE features (syntax highlighting, autocomplete, multi-cursor, alt+up/down line moves, Ctrl+F search, Ctrl+S format-on-save, live error checking via local toolchains), run it through a language-appropriate runtime, and submit code+output for evaluation with an optional note about what is confusing you. Lesson progress is extracted by a second LLM call into structured JSON and persisted independently per language.
Switch language at any time from the topbar — each language has its own conversation history, lesson progress, and saved editor content.
One-shot setup that installs all runtimes via winget, fetches dependencies, and opens the app in your browser:
.\scripts\setup.ps1It's idempotent — checks each tool first and only installs what's missing. First run takes 5–10 minutes (downloads); subsequent runs are seconds. After it finishes installing it'll start the dev server and open http://localhost:5173.
After setup, use .\lt.ps1 dev as the root dev-server entrypoint.
Two workspace shapes:
- Single-buffer (Rust / C++ / Python): one editor, one Run button, one output pane. Lessons are short snippets compiled or interpreted in a local Docker sandbox.
- Project workspace (C# / Web): on-disk project under
projects/<lang>/with a sidebar file tree, multi-tab editor, Run / Send controls above the code, integrated supervisor that runsdotnet run/pnpm dev, and an Output / preview pane. Edits autosave; the supervisor streams stdout/stderr into the Output tab.
| Language | Workspace | Lesson focus | Run target | Live errors | Format on save |
|---|---|---|---|---|---|
| Rust | single-buffer | Beginner-to-intermediate fundamentals | local Docker sandbox (rustc) |
local rustc |
local rustfmt |
| C++ | single-buffer | STL-first then modern features (C++20/23) for someone coming from a custom no-STL C++ derivative | local Docker sandbox (clang++ -std=c++23) |
local clang -fsyntax-only |
local clang-format |
| Python | single-buffer | Intermediate-to-advanced for C++/C# devs (idioms, generators, decorators, async, GIL) | local Docker sandbox (python3) |
local python ast.parse |
local black |
| C# | project workspace | Modern C# 12 → WPF fundamentals → MVVM patterns | local dotnet run for the WPF project, plus console snippets in Docker |
dotnet build (streamed into the Build errors tab) | — |
| Web | project workspace | Vanilla HTML/CSS/JS → TS → React → Hono → SQLite | private Vite server, same-origin Preview tab | TS compile via Vite | — |
Live error checking and format-on-save are optional for the single-buffer languages — if a host toolchain isn't installed, those features silently fall back. The Run button uses the local lang-tutor-toolchains:latest Docker image built by setup.
The C# workspace requires the .NET 8+ SDK on PATH (the supervisor preflights and prints an install hint if it's missing or the project's <TargetFramework> is newer than any installed SDK). The Web workspace requires pnpm on PATH.
Each project workspace has an "Open in ▾" launcher in the file-tree header (VS Code · Visual Studio (csharp only) · File Explorer) that delegates to the user's installed editor — handy when the visual XAML designer or a debugger is wanted.
Reset wipes the active language's progress + chat history. For project workspaces it also deletes that user's on-disk language workspace and re-scaffolds it from the template — so a confirmation dialog calls that out explicitly.
The lesson plan for each language is in src/constants.ts. Edit it freely.
Progress, chat history, editor buffers, and UI state are mirrored to
.local/state/local-storage.json through the local backend in development, or to
the signed-in account database in hosted mode. Browser localStorage is still
used as a cache, but provider API keys are explicitly excluded from the server
mirror and account sync.
- TypeScript (strict, including
noUncheckedIndexedAccessandexactOptionalPropertyTypes) - CodeMirror 6 for the editor (syntax highlighting, autocomplete, search, lint, fold gutter, multi-cursor)
- Vite 7 for dev server, HMR, and production builds
- Tailwind CSS 4 via the
@tailwindcss/viteplugin (config-in-CSS) - Docker Desktop for local sandboxed Rust / C++ / Python / C# console runs
- Biome for linting and formatting
- pnpm for package management
- Node 20+ runtime for the production proxy (
server.mjs) and for the/run//check//formattoolchain endpoints
Optional local toolchains (auto-detected; gracefully disabled if missing):
rustc, rustfmt, clang, clang-format, python, black.
- Node 20.6+ (uses
--env-fileflag) - pnpm — install with
npm install -g pnpmif you don't have it - Docker Desktop running Linux containers for local sandboxed code execution
- A provider API key for Anthropic Claude, OpenAI ChatGPT, or Google Gemini
# 1. Install dependencies
pnpm install
# 2. Optional: create runtime config
copy .env.example .envOpen the app, click AI Provider, choose a provider, and paste your API key.
The key stays in the browser on that device; it is not stored in .env, SQLite,
or any server-side state. After a key is entered, use Load models to fetch
that provider's currently available chat/generation models and choose one from
the dropdown.
The AI Provider dialog includes setup links and basic funding guidance:
- Anthropic Claude: create an Anthropic Console account, buy usage credits from Billing, then create an API key. Start with about $20 and keep auto-reload conservative until usage is predictable.
- OpenAI ChatGPT: create an OpenAI platform account, add prepaid credits in Billing, then create an API key. Start with about $20 and set a project budget or usage limit.
- Google Gemini: create a Google AI Studio key. Gemini may start on free-tier usage; paid quota uses Google Cloud Billing rather than prepaid credits, so set a budget or alert around $20.
The Vite dev server hosts the app and local toolchain endpoints. AI provider
requests are made directly from the browser with the user's own key, so the
local server and hosted droplet do not store provider credentials. HMR is on for
.ts and .css changes.
.\lt.ps1 devOpen the URL Vite prints (default http://localhost:5173).
Windows Ctrl+C tip: pnpm dev is invoked through a .cmd wrapper, so Ctrl+C triggers the "Terminate batch job (Y/N)?" prompt. Use .\lt.ps1 dev instead — it runs Vite via Node directly so Ctrl+C kills it cleanly. Same idea for the production server: use .\lt.ps1 serve instead of pnpm serve.
Build the static bundle, then run the Node proxy server which serves dist/ and proxies API calls.
.\lt.ps1 build # type-checks, then builds to dist/
.\lt.ps1 serve # node server.mjs (uses .env if present)Open http://localhost:3000 (override with $env:PORT = "8080"; .\lt.ps1 serve).
When hosting under a path prefix, set LANG_TUTOR_BASE_PATH before building so
asset URLs and internal API calls include that prefix:
$env:LANG_TUTOR_BASE_PATH = "/lang-tutor/"
.\lt.ps1 build
.\lt.ps1 serveserver.mjs accepts requests with or without that prefix, so either
handle_path-style prefix stripping or plain forwarding works.
The production shortcut is:
.\lt.ps1 deployThat command runs the local type-check/build gate, pushes the current branch,
archives HEAD, uploads an immutable release to the droplet, builds with
LANG_TUTOR_BASE_PATH derived from -DeployUrl, restarts
lang-tutor.service, and smoke-tests both /lang-tutor and /lang-tutor/.
It also verifies hosted auth is required before account-specific state or
toolchain endpoints are reachable. The deploy also ensures the host has the
checker/LSP binaries used by live diagnostics and format-on-save, then builds
and verifies the hosted lang-tutor-toolchains:latest Docker image used by
Rust, C++, DASM, Python, and C# console runs.
For a new droplet or a host rebuild, follow docs/deployment.md first. It records the one-time Node, Docker, Caddy, systemd, app-user, and runtime-env setup that the deploy command assumes.
Useful deploy arguments:
-Worktreedeploys local tracked and untracked non-ignored files instead ofHEAD; use it for staging uncommitted changes. It automatically skipsgit push.-DeployHost <ssh-target>changes the SSH target; default isroot@24.199.111.154.-DeployUrl <url>changes the hosted base URL and build base path; default ishttps://leftos.dev/lang-tutor.-SkipCheck,-SkipPush, and-SkipSmokeskip the local gate, branch push, or hosted smoke checks respectively.
.\lt.ps1 typecheck # tsc --noEmit
.\lt.ps1 lint # biome check --write . (lint + format)
.\lt.ps1 preview # vite preview (preview the production build via Vite)
.\lt.ps1 toolchain # build lang-tutor-toolchains:latest for local code runs.
├── src/
│ ├── main.ts Entry: state, session control, language switching, event wiring
│ ├── editor.ts CodeMirror 6 setup (theme, syntax highlight, lint, format-on-save)
│ ├── lint.ts Frontend client for /check + /format
│ ├── api.ts Provider API calls (chat + progress extraction, streaming)
│ ├── runners.ts Code execution dispatch (/run local sandbox endpoint)
│ ├── render.ts Markdown rendering, message DOM construction
│ ├── storage.ts localStorage wrapper
│ ├── constants.ts LANGUAGES record (topics, prompts, starter code), storage-key helpers
│ ├── types.ts TypeScript interfaces
│ └── style.css Tailwind import + design tokens + component classes
├── tools/
│ ├── checker.mjs Backend: spawns rustc/clang/python/rustfmt/clang-format/black
│ └── runner.mjs Backend: runs single-buffer code in the Docker sandbox image
├── scripts/
│ ├── build-toolchain-image.ps1 Builds lang-tutor-toolchains:latest
│ └── setup.ps1 One-shot Windows setup (installs runtimes, opens browser)
├── docker/
│ └── toolchains/ Docker image with Clang/LLVM, Rust, Python, .NET, formatters, and LSPs
├── index.html Vite entry HTML
├── vite.config.ts Dev server proxy + Tailwind plugin + /check + /format middleware
├── tsconfig.json Strict TypeScript config
├── biome.json Lint + format config
├── server.mjs Production server (serves dist/ + account/state APIs + /run + /check + /format)
├── lt.ps1 Root helper for dev/build/serve/check workflows
└── .env Optional runtime config (gitignored)
Each language has its own localStorage namespace:
lang-tutor:active— currently selected language (rust|cpp|python|csharp|web)lang-tutor:{lang}:history— last 30 messageslang-tutor:{lang}:progress— structured progress blob (topic statuses, strengths, struggles, notes)lang-tutor:{lang}:code— saved editor content (single-buffer languages only)lang-tutor:{lang}:openTabs/:activeTab— multi-tab UI state (project workspaces only; the files themselves live on disk under.local/workspaces/<user>/<lang>/locally or/var/lib/lang-tutor/workspaces/<user>/<lang>/when hosted)
Switching language saves the current editor content, then loads everything for the new language. Conversations are non-destructive — switching back restores exactly where you were.
Both use the selected browser-side provider:
callClaude()— the tutoring conversation. Uses a system prompt built from the active language'ssystemPromptIntro, lesson plan, strengths, struggles, and resume context.fetchProgressExtraction()— fires after eachevaluateCode(). Sends the last 14 messages with a strict JSON-output prompt; the result is merged with prior progress and persisted under the active language's key.
- Rust → browser
POST /run, backend runsrustc --edition=2021insidelang-tutor-toolchains:latest. - C++ → browser
POST /run, backend runsclang++ -std=c++23insidelang-tutor-toolchains:latest. - Python → browser
POST /run, backend runspython3insidelang-tutor-toolchains:latest. - C# → local
dotnet run --project LangTutor.Wpf/LangTutor.Wpf.csproj --verbosity minimalsupervised bytools/projects.mjs. The default scaffold isprojects/csharp/LangTutor.slnwithLangTutor.Wpf/for WPF/XAML work andLangTutor.Console/Program.csfor console exercises. The Run button starts the WPF project; status pill showsrestoring NuGet…→building…→running (PID …)derived from dotnet's stdout. The WPF window opens on the user's desktop. Stop or close the window → status flips tostoppedorexited (code N). For non-GUI lessons, the terminal button above the editor runs the active.csfile as a temporary console app insidelang-tutor-toolchains:latest. - Web → local
pnpm devVite server supervised bytools/projects.mjs, rendered into a sandboxed iframe athttp://127.0.0.1:5180/.
The snippet sandbox uses Docker with --network none, a read-only container root, dropped Linux capabilities, no-new-privileges, and CPU / memory / process limits. Build or refresh it with .\lt.ps1 toolchain.
evaluateCode() (single-buffer) formats the editor contents and last output as a [CODE]…[OUTPUT]… user message in a fence appropriate to the active language (rust / cpp / python), sends it through the normal chat path, then triggers progress extraction.
evaluateProjectCode() (project workspaces) bundles richer context:
- Web:
[FILES](open tabs) +[DOM](rendered HTML snapshot) +[CONSOLE](recent iframe console / errors) +[SERVER](recent Vite stdout/stderr). - C#:
[FILES](open tabs) +[OUTPUT](recent dotnet stdout/stderr including build errors). No DOM/CONSOLE — the tutor system prompt instructs the model to ask for screenshots when UI behaviour matters.
- The
.envfile is gitignored. Do not put provider API keys in it. - Provider models are loaded live from the selected provider after an API key is entered. If a saved model disappears from that provider's model list, the app warns the user and requires a new selection.
- Resetting progress only affects the active language. Switch first if you want to reset a different one.
- Rust, C++, DASM, Python, and C# console snippets run locally in Docker. If Run reports that
lang-tutor-toolchains:latestis missing, run.\lt.ps1 toolchain. - The XSS-safe DOM construction means you can paste arbitrary content from the AI without risk.