Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

uji. A coding agent you can shape with Lua.

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

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_url and 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, y lets it run. n or 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

RunDoes
uji, uji newStarts a new session.
uji resumeResumes the latest session in the current directory.
uji resume --id <ID>Resumes the session with that id.
uji listOpens 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 --helpPrints the usage.
uji --versionPrints the version.

Any of them takes these options. Where uji keeps its files lists the environment variables that do the same.

OptionSets
--config-dir <DIR>The config directory.
--data-dir <DIR>The data directory.
--db <FILE>The session database.

Slash commands

CommandDoes
/loginAdds a provider.
/modelsPicks the model.
/effortSets reasoning effort: off, minimal, low, medium or high.
/thinkingShows or hides the model’s reasoning.
/compactSummarises earlier messages to free context.
/syncUpdates installed packs and reloads.
/reloadStarts uji again with your current config and files, keeping the conversation and your draft.
/helpLists every command, including the ones plugins add.
/quitQuits.

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.

OptionDoes
--jsonPrints 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.

typeFieldsWhen
sessionidFirst, with the id of the new session.
messagemessageA message joined the conversation, in the form uji saves it.
progresstool, lineA running tool printed a line.
noticetextuji has something to tell you, such as a plugin error.
donetext or error, and usageLast. 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

FilesDefaultChanged 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 databaseuji.db in the data directory--db or UJI_DB
Installed packssite/ in the data directory
Pack versionsuji-lock.json in the config directory
Credentialsauth.toml in the data directoryuji.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 = " | " })
OptionMeaningDefault
defaultsfalse registers none of the built-in segments.true
separatorText between segments." · "
priorityThe window’s layout priority.10
splitWhere the window opens."bottom"
winAn 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" } })
OptionMeaningDefault
allowCommand prefixes that run without asking while planning.none
confirmfalse skips the accept picker at the end of a turn.true
keysfalse leaves Ctrl+B unbound.true
priorityThe 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({})
KeyCommandDoes
Ctrl+P/findOpens a file in your editor.
Ctrl+A/attachAdds @path to the input line.
Ctrl+G/branchAsks the model about a git branch.
Ctrl+R/historyRefills the input with a past message.
Ctrl+FSearches 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 })
OptionMeaningDefault
policyWhether the tools ask before they run: "allow", "ask" or "deny"."allow"
countResults per search.5
charsCharacters of a page that web_fetch returns at most.20000
timeoutSeconds 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 })
OptionMeaningDefault
policyWhether starting agents asks first: "allow", "ask" or "deny"."allow"
scopeWhere agents come from when the model does not say: "user", "project" or "both"."user"
confirm_projectAsk before running an agent from the project.true
max_tasksThe most tasks one call may run at the same time.8
concurrencyHow many of them run at once.4
outputBytes 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.
FieldMeaning
nameThe agent’s name. The default is the file name.
descriptionRequired. The model reads it to choose an agent.
toolsThe tools the agent gets, separated by commas, with or without brackets. The default is every tool.
modelThe model, written as provider/model. The default is yours.
effortThe 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

CommandDoes
/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.
/agentLists the agents. Picking one puts /agent <name> in the input.

The tool

ArgumentsRuns
agent and taskOne task.
tasksA list of { agent, task } that run at the same time.
chainA 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.luaStarts uji. It reads the command line, opens the session and starts the screen.
cli.luaThe command line and uji --help.
paths.luaWhere the config, the data and the session database are.
config.luaLoads your config and plugins, and runs /reload.
packs.luauji.pack, and finding modules in your config and packs.
api/The uji.* functions in the API reference.
agent/, loop.luaRuns a turn, which sends the conversation, runs tools, compacts and picks a title.
prompt.luaThe 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.luaAttaching images from files, pasted paths and the clipboard.
defaults.luaThe default screen and bindings that require("uji.defaults") loads.
event.lua, task.lua, plugin.lua, registry.lua, class.luaEvents, 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.

FolderWhat 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

NameDoes
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

NameDoes
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

NameDoes
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

NameDoes
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

NameDoes
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.
NameDoes
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

NameDoes
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

NameDoes
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

NameDoes
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

NameDoes
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

NameDoes
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

NameDoes
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

NameDoes
uji.job.start(opts)Starts a process and returns a job table.

HTTP

NameDoes
uji.http.request(opts, on_done)Sends an HTTP request and returns a function that cancels it.

Files

NameDoes
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

NameDoes
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

NameDoes
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

NameDoes
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

NameDoes
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.osReads the platform, the environment and the clock.
uji.modules(namespace)Lists the modules inside a namespace.
uji.keychainReads and writes secrets in the system keychain.
uji.clipboardReads 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.base64Encodes and decodes base64.
uji.tomlReads 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.

FieldTypeRequiredMeaning
descriptionstringnoWhat the tool does. The model reads this to decide when to call it.
parameterstablenoA JSON Schema for the arguments, written as a Lua table.
runfunctionyesRuns the call. See below.
subjectstring or functionnoWhat 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.
policystringno"allow", "ask" or "deny". Used when no policy rule matches.
display.verbstringnoThe transcript line, such as "Read", which uji follows with the subject.
display.questionstringnoThe 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.

FieldTypeRequiredMeaning
handlerfunctionyesRuns the command. It receives the text typed after the name.
descstringnoThe description in the suggestion list.
forcebooleannoWith 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.

ModeWhere it applies
normalThe input line.
suggestThe command list that opens when you type /.
selectPickers and lists.
promptText prompts.
confirmThe 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.

ArgumentTypeMeaning
modestringOne of the five modes.
keystringA key or chord.
bindingstring, table or functionAn 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

KeysActionModes
Ctrl+Cquitall
Ctrl+A, Ctrl+Ecursor_start, cursor_endnormal, suggest, prompt, select
Ctrl+B, Ctrl+Fcursor_left, cursor_rightnormal, suggest, prompt, select
Alt+B, Alt+Fword_left, word_rightnormal, suggest, prompt, select
Ctrl+Hbackspacenormal, suggest, prompt, select
Ctrl+Ddelete_forwardnormal, suggest, prompt, select
Ctrl+W, Alt+Backspacedelete_word_backnormal, suggest, prompt, select
Alt+Ddelete_word_forwardnormal, suggest, prompt, select
Ctrl+U, Ctrl+Kdelete_to_start, delete_to_endnormal, suggest, prompt, select
Ctrl+Yyanknormal, suggest, prompt, select
Ctrl+Ttransposenormal, suggest, prompt, select
Ctrl+P, Ctrl+Nhistory_prev, history_nextnormal
Ctrl+P, Ctrl+Nmodal_up, modal_downselect, suggest, confirm
Ctrl+Gmodal_cancelselect, suggest, confirm
Shift+Enter, Alt+Enter, Ctrl+Jinsert_newlinenormal
Ctrl+Vpaste_imagenormal

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" })
OptionValuesDefault
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"
sizerows or columns, "fill", or "auto" to fit the content"fill"
border"none", "plain", "rounded" or "horizontal""none"
border_colora colourthe theme’s
titletext in the bordernone
wrapwrap long linesfalse
paddingblank cells around the content0
prioritylayout order, lowest first. In a bottom split, lower sits closer to the bottom edge.50
float{ width = ..., height = ... }, each "80%" or a cell countnot 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.

OptionTypeMeaning
titlestringThe title.
itemslist of stringsThe items to filter.
previewfunctionReceives the highlighted item and returns lines to show. Without it, an item like path:line: shows that part of the file.
on_queryfunctionMakes 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" },
})
KeyMeaningDefault
show_thinkingShow the model’s reasoning. /thinking toggles it.false
input.cursor_blinkBlink the cursor on the input line.true
suggest.enabledShow command suggestions when you type /.true
suggest.max_heightRows the suggestion list may use.5
waiting.loader.framesStrings the loader cycles through while the model works.none
waiting.loader.intervalSeconds between loader frames.0.08
confirm.titleThe approval question’s title."Allow tool call?"
confirm.yesThe allow label."Yes"
confirm.noThe deny label."No"
theme.textBody text.#d4d4d4
theme.mutedSecondary text and borders.#808080
theme.codeInline code.#e0af68
theme.accentHighlights.cyan
theme.user_bgThe background of your messages.#343541
theme.selected_bgThe selected row in lists.#3a3a4a
theme.cursorThe cursor.white
theme.errorErrors.red
theme.noticeNotices.red
theme.inputText on the input line.theme.text
theme.confirm_titleThe approval question’s title.theme.text
theme.confirm_bodyThe approval question’s details.theme.text
theme.confirm_selectedThe chosen answer.the default style
theme.confirm_unselectedThe 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.

ReturnEffect
a stringuji 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.
niluji 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.

KeyMeaningDefault
cacheHow 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.enabledCompact automatically. /compact works either way.true
compaction.reserveTokens 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_recentTokens 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.

ArgumentTypeRequiredMeaning
eventstringyesThe event name.
handlerfunctionyesReceives the payload table.
opts.priorityintegernoLower runs first. The default is 50.
opts.namestringnoA 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.

ReturnEffect
{ 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.

FieldTypeMeaning
idstringRequired. The provider’s id.
namestringThe name /login shows. Required for a new provider.
wirestringThe request format: "openai-chat", "anthropic", "gemini", or one you added with uji.wire.add. Required for a new provider.
base_urlstringThe API root, such as "https://api.openai.com/v1". Required for a new provider.
auth_envlist of stringsEnvironment variables that may hold the API key.
modelslistModel 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_windowintegerThe context size to assume for a model that does not set one.
compattableOptions 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.
oauthtableSubscription 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.

FieldTypeMeaning
keychainbooleanWith 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 id
  • system, the system prompt
  • messages, the conversation in uji’s message format, where a user or tool message may have images, a list of tables with media_type, base64 data, name, width and height
  • tools, a list of name, description and parameters
  • effort, one of off, minimal, low, medium and high
  • max_output, the output token limit
  • cache, one of off, short and long
  • session, the session id
  • provider, with id, base_url and compat
  • auth, with key, and oauth for 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. answer has text, and optionally reasoning, tool_calls and usage. Each tool call has id, name and arguments, where arguments is a JSON string. usage has input, output, cache_read and cache_write.
  • reply.fail(failure) ends the call with an error. failure.kind is "http", with status, message and retry_after, or "auth", with status, or "provider", with message. uji retries http failures with a status of 408, 409, 425, 429 or 5xx, and http failures 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.

OptionTypeMeaning
cmdstring or listRequired. A string runs through sh -c. A list is the program and its arguments.
cwdstringThe directory to run in. The default is uji’s working directory.
timeoutnumberSeconds before uji kills the process.
on_stdoutfunctionReceives each line the process writes to standard output.
on_stderrfunctionReceives each line the process writes to standard error.
on_exitfunctionReceives 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 if text lacks 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.

OptionTypeMeaning
urlstringRequired. The URL.
methodstringThe HTTP method. The default is "GET".
headerstableRequest headers, name to value.
bodystringThe request body.
timeoutnumberSeconds before the request fails. The default is 30 for a plain request and none for a streamed one.
on_linefunctionStreams 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.
idlenumberFor 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.

OptionTypeMeaning
offsetintegerThe first line to return, counting from 1. The default is 1.
limitintegerThe most lines to return. The default is all of them.
max_lineintegerCuts 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, branch or commit
  • a table with url instead 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.

MemberMeaning
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.settledtrue 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.

OptionTypeMeaning
urlstringThe address. Required.
methodstringThe method. The default is "GET".
headerstableHeader names and values.
bodystringThe request body.
timeoutnumberSeconds 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.

MemberMeaning
response.statusThe status code.
response.headersHeader 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.

MemberMeaning
server.portThe 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.

OptionTypeMeaning
cwdstringThe directory to run in.
envtableExtra environment variables.
stdiostring"pipe", the default, to read and write the process, or "inherit" to give it the terminal.
MemberMeaning
proc.pidThe 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.

MemberMeaning
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.

FunctionGives
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.executableThe path of the program running uji, or nil when the system cannot tell.
uji.os.argvThe arguments uji started with, the program name first.
uji.os.rootsThe directories whose lua/ and native/ folders are searched before the built-in modules.
uji.os.carryThe text handed over by the last uji.os.restart, or nil.
uji.os.libraryThe 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 /.

MemberMeaning
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

FunctionGives
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

MemberMeaning
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.

FieldTypeMeaning
fgstringThe text colour.
bgstringThe background colour.
bold, dim, italic, underline, blink, reverse, strikethroughbooleanTurns 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

MemberMeaning
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.

typeFields
"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:

  1. Lua in your config and packs, such as ~/.config/uji/lua/uji/sys/fs.lua.
  2. The Lua that comes with uji.
  3. Native modules in native/ in your config and packs, such as native/uji/sys/fs.dylib, which exports luaopen_uji_sys_fs.
  4. 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.

ModuleWhat it is
uji.sys.taskspawn, race, timeout and on_error, in Tasks.
uji.sys.sleepThe sleep function, in Tasks.
uji.sys.promiseThe promise function, in Tasks.
uji.sys.fsFiles and folders, in uji.fs.
uji.sys.netRequests and the local server, in Network.
uji.sys.procProcesses, in Processes.
uji.sys.dbSQLite databases, in Storage.
uji.sys.osThe system and restarting, in System.
uji.sys.keychainThe keychain, in System.
uji.sys.clipboardThe clipboard, in System.
uji.sys.modulesThe modules function, in System.
uji.sys.messageThe message function, in System.
uji.sys.jsonJSON, in uji.json.
uji.sys.tomlTOML, in Encoding.
uji.sys.base64Base64, in Encoding.
uji.sys.sha256The sha256 function, in Encoding.
uji.sys.randomThe random function, in Encoding.
uji.sys.regexThe regex function, in Text.
uji.sys.globThe glob function, in Text.
uji.sys.fuzzyThe fuzzy function, in Text.
uji.sys.widthThe width function, in Text.
uji.sys.lossyThe lossy function, in Text.
uji.sys.markdownThe markdown function, in Text.
uji.sys.imageImage fitting, in Images.
uji.sys.ttyThe 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