Bernardo Magri vor 1 Monat
Commit
62b497338b

+ 11 - 0
.gitignore

@@ -0,0 +1,11 @@
+# Editor backup files
+*~
+*.swp
+*.bak
+
+# macOS
+.DS_Store
+
+# Test/development files
+test/
+*.log

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 Lumacs Contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 109 - 0
README.md

@@ -0,0 +1,109 @@
+# Lumacs Packages
+
+Official package repository for [Lumacs](https://github.com/lumacs/lumacs) editor.
+
+## Available Packages
+
+| Package | Description |
+|---------|-------------|
+| **zen-mode** | Distraction-free writing mode with increased font size and word wrap |
+| **which-key** | Display available keybindings when prefix key is pressed |
+| **projectile** | Project management and navigation with file finding |
+| **company-mode** | Text completion framework with multiple backends |
+| **doom-modeline** | Doom Emacs-inspired modeline styling |
+| **smartparens** | Automatic pairing of brackets and quotes |
+| **ido** | Enhanced completion with fuzzy matching |
+| **rainbow-delimiters** | Colorize nested delimiters by depth |
+| **goto-line** | Go to specific line number (M-g g) |
+| **recentf** | Track and access recently opened files |
+| **bookmarks** | Save and jump between named positions |
+| **visual-line** | Soft word wrap mode |
+
+## Installation
+
+### Using Package Manager
+
+Add packages to your `~/.lumacs/packages.lua`:
+
+```lua
+return {
+    -- Install by name (from registry)
+    "zen-mode",
+    "which-key",
+
+    -- With configuration
+    { "doom-modeline",
+      config = function()
+        lumacs.doom_modeline.set_preset("nord")
+      end
+    },
+}
+```
+
+Then restart Lumacs or run `M-x package-install`.
+
+### Manual Installation
+
+Clone this repository and load packages directly:
+
+```lua
+return {
+    { dir = "~/path/to/lumacs-packages/zen-mode" },
+}
+```
+
+## Package Structure
+
+Each package directory contains:
+
+```
+package-name/
+├── package.lua   # Package metadata (required)
+├── init.lua      # Main entry point (required)
+└── lua/          # Additional modules (optional)
+```
+
+### package.lua
+
+```lua
+return {
+    name = "package-name",
+    version = "1.0.0",
+    description = "Short description",
+    author = "Your Name",
+    license = "MIT",
+    dependencies = {},  -- Other packages this depends on
+    keywords = {"tag1", "tag2"},
+}
+```
+
+### init.lua
+
+```lua
+local M = {}
+
+function M.setup(opts)
+    -- Configuration handling
+end
+
+-- Register commands
+editor:register_command("my-command", "Description", function()
+    -- Implementation
+end)
+
+-- Auto-setup
+M.setup()
+
+return M
+```
+
+## Contributing
+
+1. Fork this repository
+2. Create a new directory for your package
+3. Add `package.lua` and `init.lua`
+4. Submit a pull request
+
+## License
+
+MIT License - See individual packages for their specific licenses.

+ 231 - 0
bookmarks/init.lua

@@ -0,0 +1,231 @@
+-- bookmarks.lua
+-- Save and jump between named positions in files
+-- ============================================================================
+
+local bookmarks = {}
+
+-- Configuration
+bookmarks.config = {
+    enabled = true,
+    max_bookmarks = 100,
+}
+
+-- Bookmarks storage: name -> {filepath, line, column}
+bookmarks.marks = {}
+
+-- Quick marks (single character, like Vim marks)
+bookmarks.quick_marks = {}
+
+-- Set a named bookmark at current position
+function bookmarks.set(name)
+    if not name or name == "" then
+        return {success = false, message = "Bookmark name required"}
+    end
+
+    local buffer = editor:current_buffer()
+    local filepath = buffer.filepath or buffer.name
+    local pos = editor:cursor_pos()
+
+    bookmarks.marks[name] = {
+        filepath = filepath,
+        line = pos.line,
+        column = pos.column,
+        created = os.time(),
+    }
+
+    return {success = true, message = string.format("Bookmark '%s' set at %s:%d", name, filepath, pos.line + 1)}
+end
+
+-- Jump to a named bookmark
+function bookmarks.jump(name)
+    if not name or name == "" then
+        return {success = false, message = "Bookmark name required"}
+    end
+
+    local mark = bookmarks.marks[name]
+    if not mark then
+        return {success = false, message = "Bookmark not found: " .. name}
+    end
+
+    -- Check if we need to switch buffers
+    local buffer = editor:current_buffer()
+    local current_file = buffer.filepath or buffer.name
+
+    if current_file ~= mark.filepath then
+        -- Try to open the file
+        local result = editor:execute_command("find-file", {mark.filepath})
+        if not result.success then
+            return {success = false, message = "Cannot open file: " .. mark.filepath}
+        end
+    end
+
+    -- Move to the bookmark position
+    editor:set_cursor(mark.line, mark.column)
+
+    return {success = true, message = string.format("Jumped to '%s'", name)}
+end
+
+-- Delete a bookmark
+function bookmarks.delete(name)
+    if not name or name == "" then
+        return {success = false, message = "Bookmark name required"}
+    end
+
+    if not bookmarks.marks[name] then
+        return {success = false, message = "Bookmark not found: " .. name}
+    end
+
+    bookmarks.marks[name] = nil
+    return {success = true, message = "Deleted bookmark: " .. name}
+end
+
+-- List all bookmarks
+function bookmarks.list()
+    local result = {}
+    for name, mark in pairs(bookmarks.marks) do
+        local display_file = mark.filepath:gsub("^" .. (os.getenv("HOME") or ""), "~")
+        table.insert(result, {
+            name = name,
+            filepath = mark.filepath,
+            display = string.format("%s: %s:%d", name, display_file, mark.line + 1),
+        })
+    end
+    -- Sort by name
+    table.sort(result, function(a, b) return a.name < b.name end)
+    return result
+end
+
+-- Quick mark (single character, for quick navigation)
+function bookmarks.set_quick(char)
+    if not char or #char ~= 1 then
+        return {success = false, message = "Quick mark must be a single character"}
+    end
+
+    local buffer = editor:current_buffer()
+    local filepath = buffer.filepath or buffer.name
+    local pos = editor:cursor_pos()
+
+    bookmarks.quick_marks[char] = {
+        filepath = filepath,
+        line = pos.line,
+        column = pos.column,
+    }
+
+    return {success = true, message = string.format("Mark '%s' set", char)}
+end
+
+-- Jump to quick mark
+function bookmarks.jump_quick(char)
+    if not char or #char ~= 1 then
+        return {success = false, message = "Quick mark must be a single character"}
+    end
+
+    local mark = bookmarks.quick_marks[char]
+    if not mark then
+        return {success = false, message = "Mark not set: " .. char}
+    end
+
+    local buffer = editor:current_buffer()
+    local current_file = buffer.filepath or buffer.name
+
+    if current_file ~= mark.filepath then
+        local result = editor:execute_command("find-file", {mark.filepath})
+        if not result.success then
+            return {success = false, message = "Cannot open file: " .. mark.filepath}
+        end
+    end
+
+    editor:set_cursor(mark.line, mark.column)
+    return {success = true, message = string.format("Jumped to mark '%s'", char)}
+end
+
+-- Setup function
+function bookmarks.setup(opts)
+    opts = opts or {}
+    for k, v in pairs(opts) do
+        bookmarks.config[k] = v
+    end
+
+    if not bookmarks.config.enabled then
+        return
+    end
+
+    -- Register commands
+    editor:register_command("bookmark-set", "Set a named bookmark at current position", function(args)
+        if #args == 0 then
+            return {success = true, message = "Usage: bookmark-set <name>"}
+        end
+        return bookmarks.set(args[1])
+    end, {}, true, "s")
+
+    editor:register_command("bookmark-jump", "Jump to a named bookmark", function(args)
+        if #args == 0 then
+            -- List available bookmarks
+            local list = bookmarks.list()
+            if #list == 0 then
+                return {success = true, message = "No bookmarks set"}
+            end
+            local displays = {}
+            for _, b in ipairs(list) do
+                table.insert(displays, b.display)
+            end
+            editor:set_echo_area(displays)
+            return {success = true, message = ""}
+        end
+        return bookmarks.jump(args[1])
+    end, {}, true, "s")
+
+    editor:register_command("bookmark-delete", "Delete a bookmark", function(args)
+        if #args == 0 then
+            return {success = true, message = "Usage: bookmark-delete <name>"}
+        end
+        return bookmarks.delete(args[1])
+    end, {}, true, "s")
+
+    editor:register_command("bookmark-list", "List all bookmarks", function(args)
+        local list = bookmarks.list()
+        if #list == 0 then
+            return {success = true, message = "No bookmarks set"}
+        end
+        local displays = {}
+        for _, b in ipairs(list) do
+            table.insert(displays, b.display)
+        end
+        editor:set_echo_area(displays)
+        return {success = true, message = ""}
+    end)
+
+    -- Quick marks (Vim-style m<char> and '<char>)
+    editor:register_command("mark-set", "Set a quick mark (single character)", function(args)
+        if #args == 0 then
+            return {success = true, message = "Usage: mark-set <char>"}
+        end
+        return bookmarks.set_quick(args[1])
+    end, {}, true, "c")
+
+    editor:register_command("mark-jump", "Jump to a quick mark", function(args)
+        if #args == 0 then
+            return {success = true, message = "Usage: mark-jump <char>"}
+        end
+        return bookmarks.jump_quick(args[1])
+    end, {}, true, "c")
+
+    -- Key bindings
+    -- C-x r m - set bookmark (Emacs)
+    -- C-x r b - jump to bookmark (Emacs)
+    -- C-x r l - list bookmarks (Emacs)
+    editor:bind_key("C-x r m", "bookmark-set", "Set bookmark")
+    editor:bind_key("C-x r b", "bookmark-jump", "Jump to bookmark")
+    editor:bind_key("C-x r l", "bookmark-list", "List bookmarks")
+
+    -- Quick marks with F-keys for convenience
+    editor:bind_key("F2", "mark-set", "Set quick mark")
+    editor:bind_key("S-F2", "mark-jump", "Jump to quick mark")
+
+    print("[bookmarks] Package loaded")
+end
+
+-- Auto-setup with defaults
+bookmarks.setup()
+
+return bookmarks

+ 12 - 0
bookmarks/package.lua

@@ -0,0 +1,12 @@
+-- Package metadata for bookmarks
+return {
+    name = "bookmarks",
+    version = "1.0.0",
+    description = "Save and jump between named positions in files",
+    author = "Lumacs Contributors",
+    license = "MIT",
+    homepage = "https://github.com/lumacs/packages",
+    lumacs_version = ">=0.1.0",
+    dependencies = {},
+    keywords = {"navigation", "bookmarks", "marks", "positions"},
+}

+ 504 - 0
company-mode/init.lua

@@ -0,0 +1,504 @@
+-- company.lua
+-- ============================================================================
+-- Text completion framework for lumacs.
+-- Similar to Emacs company-mode.
+--
+-- Features:
+-- - Multiple completion backends (words, buffer, path, etc.)
+-- - Completion popup display in echo area
+-- - Configurable triggers and keybindings
+-- ============================================================================
+
+local company = {}
+
+-- Configuration
+company.config = {
+    enabled = true,
+    idle_delay_ms = 500,       -- Delay before showing completions
+    min_prefix_length = 2,    -- Minimum chars before completing
+    max_candidates = 10,       -- Max candidates to show
+    backends = {"buffer", "words"},  -- Active backends in order
+    auto_complete = false,     -- Auto-show completions while typing
+}
+
+-- State
+company.active = false
+company.candidates = {}
+company.selected_index = 1
+company.prefix = ""
+company.prefix_start_col = 0
+
+-- ============================================================================
+-- Backend implementations
+-- ============================================================================
+
+company.backends = {}
+
+-- Buffer backend: Complete from words in current buffer
+company.backends.buffer = {
+    name = "buffer",
+
+    get_candidates = function(prefix)
+        local candidates = {}
+        local seen = {}
+        local buf = editor.buffer
+        local line_count = buf:line_count()
+
+        prefix = prefix:lower()
+
+        for i = 0, line_count - 1 do
+            local line = buf:line(i)
+            -- Extract words (alphanumeric + underscore)
+            for word in line:gmatch("[%w_]+") do
+                local word_lower = word:lower()
+                if #word >= company.config.min_prefix_length and
+                   word_lower:sub(1, #prefix) == prefix and
+                   not seen[word_lower] then
+                    seen[word_lower] = true
+                    table.insert(candidates, {
+                        text = word,
+                        description = "buffer",
+                    })
+                end
+            end
+        end
+
+        return candidates
+    end
+}
+
+-- Words backend: Complete from a dictionary of common words
+company.backends.words = {
+    name = "words",
+
+    -- Common programming keywords
+    keywords = {
+        "function", "return", "if", "else", "elseif", "then", "end",
+        "for", "while", "do", "repeat", "until", "break", "continue",
+        "local", "true", "false", "nil", "and", "or", "not",
+        "class", "struct", "enum", "interface", "public", "private", "protected",
+        "static", "const", "final", "abstract", "virtual", "override",
+        "import", "export", "require", "module", "package",
+        "try", "catch", "throw", "finally", "except", "raise",
+        "async", "await", "yield", "defer",
+        "self", "this", "super", "new", "delete",
+        "int", "float", "double", "string", "bool", "void", "char",
+        "array", "list", "map", "set", "dict", "table", "vector",
+        "print", "println", "printf", "console", "log", "debug", "error",
+    },
+
+    get_candidates = function(prefix)
+        local candidates = {}
+        prefix = prefix:lower()
+
+        for _, word in ipairs(company.backends.words.keywords) do
+            if word:sub(1, #prefix) == prefix then
+                table.insert(candidates, {
+                    text = word,
+                    description = "keyword",
+                })
+            end
+        end
+
+        return candidates
+    end
+}
+
+-- Path backend: Complete file paths
+company.backends.path = {
+    name = "path",
+
+    get_candidates = function(prefix)
+        local candidates = {}
+
+        -- Only trigger on path-like prefixes
+        if not prefix:match("^[%.~/]") and not prefix:match("/") then
+            return candidates
+        end
+
+        -- Expand ~ to home directory
+        local path = prefix:gsub("^~", os.getenv("HOME") or "~")
+
+        -- Get directory and partial filename
+        local dir, partial
+        if path:match("/$") then
+            dir = path
+            partial = ""
+        else
+            dir = path:match("(.+)/") or "."
+            partial = path:match("[^/]+$") or ""
+        end
+
+        -- List directory contents
+        local cmd = string.format("ls -1 '%s' 2>/dev/null", dir)
+        local handle = io.popen(cmd)
+        if not handle then
+            return candidates
+        end
+
+        partial = partial:lower()
+
+        for entry in handle:lines() do
+            if entry:lower():sub(1, #partial) == partial then
+                local full_path
+                if prefix:match("^~") then
+                    full_path = "~" .. dir:sub(#(os.getenv("HOME") or "") + 1) .. "/" .. entry
+                else
+                    full_path = dir .. "/" .. entry
+                end
+
+                table.insert(candidates, {
+                    text = full_path,
+                    description = "path",
+                })
+            end
+        end
+
+        handle:close()
+        return candidates
+    end
+}
+
+-- ============================================================================
+-- Core completion logic
+-- ============================================================================
+
+-- Get the word prefix at cursor
+function company.get_prefix()
+    local buf = editor.buffer
+    local cursor = editor.cursor
+    local line = buf:line(cursor.line)
+
+    -- Find word start (alphanumeric + underscore, or path chars)
+    local col = cursor.column
+    local start = col
+
+    while start > 0 do
+        local c = line:sub(start, start)
+        if c:match("[%w_%.~/]") then
+            start = start - 1
+        else
+            break
+        end
+    end
+
+    local prefix = line:sub(start + 1, col)
+    return prefix, start
+end
+
+-- Gather candidates from all active backends
+function company.gather_candidates(prefix)
+    local all_candidates = {}
+
+    for _, backend_name in ipairs(company.config.backends) do
+        local backend = company.backends[backend_name]
+        if backend and backend.get_candidates then
+            local candidates = backend.get_candidates(prefix)
+            for _, c in ipairs(candidates) do
+                c.backend = backend_name
+                table.insert(all_candidates, c)
+            end
+        end
+    end
+
+    -- Sort by text
+    table.sort(all_candidates, function(a, b)
+        return a.text < b.text
+    end)
+
+    -- Remove duplicates (keep first occurrence)
+    local seen = {}
+    local unique = {}
+    for _, c in ipairs(all_candidates) do
+        if not seen[c.text] then
+            seen[c.text] = true
+            table.insert(unique, c)
+        end
+    end
+
+    return unique
+end
+
+-- Format candidates for echo area display
+function company.format_display()
+    local lines = {}
+    local start_idx = 1
+    local end_idx = math.min(#company.candidates, company.config.max_candidates)
+
+    -- Adjust window if selected is out of view
+    if company.selected_index > end_idx then
+        start_idx = company.selected_index - company.config.max_candidates + 1
+        end_idx = company.selected_index
+    end
+
+    for i = start_idx, end_idx do
+        local c = company.candidates[i]
+        local marker = (i == company.selected_index) and ">" or " "
+        local line = string.format("%s %s", marker, c.text)
+        if c.description then
+            line = line .. " (" .. c.description .. ")"
+        end
+        table.insert(lines, line)
+    end
+
+    if #company.candidates > company.config.max_candidates then
+        table.insert(lines, string.format("  [%d/%d]", company.selected_index, #company.candidates))
+    end
+
+    return lines
+end
+
+-- Show completion popup
+function company.show()
+    if not company.active or #company.candidates == 0 then
+        return
+    end
+
+    local lines = company.format_display()
+    editor:set_echo_area(lines)
+end
+
+-- Hide completion popup
+function company.hide()
+    if company.active then
+        company.active = false
+        company.candidates = {}
+        company.selected_index = 1
+        editor:clear_echo_area()
+    end
+end
+
+-- Start completion at point
+function company.complete()
+    if not company.config.enabled then
+        return
+    end
+
+    company.prefix, company.prefix_start_col = company.get_prefix()
+
+    if #company.prefix < company.config.min_prefix_length then
+        company.hide()
+        return
+    end
+
+    company.candidates = company.gather_candidates(company.prefix)
+
+    if #company.candidates == 0 then
+        company.hide()
+        return
+    end
+
+    company.active = true
+    company.selected_index = 1
+    company.show()
+end
+
+-- Select next candidate
+function company.select_next()
+    if not company.active or #company.candidates == 0 then
+        return
+    end
+
+    company.selected_index = (company.selected_index % #company.candidates) + 1
+    company.show()
+end
+
+-- Select previous candidate
+function company.select_previous()
+    if not company.active or #company.candidates == 0 then
+        return
+    end
+
+    company.selected_index = ((company.selected_index - 2) % #company.candidates) + 1
+    company.show()
+end
+
+-- Insert selected candidate
+function company.insert_selection()
+    if not company.active or #company.candidates == 0 then
+        return false
+    end
+
+    local candidate = company.candidates[company.selected_index]
+    if not candidate then
+        return false
+    end
+
+    -- Delete the prefix and insert the completion
+    local buf = editor.buffer
+    local cursor = editor.cursor
+    local delete_start = lumacs.Position(cursor.line, company.prefix_start_col)
+    local delete_end = cursor
+
+    if delete_start.column < delete_end.column then
+        buf:erase(lumacs.Range(delete_start, delete_end))
+    end
+
+    -- Insert at the start position
+    buf:insert(delete_start, candidate.text)
+
+    -- Move cursor to end of inserted text
+    editor.cursor = lumacs.Position(cursor.line, company.prefix_start_col + #candidate.text)
+
+    company.hide()
+    return true
+end
+
+-- Abort completion
+function company.abort()
+    company.hide()
+end
+
+-- ============================================================================
+-- Idle hook for auto-completion
+-- ============================================================================
+
+function company.check_idle()
+    if not company.config.enabled or not company.config.auto_complete then
+        return
+    end
+
+    -- Only auto-complete if we've been idle long enough
+    local idle_ms = editor:idle_time_ms()
+    if idle_ms < company.config.idle_delay_ms then
+        return
+    end
+
+    -- Don't auto-complete if minibuffer is active
+    if editor.minibuffer_manager and editor.minibuffer_manager:is_active() then
+        return
+    end
+
+    -- Don't auto-complete if already showing
+    if company.active then
+        return
+    end
+
+    -- Try to complete
+    company.complete()
+end
+
+-- ============================================================================
+-- Commands
+-- ============================================================================
+
+editor:register_command("company-complete", "Start completion at point", function(args)
+    company.complete()
+    return {success = true}
+end, {"complete"})
+
+editor:register_command("company-select-next", "Select next completion candidate", function(args)
+    company.select_next()
+    return {success = true}
+end)
+
+editor:register_command("company-select-previous", "Select previous completion candidate", function(args)
+    company.select_previous()
+    return {success = true}
+end)
+
+editor:register_command("company-complete-selection", "Insert selected completion", function(args)
+    if company.insert_selection() then
+        return {success = true, message = "Completion inserted"}
+    else
+        return {success = false, message = "No completion active"}
+    end
+end)
+
+editor:register_command("company-abort", "Cancel completion", function(args)
+    company.abort()
+    return {success = true}
+end)
+
+editor:register_command("company-mode", "Toggle company-mode", function(args)
+    company.config.enabled = not company.config.enabled
+    if company.config.enabled then
+        editor:message("company-mode enabled")
+    else
+        company.hide()
+        editor:message("company-mode disabled")
+    end
+    return {success = true}
+end)
+
+editor:register_command("company-auto-complete", "Toggle auto-completion", function(args)
+    company.config.auto_complete = not company.config.auto_complete
+    if company.config.auto_complete then
+        editor:message("Auto-completion enabled")
+    else
+        editor:message("Auto-completion disabled")
+    end
+    return {success = true}
+end)
+
+editor:register_command("company-backends", "List or set completion backends", function(args)
+    if #args == 0 then
+        return {success = true, message = "Backends: " .. table.concat(company.config.backends, ", ")}
+    end
+
+    -- Parse comma-separated list
+    local backends = {}
+    for backend in args[1]:gmatch("[^,]+") do
+        backend = backend:match("^%s*(.-)%s*$")  -- trim
+        if company.backends[backend] then
+            table.insert(backends, backend)
+        else
+            return {success = false, message = "Unknown backend: " .. backend}
+        end
+    end
+
+    company.config.backends = backends
+    return {success = true, message = "Backends set to: " .. table.concat(backends, ", ")}
+end, {}, true, "s")
+
+-- ============================================================================
+-- Keybindings
+-- ============================================================================
+
+editor:bind_key("M-Tab", "company-complete", "Complete at point")
+editor:bind_key("C-n", function()
+    if company.active then
+        company.select_next()
+    else
+        editor:move_down()
+    end
+end, "Next line or completion")
+
+editor:bind_key("C-p", function()
+    if company.active then
+        company.select_previous()
+    else
+        editor:move_up()
+    end
+end, "Previous line or completion")
+
+-- Note: These override default behavior when completion is active
+-- Tab to complete selection when active
+editor:bind_key("Tab", function()
+    if company.active then
+        company.insert_selection()
+    else
+        -- Default tab behavior (insert spaces)
+        editor.buffer:insert(editor.cursor, "    ")
+        for i = 1, 4 do editor:move_right() end
+    end
+end, "Tab or complete")
+
+-- Escape to abort
+editor:bind_key("Escape", function()
+    if company.active then
+        company.abort()
+    else
+        editor:execute_command("keyboard-quit", {})
+    end
+end, "Abort or quit")
+
+-- Register idle hook
+lumacs.company_check_idle = company.check_idle
+
+-- Store module
+lumacs.company = company
+
+print("[company-mode] Package loaded")
+
+return company

+ 12 - 0
company-mode/package.lua

@@ -0,0 +1,12 @@
+-- Package metadata for company-mode
+return {
+    name = "company-mode",
+    version = "1.0.0",
+    description = "Text completion framework with multiple backends",
+    author = "Lumacs Contributors",
+    license = "MIT",
+    homepage = "https://github.com/lumacs/packages",
+    lumacs_version = ">=0.1.0",
+    dependencies = {},
+    keywords = {"completion", "autocomplete", "intellisense"},
+}

+ 352 - 0
doom-modeline/init.lua

@@ -0,0 +1,352 @@
+-- doom-modeline.lua
+-- ============================================================================
+-- Doom Emacs-inspired modeline styling and configuration.
+-- Provides enhanced visual styling for the modeline.
+-- ============================================================================
+
+local doom_modeline = {}
+
+-- Configuration options
+doom_modeline.config = {
+    height = 1,                  -- Number of lines (currently fixed to 1)
+    bar_width = 4,               -- Width of the colored bar indicator
+    icon_type = "unicode",       -- "unicode" or "ascii" (unicode uses special chars)
+    show_major_mode_icon = true, -- Show icon for major mode
+    show_buffer_encoding = false, -- Show buffer encoding
+    show_word_count = false,     -- Show word count for text modes
+}
+
+-- Unicode icons for various states (fallback to ASCII if not supported)
+doom_modeline.icons = {
+    modified = "●",      -- Buffer modified
+    readonly = "",      -- Read-only
+    saved = "✓",         -- Just saved
+    lock = "🔒",         -- Locked
+
+    -- Major mode icons
+    lua = "☾",
+    python = "🐍",
+    c = "C",
+    cpp = "C++",
+    javascript = "JS",
+    typescript = "TS",
+    rust = "🦀",
+    go = "Go",
+    fundamental = "F",
+
+    -- Status indicators
+    error = "✗",
+    warning = "⚠",
+    info = "ℹ",
+
+    -- Git icons (for future use)
+    git_branch = "",
+    git_added = "+",
+    git_modified = "~",
+    git_removed = "-",
+}
+
+-- ASCII fallbacks
+doom_modeline.icons_ascii = {
+    modified = "*",
+    readonly = "%%",
+    saved = "-",
+    lock = "#",
+
+    lua = "Lua",
+    python = "Py",
+    c = "C",
+    cpp = "C++",
+    javascript = "JS",
+    typescript = "TS",
+    rust = "Rs",
+    go = "Go",
+    fundamental = "F",
+
+    error = "!",
+    warning = "?",
+    info = "i",
+
+    git_branch = "@",
+    git_added = "+",
+    git_modified = "~",
+    git_removed = "-",
+}
+
+-- Get icon based on configuration
+function doom_modeline.get_icon(name)
+    if doom_modeline.config.icon_type == "ascii" then
+        return doom_modeline.icons_ascii[name] or name
+    end
+    return doom_modeline.icons[name] or doom_modeline.icons_ascii[name] or name
+end
+
+-- Define doom-modeline faces
+function doom_modeline.setup_faces()
+    -- Main modeline face (active window)
+    lumacs.set_face("doom-modeline", {
+        foreground = "#bbc2cf",
+        background = "#21242b",
+    })
+
+    -- Inactive modeline
+    lumacs.set_face("doom-modeline-inactive", {
+        foreground = "#5B6268",
+        background = "#1c1f24",
+    })
+
+    -- Buffer name (emphasized)
+    lumacs.set_face("doom-modeline-buffer-file", {
+        foreground = "#51afef",
+        weight = "bold",
+    })
+
+    -- Modified indicator
+    lumacs.set_face("doom-modeline-buffer-modified", {
+        foreground = "#da8548",
+        weight = "bold",
+    })
+
+    -- Major mode
+    lumacs.set_face("doom-modeline-buffer-major-mode", {
+        foreground = "#51afef",
+        weight = "bold",
+    })
+
+    -- Minor modes
+    lumacs.set_face("doom-modeline-buffer-minor-mode", {
+        foreground = "#98be65",
+    })
+
+    -- Position info
+    lumacs.set_face("doom-modeline-buffer-position", {
+        foreground = "#bbc2cf",
+    })
+
+    -- Error indicator (for flycheck-like features)
+    lumacs.set_face("doom-modeline-error", {
+        foreground = "#ff6c6b",
+        weight = "bold",
+    })
+
+    -- Warning indicator
+    lumacs.set_face("doom-modeline-warning", {
+        foreground = "#ECBE7B",
+        weight = "bold",
+    })
+
+    -- Info indicator
+    lumacs.set_face("doom-modeline-info", {
+        foreground = "#98be65",
+    })
+
+    -- Project/workspace
+    lumacs.set_face("doom-modeline-project", {
+        foreground = "#c678dd",
+        weight = "bold",
+    })
+
+    -- Bar segment (the colored vertical bar)
+    lumacs.set_face("doom-modeline-bar", {
+        background = "#51afef",
+    })
+
+    lumacs.set_face("doom-modeline-bar-inactive", {
+        background = "#3f444a",
+    })
+
+    -- Evil/modal state faces (for future modal editing support)
+    lumacs.set_face("doom-modeline-evil-normal", {
+        foreground = "#21242b",
+        background = "#51afef",
+        weight = "bold",
+    })
+
+    lumacs.set_face("doom-modeline-evil-insert", {
+        foreground = "#21242b",
+        background = "#98be65",
+        weight = "bold",
+    })
+
+    lumacs.set_face("doom-modeline-evil-visual", {
+        foreground = "#21242b",
+        background = "#c678dd",
+        weight = "bold",
+    })
+
+    lumacs.set_face("doom-modeline-evil-emacs", {
+        foreground = "#21242b",
+        background = "#c678dd",
+        weight = "bold",
+    })
+end
+
+-- Apply doom-modeline styling to standard modeline faces
+function doom_modeline.apply_theme()
+    -- Apply doom styling to the standard mode-line faces
+    lumacs.set_face("mode-line", {
+        foreground = "#bbc2cf",
+        background = "#21242b",
+    })
+
+    lumacs.set_face("mode-line-inactive", {
+        foreground = "#5B6268",
+        background = "#1c1f24",
+    })
+
+    lumacs.set_face("mode-line-buffer-id", {
+        foreground = "#51afef",
+        weight = "bold",
+    })
+end
+
+-- Color presets that can be applied
+doom_modeline.presets = {
+    doom_one = {
+        bar = "#51afef",
+        bar_inactive = "#3f444a",
+        buffer = "#51afef",
+        modified = "#da8548",
+        mode = "#c678dd",
+        position = "#bbc2cf",
+        background = "#21242b",
+        foreground = "#bbc2cf",
+    },
+
+    nord = {
+        bar = "#88c0d0",
+        bar_inactive = "#3b4252",
+        buffer = "#88c0d0",
+        modified = "#ebcb8b",
+        mode = "#b48ead",
+        position = "#d8dee9",
+        background = "#2e3440",
+        foreground = "#d8dee9",
+    },
+
+    dracula = {
+        bar = "#bd93f9",
+        bar_inactive = "#44475a",
+        buffer = "#ff79c6",
+        modified = "#ffb86c",
+        mode = "#bd93f9",
+        position = "#f8f8f2",
+        background = "#282a36",
+        foreground = "#f8f8f2",
+    },
+
+    gruvbox = {
+        bar = "#83a598",
+        bar_inactive = "#3c3836",
+        buffer = "#83a598",
+        modified = "#fe8019",
+        mode = "#d3869b",
+        position = "#ebdbb2",
+        background = "#282828",
+        foreground = "#ebdbb2",
+    },
+
+    solarized = {
+        bar = "#268bd2",
+        bar_inactive = "#073642",
+        buffer = "#268bd2",
+        modified = "#cb4b16",
+        mode = "#6c71c4",
+        position = "#839496",
+        background = "#002b36",
+        foreground = "#839496",
+    },
+}
+
+-- Apply a preset color scheme
+function doom_modeline.set_preset(preset_name)
+    local preset = doom_modeline.presets[preset_name]
+    if not preset then
+        editor:message("Unknown preset: " .. preset_name, "warning")
+        return
+    end
+
+    lumacs.set_face("mode-line", {
+        foreground = preset.foreground,
+        background = preset.background,
+    })
+
+    lumacs.set_face("mode-line-inactive", {
+        foreground = preset.foreground,
+        background = preset.bar_inactive,
+    })
+
+    lumacs.set_face("mode-line-buffer-id", {
+        foreground = preset.buffer,
+        weight = "bold",
+    })
+
+    lumacs.set_face("doom-modeline-buffer-modified", {
+        foreground = preset.modified,
+        weight = "bold",
+    })
+
+    lumacs.set_face("doom-modeline-buffer-major-mode", {
+        foreground = preset.mode,
+        weight = "bold",
+    })
+
+    lumacs.set_face("doom-modeline-buffer-position", {
+        foreground = preset.position,
+    })
+
+    lumacs.set_face("doom-modeline-bar", {
+        background = preset.bar,
+    })
+
+    lumacs.set_face("doom-modeline-bar-inactive", {
+        background = preset.bar_inactive,
+    })
+
+    editor:message("Applied doom-modeline preset: " .. preset_name)
+end
+
+-- Register commands
+editor:register_command("doom-modeline-preset", "Set doom-modeline color preset", function(args)
+    if #args == 0 then
+        local presets = {}
+        for name, _ in pairs(doom_modeline.presets) do
+            table.insert(presets, name)
+        end
+        table.sort(presets)
+        return {success = false, message = "Available presets: " .. table.concat(presets, ", ")}
+    end
+
+    doom_modeline.set_preset(args[1])
+    return {success = true}
+end, {"dm-preset"}, true, "s")
+
+-- Setup command to apply all doom-modeline styling
+editor:register_command("doom-modeline-setup", "Enable doom-modeline styling", function(args)
+    doom_modeline.setup_faces()
+    doom_modeline.apply_theme()
+    editor:message("Doom modeline styling applied")
+    return {success = true}
+end, {"dm-setup"})
+
+-- Toggle ASCII/Unicode mode
+editor:register_command("doom-modeline-icons", "Toggle doom-modeline icon style", function(args)
+    if #args > 0 then
+        doom_modeline.config.icon_type = args[1]
+    else
+        if doom_modeline.config.icon_type == "unicode" then
+            doom_modeline.config.icon_type = "ascii"
+        else
+            doom_modeline.config.icon_type = "unicode"
+        end
+    end
+    editor:message("Icon type: " .. doom_modeline.config.icon_type)
+    return {success = true}
+end, {"dm-icons"}, true, "s")
+
+-- Store in lumacs namespace
+lumacs.doom_modeline = doom_modeline
+
+print("[doom-modeline] Package loaded")
+
+return doom_modeline

+ 12 - 0
doom-modeline/package.lua

@@ -0,0 +1,12 @@
+-- Package metadata for doom-modeline
+return {
+    name = "doom-modeline",
+    version = "1.0.0",
+    description = "Doom Emacs-inspired modeline styling with icons and color presets",
+    author = "Lumacs Contributors",
+    license = "MIT",
+    homepage = "https://github.com/lumacs/packages",
+    lumacs_version = ">=0.1.0",
+    dependencies = {},
+    keywords = {"modeline", "theme", "ui", "doom"},
+}

+ 78 - 0
goto-line/init.lua

@@ -0,0 +1,78 @@
+-- goto-line.lua
+-- Go to a specific line number (M-g g in Emacs)
+-- ============================================================================
+
+local goto_line = {}
+
+-- Configuration
+goto_line.config = {
+    enabled = true,
+}
+
+-- Go to a specific line number
+function goto_line.goto_line(line_num)
+    if not line_num then
+        return {success = false, message = "No line number specified"}
+    end
+
+    local num = tonumber(line_num)
+    if not num or num < 1 then
+        return {success = false, message = "Invalid line number: " .. tostring(line_num)}
+    end
+
+    -- Convert to 0-indexed for internal use
+    local target_line = num - 1
+    local line_count = editor:line_count()
+
+    if target_line >= line_count then
+        target_line = line_count - 1
+    end
+
+    -- Move cursor to the beginning of the target line
+    editor:set_cursor(target_line, 0)
+
+    return {success = true, message = "Line " .. num}
+end
+
+-- Interactive go-to-line that prompts for line number
+function goto_line.goto_line_interactive()
+    -- This will be called by M-g g, prompting via minibuffer
+    local pos = editor:cursor_pos()
+    local current_line = pos.line + 1  -- 1-indexed for display
+    return {success = true, message = "Goto line (current: " .. current_line .. "): "}
+end
+
+-- Setup function
+function goto_line.setup(opts)
+    opts = opts or {}
+    for k, v in pairs(opts) do
+        goto_line.config[k] = v
+    end
+
+    if not goto_line.config.enabled then
+        return
+    end
+
+    -- Register the go-to-line command
+    editor:register_command("goto-line", "Go to a specific line number", function(args)
+        if #args == 0 then
+            -- Show current line info
+            local pos = editor:cursor_pos()
+            local current_line = pos.line + 1
+            local line_count = editor:line_count()
+            return {success = true, message = string.format("Line %d of %d", current_line, line_count)}
+        end
+        return goto_line.goto_line(args[1])
+    end, {}, true, "n")  -- "n" for number argument
+
+    -- M-g g binding (Emacs standard)
+    editor:bind_key("M-g g", "goto-line", "Go to line number")
+    editor:bind_key("M-g M-g", "goto-line", "Go to line number")
+
+    print("[goto-line] Package loaded")
+end
+
+-- Auto-setup with defaults
+goto_line.setup()
+
+return goto_line

+ 12 - 0
goto-line/package.lua

@@ -0,0 +1,12 @@
+-- Package metadata for goto-line
+return {
+    name = "goto-line",
+    version = "1.0.0",
+    description = "Go to a specific line number (M-g g)",
+    author = "Lumacs Contributors",
+    license = "MIT",
+    homepage = "https://github.com/lumacs/packages",
+    lumacs_version = ">=0.1.0",
+    dependencies = {},
+    keywords = {"navigation", "line", "goto"},
+}

+ 256 - 0
ido/init.lua

@@ -0,0 +1,256 @@
+-- ido.lua
+-- ============================================================================
+-- Interactive Do - Enhanced completion with fuzzy matching and inline display.
+-- Similar to Emacs ido-mode.
+-- ============================================================================
+
+local ido = {}
+
+-- Configuration
+ido.config = {
+    enabled = true,
+    separator = " | ",
+    max_candidates = 10,
+    show_count = true,
+    fuzzy = true,
+    case_sensitive = false,
+    -- Display format: "inline" shows in minibuffer, "vertical" would need popup
+    display = "inline",
+}
+
+-- State
+ido.active = false
+ido.candidates = {}
+ido.filtered = {}
+ido.selected_index = 1
+ido.input = ""
+
+-- Fuzzy match score function
+-- Returns score (higher is better) or nil for no match
+function ido.fuzzy_match(pattern, candidate)
+    if pattern == "" then
+        return 1000  -- Empty pattern matches everything
+    end
+
+    local p = ido.config.case_sensitive and pattern or pattern:lower()
+    local c = ido.config.case_sensitive and candidate or candidate:lower()
+
+    -- Exact match is best
+    if c == p then
+        return 10000
+    end
+
+    -- Prefix match is very good
+    if c:sub(1, #p) == p then
+        return 5000 + (1000 / #c)
+    end
+
+    -- Substring match
+    local substr_pos = c:find(p, 1, true)
+    if substr_pos then
+        return 3000 - substr_pos + (1000 / #c)
+    end
+
+    -- Fuzzy match: all chars in pattern appear in order in candidate
+    if ido.config.fuzzy then
+        local score = 0
+        local j = 1
+        local consecutive = 0
+        local last_match = -1
+
+        for i = 1, #p do
+            local char = p:sub(i, i)
+            local found = false
+
+            while j <= #c do
+                if c:sub(j, j) == char then
+                    found = true
+                    -- Bonus for consecutive matches
+                    if j == last_match + 1 then
+                        consecutive = consecutive + 1
+                        score = score + consecutive * 10
+                    else
+                        consecutive = 0
+                    end
+                    -- Bonus for matching at word boundaries
+                    if j == 1 or c:sub(j-1, j-1):match("[%s_%-/\\.]") then
+                        score = score + 50
+                    end
+                    last_match = j
+                    j = j + 1
+                    break
+                end
+                j = j + 1
+            end
+
+            if not found then
+                return nil  -- No match
+            end
+        end
+
+        -- Penalty for length difference
+        score = score + 1000 - (#c - #p) * 5
+        return math.max(1, score)
+    end
+
+    return nil
+end
+
+-- Filter and sort candidates based on input
+function ido.filter_candidates(input)
+    local results = {}
+
+    for _, candidate in ipairs(ido.candidates) do
+        local text = type(candidate) == "table" and candidate.text or candidate
+        local score = ido.fuzzy_match(input, text)
+        if score then
+            table.insert(results, {
+                candidate = candidate,
+                text = text,
+                score = score
+            })
+        end
+    end
+
+    -- Sort by score (descending)
+    table.sort(results, function(a, b)
+        return a.score > b.score
+    end)
+
+    -- Extract candidates
+    ido.filtered = {}
+    for i, r in ipairs(results) do
+        if i > ido.config.max_candidates * 2 then break end
+        table.insert(ido.filtered, r.candidate)
+    end
+
+    return ido.filtered
+end
+
+-- Format candidates for display in minibuffer
+function ido.format_display(input)
+    local parts = {}
+
+    -- Show input
+    if input ~= "" then
+        table.insert(parts, input)
+    end
+
+    -- Show candidates
+    local shown = 0
+    for i, candidate in ipairs(ido.filtered) do
+        if shown >= ido.config.max_candidates then
+            if #ido.filtered > ido.config.max_candidates then
+                table.insert(parts, "...")
+            end
+            break
+        end
+
+        local text = type(candidate) == "table" and candidate.text or candidate
+        if i == ido.selected_index then
+            text = "[" .. text .. "]"
+        end
+        table.insert(parts, text)
+        shown = shown + 1
+    end
+
+    -- Add count if enabled
+    if ido.config.show_count and #ido.filtered > ido.config.max_candidates then
+        table.insert(parts, "(" .. #ido.filtered .. " total)")
+    end
+
+    return table.concat(parts, ido.config.separator)
+end
+
+-- Select next candidate
+function ido.next()
+    if #ido.filtered > 0 then
+        ido.selected_index = (ido.selected_index % #ido.filtered) + 1
+    end
+end
+
+-- Select previous candidate
+function ido.prev()
+    if #ido.filtered > 0 then
+        ido.selected_index = ((ido.selected_index - 2) % #ido.filtered) + 1
+    end
+end
+
+-- Get selected candidate
+function ido.get_selected()
+    if ido.selected_index <= #ido.filtered then
+        return ido.filtered[ido.selected_index]
+    end
+    return nil
+end
+
+-- Complete buffer names with ido
+function ido.complete_buffer()
+    local buf_names = editor:get_buffer_names()
+    ido.candidates = buf_names
+    ido.filtered = ido.filter_candidates("")
+    ido.selected_index = 1
+
+    local display = ido.format_display("")
+    editor:message("Switch to buffer: " .. display)
+end
+
+-- Complete commands with ido
+function ido.complete_command()
+    local cmd_names = get_command_names()
+    ido.candidates = cmd_names
+    ido.filtered = ido.filter_candidates("")
+    ido.selected_index = 1
+
+    local display = ido.format_display("")
+    editor:message("M-x " .. display)
+end
+
+-- Register commands
+editor:register_command("ido-switch-buffer", "Switch buffer with ido completion", function(args)
+    ido.complete_buffer()
+    return {success = true}
+end)
+
+editor:register_command("ido-execute-command", "Execute command with ido completion", function(args)
+    ido.complete_command()
+    return {success = true}
+end)
+
+editor:register_command("ido-mode", "Toggle ido-mode for enhanced completion", function(args)
+    ido.config.enabled = not ido.config.enabled
+    if ido.config.enabled then
+        editor:message("ido-mode enabled")
+    else
+        editor:message("ido-mode disabled")
+    end
+    return {success = true}
+end)
+
+editor:register_command("ido-toggle-fuzzy", "Toggle fuzzy matching in ido", function(args)
+    ido.config.fuzzy = not ido.config.fuzzy
+    editor:message("Fuzzy matching: " .. (ido.config.fuzzy and "on" or "off"))
+    return {success = true}
+end)
+
+-- Utility: simple buffer selection demo
+editor:register_command("ido-demo", "Demonstrate ido buffer selection", function(args)
+    local buf_names = editor:get_buffer_names()
+    ido.candidates = buf_names
+    local results = ido.filter_candidates(args[1] or "")
+    ido.selected_index = 1
+
+    if #results > 0 then
+        local display = ido.format_display(args[1] or "")
+        return {success = true, message = display}
+    else
+        return {success = false, message = "No matching buffers"}
+    end
+end, {}, true, "s")
+
+-- Store in lumacs namespace
+lumacs.ido = ido
+
+print("[ido] Package loaded")
+
+return ido

+ 12 - 0
ido/package.lua

@@ -0,0 +1,12 @@
+-- Package metadata for ido
+return {
+    name = "ido",
+    version = "1.0.0",
+    description = "Interactive Do - Enhanced completion with fuzzy matching",
+    author = "Lumacs Contributors",
+    license = "MIT",
+    homepage = "https://github.com/lumacs/packages",
+    lumacs_version = ">=0.1.0",
+    dependencies = {},
+    keywords = {"completion", "fuzzy", "matching", "interactive"},
+}

+ 416 - 0
projectile/init.lua

@@ -0,0 +1,416 @@
+-- projectile.lua
+-- ============================================================================
+-- Project management and navigation package.
+-- Similar to Emacs projectile package.
+--
+-- Features:
+-- - Project root detection (via .git, .projectile, etc.)
+-- - Project-scoped file finding
+-- - Recent projects list
+-- - Project-local commands
+-- ============================================================================
+
+local projectile = {}
+
+-- Configuration
+projectile.config = {
+    -- Markers that indicate a project root
+    project_markers = {
+        ".git",
+        ".projectile",
+        ".project",
+        "Makefile",
+        "CMakeLists.txt",
+        "package.json",
+        "Cargo.toml",
+        "go.mod",
+        "pyproject.toml",
+        "setup.py",
+        ".hg",
+        ".svn",
+    },
+
+    -- File patterns to ignore when listing project files
+    ignore_patterns = {
+        "^%.git/",
+        "^%.hg/",
+        "^%.svn/",
+        "^node_modules/",
+        "^build/",
+        "^dist/",
+        "^target/",
+        "^%.cache/",
+        "^__pycache__/",
+        "%.pyc$",
+        "%.o$",
+        "%.a$",
+        "%.so$",
+        "%.dylib$",
+    },
+
+    -- Max files to scan in a project
+    max_files = 5000,
+
+    -- Max depth to search for project root
+    max_depth = 10,
+
+    -- Cache project file lists
+    cache_enabled = true,
+}
+
+-- State
+projectile.current_project = nil
+projectile.recent_projects = {}
+projectile.project_cache = {}  -- project_root -> {files, timestamp}
+
+-- Helper: Check if a path exists
+local function path_exists(path)
+    local f = io.open(path, "r")
+    if f then
+        f:close()
+        return true
+    end
+    return false
+end
+
+-- Helper: Check if path is a directory (heuristic)
+local function is_directory(path)
+    -- Try to open as directory by appending /. and checking
+    local f = io.open(path .. "/.", "r")
+    if f then
+        f:close()
+        return true
+    end
+    return false
+end
+
+-- Helper: Get parent directory
+local function parent_dir(path)
+    return path:match("(.+)/[^/]+$") or "/"
+end
+
+-- Helper: Get basename
+local function basename(path)
+    return path:match("[^/]+$") or path
+end
+
+-- Helper: Join paths
+local function join_path(...)
+    local parts = {...}
+    local result = table.concat(parts, "/")
+    -- Normalize double slashes
+    result = result:gsub("//+", "/")
+    return result
+end
+
+-- Find project root from a given path
+function projectile.find_project_root(start_path)
+    local path = start_path or os.getenv("PWD") or "."
+    local depth = 0
+
+    while path and path ~= "/" and depth < projectile.config.max_depth do
+        for _, marker in ipairs(projectile.config.project_markers) do
+            local marker_path = join_path(path, marker)
+            if path_exists(marker_path) or is_directory(marker_path) then
+                return path
+            end
+        end
+
+        path = parent_dir(path)
+        depth = depth + 1
+    end
+
+    return nil
+end
+
+-- Get current project root (from current buffer's file path)
+function projectile.get_current_project()
+    local buf = editor.buffer
+    local filepath = buf:filepath()
+
+    if filepath and filepath ~= "" then
+        local dir = parent_dir(filepath)
+        return projectile.find_project_root(dir)
+    end
+
+    -- Fallback to working directory
+    return projectile.find_project_root()
+end
+
+-- Check if a file matches ignore patterns
+local function should_ignore(relative_path)
+    for _, pattern in ipairs(projectile.config.ignore_patterns) do
+        if relative_path:match(pattern) then
+            return true
+        end
+    end
+    return false
+end
+
+-- List files in project using shell command
+function projectile.list_project_files(project_root)
+    if not project_root then
+        return {}
+    end
+
+    -- Check cache
+    if projectile.config.cache_enabled then
+        local cached = projectile.project_cache[project_root]
+        if cached then
+            -- Use cache if less than 30 seconds old
+            local age = os.time() - cached.timestamp
+            if age < 30 then
+                return cached.files
+            end
+        end
+    end
+
+    -- Use find command to list files
+    local cmd = string.format(
+        "find '%s' -type f -not -path '*/.git/*' -not -path '*/node_modules/*' -not -path '*/build/*' 2>/dev/null | head -%d",
+        project_root,
+        projectile.config.max_files
+    )
+
+    local handle = io.popen(cmd)
+    if not handle then
+        return {}
+    end
+
+    local files = {}
+    local prefix_len = #project_root + 2  -- +2 for trailing / and 1-based index
+
+    for line in handle:lines() do
+        local relative = line:sub(prefix_len)
+        if relative and relative ~= "" and not should_ignore(relative) then
+            table.insert(files, relative)
+        end
+    end
+
+    handle:close()
+
+    -- Sort files
+    table.sort(files)
+
+    -- Cache results
+    if projectile.config.cache_enabled then
+        projectile.project_cache[project_root] = {
+            files = files,
+            timestamp = os.time()
+        }
+    end
+
+    return files
+end
+
+-- Add project to recent list
+function projectile.add_recent_project(project_root)
+    if not project_root then return end
+
+    -- Remove if already in list
+    for i, p in ipairs(projectile.recent_projects) do
+        if p == project_root then
+            table.remove(projectile.recent_projects, i)
+            break
+        end
+    end
+
+    -- Add to front
+    table.insert(projectile.recent_projects, 1, project_root)
+
+    -- Keep only last 20
+    while #projectile.recent_projects > 20 do
+        table.remove(projectile.recent_projects)
+    end
+end
+
+-- Switch to project
+function projectile.switch_project(project_root)
+    projectile.current_project = project_root
+    projectile.add_recent_project(project_root)
+    editor:message("Switched to project: " .. project_root)
+end
+
+-- Find file in project
+function projectile.find_file_in_project(project_root, pattern)
+    local files = projectile.list_project_files(project_root)
+    local matches = {}
+
+    pattern = pattern:lower()
+
+    for _, file in ipairs(files) do
+        if file:lower():find(pattern, 1, true) then
+            table.insert(matches, file)
+        end
+    end
+
+    return matches
+end
+
+-- Clear project cache
+function projectile.invalidate_cache(project_root)
+    if project_root then
+        projectile.project_cache[project_root] = nil
+    else
+        projectile.project_cache = {}
+    end
+end
+
+-- Get project name from root
+function projectile.get_project_name(project_root)
+    return basename(project_root)
+end
+
+-- ============================================================================
+-- Commands
+-- ============================================================================
+
+-- Find file in current project
+editor:register_command("projectile-find-file", "Find file in current project", function(args)
+    local project_root = projectile.get_current_project()
+    if not project_root then
+        return {success = false, message = "Not in a project"}
+    end
+
+    projectile.switch_project(project_root)
+
+    local files = projectile.list_project_files(project_root)
+    if #files == 0 then
+        return {success = false, message = "No files found in project"}
+    end
+
+    -- If pattern provided, filter immediately
+    if #args > 0 then
+        local matches = projectile.find_file_in_project(project_root, args[1])
+        if #matches == 0 then
+            return {success = false, message = "No matches for: " .. args[1]}
+        elseif #matches == 1 then
+            local filepath = join_path(project_root, matches[1])
+            editor:load_file(filepath)
+            return {success = true, message = "Opened: " .. matches[1]}
+        else
+            -- Show matches
+            local display = {}
+            for i, m in ipairs(matches) do
+                if i > 10 then
+                    table.insert(display, "... and " .. (#matches - 10) .. " more")
+                    break
+                end
+                table.insert(display, m)
+            end
+            return {success = true, message = "Matches:\n" .. table.concat(display, "\n")}
+        end
+    end
+
+    -- Show file count
+    return {success = true, message = "Project: " .. projectile.get_project_name(project_root) .. " (" .. #files .. " files)"}
+end, {"pf"}, true, "s")
+
+-- Switch to known project
+editor:register_command("projectile-switch-project", "Switch to a known project", function(args)
+    if #projectile.recent_projects == 0 then
+        return {success = false, message = "No recent projects"}
+    end
+
+    if #args > 0 then
+        -- Find project matching pattern
+        local pattern = args[1]:lower()
+        for _, project in ipairs(projectile.recent_projects) do
+            if project:lower():find(pattern, 1, true) then
+                projectile.switch_project(project)
+                return {success = true, message = "Switched to: " .. projectile.get_project_name(project)}
+            end
+        end
+        return {success = false, message = "No project matching: " .. args[1]}
+    end
+
+    -- Show recent projects
+    local display = {"Recent projects:"}
+    for i, project in ipairs(projectile.recent_projects) do
+        table.insert(display, i .. ". " .. projectile.get_project_name(project))
+    end
+    return {success = true, message = table.concat(display, "\n")}
+end, {"pp"}, true, "s")
+
+-- Show current project info
+editor:register_command("projectile-project-info", "Show current project information", function(args)
+    local project_root = projectile.get_current_project()
+    if not project_root then
+        return {success = false, message = "Not in a project"}
+    end
+
+    local files = projectile.list_project_files(project_root)
+    local info = {
+        "Project: " .. projectile.get_project_name(project_root),
+        "Root: " .. project_root,
+        "Files: " .. #files,
+    }
+
+    return {success = true, message = table.concat(info, "\n")}
+end, {"pi"})
+
+-- Invalidate project cache
+editor:register_command("projectile-invalidate-cache", "Clear project file cache", function(args)
+    local project_root = projectile.get_current_project()
+    projectile.invalidate_cache(project_root)
+    return {success = true, message = "Project cache cleared"}
+end, {"pic"})
+
+-- Add current directory as project
+editor:register_command("projectile-add-known-project", "Add current directory as a known project", function(args)
+    local project_root
+    if #args > 0 then
+        project_root = args[1]
+    else
+        project_root = projectile.get_current_project() or os.getenv("PWD")
+    end
+
+    if project_root then
+        projectile.add_recent_project(project_root)
+        return {success = true, message = "Added project: " .. project_root}
+    else
+        return {success = false, message = "Could not determine project root"}
+    end
+end, {"pak"}, true, "s")
+
+-- Run command in project root
+editor:register_command("projectile-run-command", "Run shell command in project root", function(args)
+    local project_root = projectile.get_current_project()
+    if not project_root then
+        return {success = false, message = "Not in a project"}
+    end
+
+    if #args == 0 then
+        return {success = false, message = "Usage: projectile-run-command <command>"}
+    end
+
+    local cmd = table.concat(args, " ")
+    local full_cmd = string.format("cd '%s' && %s 2>&1", project_root, cmd)
+
+    local handle = io.popen(full_cmd)
+    if not handle then
+        return {success = false, message = "Failed to run command"}
+    end
+
+    local output = handle:read("*a")
+    handle:close()
+
+    -- Truncate long output
+    if #output > 500 then
+        output = output:sub(1, 500) .. "\n... (truncated)"
+    end
+
+    return {success = true, message = output}
+end, {"prc"}, true, "s")
+
+-- Keybindings
+editor:bind_key("C-c p f", "projectile-find-file", "Find file in project")
+editor:bind_key("C-c p p", "projectile-switch-project", "Switch project")
+editor:bind_key("C-c p i", "projectile-project-info", "Project info")
+
+-- Store module
+lumacs.projectile = projectile
+
+print("[projectile] Package loaded")
+
+return projectile

+ 12 - 0
projectile/package.lua

@@ -0,0 +1,12 @@
+-- Package metadata for projectile
+return {
+    name = "projectile",
+    version = "1.0.0",
+    description = "Project management and navigation with file finding and recent projects",
+    author = "Lumacs Contributors",
+    license = "MIT",
+    homepage = "https://github.com/lumacs/packages",
+    lumacs_version = ">=0.1.0",
+    dependencies = {},
+    keywords = {"project", "navigation", "files", "workspace"},
+}

+ 250 - 0
rainbow-delimiters/init.lua

@@ -0,0 +1,250 @@
+-- rainbow-delimiters.lua
+-- ============================================================================
+-- Colorize nested delimiters (parentheses, brackets, braces) with different
+-- colors based on nesting depth. Similar to Emacs rainbow-delimiters.
+-- ============================================================================
+
+local rainbow = {}
+
+-- Configuration
+rainbow.config = {
+    enabled = true,
+    max_depth = 9,  -- Number of colors to cycle through
+    delimiters = {
+        ["("] = ")",
+        ["["] = "]",
+        ["{"] = "}",
+    },
+}
+
+-- Default rainbow colors (can be customized per theme)
+rainbow.colors = {
+    "#ff6b6b",  -- Red
+    "#ffa94d",  -- Orange
+    "#ffd43b",  -- Yellow
+    "#69db7c",  -- Green
+    "#38d9a9",  -- Teal
+    "#4dabf7",  -- Blue
+    "#748ffc",  -- Indigo
+    "#da77f2",  -- Violet
+    "#f783ac",  -- Pink
+}
+
+-- Alternative color schemes
+rainbow.presets = {
+    default = {
+        "#ff6b6b", "#ffa94d", "#ffd43b", "#69db7c",
+        "#38d9a9", "#4dabf7", "#748ffc", "#da77f2", "#f783ac",
+    },
+    pastel = {
+        "#ffb3ba", "#ffdfba", "#ffffba", "#baffc9",
+        "#bae1ff", "#d4baff", "#ffbae1", "#baffff", "#e1ffba",
+    },
+    neon = {
+        "#ff0080", "#ff8000", "#ffff00", "#00ff00",
+        "#00ffff", "#0080ff", "#8000ff", "#ff00ff", "#ff0040",
+    },
+    monochrome = {
+        "#ffffff", "#e0e0e0", "#c0c0c0", "#a0a0a0",
+        "#808080", "#606060", "#404040", "#303030", "#202020",
+    },
+    nord = {
+        "#bf616a", "#d08770", "#ebcb8b", "#a3be8c",
+        "#88c0d0", "#81a1c1", "#5e81ac", "#b48ead", "#bf616a",
+    },
+    dracula = {
+        "#ff5555", "#ffb86c", "#f1fa8c", "#50fa7b",
+        "#8be9fd", "#bd93f9", "#ff79c6", "#6272a4", "#ff5555",
+    },
+}
+
+-- State
+rainbow.active = true
+
+-- Setup faces for each depth level
+function rainbow.setup_faces()
+    for i, color in ipairs(rainbow.colors) do
+        local face_name = "rainbow-delimiters-depth-" .. i
+        -- Use lumacs.face helper which handles hex conversion
+        local attrs = lumacs.face({
+            foreground = color,
+            weight = "bold",
+        })
+        if editor.theme_manager then
+            local theme = editor.theme_manager:active_theme()
+            if theme then
+                theme:set_face(face_name, attrs)
+            end
+        end
+    end
+end
+
+-- Get face name for a given depth
+function rainbow.get_face(depth)
+    local idx = ((depth - 1) % #rainbow.colors) + 1
+    return "rainbow-delimiters-depth-" .. idx
+end
+
+-- Check if char is an opener
+function rainbow.is_opener(char)
+    return rainbow.config.delimiters[char] ~= nil
+end
+
+-- Check if char is a closer
+function rainbow.is_closer(char)
+    for opener, closer in pairs(rainbow.config.delimiters) do
+        if closer == char then
+            return true, opener
+        end
+    end
+    return false, nil
+end
+
+-- Highlight delimiters in the current buffer
+function rainbow.highlight_buffer()
+    if not rainbow.active or not rainbow.config.enabled then
+        return
+    end
+
+    local buf = editor.buffer
+    local line_count = buf:line_count()
+    local depth = 0
+    local delimiter_positions = {}
+
+    -- First pass: find all delimiters and calculate their depths
+    for line_num = 0, line_count - 1 do
+        local line = buf:line(line_num)
+        local in_string = false
+        local string_char = nil
+        local escaped = false
+
+        for col = 0, #line - 1 do
+            local char = line:sub(col + 1, col + 1)
+
+            -- Handle escape sequences
+            if escaped then
+                escaped = false
+            elseif char == "\\" then
+                escaped = true
+            -- Handle strings
+            elseif not in_string and (char == '"' or char == "'") then
+                in_string = true
+                string_char = char
+            elseif in_string and char == string_char then
+                in_string = false
+                string_char = nil
+            -- Handle delimiters (outside strings)
+            elseif not in_string then
+                if rainbow.is_opener(char) then
+                    depth = depth + 1
+                    table.insert(delimiter_positions, {
+                        line = line_num,
+                        col = col,
+                        depth = depth,
+                        char = char,
+                    })
+                elseif rainbow.is_closer(char) then
+                    table.insert(delimiter_positions, {
+                        line = line_num,
+                        col = col,
+                        depth = depth,
+                        char = char,
+                    })
+                    depth = math.max(0, depth - 1)
+                end
+            end
+        end
+    end
+
+    -- Second pass: apply styles
+    for _, pos in ipairs(delimiter_positions) do
+        local face = rainbow.get_face(pos.depth)
+        local range = lumacs.Range(
+            lumacs.Position(pos.line, pos.col),
+            lumacs.Position(pos.line, pos.col + 1)
+        )
+        local attr = lumacs.TextAttribute(face)
+        buf:set_style(range, attr)
+    end
+end
+
+-- Toggle rainbow delimiters
+function rainbow.toggle()
+    rainbow.active = not rainbow.active
+    if rainbow.active then
+        rainbow.highlight_buffer()
+        editor:message("Rainbow delimiters enabled")
+    else
+        -- Clear rainbow styles
+        editor.buffer:clear_styles()
+        editor:message("Rainbow delimiters disabled")
+    end
+end
+
+-- Set color preset
+function rainbow.set_preset(name)
+    local preset = rainbow.presets[name]
+    if not preset then
+        editor:message("Unknown preset: " .. name, "warning")
+        return
+    end
+
+    rainbow.colors = preset
+    rainbow.setup_faces()
+    if rainbow.active then
+        rainbow.highlight_buffer()
+    end
+    editor:message("Rainbow preset: " .. name)
+end
+
+-- Register commands
+editor:register_command("rainbow-delimiters-mode", "Toggle rainbow delimiter coloring", function(args)
+    rainbow.toggle()
+    return {success = true}
+end, {"rainbow"})
+
+editor:register_command("rainbow-preset", "Set rainbow delimiter color preset", function(args)
+    if #args == 0 then
+        local presets = {}
+        for name, _ in pairs(rainbow.presets) do
+            table.insert(presets, name)
+        end
+        table.sort(presets)
+        return {success = false, message = "Available presets: " .. table.concat(presets, ", ")}
+    end
+
+    rainbow.set_preset(args[1])
+    return {success = true}
+end, {}, true, "s")
+
+editor:register_command("rainbow-highlight", "Re-apply rainbow highlighting to current buffer", function(args)
+    rainbow.highlight_buffer()
+    return {success = true, message = "Rainbow highlighting applied"}
+end)
+
+-- Define minor mode
+lumacs.define_minor_mode("rainbow-delimiters-mode", {
+    lighter = "Rainbow",
+    global = false,
+    setup = function()
+        rainbow.active = true
+        rainbow.setup_faces()
+        rainbow.highlight_buffer()
+        editor:message("Rainbow delimiters enabled")
+    end,
+    cleanup = function()
+        rainbow.active = false
+        editor.buffer:clear_styles()
+        editor:message("Rainbow delimiters disabled")
+    end
+})
+
+-- Initialize faces
+rainbow.setup_faces()
+
+-- Store in lumacs namespace
+lumacs.rainbow_delimiters = rainbow
+
+print("[rainbow-delimiters] Package loaded")
+
+return rainbow

+ 12 - 0
rainbow-delimiters/package.lua

@@ -0,0 +1,12 @@
+-- Package metadata for rainbow-delimiters
+return {
+    name = "rainbow-delimiters",
+    version = "1.0.0",
+    description = "Colorize nested delimiters with different colors based on depth",
+    author = "Lumacs Contributors",
+    license = "MIT",
+    homepage = "https://github.com/lumacs/packages",
+    lumacs_version = ">=0.1.0",
+    dependencies = {},
+    keywords = {"syntax", "highlighting", "colors", "brackets"},
+}

+ 156 - 0
recentf/init.lua

@@ -0,0 +1,156 @@
+-- recentf.lua
+-- Track and access recently opened files (like Emacs recentf-mode)
+-- ============================================================================
+
+local recentf = {}
+
+-- Configuration
+recentf.config = {
+    enabled = true,
+    max_saved = 20,      -- Maximum number of recent files to track
+    exclude_patterns = {  -- Patterns to exclude from recent files
+        "^/tmp/",
+        "%.git/",
+        "COMMIT_EDITMSG$",
+    },
+}
+
+-- Recent files list (most recent first)
+recentf.list = {}
+
+-- Check if a file should be excluded
+local function should_exclude(filepath)
+    if not filepath or filepath == "" then
+        return true
+    end
+    -- Exclude *scratch* and other special buffers
+    if filepath:match("^%*") then
+        return true
+    end
+    for _, pattern in ipairs(recentf.config.exclude_patterns) do
+        if filepath:match(pattern) then
+            return true
+        end
+    end
+    return false
+end
+
+-- Add a file to the recent list
+function recentf.add(filepath)
+    if should_exclude(filepath) then
+        return
+    end
+
+    -- Remove if already in list (to move to front)
+    for i, f in ipairs(recentf.list) do
+        if f == filepath then
+            table.remove(recentf.list, i)
+            break
+        end
+    end
+
+    -- Add to front
+    table.insert(recentf.list, 1, filepath)
+
+    -- Trim to max size
+    while #recentf.list > recentf.config.max_saved do
+        table.remove(recentf.list)
+    end
+end
+
+-- Get the recent files list
+function recentf.get_list()
+    return recentf.list
+end
+
+-- Clear the recent files list
+function recentf.clear()
+    recentf.list = {}
+end
+
+-- Open a recent file by index (1-based)
+function recentf.open(index)
+    local idx = tonumber(index)
+    if not idx or idx < 1 or idx > #recentf.list then
+        return {success = false, message = "Invalid index: " .. tostring(index)}
+    end
+
+    local filepath = recentf.list[idx]
+    return editor:execute_command("find-file", {filepath})
+end
+
+-- Format the recent files list for display
+function recentf.format_list()
+    local lines = {}
+    for i, filepath in ipairs(recentf.list) do
+        -- Shorten home directory
+        local display = filepath:gsub("^" .. os.getenv("HOME"), "~")
+        table.insert(lines, string.format("%2d: %s", i, display))
+    end
+    return lines
+end
+
+-- Setup function
+function recentf.setup(opts)
+    opts = opts or {}
+    for k, v in pairs(opts) do
+        recentf.config[k] = v
+    end
+
+    if not recentf.config.enabled then
+        return
+    end
+
+    -- Register commands
+    editor:register_command("recentf-open", "Open a recent file by number", function(args)
+        if #args == 0 then
+            -- List recent files
+            local list = recentf.format_list()
+            if #list == 0 then
+                return {success = true, message = "No recent files"}
+            end
+            -- Show in echo area (up to 5 files)
+            local preview = {}
+            for i = 1, math.min(5, #list) do
+                table.insert(preview, list[i])
+            end
+            local msg = table.concat(preview, " | ")
+            if #list > 5 then
+                msg = msg .. " ..."
+            end
+            return {success = true, message = msg}
+        end
+        return recentf.open(args[1])
+    end, {}, true, "n")
+
+    editor:register_command("recentf-list", "List all recent files", function(args)
+        local list = recentf.format_list()
+        if #list == 0 then
+            return {success = true, message = "No recent files"}
+        end
+        -- Use echo area for multi-line display
+        editor:set_echo_area(list)
+        return {success = true, message = ""}
+    end)
+
+    editor:register_command("recentf-clear", "Clear the recent files list", function(args)
+        recentf.clear()
+        return {success = true, message = "Recent files list cleared"}
+    end)
+
+    -- Key binding (similar to Emacs)
+    editor:bind_key("C-x C-r", "recentf-open", "Open recent file")
+
+    -- Hook into find-file to track opens
+    -- We'll expose a global function that the C++ can call
+    lumacs.recentf_track = function(filepath)
+        recentf.add(filepath)
+    end
+
+    print("[recentf] Package loaded")
+end
+
+-- Auto-setup with defaults
+recentf.setup()
+
+return recentf

+ 12 - 0
recentf/package.lua

@@ -0,0 +1,12 @@
+-- Package metadata for recentf
+return {
+    name = "recentf",
+    version = "1.0.0",
+    description = "Track and access recently opened files",
+    author = "Lumacs Contributors",
+    license = "MIT",
+    homepage = "https://github.com/lumacs/packages",
+    lumacs_version = ">=0.1.0",
+    dependencies = {},
+    keywords = {"files", "recent", "history", "navigation"},
+}

+ 62 - 0
registry.json

@@ -0,0 +1,62 @@
+{
+  "zen-mode": {
+    "description": "Distraction-free writing mode with increased font size and word wrap",
+    "version": "1.0.0",
+    "keywords": ["writing", "focus", "distraction-free", "ui"]
+  },
+  "which-key": {
+    "description": "Display available keybindings when prefix key is pressed",
+    "version": "1.0.0",
+    "keywords": ["keybindings", "help", "hints", "ui"]
+  },
+  "projectile": {
+    "description": "Project management and navigation with file finding and recent projects",
+    "version": "1.0.0",
+    "keywords": ["project", "navigation", "files", "workspace"]
+  },
+  "company-mode": {
+    "description": "Text completion framework with multiple backends",
+    "version": "1.0.0",
+    "keywords": ["completion", "autocomplete", "intellisense"]
+  },
+  "doom-modeline": {
+    "description": "Doom Emacs-inspired modeline styling with icons and color presets",
+    "version": "1.0.0",
+    "keywords": ["modeline", "theme", "ui", "doom"]
+  },
+  "smartparens": {
+    "description": "Automatic pairing of brackets, quotes, and other delimiters",
+    "version": "1.0.0",
+    "keywords": ["editing", "pairs", "brackets", "quotes"]
+  },
+  "ido": {
+    "description": "Interactive Do - Enhanced completion with fuzzy matching",
+    "version": "1.0.0",
+    "keywords": ["completion", "fuzzy", "matching", "interactive"]
+  },
+  "rainbow-delimiters": {
+    "description": "Colorize nested delimiters with different colors based on depth",
+    "version": "1.0.0",
+    "keywords": ["syntax", "highlighting", "colors", "brackets"]
+  },
+  "goto-line": {
+    "description": "Go to a specific line number (M-g g)",
+    "version": "1.0.0",
+    "keywords": ["navigation", "line", "goto"]
+  },
+  "recentf": {
+    "description": "Track and access recently opened files",
+    "version": "1.0.0",
+    "keywords": ["files", "recent", "history", "navigation"]
+  },
+  "bookmarks": {
+    "description": "Save and jump between named positions in files",
+    "version": "1.0.0",
+    "keywords": ["navigation", "bookmarks", "marks", "positions"]
+  },
+  "visual-line": {
+    "description": "Soft word wrap mode (visual-line-mode)",
+    "version": "1.0.0",
+    "keywords": ["editing", "word-wrap", "visual", "text"]
+  }
+}

+ 222 - 0
smartparens/init.lua

@@ -0,0 +1,222 @@
+-- smartparens.lua
+-- ============================================================================
+-- Automatic pairing of brackets, quotes, and other delimiters.
+-- Similar to Emacs smartparens or electric-pair-mode.
+-- ============================================================================
+
+local smartparens = {}
+
+-- Configuration
+smartparens.config = {
+    enabled = true,
+    pairs = {
+        ["("] = ")",
+        ["["] = "]",
+        ["{"] = "}",
+        ['"'] = '"',
+        ["'"] = "'",
+        ["`"] = "`",
+    },
+    -- Skip closing when next char is the same closer
+    skip_when_next = true,
+    -- Delete pair together when backspacing empty pair
+    delete_pair = true,
+    -- Wrap region with pair when mark is active
+    wrap_region = true,
+}
+
+-- State
+smartparens.active = true
+
+-- Check if char is an opener
+function smartparens.is_opener(char)
+    return smartparens.config.pairs[char] ~= nil
+end
+
+-- Check if char is a closer
+function smartparens.is_closer(char)
+    for opener, closer in pairs(smartparens.config.pairs) do
+        if closer == char and opener ~= closer then
+            return true, opener
+        end
+    end
+    return false, nil
+end
+
+-- Get closer for an opener
+function smartparens.get_closer(opener)
+    return smartparens.config.pairs[opener]
+end
+
+-- Check if we're inside a string (simple heuristic)
+function smartparens.in_string()
+    local buf = editor.buffer
+    local cursor = editor.cursor
+    local line = buf:line(cursor.line)
+    local quote_count = 0
+    local dquote_count = 0
+
+    for i = 1, cursor.column do
+        local c = line:sub(i, i)
+        if c == "'" then quote_count = quote_count + 1 end
+        if c == '"' then dquote_count = dquote_count + 1 end
+    end
+
+    return (quote_count % 2 == 1) or (dquote_count % 2 == 1)
+end
+
+-- Get char at cursor
+function smartparens.char_at_cursor()
+    local buf = editor.buffer
+    local cursor = editor.cursor
+    local line = buf:line(cursor.line)
+    if cursor.column < #line then
+        return line:sub(cursor.column + 1, cursor.column + 1)
+    end
+    return nil
+end
+
+-- Get char before cursor
+function smartparens.char_before_cursor()
+    local buf = editor.buffer
+    local cursor = editor.cursor
+    local line = buf:line(cursor.line)
+    if cursor.column > 0 then
+        return line:sub(cursor.column, cursor.column)
+    end
+    return nil
+end
+
+-- Insert pair and position cursor between them
+function smartparens.insert_pair(opener)
+    if not smartparens.active or not smartparens.config.enabled then
+        return false
+    end
+
+    local closer = smartparens.get_closer(opener)
+    if not closer then
+        return false
+    end
+
+    local buf = editor.buffer
+    local cursor = editor.cursor
+
+    -- Check if we have a region selected (for wrapping)
+    if smartparens.config.wrap_region and buf:has_mark() then
+        local region = buf:get_region(cursor)
+        if region then
+            local text = buf:get_text_in_range(region)
+            buf:erase(region)
+            buf:insert(region.start, opener .. text .. closer)
+            -- Position cursor after the wrapped text
+            editor.cursor = lumacs.Position(region.start.line, region.start.column + #text + 1)
+            buf:deactivate_mark()
+            return true
+        end
+    end
+
+    -- For symmetric pairs (quotes), check if we should skip
+    if opener == closer then
+        local next_char = smartparens.char_at_cursor()
+        if next_char == opener then
+            -- Skip instead of inserting
+            editor.cursor = lumacs.Position(cursor.line, cursor.column + 1)
+            return true
+        end
+    end
+
+    -- Insert the pair
+    buf:insert(cursor, opener .. closer)
+    -- Position cursor between the pair
+    editor.cursor = lumacs.Position(cursor.line, cursor.column + 1)
+    return true
+end
+
+-- Handle closing delimiter - skip if already there
+function smartparens.handle_closer(closer)
+    if not smartparens.active or not smartparens.config.skip_when_next then
+        return false
+    end
+
+    local next_char = smartparens.char_at_cursor()
+    if next_char == closer then
+        local cursor = editor.cursor
+        editor.cursor = lumacs.Position(cursor.line, cursor.column + 1)
+        return true
+    end
+
+    return false
+end
+
+-- Handle backspace - delete pair together if empty
+function smartparens.handle_backspace()
+    if not smartparens.active or not smartparens.config.delete_pair then
+        return false
+    end
+
+    local prev = smartparens.char_before_cursor()
+    local next = smartparens.char_at_cursor()
+
+    if prev and next then
+        local expected_closer = smartparens.get_closer(prev)
+        if expected_closer == next then
+            -- Delete both chars
+            local buf = editor.buffer
+            local cursor = editor.cursor
+            local range = lumacs.Range(
+                lumacs.Position(cursor.line, cursor.column - 1),
+                lumacs.Position(cursor.line, cursor.column + 1)
+            )
+            buf:erase(range)
+            editor.cursor = lumacs.Position(cursor.line, cursor.column - 1)
+            return true
+        end
+    end
+
+    return false
+end
+
+-- Toggle smartparens
+function smartparens.toggle()
+    smartparens.active = not smartparens.active
+    if smartparens.active then
+        editor:message("Smartparens enabled")
+    else
+        editor:message("Smartparens disabled")
+    end
+end
+
+-- Register commands
+editor:register_command("smartparens-mode", "Toggle smartparens auto-pairing", function(args)
+    smartparens.toggle()
+    return {success = true}
+end, {"sp-mode"})
+
+editor:register_command("smartparens-strict-mode", "Toggle strict pairing rules", function(args)
+    smartparens.config.skip_when_next = not smartparens.config.skip_when_next
+    smartparens.config.delete_pair = not smartparens.config.delete_pair
+    local state = smartparens.config.skip_when_next and "enabled" or "disabled"
+    editor:message("Strict mode " .. state)
+    return {success = true}
+end)
+
+-- Define minor mode
+lumacs.define_minor_mode("smartparens-mode", {
+    lighter = "()",
+    global = false,
+    setup = function()
+        smartparens.active = true
+        editor:message("Smartparens enabled")
+    end,
+    cleanup = function()
+        smartparens.active = false
+        editor:message("Smartparens disabled")
+    end
+})
+
+-- Store in lumacs namespace
+lumacs.smartparens = smartparens
+
+print("[smartparens] Package loaded")
+
+return smartparens

+ 12 - 0
smartparens/package.lua

@@ -0,0 +1,12 @@
+-- Package metadata for smartparens
+return {
+    name = "smartparens",
+    version = "1.0.0",
+    description = "Automatic pairing of brackets, quotes, and other delimiters",
+    author = "Lumacs Contributors",
+    license = "MIT",
+    homepage = "https://github.com/lumacs/packages",
+    lumacs_version = ">=0.1.0",
+    dependencies = {},
+    keywords = {"editing", "pairs", "brackets", "quotes"},
+}

+ 61 - 0
visual-line/init.lua

@@ -0,0 +1,61 @@
+-- visual-line.lua
+-- Soft word wrap mode (like Emacs visual-line-mode)
+-- ============================================================================
+
+local visual_line = {}
+
+-- Configuration
+visual_line.config = {
+    enabled = true,
+}
+
+-- Toggle visual line mode
+function visual_line.toggle()
+    local current = editor:visual_line_mode()
+    editor:set_visual_line_mode(not current)
+    if not current then
+        return {success = true, message = "Visual line mode enabled (word wrap on)"}
+    else
+        return {success = true, message = "Visual line mode disabled"}
+    end
+end
+
+-- Enable visual line mode
+function visual_line.enable()
+    editor:set_visual_line_mode(true)
+    return {success = true, message = "Visual line mode enabled"}
+end
+
+-- Disable visual line mode
+function visual_line.disable()
+    editor:set_visual_line_mode(false)
+    return {success = true, message = "Visual line mode disabled"}
+end
+
+-- Setup function
+function visual_line.setup(opts)
+    opts = opts or {}
+    for k, v in pairs(opts) do
+        visual_line.config[k] = v
+    end
+
+    if not visual_line.config.enabled then
+        return
+    end
+
+    -- Register commands
+    editor:register_command("visual-line-mode", "Toggle soft word wrap", function(args)
+        return visual_line.toggle()
+    end)
+
+    editor:register_command("toggle-word-wrap", "Toggle soft word wrap (alias)", function(args)
+        return visual_line.toggle()
+    end)
+
+    print("[visual-line] Package loaded")
+end
+
+-- Auto-setup with defaults
+visual_line.setup()
+
+return visual_line

+ 12 - 0
visual-line/package.lua

@@ -0,0 +1,12 @@
+-- Package metadata for visual-line
+return {
+    name = "visual-line",
+    version = "1.0.0",
+    description = "Soft word wrap mode (visual-line-mode)",
+    author = "Lumacs Contributors",
+    license = "MIT",
+    homepage = "https://github.com/lumacs/packages",
+    lumacs_version = ">=0.1.0",
+    dependencies = {},
+    keywords = {"editing", "word-wrap", "visual", "text"},
+}

+ 328 - 0
which-key/init.lua

@@ -0,0 +1,328 @@
+-- which-key.lua
+-- ============================================================================
+-- Displays available keybindings when a prefix key is pressed and the user
+-- waits for a configurable delay. Similar to Emacs which-key package.
+--
+-- This package uses the editor's idle time tracking and extended echo area
+-- to show hints without requiring any C++ code changes.
+-- ============================================================================
+
+local which_key = {}
+
+-- Configuration
+which_key.config = {
+    delay_ms = 1000,       -- Delay before showing hints (milliseconds)
+    max_hints = 20,        -- Max number of hints to show
+    max_lines = 5,         -- Max lines in echo area
+    separator = "  ",      -- Separator between hints on same line
+    show_description = true, -- Show command descriptions
+    enabled = true,        -- Enable which-key
+}
+
+-- State
+which_key.last_prefix = nil
+which_key.showing_hints = false
+
+-- Parse a key sequence like "C-x C-f" into parts
+local function parse_sequence(seq)
+    local parts = {}
+    for part in seq:gmatch("%S+") do
+        table.insert(parts, part)
+    end
+    return parts
+end
+
+-- Check if sequence starts with prefix
+local function starts_with(sequence, prefix)
+    local seq_parts = parse_sequence(sequence)
+    local prefix_parts = parse_sequence(prefix)
+
+    if #seq_parts < #prefix_parts then
+        return false
+    end
+
+    for i, part in ipairs(prefix_parts) do
+        if seq_parts[i] ~= part then
+            return false
+        end
+    end
+
+    return true
+end
+
+-- Get the next key after the prefix
+local function get_next_key(sequence, prefix)
+    local seq_parts = parse_sequence(sequence)
+    local prefix_parts = parse_sequence(prefix)
+
+    if #seq_parts > #prefix_parts then
+        return seq_parts[#prefix_parts + 1]
+    end
+
+    return nil
+end
+
+-- Get all bindings that start with the given prefix
+function which_key.get_bindings_for_prefix(prefix)
+    local all_bindings = editor:get_all_bindings()
+    local matching = {}
+
+    -- Normalize prefix (remove trailing dash if present)
+    prefix = prefix:gsub("%-$", "")
+
+    for _, binding in ipairs(all_bindings) do
+        if starts_with(binding.sequence, prefix) and binding.sequence ~= prefix then
+            local next_key = get_next_key(binding.sequence, prefix)
+            if next_key then
+                -- Check if we already have this next_key
+                local found = false
+                for _, m in ipairs(matching) do
+                    if m.key == next_key then
+                        found = true
+                        break
+                    end
+                end
+
+                if not found then
+                    -- Determine if this is a prefix or a command
+                    local is_prefix = false
+                    local prefix_with_key = prefix .. " " .. next_key
+                    for _, b in ipairs(all_bindings) do
+                        if starts_with(b.sequence, prefix_with_key) and b.sequence ~= prefix_with_key then
+                            is_prefix = true
+                            break
+                        end
+                    end
+
+                    table.insert(matching, {
+                        key = next_key,
+                        command = binding.command,
+                        description = binding.description,
+                        full_sequence = binding.sequence,
+                        is_prefix = is_prefix,
+                    })
+                end
+            end
+        end
+    end
+
+    -- Sort by key
+    table.sort(matching, function(a, b)
+        return a.key < b.key
+    end)
+
+    return matching
+end
+
+-- Format hints into lines for display
+function which_key.format_hints(bindings, max_width)
+    max_width = max_width or 80
+    local lines = {}
+    local current_line = ""
+
+    for i, binding in ipairs(bindings) do
+        if i > which_key.config.max_hints then
+            if current_line ~= "" then
+                table.insert(lines, current_line)
+            end
+            table.insert(lines, "... and more")
+            break
+        end
+
+        local hint
+        if binding.is_prefix then
+            hint = binding.key .. ":+"
+        elseif which_key.config.show_description and binding.description ~= "" then
+            -- Truncate long descriptions
+            local desc = binding.description
+            if #desc > 15 then
+                desc = desc:sub(1, 12) .. "..."
+            end
+            hint = binding.key .. ":" .. desc
+        else
+            -- Truncate long command names
+            local cmd = binding.command or "?"
+            if #cmd > 15 then
+                cmd = cmd:sub(1, 12) .. "..."
+            end
+            hint = binding.key .. ":" .. cmd
+        end
+
+        if current_line == "" then
+            current_line = hint
+        elseif #current_line + #which_key.config.separator + #hint <= max_width then
+            current_line = current_line .. which_key.config.separator .. hint
+        else
+            table.insert(lines, current_line)
+            current_line = hint
+
+            if #lines >= which_key.config.max_lines then
+                table.insert(lines, "...")
+                return lines
+            end
+        end
+    end
+
+    if current_line ~= "" then
+        table.insert(lines, current_line)
+    end
+
+    return lines
+end
+
+-- Show which-key hints in the extended echo area
+function which_key.show_hints(prefix)
+    if not which_key.config.enabled then
+        return
+    end
+
+    local bindings = which_key.get_bindings_for_prefix(prefix)
+
+    if #bindings > 0 then
+        local lines = which_key.format_hints(bindings)
+        -- Prepend the current prefix
+        table.insert(lines, 1, prefix .. "-")
+        editor:set_echo_area(lines)
+        which_key.showing_hints = true
+    end
+end
+
+-- Hide which-key hints
+function which_key.hide_hints()
+    if which_key.showing_hints then
+        editor:clear_echo_area()
+        which_key.showing_hints = false
+    end
+end
+
+-- Check if we should show hints (called during idle)
+function which_key.check_idle()
+    if not which_key.config.enabled then
+        return
+    end
+
+    -- Check if we're building a key sequence
+    if editor:is_building_sequence() then
+        local current = editor:current_sequence()
+        -- Remove trailing dash for comparison
+        current = current:gsub("%-$", "")
+
+        -- Check if we've been idle long enough
+        local idle_ms = editor:idle_time_ms()
+
+        if idle_ms >= which_key.config.delay_ms then
+            -- Show hints if we haven't already or prefix changed
+            if not which_key.showing_hints or which_key.last_prefix ~= current then
+                which_key.show_hints(current)
+                which_key.last_prefix = current
+            end
+        end
+    else
+        -- Not building a sequence, hide hints if showing
+        which_key.hide_hints()
+        which_key.last_prefix = nil
+    end
+end
+
+-- Register commands
+editor:register_command("which-key", "Show available keybindings for prefix", function(args)
+    if #args > 0 then
+        which_key.show_hints(args[1])
+    elseif editor:is_building_sequence() then
+        local current = editor:current_sequence():gsub("%-$", "")
+        which_key.show_hints(current)
+    else
+        -- Show all prefixes
+        local all_bindings = editor:get_all_bindings()
+        local prefixes = {}
+
+        for _, binding in ipairs(all_bindings) do
+            local parts = parse_sequence(binding.sequence)
+            if #parts > 1 then
+                local prefix = parts[1]
+                if not prefixes[prefix] then
+                    prefixes[prefix] = 0
+                end
+                prefixes[prefix] = prefixes[prefix] + 1
+            end
+        end
+
+        local hints = {}
+        for prefix, count in pairs(prefixes) do
+            table.insert(hints, prefix .. ":+" .. count)
+        end
+        table.sort(hints)
+
+        editor:message("Prefix keys: " .. table.concat(hints, " | "))
+    end
+    return {success = true}
+end, {"wk"}, true, "s")
+
+-- Describe key binding
+editor:register_command("describe-key", "Describe a key binding", function(args)
+    if #args == 0 then
+        return {success = false, message = "Usage: describe-key <key-sequence>"}
+    end
+
+    local key = args[1]
+    local all_bindings = editor:get_all_bindings()
+
+    for _, binding in ipairs(all_bindings) do
+        if binding.sequence == key then
+            local msg = key .. " runs " .. binding.command
+            if binding.description ~= "" then
+                msg = msg .. " (" .. binding.description .. ")"
+            end
+            return {success = true, message = msg}
+        end
+    end
+
+    -- Check if it's a prefix
+    if editor:has_prefix_bindings(key) then
+        which_key.show_hints(key)
+        return {success = true}
+    end
+
+    return {success = false, message = key .. " is not bound"}
+end, {"dk"}, true, "s")
+
+-- Toggle which-key
+editor:register_command("which-key-mode", "Toggle which-key hints", function(args)
+    which_key.config.enabled = not which_key.config.enabled
+    if which_key.config.enabled then
+        editor:message("which-key enabled")
+    else
+        which_key.hide_hints()
+        editor:message("which-key disabled")
+    end
+    return {success = true}
+end)
+
+-- Set delay
+editor:register_command("which-key-delay", "Set which-key delay in milliseconds", function(args)
+    if #args == 0 then
+        return {success = true, message = "which-key delay: " .. which_key.config.delay_ms .. "ms"}
+    end
+
+    local delay = tonumber(args[1])
+    if delay and delay >= 0 then
+        which_key.config.delay_ms = delay
+        return {success = true, message = "which-key delay set to " .. delay .. "ms"}
+    else
+        return {success = false, message = "Invalid delay value"}
+    end
+end, {}, true, "s")
+
+-- Bind C-h k to describe-key (Emacs standard)
+editor:bind_key("C-h k", "describe-key", "Describe a key binding")
+
+-- Register the idle check function globally so the editor can call it
+-- This gets called during the UI's idle loop
+lumacs.which_key_check_idle = which_key.check_idle
+
+-- Store module
+lumacs.which_key = which_key
+
+print("[which-key] Package loaded")
+
+return which_key

+ 12 - 0
which-key/package.lua

@@ -0,0 +1,12 @@
+-- Package metadata for which-key
+return {
+    name = "which-key",
+    version = "1.0.0",
+    description = "Display available keybindings when prefix key is pressed",
+    author = "Lumacs Contributors",
+    license = "MIT",
+    homepage = "https://github.com/lumacs/packages",
+    lumacs_version = ">=0.1.0",
+    dependencies = {},
+    keywords = {"keybindings", "help", "hints", "ui"},
+}

+ 111 - 0
zen-mode/init.lua

@@ -0,0 +1,111 @@
+-- zen-mode for lumacs
+-- ============================================================================
+-- Distraction-free writing mode. Enables visual-line-mode, increases font
+-- size, and provides a cleaner interface for focused writing.
+-- ============================================================================
+
+local M = {}
+
+-- Configuration
+M.config = {
+    font_size_increase = 4,    -- How much to increase font size
+    enable_visual_line = true, -- Enable word wrap in zen mode
+    width = 80,                -- Target text width (for future centering)
+}
+
+-- State
+M.active = false
+M.saved_state = {}
+
+-- ============================================================================
+-- Core Functions
+-- ============================================================================
+
+function M.enable()
+    if M.active then
+        return {success = true, message = "Zen mode already active"}
+    end
+
+    -- Save current state
+    M.saved_state = {
+        font_size = editor:font_size(),
+        visual_line_mode = editor:visual_line_mode(),
+    }
+
+    -- Apply zen mode settings
+    local new_font_size = M.saved_state.font_size + M.config.font_size_increase
+    editor:set_font_size(new_font_size)
+
+    if M.config.enable_visual_line then
+        editor:set_visual_line_mode(true)
+    end
+
+    M.active = true
+
+    return {success = true, message = "Zen mode enabled (font: " .. new_font_size .. ")"}
+end
+
+function M.disable()
+    if not M.active then
+        return {success = true, message = "Zen mode not active"}
+    end
+
+    -- Restore saved state
+    if M.saved_state.font_size then
+        editor:set_font_size(M.saved_state.font_size)
+    end
+
+    if M.saved_state.visual_line_mode ~= nil then
+        editor:set_visual_line_mode(M.saved_state.visual_line_mode)
+    end
+
+    M.active = false
+    M.saved_state = {}
+
+    return {success = true, message = "Zen mode disabled"}
+end
+
+function M.toggle()
+    if M.active then
+        return M.disable()
+    else
+        return M.enable()
+    end
+end
+
+function M.is_active()
+    return M.active
+end
+
+-- ============================================================================
+-- Setup
+-- ============================================================================
+
+function M.setup(opts)
+    opts = opts or {}
+
+    -- Merge user config
+    for k, v in pairs(opts) do
+        M.config[k] = v
+    end
+
+    -- Register commands
+    editor:register_command("zen-mode", "Toggle distraction-free writing mode", function(args)
+        return M.toggle()
+    end)
+
+    editor:register_command("zen-mode-enable", "Enable zen mode", function(args)
+        return M.enable()
+    end)
+
+    editor:register_command("zen-mode-disable", "Disable zen mode", function(args)
+        return M.disable()
+    end)
+
+    print("[zen-mode] Package loaded")
+end
+
+-- Auto-setup
+M.setup()
+
+return M

+ 12 - 0
zen-mode/package.lua

@@ -0,0 +1,12 @@
+-- Package metadata for zen-mode
+return {
+    name = "zen-mode",
+    version = "1.0.0",
+    description = "Distraction-free writing mode with increased font size and word wrap",
+    author = "Lumacs Contributors",
+    license = "MIT",
+    homepage = "https://github.com/lumacs/packages",
+    lumacs_version = ">=0.1.0",
+    dependencies = {},
+    keywords = {"writing", "focus", "distraction-free", "ui"},
+}