Add Shell Command plugin for launcher commands (#290)

* Add Quick Shell plugin for launcher commands

Introduces `/qs` launcher prefix to run shell commands in an interactive
terminal. Features dynamic shell completions (Fish/Bash), command
history, user snippets, and directory navigation.

* Rename quick-shell plugin to shell command plugin

* Update thumbnail.webp

* Document shell dependencies and filter completions by current word

Filter bash compgen completions to the current word being typed. Declare
`sh`, `ls`, `fish`, and `bash` as plugin dependencies.
This commit is contained in:
weinguyen
2026-08-08 15:07:11 -04:00
committed by GitHub
parent 41afff4ede
commit c67f0739dc
6 changed files with 687 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
# Shell Command
Run a shell command straight from the Noctalia launcher. Type `/sh` followed by
any command and press Enter to open it in your default terminal — a **real,
interactive shell** with live output, TUI apps, and your own native history.
No hardcoded completion table: suggestions come from the shell's own completion
engine and your command history, so they stay in sync with what's actually on
your system. Commands run through your own `$SHELL` in interactive mode, so
aliases, functions and environment from your rc config are available.
## Features
- **Instant command run** — `/sh ls -la ~/projects` opens the command in your
default terminal.
- **Fish-style autosuggestions** — completions fetched live from Fish's
completion engine (`fish -c 'complete -C "<query>"'`), falling back to bash
`compgen -c` when Fish isn't installed. Type `/sh git st` and get `git status`,
`git stash`, etc. Suggestions dynamically follow your system — no hardcoded
list to maintain.
- **Snap-complete** — when a prefix has exactly one completion, the "Run" entry
jumps to the completed command, so one Enter runs it instead of fill-then-run.
- **History** — previously run commands are remembered (per-plugin state, capped
at 100) and offered as you type, most recent first.
- **Snippets** — user-defined commands shown when `/sh` is typed with an empty
query.
- **Folder jump** — `/sh cd` lists subdirectories and lets you drill into nested
folders. Select the "Open in:" row to launch a terminal inside the current
directory.
- **Navigate and run in one launch** — `/sh cd ~/proj && make` changes into that
directory and runs the command, so tools open in the right folder.
- **Suggestion fills, explicit launch runs** — completion/history/snippet rows
fill the input (so you can keep typing or drill deeper); only "Run:" and
"Open in:" rows actually launch a terminal.
- **Stay-open terminal** — after a fast command (e.g. `git status`) the terminal
holds its output, shows a `[Press Enter to continue]` prompt, then drops you
into an interactive shell.
- **Workspace-aware** — when a default workspace is set, commands start in that
directory.
## Plugin
| Field | Value |
| --------------- | ----------------------------- |
| ID | `weinguyen/shell-command` |
| Entry | Launcher provider: `provider` |
| Launcher Prefix | `/sh` |
## Requirements
- Noctalia v5.0.0 or higher.
- `runInTerminal` needs a default terminal configured in Noctalia.
- A shell at `$SHELL` (falls back to `sh`).
- Optional: Fish (richer completions; falls back to bash otherwise).
- `ls` for the folder-jump listing.
Declared in `plugin.toml`
`dependencies`: `sh`, `ls`, plus `fish` and `bash` for the completion fallback
(the user's own `$SHELL` at runtime is whatever shell they have configured).
## Usage
Open the launcher and type `/sh` followed by a command:
```
/sh
/sh ls -la ~/projects
/sh git status
```
Press Enter to run the command in your default terminal.
With an empty query, recent commands and configured snippets are offered. As you
type a command, suggestions from the shell's completion engine appear under the
exact command you typed. Selecting a suggestion fills the input; run the filled
command by pressing Enter again (or use snap-complete when only one completion
matches).
### Navigate inside a folder first
```
/sh cd # list top-level folders
/sh cd proj # list folders starting with "proj"
/sh cd ~/Builds/ # list everything directly inside ~/Builds
```
Folder rows let you drill deeper (each selection fills the path with a trailing
slash so you can keep going). The top "Open in:" row launches a terminal inside
the current parent directory.
### Run a command in a directory
```
/sh cd ~/Builds && make
```
Navigates into `~/Builds` and runs `make` there. Paths may contain spaces,
quotes and `\ ` escapes.
## Settings
| Setting | Type | Default | Description |
| ------------------- | ------------- | ------- | ------------------------------------------------------------------------------- |
| `default_workspace` | `folder` | `""` | Working directory commands (and the `cd` listing) start in. Empty uses `$HOME`. |
| `snippets` | `string_list` | `[]` | Commands shown when `/sh` is typed with an empty query. |
## Notes
- Commands run in a real interactive terminal, so tools like `vim`, `htop` and
`tmux` work normally.
- Commands execute via `$SHELL -ic`, so aliases and functions from your rc
config are available. Note that completion — not alias expansion — is what
powers suggestions; an alias defined only transitively may still need its
underlying command.
- Completion suggestions update dynamically as Fish completions and installed
binaries change — there is no hardcoded list to maintain.
- History is stored per-plugin (XDG state directory), capped at 100 entries,
deduplicated, most recent command first.
## Development
- `shell_provider.luau` — the launcher provider entry.
- `translations/en.json`, `translations/vi.json` — user-facing strings.
+32
View File
@@ -0,0 +1,32 @@
id = "weinguyen/shell-command"
name = "Shell Command"
version = "0.1.0"
plugin_api = 3
author = "weinguyen"
license = "MIT"
icon = "terminal"
description = "Run a shell command from the launcher. Type /sh then command open it your default terminal."
tags = ["launcher", "productivity", "development"]
dependencies = ["bash", "fish", "ls", "sh"]
[[setting]]
key = "default_workspace"
type = "folder"
default = ""
label_key = "settings.default_workspace.label"
description_key = "settings.default_workspace.description"
[[setting]]
key = "snippets"
type = "string_list"
default = []
label_key = "settings.snippets.label"
description_key = "settings.snippets.description"
[[launcher_provider]]
id = "provider"
entry = "shell_provider.luau"
prefix = "sh"
glyph = "terminal"
include_in_global_search = false
debounce_ms = 150
+482
View File
@@ -0,0 +1,482 @@
-- Shell Command launcher provider.
--
-- Type `/sh <command>` in the launcher to run a shell command in your default
-- terminal. Fish-style autosuggestions: as you type it queries the shell's own
-- completion engine (`fish -c 'complete -C "<query>"'`, falling back to bash
-- `compgen -c`) so suggestions grow dynamically with your system — no hardcoded
-- list. Commands from your own history and user-defined snippets are offered
-- too. A `cd` mode lists folders so you can open a terminal in a chosen
-- directory. Selecting any entry opens it in a real terminal (via
-- noctalia.runInTerminal), so you get a full shell with live output, TUI apps,
-- and native history.
local HISTORY_FILE = "history"
local HISTORY_LIMIT = 100
local MAX_SUGGESTIONS = 8
local history = {}
local function shellQuote(s)
return "'" .. s:gsub("'", "'\"'\"'") .. "'"
end
local function trim(s)
return noctalia.string.trim(tostring(s or ""))
end
-- Private per-plugin storage (XDG state); the host creates the directory on
-- every call. nil (with a log line) when no state directory resolves.
local function dataDir()
local dir, err = noctalia.pluginDataDir()
if dir == nil then
noctalia.log("shell-command: pluginDataDir failed: " .. tostring(err))
return nil
end
return dir
end
local function loadHistory()
local dir = dataDir()
if dir == nil then
return
end
local raw = noctalia.readFile(dir .. "/" .. HISTORY_FILE)
if type(raw) ~= "string" or raw == "" then
return
end
history = {}
for line in raw:gmatch("[^\r\n]+") do
line = trim(line)
if line ~= "" then
history[#history + 1] = line
end
end
end
local function saveHistory()
local dir = dataDir()
if dir == nil then
return
end
local ok, err = noctalia.writeFile(dir .. "/" .. HISTORY_FILE, table.concat(history, "\n"))
if not ok then
noctalia.log("shell-command: could not persist history: " .. tostring(err))
end
end
-- Record a run command: move it to the front (most recent), dedupe, cap size.
local function recordHistory(cmd)
for i, entry in ipairs(history) do
if entry == cmd then
table.remove(history, i)
break
end
end
table.insert(history, 1, cmd)
if #history > HISTORY_LIMIT then
for _ = HISTORY_LIMIT + 1, #history do
table.remove(history)
end
end
saveHistory()
end
-- Build the command actually launched in the terminal.
--
-- If the query itself is a `cd <dir> && <cmd>` compound, we cd into `<dir>`
-- instead of the workspace — that way navigation and a command can be combined
-- in one launch (`/sh cd ~/proj && ls`). Otherwise, if a default workspace is
-- configured, cd there first.
--
-- Commands run through the user's own `$SHELL` (not `sh`), so aliases,
-- shell functions and environment from the login shell are available. The
-- command is wrapped so the terminal stays open after it finishes (a fast
-- command like `git status` would otherwise close the window before you can
-- read the output). We hold the output on screen with `read`, then drop into a
-- fresh interactive shell so the user can keep typing — exec'ing the shell
-- directly clears the result before it can be read.
-- Wrap a command in the terminal-held-open wrapper and run it via the user's
-- own interactive shell (`$SHELL -ic`) so aliases and functions from the
-- login/rc config are available — a bare `-c` ignores them and breaks things
-- like fish aliases. After the command we `exec $SHELL` into a fresh
-- interactive shell.
local function buildLaunch(inner)
local shell = noctalia.getenv("SHELL") or "sh"
-- `-ic`: user's rc config/aliases load. Alias-safe `read` (fish shows an
-- ugly `read>` prompt interactively — use the silent `-s`/`-l` variant; bash
-- and zsh `read` are silent already and have no such prompt) holds the
-- output until Enter, then we exec a fresh interactive shell that stays open.
local base = shell:match("([^/]+)$") or "sh"
local readCmd = (base == "fish") and "read -l -s __x" or "read __x"
local printfPrompt = "printf '%s\\n' '[Press Enter to continue] [Ctrl+C to Close]'"
return shell .. " -ic " .. shellQuote(inner .. "; " .. printfPrompt .. "; " .. readCmd .. "; exec $SHELL")
end
local function buildCommand(cmd)
local inner = cmd
-- Try to split a leading `cd <path> && <rest>` compound, honouring quoted
-- paths, spaces and `\ ` escapes inside the directory argument.
local dir = cmd:match("^%s*cd%s+(.+)$")
if dir then
local i, n = 1, #dir
local out = {}
local quote
local done
while i <= n do
local c = dir:sub(i, i)
if quote then
if c == quote then
quote = nil
elseif c == "\\" and i < n then
out[#out + 1] = dir:sub(i + 1, i + 1)
i = i + 1
else
out[#out + 1] = c
end
elseif c == '"' or c == "'" then
quote = c
elseif c == " " then
done = true
break
else
out[#out + 1] = c
end
i = i + 1
end
if not done then
i = n + 1 -- path ran to end; nothing appended
end
local rest = dir:sub(i):match("^%s*&&%s*(.+)$")
if #out > 0 and rest then
local path = table.concat(out)
local abs = path:gsub("^~/+", (noctalia.getenv("HOME") or "/") .. "/")
inner = "cd " .. shellQuote(abs) .. " && " .. rest
else
inner = cmd
end
else
local cwd = noctalia.getConfig("default_workspace")
if type(cwd) == "string" and cwd ~= "" then
inner = "cd " .. shellQuote(cwd) .. " && " .. cmd
end
end
-- Run through the user's own interactive shell (`-ic`) so aliases and
-- functions from the login/rc config are available, not just `-c` which
-- ignores them. The wrapper is a real terminal so it's interactive-stable;
-- after the command we `exec $SHELL` into a fresh interactive shell.
return buildLaunch(inner)
end
-- Open a terminal directly inside a chosen directory (no command to run).
local function buildCdCommand(path)
-- Also interactive so aliases work inside the opened shell.
return buildLaunch("cd " .. shellQuote(path))
end
-- The first token of the command, used to detect whether the binary exists.
local function firstToken(cmd)
local token = cmd:match("^%s*([^%s]+)")
return token or ""
end
local function runEntry(cmd, subtitle)
return {
id = "run:" .. cmd,
title = noctalia.tr("run_title", { command = cmd }),
subtitle = subtitle,
glyph = "terminal",
}
end
-- Rebuild the full command from a completion token. The completion engine
-- returns only the token being completed, so we re-attach the typed prefix.
local function fullCommand(query, comp)
local prefix = query:match("^(.*%s)") or ""
return prefix .. comp
end
-- Parse `fish -c 'complete -C "<query>"'` output: one completion per line,
-- optionally `completion<TAB>description`. We keep the completion token.
local function parseFish(output)
local comps = {}
for line in (tostring(output or "") .. "\n"):gmatch("(.-)\n") do
line = trim(line)
if line ~= "" then
local comp = line:match("^([^\t]+)")
if comp and comp ~= "" then
comps[#comps + 1] = comp
end
end
end
return comps
end
-- Parse `compgen -c` output: one command name per line.
local function parseCompgen(output)
local comps = {}
for line in (tostring(output or "") .. "\n"):gmatch("(.-)\n") do
line = trim(line)
if line ~= "" then
comps[#comps + 1] = line
end
end
return comps
end
-- Query the shell's completion engine for the typed prefix. Prefers fish
-- (full subcommand/flag completions); falls back to bash `compgen -c` for
-- command names when fish is unavailable.
local function fetchCompletions(query, callback)
if noctalia.commandExists("fish") then
noctalia.runAsync("fish -c " .. shellQuote("complete -C " .. shellQuote(query)), function(result)
callback(parseFish(result.stdout))
end)
return
end
if noctalia.commandExists("bash") then
local word = query:match("([^%s]+)%s*$") or ""
noctalia.runAsync("bash -c " .. shellQuote("compgen -c " .. shellQuote(word)), function(result)
callback(parseCompgen(result.stdout))
end)
return
end
callback({})
end
-- List subdirectories of the cd root (default workspace, else $HOME) so the
-- user can open a terminal inside a chosen folder. Uses `ls -d */` (fast, one
-- level) rather than `find`, which can crawl slowly over a large home dir.
-- Expand leading ~ and join a relative path onto the workspace base so
-- breadcrumb navigation works from any spelling: `~/Builds`, `Builds`,
-- `/abs/path`. Trailing slash preserved. Returns an absolute path.
local function resolvePath(p)
local base = noctalia.getConfig("default_workspace")
if type(base) ~= "string" or base == "" then
base = noctalia.getenv("HOME") or "/"
end
if type(p) ~= "string" or (p:match("^%s*$")) then
return base
end
p = p:gsub("^~/+", (noctalia.getenv("HOME") or "/") .. "/")
if p:sub(1, 1) ~= "/" then
-- Relative (or empty) — resolve against workspace base.
p = base .. "/" .. p:gsub("^/+", "")
end
return p
end
-- List subdirectories to navigate into, breadcrumb-style. The query after
-- `cd` is a path you're halfway through typing: the parent directory is listed
-- and the typed prefix filters it. Selecting a folder fills the input with that
-- path (cd:<path>/), letting you drill deeper or continue typing.
--
-- /sh cd -> list top-level dirs of the workspace
-- /sh cd proj -> list workspace dirs starting with "proj"
-- /sh cd ~/Builds -> list ~/Builds sub-entries starting with the rest
-- /sh cd ~/Builds/ -> list everything directly inside ~/Builds
local function listDirs(query)
local pathPart = query:match("^cd%s*(.*)$") or ""
local parent, prefix
if pathPart == "" then
-- Root of the workspace, and nothing typed to drill before it.
parent, prefix = resolvePath(""), ""
elseif pathPart:match("[/]$") then
-- Ends with a slash: navigate into an existing directory.
parent, prefix = resolvePath(pathPart), ""
else
local slash = pathPart:match("^.*/")
if slash then
-- Mid-path: split at the last slash into parent + typed prefix.
local base2 = resolvePath(slash:gsub("/$", ""))
parent = base2
prefix = pathPart:sub(#slash + 1):gsub("^%s", "")
else
-- Bare first segment — resolve against base, prefix is the whole pathPart.
parent, prefix = resolvePath(""), pathPart
end
end
local dirs = {}
local names = noctalia.listDir(parent) or {}
table.sort(names)
for _, name in ipairs(names) do
if name ~= "." and name ~= ".." and (prefix == "" or name:sub(1, #prefix) == prefix) then
local info = noctalia.fileInfo(parent .. "/" .. name)
if info and info.isDir then
dirs[#dirs + 1] = { path = parent .. "/" .. name, name = name }
end
end
end
return dirs, parent
end
local pendingQuery = nil
function onQuery(query)
query = trim(query)
pendingQuery = query
if query == "" then
-- Empty query: hint, then user snippets, then recent commands.
local results = {
{
id = "",
title = noctalia.tr("hint_title"),
subtitle = noctalia.tr("hint_subtitle"),
glyph = "terminal",
},
}
local snippets = noctalia.getConfig("snippets")
if type(snippets) == "table" then
for _, snippet in ipairs(snippets) do
snippet = trim(snippet)
if snippet ~= "" then
results[#results + 1] = {
id = "fill:" .. snippet,
title = snippet,
subtitle = noctalia.tr("snippet_subtitle"),
glyph = "bookmark",
}
end
end
end
for i = 1, math.min(MAX_SUGGESTIONS, #history) do
results[#results + 1] = {
id = "fill:" .. history[i],
title = history[i],
subtitle = noctalia.tr("history_subtitle"),
glyph = "history",
}
end
launcher.setResults(query, results)
return
end
-- cd mode: breadcrumb folder navigation. Folder rows fill input with
-- trailing-slash path (`fill:`) so Enter drills one level deeper and can
-- keep going. First "Open in:" row launches terminal directly in the
-- current parent dir (`cdgo:`).
-- A `cd <path> && <cmd>` compound launches a real command (navigation +
-- execution), not folder navigation. Skip the cd branch so it reaches the
-- run path, where buildCommand parses and executes it.
local isCompound = query:match("^%s*cd%s+.+&&.+$")
if not isCompound and (query == "cd" or query:match("^cd%s")) then
local dirs, parent = listDirs(query)
local results = {}
results[#results + 1] = {
id = "cdgo:" .. parent,
title = noctalia.tr("cd_open_title", { path = parent }),
subtitle = noctalia.tr("cd_open_subtitle"),
glyph = "folder-open",
}
for _, dir in ipairs(dirs) do
results[#results + 1] = {
id = "fill:cd " .. dir.path .. "/",
title = dir.name,
subtitle = dir.path,
glyph = "folder",
}
end
if #dirs == 0 then
results[#results + 1] = {
id = "",
title = noctalia.tr("cd_empty"),
subtitle = noctalia.tr("cd_empty_subtitle"),
glyph = "folder",
}
end
launcher.setResults(query, results)
return
end
local token = firstToken(query)
local subtitle = noctalia.tr("run_subtitle")
if token ~= "" and noctalia.commandExists(token) then
subtitle = noctalia.tr("run_subtitle_found", { command = token })
end
-- Show the exact command immediately, then fill in suggestions async.
launcher.setResults(query, { runEntry(query, subtitle) })
fetchCompletions(query, function(comps)
if pendingQuery ~= query then
return -- a newer query superseded this one
end
local results = { runEntry(query, subtitle) }
-- Snap-complete: a single distinct expanding candidate (a completion that
-- actually extends the typed text) becomes the one Enter action — run the
-- completed command directly without first picking a fill row. Multiple
-- candidates: keep normal suggestions. Uses its own seen-table so the
-- suggestion pass below still emits the full list.
local seenScan, single, count = { [query] = true }, nil, 0
for _, comp in ipairs(comps) do
local full = fullCommand(query, comp)
if full ~= query and not seenScan[full] then
seenScan[full] = true
single, count = full, count + 1
end
end
if count == 1 and single then
results = { runEntry(single, noctalia.tr("snap_subtitle", { command = single })) }
end
local seen = { [query] = true }
for _, comp in ipairs(comps) do
local full = fullCommand(query, comp)
if not seen[full] then
seen[full] = true
results[#results + 1] = {
id = "fill:" .. full,
title = full,
subtitle = noctalia.tr("suggest_subtitle"),
glyph = "lightbulb",
}
end
if #results >= 1 + MAX_SUGGESTIONS then
break
end
end
-- History matches, ranked after shell completions.
for _, cmd in ipairs(history) do
if cmd:sub(1, #query) == query and not seen[cmd] then
seen[cmd] = true
results[#results + 1] = {
id = "fill:" .. cmd,
title = cmd,
subtitle = noctalia.tr("history_subtitle"),
glyph = "history",
}
end
if #results >= 1 + MAX_SUGGESTIONS then
break
end
end
launcher.setResults(query, results)
end)
end
function onActivate(id)
if id == "" then
return
end
local kind, value = id:match("^(%a+):(.+)$")
if not kind or not value then
noctalia.log("shell-command: onActivate received malformed id: " .. tostring(id))
return
end
if kind == "fill" then
launcher.setQuery(value)
elseif kind == "run" then
recordHistory(value)
noctalia.runInTerminal(buildCommand(value))
elseif kind == "cdgo" then
noctalia.runInTerminal(buildCdCommand(value))
else
noctalia.log("shell-command: unknown activation kind: " .. kind)
end
end
loadHistory()
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

+25
View File
@@ -0,0 +1,25 @@
{
"hint_title": "Run a shell command",
"hint_subtitle": "Type a command after /sh and press Enter to open it in your terminal",
"run_title": "Run: {command}",
"run_subtitle": "Opens in your default terminal",
"run_subtitle_found": "{command} found — opens in your default terminal",
"history_subtitle": "Recent command",
"suggest_subtitle": "Suggestion",
"snap_subtitle": "Complete: {command} — Enter to run",
"snippet_subtitle": "Snippet",
"cd_open_title": "Open in: {path}",
"cd_open_subtitle": "Opens terminal in this folder",
"cd_empty": "No folders found",
"cd_empty_subtitle": "Type path or check workspace setting",
"settings": {
"default_workspace": {
"label": "Default Workspace",
"description": "Working directory commands run in. Leave empty to use the terminal's current directory."
},
"snippets": {
"label": "Snippets",
"description": "Commands shown when /sh is empty. Each entry is one command."
}
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"hint_title": "Chạy lệnh shell",
"hint_subtitle": "Gõ lệnh sau /sh rồi nhấn Enter để mở trong terminal",
"run_title": "Chạy: {command}",
"run_subtitle": "Mở trong terminal mặc định",
"run_subtitle_found": "Tìm thấy {command} — mở trong terminal mặc định",
"history_subtitle": "Lệnh gần đây",
"suggest_subtitle": "Gợi ý",
"snap_subtitle": "Hoàn tất: {command} — Enter để chạy",
"snippet_subtitle": "Snippet",
"cd_open_title": "Mở trong: {path}",
"cd_open_subtitle": "Mở terminal trong thư mục này",
"cd_empty": "Không tìm thấy thư mục",
"cd_empty_subtitle": "Gõ đường dẫn hoặc kiểm tra cài đặt workspace",
"settings": {
"default_workspace": {
"label": "Thư mục làm việc",
"description": "Thư mục lệnh chạy trong đó. Để trống dùng thư mục hiện tại của terminal."
},
"snippets": {
"label": "Snippets",
"description": "Lệnh hiện khi /sh trống. Mỗi mục là một lệnh."
}
}
}