Your code remembers the AI conversations that talked about it.
Set It Up Β· Use It Β· The Marks Β· Commands Β· Configure Β· How It Works Β· Status
Your agent understands your code for one hour. Then the session scrolls away. The code keeps the change and loses the reason.
Lex keeps the reason. Select some lines, paste them into Claude Code, Codex or OpenCode, ask your question. Weeks later those lines still carry a mark in Neovim, and one keypress takes you back into the conversation that talked about them, running or finished.
12 β export function login(user: User) { β π¬ 3
13 β return retry(() => post("/auth", user))
14 β }
Three conversations talked about these lines. Press one key and you are in one.
Nothing about your day changes. Your Neovim config stays yours. The agent stays in its own terminal tab. The conversation stays in the agent's own history. Lex is only the memory between the two.
Three steps, about two minutes.
{
"lukaskellerstein/lex",
dependencies = { "folke/snacks.nvim" },
event = "VeryLazy",
opts = {},
keys = {
{ "<leader>ap", "<cmd>LexCopy<cr>", mode = { "n", "x" }, desc = "π Pin Lex Place" },
{ "<leader>al", "<cmd>LexLinks<cr>", desc = "π¬ Lex conversations here" },
},
}opts = {} is the whole configuration. Every option has a default.
Once, from inside Neovim:
:LexInstallHook " Claude Code
:LexInstallHook codex " Codex
:LexInstallHook opencode " OpenCodeRun it for each agent you use. It edits that agent's own config file, writes absolute paths, and is safe to run twice.
| Agent | Takes effect |
|---|---|
| Claude Code | in your next session |
| Codex | start codex once and press t to trust the hook |
| OpenCode | the next time OpenCode starts |
:checkhealth lexIt reads each agent's config, runs the writer once into a throwaway store, and tells you how long the hook took.
That is the whole setup. There is nothing to run in the background, no daemon, no account, no index to build.
- Select lines in a buffer. Or stand on one line. Or pick a file or a folder row in the explorer.
- Press
<leader>ap. The lines turn blue with aπ 1badge: copied, not sent yet. - Paste into your agent and type your question around it. Paste a second place if the question needs two.
- Send. The blue turns yellow. That is the proof the link was written.
- Later, on any of those lines, press
<leader>aland then<CR>.
<CR> finds the agent wherever it is, any tmux session, any terminal, any
desktop, and takes you there. If the session has stopped, you pick where to
start it again and Lex resumes it for you.
What you copy is a tagged block, so the agent reads real code:
<lex-place n="1" path="/abs/aaa/src/auth.ts" repo="/abs/aaa" file="src/auth.ts" lines="12-15" lang="typescript">
export function login(user: User) {
return retry(() => post("/auth", user))
}
</lex-place>You never type that. You never edit it. Paste it and ask your question.
| Phase | Colour | Sign | Badge |
|---|---|---|---|
| selecting | blue, nvim's own Visual | ||
| pending: copied, not sent | faint blue wash | dashed β |
π 1, your own number |
| history: the link is written | yellow wash | solid β |
π¬ 3 |
| the session is answering right now | green | working⦠|
Put the cursor inside a marked range and the badge grows to show the newest prompt and its age. Nothing moves, no line is inserted. Where ranges overlap the wash goes one tone deeper and the sign column shows two bars.
Files change all day, so a link can be in one of four states:
| State | What happened | What you get |
|---|---|---|
ok |
the code is where it was | the mark |
moved |
the code is elsewhere in the file, or edited | the mark, on the code |
orphaned |
the code is gone | no mark, still in the list, greyed, with the code the conversation saw |
gone |
the agent deleted its transcript | listed, nothing to open |
A link never points at code the conversation did not see. Lex finds your lines again by their text, never by their old line number.
| Command | What |
|---|---|
:LexCopy |
pin this place and copy it for an agent |
:LexLinks |
the conversations on the row under the cursor |
:LexLinks file |
the conversations in this file |
:LexLinks repo |
everything the agents talked about in this project |
:LexWash |
the line wash on or off, for quiet reading |
:LexClear |
forget the copied, unsent places |
:LexInstallHook [claude|codex|opencode] |
wire the writer into an agent |
:checkhealth lex |
is it all installed, and how fast is the hook |
One row is one conversation: the session, the agent, the age, what it holds here, and where its agent is open right now. The preview is the conversation as it happened, turn by turn, with the places each prompt carried.
| Key | What |
|---|---|
<CR>, double click |
go to the agent |
g |
go to the lines |
d |
forget this conversation, or every selected one, after a confirm |
<Tab> |
select a row |
/ |
search |
q |
close |
? |
every key, snacks' own too |
The bottom border of the list shows every key above except q and ?, as many
as fit. The list opens on the rows, not in the search, so every key works at
once.
d removes Lex's memory of a conversation. It never touches the agent's own
history.
To forget every link of one file, for example from a right-click on an explorer
row, call require("lex.picker").forget_file(path). Each conversation loses
only its places in that file. A conversation with no place left disappears from
every list, because Lex keeps no conversation apart from its places.
Everything below is optional.
require("lex").setup({
wash = true, -- the line background under a linked range
icon = "π¬",
pin = "π",
colors = {
lex = "#EACB4A", -- the product colour
tone = { "#2F2C1B", "#3D381F", "#4B4423" }, -- the wash, deeper on overlap
running = "#66AD93",
pending = "#8FB4F0",
},
agents = {
claude = { name = "Claude Code", resume = { "claude", "--resume" } },
codex = { name = "Codex", resume = { "codex", "resume" } },
opencode = { name = "OpenCode", resume = { "opencode", "-s" } },
},
opener = nil, -- fun(t): your machine's own way to a session's window
where = nil, -- fun(loc, snap): what only your machine knows, for the list
terminal = nil, -- nil tries Ghostty, WezTerm, kitty, Alacritty, $TERMINAL,
-- Terminal.app; a name picks one; a function does it your
-- way; false hides the "new terminal" entry
})A fourth agent is one more entry in agents, plus a writer that speaks the
contract.
$LEX_HOME moves the store away from ~/.lex.
A lualine component, π¬ 9/12: the conversations still anchored in this file,
and the ones in the whole project. Left click opens the file, right click opens
the project.
{
function() return require("lex.statusline").component() end,
on_click = function(_, button) require("lex.statusline").click(button) end,
}The snacks explorer, in its format hook, asks Lex about each visible row: the
count, ?N for lost ones, the pin for a pending row, and the wash.
local lex = require("lex.explorer")
local info = lex.info(path, is_dir) -- per row
-- lex.chunks(info) β extmark chunks, right-aligned next to the git letter
-- lex.wash(info) β a line_hl_group name, or nilA whole file or a whole folder is a place too: pick the row in the explorer and pin it. A folder place is one block however many files are under it, and it marks every one of them.
:LexCopy and require("lex").copy() are the same call. It reads the current
selection, the cursor line, or the explorer rows under the mouse, so one menu
item covers every surface:
for _, mode in ipairs({ "n", "x" }) do
vim.cmd(mode .. "noremenu PopUp.π\\ Pin\\ Lex\\ Place <Cmd>LexCopy<CR>")
endTwo details, both learned the hard way. Use nnoremenu and xnoremenu, never
one anoremenu: the a form wraps the Visual variant in <C-C>, which ends
Visual before the command runs, and then there is no selection left to copy. And
use <Cmd>, which keeps the mode alive.
If you would rather build the block yourself:
local place, pending = require("lex.place"), require("lex.pending")
local p = place.for_buffer(buf, from, to) -- or place.for_path(path)
p.n = pending.add(p)
vim.fn.setreg("+", place.block(p))
vim.notify("Lex: copied " .. place.describe(p))Two halves live in this repository.
| Half | What it does | Where |
|---|---|---|
| The writer | a hook inside the agent. It runs on every prompt, finds the <lex-place> blocks in it, and appends one record per place |
agents/ |
| The reader | lex.nvim. It paints the marks, counts the conversations, and takes you back into one |
the repo root |
The writer is small and strict. It never fails your prompt, never prints to
stdout, and runs no git or network. It is a single Lua file run by nvim -l
for Claude Code and Codex, and a TypeScript plugin for OpenCode.
~/.lex/<readable-repo>--<hash>/links.jsonl records and forget markers
~/.lex/sessions/<session>.json live session state
~/.lex/hook.log writer failures, if any
The hash prevents different repository paths from sharing a store. Upgrading from 0.1.0 incrementally imports matching records from the old lossy folder and leaves the old file in place. Forgetting appends a small marker, so it cannot overwrite a prompt another agent writes at the same moment.
contract/ keeps the three writers equal. Run the complete suite with:
sh tests/run.shEvery Lua test runs headless under nvim -l; the OpenCode writer test runs under
Bun when Bun is available. The store format, session lookup, re-anchoring, and
writer contract are described in the architecture guide.
| Component | Supported / tested |
|---|---|
| Neovim | 0.11+; CI tests 0.11 and the current stable release |
| OS | macOS and Linux; Windows is not yet supported |
| snacks.nvim | required for the conversation picker; no minimum version pinned yet |
| Claude Code | hook contract and installer tested; 2.1 tested end to end |
| Codex | 0.154 contract and hook config generation tested; live hook exercise pending |
| OpenCode | 1.18 plugin contract and installer tested; wider live exercise pending |
| tmux | optional; adds pane jumping and tmux resume targets |
| Bun | optional; needed only to run the OpenCode writer test |
Without tmux, Lex can still identify live agent processes and offer supported
terminal launchers. ps and lsof improve live-session discovery when present;
missing system tools degrade to fewer jump targets rather than an error.
Lex is local-only and makes no network requests. The store contains the code
you copy, up to two surrounding lines, absolute paths, the first line of your
prompt, agent session IDs, and transcript paths. By default it lives in
~/.lex; set $LEX_HOME before starting Neovim and your agents to move it.
Deleting a row with d appends a forget marker to Lex's store. It does not
delete the agent's transcript. To erase everything Lex remembers, remove the
store directory after closing Neovim and the agents.
- Remove the plugin from your Neovim plugin specification.
- Remove the Lex hook entries from
~/.claude/settings.jsonand~/.codex/config.toml, and remove~/.config/opencode/plugin/lex.tsif you installed the OpenCode adapter. - Optionally remove
~/.lex(or$LEX_HOME) to delete stored links and session state. This is permanent and does not affect agent transcripts.
Early. The writers, store, marks, picker, installers, and resume flow are covered by automated tests. Claude Code has also run end to end; Codex and OpenCode have local contract and installer coverage but still need broader real-world testing.
PLAN.md holds the design journal: every decision with its date, what was
measured, what was rejected, and what is still open.
MIT.