# The Lua API
Every lug.* function and event. Lug's own plugins in
basic.lug use exactly this API, so they double as
examples.
- Errors. A bad argument raises a Lua error naming the function, at the caller's
line:
lug.keymap.set: unknown mode 'Normal'. What goes wrong at run time returnsniland a message instead, as Lua'sio.opendoes: a staleexpectedinlug.stream.append,lug.session.openduring a run, a programlug.proc.spawncannot start. Such a function that has nothing else to return returnstrue. - Waiting. Functions marked waits yield the calling coroutine until the answer
arrives. Handlers Lug calls (keymaps, commands, event hooks, tools) run as coroutines,
so they can wait, and so can tool rules, whose call waits for the answer. A handler that
keeps failing, at once or after waiting, is detached after three errors.
lug.waitmakes a wait of any callback. A plugin may start coroutines of its own, andlug.taskruns them as tasks to wait on or cancel (see tasks). Functions Lug needs an answer from at once cannot, and a function that waits raises there: the statusline's segments, window sizes,lug.transcript.transform,lug.prompt.on_send, a tool'spreview, a picker'spreviewandon_change, and completion functions. - Composing the screen. The statusline's segments and window sizes compose the screen
from what Lug holds and change nothing in it. While they run, only the functions that read or render may be called:
lug.opt.NAME,pairs(lug.opt),lug.ui.focus()with no argument,lug.prompt.height,get,modeandselection,lug.transcript.items,lug.feed.selectionandentry,lug.ui.markdown,code,diff,diffstat,widthandfilter,lug.hl.get,lug.keymap.list,lug.cmd.listandhistory,lug.tool.grants,listandasking,lug.session.workspace,status,systemandlist,lug.stream.readandlist,lug.ui.windowsandtabs,lug.model.currentandlisted,lug.agent.status,listandget, and a handle'sstatusanditems. Any otherlug.*function,printandos.exitraisenot allowed while composing the screen, which fails the frame as any error does. Lua's own tables and functions are free to use. Every function that changes something draws the frame again after it, solug.ui.redrawis only for changes to your own tables, such as a text view'slines. - Copies. Tables passed in or returned are copies. Text offsets are bytes, as
string.subcounts them. JSON'snull, in tool arguments, events or streams, arrives asnil. - Time. Lua runs on the UI thread. A stretch that runs longer than
lua_timeout(1,000 ms by default) without yielding is stopped with an error, so slow work belongs inlug.proc.runorlug.proc.spawn. Composing the screen happens before every frame and gets 50 ms, BSP sizes and panes included. A frame whose composition fails or runs longer keeps the last frame, and after 3 failures the screen starts again with no windows and fireslayout_failed.
## In your editor
lug exists only inside Lug, so lua-language-server reports it as undefined. lug docs lua writes the lug table's annotations, this page and the modules built into the binary
(lug.bsp, lug.bootstrap) to ~/.local/share/lug/lua-docs and prints that directory.
Point the language server at
it, and at the stock runtime's lua/ for its modules, for example
with ~/.config/lug/.luarc.json:
{
"runtime.version": "Lua 5.4",
"workspace.library": [
"/home/you/.local/share/lug/lua-docs",
"/home/you/.local/share/lug/plugins/basic/lua"
]
}
Run it again after upgrading Lug, since the directory follows the installed version.
## Options and events
| Name | Arguments | Returns |
|---|---|---|
lug.opt.NAME |
— | The value; a table is a copy |
lug.opt.NAME = value |
A value of the option's type | Raises when refused |
pairs(lug.opt) |
— | Each name and value, by name |
lug.opt.declare(name, spec) |
A new option's name; { type, default, min? }, type one of boolean, integer, string, list, table |
Nothing: the option works as Lug's do, for :set, option_changed and checking. Raises when the name is taken |
lug.on(event, fn) |
An event name below, or a plugin's own, which has a dot in its name (workflow.changed); fn(event) runs as a coroutine |
A function that removes the hook; calling it again does nothing |
lug.emit(event, data?) |
A plugin's event, with a dot in its name; plain values | Nothing: runs each hook on it before returning, each as its own coroutine with its own copy of data, as Lug's events run. Raises on a name with no dot, so no plugin fires one of Lug's, or on data holding a function |
Events arrive as tables with these fields. The runtime's events, from run_started
on, also carry type, the event's name.
| Event | Fields |
|---|---|
run_started |
run |
turn_started, turn_finished |
run, turn; turn_finished adds usage |
text_delta, reasoning_delta |
run, text |
message_added |
run, message (role, parts) |
tool_call_requested |
run, call (id, name, args); hooks get it before the call is settled |
tool_started, tool_finished |
run, id; tool_finished adds output, is_error |
tool_progress |
run, id, progress: a running Lua tool's handler called ctx.progress |
run_finished |
run, outcome, usage. outcome.status is completed, cancelled, stopped (a call was denied with stop) or failed, which adds reason (max_turns, misfit: the answer did not fit its schema twice, refused, or failed) and error, what happened. A job a cancel dropped before it started ends with one too, and no run_started |
compacted |
run, kept |
retrying |
run, attempt, delay_secs, reason |
sent |
text: sent to the session, by lug.session.send or the prompt |
option_changed |
name, value |
layout_failed |
None: composing the screen failed three times, and it starts again with no windows, for a plugin to open its own again |
prompt_changed |
name: typing or a paste changed that line editor's text |
agent_started |
agent, name, parent: a run of the session or of a lug.agent.call started. agent is its conversation's id, and parent the agent whose tool call started it, if one did |
agent_finished |
As agent_started, with outcome and usage as run_finished has them: that run ended |
window_closed |
name, tab: lug.ui.close, lug.ui.tab_close or an open with replace took the window away (a window given back on a close does not fire it). Closing a tab fires it for each of its windows, in order, before tab_closed |
tab_closed |
id: lug.ui.tab_close closed the tab |
A plugin's own events are named with a dot, hunk.done say, so they never clash
with Lug's, and carry whatever lug.emit gives them. Nothing registers them. A plugin
that shows progress can emit it as an event for a statusline to draw, rather than
expose a module other plugins require.
-- a plugin says what changed; anyone may listen
lug.emit("workflow.changed", { run = id, status = "waiting" })
local off = lug.on("workflow.changed", function(e) lug.ui.redraw() end)
off() -- no longer listening
-- a window that closes ends what waits on it
lug.on("window_closed", function(e)
if e.name == "review" then finish(nil, "the review window was closed") end
end)
started is Lug's own: it is fired once at the end of
the load order (its hooks see the options as --cmd and the flags leave them), and again
when :reload builds a fresh state. Its pick_session is true once, at startup, when
lug --session was given no id.
## Keys and commands
| Name | Arguments | Returns |
|---|---|---|
lug.keymap.set(mode, lhs, rhs, opts) |
mode: normal, insert, visual, transcript, command, popup, approval, input, confirm, picker, or a feed's, text component's or line editor's; lhs in key notation, or <Char> for what is typed; rhs: function(count) (function(text) for <Char>) or ":…"; opts: { desc?, nowait? } |
Nothing |
lug.keymap.del(mode, lhs) |
As for set |
Nothing |
lug.keymap.list(mode) |
A mode | { { lhs, rhs, desc }, … }, by lhs, as written |
lug.cmd.set(name, fn, opts) |
fn(cmd) with cmd = { args, raw, bang }, run as a coroutine; opts: { desc?, complete?, history? }; history = false keeps its lines out of the : history |
Nothing |
lug.cmd.open(text?) |
Text to start with | Nothing: opens the : line, a line editor named cmdline in the command mode |
lug.cmd.run(line, opts?) |
A command line, without the :; { history? } |
Nothing: runs it; history = true keeps it in the : history, unless its command says not to |
lug.cmd.history() |
— | The lines the : line ran, oldest first |
lug.cmd.list() |
— | { { name, desc, complete? }, … }, by name |
lug.feedkeys(keys) |
Keys in key notation | Nothing: they go where typed keys go, through the mappings, the focus and the dialogs, and a sequence left unfinished runs what it maps to at once. Raises on bad notation |
The : line is Lua built into Lug, require("lug.cmdline"), whose run(), close(),
backspace(), complete() and history(step) are what the command mode's keys do.
Its draw(width, height) returns the components it is drawn with while open, a : and
the editor over the last row; replace it to change only the look, keeping a prompt
component with id cmdline and mode command.
lug.feedkeys is lug.prompt.keys' opposite: where that types into one editor past every
mapping, this presses keys as the terminal would. A test drives a plugin with it.
## The prompt
| Name | Arguments | Returns |
|---|---|---|
lug.prompt.get(name?) |
A line editor's id, the prompt unless given | text, cursor |
lug.prompt.set(text, cursor?, name?) |
Text; a byte offset, default the end; a line editor's id | Nothing |
lug.prompt.insert(text, name?) |
Text, inserted at the cursor; a line editor's id | Nothing |
lug.prompt.keys(keys, name?) |
Keys in key notation; a line editor's id | Nothing: the editor takes them as if typed, past every mapping |
lug.prompt.mode() |
— | "normal", "insert" or "visual" |
lug.prompt.set_mode(mode) |
One of those three | Nothing |
lug.prompt.selection() |
— | from, to, linewise; nil outside visual mode |
lug.prompt.send() |
— | Nothing: the prompt's text goes to the first lug.prompt.on_send function that takes it, else to the session |
lug.prompt.on_send(fn) |
fn(text), returning true to take the text; it must answer at once |
A function that removes it. Each runs in the order they were added |
lug.prompt.history(step) |
-1 older, 1 newer |
Nothing |
lug.prompt.height(width) |
A width | The rows the text needs there, at least 1 |
lug.prompt.complete(from, items, name?) |
A byte offset where the word starts; a list of strings or { word, hint? }; a line editor's id |
Nothing: opens the completion popup over that editor, whose keys are the popup mode's first |
lug.prompt.popup(how?) |
A number of candidates to move the highlight, round the ends; "accept" puts the highlighted one in place of the word; "close"; nil asks |
The id of the editor whose popup is open, or nil |
## The transcript and the screen
| Name | Arguments | Returns |
|---|---|---|
lug.ui.open(view, opts?) |
A view; { split?, win?, size?, sep?, tab? } (see windows) |
The window's name |
lug.ui.close(name) |
A window's name | Nothing |
lug.ui.resize(name, cells?) |
A window's name; rows or columns, as its split runs, or nil to share the split's room evenly again | Nothing |
lug.ui.tab_open() |
— | A new, empty tab's id (see tabs) |
lug.ui.tab_focus(id?) |
A tab's id; nil asks | The shown tab's id |
lug.ui.tab_close(id) |
A tab's id | Nothing |
lug.ui.tabs() |
— | The tabs' ids, in order |
lug.ui.windows() |
— | The windows in the last frame, in order: { { name, view, x, y, w, h, split }, … }, split being which way the split holding it runs, h side by side or v stacked, and nil for a window alone |
lug.ui.redraw() |
— | Nothing: a frame follows |
lug.ui.markdown(text, width) |
Markdown; a width | text lines, drawn as the transcript draws an answer. A text component's content draws them without the lines. |
lug.ui.code(text, lang, width) |
Code; a language name; a width | text lines |
lug.ui.diff(old, new, path, width, line?) |
Two texts; a path for the language; a width; the file line the texts start at, which numbers the rows | text lines, as a diff entry draws them |
lug.ui.diffstat(old, new) |
Two texts | adds, dels: how many lines the change adds and deletes |
lug.ui.filter(labels, text, sort?) |
Strings; the text typed; false keeps their order |
The indices of the labels that hold the text's characters in order, ignoring case, closest first |
lug.ui.width(text) |
A string | The terminal cells it takes as Lug draws it: 2 for a wide CJK character or an emoji, 0 for a combining mark |
lug.transcript.items() |
— | The conversation, item by item: { { id, version, kind, … }, … } |
lug.transcript.transform(fn) |
fn(entry), or nil to remove every transform |
A function that removes this one |
lug.feed.upsert(name, entry, opts?) |
A feed's name; an entry of a kind Lug draws, with an id (a whole number or a string) to replace the one that has it; { before = id } or { after = id } to put it beside another entry |
The entry's id. Raises on a conversation item, which lug.feed.item shows, and on an entry to go beside that the feed does not have |
lug.feed.item(name, id, item, opts?) |
A feed's name; an id, a whole number or a string; a conversation item, as lug.transcript.items() gives them, or nil to remove it; { before = id } or { after = id }, as upsert takes |
Nothing: shows the item as the transcript would, under id. Raises on an entry to go beside that the feed does not have |
lug.feed.clear(name) |
A feed's name | Nothing |
lug.feed.scroll(n, feed?) |
Rows; a fraction of the view; ±math.huge; a feed's name |
Nothing |
lug.feed.jump(n, kind?, feed?) |
Entries to move; the kind entries came from (a conversation item's, or the kind lug.feed.upsert was given); a feed's name |
Nothing |
lug.feed.fold(open?, feed?) |
true, false, or nil to toggle; a feed's name |
Nothing |
lug.feed.fold_all(open, feed?) |
Boolean; a feed's name | Nothing |
lug.feed.move(keys, feed?) |
A prompt motion along the cursor's row: h l w b e W B E 0 ^ $, with an optional count (3w); a feed's name |
Nothing |
lug.feed.select(charwise?, feed?) |
true for characters; nil or false for whole rows; a feed's name |
Nothing: starts a selection at the cursor, ends one of the same kind, or switches to the other kind |
lug.feed.selection(feed?) |
A feed's name | text, from, to, linewise: the selected text (a charwise selection's exact span), or the cursor row's; then, with a selection, where the text starts and ends in the selected rows' text (byte offsets, half-open, as lug.prompt.selection's) and whether it takes whole rows. Nil while the feed has no cursor |
lug.feed.entry(feed?) |
A feed's name | The id of the cursor's entry: its item's in the transcript, the id, number or string, lug.feed.upsert or lug.feed.item gave it in another feed. Nil while the feed has no cursor |
lug.feed.entries(name) |
A feed's name | { { id, kind, text, old?, title? }, … }, in order: each entry's id, the kind it came from, and what it says (a text entry's lines joined with \n, a diff's new text with its old, a box's title with what it holds). A conversation item may make several. {} for a feed that has none |
lug.feed.yank(feed?) |
A feed's name | Nothing |
lug.hl.set(name, spec) |
A group; { fg?, bg?, bold?, italic?, underline?, reverse?, link?, default? }; default = true sets it only if nothing has |
Raises on a bad colour |
lug.hl.get(name) |
A group | Its definition as set, or nil |
### Highlight groups
Chunks name highlight groups, and an unset group draws plain. Lug names a few groups for what they mean, not where they are used, and a theme fills them in:
| Group | For |
|---|---|
LugAccent |
Text that stands out |
LugOk |
Success |
LugWarn |
What needs attention: a warning, a question waiting on the user |
LugError |
Failure |
LugMuted |
Text that recedes |
LugTitle |
A window's title, with no background of its own |
LugSelection |
A selection, or the cursor's row |
The other Lug* groups are the stock look's, which a theme may derive as it likes, so a
plugin names groups of its own and links each to a role, with default so a user's or
theme's choice stands whichever loads first:
lug.hl.set("MyPluginTitle", { link = "LugTitle", default = true })
lug.hl.set("MyPluginWaiting", { link = "LugWarn", default = true })
A user then restyles the plugin through its groups, and a link is followed as it is drawn, so a new theme reaches the plugin too.
### Feeds and entries
A feed is a list of entries that Rust lays out, caches and scrolls. Each feed in the plan
keeps its own place, folds, cursor and selection. The transcript is the feed the
conversation fills. { kind = "transcript", … } shows it, and
{ kind = "feed", id = name, … } shows any other. A feed takes the focus under its name, and
with it a cursor; its keys are its mode's (transcript unless given). lug.feed.scroll,
jump, fold, fold_all, move, select, selection, entry and yank act on the feed
they name, or without one on the feed with the cursor, the one focused last (a dialog or
the : line over it leaves it its cursor and selection), or else on the transcript. The mouse wheel
arrives as the keys <ScrollUp> and <ScrollDown>, in whatever mode has the keys.
The focus goes to a name on the screen as drawn: prompt, a feed's name, or a text
component's or line editor's id. A text component without an id takes no focus; with one, its keys are
its mode's (normal unless given; any name lug.keymap.set takes). { kind = "prompt", id = name, mode = mode } is a line editor of its own, as the : line and the dialogs'
inputs are: its keys are its mode's, the keys no mapping takes edit it as the prompt's
insert mode does, and the lug.prompt functions that take a name act on it. The plan is refused
when an id is empty or is prompt or transcript, when two components share a name, when
a line editor has no mode, or when a feed is named prompt. When the focused component leaves the plan,
because its pane went or its leaf now shows another tab, the first component in the plan
that can take the focus has it.
focusable = false on a feed or text component keeps the focus off it: a status bar in a
window, say. lug.ui.focus cannot give it the keys, the focus leaves it at the next frame
when it has them, and the stock <C-w> moves pass over its window.
A conversation item is a table of plain values. Its kind is one of these:
kind |
Fields |
|---|---|
user |
text |
assistant |
text, streaming |
reasoning |
text, streaming |
tool |
call_id, name, args, status (pending, awaiting_approval, running or done), progress (while running, what its handler last reported), output, is_error, summary |
notice |
text, error |
Lug draws none of them itself. A transform draws each as entries of the kinds Lug renders, and the stock runtime's look, added once loading ends, draws the user bar, thinking, notices and tool cards; an item no transform drew shows as plain text. An entry Lug draws is a table of plain values too, of one of these kinds:
kind |
Fields | Drawn as |
|---|---|---|
markdown |
text, streaming |
Markdown; while streaming, only the text after its last settled paragraph is parsed again |
code |
text, lang |
Highlighted code |
diff |
old, new, path, line |
A diff; with line, the file line the texts start at, each row numbered in LugLineNr: a removed line where it was, any other where it is |
ansi |
text |
A program's output, its colours kept |
canvas |
h, w, marker, shapes |
Shapes drawn in pixels, several to a cell; see below |
text |
lines, as a text component's; prefix and indent, chunks |
The lines, wrapped at words, the first row after prefix and the others after indent (prefix unless given) |
A canvas is h cells high and w wide (as wide as it is laid out, and no wider, unless
given). Its marker says how many pixels a cell holds: braille (the default) 2 across and 4
down, half 1 across and 2 down, block one. Pixels count from 0 at the top left, so moving
a sprite one pixel is adding 1 to its x. shapes are drawn in order, each in its group:
kind |
Fields | Draws |
|---|---|---|
points |
xy, a flat list: x, y, x, y, … |
Those pixels |
line |
x1, y1, x2, y2 |
A line between two pixels |
text |
x, y, text, in cells |
The text over the pixels; its spaces leave what is under them |
A cell has one colour: that of the last pixel set in it. Rust keeps a canvas's rows like any
other entry's, so give it an id and bump its version when a shape changes.
local slug = { kind = "canvas", id = 1, version = 1, h = 4, shapes = {
{ kind = "line", x1 = 0, y1 = 15, x2 = 39, y2 = 15, group = "LugDim" },
{ kind = "points", xy = { 10, 13, 11, 12, 12, 12, 13, 13 }, group = "LugSlug" },
{ kind = "text", x = 1, y = 0, text = "a slug" },
} }
Any entry may also have these:
frame:truedraws it inside a box, and a string does too, with the string as the title in its top edge. The box is drawn inLugFrameand the title inLugFrameTitle.fold = { head, right?, arrow?, open? }: it folds to one row, an arrow in the grouparrow(LugDimunless given), then the chunkshead, cut to leave room for the chunksrightat the right edge. It is open whenopenis true, untillug.feed.foldorfold_allfolds or opens it.join: it sits under the entry before, with no blank row, when that one came from the same kind of item. Entrieslug.feed.upsertis given astextjoin unless they say not.band = { group?, bar?, pad? }: it is drawn on a band.groupis laid under every row and fills it to the right edge, each row starts with the chunksbarand a space and ends with a space, andpadrows of band (none unless given) go above and below. A fold's head row is on the band too. What the band adds is decoration.
A chunk { text, group, true } is decoration: a selection or yank leaves it out, as it
leaves out fold arrows, boxes and code blocks' frames, and a row of nothing else.
lug.transcript.items() gives each conversation item as an entry with its id and its
version, which goes up whenever the item changes. Rust lays out markdown, code and diffs and
keeps the rows, so an entry that has not changed costs nothing on the next frame; it is laid
out again only when it is written or the width changes. A transform that returns a
markdown, code, diff or ansi entry hands Rust the text, never rows.
An item keeps its id for its lifetime: a streaming answer and a tool call going from
pending to done change their version, never their id, and leave every other item alone.
Redraws, focus changes and resizes change no version. A new width lays the entries in view
out again, but nothing is projected again. items() builds fresh tables on every call, so a
plugin that follows the conversation does it in lug.transcript.transform, which gets only
what is new or changed.
lug.transcript.transform(fn) adds a step to how the conversation becomes the
transcript's entries. fn gets each item as an entry when it is new and again whenever it
changes: once for each change to a streaming answer, and once for each step of a tool call.
It returns the entry, changed or not; nil to leave the item out; or a list of entries to
show in its place. Transforms run in the order they were added, each on every entry the
one before gave, so plugins' transforms compose; the stock runtime's sets tool cards'
summaries while loading, and draws the look once loading ends, so a transform a plugin adds
while loading sees the items. Where one fails, the entries it was given go on as they were. After three
errors it is detached. The function transform returns removes it.
lug.transcript.transform(function(entry)
if entry.kind == "tool" and entry.name == "read" then
entry.summary = entry.args.path
elseif entry.kind == "user" then
return { { kind = "text", lines = { { { os.date("%H:%M"), "LugDim" } } } }, entry }
end
return entry
end)
Any other feed is filled with lug.feed.upsert, which adds an entry at the end or replaces
the one with the same id:
lug.feed.upsert("log", { kind = "text", lines = { "build started" } })
lug.ui.open({ kind = "feed", id = "log" }, { split = "below", win = "transcript", size = 8 })
An id may be a string, which the feed keeps for the entry; lug.feed.entry gives it back.
{ before = id } or { after = id } puts a new entry beside one the feed has; an entry
already there is replaced where it is. A view scrolled up holds its place, the cursor
staying on its entry:
lug.feed.upsert("steps", { id = "build", kind = "text", lines = { "build" } })
lug.feed.upsert("steps", { id = "lint", kind = "text", lines = { "lint" } }, { before = "build" })
Keep to numbers or to strings in one feed: a string's entry is given a number no entry had.
A conversation that is not this session's, a sub-agent's from lug.agent.call's
on_item or a saved one from lug.session.items, goes in a feed with lug.feed.item(name, id, item). The item is drawn exactly as the transcript draws it: through
lug.transcript.transform, the look included, and drawn again
when a transform is added or removed. Calling it again with the same id replaces what that item
showed, wherever it is in the feed, and nil removes it. Items and lug.feed.upsert's
entries share a feed's ids, in the order they were first added, and a new item takes
before or after as an entry does. An item a transform leaves out has no entry to put
another beside.
for i, item in ipairs(lug.session.items(id)) do lug.feed.item("agent", i, item) end
### Text components
A text component draws lines, each a string or chunks { text, group, decoration? }, then the rows of
its content, from line scroll on, and no further than its last screenful. group fills its rectangle first, and id and mode
let it take the focus. content is a list of markdown, code, diff, ansi and canvas entries, with
the fields of the feed entries above. Rust lays them out at the component's width and keeps
the rows, so no rows pass through Lua. An entry with both an id and a version is kept by
them and the width (with a code block's lang or a diff's path), and its text is not compared again, so change the version whenever the text changes.
Otherwise it is kept by its text. lug.ui.markdown, code and diff return the same rows
as lines, for a plugin that changes them.
local notes = { kind = "markdown", text = "# Notes\n\n- one", id = 1, version = 1 }
lug.ui.open({ kind = "text", id = "notes", lines = { { { "pinned", "LugDim" } } }, content = { notes } },
{ split = "above", win = "transcript", size = 8 })
## Windows
The screen is windows side by side and stacked, with the statusline under them. Each
window shows a view: a component without x, y, w, h, which is a table Lug reads
again every frame, so changing its fields changes what is drawn. A view is shown at most
once in a tab, and its window is named by it: a feed's or a text component's id, or transcript or
prompt. Focus is by that name too, so lug.ui.focus(name) focuses a window.
A plugin opens a view and leaves where it goes to the screen: lug.ui.open(view) splits
the focused window in the direction of the split option (right by default), half and
half. opts asks for something else:
split:right,left,beloworabove.win: the window to split;falsesplits the whole screen.size: rows or columns for the new window, orfn(rect)returning them, worked out every frame from the split's rectangle.sep:falseopens it with no rule between it and the window it splits.tab: the tab to open it in, fromlug.ui.tab_open(); the one shown by default.replace: a window in that tab whose place the view takes, with its size and, if it had it, the focus; that window's view is no longer shown there until this one closes.
A view already shown in the tab is not opened again, and open returns its window's name.
Opening does not move the focus, but for replace. Closing a window that replaced another
puts that one back in its place, with the focus if the closed one had it, unless its view
is shown elsewhere in the tab by now; replaces nested in one place unwind in order. Closing
any other window gives its place to its neighbour, and if it had the focus, the first window
that can take it does. A window that should give its place away instead is closed first,
then the new one opened.
### Decorating windows
lug.ui.decorate, when a plugin sets it, is asked every frame how to draw each window
around its view. It gets the window, { name, view, focused, tab, x, y, w, h }, and returns
nil to draw it bare, or:
border: 8 elements, the top left corner first and on clockwise, each a string (drawn inLugBorder) or{ text, group }. A side's text is repeated along it, so a pattern of several characters makes a wavy edge, and""leaves the side out.titleandfooter: a string (drawn inLugTitle) or chunks, set in the top or bottom edge;title_posandfooter_posput themleft(the default),centerorright. One too long for its edge is left out.padding: cells between the border, or the window's edge, and the view: one number for all four sides, or{ top, right, bottom, left }, a missing side taking the one opposite.group: fills the whole window first.dim: fades everything in the window, from 0 to 1 of the way to the background.
The view is drawn in what is left. A rule between windows that all have a border edge
beside it is left out, so boxes stand apart with a gap. An error in decorate is shown in
the window's top row, and the window is drawn bare. The stock runtime sets it: its
lug.windows module is the place to see one.
lug.ui.decorate = function(win)
if win.name == "transcript" then return { padding = { 1, 2, 0, 2 } } end
return {
border = { "╭", "─", "╮", "│", "╯", "─", "╰", "│" },
title = " " .. (win.view.title or win.name) .. " ",
dim = not win.focused and 0.4 or nil,
}
end
Fading is a component of its own, which a plan may use anywhere: { kind = "dim", amount, x, y, w, h } mixes the colours the components before it drew in its rectangle amount of
the way to the background, the cell's own or else LugNormal's, where they are RGB, and
gives the rest the terminal's faint attribute.
The stock windows, the transcript and the prompt below it, are opened by
basic.lug's lua/lug/layout.lua. Shadow it with
~/.config/lug/lua/lug/layout.lua to start with other windows:
-- ~/.config/lug/lua/lug/layout.lua: the transcript over a 3-row prompt, no statusline.
lug.statusline.left, lug.statusline.right = {}, {}
lug.ui.open({ kind = "transcript" })
lug.ui.open({ kind = "prompt" }, { split = "below", size = 3 })
## The statusline
The bottom row is the statusline, drawn from the segments in lug.statusline.left and
lug.statusline.right: functions of no arguments that return nil, a string or chunks,
called once per frame while the screen is composed. left runs from the left edge and
right ends at the right edge, and when they do not fit, the segment nearest the middle on
the wider side goes. A segment that fails shows its error in its place. With no segments
there is no statusline row. The stock runtime adds its segments to both lists, so a plugin
adds its own beside them:
table.insert(lug.statusline.right, function()
return os.date("%H:%M")
end)
## Tabs
Each tab is a screen of windows of its own, and one is shown at a time. Lug starts with
one. lug.ui.tab_open() adds an empty tab and returns its id; lug.ui.open(view, { tab = id }) puts windows in it without showing it, and lug.ui.tab_focus(id) shows it,
giving the focus back to the window that last had it there. A view is shown at most once
in a tab, but may be in several tabs, as the prompt is when a plugin gives its tab one
too; the view is the same, so its text, scroll and folds are too. lug.ui.close(name) and
lug.ui.resize act on the shown tab's window when it has one, else on the first tab
that does, and lug.ui.windows() lists the shown tab's. lug.ui.tab_close(id) closes a tab and its
windows; the only tab can't be closed.
-- A run of something, on a tab of its own, until it's done.
local tab = lug.ui.tab_open()
lug.ui.open({ kind = "text", id = "run", lines = { "running" } }, { tab = tab })
local back = lug.ui.tab_focus()
lug.ui.tab_focus(tab)
-- later
lug.ui.tab_focus(back)
lug.ui.tab_close(tab)
A window opened without tab goes in the shown tab, so what opens while a user watches
one tab shows up there.
## Layouts: lug.bsp
Windows are the leaves of a tree from require("lug.bsp"), which divides a rectangle
between named leaves. It is plain Lua that a plugin can use for anything it lays out.
| Name | Arguments | Returns |
|---|---|---|
bsp.leaf(name, pane?) |
A name, once in the tree; a component, or fn(rect) returning one |
A leaf |
bsp.hsplit(size, first, second) |
A size; two children, each a leaf or another split | A split, first on the left. Its sep = true rules its children off with a line |
bsp.vsplit(size, first, second) |
As hsplit |
A split, first on top |
bsp.ratio(r) |
The first child's share, from 0 to 1 | A size |
bsp.fixed(n) / bsp.fixed_second(n) |
Cells for the first or the second child, or n(rect) returning them |
A size; the other child takes the rest |
bsp.replace(tree, name, fn) |
A tree; a leaf's name; fn(leaf) returning a tree |
A new tree with that leaf replaced, sharing the rest |
bsp.remove(tree, name) |
A tree; a leaf's name | A new tree whose leaf's sibling takes its split's place |
bsp.plan(tree, width, height) |
A tree; a size | Each leaf's component in its rectangle, then { { name, x, y, w, h }, … } |
A tree holds no sizes of its own. It is resolved from the rectangle down, and a size given
as a function sees its split's rectangle then ({ x, y, w, h }), which is how the prompt
grows with its text. Each leaf's name is in the tree once and is not empty, or bsp.plan
raises.
## Tasks: lug.task
A coroutine that waits can be cancelled, a timer is a sleep, and independent waits can
run together. lug.sleep and lug.cancel are the mechanism; require("lug.task"), built
into Lug, runs functions as coroutines of their own on top of them. A plugin may also
start a coroutine itself: a waiting function yields whichever coroutine calls it.
| Name | Arguments | Returns |
|---|---|---|
lug.sleep(ms) |
Milliseconds, 0 or more | true once they have passed. Waits. A headless run waits for it. |
lug.cancel(co) |
A coroutine | true when it was waiting: the work it waits on stops (a process group is killed, an agent's run ends, a dialog closes, a lug.tool.call never runs), and on the loop's next turn the waiting function returns nil, "cancelled", so its cleanup runs. false when it was not waiting, or was cancelled already. |
lug.worker.run(fn, ...) |
A Lua function that uses no local from outside it, and plain values | What fn returned, run in a fresh Lua state on a thread of its own: the standard library and package.path, but no lug. Arguments and results cross as JSON; an error, or a result that is not a plain value, gives nil and the message. Waits; lua_timeout does not stop it, and cancelling the caller does. |
task.run(fn, ...) |
A function and its arguments | A task, at once: fn(...) runs as a coroutine of its own. An error in it ends the task with nil and the message. |
t:wait() |
— | What fn returned, once it has. Waits, unless it has ended. |
t:done() |
— | Whether it has ended |
t:status() |
— | What the lug.agent.call the task waits on now is doing, as lug.agent.status says; nil when it waits on none |
t:cancel() |
— | true: the task ends with nil, "cancelled" and lug.cancel stops what its coroutine waits on; false when it had ended |
task.all(tasks) |
A list of tasks | Each task's first value, in order, once all have ended; or, when one ends with nil and a message, that, after cancelling the others still running. Waits. |
task.first(tasks) |
A list of tasks | The index of the task that ends first, then its values; the others are cancelled. Waits. |
local task = require("lug.task")
-- every file's diff at once
local jobs = {}
for i, file in ipairs(files) do
jobs[i] = task.run(lug.proc.run, { "git", "diff", "--no-index", "/dev/null", file })
end
local diffs = task.all(jobs)
-- a step that can be stopped: its agent's run ends, and wait() gives nil, "cancelled"
local step = task.run(lug.agent.call, spec)
step:cancel()
-- a timeout
local which, result = task.first({ task.run(lug.proc.run, argv), task.run(lug.sleep, 5000) })
Cancelling a task does not cancel tasks it started. A cancelled coroutine is resumed, not
closed, so it may go on to wait again; a loop that should stop checks what its waits
return. A long loop in Lua can yield with lug.sleep(0) to stay within lua_timeout, or
run in a worker when it needs no lug and its input and output copy cheaply:
local hunks = lug.worker.run(function(patch)
return require("hunk.diff").parse(patch) -- plugins' modules are on package.path
end, patch)
## Dialogs and the terminal
The dialogs are Lua built into Lug, require("lug.dialog"): boxes over the screen, shown
one at a time in the order asked. The one shown has the focus, so its keys are the
input, confirm or picker mode's, and accept(), cancel() and move(by) are what
they do. A picker's keys stand over the picker mode's while it shows.
Every dialog's spec may say where it belongs and who asks:
win: a window's name. The dialog waits in the tab showing that window and is drawn over it. If the window closes first, the dialog answers as<Esc>does.tab: a tab's id, fromlug.ui.tab_open(). The dialog waits in that tab, over its windows, and answers as<Esc>does if the tab closes.kind: a word naming what is asked ("rename", say), for a replacement to go by; the stock dialogs ignore it.
A dialog with a place shows, and takes the focus, only while its tab is shown; others wait behind it in their own tabs, and what is typed in one is kept while its tab is hidden. One with no place shows over any tab, as dialogs always have.
| Name | Arguments | Returns |
|---|---|---|
lug.ui.select(spec) |
{ prompt, items = { { label, hint?, value? } }, sort?, preview?, on_change?, keys?, win?, tab?, kind? } |
The chosen value or item, or nil. Waits. |
lug.ui.input(spec) |
{ prompt, default?, message?, win?, tab?, kind? }; message is shown over what is typed, as a confirm's is |
The text, or nil. Waits. |
lug.ui.confirm(spec) |
{ prompt, message?, win?, tab?, kind? } |
Boolean. Waits. |
lug.tool.asking() |
— | The call the approval dialog asks about, { name, args, json }, its arguments as edited and as JSON text; or nil |
lug.tool.answer(decision) |
"allow", "always", "deny" or "stop" |
Nothing: closes the approval dialog so |
lug.tool.edit(args) |
A table, or JSON text of an object | true: the call runs with them, and the preview follows; or nil and why when the text is not an object |
lug.ui.notify(text, level?) |
Text; "info" (default) or "error" |
Nothing |
lug.ui.focus(target?) |
"prompt", "transcript", a feed's name, a text component's or line editor's id, or nil to ask |
The focused one's name |
lug.ui.suspend(argv) |
A program and its arguments | Its exit code, or nil, message when it could not start. Waits. |
lug.wait(fn) |
fn(done), called at once; a function it returns is what cancelling the coroutine calls, to close what it opened |
What done(...) is first given, from a keymap, say. Waits. A headless run takes it as waiting for keys, unless work is running. |
lug.ui.select, lug.ui.input and lug.ui.confirm may be replaced by assigning your own,
as vim.ui.select may in Neovim, and every plugin that asks then gets yours. A replacement
keeps the contract above: it waits (with lug.wait), answers what the stock one would
(nil, or false for a confirm, when dismissed), respects win and tab or at least never
takes the keys in a tab not shown, and calls a picker's on_change and preview with the
highlighted item each time it changes, as the stock picker does. Keep the stock one to fall
back on: local select = lug.ui.select.
To change only how the dialogs look, replace one of require("lug.dialog").draw's
select, input and confirm. Each is fn(dialog, width, height) and returns the
components drawn, placed within the width by height cells the dialog is over (the
screen, or its window). dialog is plain values: { kind, prompt, message?, items?, at?, preview? }, where a picker's items are the { label, hint } matching what is typed, at
the highlighted one's place among them, and preview its lines. What it returns must hold
the dialog's editor, a prompt component with id and mode picker or input, or for a
confirm a component with id and mode confirm, since those take the keys.
local dialog = require("lug.dialog")
dialog.draw.select = function(d, width, height)
local line = { { " " .. d.prompt .. " ", "LugModeNormal" } }
for i, item in ipairs(d.items) do
line[#line + 1] = { " " .. item.label .. " ", i == d.at and "LugPickerSel" or "LugDim" }
end
return {
{ kind = "text", lines = { line }, x = 0, y = 0, w = width, h = 1 },
{ kind = "prompt", id = "picker", mode = "picker", x = 0, y = 1, w = width, h = 1 },
}
end
## The session and the model
| Name | Arguments | Returns |
|---|---|---|
lug.session.send(text) |
Text | true, and sent fires; or nil and why it could not be sent |
lug.session.steer(text) |
Text | true; or nil and why. The text joins the run in progress, as a user message after the tool results the model waits on. When the model is not waiting on a tool, or nothing is running, it is sent as the next message, ahead of what is queued |
lug.session.cancel() |
— | Nothing |
lug.session.compact(instructions?) |
Text | true, and compacted follows; or nil and why |
lug.session.rename(title) |
Text | Nothing |
lug.session.workspace() |
— | The workspace's absolute path: the directory lug started in, which sessions, streams and trust belong to |
lug.session.cd(path?) |
A directory, relative to the workspace; nil asks | Where the session works, as an absolute path; or nil and why when path is not a directory. Given a path, the session works there from its next run: its built-in tools, :!, and lug.proc.* without a cwd. The model is told. The session records it and comes back there when continued, or to the workspace if the directory is gone. See working directories. |
lug.session.status() |
— | { id, busy, started?, queued, usage?, retry? }: whether a run is in progress or waiting, when the run in progress started (seconds since the epoch), how many sends and compactions wait for it, the last model call's usage that counted input (how full the context is; a compaction clears it), and the retry it waits to make, { attempt, delay_secs, reason } |
lug.session.add_context(name, text?) |
A heading; text, or nil | Nothing: from the next run, the system prompt has text under the heading, in place of what it had there; nil removes the section |
lug.session.tools(which?) |
A list of tool names; true for every tool; nil asks |
The names of the tools the model is offered from the next run. A list offers only those, tools registered with model = false among them, so a plan mode can take write, edit and bash away; true offers every tool registered for the model again. An unknown name raises. :reload offers every tool again. |
lug.session.system() |
— | The system prompt the session's next run sends: the system_prompt option, then each context section. An agent's default. |
lug.session.approve(fn) |
approve(call), or nil to remove it |
Nothing: asked about each of the session's calls that would open the approval dialog, in its place, as lug.agent.call's approve is (see typed agent calls) |
lug.session.list(opts?) |
{ all? } |
{ { id, title, workspace, updated, entries, open, first, last }, … }, newest first |
lug.session.open(id) |
A session id | true; or nil and why during a run or for another workspace's session |
lug.session.delete(id) |
A session id | true; or nil and why for this lug's session or an open one |
lug.session.items(id) |
A session id, or a conversation lug.agent.call saved |
Its items, as lug.transcript.items() gives this session's, or nil and why. It only reads, so the session may be open. |
lug.auth.set(provider, key) |
A provider name; a non-empty key | true: saves it in ~/.lug/auth.json; or nil and why the write failed. Raises on an unknown provider. |
lug.model.current() |
— | { id, context_window?, wire? }; context_window from the provider's last listing; wire, the API the model speaks (anthropic, openai, openai-chat, openrouter, ollama, or mock under --mock), nil for a provider Lug does not know |
lug.model.list() |
— | The current provider's { { id, name?, context_window? }, … }, or nil, message when listing fails. Waits on the provider's listing. |
lug.model.listed() |
— | The current provider's last listing, as list gave it, without waiting; nil before one has been fetched |
lug.agent.call(spec) |
{ prompt, name?, input?, output?, system?, model?, thinking?, max_turns?, max_tokens?, mode?, tools?, dir?, on_event?, on_item?, approve?, save?, from? } |
The answer and { model, usage, session?, messages? }, or nil, a message, and the same table with reason. Waits. See typed agent calls. |
lug.agent.status(co) |
A coroutine | What the lug.agent.call it waits on is doing: { phase, tool?, attempt?, usage }, phase one of thinking, writing, running (tool runs), approval (a call to tool waits for a rule, approve or the dialog) or retrying (attempt), and usage the tokens its turns have taken so far; nil when it waits on none. A task's t:status() reads it for the task (see tasks). |
lug.agent.list() |
— | Every live agent: the session first, then each a plugin started and has not closed, as { id, name, session?, parent?, dir, busy, queued, started?, phase, tool?, attempt?, usage }. id is its conversation's, session is true for the session, parent is the agent whose tool call started it, dir is where it works, busy says a run is in progress or queued, queued how many wait, started is when its run in progress started (seconds since the epoch), and the rest is as lug.agent.status says, usage over its life. |
lug.agent.start(spec) |
As lug.agent.call's, without prompt and input, with meta? |
A handle, at once; or nil and why: from cannot be opened, or dir is not a directory. See agent handles. |
lug.agent.get(id) |
An agent's id | Its handle, the session's included; nil when it is not live |
lug.reload() |
— | Nothing; an error notice during a run. The old state's coroutines end: the dialogs they opened close, and a Lua tool's call in progress fails with reloaded. |
## Tools and processes
| Name | Arguments | Returns |
|---|---|---|
lug.tool.register(name, spec) |
{ description, handler, parameters?, output?, ask?, model?, recovery?, recover? }; handler(args, ctx) runs as a coroutine |
Raises on a bad spec, a schema it cannot check, or a taken name |
lug.tool.call(name, input?, ctx?) |
A tool's name, built in or Lua's; its arguments; a table the handler gets as ctx, whose dir (relative to the workspace, the session's by default) is where a built-in tool works |
The result (a table when the tool has an output schema, else text), or nil, message when the input or output does not fit, the tool fails, or the call is denied, or ctx.dir is not a directory. Asks in the approval dialog as the model's calls do, or ctx.approve in its place (see typed agent calls). Waits. |
lug.tool.list() |
— | Every tool: { { name, description, parameters, output?, recovery, model, ask }, … } |
lug.tool.recover(name, input, ctx?) |
As for call |
What the tool's recover returns: "done", output, "not_done" or "unknown"; "unknown" when it has none. Waits when recover does. |
lug.tool.rule(name, rule) |
A tool, or "*" for every tool; "allow", "ask", "deny", rule(args, call), or nil to remove the tool's rules; rule may wait, and the call waits for it. A second value it returns is the reason: shown in the dialog with "ask", given to the model with "deny" |
A function that removes this rule. call is { id, tool, agent? }, agent the { id, name } of the agent that made the call, absent for lug.tool.call. Rules run in the order they were made: any deny denies, even in auto mode; else any ask asks (see tools) |
lug.tool.grants(agent?) |
An agent's name; nil for the session | The tools allowed always for that agent, sorted. lug.tool.call counts on the session's. |
lug.tool.revoke() |
— | Nothing: revokes every grant |
lug.proc.run(argv, opts?) |
A list of strings; { timeout?, cwd?, env? }: seconds (120); a directory, relative to the workspace; variables added to Lug's environment |
{ code, stdout, stderr }, code nil when killed; or nil and why when cwd is not a directory. Runs in cwd, else where the session works, unsandboxed for now, stdin empty, in a process group of its own that the timeout or lug.cancel kills, as bash does. Waits. |
lug.proc.spawn(argv, opts?) |
A list of strings; { cwd?, env?, on_stdout?, on_stderr?, on_exit? }, cwd and env as run's |
A handle with :write(text), :close() (ends stdin) and :kill(), at once. Runs in cwd, else where the session works, unsandboxed for now, with no timeout, in a process group of its own that :kill() kills. nil and why when the program cannot start or cwd is not a directory. |
lug.proc.spawn starts a process in the background. on_stdout(line) and
on_stderr(line) get each line without its newline, and on_exit(code) gets the exit
code, nil when killed, after the last line. Each call runs as a coroutine, so it may
wait. stdin stays open until :close(). :write returns nil and a message once stdin is
closed or the process has exited; :close and :kill then do nothing. After :kill(), lines not yet
delivered are dropped. A callback that fails 3 times kills the process, with a notice.
lug.reload and quitting kill every spawned process. A first message given on the
command line (lug "...") waits for spawned processes to exit, so plugins haul is
still cloning load first, and so does a headless run.
A tool's parameters and output are JSON Schema, checked on every call, from the
model or from Lua. Lug checks this subset: type, properties, required, items,
enum, const, additionalProperties, minimum and maximum, plus description,
title, default and examples, which only describe. A schema using anything else is
refused when the tool is registered. An empty Lua table fits both object and array.
With output, the handler returns a table, the model sees it as JSON, and
lug.tool.call returns it as a table. model = false keeps the tool from the model, so
only Lua calls it. recovery says what running the tool again does, for a caller that
cannot tell whether a call finished: "pure" (nothing), "idempotent" (the same as
once), or "unknown", the default. Lug stores it and acts on none of it. recover(input, ctx) checks whether an interrupted call took effect. The built-in read, grep and ls
are pure, write is idempotent, and edit and bash are unknown.
When no one wants a call's result any more, because the run that made it was cancelled
or stopped, or the coroutine that called lug.tool.call was cancelled, the handler is
cancelled as lug.cancel cancels it: what it waits on stops (a sub-agent's run ends, a
process group is killed, a dialog closes), the waiting function returns nil, "cancelled" so its cleanup runs, and what it returns goes nowhere.
Handlers run on the UI thread with every agent's callbacks and the screen, so a handler
with long stretches of pure Lua (parsing a large output, say) does them in
lug.worker.run, which a cancelled call stops too.
ctx says who called: { caller = "model", id, agent, dir, progress }, id being the
model's id for the call, agent the handle of the agent whose model made it (its id and
name among the rest, see agent handles) and dir where that agent
works; or for lug.tool.call the table given, with caller = "lua", call and id, an
id for this one call, and dir made absolute (the session's when not given). A handler
that runs programs passes cwd = ctx.dir, so it works where its caller does. A lug.agent.call the handler makes has that agent as its parent.
ctx.progress(value) reports how the call is getting on: the value, plain data, becomes
the call's item's progress while it runs, so the transcript can draw it, and a
tool_progress event of the run that made the call.
handler = function(args, ctx)
return lug.agent.call({ name = "research", prompt = args.question, tools = { "read", "grep", "ls" },
on_event = function(e)
if e.type == "tool_call_requested" then ctx.progress(e.call.name) end
end })
end
A caller that may run a call
again passes a key that stays the same each time, which the tool can use to do the
work once.
lug.tool.register("git_diff", {
description = "The diff from a base ref",
parameters = { type = "object", required = { "base" }, properties = { base = { type = "string" } } },
output = { type = "object", required = { "diff" }, properties = { diff = { type = "string" } } },
recovery = "pure",
ask = false,
handler = function(input, ctx)
return { diff = lug.proc.run({ "git", "diff", input.base }).stdout }
end,
})
local out, err = lug.tool.call("git_diff", { base = "main" }, { key = "release/diff" })
A timer is a task that sleeps (see tasks):
require("lug.task").run(function()
lug.sleep(5000)
lug.ui.notify("five seconds")
end)
## Typed agent calls
lug.agent.call(spec) makes one model call with no history, and stays out of the
transcript. prompt says what to do, and input, any plain value, follows it as
JSON. With output, a JSON Schema as tools take, the answer is a table that fits it;
without, the answer is text. system replaces the system prompt, which
lug.session.system() gives to build on, and model the session's model, for this call
only. thinking, max_turns and max_tokens do the same for the options of those names
(max_tokens is the output-token cap per model call). mode says how the provider
is held to the schema: "native" (its structured output), "tool" (a tool the model
calls with its answer) or "prompted" (the schema in the prompt); left out, Lug lets
Rig choose, except on providers that relay other vendors' models (opencode-go,
openrouter and ones added to lug.opt.providers), where many models fail a request that
carries a schema, so it is always a tool. An answer that is not JSON, or does not fit, returns nil, message, info, and
the caller decides whether to ask again. Cancelling the caller (see tasks) ends
the run at once: no later tool call runs, and the calls it holds leave the dialog.
local plan, info = lug.agent.call({
prompt = "Which files need changing to fix this?",
input = { error = stderr },
output = { type = "object", required = { "files" },
properties = { files = { type = "array", items = { type = "string" } } } },
})
-- plan.files; info.model, info.usage.input_tokens, info.usage.output_tokens
A plugin names a kind of model rather than a vendor: model = "@fast" is the model the
user maps fast to in lug.opt.model_roles, and the session's model while they have
not, so a plugin never fails for want of a provider's key. Lug's documented roles are
fast and strong; any other name works the same way.
-- init.lua: what the roles mean
lug.opt.model_roles = { fast = "openrouter/qwen/qwen3-coder", strong = "anthropic/claude-opus-5" }
-- a plugin
local msg = lug.agent.call({ prompt = "Write a commit message.", input = diff, model = "@fast", max_turns = 1 })
Every call runs in a runtime of its own, as the session's runs do, from a fresh
conversation that is not saved and stays out of the transcript, with the same retries.
With tools = true it gets every tool the model is offered, runs them through the same
approvals (the dialog names the agent), and keeps going until it answers. A list
of tool names, tools = { "read", "grep", "ls" }, offers it only those, and may name a
tool registered with model = false: a model is never offered a tool it may not use, so
this holds in auto mode too, where approve is never asked. A call to a tool it was not
offered fails the run. The list may also hold tools of the agent's own, specs as
lug.tool.register takes them with a name (and no recover): only that agent is
offered one, it may take a registered tool's name, which it then means for that agent
alone, and it goes when the agent's run ends. A report tool the model calls once per
finding hands a plugin results as they come, while output stays for the answer.
lug.agent.call({
name = "security", prompt = "Review the diff for security problems.",
tools = { "read", "grep", "ls",
{ name = "report", description = "Report one finding as soon as you find it.",
parameters = finding, ask = false,
handler = function(f) lug.feed.upsert("review", entry_for(f)) end } },
})
With output, Rig holds the model to the schema (the provider's structured output where it
works, else a tool the model answers through, unless mode says which; see above), and Lug checks
the answer; one that does not fit is asked for once more, in the same conversation,
before the call returns nil, message. on_event(event) is given each of the call's
events as it happens, as the tables lug.on hooks get (see
events): enough to show "thinking", "running bash" or "waiting
for approval" while it works.
local done = lug.agent.call({
prompt = "Fix the failing test in src/cli.rs.",
tools = true,
output = { type = "object", required = { "summary" },
properties = { summary = { type = "string" } } },
})
name is what the dialog, approve, rules, lug.agent.list and the agent_started and
agent_finished events call the agent, agent when it is left out. A tool allowed always
is allowed for every agent of that name, so a reviewer's grants are not the session's.
on_item(item) is given each item of the agent's conversation when
it is new and again when it changes, as lug.transcript.items() gives this session's, for
lug.feed.item to show.
approve(call) is asked about each call that would open the
approval dialog, in its place: the mode, rules and tools allowed always are applied
first, as for any call. It gets { id, name, args, why, preview, agent? } (preview has the
dialog's path, created, notes, and text or the diff's old and new), runs as a
coroutine, may wait, and returns what the dialog's keys would: "allow", "always"
(and the tool is allowed always for this agent), "deny" or "stop" (deny and end the run), with a
reason for a denial. It is logged in approvals.log as the dialog's answers are. An
error, or anything else, opens the dialog after all, with a notice. lug.tool.call(name, input, { approve = fn }) asks the same way.
With tools, save = true saves the conversation as the session is saved, as a stream
of kind agent, so it stays out of lug.session.list. on_event first gets { type = "agent_started", session = id }, and
info.session and info.messages give its id and how many messages it holds when the call
returns. from = { session = id } continues a saved conversation, prompt being its next
message, and from = { session = id, at = n } continues a copy of its first n messages,
saved as a new one, leaving the old one whole. id may also be a session's, this one's
(lug.session.status().id) included: that is always copied, whole or up to at, so a
side question or a reviewer starts from the conversation itself. on_item gets the conversation from its
start either way. lug.session.items(id) reads one back.
local side = lug.agent.call({ prompt = "Why a BTreeMap there?", from = { session = lug.session.status().id } })
local q, info = lug.agent.call({ prompt = "Plan the fix.", tools = true, save = true, output = schema })
-- later, with the user's answer as the next message:
local plan = lug.agent.call({ prompt = answer, tools = true, output = schema,
from = { session = info.session } })
A call that fails returns nil, a message for a person, and info as a call that
answers does, with reason for the caller to act on: cancelled (the run was
cancelled), stopped (an approval said stop), max_turns, misfit (the answer did not
fit, twice), refused (the model declined) or failed (a provider error, or anything
else). info.usage is what the run spent, and a saved conversation's info.session can
be continued with from.
local plan, err, info = lug.agent.call(spec) -- spec.save is true
if not plan and info.reason == "max_turns" then
plan, err, info = lug.agent.call({ prompt = "Stop exploring and give the plan now.",
tools = true, output = spec.output, from = { session = info.session } })
end
Cancelling the caller itself with lug.cancel returns nil, "cancelled", as any wait does.
### Agent handles
lug.agent.call is one answer. lug.agent.start(spec) starts an agent a plugin holds
and gives its handle at once: the agent keeps its runtime, and with it its conversation,
until it is closed, so each message continues it. The spec is lug.agent.call's without
prompt and input, and with save, meta, a table, is kept in the header of the
stream it starts, where lug.stream.list("agent") finds it after a restart. A from it
cannot open gives nil and why. lug.agent.call is start, send and close in one.
| Method | Arguments | Returns |
|---|---|---|
a:send(prompt, opts?) |
Text; { input?, output?, mode? } for this message, output in place of the agent's |
The answer and info, or nil, why and info, as lug.agent.call gives them. Waits. |
a:queue(prompt, opts?) |
As send |
true: it runs after what is queued, and nothing waits for it; or nil and why |
a:steer(text) |
Text | true, or nil and why; the text reaches its run as lug.session.steer's reaches the session's, and nothing waits for it |
a:compact(instructions?) |
Text | true once its conversation is compacted, or nil and why. Waits. |
a:cancel() |
— | Nothing: ends the run in progress and drops what is queued; what waits on them gets nil, why and reason = "cancelled" |
a:status() |
— | Its row of lug.agent.list, nil once it is closed |
a:items() |
— | Its conversation, as lug.transcript.items() gives the session's |
a:on(event, fn) |
One of the run events (see events), or "item" |
A function that removes the hook. Only its own events reach it; lug.on hears the session's. With "item", fn(item) is given its conversation from the start, then each item again whenever it changes, as on_item is, so a window can show an agent it did not start; the session's raises, since the transcript shows it |
a:close() |
— | true once its run has ended and its conversation is let go, so from can continue a saved one. Waits. Its own tools go with it. |
a.id, a.name |
— | Its conversation's id, and its name |
Cancelling a coroutine that waits on send or compact cancels the agent as a:cancel()
does. A handle needs closing: an agent left open runs until :reload. lug.agent.get(id)
gives a handle on any live agent, the session's included, so a panel can watch or cancel
agents it did not start; the session's send takes no output and its close raises. A
tool handler's ctx.agent is the handle of the agent whose model called it.
local fixer = lug.agent.start({ name = "fixer", model = "@fast", tools = { "read", "grep", "edit" } })
local answer = fixer:send("The tests fail; fix them.", { input = { log = log } })
fixer:queue("Now run the whole suite.")
local off = fixer:on("tool_finished", function(e) progress(e) end)
fixer:close()
Under --mock, a script's { "json": value } event answers with value as JSON. Every
runtime shares the script's turns, each model call taking the next, so agents running at
once would take them in whatever order they happen to ask. A script gives an agent turns
of its own under agents, by its name, and agents it does not name share turns as
before, so a test of five reviewers at once plays the same way every time.
{
"turns": [[{ "tool_call": { "name": "review", "args": {} } }], [{ "text": "Two findings." }]],
"agents": {
"security": { "turns": [[{ "tool_call": { "name": "report", "args": { "line": 12 } } }], [{ "text": "done" }]] },
"style": { "turns": [[{ "text": "nothing to report" }]] }
}
}
### Working directories
The workspace is the directory lug started in; sessions, streams and trust belong to it.
Each agent also has a working directory, where its built-in tools read, write and run
commands: the session's by default, which is the workspace until lug.session.cd moves
it. dir in lug.agent.call and lug.agent.start sets an agent's own, so several agents
can work at once in as many git worktrees, each told where it works. Lug knows nothing of
git: a plugin makes the worktree and passes its path.
local dir = "../wt/fix-login" -- made by a plugin with `git worktree add`
local fixer = lug.agent.start({ name = "fixer", dir = dir, tools = true })
fixer:send("Fix the login redirect.")
local status = lug.proc.run({ "git", "status", "--short" }, { cwd = dir })
A Lua tool's handler gets its caller's directory as ctx.dir, lug.proc.* take cwd,
and lug.tool.call runs a built-in tool in ctx.dir. The approval dialog says where a
call runs when that is not where the session works. A worktree's own .lug/init.lua is
never loaded.
## Streams
A stream is a durable list of events a plugin keeps: a workflow's steps, a todo list, a
memory. It is a file of JSON lines under Lug's state directory, streams/KIND/ID.jsonl,
and it outlives lug. What the events mean is the plugin's; Lug only appends, reads,
lists and holds them. One lug holds a stream at a time; a reload or quitting gives
back every stream it holds.
Sessions are streams too, of kind session, and lug.agent.call's saved conversations
of kind agent. Their header's meta is { rig, model, title }, and their events are
message (data.m is the message as Rig encodes it, data.n its place),
compacted (data.summary, the whole summary message, and data.kept, how many
messages it kept) and meta (a new title or model). A plugin can list and read
them, to search or export sessions, but only Lug writes them: lug.stream.create and
open refuse those kinds. lug.session.items reads one as the transcript shows it.
| Name | Arguments | Returns |
|---|---|---|
lug.stream.create(kind, meta?) |
A kind: letters, digits, _ or -; a table kept with the stream |
Its id, "kind/k3f9x2ab", held by this lug; or nil and why |
lug.stream.open(id) |
A stream's id | Its version: its last event's seq, 0 for none; or nil and why when another lug holds it |
lug.stream.append(id, events, opts?) |
A stream this lug holds; { { type, data?, meta? }, … }; { expected? } |
The new version; or nil and why when the stream is not at expected, or an event is over 1 MiB. Raises when this lug does not hold it. |
lug.stream.read(id, opts?) |
{ from?, limit?, type? } |
events, header: { { seq, at, type, data, meta }, … } from seq from (default 1), and { id, kind, workspace, created, meta }; or nil and why |
lug.stream.list(kind?, opts?) |
A kind, or nil for all; { all? } for every workspace's |
{ { id, kind, workspace, created, meta, updated, version, open }, … }, most recently changed first |
lug.stream.close(id) |
A stream's id | Nothing: gives it back |
The events of one append are written together. expected makes an append fail when
someone appended since you last read, so two writers never interleave. meta on an event
is for the plugin's own bookkeeping, such as which event caused it. A lug killed while
writing loses at most the event it was writing.
local id = lug.stream.create("todo", { title = "Release" })
lug.stream.append(id, { { type = "added", data = { text = "tag v0.0.2" } } })
for _, e in ipairs(lug.stream.read(id)) do print(e.seq, e.type, e.data.text) end
## Lug itself
Plugins are installed by haul, a plugin like any
other, whose README documents require("haul"). See
Configuration.
| Name | Arguments | Returns |
|---|---|---|
lug.version |
— | Lug's version, like "0.0.1" |
lug.dirs |
— | { config, data }: where init.lua and plugins/ live |
lug.docs |
— | The files lug docs lua writes, by name ("lug.lua", "lua-api.md", "lug/bsp.lua"...), as text |
## Testing a plugin
lug test runs a plugin's tests inside the real Lug, headless, rather than against a
stand-in lug table. Run it in the plugin's folder:
lug test # every tests/test_*.lua
lug test tests/test_panel.lua # the files named
lug test --with ../basic.lug --mock script.json
Each file runs in its own lug, with a fresh home, so no config, plugin, session or API
key of yours reaches it, and an environment cleared but for PATH. Its workspace is an
empty folder of its own, so what it runs and writes never touches the plugin's folder, and
the files beside it can be required as helpers (require("fixtures") for
tests/fixtures.lua). The plugin in the folder loads as haul loads one (its lua/ on the path,
then require(NAME)), after each --with folder and after the ones it depends on. A dependency its haul.lua names is never cloned: give its folder
with --with, or the file fails. basic.lug is a plugin like any other, so a test that needs
its keys (the dialogs', say) names it with --with. --mock FILE is the scripted model,
as for lug --mock, and a test_NAME.json beside test_NAME.lua is that file's own.
The file runs as a coroutine once Lug has started, so it may wait on anything a plugin
can. It passes when it returns, and fails when it raises (printed with its traceback) or
calls os.exit with a status other than 0. One left waiting on what nothing will finish, a
dialog no key answers, say, fails too. lug test prints ok or FAIL for each file and
exits 1 when any failed. Assertions are plain assert and error.
lug.feedkeys runs what the keys map to at once, but some answers arrive on the loop's
next turn, as they would between two keys typed: a Lua tool's handler starts then, and so
does a coroutine a dialog answers. lug.sleep(0) lets the loop turn, and draws a frame, so
lug.ui.windows() shows what the keys opened.
-- tests/test_greet.lua
local greet = require("greet")
local answer = require("lug.task").run(greet.ask) -- opens a dialog, waits for it
lug.feedkeys("world<CR>") -- through the dialog's mappings
assert(answer:wait() == "world") -- waits a turn for the answer
local entries = lug.feed.entries("greet")
assert(entries[#entries].text == "hello, world", entries[#entries].text)
debug.traceback(msg?, level?) is there, alone of the debug library, for error
handlers such as xpcall(f, debug.traceback).