Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ jobs:
echo "$HOME/.local/bin" >> $GITHUB_PATH
echo "$HOME/.local/share/mise/bin" >> $GITHUB_PATH
echo "$HOME/.local/share/mise/shims" >> $GITHUB_PATH
mise use -g rust@1.92.0

- name: Install mise dependencies
run: mise install
Expand Down
2 changes: 2 additions & 0 deletions .opencode/command/prose-code-diff.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ agent: build
---

Use @prose-coder to review your CHANGE (not the rest of the code, JUST your change), and whittle it down (IF APPLICABLE) to a clean diff. While you are reviewing your change ONLY, review it in _context_ of whole (applicable) files.

You can check what changed with either `git diff` or `git diff --staged`.
3 changes: 2 additions & 1 deletion .styluaignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
lua/pm/morph
library
lua/tuis/morph
39 changes: 39 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@

This document outlines the standard pattern for creating UI modules in the tuis.nvim plugin.

## Runtime

Target LuaJIT in Neovim. DO NOT use goto, EVER.


## Build/Lint/Test Commands

- `mise run ci` - Run lint, format check, and tests
- `mise run lint` - Typecheck with emmylua_check
- `mise run fmt` - Format code with stylua
- `mise run fmt:check` - Check code formatting
- `mise run test` - Run tests with busted

**Single test**: Use busted directly: `busted --verbose --filter='"<FULL TEST NAME HERE>"'`

## UI Module Structure

All UI modules should be located in `lua/tuis/uis/` and follow this pattern:
Expand Down Expand Up @@ -88,6 +103,30 @@ To migrate an existing UI file:
5. Add `return M` at the end
6. Remove any early returns based on CLI availability (let `M.is_enabled()` handle this)


### Testing Neovim Behaviors Interactively

When you need to quickly test Neovim behaviors outside of the formal test suite:

1. **Create a test script**: Create a standalone `.lua` file (e.g., `test_behavior.lua`) in the project root
2. **Write the test**: Use standard Lua and Neovim API calls. The script should end with `vim.cmd.qall { bang = true }` to exit
3. **Run the test**: Execute with `nvim --headless -u NONE -c "set rtp+=." -c "luafile test_behavior.lua" 2>&1`
- `--headless`: Run without UI
- `-u NONE`: Don't load user config
- `-c "set rtp+=."`: Add current directory to runtime path so `require 'morph'` works
- `-c "luafile test_behavior.lua"`: Execute your test script
- `2>&1`: Capture all output
4. **Clean up**: Delete test files when done (they should not be committed)

**Example test script**:
```lua
#!/usr/bin/env nvim -l
-- Your test code here
vim.cmd.qall { bang = true }
```

**Note**: Interactive nvim commands with input (like `nvim --headless -c "..."` where commands expect user input) will hang. Always use non-interactive commands or scripts.

## Examples

See `lua/tuis/uis/launchd_services.lua` for a complete example of this pattern.
42 changes: 15 additions & 27 deletions lua/tuis/components.lua
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,9 @@ local function strdisplaywidth(s)
return ok and w or #s
end

-- __ __ _
-- | \/ | ___| |_ ___ _ __
-- | |\/| |/ _ \ __/ _ \ '__|
-- | | | | __/ || __/ |
-- |_| |_|\___|\__\___|_|
--------------------------------------------------------------------------------
-- Meter
--------------------------------------------------------------------------------

--- Unicode block characters for smooth horizontal progress display
local METER_BLOCKS = { ' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█' }
Expand Down Expand Up @@ -63,12 +61,9 @@ function M.Meter(ctx)
return h('text', { hl = hl }, meter_str)
end

-- ____ _ _ _
-- / ___| _ __ __ _ _ __| | _| (_)_ __ ___
-- \___ \| '_ \ / _` | '__| |/ / | | '_ \ / _ \
-- ___) | |_) | (_| | | | <| | | | | | __/
-- |____/| .__/ \__,_|_| |_|\_\_|_|_| |_|\___|
-- |_|
--------------------------------------------------------------------------------
-- Sparkline
--------------------------------------------------------------------------------

--- @alias morph.SparklineProps {
--- values: number[],
Expand Down Expand Up @@ -118,11 +113,9 @@ function M.Sparkline(ctx)
return h('text', { hl = hl }, sparkline_str)
end

-- _____ _ _
-- |_ _|_ _| |__ | | ___
-- | |/ _` | '_ \| |/ _ \
-- | | (_| | |_) | | __/
-- |_|\__,_|_.__/|_|\___|
--------------------------------------------------------------------------------
-- Table
--------------------------------------------------------------------------------

--- @alias morph.TableBorderStyle 'none'|'single'|'double'|'rounded'|'ascii'

Expand Down Expand Up @@ -426,11 +419,9 @@ function M.Table(ctx)
return result
end

-- _____ _ ____
-- |_ _|_ _| |__ | __ ) __ _ _ __
-- | |/ _` | '_ \| _ \ / _` | '__|
-- | | (_| | |_) | |_) | (_| | |
-- |_|\__,_|_.__/|____/ \__,_|_|
--------------------------------------------------------------------------------
-- TabBar
--------------------------------------------------------------------------------

--- @class morph.TabBarTab
--- @field key string
Expand Down Expand Up @@ -486,12 +477,9 @@ function M.TabBar(ctx)
return { result, '\n\n' }
end

-- _ _ _
-- | | | | ___| |_ __
-- | |_| |/ _ \ | '_ \
-- | _ | __/ | |_) |
-- |_| |_|\___|_| .__/
-- |_|
--------------------------------------------------------------------------------
-- Help
--------------------------------------------------------------------------------

--- @alias morph.HelpKeymap { [1]: string, [2]: string }

Expand Down
22 changes: 11 additions & 11 deletions lua/tuis/term_emulator.lua
Original file line number Diff line number Diff line change
Expand Up @@ -32,23 +32,23 @@ local function detect_terminal()
end
end

--- @class TerminalInfo
--- @class tuis.term_emulator.TerminalInfo
--- @field terminal string The detected terminal type ('tmux', 'wezterm', 'ghostty', 'unknown')
--- @field is_wsl2 boolean Whether running in WSL2
--- @field is_tmux boolean Whether running in TMUX
--- @field is_wezterm boolean Whether running in WezTerm
--- @field is_ghostty boolean Whether running in Ghostty
--- @field wezterm_path string Path to WezTerm executable

--- @class Emulator
--- @class tuis.term_emulator.Emulator
--- @field name string
local Emulator = {}
M.Emulator = Emulator
Emulator.__index = Emulator

--- Get emulator instance by kind, or currently detected if kind is nil
--- @param kind string?
--- @return Emulator?
--- @return tuis.term_emulator.Emulator?
function Emulator.get(kind)
kind = kind or detect_terminal()

Expand All @@ -64,7 +64,7 @@ function Emulator.get(kind)
end

--- @param name string
--- @return Emulator
--- @return tuis.term_emulator.Emulator
function Emulator:new(name) return setmetatable({ name = name }, self) end

--- @param _program string?
Expand Down Expand Up @@ -93,7 +93,7 @@ end
-- WezTerm
--------------------------------------------------------------------------------

--- @class WezTerm : Emulator
--- @class tuis.term_emulator.WezTerm : tuis.term_emulator.Emulator
local WezTerm = setmetatable({}, { __index = Emulator })
M.WezTerm = WezTerm
WezTerm.__index = WezTerm
Expand All @@ -107,7 +107,7 @@ function WezTerm.get_wezterm_path()
end
end

--- @return WezTerm
--- @return tuis.term_emulator.WezTerm
function WezTerm:new() return setmetatable(Emulator.new(self, 'wezterm'), self) end

--- @param program string?
Expand Down Expand Up @@ -174,12 +174,12 @@ end
-- Tmux
--------------------------------------------------------------------------------

--- @class Tmux : Emulator
--- @class tuis.term_emulator.Tmux : tuis.term_emulator.Emulator
local Tmux = setmetatable({}, { __index = Emulator })
M.Tmux = Tmux
Tmux.__index = Tmux

--- @return Tmux
--- @return tuis.term_emulator.Tmux
function Tmux:new() return setmetatable(Emulator.new(self, 'tmux'), self) end

--- @param program string?
Expand Down Expand Up @@ -213,12 +213,12 @@ end
-- Ghostty
--------------------------------------------------------------------------------

--- @class Ghostty : Emulator
--- @class tuis.term_emulator.Ghostty : tuis.term_emulator.Emulator
local Ghostty = setmetatable({}, { __index = Emulator })
M.Ghostty = Ghostty
Ghostty.__index = Ghostty

--- @return Ghostty
--- @return tuis.term_emulator.Ghostty
function Ghostty:new() return setmetatable(Emulator.new(self, 'ghostty'), self) end

--- @param program string?
Expand Down Expand Up @@ -326,7 +326,7 @@ function M.new_tab(program, emulator)
end

--- Get the currently detected emulator instance
--- @return Emulator?
--- @return tuis.term_emulator.Emulator?
function M.current() return Emulator.get() end

return M
9 changes: 5 additions & 4 deletions lua/tuis/uis/aws.lua
Original file line number Diff line number Diff line change
Expand Up @@ -551,8 +551,9 @@ local function App(ctx)
state.loading = true
ctx:update(state)

local page = assert(state.page)
--- @type aws.PageConfig
local config = PAGE_CONFIGS[state.page]
local config = assert(PAGE_CONFIGS[page])
config.fetch(state.region, function(items)
state[config.state_key] = items
state.loading = false
Expand Down Expand Up @@ -600,10 +601,10 @@ local function App(ctx)
end

-- Render current page
--- @cast state.page aws.Page
local page = state.page
local page = assert(state.page)
--- @cast page aws.Page
--- @type aws.PageConfig
local config = PAGE_CONFIGS[page]
local config = assert(PAGE_CONFIGS[page])
local page_content = h(config.view, {
items = state[config.state_key],
loading = state.loading,
Expand Down
6 changes: 3 additions & 3 deletions lua/tuis/uis/bitwarden.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1022,8 +1022,8 @@ local function App(ctx)
local state = assert(ctx.state)

if ctx.phase == 'unmount' then
state.timer:stop()
state.timer:close()
assert(state.timer):stop()
assert(state.timer):close()
end

-- Build navigation keymaps
Expand Down Expand Up @@ -1072,7 +1072,7 @@ local function App(ctx)
folders = state.folders,
items = state.items,
loading = state.loading,
on_filter_folder = function(folder_id)
on_filter_folder = function(_folder_id)
-- Switch to items page with folder filter
state.page = 'items'
ctx:update(state)
Expand Down
25 changes: 13 additions & 12 deletions lua/tuis/uis/docker.lua
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,9 @@ local function show_inspect(type, id)
end

--- Ask for confirmation before running a dangerous action
--- @param prompt string
--- @param _prompt string
--- @param on_confirm fun()
local function confirm_action(prompt, on_confirm)
local function confirm_action(_prompt, on_confirm)
local choice = vim.fn.confirm('Are you sure?', '&Yes\n&No', 2)
if choice == 1 then vim.schedule(on_confirm) end
end
Expand Down Expand Up @@ -1015,7 +1015,7 @@ local StatsView = create_resource_view {
}
end,

keymaps = function(stat, on_refresh)
keymaps = function(stat, _on_refresh)
return {
['gi'] = keymap(function() show_inspect('container', stat.id) end),
['gl'] = keymap(function() term.open('docker logs --since 5m -f ' .. stat.id) end),
Expand Down Expand Up @@ -1046,7 +1046,7 @@ local ComposeView = create_resource_view {
}
end,

keymaps = function(project, on_refresh)
keymaps = function(project, _on_refresh)
return {
['gi'] = keymap(function()
vim.schedule(function()
Expand Down Expand Up @@ -1516,7 +1516,8 @@ local function App(ctx)
ctx:update(state)
end

local config = PAGE_CONFIGS[state.page]
local page = assert(state.page)
local config = assert(PAGE_CONFIGS[page])
if config.custom and state.page == 'system' then
-- System page returns two values: disk_usage and info
config.fetch(function(disk_usage, info)
Expand All @@ -1525,15 +1526,15 @@ local function App(ctx)
state.loading = false
ctx:update(state)
end)
elseif state.page == 'contexts' then
elseif page == 'contexts' then
-- Contexts page also updates header context display
config.fetch(function(items)
state[config.state_key] = items
state.current_context = get_current_context()
state.loading = false
ctx:update(state)
end)
elseif state.page == 'hub' then
elseif page == 'hub' then
state.loading = false
ctx:update(state)
else
Expand Down Expand Up @@ -1591,10 +1592,9 @@ local function App(ctx)
end

local state = assert(ctx.state)

if ctx.phase == 'unmount' then
state.timer:stop()
state.timer:close()
assert(state.timer):stop()
assert(state.timer):close()
end

-- Build navigation keymaps
Expand All @@ -1614,9 +1614,10 @@ local function App(ctx)
end

-- Render current page
local config = PAGE_CONFIGS[state.page]
local page = assert(state.page)
local config = assert(PAGE_CONFIGS[page])
local page_content
if config.custom and state.page == 'system' then
if config.custom and page == 'system' then
page_content = h(config.view, {
disk_usage = state.disk_usage,
info = state.system_info,
Expand Down
9 changes: 5 additions & 4 deletions lua/tuis/uis/gcloud.lua
Original file line number Diff line number Diff line change
Expand Up @@ -548,8 +548,9 @@ local function App(ctx)
state.loading = true
ctx:update(state)

local page = assert(state.page)
--- @type gcloud.PageConfig
local config = PAGE_CONFIGS[state.page]
local config = assert(PAGE_CONFIGS[page])
config.fetch(state.project, function(items)
state[config.state_key] = items
state.loading = false
Expand Down Expand Up @@ -597,10 +598,10 @@ local function App(ctx)
end

-- Render current page
--- @cast state.page gcloud.Page
local page = state.page
local page = assert(state.page)
--- @cast page gcloud.Page
--- @type gcloud.PageConfig
local config = PAGE_CONFIGS[page]
local config = assert(PAGE_CONFIGS[page])
local page_content = h(config.view, {
items = state[config.state_key],
loading = state.loading,
Expand Down
Loading