Skip to content

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.

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.

local oil = cru.oil
-- Simple text
local hello = oil.text("Hello, World!")
-- Styled text
local styled = oil.text("Important!", { fg = "red", bold = true })
-- Layout with column
local view = oil.col({ gap = 1 },
oil.text("Title", { bold = true }),
oil.text("Content goes here"),
oil.spinner("Loading...")
)
-- Conditional rendering
local loading_view = oil.when(is_loading, oil.spinner("Please wait..."))

Display text with optional styling.

Parameters:

  • content (string|number): Text content to display
  • opts (table, optional): Styling options

Returns: Node

Example:

oil.text("Hello")
oil.text("Error!", { fg = "red", bold = true })
oil.text(42, { fg = "cyan" })

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 children
  • padding (number): Internal padding
  • margin (number): External margin
  • border (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 column
oil.col(
oil.text("Line 1"),
oil.text("Line 2")
)
-- Column with options
oil.col({ gap = 2, padding = 1, border = "rounded" },
oil.text("Title"),
oil.text("Content")
)

Horizontal layout container (row).

Parameters: Same as col()

Returns: Node

Example:

-- Simple row
oil.row(
oil.text("Left"),
oil.spacer(),
oil.text("Right")
)
-- Row with gap
oil.row({ gap = 2 },
oil.text("Item 1"),
oil.text("Item 2"),
oil.text("Item 3")
)

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")
)

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")
)

Animated loading spinner.

Parameters:

  • label (string, optional): Text to display next to spinner

Returns: Node

Example:

oil.spinner()
oil.spinner("Loading data...")

Text input field with cursor.

Parameters:

  • opts (table):
    • value (string): Current input value
    • cursor (number): Cursor position
    • placeholder (string, optional): Placeholder text
    • focused (boolean, optional): Whether input is focused (default: true)

Returns: Node

Example:

oil.input({
value = "Hello",
cursor = 5,
placeholder = "Type here...",
focused = true
})

Popup menu with selectable items.

Parameters:

  • items (table): Array of items (strings or tables with label, desc, kind)
  • selected (number, optional): Index of selected item (default: 0)
  • max_visible (number, optional): Maximum visible items (default: 10)

Returns: Node

Example:

-- Simple popup
oil.popup({"Option 1", "Option 2", "Option 3"}, 0, 5)
-- Popup with descriptions
oil.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 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 wide
oil.progress(0.75, 40) -- 75% progress, 40 chars wide

Small labeled badge.

Parameters:

  • label (string): Badge text
  • opts (table, optional): Styling options

Returns: Node

Example:

oil.badge("NEW")
oil.badge("ERROR", { fg = "red", bold = true })
oil.badge("v1.2.3", { fg = "cyan" })

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) -- ********...

Horizontal rule (same as divider() with defaults).

Returns: Node

Example:

oil.hr()

Bulleted list.

Parameters:

  • items (table): Array of strings

Returns: Node

Example:

oil.bullet_list({
"First item",
"Second item",
"Third item"
})

Numbered list.

Parameters:

  • items (table): Array of strings

Returns: Node

Example:

oil.numbered_list({
"Step one",
"Step two",
"Step three"
})

Key-value pair display.

Parameters:

  • key (string): Key label
  • value (string): Value text

Returns: Node

Example:

oil.kv("Name", "John Doe")
oil.kv("Status", "Active")

Render node only if condition is true.

Parameters:

  • condition (boolean): Condition to check
  • node (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" }))

Render one of two nodes based on condition. Also available as if_else.

Parameters:

  • condition (boolean): Condition to check
  • true_node (Node): Node to render if true
  • false_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_else
oil.if_else(has_data,
oil.text(data),
oil.text("No data", { fg = "yellow" })
)

Map over items and render each one.

Parameters:

  • items (table): Array of items
  • fn (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)

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, border
  • fg, 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>
]])

Create a reusable component with default props.

Parameters:

  • base_fn (function): Base function (like oil.col or oil.row)
  • defaults (table): Default options

Returns: Function that merges user props with defaults

Example:

-- Create a Card component with default styling
local Card = oil.component(oil.col, {
border = "rounded",
padding = 1,
gap = 1
})
-- Use it with custom props
Card({ gap = 2 },
oil.text("Title", { bold = true }),
oil.text("Content")
)
-- Use it with defaults only
Card(
oil.text("Simple card")
)

Scrollable content area. Preserves scroll position across renders.

Parameters:

  • key (string): Unique key for this scrollback area
  • children...: Child nodes

Returns: Node

Example:

oil.scrollback("chat-messages",
oil.text("Message 1"),
oil.text("Message 2"),
oil.text("Message 3")
)

Animated decrypt/scramble effect.

Parameters:

  • content (string): Text to decrypt
  • revealed (number): Number of characters revealed
  • frame (number, optional): Animation frame (default: 0)

Returns: Node

Example:

-- Gradually reveal text
oil.decrypt("Secret message", 6, 0) -- "Secret█████████"
oil.decrypt("Secret message", 14, 0) -- "Secret message"

All nodes support chainable methods for styling:

Apply styling to a node.

Example:

oil.text("Hello"):with_style({ fg = "red", bold = true })

Add padding to a node.

Example:

oil.col(oil.text("Content")):with_padding(2)

Add border to a node.

Example:

oil.col(oil.text("Boxed")):with_border("rounded")

Add margin to a node.

Example:

oil.text("Spaced"):with_margin(1)

Set gap between children.

Example:

oil.col(child1, child2):gap(2)

Set justify mode.

Example:

oil.col(child1, child2):justify("center")

Set align mode.

Example:

oil.row(child1, child2):align("center")

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 color
{ bold = true } -- Bold text
{ dim = true } -- Dimmed text
{ italic = true } -- Italic text
{ underline = true } -- Underlined text
{ gap = 2 } -- Spacing between children
{ padding = 1 } -- Internal padding
{ margin = 1 } -- External margin
{ border = "rounded" } -- Border style
{ justify = "center" } -- Justify content
{ align = "center" } -- Align items

Border 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

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
-- Usage
message_block("user", "Hello, how are you?")
message_block("assistant", "I'm doing well, thank you!")
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
-- Usage
status_bar("NORMAL", "gpt-4o", 45)
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
-- Usage
tool_call_display("search", "running", nil)
tool_call_display("search", "complete", "Found 5 results")
local function loading_view(is_loading, error_msg, data)
return oil.col({ gap = 1 },
oil.text("Data Viewer", { bold = true }),
oil.hr(),
-- 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)
)
)
end
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
-- Usage
progress_indicator("Processing files", 7, 10)
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
-- Usage
card("System Info", oil.col({ gap = 0 },
oil.kv("Model", "gpt-4o"),
oil.kv("Status", "Ready"),
oil.kv("Uptime", "2h 34m")
))

Build complex UIs from simple, reusable components:

-- Define reusable components
local 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 them
local view = Card("User Info",
oil.col({ gap = 0 },
InfoRow("Name", "Alice"),
InfoRow("Role", "Admin"),
InfoRow("Status", "Active")
)
)

Use semantic colors and consistent spacing:

-- Good: Semantic colors
local colors = {
success = "green",
error = "red",
warning = "yellow",
info = "blue"
}
-- Good: Consistent spacing
local SPACING = {
tight = 0,
normal = 1,
loose = 2
}
oil.col({ gap = SPACING.normal },
oil.text("Title"),
oil.text("Content")
)

Use when() and either() for clean conditional logic:

-- Good: Clear conditional rendering
oil.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() end
  1. Avoid deep nesting: Keep component trees shallow
  2. Use keys for lists: Helps with efficient re-rendering
  3. Memoize expensive computations: Cache results when possible
  4. Use fragment() sparingly: Only when you need grouping without layout

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