Agentic coding assistant (TUI). Talks to self-hosted models through Ollama or any OpenAI-compatible server, and to hosted apis (Anthropic natively, OpenAI/Google/OpenRouter over the OpenAI api). Rust, edition 2024.
cargo build # debug build
cargo build --release # -> target/release/thoth
cargo fmt # rustfmt defaults, no config
cargo test # unit tests
cargo test -- --ignored # network tests (need internet)
cargo clippy -- -D warnings # CI enforces zero warningsStable Rust 1.85+ (edition 2024). CI runs build --release, test and
clippy on Linux, macOS and Windows, so a cfg!(windows) branch that only
compiles on one side fails the matrix.
Tests live in a #[cfg(test)] mod tests at the bottom of the file they
cover. There is no tests/ directory, don't add one.
A file past roughly a thousand lines is a sign it holds two things: split it
into a module directory (client/) or a sibling (ui/screen.rs) rather than
letting it grow. A child module sees its parent's private items, so a split
along those lines needs no new pub.
main.rs: the order things happen in at startup, and nothing else. Config resolution, model discovery, spawning the agent task, then TUI or-p.cli.rs: the clap surface and the subcommands that answer without ever starting a session (upgrade,config,--view). Everything here has to work on a machine with no model running, which is why it sits before the config load rather than inside it.print.rs:-pmode. The split down the middle is the point: the model's answer goes to stdout and nothing else does, sothoth -p "..." > out.txtleaves a file with the answer and a terminal with the working shown.config.rs: named profiles in~/.thoth/config.tomlplus the resolution order: CLI flag, thenTHOTH_*env var, then the active profile, then the built-in default. Ownsthoth_home(), the one definition of~/.thoththat session state and the editor bridge also use. Reads the pre-0.3 file (flat, in the platform config dir) as a profile calleddefault. A new setting means all ofConfig,Profile,resolve, the field list inui/config.rsanddocs/configuration.md.client/: LLM transport, one module per wire protocol.mod.rsholds the message shape, theClientand the transport choice:autopicks by url and only probes/api/versionon a local address, because a paid endpoint must never see a request to a path we guessed.openai.rsis OpenAI-compatible SSE (/chat/completions),ollama.rsthe native/api/chat(which takes the context window per request),anthropic.rsthe native/v1/messages(which allows prompt caching).stream.rsis what both text protocols need:ThinkFilterroutes inline<think>tags to reasoning,ToolTextFilterreads a tool call a model wrote as text, andTextStreamis the pair of them, so neither transport grows its own copy.agent/mod.rs: the agentic loop (model call, tool calls, results, repeat). Owns conversation history, permission gating, duplicate-call breaker, truncation recovery, /recap, editor-context injection,!commandruns.agent/history.rs: what leaves the conversation, since context is the scarce resource and three things spend it. The summary that replaces a history too long to send (compact, automatic at 2/3 of the window or on/compact), the trim that saves a request the server already truncated (hard_trim), and the older copy of a result a newer identical read has superseded (drop_stale_copy) - where identical is the whole safety condition. A child module, so these stayimpl Agent.agent/prompt.rs: system prompt. Environment (cwd, os, date, git branch), project scan, guardrail rules, instruction file (THOTH.md/AGENTS.md/ CLAUDE.md, pointers followed), project memory.agent/stack.rs: what kind of project this is and what checks it. One table, one row per stack (marker files, extensions, the check command, the words that mean a check ran, and whether running the code already was that check). The prompt reads it to name the command, the agent loop reads it to notice code that was changed and never checked. Supporting another language is adding a row: nothing else in thoth may name a language or a tool.agent/undo.rs: what a file looked like before thoth changed it. Each request is one checkpoint under~/.thoth/projects/<key>/undo/;/undoputs the newest one back and leaves alone anything that changed since. Recording is armed bymain.rs, so the file tools stay inert outside a real session (which also keeps the tests off the user's state).agent/session.rs: per-project state under~/.thoth/projects/<key>/: saved transcript for--continueand the persistent permission allowlist. Written atomically, owner-only on unix.tools/:fs.rs(read/write/edit/multi_edit/move/delete, read-coverage registry, unified diffs, line-ending alignment),search.rs(grep with optional context lines),shell.rs(foreground with timeout, background mode),web.rs(DuckDuckGo search, html to text),memory.rs(project memory, session recap, project key),todo.rs(the plan for the task),mod.rs(tool schemas, dispatch, permission rules,output_cap).editor.rs: VS Code awareness. Reads state files written by the companion extension (thoth-for-vscode) from~/.thoth/ide/, falls back to window titles on Windows.ui/mod.rs: ratatui interface. The state and everything that changes it: agent events, slash commands, the transcript blocks.ui/screen.rsis the other half, the drawing, including the incremental wrap cache; it is a child module, so it readsApp's private fields without any of them being made public.ui/keys.rsis what a key means: the mode routing (chooser, permission prompt, completion popup, plain typing) and the input editing that follows from it, and the order the modes are tested in is the whole of the routing.ui/render.rsturns text into styled lines (markdown, diffs, wrapping),ui/theme.rsholds colors and glyphs,ui/input.rshandles@pathattachments and completion.ui/clipboard.rs: OSC 52, so the terminal does the copying and thoth carries no clipboard library and no platform code.ctrl+t(mouse capture off) is the way out for terminals that ignore it.ui/demo.rs: fake sessions for every screen, printed bythoth --view <name>in a debug build. A screen that only appears after a permission prompt, anask_usercall or a plan-mode turn is otherwise minutes of model time away, so it never gets looked at and the bugs stay. Child module ofui, so it builds anAppand sets its private fields.every_view_drawsrenders all of them at two sizes oncargo test; a new screen means a new view here.ui/config.rs: the profile screen. One struct drives boththoth config(its own event loop) and/config(the App holds it and forwards keys), so the two cannot drift. Saving hands back aConfigthat the agent applies to the live session.upgrade.rs:thoth upgrade, replacing the running binary with the latest verified GitHub release.
docs/: the user-facing docs (getting-started, usage, configuration, tools). A new flag, tool or slash command is not done until the matching page says so.CHANGELOG.md: one line under Unreleased for anything user-visible.scripts/: install and uninstall, sh and PowerShell. They must stay in step with the release asset names in.github/workflows/release.ymland withupgrade.rs.THOTH.mdandCLAUDE.mdare pointers to this file. Keep them that way, put the content here.note.txtis gitignored personal notes. Leave it alone.
- Guardrails go in code, not in the prompt, whenever possible. See the
read registry in
tools/fs.rs: write_file needs every line of a file covered by earlier reads, not just "a read happened". And the rewrite guard inagent/mod.rs: a file written whole twice in one request with no check in between was written the second time out of the same knowledge as the first. - A note that only reaches the user reaches it too late to fix anything. "Files changed and nothing was run" is worth saying either way, but the model is the one that can still act on it, so it is put to the model first and told to the user afterwards. Once per request: a model that will not run the check must not be asked round and round.
- A guardrail that refuses has to be able to say what would satisfy it. The
rewrite guard stays quiet when no stack is detected, because
ran_a_checkreads the same list: with nothing in it, no command the model could run would lift the block, and a refusal it cannot clear locks it out of the file for the rest of the request. - thoth is not a TypeScript tool or a Rust tool. No language, framework,
file extension or shell command belongs anywhere but a table:
tsc,cargo,php -land the rest live inagent/stack.rsand nowhere else. If a fix wants a rule about one language, the fix is a row plus a rule written in terms of the row's fields. Anything else is a stack that happens to be the one being tested that day, and it strands every other language the user works in. - Anything that touches the user's machine must be visible in the UI: full command lines, full diffs, even when auto-approved.
- "Always allow" is scoped, never a blanket grant: shell is keyed by
program (
shell:cargo), web_fetch by host. Seepermission_keyintools/mod.rs. - Context is the scarce resource. Everything sent on every request is
budgeted against the window:
tools::output_capsizes tool results,prompt::situational_rulesdrops rules for things that are not there, and the instruction file is capped the same way.cargo test prompt_budget -- --ignored --nocaptureprints what a request costs before the conversation starts; check it after touching the prompt or the tool schemas. - A tool that truncates its own output says what was cut and how to get the rest. Never let a caller blindly cut a result that had a footer.
- Anything parsed from the network or from a file can be hostile: no slicing by byte offset, no unvalidated value in a filesystem path.
- Prefer runtime
cfg!(windows)over#[cfg]so both branches compile on every platform.#[cfg]items need a non-Windows stub. - No new heavyweight dependencies without a good reason.
- Development-only code is
#[cfg(debug_assertions)], not a runtimeif.ui/demo.rscarries made-up transcripts nobody who installs thoth will ever see; the optimizer happened to drop them, and#[cfg]is the same thing said as a guarantee. The release profile is tuned for what a user downloads (lto = "fat", one codegen unit, symbols stripped,panic = "abort"): thoth has nocatch_unwindand nothing that must run while a panic travels, and ratatui's hook still puts the terminal back under abort, so the unwinding tables were pure weight. Adding either would mean takingpanic = "abort"back out.