Oil Lua API Reference
Build custom TUI components with Lua
Oil is Crucible’s declarative UI library for building terminal interfaces. This API allows Lua plugins to create custom views, components, and interactive elements using a composable, React-like approach.
Overview
The Oil Lua API provides:
- Layout Primitives: Build UIs with columns, rows, and flexible spacing
- Widgets: Spinners, progress bars, input fields, popups, and more
- Styling: Colors, borders, padding, and text attributes
- Conditional Rendering: Show/hide elements based on state
- Component Composition: Build reusable UI components
- Markup Syntax: Quick prototyping with XML-like syntax
All Oil functions are available under the cru.oil namespace.
Quick Start
local oil = cru.oil
-- Simple textlocal hello = oil.text("Hello, World!")
-- Styled textlocal styled = oil.text("Important!", { fg = "red", bold = true })
-- Layout with columnlocal view = oil.col({ gap = 1 }, oil.text("Title", { bold = true }), oil.text("Content goes here"), oil.spinner("Loading..."))
-- Conditional renderinglocal loading_view = oil.when(is_loading, oil.spinner("Please wait..."))API Reference
Layout Primitives
text(content, opts?)
Display text with optional styling.
Parameters:
content(string|number): Text content to displayopts(table, optional): Styling options
Returns: Node
Example:
oil.text("Hello")oil.text("Error!", { fg = "red", bold = true })oil.text(42, { fg = "cyan" })col(opts_or_children...)
Vertical layout container (column).
Parameters:
- First argument can be options table (if it contains layout keys like
gap,padding, etc.) - Remaining arguments are child nodes
Options:
gap(number): Spacing between childrenpadding(number): Internal paddingmargin(number): External marginborder(string): Border style (“single”, “double”, “rounded”, “heavy”)justify(string): Vertical alignment (“start”, “end”, “center”, “space_between”, “space_around”, “space_evenly”)align(string): Horizontal alignment (“start”, “end”, “center”, “stretch”)
Returns: Node
Example:
-- Simple columnoil.col( oil.text("Line 1"), oil.text("Line 2"))
-- Column with optionsoil.col({ gap = 2, padding = 1, border = "rounded" }, oil.text("Title"), oil.text("Content"))row(opts_or_children...)
Horizontal layout container (row).
Parameters: Same as col()
Returns: Node
Example:
-- Simple rowoil.row( oil.text("Left"), oil.spacer(), oil.text("Right"))
-- Row with gapoil.row({ gap = 2 }, oil.text("Item 1"), oil.text("Item 2"), oil.text("Item 3"))spacer()
Flexible space that expands to fill available space. Useful for pushing elements apart in rows.
Returns: Node
Example:
oil.row( oil.text("Left"), oil.spacer(), -- Pushes "Right" to the far right oil.text("Right"))fragment(children...)
Container that renders children without adding layout. Useful for grouping.
Parameters:
children...: Child nodes
Returns: Node
Example:
oil.fragment( oil.text("Item 1"), oil.text("Item 2"))Widgets
spinner(label?)
Animated loading spinner.
Parameters:
label(string, optional): Text to display next to spinner
Returns: Node
Example:
oil.spinner()oil.spinner("Loading data...")input(opts)
Text input field with cursor.
Parameters:
opts(table):value(string): Current input valuecursor(number): Cursor positionplaceholder(string, optional): Placeholder textfocused(boolean, optional): Whether input is focused (default: true)
Returns: Node
Example:
oil.input({ value = "Hello", cursor = 5, placeholder = "Type here...", focused = true})popup(items, selected?, max_visible?)
Popup menu with selectable items.
Parameters:
items(table): Array of items (strings or tables withlabel,desc,kind)selected(number, optional): Index of selected item (default: 0)max_visible(number, optional): Maximum visible items (default: 10)
Returns: Node
Example:
-- Simple popupoil.popup({"Option 1", "Option 2", "Option 3"}, 0, 5)
-- Popup with descriptionsoil.popup({ {label = "Save", desc = "Save current file", kind = "action"}, {label = "Load", desc = "Load from disk", kind = "action"}, {label = "Exit", desc = "Quit application", kind = "danger"}}, 0, 10)progress(value, width?)
Progress bar.
Parameters:
value(number): Progress value (0.0 to 1.0)width(number, optional): Bar width in characters (default: 20)
Returns: Node
Example:
oil.progress(0.5, 30) -- 50% progress, 30 chars wideoil.progress(0.75, 40) -- 75% progress, 40 chars widebadge(label, opts?)
Small labeled badge.
Parameters:
label(string): Badge textopts(table, optional): Styling options
Returns: Node
Example:
oil.badge("NEW")oil.badge("ERROR", { fg = "red", bold = true })oil.badge("v1.2.3", { fg = "cyan" })divider(char?, width?)
Horizontal divider line.
Parameters:
char(string, optional): Character to repeat (default: ”─”)width(number, optional): Line width (default: 80)
Returns: Node
Example:
oil.divider() -- ─────────...oil.divider("=", 40) -- ========...oil.divider("*", 20) -- ********...hr()
Horizontal rule (same as divider() with defaults).
Returns: Node
Example:
oil.divider()Lists
bullet_list(items)
Bulleted list.
Parameters:
items(table): Array of strings
Returns: Node
Example:
oil.bullet_list({ "First item", "Second item", "Third item"})numbered_list(items)
Numbered list.
Parameters:
items(table): Array of strings
Returns: Node
Example:
oil.numbered_list({ "Step one", "Step two", "Step three"})kv(key, value)
Key-value pair display.
Parameters:
key(string): Key labelvalue(string): Value text
Returns: Node
Example:
oil.kv("Name", "John Doe")oil.kv("Status", "Active")Conditional Rendering
when(condition, node)
Render node only if condition is true.
Parameters:
condition(boolean): Condition to checknode(Node): Node to render if true
Returns: Node (or empty node if false)
Example:
oil.when(is_loading, oil.spinner("Loading..."))oil.when(has_error, oil.text("Error occurred!", { fg = "red" }))either(condition, true_node, false_node)
Render one of two nodes based on condition. Also available as if_else.
Parameters:
condition(boolean): Condition to checktrue_node(Node): Node to render if truefalse_node(Node): Node to render if false
Returns: Node
Example:
oil.either(is_ready, oil.text("Ready!", { fg = "green" }), oil.spinner("Initializing..."))
-- Also available as if_elseoil.either(has_data, oil.text(data), oil.text("No data", { fg = "yellow" }))Conditional rendering
maybe is removed; plain Lua says it more directly.
if session.error then return oil.text(session.error, { fg = "red" })endmatch_state(state, handlers)
Table-driven dispatch on a state value.
Parameters:
state(any): Key to look up inhandlershandlers(table): Maps state keys to a Node, a string (rendered as text), or a zero-argument function returning a Node. The key_is the default handler.
Returns: Node. When state has no handler and there is no _ entry, an empty node — a missing state renders nothing rather than erroring.
Example:
oil.match_state(status, { loading = function() return oil.spinner("Loading...") end, error = oil.text("Failed", { fg = "red" }), done = "Complete", _ = oil.text("Unknown state", { fg = "yellow" }),})Iteration
each(items, fn)
Map over items and render each one.
Parameters:
items(table): Array of itemsfn(function): Function that takes(item, index)and returns a Node
Returns: Node (fragment containing all rendered items)
Example:
local names = {"Alice", "Bob", "Charlie"}
oil.each(names, function(name, idx) return oil.text(idx .. ". " .. name)end)Advanced
markup(xml_string)
Parse XML-like markup into nodes. Useful for quick prototyping.
Parameters:
xml_string(string): XML-like markup
Supported tags:
<div>→ col<p>→ text<ul>→ bullet_list<li>→ list item
Attributes:
gap,padding,margin,borderfg,bg,bold,dim,italic,underline
Returns: Node
Example:
oil.markup([[ <div gap="2" padding="1" border="rounded"> <p bold="true" fg="cyan">Title</p> <p>Content goes here</p> <ul> <li>Item 1</li> <li>Item 2</li> </ul> </div>]])component(base_fn, defaults)
Create a reusable component with default props.
Parameters:
base_fn(function): Base function (likeoil.coloroil.row)defaults(table): Default options
Returns: Function that merges user props with defaults
Example:
-- Create a Card component with default stylinglocal Card = oil.component(oil.col, { border = "rounded", padding = 1, gap = 1})
-- Use it with custom propsCard({ gap = 2 }, oil.text("Title", { bold = true }), oil.text("Content"))
-- Use it with defaults onlyCard( oil.text("Simple card"))scrollback(key, children...)
Deprecated. The scrollback widget was removed; this function is kept only for backward compatibility. It ignores key and returns its children as a plain fragment — no scrolling, no scroll-position state.
Parameters:
key(string): Ignoredchildren...: Child nodes
Returns: Node (fragment of the children)
Node Methods
All nodes support chainable methods for styling:
:with_style(opts)
Apply styling to a node.
Example:
oil.text("Hello"):with_style({ fg = "red", bold = true }):with_padding(n)
Add padding to a node.
Example:
oil.col(oil.text("Content")):with_padding(2):with_border(type)
Add border to a node.
Example:
oil.col(oil.text("Boxed")):with_border("rounded"):with_margin(n)
Add margin to a node.
Example:
oil.text("Spaced"):with_margin(1):gap(n)
Set gap between children.
Example:
oil.col(child1, child2):gap(2):justify(mode)
Set justify mode.
Example:
oil.col(child1, child2):justify("center"):align(mode)
Set align mode.
Example:
oil.row(child1, child2):align("center")Styling Options
Colors
Named colors:
"red","green","blue","cyan","yellow","magenta","white","black"
Hex colors:
"#ff0000","#00ff00","#0000ff", etc.
Usage:
{ fg = "red" } -- Foreground color{ bg = "blue" } -- Background color{ fg = "#ff5500" } -- Hex colorText Attributes
{ bold = true } -- Bold text{ dim = true } -- Dimmed text{ italic = true } -- Italic text{ underline = true } -- Underlined textLayout Options
{ gap = 2 } -- Spacing between children{ padding = 1 } -- Internal padding{ margin = 1 } -- External margin{ border = "rounded" } -- Border style{ justify = "center" } -- Justify content{ align = "center" } -- Align itemsBorder styles:
"single"- Single line border"double"- Double line border"rounded"- Rounded corners"heavy"- Heavy/thick border
Justify modes:
"start"- Align to start"end"- Align to end"center"- Center items"space_between"- Space between items"space_around"- Space around items"space_evenly"- Even spacing
Align modes:
"start"- Align to start"end"- Align to end"center"- Center items"stretch"- Stretch to fill
Common Patterns
Message Block
local function message_block(role, content) local role_colors = { user = "green", assistant = "blue", system = "yellow" }
local color = role_colors[role] or "white"
return oil.col({ gap = 0 }, oil.text(""), oil.text(string.upper(role), { fg = color, bold = true }), oil.col({ padding = 1, border = "single" }, oil.text(content) ), oil.text("") )end
-- Usagemessage_block("user", "Hello, how are you?")message_block("assistant", "I'm doing well, thank you!")Status Bar
local function status_bar(mode, model, context_pct) local mode_colors = { NORMAL = "green", PLAN = "blue", AUTO = "yellow" }
return oil.row({ gap = 2 }, oil.text(" " .. mode .. " ", { bg = mode_colors[mode], fg = "black", bold = true }), oil.text(model, { fg = "cyan" }), oil.spacer(), oil.text(string.format("%d%% ctx", context_pct), { fg = "yellow" }) )end
-- Usagestatus_bar("NORMAL", "gpt-4o", 45)Tool Call Display
local function tool_call_display(name, status, result) local status_colors = { pending = "yellow", running = "cyan", complete = "green", error = "red" }
return oil.col({ border = "rounded", padding = 1, gap = 1 }, oil.row({ gap = 2 }, oil.text("🔧", { bold = true }), oil.text(name, { bold = true }), oil.badge(status, { fg = status_colors[status] }) ), oil.when(status == "running", oil.spinner("Processing...")), oil.when(result ~= nil, oil.col({ gap = 0 }, oil.divider("─", 40), oil.text(result) ) ) )end
-- Usagetool_call_display("search", "running", nil)tool_call_display("search", "complete", "Found 5 results")Loading State
local function loading_view(is_loading, error_msg, data) return oil.col({ gap = 1 }, oil.text("Data Viewer", { bold = true }), oil.divider(),
-- Show spinner while loading oil.when(is_loading, oil.col({ gap = 1 }, oil.spinner("Loading..."), oil.text("Please wait", { fg = "yellow" }) ) ),
-- Show error if present oil.when(error_msg ~= nil, oil.col({ border = "heavy", padding = 1 }, oil.text("Error", { fg = "red", bold = true }), oil.text(error_msg, { fg = "red" }) ) ),
-- Show data when ready oil.when(not is_loading and error_msg == nil and data ~= nil, oil.text(data) ) )endProgress Indicator
local function progress_indicator(label, current, total) local percentage = math.floor((current / total) * 100)
return oil.col({ gap = 0 }, oil.row({ gap = 2 }, oil.text(label), oil.text(string.format("%d/%d", current, total), { fg = "cyan" }) ), oil.progress(current / total, 40), oil.text(string.format("%d%%", percentage), { fg = "green" }) )end
-- Usageprogress_indicator("Processing files", 7, 10)Card Component
local function card(title, content, opts) opts = opts or {} local border_style = opts.border or "rounded" local padding = opts.padding or 1
return oil.col({ border = border_style, padding = padding, gap = 1 }, oil.text(title, { bold = true, fg = "cyan" }), content )end
-- Usagecard("System Info", oil.col({ gap = 0 }, oil.kv("Model", "gpt-4o"), oil.kv("Status", "Ready"), oil.kv("Uptime", "2h 34m")))Best Practices
Component Composition
Build complex UIs from simple, reusable components:
-- Define reusable componentslocal function InfoRow(label, value) return oil.row({ gap = 2 }, oil.text(label .. ":", { fg = "cyan" }), oil.text(value) )end
local function Card(title, children) return oil.col({ border = "rounded", padding = 1, gap = 1 }, oil.text(title, { bold = true }), children )end
-- Compose themlocal view = Card("User Info", oil.col({ gap = 0 }, InfoRow("Name", "Alice"), InfoRow("Role", "Admin"), InfoRow("Status", "Active") ))Styling Conventions
Use semantic colors and consistent spacing:
-- Good: Semantic colorslocal colors = { success = "green", error = "red", warning = "yellow", info = "blue"}
-- Good: Consistent spacinglocal SPACING = { tight = 0, normal = 1, loose = 2}
oil.col({ gap = SPACING.normal }, oil.text("Title"), oil.text("Content"))Conditional Rendering
Use when() and either() for clean conditional logic:
-- Good: Clear conditional renderingoil.when(is_loading, oil.spinner("Loading..."))
oil.either(has_data, oil.text(data), oil.text("No data available", { fg = "yellow" }))
-- Avoid: Lua conditionals that return nil-- Bad: if is_loading then return oil.spinner() endPerformance Tips
- Avoid deep nesting: Keep component trees shallow
- Use keys for lists: Helps with efficient re-rendering
- Memoize expensive computations: Cache results when possible
- Use
fragment()sparingly: Only when you need grouping without layout
Examples
See examples/plugins/custom-ui.lua for comprehensive examples including:
- Chat interface with messages and tool calls
- Dashboard with cards and statistics
- Progress tracking with multiple indicators
- Conditional rendering patterns
- Markup syntax examples
- Component composition techniques