Introduction
uji is a coding agent for your terminal, configured in Lua. This is a
complete ~/.config/uji/init.lua:
require("uji.defaults")
uji.pack.add({ "uji-labs/uji-plugins" })
require("statusline").setup({})
require("planmode").setup({})
uji.tool.policy({
run_command = { allow = { "git status", "cargo test*" } },
})
uji.keymap.add("normal", "<C-p>", { command = "models" })
uji.command.add("standup", function()
uji.session.submit("Summarise the commits since yesterday.")
end)
- Getting started installs uji and signs you in.
- Configuration shows where your config goes.
- Plugins lists the plugins and their options.
- Examples build tools, commands, a footer, providers and approval rules.
- Rebuilding uji replaces any part of uji with your own Lua.
- API reference lists every
uji.*function. - Runtime covers tasks, the network, processes, storage and the system.
Getting started
This chapter installs uji, signs you in to a provider, and lists the commands and keys you use while you work.
Installation
On macOS and Linux, install uji with Homebrew:
brew install uji-labs/uji/uji
brew upgrade uji moves to a newer release. Macs with Apple silicon and
64-bit Intel Linux get a ready-made build. On other machines Homebrew builds
uji from source, which takes a few minutes.
With Cargo
cargo install --git https://github.com/uji-labs/uji --locked uji
The build needs Rust 1.88 or newer, a C compiler and make. Cargo puts the
uji binary in ~/.cargo/bin, and uji --version prints the version once
your shell finds it.
From a clone of the repository, run cargo install --path crates/uji --locked
in its top folder instead. Adding --force to either command replaces an
installed uji with a newer one.
First run
Start uji in the directory you want to work on.
cd ~/code/project
uji
Signing in
Type /login and pick a provider from the list.
- Anthropic and OpenAI ask how you want to sign in. “Subscription (sign in with browser)” opens the provider’s sign-in page, and “API key” asks for a key.
- A provider that needs a key asks for it. The prompt names the environment
variable uji also reads, such as
ANTHROPIC_API_KEY, and pressing Enter on an empty prompt uses that variable instead. - Custom asks for the
base_urland model of a server that takes OpenAI chat requests.
uji saves keys in auth.toml in the
data directory, readable only by you. To keep them
in the system keychain instead, call
uji.auth.configure in your config.
Choosing a model
/models lists the models of every provider you are signed in to. /effort
sets how much the model reasons, from off to high.
Working with the model
- Enter sends your message. Shift+Enter, Alt+Enter or Ctrl+J start a new line.
- A message you send while the model works waits, and goes out when the turn ends.
- Esc clears the input line. On an empty line, Esc stops the turn.
- A line that starts with
!runs in your shell, in the session’s directory. Its output shows in the transcript, and the model does not see it. - When a tool call needs your approval,
ylets it run.nor Esc refuses it, and you can then tell the model what to do instead.
Sending images
Ctrl+V attaches the image on the clipboard, or pastes its text when it holds
no image. Pasting the path of an image file attaches that file, which is what
dragging a file into most terminals does, and @screenshot.png in a message
attaches the file when you send it.
Each attached image shows as [image #1] on the input line, and deleting the
marker drops the image. uji takes PNG, JPEG, GIF and WebP files. It turns
photos upright and scales images down to 1568 pixels on their long side, then
to a smaller size or JPEG when they would still pass 5 MB.
A request carries the 20 newest images of the conversation, and a note takes the place of older ones. A model that takes no images gets the text with the same kind of note. uji knows this for most built-in models, and learns it for others the first time a model refuses a request with images.
The model can open images on its own as well. read_file on a PNG, JPEG, GIF
or WebP file in the working directory gives it the image.
uji saves every conversation as a session. Commands shows how to go back to one.
Commands
Running uji
| Run | Does |
|---|---|
uji, uji new | Starts a new session. |
uji resume | Resumes the latest session in the current directory. |
uji resume --id <ID> | Resumes the session with that id. |
uji list | Opens a picker of the sessions in the current directory. |
uji delete <ID> | Deletes a session. |
uji run <PROMPT> | Sends one prompt without the screen and prints the answer. See Running without the screen. |
uji --help | Prints the usage. |
uji --version | Prints the version. |
Any of them takes these options. Where uji keeps its files lists the environment variables that do the same.
| Option | Sets |
|---|---|
--config-dir <DIR> | The config directory. |
--data-dir <DIR> | The data directory. |
--db <FILE> | The session database. |
Slash commands
| Command | Does |
|---|---|
/login | Adds a provider. |
/models | Picks the model. |
/effort | Sets reasoning effort: off, minimal, low, medium or high. |
/thinking | Shows or hides the model’s reasoning. |
/compact | Summarises earlier messages to free context. |
/sync | Updates installed packs and reloads. |
/reload | Starts uji again with your current config and files, keeping the conversation and your draft. |
/help | Lists every command, including the ones plugins add. |
/quit | Quits. |
Keys
Default bindings lists every key uji binds, and uji.keymap changes them.
Running a prompt without the screen
uji run sends one prompt, lets the model work until it answers, prints the
answer and exits. Scripts and other programs use it, and so do the agents of
the subagent plugin. It reads the same config,
plugins and packs as uji does on the screen, and saves the conversation as a
session.
uji run "Summarise what changed in the last commit"
The words after the options form the prompt. The exit code is 0 when the
model answers and 1 when the turn fails, with the reason on standard error.
| Option | Does |
|---|---|
--json | Prints every event as a line of JSON instead of the answer alone. |
--model <PROVIDER/ID> | Uses this model instead of the one you picked with /models. |
--effort <LEVEL> | Uses this reasoning effort: off, minimal, low, medium or high. |
--tools <A,B> | Offers the model only these tools, separated by commas. |
--append-prompt <TEXT> | Adds the text to the end of the system prompt. |
--title <TEXT> | Titles the session. The default is the first line of the prompt. |
--parent <ID> | Saves the session under another one. uji list leaves it out, and uji resume --id opens it. |
Nobody can answer an approval question here, so a call your policy would ask about runs without asking. Calls your policy denies stay denied.
The options --config-dir, --data-dir and --db work as they do for the
other commands.
JSON events
With --json, each line is one event.
type | Fields | When |
|---|---|---|
session | id | First, with the id of the new session. |
message | message | A message joined the conversation, in the form uji saves it. |
progress | tool, line | A running tool printed a line. |
notice | text | uji has something to tell you, such as a plugin error. |
done | text or error, and usage | Last. usage has input, output, cache_read and cache_write tokens for the whole run. |
uji run --json --tools read_file "What does src/main.rs do?" | jq -r 'select(.type == "done") | .text'
Configuration
uji reads ~/.config/uji, or the directory in UJI_CONFIG_DIR.
~/.config/uji/
init.lua runs first
plugin/*.lua runs after init.lua, sorted by file name
lua/ modules for require()
init.lua replaces the default config. Start it with
require("uji.defaults") to keep the default screen.
require("uji.defaults")
uji.keymap.add("normal", "<C-p>", { command = "models" })
Where uji keeps its files
| Files | Default | Changed by |
|---|---|---|
| Config directory | ~/.config/uji | --config-dir, UJI_CONFIG_DIR or XDG_CONFIG_HOME |
| Data directory | ~/.local/share/uji | --data-dir, UJI_DATA_DIR or XDG_DATA_HOME |
| Session database | uji.db in the data directory | --db or UJI_DB |
| Installed packs | site/ in the data directory | |
| Pack versions | uji-lock.json in the config directory | |
| Credentials | auth.toml in the data directory | uji.auth.configure |
An option on the command line wins over the UJI_ variable, which wins over
the XDG_ one. uji adds /uji to the XDG_ directories.
Packs
uji.pack.add installs a directory or git repository with
its own lua/ and plugin/. /sync updates them.
uji.pack.add({
"uji-labs/uji-plugins",
{ "someone/tool", tag = "v1.2" },
{ dir = "~/code/my-plugin" },
})
Plugins
The uji-plugins pack holds
optional features. Install it once, then call setup for each plugin you
want.
uji.pack.add({ "uji-labs/uji-plugins" })
statusline
A footer with the directory, model, reasoning effort, context use, tokens, cache hit rate and turns.
require("statusline").setup({ separator = " | " })
| Option | Meaning | Default |
|---|---|---|
defaults | false registers none of the built-in segments. | true |
separator | Text between segments. | " · " |
priority | The window’s layout priority. | 10 |
split | Where the window opens. | "bottom" |
win | An existing window to draw into. | none |
Add a segment of your own with uji.status.add.
planmode
A read-only mode. The model investigates and writes a plan, then you accept it, keep planning, or leave.
require("planmode").setup({ allow = { "npm test" } })
| Option | Meaning | Default |
|---|---|---|
allow | Command prefixes that run without asking while planning. | none |
confirm | false skips the accept picker at the end of a turn. | true |
keys | false leaves Ctrl+B unbound. | true |
priority | The priority of its before_tool hook. | 10 |
Commands are /plan, /plan <task> and /approve. Ctrl+B toggles plan mode.
mcp
Tools from MCP servers, over stdio or HTTP.
require("mcp").setup({
servers = {
files = { cmd = { "npx", "-y", "@modelcontextprotocol/server-filesystem", "." } },
linear = { url = "https://mcp.linear.app/mcp" },
},
})
A server has cmd and optional cwd, or url and optional token. Its
tools are named server__tool, and each call asks you first unless your
policy allows it. /mcp add URL connects a
server and signs you in when it asks, /mcp remove disconnects one, and
/mcp lists them.
telescope
A fuzzy finder.
require("telescope").setup({})
| Key | Command | Does |
|---|---|---|
| Ctrl+P | /find | Opens a file in your editor. |
| Ctrl+A | /attach | Adds @path to the input line. |
| Ctrl+G | /branch | Asks the model about a git branch. |
| Ctrl+R | /history | Refills the input with a past message. |
| Ctrl+F | Searches file contents as you type. | |
/grep <pattern> | Opens a matching file. |
editor sets the program that opens files. It defaults to UJI_EDITOR, then
VISUAL, then EDITOR. keys = false leaves the keys unbound.
skills
Announces Agent Skills folders to the model.
require("skills").setup({ roots = { "~/.agents/skills", ".agents/skills" } })
roots defaults to ~/.agents/skills, .uji/skills and .agents/skills.
/skills lists what it found.
websearch
web_search and web_fetch tools. Both go through Exa’s public search
service, so they need no key.
require("websearch").setup({ count = 8 })
| Option | Meaning | Default |
|---|---|---|
policy | Whether the tools ask before they run: "allow", "ask" or "deny". | "allow" |
count | Results per search. | 5 |
chars | Characters of a page that web_fetch returns at most. | 20000 |
timeout | Seconds per request. | 30 |
subagent
A subagent tool that hands tasks to agents. Each agent runs as a separate
uji process with uji run, in a context of its
own, and only its final answer comes back.
require("subagent").setup({ concurrency = 2 })
| Option | Meaning | Default |
|---|---|---|
policy | Whether starting agents asks first: "allow", "ask" or "deny". | "allow" |
scope | Where agents come from when the model does not say: "user", "project" or "both". | "user" |
confirm_project | Ask before running an agent from the project. | true |
max_tasks | The most tasks one call may run at the same time. | 8 |
concurrency | How many of them run at once. | 4 |
output | Bytes of each answer kept when several run at once. | 51200 |
Agents
An agent is a markdown file. The front matter describes it and the text below is added to its system prompt.
---
name: scout
description: Looks things up in the code and reports back with paths and lines
tools: read_file, run_command
model: anthropic/claude-haiku-4-5
effort: low
---
You are a scout. Find what you were asked for and report it briefly.
| Field | Meaning |
|---|---|
name | The agent’s name. The default is the file name. |
description | Required. The model reads it to choose an agent. |
tools | The tools the agent gets, separated by commas, with or without brackets. The default is every tool. |
model | The model, written as provider/model. The default is yours. |
effort | The reasoning effort. The default is yours. |
uji looks for agents in agents/ in your config directory and in each pack.
With the project or both scope, it also reads .uji/agents/ in the
session’s directory, or in the nearest directory above it that has one. An
agent there replaces one of yours with the same name. A project’s agents ask before they
run, unless confirm_project is false, because the repository controls
them.
Commands
| Command | Does |
|---|---|
/agent <name> <task> | Asks the model to run that agent on the task with the subagent tool. The agent’s answer shows under the tool call, and the model replies with what it found. |
/agent | Lists the agents. Picking one puts /agent <name> in the input. |
The tool
| Arguments | Runs |
|---|---|
agent and task | One task. |
tasks | A list of { agent, task } that run at the same time. |
chain | A list of { agent, task } that run in order. {previous} in a task is replaced by the answer of the step before, and a failed step stops the chain. |
Each item may also have cwd, the directory it works in. scope picks where
agents come from for this call.
An agent’s tool calls run without asking, except the ones your policy denies.
Its conversation is saved as a session under yours, which
uji resume --id <ID> opens. Interrupting the turn stops every agent it
started. The agents themselves do not get the subagent tool.
readonly
Refuses edits in the directories you list, so the model can read them but not change them.
uji.tool.roots({ "~/reference/other-project" })
require("readonly").setup({ "~/reference/other-project" })
Examples
Each example is Lua that goes in ~/.config/uji/init.lua or in a file in
~/.config/uji/plugin/.
A tool of your own
Put these tools in ~/.config/uji/plugin/tools.lua, or in init.lua.
A tool that answers at once
run returns the result as a string.
uji.tool.add("branch", {
description = "Name of the current git branch.",
parameters = { type = "object", properties = {} },
policy = "allow",
display = { verb = "Checked the branch" },
run = function()
return io.popen("git branch --show-current"):read("*l") or "not a git repository"
end,
})
A tool that waits on a process
run starts a job and returns job.stop, so an interrupt kills it. The job
calls ctx.done when it exits.
uji.tool.add("count_todos", {
description = "Count TODO comments per file in the working directory.",
parameters = { type = "object", properties = {} },
policy = "allow",
display = { verb = "Counted TODOs" },
run = function(_, ctx)
local lines = {}
local job = uji.job.start({
cmd = { "rg", "--count-matches", "TODO" },
on_stdout = function(line)
lines[#lines + 1] = line
ctx.progress(line)
end,
on_exit = function(code)
if code == 1 then
ctx.done("no TODO comments")
else
ctx.done(table.concat(lines, "\n"))
end
end,
})
return job.stop
end,
})
A tool with arguments and a subject
subject returns the URL, so the approval question shows it and the policy
matches it.
uji.tool.add("fetch_url", {
description = "Download a web page and return its body.",
parameters = {
type = "object",
properties = { url = { type = "string", description = "The page to fetch." } },
required = { "url" },
},
subject = function(args)
return args.url
end,
policy = "ask",
display = { verb = "Fetched", question = "Fetch this page?" },
run = function(args, ctx)
return uji.http.request({ url = args.url }, function(response, err)
if not response then
ctx.done("error: " .. err)
else
ctx.done(response.body:sub(1, 20000))
end
end)
end,
})
uji.tool.policy({
fetch_url = { allow = { "https://docs.rs/*" } },
})
A slash command
/switch lists your git branches in a picker and switches to the one you
choose. Alt+G opens it.
uji.command.add("switch", {
desc = "switch git branch",
handler = function()
local branches = {}
uji.job.start({
cmd = { "git", "branch", "--format=%(refname:short)" },
on_stdout = function(line)
branches[#branches + 1] = line
end,
on_exit = function()
uji.ui.select({ title = "Switch to", items = branches }, function(choice)
if not choice then
return
end
uji.job.start({
cmd = { "git", "switch", choice },
on_exit = function(code)
uji.notify(code == 0 and ("on " .. choice) or ("git switch failed with " .. code))
uji.emit("status_changed", {})
end,
})
end)
end,
})
end,
})
uji.keymap.add("normal", "<A-g>", { command = "switch" })
The handler receives the text typed after the command name.
uji.command.add("ask", function(args)
uji.session.submit("Investigate before changing anything. " .. args)
end)
A footer
A one-line footer with the model and the tokens spent.
require("uji.defaults")
local footer = uji.ui.open_win({ split = "bottom", size = 1, priority = 10 })
uji.status.add("model", function()
return { text = uji.status.model() or "no model", color = "cyan" }
end, { priority = 10 })
uji.status.add("tokens", function()
local usage = uji.session.usage()
if usage.total > 0 then
return { text = usage.total .. " tokens", color = "gray" }
end
end, { priority = 20 })
local function draw()
local spans = {}
for _, part in ipairs(uji.status.render()) do
if #spans > 0 then
spans[#spans + 1] = " "
end
spans[#spans + 1] = part
end
uji.ui.set_lines(footer, { spans })
end
uji.on("status_changed", draw)
uji.on("message_appended", draw)
A provider
An OpenAI-compatible endpoint
A LiteLLM proxy on your machine, or any server that speaks the OpenAI chat format.
uji.provider.add({
id = "litellm",
name = "LiteLLM",
wire = "openai-chat",
base_url = os.getenv("LITELLM_BASE_URL") or "http://localhost:4000",
auth_env = { "LITELLM_API_KEY" },
models = {
{ id = "claude-sonnet-4-5", context = 1000000, output = 64000, reasoning = true },
{ id = "glm-4.6", context = 204800, output = 131072, reasoning = true },
},
})
Changing a built-in provider
With the id of a built-in provider, uji.provider.add changes only the fields
you give.
uji.provider.add({ id = "openai", base_url = "https://llm-proxy.internal/v1" })
uji.provider.add({
id = "anthropic",
models = { { id = "claude-fable-5-1", context = 1000000, output = 128000, reasoning = true } },
})
An approval rule
Rules in the policy
Read-only git commands and the tests run without asking, and force-pushes are refused.
uji.tool.policy({
run_command = {
allow = { "git status", "git diff*", "git log*", "cargo test*" },
deny = { "/git push.*(--force|-f)/" },
},
})
A hook for decisions that need code
Refuses git push, and asks before commands that reach the network.
uji.on("before_tool", function(call)
if call.name ~= "run_command" then
return nil
end
local command = call.arguments.command or ""
if command:match("^git push") then
return { deny = "Pushing is my job. Tell me when the branch is ready." }
end
if command:match("curl") or command:match("wget") then
return { ask = "This command reaches the network. Run it?" }
end
end, { priority = 10 })
The system prompt
Your config can change the instructions that the model gets with every message you send.
Adding project notes
Adds NOTES.md from the working directory to the system prompt on every
turn.
uji.context.add("notes", function()
local file = io.open(uji.session.info().directory .. "/NOTES.md")
if not file then
return nil
end
local notes = file:read("a")
file:close()
return "Project notes from NOTES.md:\n\n" .. notes
end)
A reminder at the end of the turn
Text returned with at = "turn" goes after the conversation instead of into
the system prompt.
uji.context.add("reminder", function()
return { text = "Run the tests before you say you are done.", at = "turn" }
end)
Rewriting the whole prompt
A before_turn hook returns a new system prompt.
uji.on("before_turn", function(turn)
return turn.system .. "\n\nWrite commit messages in the imperative mood."
end)
To replace the prompt entirely, override the built-in uji.prompt module, as
Rebuilding uji shows.
Rebuilding uji
Everything uji does, from startup to the screen, is written in Lua in the
uji module tree, and you can replace any file in it. A file at
lua/uji/<path>.lua in your config directory or in a pack replaces the
built-in module uji.<path>. A module that is a folder, such as uji.agent,
lives in lua/uji/agent/init.lua.
A replacement is a whole file. uji loads yours instead of the built-in one, so
the easiest start is a copy of the original from the lua/uji folder of the
uji source.
For example, ~/.config/uji/lua/uji/prompt.lua:
local M = {}
function M.system(env)
return "You are a careful coding agent. Work in " .. env.directory .. "."
end
return M
Where things live
Path in lua/uji/ | What it does |
|---|---|
boot.lua | Starts uji. It reads the command line, opens the session and starts the screen. |
cli.lua | The command line and uji --help. |
paths.lua | Where the config, the data and the session database are. |
config.lua | Loads your config and plugins, and runs /reload. |
packs.lua | uji.pack, and finding modules in your config and packs. |
api/ | The uji.* functions in the API reference. |
agent/, loop.lua | Runs a turn, which sends the conversation, runs tools, compacts and picks a title. |
prompt.lua | The system prompt. |
model.lua, catalog.lua, providers/ | Providers, models and choosing between them. |
wires/ | The request format of each provider API. |
tools/ | read_file, edit_file, write_file and run_command. |
tool.lua, system/ | Registering tools, the tool policy, file access and processes. |
commands/ | The built-in slash commands, one file each. |
store/ | Sessions and messages in the database. |
auth/ | API keys, auth.toml, the optional keychain and subscription sign-in. |
ui/ | Draws the screen, with its layout, windows, input line, markdown, keys and theme. |
ui/views/ | The transcript, the input line, pickers, prompts and the approval question. |
images.lua | Attaching images from files, pasted paths and the clipboard. |
defaults.lua | The default screen and bindings that require("uji.defaults") loads. |
event.lua, task.lua, plugin.lua, registry.lua, class.lua | Events, tasks, plugin ownership and the building blocks the rest is made of. |
sys/ | require("uji.sys"), a table with a field for each Runtime module, such as fs or json. A field loads its module, uji.sys.fs or uji.sys.json, the first time it is read. uji.* falls back to the same fields for anything api/ does not define. |
The Runtime modules are built into the uji program, and a module with the same
name replaces any of them, as
Native modules describes.
Everything built on them, sys/ included, can be replaced too.
Adding a module
uji loads every file in the commands/, tools/, wires/, providers/ and
api/ folders of lua/uji/, and each file registers itself. A new file such
as ~/.config/uji/lua/uji/commands/hello.lua adds a command without a change
to any other file, and the same works in a pack.
| Folder | What a file there calls |
|---|---|
commands/ | require("uji.command").builtin(name, description, handler). The handler receives the text typed after the name. |
tools/ | require("uji.tool").add(name, spec), with the fields of uji.tool.add. |
wires/ | require("uji.wire").add(name, spec), with the fields of uji.wire.add. |
providers/ | require("uji.catalog").builtin(spec), with the fields of uji.provider.add. |
api/ | Nothing. It sets its own table on uji, such as uji.session. |
Files in a folder load in alphabetical order, so /login lists providers and
the command list shows built-in commands in that order.
uji.modules gives the same list
uji loads from.
When a replacement takes effect
A replacement is used from the first file uji loads, boot.lua included. The
first time a pack that replaces modules is added, uji starts over once while it
starts up, so that the pack’s files are used from the beginning. It remembers
those packs for later starts.
/reload and /sync start uji’s Lua side again with your current files. The
conversation, what you are typing and the screen stay as they are. A turn in
progress has to finish, or be interrupted with Esc, before a reload. Processes
that plugins started, such as MCP servers, are started again.
Replacing everything
Copy the whole lua/uji folder of the uji source into
~/.config/uji/lua/uji. Every module then comes from your copy. A file you
delete from your copy falls back to the built-in one.
To run a complete tree kept somewhere else, set UJI_RUNTIME to the directory
that contains its uji folder. uji then uses none of its built-in files, and
replacements in your config and packs still apply on top.
Such a tree needs one module. uji.boot returns the function that uji runs
with the command line as the first task. uji stops when no task is left, or
after uji.os.exit or uji.os.restart. The modules built into uji stay
available to the tree, such as require("uji.sys.fs"), and
Native modules describes
how to replace them.
If a replacement breaks uji
When uji cannot start, it prints the error and the file it came from. Fix or
remove that file, or start uji with --config-dir pointing at another
directory.
Available APIs
Every function lives under the global uji table. A function that finishes later takes a callback as its last argument, which receives the result, or nil and an error message. Called without the callback from a slash command, a key binding or a timer, it waits and returns the result instead. A function that starts background work returns a function that stops it. Wrong arguments raise an error at the call.
Tools
| Name | Does |
|---|---|
uji.tool.add(name, spec) | Registers a tool the model can call, or replaces the tool with the same name. |
uji.tool.remove(name) | Removes a tool and returns true if it existed. |
uji.tool.list() | Returns the names of every registered tool. |
uji.tool.disable(names) | Turns tools off. The model still sees them, and uji denies any call to them. |
uji.tool.enable(names) | Turns tools back on after uji.tool.disable. |
uji.tool.policy(rules) | Sets which tool calls run without asking, which ask first, and which uji refuses. |
uji.tool.confine(enabled) | With true, limits read_file, edit_file and write_file to the working directory and the roots from uji.tool.roots. |
uji.tool.roots(paths) | Replaces the directories the file tools may reach besides the working directory, and returns the list. |
Commands
| Name | Does |
|---|---|
uji.command.add(name, spec) | Registers /name, or replaces the Lua command with the same name. |
uji.command.remove(name) | Removes a Lua command and returns true if it existed. |
uji.command.list() | Returns the names of the Lua commands in alphabetical order. |
Keys
| Name | Does |
|---|---|
uji.keymap.add(mode, key, binding) | Binds a key in one mode, replacing what the key did there. |
uji.keymap.remove(mode, key) | Unbinds a key in one mode, including a default binding. |
uji.keymap.reset() | Restores the default bindings. |
uji.keymap.list() | Returns one row per binding, with mode, key, and one of action, command or unbound = true. |
Actions
| Name | Does |
|---|---|
uji.action.add(name, handler) | Registers an action. |
uji.action.remove(name) | Removes an action you added and returns true if it existed. |
uji.action.list() | Returns the names of every action, built-in and added, in alphabetical order. |
Windows, pickers and appearance
| Name | Does |
|---|---|
uji.ui.open_win(opts) | Opens a window and returns its id. |
uji.ui.set_lines(id, lines) | Replaces a window’s content. |
uji.ui.clear(id) | Empties a window. |
uji.ui.set_size(id, size) | Changes a window’s size to rows or columns, "fill" or "auto". |
uji.ui.set_title(id, title) | Sets the title in a window’s border, or removes it when title is nil. |
uji.ui.close_win(id) | Closes a window and returns true if it was open. |
uji.ui.select(opts, on_done) | Shows a list to choose from. |
uji.ui.pick(opts, on_done) | Shows a fuzzy finder with a preview pane. |
uji.ui.prompt(opts, on_done) | Asks for a line of text. |
uji.ui.exec(cmd) | Hides uji, runs a program in the terminal, and comes back when it exits. |
uji.ui.configure(opts) | Sets colours and screen behaviour. |
Status and footer
| Name | Does |
|---|---|
uji.status.provider() | Returns the name of the current provider, or nil before one is set. |
uji.status.model() | Returns the current model id, or nil before one is set. |
uji.status.effort() | Returns the reasoning effort, such as "medium", or nil when reasoning is off. |
uji.status.context() | Returns a table with used, the estimated tokens in the conversation, and window, the model’s context size when uji knows it. |
uji.status.queue() | Returns the messages you typed while the model worked, which uji has not sent yet. |
uji.status.state() | Returns "working" while a turn runs and "idle" otherwise. |
uji.status.elapsed() | Returns the seconds since the current turn started, or nil when idle. |
uji.status.loader_frame() | Returns the loader frame to draw now, from waiting.loader.frames in uji.ui.configure. |
uji.status.add(name, render, opts) | Registers a footer segment. |
uji.status.remove(name) | Removes a segment and returns true if it existed. |
uji.status.list() | Returns the segment names in priority order. |
uji.status.render() | Calls every segment in priority order and returns the values that are not nil. |
Model context
| Name | Does |
|---|---|
uji.context.add(name, provide, opts) | Registers a function that uji calls at the start of every turn. |
uji.context.remove(name) | Removes a context function and returns true if it existed. |
uji.context.list() | Returns the names of the context functions, in the order uji calls them. |
uji.context.configure(opts) | Sets how long the provider caches the conversation, and when uji compacts. |
Events
| Name | Does |
|---|---|
uji.on(event, handler, opts) | Adds a handler for an event and returns the handler’s name. |
uji.off(event, name) | Removes the handler with that name and returns true if it existed. |
uji.emit(event, payload) | Runs the handlers of any event with the payload you give. |
Session
| Name | Does |
|---|---|
uji.session.info() | Returns a table with the session’s id, title and directory. |
uji.session.messages() | Returns the transcript as a list of tables with type and text. |
uji.session.usage() | Returns the tokens spent in this session. |
uji.session.set_title(title) | Renames the session, saves the name, and fires session_titled. |
uji.session.submit(text) | Sends a message as if you typed it. |
uji.session.interrupt() | Stops the current turn, or the running ! command. |
Input line
| Name | Does |
|---|---|
uji.input.get() | Returns the text on the input line. |
uji.input.set(text) | Replaces the text on the input line. |
uji.input.append(text) | Adds text to the end of the input line. |
uji.input.clear() | Empties the input line. |
uji.input.attach(path) | Attaches an image file to the message on the input line. |
uji.input.capture(handler) | Sends every key press to handler instead of the normal bindings, until uji.input.release runs. |
uji.input.release() | Returns the keyboard to the normal bindings. |
Providers
| Name | Does |
|---|---|
uji.provider.add(spec) | Adds a provider, or merges spec into the provider with the same id. |
uji.provider.remove(id) | Removes a provider and returns true if it existed. |
uji.provider.list() | Returns one table per provider with id, name, wire, base_url and models. |
uji.auth.configure(opts) | Chooses between auth.toml and the system keychain for API keys and sign-ins. |
Request formats
| Name | Does |
|---|---|
uji.wire.add(name, spec) | Registers a wire, or replaces the wire with the same name. |
uji.wire.remove(name) | Removes a wire and returns true if it existed. |
uji.wire.list() | Returns the names of the registered wires. |
Processes
| Name | Does |
|---|---|
uji.job.start(opts) | Starts a process and returns a job table. |
HTTP
| Name | Does |
|---|---|
uji.http.request(opts, on_done) | Sends an HTTP request and returns a function that cancels it. |
Files
| Name | Does |
|---|---|
uji.fs.read(path, on_done) | Reads a whole file. |
uji.fs.lines(path, opts, on_done) | Reads a range of lines from a text file. |
uji.fs.write(path, content, on_done) | Writes a file, creating missing directories. |
JSON
| Name | Does |
|---|---|
uji.json.encode(value) | Turns a Lua value into a JSON string. |
uji.json.decode(text, opts) | Turns a JSON string into a Lua value. |
uji.json.array(table) | Marks a table as a JSON array, so it encodes as [] even when empty. |
Packs
| Name | Does |
|---|---|
uji.pack.add(specs) | Installs and loads packs. |
uji.pack.list() | Returns every directory uji searches for modules and plugin/ files, starting with your config directory. |
uji.pack.update() | Pulls every installed git pack and records the new commits in the lock file. |
Timers and notices
| Name | Does |
|---|---|
uji.schedule(callback) | Runs callback once the code that called it has finished. |
uji.defer(seconds, callback) | Runs callback after a delay and returns a function that cancels it. |
uji.notify(message) | Shows a notice in the transcript. |
Runtime
| Name | Does |
|---|---|
uji.task.spawn(fn, ...) | Starts a function as a task that runs alongside the rest of uji. |
uji.sleep(seconds) | Pauses the current task. |
uji.task.race(fn, ...) | Runs functions at once and returns the first to finish. |
uji.task.timeout(seconds, fn) | Runs a function with a time limit. |
uji.promise() | Returns a promise that tasks can wait on. |
uji.net.request(opts) | Sends an HTTP request and returns the answer. |
uji.net.open(opts) | Sends an HTTP request and streams the answer. |
uji.net.listen(port) | Accepts connections on a local port. |
uji.proc.spawn(argv, opts) | Starts a process. |
uji.db.open(path) | Opens a SQLite database. |
uji.os | Reads the platform, the environment and the clock. |
uji.modules(namespace) | Lists the modules inside a namespace. |
uji.keychain | Reads and writes secrets in the system keychain. |
uji.clipboard | Reads and writes the system clipboard, and reads images from it. |
uji.regex(pattern) | Compiles a regular expression. |
uji.glob(pattern, opts) | Compiles a glob. |
uji.fuzzy(query, items) | Ranks strings against a query. |
uji.markdown(source) | Parses Markdown into events. |
uji.width(text) | Measures text in terminal columns. |
uji.lossy(data) | Turns bytes into valid UTF-8. |
uji.base64 | Encodes and decodes base64. |
uji.toml | Reads and writes TOML. |
uji.sha256(data) | Hashes data. |
uji.random(count) | Returns random bytes. |
uji.image.fit(data, edge, bytes) | Checks an image and fits it to a size and a byte limit. |
uji.tool
uji ships four tools, read_file, edit_file, write_file and
run_command. The functions below add your own, switch tools off and on, and
decide which calls need your approval.
uji.tool.add(name, spec)
Registers a tool the model can call, or replaces the tool with the same name.
| Field | Type | Required | Meaning |
|---|---|---|---|
description | string | no | What the tool does. The model reads this to decide when to call it. |
parameters | table | no | A JSON Schema for the arguments, written as a Lua table. |
run | function | yes | Runs the call. See below. |
subject | string or function | no | What policy rules match against, and what the transcript and approval question show. A function receives the arguments and returns a string. The default is the tool’s name. |
policy | string | no | "allow", "ask" or "deny". Used when no policy rule matches. |
display.verb | string | no | The transcript line, such as "Read", which uji follows with the subject. |
display.question | string | no | The title of the approval question. |
run(args, ctx) receives the decoded arguments and a context table. It returns
the result in one of three ways:
- It returns a string, which becomes the result at once.
- It returns nothing and calls
ctx.done(text)later. - It returns a function and calls
ctx.done(text)later. uji calls that function to stop the work if you interrupt the turn.
A result may also be a table with text and images, in any of the three
ways. images is a list in the format the
uji.wire.add request describes. The model gets
the images with the text, and after_tool handlers see only the text. An
image with no supported media_type, no data or more than 5 MB is left out,
and a note at the end of the text says so.
ctx.progress(line) shows a line under the running tool while it works.
Raises an error when run is missing, policy is not one of the three
values, or subject is neither a string nor a function.
uji.tool.add("branch", {
description = "Name of the current git branch.",
parameters = { type = "object", properties = {} },
policy = "allow",
display = { verb = "Checked the branch", question = "Read the current branch?" },
run = function()
return io.popen("git branch --show-current"):read("*l")
end,
})
uji.tool.remove(name)
Removes a tool and returns true if it existed.
uji.tool.remove("write_file")
uji.tool.list()
Returns the names of every registered tool.
for _, name in ipairs(uji.tool.list()) do
uji.notify(name)
end
uji.tool.disable(names)
Turns tools off. The model still sees them, and uji denies any call to them. Plan mode uses this to take away the editing tools.
uji.tool.disable({ "edit_file", "write_file" })
uji.tool.enable(names)
Turns tools back on after uji.tool.disable.
uji.tool.enable({ "edit_file", "write_file" })
uji.tool.policy(rules)
Sets which tool calls run without asking, which ask first, and which uji
refuses. rules maps a tool name to a table with allow, ask and deny
lists and an optional default. A top-level default applies to every tool.
uji.tool.policy({
default = "ask",
run_command = {
allow = { "git status", "git diff *", "cargo test*" },
deny = { "/^rm\\s+-rf/" },
},
})
Each rule matches the tool’s subject: the path for the file tools, the command
line for run_command, and the subject of a tool you add. A rule is an
exact string, a glob when it contains *, ? or [, or a regular expression
between slashes.
uji checks deny rules first, then allow, then ask, and uses the first
match. With no match, it uses the tool’s default, then the policy the tool
declares, then the top-level default, then ask. read_file declares
allow and the other built-in tools declare ask.
Each call replaces the tools it names and keeps the others. A
before_tool hook runs before the policy and
overrides it.
uji.tool.confine(enabled)
With true, limits read_file, edit_file and write_file to the working
directory and the roots from uji.tool.roots. A ../ path or a symbolic link
cannot reach outside them. run_command is not limited. With false, lifts
the limit. Returns whether the limit is on, so calling it with no argument
reads the setting.
uji.tool.confine(true)
local confined = uji.tool.confine()
uji.tool.roots(paths)
Replaces the directories the file tools may reach besides the working
directory, and returns the list. A path may start with ~/. Calling it with no
argument returns the list without changing it.
uji.tool.roots({ "~/reference/other-project" })
local roots = uji.tool.roots()
uji.command
These functions manage the slash commands that your config and plugins add. Slash commands lists the built-in ones.
uji.command.add(name, spec)
Registers /name, or replaces the Lua command with the same name. spec is a
function, or a table with these fields.
| Field | Type | Required | Meaning |
|---|---|---|---|
handler | function | yes | Runs the command. It receives the text typed after the name. |
desc | string | no | The description in the suggestion list. |
force | boolean | no | With true, replaces a built-in command with the same name. |
Raises an error when spec is neither a function nor a table with a
handler.
uji.command.add("standup", {
desc = "summarise yesterday's commits",
handler = function(args)
uji.session.submit("Summarise the commits since yesterday. " .. args)
end,
})
uji.command.remove(name)
Removes a Lua command and returns true if it existed.
uji.command.remove("standup")
uji.command.list()
Returns the names of the Lua commands in alphabetical order. Built-in commands are not in the list.
local names = uji.command.list()
uji.keymap
A binding belongs to one of five modes.
| Mode | Where it applies |
|---|---|
normal | The input line. |
suggest | The command list that opens when you type /. |
select | Pickers and lists. |
prompt | Text prompts. |
confirm | The approval question. |
A key is a single character such as "q", or a chord in angle brackets.
<C-x> is Ctrl, <A-x> or <M-x> is Alt, <S-x> is Shift, and they
combine, as in <C-A-x>. The named keys are CR, Esc, BS, Del, Tab,
S-Tab, Left, Right, Up, Down, Home, End, PageUp, PageDown,
Insert, Space, lt for <, gt for >, and F1 to F12.
uji.keymap.add(mode, key, binding)
Binds a key in one mode, replacing what the key did there.
| Argument | Type | Meaning |
|---|---|---|
mode | string | One of the five modes. |
key | string | A key or chord. |
binding | string, table or function | An action name from the list below, { command = "name" } to run a slash command, or a function. |
Raises an error for an unknown mode, a key uji cannot parse, or a binding of another type.
uji.keymap.add("normal", "<C-p>", { command = "models" })
uji.keymap.add("normal", "<C-l>", "clear_input")
uji.keymap.add("normal", "<A-i>", function()
uji.session.interrupt()
end)
uji.keymap.remove(mode, key)
Unbinds a key in one mode, including a default binding.
uji.keymap.remove("normal", "<C-t>")
uji.keymap.reset()
Restores the default bindings.
uji.keymap.reset()
uji.keymap.list()
Returns one row per binding, with mode, key, and one of action,
command or unbound = true.
for _, row in ipairs(uji.keymap.list()) do
if row.command then
uji.notify(row.key .. " runs /" .. row.command)
end
end
Default bindings
| Keys | Action | Modes |
|---|---|---|
| Ctrl+C | quit | all |
| Ctrl+A, Ctrl+E | cursor_start, cursor_end | normal, suggest, prompt, select |
| Ctrl+B, Ctrl+F | cursor_left, cursor_right | normal, suggest, prompt, select |
| Alt+B, Alt+F | word_left, word_right | normal, suggest, prompt, select |
| Ctrl+H | backspace | normal, suggest, prompt, select |
| Ctrl+D | delete_forward | normal, suggest, prompt, select |
| Ctrl+W, Alt+Backspace | delete_word_back | normal, suggest, prompt, select |
| Alt+D | delete_word_forward | normal, suggest, prompt, select |
| Ctrl+U, Ctrl+K | delete_to_start, delete_to_end | normal, suggest, prompt, select |
| Ctrl+Y | yank | normal, suggest, prompt, select |
| Ctrl+T | transpose | normal, suggest, prompt, select |
| Ctrl+P, Ctrl+N | history_prev, history_next | normal |
| Ctrl+P, Ctrl+N | modal_up, modal_down | select, suggest, confirm |
| Ctrl+G | modal_cancel | select, suggest, confirm |
| Shift+Enter, Alt+Enter, Ctrl+J | insert_newline | normal |
| Ctrl+V | paste_image | normal |
Enter, Esc, Tab, the arrow keys, and y and n in the approval question work
without a binding. A binding on one of these keys overrides it.
Actions
nothing, quit, interrupt, toggle_thinking, submit, clear_input,
backspace, delete_forward, delete_word_back, delete_word_forward,
delete_to_start, delete_to_end, yank, paste_image, transpose, insert_newline,
cursor_left, cursor_right, cursor_start, cursor_end, word_left,
word_right, scroll_up, scroll_down, page_up, page_down,
scroll_top, scroll_bottom, history_prev, history_next, modal_up,
modal_down, modal_accept, modal_cancel, suggest_complete,
confirm_allow, confirm_deny, confirm_toggle.
uji.action.add adds your own.
uji.action
Actions are named functions that keys can run. uji has built-in actions, listed in Keys, and you can add your own.
uji.action.add(name, handler)
Registers an action. A key bound to name then runs handler with no
arguments. Raises an error when name belongs to a built-in action.
uji.action.add("insert_date", function()
uji.input.append(os.date("%Y-%m-%d"))
end)
uji.keymap.add("normal", "<A-d>", "insert_date")
uji.action.remove(name)
Removes an action you added and returns true if it existed.
uji.action.remove("insert_date")
uji.action.list()
Returns the names of every action, built-in and added, in alphabetical order.
local actions = uji.action.list()
uji.ui
uji.ui.open_win(opts)
Opens a window and returns its id. The default config opens three.
uji.ui.open_win({ view = "messages", split = "top", size = "fill", wrap = true })
uji.ui.open_win({ view = "input", split = "bottom", size = "auto", border = "horizontal" })
uji.ui.open_win({ view = "modal", split = "bottom", size = "auto" })
| Option | Values | Default |
|---|---|---|
view | "messages" for the transcript, "input" for the input line, "modal" for pickers, prompts and approval questions. Leave it out for a window you draw into. | none |
split | "top", "bottom", "left" or "right" | "top" |
size | rows or columns, "fill", or "auto" to fit the content | "fill" |
border | "none", "plain", "rounded" or "horizontal" | "none" |
border_color | a colour | the theme’s |
title | text in the border | none |
wrap | wrap long lines | false |
padding | blank cells around the content | 0 |
priority | layout order, lowest first. In a bottom split, lower sits closer to the bottom edge. | 50 |
float | { width = ..., height = ... }, each "80%" or a cell count | not floating |
Raises an error for an unknown view, split, border or size.
uji.ui.set_lines(id, lines)
Replaces a window’s content. Each line is a list of spans. A span is a string,
or a table with text and any of color, bg, bold, italic and
underline. A line may also be a single span.
local panel = uji.ui.open_win({ split = "bottom", size = 2 })
uji.ui.set_lines(panel, {
{ { text = "build", bold = true }, " passing" },
{ text = "3 files changed", color = "gray" },
})
uji.ui.clear(id)
Empties a window.
local panel = uji.ui.open_win({ split = "bottom", size = 1 })
uji.ui.clear(panel)
uji.ui.set_size(id, size)
Changes a window’s size to rows or columns, "fill" or "auto".
local panel = uji.ui.open_win({ split = "bottom", size = 0 })
uji.ui.set_size(panel, 3)
uji.ui.set_title(id, title)
Sets the title in a window’s border, or removes it when title is nil.
local panel = uji.ui.open_win({ split = "right", size = 30, border = "plain" })
uji.ui.set_title(panel, "todo")
uji.ui.close_win(id)
Closes a window and returns true if it was open.
local panel = uji.ui.open_win({ split = "bottom", size = 1 })
uji.ui.close_win(panel)
uji.ui.select(opts, on_done)
Shows a list to choose from. opts.title is the title and opts.items is a
list of strings. on_done receives the chosen item, or nil if you cancel.
Without on_done, the call waits and returns the chosen item.
uji.ui.select({ title = "Branch", items = { "main", "dev" } }, function(choice)
if choice then
uji.notify("picked " .. choice)
end
end)
uji.ui.pick(opts, on_done)
Shows a fuzzy finder with a preview pane. on_done receives the chosen item,
or nil if you cancel. Without on_done, the call waits and returns the
chosen item.
| Option | Type | Meaning |
|---|---|---|
title | string | The title. |
items | list of strings | The items to filter. |
preview | function | Receives the highlighted item and returns lines to show. Without it, an item like path:line: shows that part of the file. |
on_query | function | Makes the list live. Receives the query each time typing pauses, and a show(items) function that replaces the list. |
uji.ui.pick({
title = "Search",
on_query = function(query, show)
local hits = {}
uji.job.start({
cmd = { "rg", "--line-number", "--no-heading", query },
on_stdout = function(line)
hits[#hits + 1] = line
end,
on_exit = function()
show(hits)
end,
})
end,
}, function(choice)
if choice then
uji.notify(choice)
end
end)
uji.ui.prompt(opts, on_done)
Asks for a line of text. opts.title is the question, opts.value fills the
line, and opts.hidden = true masks the input. on_done receives the text,
or nil if you cancel. Without on_done, the call waits and returns the text.
uji.ui.prompt({ title = "Commit message" }, function(message)
if message and message ~= "" then
uji.session.submit("Commit the staged changes with the message: " .. message)
end
end)
uji.ui.exec(cmd)
Hides uji, runs a program in the terminal, and comes back when it exits.
cmd is a string, run through sh -c, or a list of the program and its
arguments.
uji.ui.exec("git log --oneline | less")
uji.ui.configure(opts)
Sets colours and screen behaviour. Each call changes only the keys it names. Raises an error for an unknown key or an invalid colour.
uji.ui.configure({
theme = { accent = "#c65036", user_bg = "#2b2b2b" },
input = { cursor_blink = false },
waiting = { loader = { frames = { "-", "\\", "|", "/" }, interval = 0.1 } },
confirm = { title = "Run this?", yes = "Run", no = "Skip" },
})
| Key | Meaning | Default |
|---|---|---|
show_thinking | Show the model’s reasoning. /thinking toggles it. | false |
input.cursor_blink | Blink the cursor on the input line. | true |
suggest.enabled | Show command suggestions when you type /. | true |
suggest.max_height | Rows the suggestion list may use. | 5 |
waiting.loader.frames | Strings the loader cycles through while the model works. | none |
waiting.loader.interval | Seconds between loader frames. | 0.08 |
confirm.title | The approval question’s title. | "Allow tool call?" |
confirm.yes | The allow label. | "Yes" |
confirm.no | The deny label. | "No" |
theme.text | Body text. | #d4d4d4 |
theme.muted | Secondary text and borders. | #808080 |
theme.code | Inline code. | #e0af68 |
theme.accent | Highlights. | cyan |
theme.user_bg | The background of your messages. | #343541 |
theme.selected_bg | The selected row in lists. | #3a3a4a |
theme.cursor | The cursor. | white |
theme.error | Errors. | red |
theme.notice | Notices. | red |
theme.input | Text on the input line. | theme.text |
theme.confirm_title | The approval question’s title. | theme.text |
theme.confirm_body | The approval question’s details. | theme.text |
theme.confirm_selected | The chosen answer. | the default style |
theme.confirm_unselected | The other answer. | the default style |
Colours
A colour is #rrggbb or one of black, red, green, yellow, blue,
magenta, cyan, white, gray, dark_gray, light_red, light_green,
light_yellow, light_blue, light_magenta and light_cyan. grey and
dark_grey also work.
uji.status
These functions report what uji is doing right now, and keep the footer segments that a statusline plugin draws.
uji.status.provider()
Returns the name of the current provider, or nil before one is set.
local provider = uji.status.provider()
uji.status.model()
Works like uji.status.provider, but gives the model id.
local model = uji.status.model()
uji.status.effort()
The reasoning effort comes back as a string such as "medium", or as nil
when reasoning is off.
local effort = uji.status.effort() or "off"
uji.status.context()
Returns a table with used, the estimated tokens in the conversation, and
window, the model’s context size when uji knows it.
local context = uji.status.context()
if context.window then
uji.notify(string.format("%d%% of context used", context.used * 100 // context.window))
end
uji.status.queue()
Lists the messages you typed while the model worked that uji has not sent yet.
local waiting = #uji.status.queue()
uji.status.state()
Gives "working" during a turn, and "idle" otherwise.
local busy = uji.status.state() == "working"
uji.status.elapsed()
Counts the seconds since the current turn started. When idle, the result is
nil.
local seconds = math.floor(uji.status.elapsed() or 0)
uji.status.loader_frame()
Picks the loader frame to draw now from waiting.loader.frames in
uji.ui.configure, or an empty string when idle.
local frame = uji.status.loader_frame()
uji.status.add(name, render, opts)
Registers a footer segment. render returns any value the statusline plugin
understands, or nil to hide the segment. opts.priority orders segments,
lowest first, and defaults to 50.
uji.status.add("model", function()
return { text = uji.status.model() or "no model", color = "cyan" }
end, { priority = 10 })
uji.status.remove(name)
Removes a segment. The result is true if it existed.
uji.status.remove("model")
uji.status.list()
Gives the segment names in priority order.
local segments = uji.status.list()
uji.status.render()
Calls every segment in priority order and returns the values that are not
nil. Footer plugins draw from it.
local parts = uji.status.render()
uji.context
These functions control what the model sees besides the conversation. They add your own lines to each turn and decide when uji compacts old messages.
uji.context.add(name, provide, opts)
Registers a function that uji calls at the start of every turn. What it returns decides where the text goes.
| Return | Effect |
|---|---|
| a string | uji appends it to the system prompt. |
{ text = "...", at = "turn" } | uji adds the text after your message and keeps it in the conversation. The transcript does not show it. |
nil | uji adds nothing this turn. |
Text added with at = "turn" stays in the conversation. When it stops being
true, return a line that says so once, such as Plan mode is off. Return text
that changes between turns this way, not as a string. When the system prompt
changes, the provider cannot reuse its cache for the conversation.
opts.priority orders the functions, lowest first. The default is 50. A
function with the same name replaces the earlier one.
uji.context.add("branch", function()
local branch = io.popen("git branch --show-current"):read("*l")
if branch and branch ~= "" then
return "The current git branch is " .. branch .. "."
end
end, { priority = 20 })
uji.context.remove(name)
Removes a context function and returns true if it existed.
uji.context.remove("branch")
uji.context.list()
Returns the names of the context functions, in the order uji calls them.
local names = uji.context.list()
uji.context.configure(opts)
Sets how long the provider caches the conversation, and when uji compacts. When the conversation grows past the model’s context window minus a reserve, uji summarises older messages before the next turn.
| Key | Meaning | Default |
|---|---|---|
cache | How long the provider keeps the conversation cached between turns. "off", "short" for 5 minutes, or "long" for 1 hour. A cache write costs more with "long", and the cache survives longer pauses. It applies to models with cache = true in uji.provider.add. | "short" |
compaction.enabled | Compact automatically. /compact works either way. | true |
compaction.reserve | Tokens to keep free for the reply. | the model’s output limit, or 20,000 when unknown, at most a quarter of the window |
compaction.keep_recent | Tokens of recent messages to keep word for word. | 20000 |
Raises an error for an unknown key or an unknown cache value.
uji.context.configure({
cache = "long",
compaction = { keep_recent = 40000 },
})
Events
Handlers run lowest priority first. For the events under Hooks, uji uses what the handlers return.
uji.on("tool_started", function(event)
uji.notify("running " .. event.name)
end)
Every payload also has an event field that holds the event’s name.
uji.on(event, handler, opts)
Adds a handler for an event and returns the handler’s name.
| Argument | Type | Required | Meaning |
|---|---|---|---|
event | string | yes | The event name. |
handler | function | yes | Receives the payload table. |
opts.priority | integer | no | Lower runs first. The default is 50. |
opts.name | string | no | A name for the handler. A handler with the same name replaces this one. uji picks a name when you leave it out. |
uji.on("turn_finished", function()
uji.notify("done")
end, { name = "notify-done", priority = 90 })
uji.off(event, name)
Removes the handler with that name and returns true if it existed.
local name = uji.on("turn_finished", function() end)
uji.off("turn_finished", name)
uji.emit(event, payload)
Runs the handlers of any event with the payload you give. Plugins use it for
events of their own, and to redraw the footer through status_changed.
uji.emit("status_changed", {})
Notifications
uji ignores what these handlers return.
session_created
A new session started. The payload has session_id.
session_resumed
You resumed a saved session. The payload has session_id.
session_titled
The session got a title. The payload has title.
session_compacted
uji summarised earlier messages to free context. The payload has count, the
number of messages it folded into the summary.
message_submitted
You sent a message. The payload has text.
message_appended
A message joined the transcript. The payload has type and text. The type
is one of user, assistant, tool, system, shell, error,
compaction and context.
queue_changed
The queue of messages you typed while the model worked changed. The payload
has count.
shell_started
A ! command started. The payload has command.
shell_finished
A ! command finished. The payload has command and code, its exit code.
tool_started
A tool call passed approval and started. The payload has name.
turn_finished
The model finished its turn, or you interrupted it.
model_changed
The provider or model changed. The payload has provider and model.
status_changed
Something the footer shows changed, such as the running state, the model or the queue.
loader_ticked
Fires on every loader frame while the model works, for redrawing an animation.
before_quit
uji is about to exit.
Hooks
before_turn
Runs before each turn. The payload has text, the message you sent, and
system, the system prompt. Return a string to replace the system prompt.
Each handler receives the prompt as the previous handler left it.
uji.on("before_turn", function(turn)
return turn.system .. "\n\nAnswer in British English."
end)
before_tool
Runs before each tool call, before the tool policy. The payload has name and
arguments. The first handler to return a decision wins, and uji then skips
the policy. Return nil to leave the decision to the next handler.
| Return | Effect |
|---|---|
{ allow = true } | Run the tool without asking. |
{ deny = "reason" } | Refuse the call. The model sees the reason. |
{ ask = true } | Ask you before running it. |
{ ask = "question" } | Ask you, with your own question as the title. |
uji.on("before_tool", function(call)
if call.name == "run_command" and call.arguments.command:match("^git push") then
return { deny = "Pushing is my job." }
end
end, { priority = 10 })
after_tool
Runs after each tool call, before the result reaches the model. The payload
has name and content. Return a string to replace the result. Each handler
receives the result as the previous handler left it.
uji.on("after_tool", function(result)
return (result.content:gsub("sk%-%w+", "[redacted]"))
end)
render_message
Runs when uji draws a block of the transcript. The payload has type, text,
and for tool results name, and for assistant messages tool_calls. Besides
the message types, type can be notice, pending, thinking or queued.
Return a list of lines, in the uji.ui.set_lines
format, to draw instead of the default. The first handler to return lines
wins.
uji.on("render_message", function(block)
if block.type == "tool" and block.name == "todo" then
return { { { text = " task list updated", color = "gray" } } }
end
end)
uji.session
These functions read the open conversation, and can post to it, rename it or stop the running turn.
uji.session.info()
Returns a table with the session’s id, title and directory.
local here = uji.session.info().directory
uji.session.messages()
Returns the transcript as a list of tables with type and text, and tool
results also carry the tool’s name.
local count = 0
for _, message in ipairs(uji.session.messages()) do
if message.type == "user" then
count = count + 1
end
end
uji.session.usage()
Counts the tokens spent in this session. The table it gives has input,
output, cache_read, cache_write and total, plus last, a table of the
same fields for the latest request, and requests, the number of requests so
far.
local usage = uji.session.usage()
local line = string.format("%d tokens over %d requests", usage.total, usage.requests)
uji.session.set_title(title)
Renames the session, saves the name, and fires session_titled. An empty
title raises an error.
uji.session.set_title("fix the flaky login test")
uji.session.submit(text)
Sends a message as if you typed it. While the model works, uji queues it until the turn ends. Empty text raises an error.
uji.session.submit("Run the tests and fix what fails.")
uji.session.interrupt()
Stops the current turn, or the running ! command.
uji.session.interrupt()
uji.input
These functions read and change what you have typed, and can take over the keyboard.
uji.input.get()
Returns the text on the input line.
local draft = uji.input.get()
uji.input.set(text)
Replaces the text on the input line and puts the cursor at the end. Text such
as /models opens the command list, as typing it would.
uji.input.set("/models")
uji.input.append(text)
Adds text at the end, with the cursor after it.
uji.input.append(" and add a test")
uji.input.clear()
Empties the input line.
uji.input.clear()
uji.input.attach(path)
Attaches the image at path to the message on the input line and adds its
[image #n] marker, then gives true, or nil and an error message when the
file is not a PNG, JPEG, GIF or WebP image. Relative paths start at the
session’s directory.
uji.input.attach("screenshots/login.png")
uji.input.capture(handler)
Sends every key press to handler instead of the normal bindings, until
uji.input.release runs. The handler receives a table with key, such as
"<C-x>", char for a printable key, and ctrl, alt and shift. uji
releases the capture if the handler raises an error.
uji.input.capture(function(event)
if event.key == "<Esc>" then
uji.input.release()
end
end)
uji.input.release()
Returns the keyboard to the normal bindings.
uji.input.release()
uji.provider
uji ships 22 providers, and /login and /models offer every one you add
here as well.
uji.provider.add(spec)
Adds a provider, or merges spec into the provider with the same id.
| Field | Type | Meaning |
|---|---|---|
id | string | Required. The provider’s id. |
name | string | The name /login shows. Required for a new provider. |
wire | string | The request format: "openai-chat", "anthropic", "gemini", or one you added with uji.wire.add. Required for a new provider. |
base_url | string | The API root, such as "https://api.openai.com/v1". Required for a new provider. |
auth_env | list of strings | Environment variables that may hold the API key. |
models | list | Model ids, or tables with id, context, output, reasoning, cache and images. images is true or false when you know whether the model takes images. |
context_window | integer | The context size to assume for a model that does not set one. |
compat | table | Options for the wire, such as the name of the output limit field. On openai-chat, cache_key = true sends the session id as prompt_cache_key, which the OpenAI API uses to reuse its cache. It is on for api.openai.com. bridge_tool_images = true puts a short assistant message between tool results and the images they returned, which Mistral needs, and it is on for api.mistral.ai. |
oauth | table | Subscription sign-in settings. The built-in Anthropic and OpenAI providers show the format. |
When the provider exists, each field you give replaces the old one, except
compat, whose keys merge, and models, which merge by id.
Raises an error for an unknown field, and for a new provider without name,
wire and base_url.
uji.provider.add({
id = "litellm",
name = "LiteLLM",
wire = "openai-chat",
base_url = "http://localhost:4000",
auth_env = { "LITELLM_API_KEY" },
models = {
{ id = "claude-sonnet-4-5", context = 200000, output = 64000, reasoning = true },
},
})
uji.provider.add({ id = "openai", base_url = "https://proxy.example.com/v1" })
uji.provider.remove(id)
Removes a provider and returns true if it existed.
uji.provider.remove("perplexity")
uji.provider.list()
Returns one table per provider with id, name, wire, base_url and
models. Each model has id, context and output.
for _, provider in ipairs(uji.provider.list()) do
if provider.wire == "anthropic" then
uji.notify(provider.name)
end
end
uji.auth
uji.auth.configure(opts)
Chooses where uji keeps API keys and subscription sign-ins.
| Field | Type | Meaning |
|---|---|---|
keychain | boolean | With true, uji saves credentials in the system keychain and looks there first. The default is false. |
Without the keychain, uji keeps credentials in auth.toml in the
data directory, readable only by you. Each
provider gets a table named by its id, and a provider can also be a plain
string, which uji reads as an API key. A keychain that refuses a save sends
the credential to auth.toml as well.
Raises an error for an unknown field, and for a keychain that is not a
boolean.
uji.wire
Wires turn a model call into the HTTP format of one kind of API, and stream
the answer back. uji ships three, openai-chat, anthropic and gemini, as
Lua modules in uji.wires. Copying one of them is the easiest way to start a
new one.
uji.wire.add(name, spec)
Registers a wire, or replaces the wire with the same name. spec.stream is a
function that receives a request and a reply table. It may return a function
that cancels the call.
The request has these fields:
model, the model idsystem, the system promptmessages, the conversation in uji’s message format, where a user or tool message may haveimages, a list of tables withmedia_type, base64data,name,widthandheighttools, a list ofname,descriptionandparameterseffort, one ofoff,minimal,low,mediumandhighmax_output, the output token limitcache, one ofoff,shortandlongsession, the session idprovider, withid,base_urlandcompatauth, withkey, andoauthfor subscription sign-in
The reply table has four functions:
reply.text(delta)streams answer text.reply.reasoning(delta)streams reasoning text.reply.done(answer)finishes the call.answerhastext, and optionallyreasoning,tool_callsandusage. Each tool call hasid,nameandarguments, whereargumentsis a JSON string.usagehasinput,output,cache_readandcache_write.reply.fail(failure)ends the call with an error.failure.kindis"http", withstatus,messageandretry_after, or"auth", withstatus, or"provider", withmessage. uji retrieshttpfailures with a status of 408, 409, 425, 429 or 5xx, andhttpfailures with no status, such as a dropped connection.
uji.wire.add("echo", {
stream = function(request, reply)
local last = request.messages[#request.messages]
reply.text("you said: ")
reply.done({ text = "you said: " .. (last.text or "") })
end,
})
uji.wire.remove(name)
Removes a wire and returns true if it existed.
uji.wire.remove("echo")
uji.wire.list()
Returns the names of the registered wires.
local wires = uji.wire.list()
uji.job
uji.job runs a process in the background and streams its output to Lua.
uji.job.start(opts)
Starts a process and returns a job table.
| Option | Type | Meaning |
|---|---|---|
cmd | string or list | Required. A string runs through sh -c. A list is the program and its arguments. |
cwd | string | The directory to run in. The default is uji’s working directory. |
timeout | number | Seconds before uji kills the process. |
on_stdout | function | Receives each line the process writes to standard output. |
on_stderr | function | Receives each line the process writes to standard error. |
on_exit | function | Receives the exit code. When uji stopped the process, it receives -1 and "timeout" or "stopped". |
The job table has three functions:
job.send(text)writes a line to the process’s standard input, adding a newline iftextlacks one.job.close()closes standard input.job.stop()kills the process.
Raises an error when cmd is missing or empty.
local job = uji.job.start({
cmd = { "git", "status", "--short" },
on_stdout = function(line)
uji.notify(line)
end,
on_exit = function(code)
if code ~= 0 then
uji.notify("git status failed with " .. code)
end
end,
})
job.close()
uji.http
uji.http.request(opts, on_done)
Sends an HTTP request and returns a function that cancels it. on_done
receives a response table with status, headers and body, or nil and an
error message. Header names are lower case.
| Option | Type | Meaning |
|---|---|---|
url | string | Required. The URL. |
method | string | The HTTP method. The default is "GET". |
headers | table | Request headers, name to value. |
body | string | The request body. |
timeout | number | Seconds before the request fails. The default is 30 for a plain request and none for a streamed one. |
on_line | function | Streams the response. uji calls it with each line of the body as it arrives. Return false for a line that is not progress, such as a keep-alive. |
idle | number | For a streamed request, the seconds without progress before uji gives up. The default is 120. |
A non-2xx status still counts as a response. Check status yourself. An
invalid URL, method or header raises an error.
uji.http.request({
url = "https://api.github.com/repos/uji-labs/uji",
headers = { Accept = "application/vnd.github+json" },
}, function(response, err)
if not response then
uji.notify("request failed: " .. err)
elseif response.status == 200 then
uji.notify(uji.json.decode(response.body).description)
end
end)
uji.fs
Every function here goes through the same checks as the file tools. Relative paths start at the working directory, and confinement applies.
uji.fs.read(path, on_done)
Reads a whole file. on_done receives the contents, or nil and an error
message.
uji.fs.read("Cargo.toml", function(text, err)
if text then
uji.notify(#text .. " bytes")
end
end)
uji.fs.lines(path, opts, on_done)
Reads a range of lines from a text file. It refuses directories and binary files.
| Option | Type | Meaning |
|---|---|---|
offset | integer | The first line to return, counting from 1. The default is 1. |
limit | integer | The most lines to return. The default is all of them. |
max_line | integer | Cuts lines longer than this many bytes. |
on_done receives a table with lines, the list of lines, cut, whose keys
are the positions of lines that max_line cut, and total, the number of
lines in the file.
uji.fs.lines("README.md", { offset = 1, limit = 20 }, function(page, err)
if page then
uji.notify(string.format("showing %d of %d lines", #page.lines, page.total))
end
end)
uji.fs.write(path, content, on_done)
Writes a file, creating missing directories. on_done receives a table whose
created field is true when the file did not exist, or nil and an error
message.
uji.fs.write("notes/todo.md", "- write the docs\n", function(result, err)
if result and result.created then
uji.notify("created notes/todo.md")
end
end)
uji.json
Plugins use these to build request bodies and to read saved files and HTTP answers.
uji.json.encode(value)
Turns a Lua value into a JSON string, where an empty table becomes {} unless
uji.json.array marked it. A function, a table that
contains itself, or a key that is not a string or a number raises an error.
local body = uji.json.encode({ model = "gpt-4.1", stream = true })
uji.json.decode(text, opts)
Turns a JSON string into a Lua value. A JSON null becomes uji.json.null,
so it survives a round trip through uji.json.encode, unless you pass
opts.nulls = false, which turns it into nil and drops the key. Invalid JSON
raises an error.
local value = uji.json.decode('{"a": 1, "b": null}', { nulls = false })
uji.json.array(table)
Marks a table as a JSON array, so it encodes as [] even when empty, and
returns the same table.
local body = uji.json.encode({ tools = uji.json.array({}) })
uji.json.null stands for JSON null.
uji.pack
Packs installed here come from GitHub, any git URL or a local folder, and
/sync updates them.
uji.pack.add(specs)
Installs and loads packs. specs is a list, and each entry is one of these:
- a
"user/repo"GitHub shorthand or a git URL - a table with the URL or shorthand first, and one of
tag,branchorcommit - a table with
urlinstead of the first entry - a table with
dir, a local directory that uji never clones
A table may also set name, which defaults to the repository or directory
name. uji reports a pack it cannot install as a notice and keeps going.
Raises an error when specs is not a list.
uji.pack.add({
{ dir = "~/code/my-plugin" },
})
uji.pack.list()
Returns every directory uji searches for modules and plugin/ files, starting
with your config directory.
for _, root in ipairs(uji.pack.list()) do
uji.notify(root)
end
uji.pack.update()
Pulls every installed git pack and records the new commits in the lock file.
/sync does the same and then reloads.
uji.pack.update()
Timers and notices
Both timers run their function as a task, so it can wait.
uji.schedule(callback)
Runs callback once the code that called it has finished. Called from your
config, it runs after uji has loaded the whole config and every plugin.
uji.schedule(function()
uji.notify("config loaded")
end)
uji.defer(seconds, callback)
Runs callback after a delay and returns a function that cancels it.
local cancel = uji.defer(30, function()
uji.notify("thirty seconds passed")
end)
uji.notify(message)
Shows a notice in the transcript.
uji.notify("hello from init.lua")
Runtime
These functions are the building blocks the rest of uji is written with. A plugin can use them to run work in the background, talk to the network, start processes, keep data in SQLite and read the system.
Tasks
Tasks let Lua functions wait without holding up uji. When one waits on the
network, a process, the keychain or a timer, the others and the screen carry
on. Your config, slash commands, key bindings, actions, tool functions and the
timers from uji.schedule and uji.defer are all tasks, so any of them can
wait.
Only one task runs at a time. It keeps running until it waits in a runtime
function that finishes later, in uji.sleep, in promise:await(), or in
uji.task.race and uji.task.timeout, and then another task gets its turn.
So a loop that never waits holds up everything. Waiting outside a task raises
an error. A coroutine you create yourself cannot wait either. A wait inside one
does not finish, and the coroutine hands an internal value back to whoever
resumed it.
uji.task.spawn(fn, …)
Starts fn as a new task with the arguments that follow, and returns a task
object. It begins once the current task waits. Errors show as notices.
task:cancel()
Stops the task where it waits, together with the functions that
uji.task.race and uji.task.timeout run for it. What it was waiting on stops
too, and uji closes the connections and processes that only this task used. A
task that cancels itself stops at its next wait.
uji.sleep(seconds)
Pauses the task for seconds. Fractions work. With 0 the task pauses only
long enough for every other task that is ready to run first, which is how a
long loop can give the screen and the rest of uji their turn. With math.huge
it waits until it is cancelled. Anything other than a number of seconds raises
an error.
uji.task.race(fn, …)
Runs every function at once inside the current task, and waits for the first to finish. The result is its position followed by what it returned, and the others stop there. An error in the first to finish is raised again, and cancelling the current task stops all of them.
uji.task.timeout(seconds, fn)
Runs fn inside the current task for at most seconds, where math.huge
means no limit.
When fn finishes in time the result is true followed by what it returned.
Otherwise fn stops and the result is false.
uji.promise()
Creates a promise. Tasks wait on it until another task settles it.
| Member | Meaning |
|---|---|
promise:resolve(...) | Settles the promise with the values given, and wakes every task waiting on it. The result is true the first time and false after. |
promise:await() | Waits until the promise is settled and gives back its values. A settled promise gives them back at once. |
promise.settled | true once the promise is settled. |
Network
Each call here waits inside the current task, so the rest of uji keeps drawing and taking keys meanwhile.
uji.net.request(opts)
Sends an HTTP request and waits for the answer, a table with status,
headers and body. When no answer comes back the result is nil and an
error message. An error status still counts as an answer.
| Option | Type | Meaning |
|---|---|---|
url | string | The address. Required. |
method | string | The method. The default is "GET". |
headers | table | Header names and values. |
body | string | The request body. |
timeout | number | Seconds to wait for the whole answer. |
uji.net.open(opts)
Sends a request like uji.net.request, but hands back a response object as
soon as the headers arrive, so the body can be read while it streams. It takes
the same options plus idle, the seconds without data after which reading
fails, 120 by default. A failed request gives nil and an error message.
| Member | Meaning |
|---|---|
response.status | The status code. |
response.headers | Header names and values. |
response:line(seconds) | The next line, nil at the end of the body, or false if seconds pass first. Without seconds it waits as long as the line takes. |
response:lines() | An iterator over the remaining lines, for a for loop. |
response:read() | Everything left in the body, once it has arrived. |
uji.net.listen(port)
Listens on port on the local machine, or on a free port when port is 0.
The result is a server, or nil and an error message.
| Member | Meaning |
|---|---|
server.port | The port it listens on. |
server:accept() | The next connection, or nil and an error message once the server is closed. |
server:close() | Stops listening. |
conn:line(seconds) | The next line, nil when the other side closes, or false if seconds pass first. Without seconds it waits as long as the line takes. |
conn:read(count) | Exactly count bytes. |
conn:write(data) | Sends data. |
conn:close() | Closes the connection. |
Processes
uji.proc.spawn(argv, opts)
Starts the program named by the first item of the list argv, with the rest
as its arguments. The result is a process, or nil and an error message. The
process is killed when nothing refers to it any more.
| Option | Type | Meaning |
|---|---|---|
cwd | string | The directory to run in. |
env | table | Extra environment variables. |
stdio | string | "pipe", the default, to read and write the process, or "inherit" to give it the terminal. |
| Member | Meaning |
|---|---|
proc.pid | The process id. |
proc:line() | The next line of output with "stdout" or "stderr", or nil once both streams end. |
proc:lines() | An iterator over the remaining lines and their streams. |
proc:write(data) | Writes data to the process’s input. |
proc:close() | Closes the process’s input. |
proc:kill() | Kills the process. |
proc:wait() | Waits for the process to exit and gives a table with code, signal and success. |
Storage
uji.db.open(path)
Opens or creates the SQLite database at path. The result is a database, or
nil and an error message. Parameters are a list that fills the ? marks in
the statement, and uji.db.null stands for NULL in that list. A statement
that SQLite rejects raises an error, in every method below.
| Member | Meaning |
|---|---|
db:exec(sql, params) | Runs a statement and gives the number of rows it changed. Without params, sql may hold several statements. |
db:query(sql, params) | The rows, as a list of tables keyed by column name. |
db:transaction(fn) | Runs fn in a transaction and gives back what it returns. An error inside fn rolls the transaction back and raises again. fn cannot wait, so no other task can write in the middle of the transaction. |
db:close() | Closes the database. |
System
These describe the machine and the current run, and give access to saved passwords and to what you last copied.
| Function | Gives |
|---|---|
uji.os.platform | "macos", "linux", "windows" or "other". |
uji.os.env(name) | The value of an environment variable, or nil when it is unset or empty. |
uji.os.cwd() | The directory uji started in. |
uji.os.home() | Your home directory, or nil. |
uji.os.now() | The time in milliseconds since the Unix epoch. |
uji.os.clock() | Seconds since uji started, for measuring how long something took. |
uji.os.executable | The path of the program running uji, or nil when the system cannot tell. |
uji.os.argv | The arguments uji started with, the program name first. |
uji.os.roots | The directories whose lua/ and native/ folders are searched before the built-in modules. |
uji.os.carry | The text handed over by the last uji.os.restart, or nil. |
uji.os.library | The file extension of a native module on this system, "dylib" or "so". |
uji.message(err) | The text of an error caught with pcall, without the stack trace that errors from the runtime carry. |
uji.os.restart(opts)
Starts uji’s Lua side again once the current code yields, keeping the screen.
Every task, connection and process from this run stops. opts.args is the
command line for the new run, opts.roots sets uji.os.roots for it, and
opts.carry is text it can read from uji.os.carry.
uji.modules(namespace)
Lists the modules directly inside namespace, such as "uji.commands", as
full names in alphabetical order. It looks in the lua/ folder of every
directory in uji.os.roots and in the built-in modules. A folder with an
init.lua counts as one module.
uji.keychain.get(service, account)
Looks up the secret saved in the system keychain for service and account,
and gives nil when there is none.
uji.keychain.set(service, account, secret)
Saves secret in the system keychain, then gives true, or nil and an
error message.
uji.keychain.delete(service, account)
Removes the secret, with the same result as uji.keychain.set.
uji.clipboard.get()
Reads the text on the system clipboard, or gives nil and an error message.
uji.clipboard.set(text)
Puts text on the system clipboard and gives true, or nil and an error
message.
uji.clipboard.image(edge, bytes)
Takes the image on the system clipboard and fits it the way
uji.image.fit does, and gives the
same table. A clipboard without an image gives nil and “the clipboard has
no image”.
Text
uji.regex(pattern)
Compiles a regular expression into a matcher, or gives nil and an error
message.
uji.glob(pattern, opts)
Compiles a glob such as *.rs into a matcher, or gives nil and an error
message. With opts.separator = true, * does not match /.
| Member | Meaning |
|---|---|
matcher:test(text) | true when the pattern matches somewhere in text. |
matcher:find(text) | The start and end positions of the first match, or nil. |
uji.fuzzy(query, items)
Ranks the list of strings items against query the way the pickers do, as
positions in items with the best match first. An empty query gives every
position in order.
uji.markdown(source)
Parses Markdown into a list of events. Each event is itself a list that starts
with its kind, one of "start", "end", "text", "code", "html",
"break", "rule" and "task". The details of that event come next, then its
start and end byte positions in source.
uji.width(text)
Counts the terminal columns text takes.
uji.lossy(data)
Turns data into valid UTF-8, replacing each invalid byte sequence with
U+FFFD.
Encoding
| Function | Gives |
|---|---|
uji.base64.encode(data, opts) | data in base64. opts.url = true uses the URL alphabet and opts.pad = false leaves out padding. |
uji.base64.decode(text, opts) | The decoded bytes. It takes the same options and raises an error for text that is not base64. |
uji.sha256(data) | The SHA-256 digest of data, as 32 raw bytes. |
uji.random(count) | count random bytes. |
uji.toml.decode(text) | A table with the values in the TOML document text. It raises an error for text that is not valid TOML. |
uji.toml.encode(table) | table written as a TOML document. It raises an error for a value TOML cannot hold, such as a list at the top. |
Images
uji.image.fit(data, edge, bytes)
Checks that data holds a PNG, JPEG, GIF or WebP image and returns it ready to
send. A photo with an EXIF orientation is turned upright, and an image whose
long side passes edge pixels is scaled down. When the result is still larger
than bytes, it is saved as JPEG at lower quality, and then at half the size
until it fits.
The result is a table with four fields. data holds the image encoded as
base64, media_type names its type such as "image/png", and width and
height give its size in pixels. An image that needs no change keeps its
original bytes. Anything else gives nil and an error message.
local image = uji.image.fit(uji.fs.read("shot.png"), 1568, 3 * 1024 * 1024)
Terminal
uji.tty.open()
Opens the terminal and gives two values: the screen and its input. Every call
gives the same two, and they stay open across /reload. This is the terminal
uji draws its own screen on, so anything else drawn on it is replaced at uji’s
next frame. It is mainly for a tree that
replaces everything.
Rows and columns count from 0 at the top left. Drawing only changes the screen
in memory, and screen:flush() shows the changes. A position outside the
screen draws nothing.
local tty = require("uji.sys.tty")
return function()
local screen, input = tty.open()
local title = screen:style({ fg = "cyan", bold = true })
local width, height = screen:size()
screen:clear()
screen:line(0, 0, { { "hello ", title }, "from uji" })
screen:line(1, 0, "the screen is " .. width .. " by " .. height)
screen:line(2, 0, "press q to quit")
screen:cursor(3, 0, "bar")
screen:flush()
for event in input:events() do
if event.type == "key" and event.key == "q" then
break
end
screen:line(3, 0, "you pressed " .. (event.key or event.type) .. " ")
screen:flush()
end
screen:close()
end
The screen
| Member | Meaning |
|---|---|
screen:size() | The width and the height, in columns and rows. |
screen:style(spec) | Makes a style and gives its number, for use in spans, fill and paint. Style 0 is the terminal’s default. |
screen:line(row, col, spans, width) | Draws spans from row and col, and gives the column after the last character. spans is a string, or a list whose items are strings or { text, style } pairs. width stops the text after that many columns. |
screen:fill(row, col, width, height, style, symbol) | Fills the area with symbol, a space when it is left out, in style. |
screen:paint(row, col, width, height, style) | Sets the style of the area and keeps its text. |
screen:text(row) | The text on row, or nil outside the screen. |
screen:clear() | Empties the whole screen. |
screen:cursor(row, col, shape) | Shows the cursor at the position, as "block", the default, "bar" or "underline". With no position it hides the cursor. |
screen:flush() | Shows what changed since the last flush, and puts the cursor in place. |
screen:write(bytes) | Sends bytes to the terminal as they are. |
screen:suspend() | Gives the terminal back, for a program that needs it. |
screen:resume() | Takes the terminal again and clears it, ready for the next flush. |
screen:close() | Gives the terminal back for good. |
A style spec has these fields, and each one can be left out.
| Field | Type | Meaning |
|---|---|---|
fg | string | The text colour. |
bg | string | The background colour. |
bold, dim, italic, underline, blink, reverse, strikethrough | boolean | Turns the attribute on. |
A colour is a name, a number from 0 to 255, or "#rrggbb". The names are
black, red, green, yellow, blue, magenta, cyan, gray,
darkgray, lightred, lightgreen, lightyellow, lightblue,
lightmagenta, lightcyan, white and reset.
A span that is neither a string nor a pair, a colour that is not one of these,
an unknown cursor shape, and a style number that style never gave all raise
an error.
The input
| Member | Meaning |
|---|---|
input:event() | Waits for the next event and gives it as a table. |
input:events() | An iterator over the events, for a for loop. |
Each event has a type field.
type | Fields |
|---|---|
"key" | key, and ctrl, alt, shift, meta and repeat as booleans. |
"mouse" | kind, button, row, col, and ctrl, alt and shift as booleans. |
"paste" | text, the pasted text. |
"resize" | width and height, the new size. |
"focus" | focused, true when the terminal gains focus and false when it loses it. |
key is the character typed, such as "a" or "A", or one of "enter",
"esc", "backspace", "delete", "tab", "backtab", "left", "right",
"up", "down", "home", "end", "pageup", "pagedown", "insert" and
"f1" to "f12".
A mouse kind is "down", "up", "drag", "move", "scroll_up",
"scroll_down", "scroll_left" or "scroll_right". button is "left",
"right" or "middle" for presses, releases and drags, and nil otherwise.
Native modules
A native module is a shared library that Lua loads with require. uji looks
for it in native/ in the config directory and
in every pack. require("name") finds
name.dylib on macOS and name.so on Linux. A dotted name looks in folders,
so require("tools.fast") finds native/tools/fast.dylib. Native modules
work on macOS and Linux only.
The library is a Lua C module for LuaJIT. It exports a function named
luaopen_ followed by the module name, with each dot written as an
underscore, and whatever that function returns is the module. A module can be
written in any language that can export such a function, such as C, Zig or
Rust.
The library does not link LuaJIT itself. It uses the LuaJIT inside uji, which
on macOS needs the linker flag -undefined dynamic_lookup.
A native function runs while every task waits, so a slow one holds up the screen until it returns.
Loading
uji loads a library the first time require asks for it, and keeps it until
uji’s Lua starts again. After you rebuild a library, /reload loads the new
one.
Errors
An error raised inside a native function, such as an argument of the wrong
type, reaches Lua like any other error. pcall catches it, and
uji.message(err) gives its text without a stack trace.
local ok, err = pcall(require("hello").greet)
if not ok then
uji.notify(uji.message(err))
end
Writing one in C
The module needs LuaJIT’s headers, for example from brew install luajit or a
libluajit-5.1-dev package.
#include <lua.h>
#include <lauxlib.h>
static int greet(lua_State *L) {
const char *name = luaL_checkstring(L, 1);
lua_pushfstring(L, "hello %s", name);
return 1;
}
int luaopen_hello(lua_State *L) {
lua_newtable(L);
lua_pushcfunction(L, greet);
lua_setfield(L, -2, "greet");
return 1;
}
On macOS:
cc -shared -undefined dynamic_lookup -I"$(brew --prefix luajit)/include/luajit-2.1" -o hello.dylib hello.c
mkdir -p ~/.config/uji/native && cp hello.dylib ~/.config/uji/native/
On Linux:
cc -shared -fPIC -I/usr/include/luajit-2.1 -o hello.so hello.c
mkdir -p ~/.config/uji/native && cp hello.so ~/.config/uji/native/
uji.notify(require("hello").greet("uji"))
The notice reads hello uji.
Writing one in Rust
A Rust module is a crate with crate-type = ["cdylib"] that depends on mlua
with the luajit52 and module features. #[mlua::lua_module] on a function
that takes &Lua and returns the module exports it as luaopen_ followed by
the function’s name, and #[mlua::lua_module(name = "tools_fast")] exports it
under the name you give instead. Functions, objects and conversions follow
mlua’s own documentation. An object that Lua no longer holds is dropped in
Rust once Lua collects it.
Cargo.toml:
[package]
name = "counter"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
mlua = { version = "0.12", features = ["luajit52", "module"] }
build.rs, which passes the macOS linker flag:
fn main() {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") {
println!("cargo::rustc-cdylib-link-arg=-undefined");
println!("cargo::rustc-cdylib-link-arg=dynamic_lookup");
}
}
src/lib.rs:
#![allow(unused)]
fn main() {
use mlua::prelude::*;
struct Counter {
value: i64,
}
impl LuaUserData for Counter {
fn add_methods<M: LuaUserDataMethods<Self>>(methods: &mut M) {
methods.add_method_mut("bump", |_, this, ()| {
this.value += 1;
Ok(this.value)
});
}
}
#[mlua::lua_module]
fn counter(lua: &Lua) -> LuaResult<LuaTable> {
let module = lua.create_table()?;
module.set("new", lua.create_function(|_, start: i64| Ok(Counter { value: start }))?)?;
Ok(module)
}
}
Build it and copy the library under the module’s name, counter.dylib on
macOS or counter.so on Linux:
cargo build --release
cp target/release/libcounter.dylib ~/.config/uji/native/counter.dylib
local counter = require("counter").new(10)
uji.notify(tostring(counter:bump()))
The notice reads 11.
Replacing a built-in module
Every part of the Runtime is a module named uji.sys. followed by
its name. require looks in these places, in order, and uses the first module
it finds:
- Lua in your config and packs, such as
~/.config/uji/lua/uji/sys/fs.lua. - The Lua that comes with uji.
- Native modules in
native/in your config and packs, such asnative/uji/sys/fs.dylib, which exportsluaopen_uji_sys_fs. - The modules built into uji.
A module you provide under one of these names replaces the built-in one, and everything in uji that uses it uses yours. The other modules stay built in.
| Module | What it is |
|---|---|
uji.sys.task | spawn, race, timeout and on_error, in Tasks. |
uji.sys.sleep | The sleep function, in Tasks. |
uji.sys.promise | The promise function, in Tasks. |
uji.sys.fs | Files and folders, in uji.fs. |
uji.sys.net | Requests and the local server, in Network. |
uji.sys.proc | Processes, in Processes. |
uji.sys.db | SQLite databases, in Storage. |
uji.sys.os | The system and restarting, in System. |
uji.sys.keychain | The keychain, in System. |
uji.sys.clipboard | The clipboard, in System. |
uji.sys.modules | The modules function, in System. |
uji.sys.message | The message function, in System. |
uji.sys.json | JSON, in uji.json. |
uji.sys.toml | TOML, in Encoding. |
uji.sys.base64 | Base64, in Encoding. |
uji.sys.sha256 | The sha256 function, in Encoding. |
uji.sys.random | The random function, in Encoding. |
uji.sys.regex | The regex function, in Text. |
uji.sys.glob | The glob function, in Text. |
uji.sys.fuzzy | The fuzzy function, in Text. |
uji.sys.width | The width function, in Text. |
uji.sys.lossy | The lossy function, in Text. |
uji.sys.markdown | The markdown function, in Text. |
uji.sys.image | Image fitting, in Images. |
uji.sys.tty | The screen and its input, in Terminal. |
This file at ~/.config/uji/lua/uji/sys/width.lua counts every character as
one column. /reload puts it to use.
return function(text)
local _, count = text:gsub("[^\128-\191]", "")
return count
end