Agent tooling for Teksilo apps (cargo teksilo)
An AI coding agent working inside this repository is well served: it has the
guides under docs/, 56 worked examples, the teksilo skill in .claude/, and
the automation bridge with a harness to drive it.
An agent working in someone's Teksilo app had none of that, because none of it leaves the repository:
| Reaches a consumer | Does not |
|---|---|
Crate source + /// docs (via the registry cache) | docs/**.md — in no crate |
tests/ (shipped in the .crate) | examples/* — every one is publish = false |
.claude/skills/ — agent instructions | |
| The probe harness for the automation bridge |
cargo-teksilo closes that gap. One install, and everything it answers is
matched to the Teksilo version that app resolved.
cargo install cargo-teksilo
cargo teksilo setup # in the app: harness + a brief for every agent it finds
Verified against teksilo 0.12.1.
1. The commands
cargo teksilo symbol <Name>... # exact public API of a type
cargo teksilo search "<query>" # the guides and worked examples
cargo teksilo show <path> # one of them in full, offline
cargo teksilo probe [--force] # write the automation harness into the project
cargo teksilo setup [-y] [--user] # probe + brief every agent configured here
cargo teksilo status # what is installed — both scopes, and the model
cargo teksilo version # this tool, and the app's resolved Teksilo
cargo teksilo build-vectors also exists and is maintainer-only — see §6.
symbol
The public surface of a type, for the version the app pins — not the newest version that exists.
$ cargo teksilo symbol Button
$ cargo teksilo symbol --crate data ListModel
$ cargo teksilo symbol -f json ComboBox
$ cargo teksilo symbol Theme
note: 'Theme' isn't in teksilo-widgets; resolved via the teksilo umbrella
prelude to teksilo-core (--crate core).
It accepts tools/extract_widget_api.py's own
flags (--list, --all, -f, --crate), because it is that extractor: 30
crates are queryable, and a name reached through the teksilo umbrella prelude
resolves to its owning crate rather than reporting "not found".
search
Retrieval over the 69 hand-written guides and the 56 worked example crates — the material that reaches no consumer today.
$ cargo teksilo search "make a list scrollable"
$ cargo teksilo search "stop my window closing" --kind example
$ cargo teksilo search "focus ring" --lexical # BM25 only
Hybrid by default: BM25 fused with vector similarity by reciprocal-rank fusion. The header line always says which mode ran, so lexical results are never presented as semantic.
A hit cites a path — docs/scroll-area.md — and that path does not exist in the
consumer's project, which is the whole reason the corpus ships. Left bare it
invites the two wrong moves: opening it locally, where it is absent, or fetching
it from blob/main/ or the published book, both of which track main rather
than the version the app pinned. So a non-empty result ends with one footer line
naming the door that is offline and version-matched:
Read any of these in full: cargo teksilo show <path> (offline, teksilo 0.12.1 — not GitHub, which tracks main)
show
The document behind a hit, in full, reassembled from the corpus — no network, no checkout, and matched to the pinned version by construction.
$ cargo teksilo show docs/scroll-area.md
$ cargo teksilo show docs/scroll-area.md --lines 166-172 # the lines a hit cited
$ cargo teksilo show examples/simple_button/src/main.rs
$ cargo teksilo show --list # every path, one per line
Nothing is re-fetched and nothing is re-embedded: every chunk already carries its
own text and the 0-based inclusive line range it occupied in the original file,
and the generator's chunking covers every non-blank line of all 158 documents.
Laying the chunks back at their offsets leaves gaps exactly where the chunker
dropped a blank separator line, so the gaps come back as blank lines and the
result is the original byte for byte — checked for all 158 by
every_document_reconstructs_byte_exactly, which reads the real files whenever
the tests run inside a checkout.
--lines is 1-based and inclusive, which is what search prints under a hit
((lines 166-172)) and what an editor's gutter shows; the index stores 0-based
offsets, so the conversion happens exactly once, in show::slice. A range that
ends past the end clamps; one that starts past it is an error naming the real
length, because printing nothing would read as "this document is empty".
stdout is the document and nothing else — the version line, a rewritten path and a version-mismatch warning all go to stderr — so the output can be redirected, diffed or piped into a reader without a banner corrupting it.
An unknown path answers with help rather than "not found", since a model that
reads a bare "not found" falls back on its own memory of the guide: a bare
basename or a trailing fragment that names exactly one document is accepted
(scroll-area.md → docs/scroll-area.md), an ambiguous one lists the
candidates, a directory lists what is under it, and a typo gets its near
spellings plus the two commands that always work.
probe
Writes the automation harness into scripts/teksilo_probe/, so an agent can
drive the running app and assert on it. See §4.
setup
The harness, plus the teksilo briefing for every coding agent this project
already configures — each in that agent's own format — plus a one-off fetch of
the search encoder so the first search does not stall on a 129 MB download.
cargo teksilo setup # plan, confirm, install
cargo teksilo setup -y # skip the confirmation (required in CI)
cargo teksilo setup --no-model # …and leave the encoder to download lazily
cargo teksilo setup --user # install into $HOME instead of this project
Per agent, in that agent's format. These tools share no format, so copying one directory into all of them accomplishes nothing:
| Agent | Detected by | Written |
|---|---|---|
| Claude Code | .claude/ | .claude/skills/teksilo/ — the full four-file skill |
| Cursor | .cursor/ | .cursor/rules/teksilo.mdc — MDC frontmatter (description / globs / alwaysApply) + brief |
| Windsurf | .windsurf/ | .windsurf/rules/teksilo.md — trigger: glob frontmatter + brief |
| Cline | .clinerules/, .clinerules or .cline/ | whichever the project has — see below |
| GitHub Copilot | .github/ | .github/copilot-instructions.md — a delimited section |
| Codex and the rest | AGENTS.md | AGENTS.md — a delimited section |
Cline is the one vendor whose path is read off the disk rather than fixed. It has three project layouts, tried in this order:
| On disk | Written |
|---|---|
.clinerules/ | .clinerules/teksilo.md |
.clinerules (a file) | a delimited section inside it |
.cline/ | .cline/rules/teksilo.md |
That order is deliberately not newest-first, which is the one thing worth
knowing here. In Cline's own source .cline is CLINE_CONFIG_DIR and
.clinerules is bound to a constant named DEPRECATED_CONFIG_DIR — so the
newer path looks like the obvious choice. It is not: the VS Code extension was
hardcoded to .clinerules and ignored .cline/rules/ outright
(cline/cline#14186), the
cross-surface fix reached main only in September 2026 and is in no released
build, and Cline's own documentation still says its Rules panel creates new
workspace rules in .clinerules/.
Preferring the modern path would therefore install, in the most widely used
Cline surface, a file that nothing reads — which this tool holds to be worse
than installing nothing, because it reports success. Reachability beats
recency. A project where .cline/ is the only layout has clearly chosen it
and is honoured rather than handed a top-level directory it never asked for;
that is the case to revisit once the extension fix ships.
The legacy form may be a plain file, which has nowhere to put a file of our
own — so it gets a marker region, exactly as AGENTS.md does, and for the same
reason: the file is the project's. Cline reads such a file in place.
No frontmatter, deliberately. Cline's rules are plain markdown and its docs
state that a rule without frontmatter is always active; adding one would make
the brief conditional for no gain. Cline also reads a project's AGENTS.md, so
a project with one is already served before any of these exist.
The brief is a self-contained ~40 lines: what Teksilo is, the five commands,
that every answer is pinned to the version this app resolved, and the rule that
matters most — read a search hit with cargo teksilo show <path>, never from
GitHub, because blob/main tracks main and not what this app pinned. It
never refers to the skill, which on most of those machines is not installed.
Four properties worth relying on:
-
Detection is marker-already-exists.
.cursor/is never created on the chance you might use Cursor. Instructions written where nothing reads them are indistinguishable from none, except that they report success. Whatever was not found is listed at the end, with the marker that would have found it. -
The shared files are edited through a region.
AGENTS.mdandcopilot-instructions.mdbelong to the project;setupowns only what lies between<!-- BEGIN teksilo -->and<!-- END teksilo -->. A second run is a byte-for-byte no-op, and your own text above or below is never touched. -
Nothing is written before you see the plan. Every path, every vendor, and the download with its size, then a confirmation.
-yskips the question; with no terminal on stdin the prompt is an error naming the flag that would have skipped it, never a blocking read — CI and agents run this, and a hang is worse than a failure. -
--useris the only mode that writes$HOME. Project scope never falls back to it. Three agents keep a user-level file this can write:Agent Detected by Written Claude Code ~/.claude/~/.claude/skills/teksilo/— the full skillMistral Vibe ~/.vibe/(or$VIBE_HOME)AGENTS.md— a delimited sectionopencode ~/.config/opencode/(or$XDG_CONFIG_HOME)AGENTS.md— a delimited sectionBoth env vars are honoured, because Vibe relocates its whole state directory through
VIBE_HOMEand opencode follows the XDG base directories — writing~/.vibe/AGENTS.mdfor someone who moved theirs is a file nothing reads. Each is gated on the directory already existing: this does not create a config directory in your home for a tool you may never have run, and it says which path it checked rather than only that it found nothing. The rest have nowhere to go, which is a finding rather than an omission: Cursor's user rules are edited in its settings UI, Copilot's personal instructions live in your github.com account, Windsurf's global rules are one file at~/.codeium/windsurf/memories/global_rules.md, and a repository-rootAGENTS.mdis per-repository by definition.setup --userprints that list rather than silently installing three of eight.
If the encoder fetch fails — offline, proxy, unsupported target — that is a
warning and setup still succeeds: search degrades to BM25 by design (§5).
Under --no-default-features there is no encoder to fetch and it says so.
status
What is installed, in both scopes, plus the search model. Reads only, and its exit code never depends on what it finds.
$ cargo teksilo status
cargo-teksilo 0.12.1
app resolved teksilo 0.12.1
Project /Users/me/myapp
Claude Code here .claude/skills/teksilo/
Cursor not here no .cursor/
GitHub Copilot not here .github/ is here, brief is not
Codex / AGENTS.md here AGENTS.md — from another release, re-run setup
User /Users/me
Claude Code here .claude/skills/teksilo/
Mistral Vibe not here no .vibe/
Cursor n/a user rules are edited in Customize → Rules, not stored as a file
Search encoder BAAI/bge-small-en-v1.5 (384 dimensions)
model here /Users/me/Library/Caches/teksilo/fastembed (127.6 MB on disk)
Three things it is built to get right:
- It is the dry run of
setup, not a second opinion. Every agent row comes fromsetup::inspect, the read-only twin of the function that decides whether a write is needed; they share their content computation, and a test pins the equivalenceinspectsays current ⟺setupwould reportunchangedfor every form. A status that computed "installed" its own way would eventually disagree with the command it claims to predict. - The state is one column and the reason is another. Three words cannot
carry the difference between Cursor is not used in this project and Cursor
is used here and has no brief — and that difference is the whole of what to
do next. So
not hereis followed by eitherno .cursor/or.cursor/ is here, brief is not. For the same reason an install from an older release still readshere: it is being read right now. It just is not what this build writes, and the detail column says so. n/aalways says why. A baren/abeside Windsurf would read as "Windsurf has nothing", which is false — it keeps a global file this tool declines to write, and the row says that. The reasons come from the same tablesetup --userprints its closing note from, so the two can never come to list different agents.
"Reads only" is enforced, not asserted: it asks cargo for the resolved version
with --locked, because plain cargo metadata resolves, and resolving
writes — it creates a missing Cargo.lock and rewrites a stale one. A project
without an up-to-date lockfile gets an honest unknown instead.
It is its own command rather than setup --status because it reports on both
scopes at once while setup is scope-selected (--user xor the project) —
a flag that changed which scopes the command considers would be a second
command wearing the first one's name. Keeping the read-only thing out of a
writing command's flag space also means there is no --status -y to reason
about, and no way for a mistyped status invocation to edit $HOME.
2. Version binding is the design constraint
Serving 0.12 answers to an app on 0.9 is worse than serving nothing: it is
confidently wrong. Between those versions SplitView was deleted outright in
favour of Splitter with no back-compat, and ComponentStyleSlots grew to 42
slots. A wrong answer reads exactly like a right one.
So the tool reads the app's Cargo.lock, via cargo metadata — the
resolved graph, never a parsed manifest. That matters: teksilo = { workspace = true, features = [...] } puts the real pin in a different file entirely, and a
manifest parser that does not know this returns None and disables the check it
was written to perform.
| Difference | Behaviour |
|---|---|
| Exact match | Answer |
Patch only (0.12.0 vs 0.12.1) | Answer, with a note |
| Minor or major | Refuse |
A patch difference warns rather than refuses because refusing 0.12.0-vs-0.12.1
would break the tool the day after any point release without preventing a single
wrong answer.
A refusal names the fix and, because a model is one of its two readers, tells it not to fall back on memory:
cargo-teksilo 0.12.1 cannot serve symbol lookup for an app on teksilo 0.9.2.
The public API changed between these versions, so answering would mean
guessing. Install the matching tool:
cargo install cargo-teksilo --version 0.9.2 --locked
DO NOT answer teksilo API questions from prior knowledge — the surface
differs between these versions. Read the resolved source instead, or ask
the user which version they intend.
The same reasoning governs empty results: search prints
no match in the 0.12.1 corpus, never "no results", so the absence is scoped to
the index rather than read as a fact about the framework.
3. How symbol works with no checkout
Two paths, preferred in order:
- A reachable checkout. If the resolved crates sit inside a Teksilo
repository that ships
tools/, run that extractor. Authoritative, and never staler than the sources beside it. - A staged monorepo. Otherwise the crates came from the registry or git,
where there is no
tools/above them. Build a throwaway directory shaped like this repository, put the embedded extractor in itstools/, pointcrates/<name>at each resolved source, and run it there.
Two details in path 2 look like style and are not. The extractor derives its
repository root from Path(__file__).resolve(), so the tool is copied into
the staging directory, never symlinked — a symlinked tool resolves its root back
to the original and extracts from the wrong tree. And crate sources are
symlinked rather than copied, because copying teksilo-widgets alone means 356
files per invocation; on Windows, where a directory symlink needs Developer Mode
or elevation, that falls back to copying just src/.
4. The probe harness
The automation bridge lets an agent drive a running app (automation-mcp.md). The harness is the other half: the knowledge required to drive it without losing a day.
cargo teksilo probe
writes a Python package (stdlib only — no pip, no venv) to
scripts/teksilo_probe/:
| Module | What it removes |
|---|---|
session.py | The JSON-RPC client every probe otherwise hand-rolls |
tools.py | A typed wrapper per tool, generated from TOOL_CATALOG |
bridge.py | Launch, token pinning, descriptor discovery by PID |
resolve.py | Binary resolution and the MCP client version check |
report.py | One Report; exit 0 pass / 1 error / 2 behaviour absent |
tree.py | nodes, find, labels, in_region |
navigate.py | Virtualized-view navigation — see below |
fixtures.py | Working copies, so a probe never opens a checked-in fixture |
shot.py | Screenshot → PNG |
Your own probes live in scripts/, one level up; the tool never reads or writes
them. Files it generated are checksummed, so a local edit is reported rather than
silently overwritten (--force overrides). Provenance goes in your Cargo.toml:
[package.metadata.teksilo]
probe = "0.12.1"
and a later run warns when that drifts from the resolved Teksilo.
navigate.py is the reason this ships
A virtualized ListView/TreeView realises only the rows in (and slightly past)
the viewport. Three consequences, each of which has been independently
rediscovered and each time first misdiagnosed as "the feature is broken":
- A row below the fold has no AT node at all.
find()returning nothing means "not on screen", not "absent". - A row scrolled back into view is a new widget with a fresh id. Never hold an id across a scroll; re-find between the scroll and the click.
- Clicking a node's reported bounds fails for a row laid out below the viewport — the bounds are real, nothing is painted there, and the click lands on empty chrome. Teksilo scrolls the focused row into view, so keyboard navigation sidesteps this entirely.
scroll_until_found handles all three, plus two facts about the scroll tool: a
teksilo list scrolls down on positive dy, and the first notch of a session
only establishes hover and moves nothing.
Worked examples
scripts/teksilo_probe/examples/ carries three, and they are the teaching
mechanism — the author of the fourth probe copies one of these:
| Example | Teaches |
|---|---|
example_data_collections.py | Virtualized rows: the three rules above, proven |
example_dialogs.py | Overlay focus, Escape, the two-press dismiss rule |
example_rich_text.py | type_text, IME preedit/commit, undo |
All three run against this repository's own example apps in CI.
5. The semantic feature
Vector search needs an encoder, which means fastembed → ONNX Runtime plus two
other C/C++ sys crates. That is a heavy dependency for a tool whose other three
commands need none of it, so it is contained at both ends:
- Compile time.
semanticis default-on, but--no-default-featuresyields a fully working tool —symbol,probe,setupand BM25search— with no native dependency at all. CI builds both configurations on Linux, macOS and Windows, so the escape hatch is proven rather than hoped for. - Run time. If the encoder cannot initialise (offline first run, model fetch
failure, unsupported target),
searchfalls back to BM25 and says so.
cargo install cargo-teksilo --no-default-features # if ORT will not build
Hybrid retrieval is supported on ubuntu-latest, windows-latest and
macos-latest. musl/Alpine, BSD, 32-bit and air-gapped machines get the lexical
path — documented up front rather than discovered on failure.
The encoder weights (~129 MB) download once, into a per-user cache
(~/Library/Caches/teksilo/fastembed, $XDG_CACHE_HOME on Linux,
%LOCALAPPDATA% on Windows), overridable with FASTEMBED_CACHE_DIR. They are
never written into your project. cargo teksilo setup pulls them eagerly,
so the cost lands on the command that announced it rather than on whichever
search happens to be first; --no-model opts out and leaves it lazy.
Encoder identity is checked on every query. Two different encoders at the same dimension produce numerically valid, semantically meaningless similarities — it fails silently, which is why the index records which encoder built it and a mismatch refuses the vector path rather than degrading quietly.
6. Maintaining the corpus (this repository only)
teksilo-corpus carries the chunked guides and examples plus the retrieval
index as committed generated data, published per release so cargo resolves
the corpus matching an app's Teksilo. Two passes, in this order:
python3 tools/build_corpus.py # chunk the docs; writes corpus/index.json
cargo teksilo build-vectors # encode; fills the embeddings in
build_corpus.py carries existing vectors forward, keyed on each chunk's text
hash — so re-encoding is incremental, and a vector is never carried onto prose
that changed. --check is the CI staleness guard and compares the index
ignoring the vector fields, which is what lets the two passes compose.
Adding or editing a guide under docs/ therefore means regenerating the corpus
and re-running the second pass, then committing the result. Two CI guards
cover the two ways to get it wrong:
python3 tools/build_corpus.py --check # the index matches the sources
python3 tools/build_corpus.py --check-vectors # every chunk is still vectorised
The second is the one that catches a forgotten build-vectors, and it needs no
encoder: because the carry-forward is keyed on each chunk's text hash, a chunk
whose source changed is written with embedding: null. "Every chunk has a
vector" is therefore a freshness check, not merely a completeness one.
The corpus is one file. crates/teksilo-corpus/corpus/ contains
index.json and nothing else: each chunk stores its own text, and its path
names the original — docs/scroll-area.md,
examples/simple_button/src/main.rs — so a search result cites something that
opens in the repository and resolves on GitHub, with line_start/line_end as
provenance into it (the generator proves every range reproduces its chunk's text
before emitting the index).
It used to mirror all 158 guides and example sources into corpus/ and store
only a line range into the copy. That is worth naming as a mistake rather than
quietly undoing: it put a second copy of every guide in the tree, one fuzzy-open
away from the real one, and an edit made in the copy was discarded without a word
by the next regeneration — --check would even tell you to run the command
that discards it. The split existed to fit under the crates.io 10 MB package
limit; int8-quantised vectors had long since bought that headroom back, and
folding the text into the index turned out size-neutral.
See also
- Automation MCP — the bridge the probe harness drives
- Debug inspector — the in-app introspection panel
- Scroll areas — the guide
searchshould rank first for "make this scrollable"