Raxol Plugin Development Guide

View Source

Overview

Raxol's plugin system allows you to extend terminal functionality with custom features, UI components, and integrations. This guide covers everything you need to know to create powerful plugins for the Raxol terminal emulator.

Quick Start

Basic Plugin Structure

defmodule YourApp.Plugins.MyPlugin do
  @moduledoc """
  Description of your plugin functionality
  """

  use GenServer
  require Logger

  # Plugin Manifest - Required
  def manifest do
    %{
      name: "my-plugin",
      version: "1.0.0",
      description: "Brief description of plugin functionality",
      author: "Your Name",
      dependencies: %{
        "raxol-core" => "~> 1.5"
      },
      capabilities: [
        :ui_panel,
        :keyboard_input,
        :shell_command
      ],
      config_schema: %{
        hotkey: %{type: :string, default: "ctrl+p"},
        enabled: %{type: :boolean, default: true}
      }
    }
  end

  # Plugin State
  defstruct [
    :config,
    :state_field_1,
    :state_field_2
  ]

  # GenServer implementation
  def start_link(config) do
    GenServer.start_link(__MODULE__, config, name: __MODULE__)
  end

  @impl true
  def init(config) do
    state = %__MODULE__{config: config}
    {:ok, state}
  end

  # Handle plugin events
  @impl true
  def handle_call(:some_action, _from, state) do
    {:reply, :ok, state}
  end
end

Core Concepts

1. Plugin Manifest

Every plugin must define a manifest/0 function that returns plugin metadata:

def manifest do
  %{
    # Required fields
    name: "unique-plugin-name",           # Unique identifier (kebab-case)
    version: "1.0.0",                     # Semantic version
    description: "What this plugin does", # User-friendly description
    author: "Plugin Author",              # Author information

    # Dependencies
    dependencies: %{
      "raxol-core" => "~> 1.5",          # Required Raxol version
      "other-plugin" => "~> 2.0"         # Optional plugin dependencies
    },

    # Capabilities - what the plugin can do
    capabilities: [
      :ui_panel,        # Can render UI panels
      :keyboard_input,  # Handles keyboard events
      :shell_command,   # Can execute shell commands
      :file_system,     # Accesses file system
      :file_watcher,    # Watches file changes
      :status_line,     # Contributes to status line
      :theme_provider   # Provides color themes
    ],

    # Configuration schema
    config_schema: %{
      field_name: %{
        type: :string,           # :string, :integer, :boolean, :list, :map
        default: "default_value", # Default value
        description: "Field purpose", # User documentation
        required: false,         # Whether field is required
        enum: ["opt1", "opt2"]   # Valid options (optional)
      }
    }
  }
end

2. Plugin State Management

Use a struct to define your plugin's internal state:

defstruct [
  :config,           # Plugin configuration from user
  :ui_state,         # UI-related state
  :data,             # Plugin-specific data
  :timers,           # Active timers
  :subscriptions     # Event subscriptions
]

3. GenServer Lifecycle

Plugins are implemented as GenServers for state management and concurrency:

@impl true
def init(config) do
  # Initialize plugin state
  state = %__MODULE__{
    config: config,
    data: load_initial_data()
  }

  # Set up timers, subscriptions, etc.
  timer = :timer.send_interval(config.refresh_interval, :refresh)

  {:ok, %{state | timers: [timer]}}
end

@impl true
def terminate(_reason, state) do
  # Clean up resources
  Enum.each(state.timers, &:timer.cancel/1)
  :ok
end

Plugin Capabilities

UI Panel Capability

Render custom UI panels within the terminal:

def render_panel(state, width, height) do
  # Return list of rendered lines
  lines = [
    %{
      text: "Panel Header",
      style: TextFormatting.new() |> TextFormatting.apply_attribute(:bold)
    },
    %{
      text: "Content line 1",
      style: TextFormatting.new()
    }
  ]

  # Ensure we return exactly 'height' lines
  lines
  |> Enum.take(height)
  |> pad_to_height(height, width)
end

# Helper function to pad lines to required height
defp pad_to_height(lines, height, width) do
  empty_line = %{
    text: String.pad_trailing("", width),
    style: TextFormatting.new()
  }

  current_count = length(lines)
  if current_count < height do
    lines ++ List.duplicate(empty_line, height - current_count)
  else
    lines
  end
end

Keyboard Input Capability

Handle keyboard events and shortcuts:

def handle_keypress(key, state) do
  case key do
    "enter" ->
      # Handle enter key
      execute_action(state)

    "escape" ->
      # Handle escape
      %{state | ui_state: :closed}

    "j" ->
      # Vim-style navigation
      move_cursor_down(state)

    "k" ->
      move_cursor_up(state)

    _ ->
      state  # Ignore other keys
  end
end

# Register hotkeys in manifest
config_schema: %{
  hotkey: %{type: :string, default: "ctrl+p"},
  toggle_key: %{type: :string, default: "f2"}
}

Shell Command Capability

Execute shell commands safely:

def execute_command(command, args, working_dir) do
  case System.cmd(command, args, cd: working_dir, stderr_to_stdout: true) do
    {output, 0} ->
      {:ok, output}

    {error, exit_code} ->
      Logger.error("Command failed with exit code #{exit_code}: #{error}")
      {:error, {exit_code, error}}
  end
end

# Example: Git integration
defp get_git_status(repo_path) do
  case execute_command("git", ["status", "--porcelain"], repo_path) do
    {:ok, output} -> parse_git_status(output)
    {:error, _} -> []
  end
end

File System Capability

Access and monitor file system changes:

def list_directory(path) do
  case File.ls(path) do
    {:ok, entries} ->
      entries
      |> Enum.map(&get_file_info(Path.join(path, &1)))
      |> Enum.sort_by(& &1.name)

    {:error, reason} ->
      Logger.error("Failed to list directory #{path}: #{reason}")
      []
  end
end

defp get_file_info(path) do
  stat = File.stat!(path)

  %{
    name: Path.basename(path),
    path: path,
    type: if(stat.type == :directory, do: :directory, else: :file),
    size: stat.size,
    modified: stat.mtime
  }
end

Status Line Capability

Contribute information to the status line:

def status_line_info(state) do
  case state.data do
    nil -> ""
    data ->
      # Return string to display in status line
      "#{data.branch} +#{data.changes}"
  end
end

# Status line integration happens automatically
# Your string will be included in the status line display

Advanced Features

Real-time Updates

Use timers and GenServer messages for real-time updates:

@impl true
def init(config) do
  # Set up periodic refresh
  timer = :timer.send_interval(config.refresh_interval || 1000, :refresh)

  state = %__MODULE__{
    config: config,
    refresh_timer: timer
  }

  {:ok, state}
end

@impl true
def handle_info(:refresh, state) do
  # Refresh plugin data
  updated_state = refresh_data(state)
  {:noreply, updated_state}
end

File Watching

Monitor file system changes:

def start_file_watcher(path, state) do
  # This would integrate with Raxol's file watching system
  # Implementation depends on the underlying file watcher
  watcher = FileWatcher.watch(path, self())
  %{state | file_watcher: watcher}
end

@impl true
def handle_info({:file_event, path, events}, state) do
  # Handle file change events
  updated_state = handle_file_change(path, events, state)
  {:noreply, updated_state}
end

Inter-Plugin Communication

Communicate with other plugins:

# Send message to another plugin
def notify_other_plugin(message) do
  GenServer.cast(OtherPlugin, {:notification, message})
end

# Handle messages from other plugins
@impl true
def handle_cast({:notification, message}, state) do
  # Process notification
  updated_state = process_notification(message, state)
  {:noreply, updated_state}
end

Styling and Theming

Text Formatting

Use Raxol.Terminal.ANSI.TextFormatting for rich text:

alias Raxol.Terminal.ANSI.TextFormatting

# Create formatted text
def format_text(text, options \\ []) do
  style = TextFormatting.new()

  style = if options[:bold], do: TextFormatting.apply_attribute(style, :bold), else: style
  style = if options[:color], do: TextFormatting.set_foreground(style, options[:color]), else: style
  style = if options[:bg], do: TextFormatting.set_background(style, options[:bg]), else: style

  %{text: text, style: style}
end

# Common styling patterns
def success_text(text), do: format_text(text, color: :green, bold: true)
def error_text(text), do: format_text(text, color: :red, bold: true)
def warning_text(text), do: format_text(text, color: :yellow)
def muted_text(text), do: format_text(text, color: :gray)

Color Schemes

Support different color themes:

def get_color_scheme(theme \\ :default) do
  case theme do
    :default -> %{
      primary: :blue,
      secondary: :cyan,
      success: :green,
      warning: :yellow,
      error: :red,
      muted: :gray
    }

    :dark -> %{
      primary: :bright_blue,
      secondary: :bright_cyan,
      success: :bright_green,
      warning: :bright_yellow,
      error: :bright_red,
      muted: :gray
    }
  end
end

Plugin Examples

File Browser Plugin

defmodule Raxol.Plugins.Examples.FileBrowserPlugin do
  use GenServer

  def manifest do
    %{
      name: "file-browser",
      version: "1.0.0",
      description: "Tree-style file browser",
      author: "Raxol Team",
      dependencies: %{"raxol-core" => "~> 1.5"},
      capabilities: [:file_system, :ui_panel, :keyboard_input],
      config_schema: %{
        initial_path: %{type: :string, default: "."},
        show_hidden: %{type: :boolean, default: false},
        hotkey: %{type: :string, default: "ctrl+b"}
      }
    }
  end

  defstruct [:config, :current_path, :entries, :selected_index]

  # Implementation...
end

Git Integration Plugin

defmodule Raxol.Plugins.Examples.GitPlugin do
  use GenServer

  def manifest do
    %{
      name: "git-integration",
      version: "1.0.0",
      description: "Git repository management",
      author: "Raxol Team",
      dependencies: %{"raxol-core" => "~> 1.5"},
      capabilities: [:shell_command, :ui_panel, :status_line, :file_watcher],
      config_schema: %{
        auto_refresh: %{type: :boolean, default: true},
        refresh_interval: %{type: :integer, default: 2000}
      }
    }
  end

  # Implementation with git commands, status monitoring, etc.
end

Testing Plugins

Basic Test Structure

defmodule MyPluginTest do
  use ExUnit.Case, async: true

  alias MyApp.Plugins.MyPlugin

  describe "plugin manifest" do
    test "returns valid manifest" do
      manifest = MyPlugin.manifest()

      assert is_binary(manifest.name)
      assert is_binary(manifest.version)
      assert is_list(manifest.capabilities)
    end
  end

  describe "plugin functionality" do
    setup do
      config = %{enabled: true, hotkey: "ctrl+p"}
      {:ok, pid} = MyPlugin.start_link(config)

      on_exit(fn -> GenServer.stop(pid) end)

      {:ok, plugin: pid, config: config}
    end

    test "handles basic operations", %{plugin: plugin} do
      result = GenServer.call(plugin, :some_operation)
      assert result == :expected_result
    end
  end
end

Integration Testing

defmodule MyPluginIntegrationTest do
  use ExUnit.Case

  import Raxol.Test.PluginTestHelpers

  test "plugin integrates with terminal" do
    # Set up test terminal
    {:ok, terminal} = start_test_terminal()

    # Load plugin
    {:ok, _plugin} = load_plugin(terminal, MyPlugin, %{})

    # Test plugin functionality
    send_keypress(terminal, "ctrl+p")

    # Assert expected behavior
    assert_panel_visible(terminal, "my-plugin")
  end
end

Plugin Distribution

Plugin Package Structure

my_plugin/
 mix.exs
 lib/
    my_plugin.ex
 test/
    my_plugin_test.exs
 README.md
 CHANGELOG.md
 plugin.json

Plugin Metadata (plugin.json)

{
  "name": "my-plugin",
  "version": "1.0.0",
  "description": "Plugin description",
  "author": "Author Name",
  "repository": "https://github.com/author/my-plugin",
  "license": "MIT",
  "raxol_version": "~> 1.5",
  "keywords": ["terminal", "productivity"],
  "screenshots": [
    "screenshots/main.png",
    "screenshots/config.png"
  ]
}

Publishing to Plugin Marketplace

# Package plugin
mix raxol.plugin.package

# Publish to marketplace
mix raxol.plugin.publish --token YOUR_API_TOKEN

Best Practices

1. Error Handling

def safe_operation(state) do
  try do
    result = dangerous_operation(state)
    {:ok, result}
  rescue
    error ->
      Logger.error("Plugin operation failed: #{inspect(error)}")
      {:error, error}
  end
end

2. Resource Management

@impl true
def init(config) do
  # Always clean up resources in terminate/2
  state = %__MODULE__{config: config, resources: []}
  {:ok, state}
end

@impl true
def terminate(_reason, state) do
  # Clean up all resources
  Enum.each(state.resources, &cleanup_resource/1)
  :ok
end

3. Configuration Validation

def validate_config(config) do
  required_fields = [:api_key, :endpoint]

  case check_required_fields(config, required_fields) do
    :ok -> {:ok, config}
    {:error, missing} -> {:error, "Missing required fields: #{Enum.join(missing, ", ")}"}
  end
end

4. User Experience

  • Provide clear error messages
  • Use consistent keyboard shortcuts
  • Implement loading states for slow operations
  • Support theming and customization
  • Include helpful documentation

5. Performance

  • Use GenServer for state management
  • Implement efficient rendering
  • Cache expensive operations
  • Use background tasks for heavy work
  • Minimize file system access

Debugging Plugins

Logging

require Logger

def debug_operation(state) do
  Logger.debug("Plugin operation started with state: #{inspect(state)}")

  result = perform_operation(state)

  Logger.debug("Plugin operation completed: #{inspect(result)}")

  result
end

Development Tools

# Start Raxol in development mode with plugin debugging
mix raxol.dev --debug-plugins

# Watch plugin files for changes
mix raxol.dev --watch lib/plugins/

# Plugin-specific logs
mix raxol.dev --log-level debug --filter "plugin:my-plugin"

FAQ

Q: Can plugins communicate with external APIs? A: Yes, plugins can make HTTP requests and communicate with external services. Use libraries like HTTPoison or Finch.

Q: How do I handle plugin configuration? A: Define a config_schema in your manifest and access user configuration through the config parameter in init/1.

Q: Can plugins modify terminal settings? A: Plugins can request capabilities like theme changes or keybinding modifications, but these require user permission.

Q: How do I distribute private plugins? A: You can load plugins directly from local paths or private git repositories using mix raxol.plugin.install.

Q: Are plugins sandboxed? A: Yes, plugins run in isolated processes and only have access to capabilities they declare in their manifest.

Getting Help