diff --git a/.gitignore b/.gitignore index dc4314e3..4351fabf 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,6 @@ ty-chrome.zip .pr-body.md .pr-comment.md .qacfg/ + +# Plugin user config (copied from *.example.env) +examples/plugins/**/config.env diff --git a/README.md b/README.md index 5c433c27..db639f5c 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ The same UI is also served in your browser at `http://localhost:8484` whenever ` - **Git Worktrees** - Each task runs in an isolated worktree, no conflicts between parallel tasks - **Pluggable Executors** - Choose between Claude Code, OpenAI Codex, Gemini, Pi, OpenClaw, or OpenCode per task - **Workflows** - Turn one goal into a multi-step DAG (e.g. plan → code → parallel review → collect), each step on its own executor/model, advancing automatically (see [Workflows](#workflows)) -- **Event Hooks** - Run scripts when tasks change state (see [Event Hooks](#event-hooks)) +- **Event Hooks & Plugins** - Run scripts when tasks change state, or drop in self-contained plugins (see [Event Hooks](#event-hooks) and [Plugins](#plugins)) - **Ghost Text Autocomplete** - LLM-powered suggestions for task titles and descriptions as you type - **VS Code-style Fuzzy Search** - Quick task navigation with smart matching (e.g., "dsno" matches "diseno website") - **Markdown Rendering** - Task descriptions render with proper formatting in the detail view @@ -630,6 +630,24 @@ TASK_TIMESTAMP # ISO 8601 timestamp See [examples/hooks/](examples/hooks/) for examples. +### Plugins + +The hooks dir above holds **one script per event**, so two integrations that +both want `task.done` collide. **Plugins** solve that: a plugin is a +self-contained directory under `~/.config/task/plugins/` with a `plugin.yaml` +manifest declaring which events it handles. Drop it in and it's live — any +number of plugins can handle the same event, and all of them run. + +```bash +cp -R examples/plugins/desktop-notify ~/.config/task/plugins/ +ty plugins list +``` + +See [docs/plugins.md](docs/plugins.md) for the manifest format and the authoring +guide, [`examples/plugins/`](examples/plugins/) for ready-to-copy plugins +(desktop-notify, slack, worktree), and [docs/plugin-ideas.md](docs/plugin-ideas.md) +for a gallery of things to build. + ## Configuration ### Settings diff --git a/cmd/task/main.go b/cmd/task/main.go index 2d4d5dfd..55c60c7b 100644 --- a/cmd/task/main.go +++ b/cmd/task/main.go @@ -513,6 +513,9 @@ Examples: rootCmd.AddCommand(sessionsCmd) + // Plugins subcommand - inspect installed task plugins + rootCmd.AddCommand(newPluginsCmd()) + // Alias: claudes -> sessions (for backwards compatibility) claudesCmd := &cobra.Command{ Use: "claudes", diff --git a/cmd/task/plugins.go b/cmd/task/plugins.go new file mode 100644 index 00000000..c1bd37c7 --- /dev/null +++ b/cmd/task/plugins.go @@ -0,0 +1,142 @@ +package main + +import ( + "context" + "fmt" + "os" + "sort" + "strconv" + + "github.com/spf13/cobra" + + "github.com/bborn/workflow/internal/db" + "github.com/bborn/workflow/internal/hooks" +) + +// newPluginsCmd returns the `ty plugins` command group for inspecting installed +// task plugins (self-contained hook bundles under ~/.config/task/plugins/). +func newPluginsCmd() *cobra.Command { + pluginsCmd := &cobra.Command{ + Use: "plugins", + Short: "List and inspect installed task plugins", + Long: `Task plugins are self-contained directories under ~/.config/task/plugins/ +that react to task events. Each plugin has a plugin.yaml manifest declaring +which events it handles; drop a plugin directory in and it is active. Any number +of plugins may handle the same event.`, + Run: func(cmd *cobra.Command, args []string) { + listPlugins() + }, + } + + pluginsCmd.AddCommand(&cobra.Command{ + Use: "list", + Short: "List installed plugins and the events they handle", + Run: func(cmd *cobra.Command, args []string) { listPlugins() }, + }) + + pluginsCmd.AddCommand(&cobra.Command{ + Use: "dir", + Short: "Print the plugins directory path", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println(hooks.DefaultPluginsDir()) + }, + }) + + runCmd := &cobra.Command{ + Use: "run [task-id]", + Short: "Run a plugin action, optionally in the context of a task", + Args: cobra.RangeArgs(2, 3), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + var taskID int64 + if len(args) == 3 { + id, err := strconv.ParseInt(args[2], 10, 64) + if err != nil { + return fmt.Errorf("invalid task id %q: %w", args[2], err) + } + taskID = id + } + return runPluginAction(cmd.Context(), args[0], args[1], taskID) + }, + } + pluginsCmd.AddCommand(runCmd) + + return pluginsCmd +} + +func runPluginAction(ctx context.Context, pluginName, actionID string, taskID int64) error { + plugins, warnings := hooks.LoadPlugins(hooks.DefaultPluginsDir()) + for _, w := range warnings { + fmt.Fprintln(os.Stderr, "warning: "+w) + } + + plugin, action, err := hooks.FindAction(plugins, pluginName, actionID) + if err != nil { + return err + } + + // Load the task for context, if one was named. + var task *db.Task + if taskID != 0 { + database, dberr := openTaskDB(db.DefaultPath()) + if dberr != nil { + return dberr + } + defer database.Close() + task, err = database.GetTask(taskID) + if err != nil { + return fmt.Errorf("task #%d: %w", taskID, err) + } + } + + out, runErr := hooks.RunAction(ctx, plugin, action, task) + if len(out) > 0 { + fmt.Print(string(out)) + if out[len(out)-1] != '\n' { + fmt.Println() + } + } + if runErr != nil { + return fmt.Errorf("action %s/%s failed: %w", pluginName, actionID, runErr) + } + return nil +} + +func listPlugins() { + dir := hooks.DefaultPluginsDir() + plugins, warnings := hooks.LoadPlugins(dir) + + for _, w := range warnings { + fmt.Fprintln(os.Stderr, "warning: "+w) + } + + if len(plugins) == 0 { + fmt.Printf("No plugins installed in %s\n", dir) + fmt.Println("Add one by creating /plugin.yaml there. See docs/plugins.md.") + return + } + + fmt.Printf("Plugins in %s:\n\n", dir) + for _, p := range plugins { + ver := p.Version + if ver == "" { + ver = "—" + } + fmt.Printf(" %s (%s)\n", p.Name, ver) + if p.Description != "" { + fmt.Printf(" %s\n", p.Description) + } + events := make([]string, 0, len(p.Hooks)) + for e := range p.Hooks { + events = append(events, e) + } + sort.Strings(events) + for _, e := range events { + fmt.Printf(" hook %-18s → %s\n", e, p.Hooks[e]) + } + for _, a := range p.Actions { + fmt.Printf(" action %-18s → %s (ty plugins run %s %s)\n", a.DisplayLabel(), a.Command, p.Name, a.ID) + } + fmt.Println() + } +} diff --git a/docs/plugin-ideas.md b/docs/plugin-ideas.md new file mode 100644 index 00000000..caa138a3 --- /dev/null +++ b/docs/plugin-ideas.md @@ -0,0 +1,62 @@ +# Plugin ideas + +A menu of things TaskYou plugins can do, to seed community (and your own) +plugins. Everything here is buildable on today's contract — [hooks](plugins.md) +(fire on task events) and [actions](plugins.md#actions-user-invoked) (run on +demand with the task's env). A plugin is just a directory with executables; it +can be any language and can bundle its own config/binaries. + +✅ = shipped as an example in [`examples/plugins/`](../examples/plugins/). + +## Notifications & awareness (hooks) + +- ✅ **desktop-notify** — native OS notification on done/blocked/failed. +- ✅ **slack** — post task updates to a Slack channel (incoming webhook). +- **sound** — play a chime on `task.done`, a different one on `task.failed` + (`afplay`/`paplay`/terminal bell). ~10 lines. +- **say / TTS** — `say "task 42 is done"` (macOS) or `espeak`. +- **phone push** — ntfy.sh / Pushover / Telegram bot for away-from-desk pings. +- **auth-alert** — loud, unmissable alert on `task.auth_required` (the executor + needs re-login and everything is stalled until you notice). + +## Logging & analytics (hooks) + +- **webhook** — POST the task-event JSON to a configured URL. The universal + primitive; every integration above is a specialization of this. +- **timetrack** — append one JSONL row per event; derive cycle time, throughput, + blocked-rate over time. A personal productivity ledger. +- **status-file** — maintain a tiny JSON of live counts for a tmux statusline or + menubar widget. + +## Worktree & quality (actions) + +- ✅ **worktree** — show the task's diff; run its tests. +- **lint** — run the project linter in the worktree, surface pass/fail. +- **format** — run the formatter and report what changed. +- **branch-copy** — copy the task's branch name / PR URL to the clipboard. + +## Integrations (hooks + actions) + +- **linear / jira / github-issue** — on `task.done`, comment or transition the + linked issue; an action to open it. (Needs an id convention + API token in the + plugin's `config.env`.) +- **calendar / timeblock** — log completed tasks to a calendar. +- **journal** — append a daily standup line ("finished #42: …") to a notes file. + +## Ambient / fun + +- **confetti** — a celebratory splash (or `cowsay`) on `task.done`. +- **now-playing** — pause music while an executor is actively running. + +## Where should plugins live? (in-repo vs. own repo) + +- **In-repo `examples/plugins/`** — small, canonical, copy-paste starting points + that ship with TaskYou and are covered by the loader's tests. The three above + live here. Best for anything short enough to read in one sitting. +- **Its own repo** — when a plugin grows an independent release cadence, ships a + compiled binary or heavier dependencies, or has a real surface of its own + (config, docs, versioning). Install by dropping (or symlinking) its directory + into `~/.config/task/plugins/`. This is where a bigger integration — say a + token-compressing proxy or a full issue-tracker sync — belongs. + +Rule of thumb: **start in `examples/`; graduate to a repo when it earns one.** diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 00000000..fafb676a --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,136 @@ +# TaskYou plugins + +A plugin is a **self-contained directory** that reacts to task events. Drop it +into `~/.config/task/plugins/` and it's live — no rebuild, no config edits, and +no collisions with other plugins. This is the easy on-ramp for community +integrations (notifications, proxies, trackers, chat bridges, …). + +It builds on TaskYou's existing [event hooks](../README.md#event-hooks). The +difference: the legacy hooks dir allows **one script per event**, so two +integrations that both want `task.done` fight over the same file. A plugin +namespaces its scripts in its own directory and declares what it handles in a +manifest, so **any number of plugins can handle the same event** and all of them +run. + +## Anatomy + +``` +~/.config/task/plugins/ +└── my-plugin/ + ├── plugin.yaml # manifest (required) + └── on-done.sh # your script(s) +``` + +### `plugin.yaml` + +```yaml +name: my-plugin # required, unique +version: 0.1.0 # optional +description: What it does. # optional +hooks: # event -> script path (relative to this dir) + task.done: on-done.sh + task.blocked: on-blocked.sh +actions: # user-invoked commands (optional) + - id: sync + label: Sync to tracker + command: sync.sh +``` + +Script paths are resolved relative to the plugin directory. Scripts must be +executable (`chmod +x`). A plugin needs at least one usable hook **or** action. + +## Events + +Plugins handle the same events as the [event hooks](../README.md#event-hooks) +system. The ones dispatched today: + +| Event | When | +|-------|------| +| `task.started` | Execution begins | +| `task.done` | Agent finished successfully | +| `task.blocked` | Task needs input | +| `task.failed` | Agent execution failed | +| `task.auth_required` | Executor session needs re-authentication | + +A plugin may declare any event string; it only runs for events TaskYou actually +emits, so unknown events are harmless. + +## Environment + +Every hook receives the standard task variables: + +``` +TASK_ID TASK_TITLE TASK_STATUS TASK_PROJECT TASK_TYPE +TASK_MESSAGE TASK_EVENT WORKTREE_PATH +``` + +Plugin hooks additionally receive: + +``` +TASK_PLUGIN_NAME # this plugin's name +TASK_PLUGIN_DIR # absolute path to this plugin's directory +``` + +The script's working directory is set to the plugin directory, so it can read +its own bundled files (config, templates, helper binaries) with relative paths. + +## Actions (user-invoked) + +Hooks fire automatically on events. **Actions** are commands the user triggers on +demand. Each action has an `id`, an optional `label`, and a `command` (a script +path relative to the plugin dir). + +Run one from the CLI, optionally against a task: + +```bash +ty plugins run my-plugin sync # no task context +ty plugins run my-plugin sync 42 # with task #42's env +``` + +An action script receives `TASK_PLUGIN_NAME` / `TASK_PLUGIN_DIR` always, and the +`TASK_*` variables (`TASK_ID`, `TASK_TITLE`, `TASK_STATUS`, `TASK_PROJECT`, +`TASK_TYPE`, `WORKTREE_PATH`) when a task is in context. Unlike hooks, actions +run synchronously (up to 60s) and their output is shown to the caller (the CLI +prints it; the TUI shows the first line in the notification banner). + +In the TUI, actions are reachable two ways, both running the same command: + +- **Detail view:** press `A` on a task to open a picker of that task's plugin + actions; the chosen one runs with the task's env. +- **Command palette:** open it (`p` / `Ctrl+P`) and type a leading `>` to switch + from task search to action search. + +## Behavior & guarantees + +- **Fan-out**: for each event, the legacy single-script hook *and* every plugin + that declares the event run, concurrently and in the background. A slow or + failing plugin never blocks task execution or the other plugins. +- **Isolation**: a malformed manifest, a missing script, or a plugin with no + name is skipped (surfaced via `ty plugins list` and the daemon log) — one bad + community plugin can't break your pipeline. +- **Timeout**: each hook is given 30s before it's killed. +- **Deterministic order**: plugins run sorted by name. + +## Inspecting + +```bash +ty plugins list # what's installed and which events each handles +ty plugins dir # the plugins directory path +``` + +## Examples + +Complete, copy-pasteable plugins live in [`examples/plugins/`](../examples/plugins/): + +| Plugin | Kind | What it shows | +|---|---|---| +| [`desktop-notify`](../examples/plugins/desktop-notify/) | hooks + action | native notifications; a `test` action | +| [`slack`](../examples/plugins/slack/) | hooks | webhook integration; bundled `config.env` | +| [`worktree`](../examples/plugins/worktree/) | actions | task-scoped `diff` / `test` using `WORKTREE_PATH` | + +```bash +cp -R examples/plugins/desktop-notify ~/.config/task/plugins/ +ty plugins list +``` + +For more, see the [plugin idea gallery](plugin-ideas.md). diff --git a/examples/plugins/desktop-notify/notify.sh b/examples/plugins/desktop-notify/notify.sh new file mode 100755 index 00000000..a0949b14 --- /dev/null +++ b/examples/plugins/desktop-notify/notify.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Desktop-notify plugin: fire a native notification on a task event. +# +# TaskYou exports these for every hook: +# TASK_ID TASK_TITLE TASK_STATUS TASK_PROJECT TASK_TYPE +# TASK_MESSAGE TASK_EVENT WORKTREE_PATH +# and for plugin hooks specifically: +# TASK_PLUGIN_NAME TASK_PLUGIN_DIR +set -euo pipefail + +title="TaskYou: ${TASK_EVENT#task.}" +body="#${TASK_ID} ${TASK_TITLE} — ${TASK_MESSAGE:-}" + +case "$(uname -s)" in + Darwin) + osascript -e "display notification \"${body//\"/\\\"}\" with title \"${title}\"" || true + ;; + Linux) + command -v notify-send >/dev/null 2>&1 && notify-send "$title" "$body" || true + ;; + *) + echo "[$title] $body" + ;; +esac diff --git a/examples/plugins/desktop-notify/plugin.yaml b/examples/plugins/desktop-notify/plugin.yaml new file mode 100644 index 00000000..ed5afe86 --- /dev/null +++ b/examples/plugins/desktop-notify/plugin.yaml @@ -0,0 +1,21 @@ +# A TaskYou plugin manifest. +# +# Copy this directory to ~/.config/task/plugins/desktop-notify/ and it is live — +# no code changes, no collision with other plugins that also handle these events. +name: desktop-notify +version: 0.1.0 +description: Native desktop notifications when a task finishes or needs you. + +# event name -> script path (relative to this plugin directory). +# Run `ty plugins list` to see what was discovered. +hooks: + task.done: notify.sh + task.blocked: notify.sh + task.failed: notify.sh + +# user-invoked commands: `ty plugins run desktop-notify test`, +# or from the detail-view picker / command palette. +actions: + - id: test + label: Send a test notification + command: test-notify.sh diff --git a/examples/plugins/desktop-notify/test-notify.sh b/examples/plugins/desktop-notify/test-notify.sh new file mode 100755 index 00000000..18a52bd8 --- /dev/null +++ b/examples/plugins/desktop-notify/test-notify.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# desktop-notify action: fire a test notification on demand. +# +# Actions get TASK_PLUGIN_NAME / TASK_PLUGIN_DIR always, and the TASK_* vars +# (TASK_ID, TASK_TITLE, …) only when invoked with a task in context. +set -euo pipefail + +title="TaskYou: ${TASK_PLUGIN_NAME:-plugin} test" +if [[ -n "${TASK_ID:-}" ]]; then + body="Wired up for #${TASK_ID} ${TASK_TITLE:-}" +else + body="Notifications are wired up." +fi + +case "$(uname -s)" in + Darwin) osascript -e "display notification \"${body//\"/\\\"}\" with title \"${title}\"" || true ;; + Linux) command -v notify-send >/dev/null 2>&1 && notify-send "$title" "$body" || true ;; + *) echo "[$title] $body" ;; +esac +echo "sent test notification" diff --git a/examples/plugins/slack/config.example.env b/examples/plugins/slack/config.example.env new file mode 100644 index 00000000..e691154a --- /dev/null +++ b/examples/plugins/slack/config.example.env @@ -0,0 +1,3 @@ +# Copy to config.env (next to this file) and fill in. +# Create an incoming webhook at https://api.slack.com/messaging/webhooks +SLACK_WEBHOOK_URL=https://hooks.slack.com/services/XXXX/YYYY/ZZZZ diff --git a/examples/plugins/slack/notify.sh b/examples/plugins/slack/notify.sh new file mode 100755 index 00000000..cb6e2171 --- /dev/null +++ b/examples/plugins/slack/notify.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# Slack notification hook. Reads SLACK_WEBHOOK_URL from the environment or from +# config.env bundled next to this script (TASK_PLUGIN_DIR). Fails quietly (exit +# 0) when unconfigured so it never blocks the task pipeline. +set -euo pipefail + +# Load bundled config, if present. cwd is already the plugin dir, but be explicit. +if [[ -n "${TASK_PLUGIN_DIR:-}" && -f "$TASK_PLUGIN_DIR/config.env" ]]; then + # shellcheck disable=SC1091 + source "$TASK_PLUGIN_DIR/config.env" +fi + +if [[ -z "${SLACK_WEBHOOK_URL:-}" ]]; then + echo "slack plugin: SLACK_WEBHOOK_URL not set (see config.example.env)" >&2 + exit 0 +fi + +# Pick an emoji per event. +case "${TASK_EVENT:-}" in + task.done) emoji=":white_check_mark:" ;; + task.blocked) emoji=":raised_hand:" ;; + task.failed) emoji=":x:" ;; + *) emoji=":information_source:" ;; +esac + +# Minimal JSON string escaping (backslash + double-quote). +esc() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; } + +event="${TASK_EVENT#task.}" +header="$emoji *#${TASK_ID:-?} ${TASK_TITLE:-untitled}* — ${event}" +detail="${TASK_MESSAGE:-}" +project="${TASK_PROJECT:-}" +[[ -n "$project" ]] && header="$header \`${project}\`" + +text="$header" +[[ -n "$detail" ]] && text="$text\n${detail}" + +payload=$(printf '{"text":"%s"}' "$(esc "$text")") + +curl -sS -X POST -H 'Content-type: application/json' --data "$payload" "$SLACK_WEBHOOK_URL" >/dev/null \ + || echo "slack plugin: webhook POST failed" >&2 diff --git a/examples/plugins/slack/plugin.yaml b/examples/plugins/slack/plugin.yaml new file mode 100644 index 00000000..18722f06 --- /dev/null +++ b/examples/plugins/slack/plugin.yaml @@ -0,0 +1,14 @@ +# Slack notifications for task events. +# +# Setup: +# cp -R examples/plugins/slack ~/.config/task/plugins/ +# cp ~/.config/task/plugins/slack/config.example.env \ +# ~/.config/task/plugins/slack/config.env +# # edit config.env and paste your Slack incoming-webhook URL +name: slack +version: 0.1.0 +description: Post task updates to a Slack channel via an incoming webhook. +hooks: + task.done: notify.sh + task.blocked: notify.sh + task.failed: notify.sh diff --git a/examples/plugins/worktree/diff.sh b/examples/plugins/worktree/diff.sh new file mode 100755 index 00000000..97c7dbfb --- /dev/null +++ b/examples/plugins/worktree/diff.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Show a compact summary of the task's worktree changes. +# WORKTREE_PATH is provided when the action is run with a task in context. +set -euo pipefail + +wt="${WORKTREE_PATH:-}" +if [[ -z "$wt" || ! -d "$wt" ]]; then + echo "no worktree for this task" + exit 0 +fi +cd "$wt" + +changed=$(git status --porcelain | wc -l | tr -d ' ') +stat=$(git diff --stat 2>/dev/null | tail -1 | sed 's/^ *//') + +if [[ "$changed" == "0" && -z "$stat" ]]; then + echo "worktree clean" + exit 0 +fi + +# First line is what the TUI banner shows; the rest is visible in the CLI. +echo "${stat:-$changed uncommitted file(s)}" +echo +git status --short diff --git a/examples/plugins/worktree/plugin.yaml b/examples/plugins/worktree/plugin.yaml new file mode 100644 index 00000000..0675ef13 --- /dev/null +++ b/examples/plugins/worktree/plugin.yaml @@ -0,0 +1,14 @@ +# Task-scoped worktree helpers, invoked on demand: +# - detail view: press A, pick one +# - palette: type ">" then search +# - CLI: ty plugins run worktree diff +name: worktree +version: 0.1.0 +description: Inspect a task's worktree — show its diff, or run its tests. +actions: + - id: diff + label: Show worktree diff + command: diff.sh + - id: test + label: Run tests in worktree + command: test.sh diff --git a/examples/plugins/worktree/test.sh b/examples/plugins/worktree/test.sh new file mode 100755 index 00000000..83abeaf7 --- /dev/null +++ b/examples/plugins/worktree/test.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Run the task worktree's tests. Uses $TEST_CMD if set (export it, or put it in +# a config.env next to this script); otherwise auto-detects by project type. +set -euo pipefail + +wt="${WORKTREE_PATH:-}" +if [[ -z "$wt" || ! -d "$wt" ]]; then + echo "no worktree for this task" + exit 0 +fi + +if [[ -n "${TASK_PLUGIN_DIR:-}" && -f "$TASK_PLUGIN_DIR/config.env" ]]; then + # shellcheck disable=SC1091 + source "$TASK_PLUGIN_DIR/config.env" +fi + +cd "$wt" + +cmd="${TEST_CMD:-}" +if [[ -z "$cmd" ]]; then + if [[ -f go.mod ]]; then + cmd="go test ./..." + elif [[ -f package.json ]]; then + cmd="npm test" + elif [[ -f Rakefile || -d spec ]]; then + cmd="bundle exec rspec" + else + echo "no TEST_CMD set and could not detect project type" + exit 0 + fi +fi + +echo "running: $cmd" +if eval "$cmd"; then + echo "tests passed" +else + echo "tests FAILED" + exit 1 +fi diff --git a/internal/hooks/actions.go b/internal/hooks/actions.go new file mode 100644 index 00000000..e387fb1e --- /dev/null +++ b/internal/hooks/actions.go @@ -0,0 +1,70 @@ +package hooks + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/bborn/workflow/internal/db" +) + +// ActionTimeout bounds how long a user-invoked action may run. Actions are +// synchronous (their output is shown to the user), so this is more generous than +// the fire-and-forget hook timeout. +const ActionTimeout = 60 * time.Second + +// ActionEnv builds the environment for a plugin action. task may be nil when an +// action is invoked without a task in context; the TASK_* vars are then omitted. +func ActionEnv(p Plugin, task *db.Task) []string { + env := os.Environ() + if task != nil { + env = append(env, + fmt.Sprintf("TASK_ID=%d", task.ID), + fmt.Sprintf("TASK_TITLE=%s", task.Title), + fmt.Sprintf("TASK_STATUS=%s", task.Status), + fmt.Sprintf("TASK_PROJECT=%s", task.Project), + fmt.Sprintf("TASK_TYPE=%s", task.Type), + fmt.Sprintf("WORKTREE_PATH=%s", task.WorktreePath), + ) + } + env = append(env, + "TASK_PLUGIN_NAME="+p.Name, + "TASK_PLUGIN_DIR="+p.Dir, + ) + return env +} + +// RunAction runs a plugin action to completion and returns its combined output. +// It is synchronous by design: actions are user-triggered and their result is +// surfaced back to the user (CLI stdout, a TUI banner, etc.). +func RunAction(ctx context.Context, p Plugin, a Action, task *db.Task) ([]byte, error) { + script := filepath.Join(p.Dir, a.Command) + if !isExecutableFile(script) { + return nil, fmt.Errorf("action %q: script %s not found", a.ID, a.Command) + } + + ctx, cancel := context.WithTimeout(ctx, ActionTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, script) + cmd.Dir = p.Dir + cmd.Env = ActionEnv(p, task) + return cmd.CombinedOutput() +} + +// FindAction locates a plugin and action by name/id across the given plugins. +func FindAction(plugins []Plugin, pluginName, actionID string) (Plugin, Action, error) { + for _, p := range plugins { + if p.Name != pluginName { + continue + } + if a, ok := p.Action(actionID); ok { + return p, a, nil + } + return Plugin{}, Action{}, fmt.Errorf("plugin %q has no action %q", pluginName, actionID) + } + return Plugin{}, Action{}, fmt.Errorf("no plugin named %q", pluginName) +} diff --git a/internal/hooks/actions_test.go b/internal/hooks/actions_test.go new file mode 100644 index 00000000..33ad919a --- /dev/null +++ b/internal/hooks/actions_test.go @@ -0,0 +1,110 @@ +package hooks + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/bborn/workflow/internal/db" +) + +func TestLoadPlugins_ActionsValidatedAndKept(t *testing.T) { + root := t.TempDir() + manifest := "name: acty\n" + + "actions:\n" + + " - id: greet\n" + + " label: Say hi\n" + + " command: greet.sh\n" + + " - id: broken\n" + // script missing -> dropped + " command: missing.sh\n" + + " - label: no-id\n" + // no id -> dropped + " command: greet.sh\n" + writePlugin(t, root, "acty", manifest, map[string]string{"greet.sh": "#!/bin/sh\necho hi\n"}) + + plugins, warnings := LoadPlugins(root) + if len(plugins) != 1 { + t.Fatalf("got %d plugins, want 1", len(plugins)) + } + if len(plugins[0].Actions) != 1 || plugins[0].Actions[0].ID != "greet" { + t.Fatalf("kept actions = %+v, want only 'greet'", plugins[0].Actions) + } + if plugins[0].Actions[0].DisplayLabel() != "Say hi" { + t.Errorf("DisplayLabel = %q", plugins[0].Actions[0].DisplayLabel()) + } + if len(warnings) != 1 { + t.Errorf("want 1 warning about dropped actions, got %v", warnings) + } +} + +func TestLoadPlugins_ActionsOnlyPluginIsValid(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "actiononly", + "name: actiononly\nactions:\n - id: go\n command: go.sh\n", + map[string]string{"go.sh": "#!/bin/sh\n"}) + + plugins, _ := LoadPlugins(root) + if len(plugins) != 1 { + t.Fatalf("actions-only plugin should load; got %d", len(plugins)) + } + if len(plugins[0].Hooks) != 0 { + t.Errorf("expected no hooks") + } +} + +func TestRunAction_InjectsTaskAndPluginEnv(t *testing.T) { + root := t.TempDir() + script := "#!/bin/sh\necho \"$TASK_PLUGIN_NAME|$TASK_ID|$TASK_TITLE\"\n" + dir := writePlugin(t, root, "p", "name: p\nactions:\n - id: a\n command: a.sh\n", + map[string]string{"a.sh": script}) + + p := Plugin{Name: "p", Dir: dir, Actions: []Action{{ID: "a", Command: "a.sh"}}} + task := &db.Task{ID: 7, Title: "hello"} + + out, err := RunAction(context.Background(), p, p.Actions[0], task) + if err != nil { + t.Fatalf("RunAction: %v (%s)", err, out) + } + if got := strings.TrimSpace(string(out)); got != "p|7|hello" { + t.Errorf("action output = %q, want %q", got, "p|7|hello") + } +} + +func TestRunAction_NilTaskOmitsTaskVars(t *testing.T) { + root := t.TempDir() + // set -u would fail on unset TASK_ID; use default-expansion to prove it's unset. + script := "#!/bin/sh\nset -u\necho \"plugin=$TASK_PLUGIN_NAME id=${TASK_ID:-none}\"\n" + dir := writePlugin(t, root, "p", "name: p\nactions:\n - id: a\n command: a.sh\n", + map[string]string{"a.sh": script}) + + p := Plugin{Name: "p", Dir: dir, Actions: []Action{{ID: "a", Command: "a.sh"}}} + out, err := RunAction(context.Background(), p, p.Actions[0], nil) + if err != nil { + t.Fatalf("RunAction: %v (%s)", err, out) + } + if got := strings.TrimSpace(string(out)); got != "plugin=p id=none" { + t.Errorf("action output = %q, want %q", got, "plugin=p id=none") + } +} + +func TestFindAction(t *testing.T) { + plugins := []Plugin{{Name: "p", Actions: []Action{{ID: "a"}}}} + + if _, a, err := FindAction(plugins, "p", "a"); err != nil || a.ID != "a" { + t.Errorf("FindAction(p,a) = %+v, %v", a, err) + } + if _, _, err := FindAction(plugins, "p", "nope"); err == nil { + t.Error("expected error for unknown action") + } + if _, _, err := FindAction(plugins, "nope", "a"); err == nil { + t.Error("expected error for unknown plugin") + } +} + +func TestRunAction_MissingScriptErrors(t *testing.T) { + p := Plugin{Name: "p", Dir: filepath.Join(t.TempDir(), "p")} + _, err := RunAction(context.Background(), p, Action{ID: "x", Command: "nope.sh"}, nil) + if err == nil { + t.Error("expected error for missing action script") + } +} diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go index d0c18687..cdc70d31 100644 --- a/internal/hooks/hooks.go +++ b/internal/hooks/hooks.go @@ -26,46 +26,76 @@ const ( // Runner executes hooks for task events. type Runner struct { - hooksDir string - logger *log.Logger + hooksDir string + pluginsDir string + plugins []Plugin + logger *log.Logger } // New creates a new hook runner. // hooksDir is typically ~/.config/task/hooks/ func New(hooksDir string) *Runner { - return &Runner{ - hooksDir: hooksDir, - logger: log.NewWithOptions(os.Stderr, log.Options{Prefix: "hooks"}), - } + return newRunner(hooksDir, DefaultPluginsDir(), + log.NewWithOptions(os.Stderr, log.Options{Prefix: "hooks"})) } // NewSilent creates a hook runner without logging. func NewSilent(hooksDir string) *Runner { - return &Runner{ - hooksDir: hooksDir, - logger: log.NewWithOptions(os.Stderr, log.Options{Level: log.FatalLevel}), + return newRunner(hooksDir, DefaultPluginsDir(), + log.NewWithOptions(os.Stderr, log.Options{Level: log.FatalLevel})) +} + +// newRunner constructs a Runner and loads any plugins from pluginsDir. It is the +// shared constructor used by New/NewSilent and tests. +func newRunner(hooksDir, pluginsDir string, logger *log.Logger) *Runner { + r := &Runner{hooksDir: hooksDir, pluginsDir: pluginsDir, logger: logger} + plugins, warnings := LoadPlugins(pluginsDir) + for _, w := range warnings { + logger.Warn("plugin load", "detail", w) + } + for _, p := range plugins { + logger.Debug("plugin loaded", "name", p.Name, "hooks", len(p.Hooks)) } + r.plugins = plugins + return r } -// Run executes hooks for the given event. -// Hooks are scripts in hooksDir named after the event (e.g., task.blocked) +// Plugins returns the loaded plugins (read-only view for inspection/CLI). +func (r *Runner) Plugins() []Plugin { return r.plugins } + +// Run executes every hook registered for the given event: the legacy +// single-script hook in hooksDir (named after the event), plus the matching +// hook from each loaded plugin. All run concurrently in the background. func (r *Runner) Run(event string, task *db.Task, message string) { - if r.hooksDir == "" { - return - } + baseEnv := taskEnv(event, task, message) - // Look for hook script - hookPath := filepath.Join(r.hooksDir, event) - if _, err := os.Stat(hookPath); os.IsNotExist(err) { - return + // Legacy single-script hook: ~/.config/task/hooks/ + if r.hooksDir != "" { + hookPath := filepath.Join(r.hooksDir, event) + if fi, err := os.Stat(hookPath); err == nil && !fi.IsDir() { + r.runScript(event, "", hookPath, r.hooksDir, baseEnv) + } } - // Execute hook with environment variables - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() + // Plugin hooks: fan out to every plugin that handles this event. + for _, p := range r.plugins { + script, ok := p.ScriptFor(event) + if !ok { + continue + } + env := make([]string, 0, len(baseEnv)+2) + env = append(env, baseEnv...) + env = append(env, + fmt.Sprintf("TASK_PLUGIN_NAME=%s", p.Name), + fmt.Sprintf("TASK_PLUGIN_DIR=%s", p.Dir), + ) + r.runScript(event, p.Name, script, p.Dir, env) + } +} - cmd := exec.CommandContext(ctx, hookPath) - cmd.Env = append(os.Environ(), +// taskEnv builds the environment shared by every hook for an event. +func taskEnv(event string, task *db.Task, message string) []string { + return append(os.Environ(), fmt.Sprintf("TASK_ID=%d", task.ID), fmt.Sprintf("TASK_TITLE=%s", task.Title), fmt.Sprintf("TASK_STATUS=%s", task.Status), @@ -75,14 +105,27 @@ func (r *Runner) Run(event string, task *db.Task, message string) { fmt.Sprintf("TASK_EVENT=%s", event), fmt.Sprintf("WORKTREE_PATH=%s", task.WorktreePath), ) +} - // Run in background, don't block +// runScript launches a hook script in the background. The 30s timeout is owned +// by the goroutine (not the caller) so the context isn't cancelled the instant +// Run returns — which would otherwise kill the hook before it could do anything. +// plugin is "" for the legacy hook, or the plugin name for a plugin hook. +func (r *Runner) runScript(event, plugin, scriptPath, workDir string, env []string) { go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, scriptPath) + cmd.Dir = workDir + cmd.Env = env + output, err := cmd.CombinedOutput() if err != nil { - r.logger.Error("Hook failed", "event", event, "error", err, "output", strings.TrimSpace(string(output))) + r.logger.Error("Hook failed", "event", event, "plugin", plugin, "error", err, + "output", strings.TrimSpace(string(output))) } else { - r.logger.Debug("Hook executed", "event", event) + r.logger.Debug("Hook executed", "event", event, "plugin", plugin) } }() } diff --git a/internal/hooks/plugins.go b/internal/hooks/plugins.go new file mode 100644 index 00000000..49abaea3 --- /dev/null +++ b/internal/hooks/plugins.go @@ -0,0 +1,180 @@ +package hooks + +import ( + "fmt" + "os" + "path/filepath" + "sort" + + "gopkg.in/yaml.v3" +) + +// ManifestName is the file every plugin directory must contain to be loaded. +const ManifestName = "plugin.yaml" + +// Plugin is a self-contained, droppable unit that reacts to task events. +// +// A plugin is a directory under the plugins dir (default +// ~/.config/task/plugins//) containing a plugin.yaml manifest and the +// scripts it references. Unlike the legacy single-script hooks dir — where one +// file per event means two integrations collide — any number of plugins may +// each declare a handler for the same event, and all of them run. +type Plugin struct { + Name string `yaml:"name"` + Version string `yaml:"version"` + Description string `yaml:"description"` + // Hooks maps a task event name (e.g. "task.done") to a script path, + // resolved relative to the plugin directory. Hooks fire automatically. + Hooks map[string]string `yaml:"hooks"` + // Actions are user-invoked commands (from `ty plugins run`, the detail-view + // picker, or the command palette), each backed by a script in the plugin dir. + Actions []Action `yaml:"actions"` + + // Dir is the absolute path to the plugin directory (not from the manifest). + Dir string `yaml:"-"` +} + +// Action is a user-triggered plugin command. +type Action struct { + ID string `yaml:"id"` // stable identifier, unique within the plugin + Label string `yaml:"label"` // human-facing label; defaults to ID if empty + Command string `yaml:"command"` // script path, relative to the plugin dir +} + +// DisplayLabel returns the label, falling back to the ID. +func (a Action) DisplayLabel() string { + if a.Label != "" { + return a.Label + } + return a.ID +} + +// ScriptFor returns the absolute path to the script handling event, and whether +// the plugin handles that event at all. +func (p Plugin) ScriptFor(event string) (string, bool) { + rel, ok := p.Hooks[event] + if !ok || rel == "" { + return "", false + } + return filepath.Join(p.Dir, rel), true +} + +// Action returns the action with the given ID. +func (p Plugin) Action(id string) (Action, bool) { + for _, a := range p.Actions { + if a.ID == id { + return a, true + } + } + return Action{}, false +} + +// DefaultPluginsDir returns the default plugins directory path. +func DefaultPluginsDir() string { + configDir, err := os.UserConfigDir() + if err != nil { + return "" + } + return filepath.Join(configDir, "task", "plugins") +} + +// LoadPlugins discovers and validates every plugin under pluginsDir. +// +// It is intentionally forgiving: a malformed or incomplete plugin is skipped +// (and reported via the returned warnings) rather than failing the whole load, +// so one bad community plugin can't break a user's task pipeline. A missing +// plugins dir is not an error — it just yields no plugins. +func LoadPlugins(pluginsDir string) (plugins []Plugin, warnings []string) { + if pluginsDir == "" { + return nil, nil + } + entries, err := os.ReadDir(pluginsDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, []string{fmt.Sprintf("read plugins dir %s: %v", pluginsDir, err)} + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + dir := filepath.Join(pluginsDir, entry.Name()) + p, warn := loadPlugin(dir) + if warn != "" { + warnings = append(warnings, warn) + } + if p != nil { + plugins = append(plugins, *p) + } + } + + // Deterministic order so fan-out and `plugins list` are stable. + sort.Slice(plugins, func(i, j int) bool { return plugins[i].Name < plugins[j].Name }) + return plugins, warnings +} + +// loadPlugin parses and validates a single plugin directory. It returns a nil +// plugin (with a warning) when the directory should be skipped. +func loadPlugin(dir string) (*Plugin, string) { + manifestPath := filepath.Join(dir, ManifestName) + data, err := os.ReadFile(manifestPath) + if err != nil { + if os.IsNotExist(err) { + // Not a plugin directory; silently ignore. + return nil, "" + } + return nil, fmt.Sprintf("plugin %s: read manifest: %v", filepath.Base(dir), err) + } + + var p Plugin + if err := yaml.Unmarshal(data, &p); err != nil { + return nil, fmt.Sprintf("plugin %s: invalid %s: %v", filepath.Base(dir), ManifestName, err) + } + p.Dir = dir + + if p.Name == "" { + return nil, fmt.Sprintf("plugin %s: manifest is missing a name", filepath.Base(dir)) + } + if len(p.Hooks) == 0 && len(p.Actions) == 0 { + return nil, fmt.Sprintf("plugin %q: manifest declares no hooks or actions; skipping", p.Name) + } + + // Drop hooks and actions whose script is missing or not a regular file, + // keeping the rest of the plugin usable. + var dropped []string + for event, rel := range p.Hooks { + if !isExecutableFile(filepath.Join(dir, rel)) { + delete(p.Hooks, event) + dropped = append(dropped, "hook:"+event) + } + } + kept := p.Actions[:0] + for _, a := range p.Actions { + switch { + case a.ID == "" || a.Command == "": + dropped = append(dropped, "action:") + case !isExecutableFile(filepath.Join(dir, a.Command)): + dropped = append(dropped, "action:"+a.ID) + default: + kept = append(kept, a) + } + } + p.Actions = kept + + if len(p.Hooks) == 0 && len(p.Actions) == 0 { + return nil, fmt.Sprintf("plugin %q: no usable hook or action scripts found; skipping", p.Name) + } + if len(dropped) > 0 { + sort.Strings(dropped) + return &p, fmt.Sprintf("plugin %q: ignored entries with missing/invalid scripts: %v", p.Name, dropped) + } + return &p, "" +} + +// isExecutableFile reports whether path exists and is a regular file. +func isExecutableFile(path string) bool { + fi, err := os.Stat(path) + return err == nil && !fi.IsDir() +} diff --git a/internal/hooks/plugins_test.go b/internal/hooks/plugins_test.go new file mode 100644 index 00000000..1187a14d --- /dev/null +++ b/internal/hooks/plugins_test.go @@ -0,0 +1,168 @@ +package hooks + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/charmbracelet/log" + + "github.com/bborn/workflow/internal/db" +) + +// writePlugin creates a plugin dir with the given manifest and scripts. +// scripts maps a relative filename to its body; each is made executable. +func writePlugin(t *testing.T, root, name, manifest string, scripts map[string]string) string { + t.Helper() + dir := filepath.Join(root, name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if manifest != "" { + if err := os.WriteFile(filepath.Join(dir, ManifestName), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + } + for rel, body := range scripts { + p := filepath.Join(dir, rel) + if err := os.WriteFile(p, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + } + return dir +} + +func TestLoadPlugins_DiscoversValidPlugins(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "zeta", "name: zeta\nversion: 1.0.0\nhooks:\n task.done: done.sh\n", + map[string]string{"done.sh": "#!/bin/sh\n"}) + writePlugin(t, root, "alpha", "name: alpha\nhooks:\n task.blocked: b.sh\n task.done: d.sh\n", + map[string]string{"b.sh": "#!/bin/sh\n", "d.sh": "#!/bin/sh\n"}) + + plugins, warnings := LoadPlugins(root) + if len(warnings) != 0 { + t.Fatalf("unexpected warnings: %v", warnings) + } + if len(plugins) != 2 { + t.Fatalf("got %d plugins, want 2", len(plugins)) + } + // Sorted by name for deterministic fan-out. + if plugins[0].Name != "alpha" || plugins[1].Name != "zeta" { + t.Errorf("plugins not sorted by name: %q, %q", plugins[0].Name, plugins[1].Name) + } + if got, ok := plugins[0].ScriptFor("task.done"); !ok || filepath.Base(got) != "d.sh" { + t.Errorf("alpha task.done script = %q (ok=%v)", got, ok) + } +} + +func TestLoadPlugins_MissingDirIsNotAnError(t *testing.T) { + plugins, warnings := LoadPlugins(filepath.Join(t.TempDir(), "does-not-exist")) + if plugins != nil || warnings != nil { + t.Errorf("got plugins=%v warnings=%v, want nil/nil", plugins, warnings) + } +} + +func TestLoadPlugins_SkipsInvalidAndWarns(t *testing.T) { + root := t.TempDir() + // Loose file (not a dir) is ignored entirely. + if err := os.WriteFile(filepath.Join(root, "loose.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + // Dir without a manifest is silently ignored (no warning). + writePlugin(t, root, "notaplugin", "", map[string]string{"x.sh": "#!/bin/sh\n"}) + // Manifest missing a name -> skipped with warning. + writePlugin(t, root, "noname", "hooks:\n task.done: d.sh\n", map[string]string{"d.sh": "#!/bin/sh\n"}) + // Hook references a script that doesn't exist -> skipped with warning. + writePlugin(t, root, "broken", "name: broken\nhooks:\n task.done: missing.sh\n", nil) + // One good plugin survives. + writePlugin(t, root, "good", "name: good\nhooks:\n task.done: d.sh\n", map[string]string{"d.sh": "#!/bin/sh\n"}) + + plugins, warnings := LoadPlugins(root) + if len(plugins) != 1 || plugins[0].Name != "good" { + t.Fatalf("got %d plugins (%v), want only 'good'", len(plugins), plugins) + } + if len(warnings) != 2 { + t.Errorf("got %d warnings, want 2 (noname, broken): %v", len(warnings), warnings) + } +} + +func TestLoadPlugins_DropsMissingHookButKeepsRest(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "partial", + "name: partial\nhooks:\n task.done: ok.sh\n task.blocked: gone.sh\n", + map[string]string{"ok.sh": "#!/bin/sh\n"}) + + plugins, warnings := LoadPlugins(root) + if len(plugins) != 1 { + t.Fatalf("got %d plugins, want 1", len(plugins)) + } + if _, ok := plugins[0].ScriptFor("task.done"); !ok { + t.Error("task.done hook should survive") + } + if _, ok := plugins[0].ScriptFor("task.blocked"); ok { + t.Error("task.blocked hook should have been dropped") + } + if len(warnings) != 1 { + t.Errorf("expected 1 warning about dropped hook, got %v", warnings) + } +} + +func TestRunner_FansOutToPluginsAndLegacyHook(t *testing.T) { + hooksDir := t.TempDir() + pluginsDir := t.TempDir() + out := t.TempDir() // each hook touches a marker file here + + // Legacy single-script hook. + legacy := "#!/bin/sh\necho legacy > \"" + filepath.Join(out, "legacy") + "\"\n" + if err := os.WriteFile(filepath.Join(hooksDir, "task.done"), []byte(legacy), 0o755); err != nil { + t.Fatal(err) + } + + // Two plugins both handling task.done — neither collides. + for _, name := range []string{"one", "two"} { + marker := filepath.Join(out, name) + body := "#!/bin/sh\necho \"$TASK_PLUGIN_NAME:$TASK_ID\" > \"" + marker + "\"\n" + writePlugin(t, pluginsDir, name, + "name: "+name+"\nhooks:\n task.done: run.sh\n", + map[string]string{"run.sh": body}) + } + + r := newRunner(hooksDir, pluginsDir, log.NewWithOptions(os.Stderr, log.Options{Level: log.FatalLevel})) + if len(r.Plugins()) != 2 { + t.Fatalf("loaded %d plugins, want 2", len(r.Plugins())) + } + + r.Run("task.done", &db.Task{ID: 42, Title: "t"}, "done") + + // Hooks run in background goroutines; wait for all three markers. + for _, f := range []string{"legacy", "one", "two"} { + waitForFile(t, filepath.Join(out, f)) + } + + // Plugin env was injected. + if got := readFile(t, filepath.Join(out, "one")); got != "one:42\n" { + t.Errorf("plugin 'one' marker = %q, want %q", got, "one:42\n") + } +} + +func waitForFile(t *testing.T, path string) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(path); err == nil { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("hook marker %q never appeared", filepath.Base(path)) +} + +func readFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(b) +} diff --git a/internal/ui/action_picker.go b/internal/ui/action_picker.go new file mode 100644 index 00000000..b475e9fe --- /dev/null +++ b/internal/ui/action_picker.go @@ -0,0 +1,171 @@ +package ui + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/bborn/workflow/internal/hooks" +) + +// PluginActionItem pairs a plugin with one of its actions for display/execution. +type PluginActionItem struct { + Plugin hooks.Plugin + Action hooks.Action +} + +// ActionPickerModel is a modal list of plugin actions for the current task. +// It mirrors CommandPaletteModel: a self-contained sub-model with its own View, +// switched to via a dedicated View constant, so it never touches the huh +// form-router path. +type ActionPickerModel struct { + taskTitle string + items []PluginActionItem + selectedIndex int + width int + height int + + // Result + selected *PluginActionItem + cancelled bool +} + +// gatherPluginActions loads all installed plugins and flattens their actions +// into a display list. Warnings from malformed plugins are ignored here; they +// surface via `ty plugins list`. +func gatherPluginActions() []PluginActionItem { + plugins, _ := hooks.LoadPlugins(hooks.DefaultPluginsDir()) + var items []PluginActionItem + for _, p := range plugins { + for _, a := range p.Actions { + items = append(items, PluginActionItem{Plugin: p, Action: a}) + } + } + return items +} + +// NewActionPickerModel creates an action picker for the given items. +func NewActionPickerModel(taskTitle string, items []PluginActionItem, width, height int) *ActionPickerModel { + return &ActionPickerModel{ + taskTitle: taskTitle, + items: items, + width: width, + height: height, + } +} + +// Init implements tea.Model. +func (m *ActionPickerModel) Init() tea.Cmd { return nil } + +// Update handles key input. It returns the model and never a command; the parent +// reads Selected()/IsCancelled() after each update. +func (m *ActionPickerModel) Update(msg tea.Msg) (*ActionPickerModel, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch keyMsg.String() { + case "esc", "q": + m.cancelled = true + case "enter": + if len(m.items) > 0 { + sel := m.items[m.selectedIndex] + m.selected = &sel + } + case "up", "ctrl+p", "ctrl+k", "k": + if len(m.items) > 0 { + m.selectedIndex-- + if m.selectedIndex < 0 { + m.selectedIndex = len(m.items) - 1 + } + } + case "down", "ctrl+n", "ctrl+j", "j": + if len(m.items) > 0 { + m.selectedIndex++ + if m.selectedIndex >= len(m.items) { + m.selectedIndex = 0 + } + } + } + return m, nil +} + +// View renders the modal. +func (m *ActionPickerModel) View() string { + modalWidth := min(72, m.width-4) + + header := lipgloss.NewStyle(). + Bold(true). + Foreground(ColorPrimary). + MarginBottom(1). + Render("Plugin Actions") + + var body strings.Builder + if len(m.items) == 0 { + body.WriteString(lipgloss.NewStyle(). + Foreground(ColorMuted). + Italic(true). + Render("No plugin actions installed. See docs/plugins.md.")) + } else { + for i, it := range m.items { + body.WriteString(m.renderItem(it, i == m.selectedIndex, modalWidth-6)) + if i < len(m.items)-1 { + body.WriteString("\n") + } + } + } + + help := lipgloss.NewStyle(). + Foreground(ColorMuted). + MarginTop(1). + Render("enter: run esc: cancel " + IconArrowUp() + "/" + IconArrowDown() + ": navigate") + + content := lipgloss.JoinVertical(lipgloss.Left, header, body.String(), help) + + modalBox := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ColorPrimary). + Padding(1, 2). + Width(modalWidth) + + return lipgloss.NewStyle(). + Width(m.width). + Height(m.height). + Align(lipgloss.Center, lipgloss.Center). + Render(modalBox.Render(content)) +} + +func (m *ActionPickerModel) renderItem(it PluginActionItem, selected bool, width int) string { + var line strings.Builder + if selected { + line.WriteString(lipgloss.NewStyle().Foreground(ColorPrimary).Bold(true).Render("> ")) + } else { + line.WriteString(" ") + } + + label := it.Action.DisplayLabel() + labelStyle := lipgloss.NewStyle() + if selected { + labelStyle = labelStyle.Bold(true).Foreground(ColorPrimary) + } + line.WriteString(labelStyle.Render(label)) + + // Dim "· plugin-name" suffix so it's clear which plugin owns the action. + suffix := " · " + it.Plugin.Name + line.WriteString(lipgloss.NewStyle().Foreground(ColorMuted).Render(suffix)) + + return line.String() +} + +// Selected returns the chosen item, or nil if none was chosen yet. +func (m *ActionPickerModel) Selected() *PluginActionItem { return m.selected } + +// IsCancelled reports whether the user dismissed the picker. +func (m *ActionPickerModel) IsCancelled() bool { return m.cancelled } + +// SetSize updates dimensions. +func (m *ActionPickerModel) SetSize(width, height int) { + m.width = width + m.height = height +} diff --git a/internal/ui/action_picker_test.go b/internal/ui/action_picker_test.go new file mode 100644 index 00000000..d2033ad2 --- /dev/null +++ b/internal/ui/action_picker_test.go @@ -0,0 +1,79 @@ +package ui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/bborn/workflow/internal/hooks" +) + +func testItems() []PluginActionItem { + return []PluginActionItem{ + {Plugin: hooks.Plugin{Name: "p1"}, Action: hooks.Action{ID: "a", Label: "Alpha"}}, + {Plugin: hooks.Plugin{Name: "p2"}, Action: hooks.Action{ID: "b", Label: "Beta"}}, + } +} + +func keyFor(s string) tea.KeyMsg { + switch s { + case "down": + return tea.KeyMsg{Type: tea.KeyDown} + case "up": + return tea.KeyMsg{Type: tea.KeyUp} + case "enter": + return tea.KeyMsg{Type: tea.KeyEnter} + case "esc": + return tea.KeyMsg{Type: tea.KeyEsc} + default: + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} + } +} + +func TestActionPicker_SelectDefaultIsFirst(t *testing.T) { + m := NewActionPickerModel("task", testItems(), 80, 24) + m, _ = m.Update(keyFor("enter")) + sel := m.Selected() + if sel == nil || sel.Action.ID != "a" { + t.Fatalf("selected = %v, want action a", sel) + } +} + +func TestActionPicker_NavigateThenSelect(t *testing.T) { + m := NewActionPickerModel("task", testItems(), 80, 24) + m, _ = m.Update(keyFor("down")) + m, _ = m.Update(keyFor("enter")) + if sel := m.Selected(); sel == nil || sel.Action.ID != "b" { + t.Fatalf("selected = %v, want action b", sel) + } +} + +func TestActionPicker_WrapAround(t *testing.T) { + m := NewActionPickerModel("task", testItems(), 80, 24) + m, _ = m.Update(keyFor("up")) // wraps from 0 to last + m, _ = m.Update(keyFor("enter")) + if sel := m.Selected(); sel == nil || sel.Action.ID != "b" { + t.Fatalf("selected = %v, want last item after wrap", sel) + } +} + +func TestActionPicker_Cancel(t *testing.T) { + m := NewActionPickerModel("task", testItems(), 80, 24) + m, _ = m.Update(keyFor("esc")) + if !m.IsCancelled() { + t.Error("expected cancelled after esc") + } + if m.Selected() != nil { + t.Error("expected no selection after cancel") + } +} + +func TestActionPicker_EmptyListEnterIsNoop(t *testing.T) { + m := NewActionPickerModel("task", nil, 80, 24) + m, _ = m.Update(keyFor("enter")) + if m.Selected() != nil { + t.Error("enter on empty list should not select") + } + // View must not panic on an empty list. + _ = m.View() +} diff --git a/internal/ui/app.go b/internal/ui/app.go index 359f6f09..e82f402e 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -25,6 +25,7 @@ import ( "github.com/bborn/workflow/internal/db" "github.com/bborn/workflow/internal/executor" "github.com/bborn/workflow/internal/github" + "github.com/bborn/workflow/internal/hooks" "github.com/bborn/workflow/internal/pipeline" "github.com/bborn/workflow/internal/tasksummary" ) @@ -52,6 +53,7 @@ const ( ViewWelcome // first-run fork: set up a project vs start a task ViewFolderPicker // fuzzy folder picker for "set up a project" ViewRoutines // global routines fleet-health view + ViewActionPicker // modal list of plugin actions for the current task ) // KeyMap defines key bindings. @@ -83,6 +85,7 @@ type KeyMap struct { OpenWorktree key.Binding ToggleShellPane key.Binding JumpToNotification key.Binding + Actions key.Binding // Column focus shortcuts FocusBacklog key.Binding FocusInProgress key.Binding @@ -235,6 +238,10 @@ func DefaultKeyMap() KeyMap { key.WithKeys("g"), key.WithHelp("g", "go to notification"), ), + Actions: key.NewBinding( + key.WithKeys("A"), + key.WithHelp("A", "plugin actions"), + ), FocusBacklog: key.NewBinding( key.WithKeys("B"), key.WithHelp("B", "backlog"), @@ -493,6 +500,10 @@ type AppModel struct { commandPaletteReturnView View commandPaletteReturnTaskID int64 + // Plugin action picker state (opened from the detail view) + actionPickerView *ActionPickerModel + actionPickerTask *db.Task + // AI command service for natural language command interpretation aiCommandService *ai.CommandService @@ -733,6 +744,10 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.(type) { case tickMsg, focusTickMsg, dbChangeMsg, taskEventMsg, tasksLoadedMsg, prRefreshTickMsg, liveSpinnerTickMsg: isSystemMsg = true + case actionFinishedMsg: + // A plugin action completed off the UI loop; its result must reach the + // main switch to update the notification banner, not be routed to a view. + isSystemMsg = true } if !isSystemMsg { @@ -801,6 +816,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.currentView == ViewCommandPalette && m.commandPaletteView != nil { return m.updateCommandPalette(msg) } + if m.currentView == ViewActionPicker && m.actionPickerView != nil { + return m.updateActionPicker(msg) + } // Handle detail view feedback mode (needs all message types for text input) if m.currentView == ViewDetail && m.detailView != nil && m.detailView.InFeedbackMode() { return m.updateDetail(msg) @@ -1256,6 +1274,18 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, m.handleAICommand(msg.cmd)) } + case actionFinishedMsg: + if msg.err != nil { + m.notification = fmt.Sprintf("%s %s failed: %s", IconBlocked(), msg.label, msg.err.Error()) + } else { + summary := actionResultSummary(msg.output) + if summary == "" { + summary = "done" + } + m.notification = fmt.Sprintf("%s %s: %s", IconDone(), msg.label, summary) + } + m.notifyUntil = time.Now().Add(6 * time.Second) + case taskPermissionModeCycledMsg: cmds = append(cmds, m.loadTasks()) // Refresh the detail view panes since the executor window was recreated @@ -1521,6 +1551,9 @@ func (m *AppModel) applyWindowSize(width, height int) { if m.commandPaletteView != nil { m.commandPaletteView.SetSize(width, height) } + if m.actionPickerView != nil { + m.actionPickerView.SetSize(width, height) + } if m.newTaskForm != nil { m.newTaskForm.SetSize(width, height) } @@ -1609,6 +1642,10 @@ func (m *AppModel) View() string { if m.commandPaletteView != nil { return m.commandPaletteView.View() } + case ViewActionPicker: + if m.actionPickerView != nil { + return m.actionPickerView.View() + } } return "" @@ -2643,6 +2680,9 @@ func (m *AppModel) updateDetail(msg tea.Msg) (tea.Model, tea.Cmd) { m.detailView.ToggleShellPane() return m, nil } + if key.Matches(keyMsg, m.keys.Actions) && m.selectedTask != nil { + return m.openActionPicker() + } if key.Matches(keyMsg, m.keys.Help) && m.detailView != nil { // Expand/collapse the detail footer help row. m.detailView.ToggleHelpExpanded() @@ -3974,6 +4014,29 @@ func (m *AppModel) updateCommandPalette(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } + // Check if user chose a plugin action (action mode, ">" prefix) + if act := m.commandPaletteView.SelectedAction(); act != nil { + item := *act + task := m.selectedTask // task context we were on, if any + returnView := m.commandPaletteReturnView + returnTaskID := m.commandPaletteReturnTaskID + if returnView == ViewDashboard && m.detailView != nil && m.selectedTask != nil { + returnView = ViewDetail + returnTaskID = m.selectedTask.ID + } + m.commandPaletteView = nil + m.commandPaletteReturnView = ViewDashboard + m.commandPaletteReturnTaskID = 0 + m.currentView = returnView + m.notification = fmt.Sprintf("%s Running %s…", IconInProgress(), item.Action.DisplayLabel()) + m.notifyUntil = time.Now().Add(hooks.ActionTimeout) + cmds := []tea.Cmd{runPluginActionCmd(item, task)} + if returnView == ViewDetail && m.detailView == nil && returnTaskID != 0 { + cmds = append(cmds, m.loadTask(returnTaskID)) + } + return m, tea.Batch(cmds...) + } + // Check if user selected a task if selectedTask := m.commandPaletteView.SelectedTask(); selectedTask != nil { taskID := selectedTask.ID @@ -4018,6 +4081,84 @@ func (m *AppModel) updateCommandPalette(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } +// actionFinishedMsg carries the result of a plugin action that ran off the UI +// loop. It is exempted from the view routers (see isSystemMsg) so it always +// reaches the main switch to update the notification banner. +type actionFinishedMsg struct { + label string + output string + err error +} + +// openActionPicker gathers plugin actions and opens the modal picker, or shows a +// notification when no actions are installed. +func (m *AppModel) openActionPicker() (tea.Model, tea.Cmd) { + items := gatherPluginActions() + if len(items) == 0 { + m.notification = fmt.Sprintf("%s No plugin actions installed (see docs/plugins.md)", IconBlocked()) + m.notifyUntil = time.Now().Add(4 * time.Second) + return m, nil + } + title := "" + if m.selectedTask != nil { + title = m.selectedTask.Title + } + m.actionPickerTask = m.selectedTask + m.actionPickerView = NewActionPickerModel(title, items, m.width, m.height) + m.currentView = ViewActionPicker + return m, m.actionPickerView.Init() +} + +// updateActionPicker drives the picker sub-model and acts on its result. +func (m *AppModel) updateActionPicker(msg tea.Msg) (tea.Model, tea.Cmd) { + if m.actionPickerView == nil { + return m, nil + } + var cmd tea.Cmd + m.actionPickerView, cmd = m.actionPickerView.Update(msg) + + if m.actionPickerView.IsCancelled() { + m.actionPickerView = nil + m.currentView = ViewDetail + return m, nil + } + if sel := m.actionPickerView.Selected(); sel != nil { + item := *sel + task := m.actionPickerTask + m.actionPickerView = nil + m.actionPickerTask = nil + m.currentView = ViewDetail + m.notification = fmt.Sprintf("%s Running %s…", IconInProgress(), item.Action.DisplayLabel()) + m.notifyUntil = time.Now().Add(hooks.ActionTimeout) + return m, runPluginActionCmd(item, task) + } + return m, cmd +} + +// runPluginActionCmd runs a plugin action off the UI loop and reports the result. +func runPluginActionCmd(item PluginActionItem, task *db.Task) tea.Cmd { + return func() tea.Msg { + out, err := hooks.RunAction(context.Background(), item.Plugin, item.Action, task) + return actionFinishedMsg{label: item.Action.DisplayLabel(), output: string(out), err: err} + } +} + +// actionResultSummary reduces an action's output to a single, length-capped +// line for the notification banner. +func actionResultSummary(output string) string { + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if len(line) > 80 { + return line[:79] + "…" + } + return line + } + return "" +} + // projectInferredMsg carries the result of an async claude -p inference for the // project currently being offered in the detect-confirm card. type projectInferredMsg struct { diff --git a/internal/ui/command_palette.go b/internal/ui/command_palette.go index cc308804..2993a99c 100644 --- a/internal/ui/command_palette.go +++ b/internal/ui/command_palette.go @@ -34,8 +34,15 @@ type CommandPaletteModel struct { height int maxVisible int + // Action mode: entered by typing a leading ">". Filters plugin actions + // instead of tasks. Task-switching behavior is unchanged when not in it. + allActions []PluginActionItem + filteredActions []PluginActionItem + actionMode bool + // Result selectedTask *db.Task + selectedAction *PluginActionItem cancelled bool aiCommandRequest bool // True when user pressed Enter with text that should go to AI rawInput string // The raw input text for AI command processing @@ -44,7 +51,7 @@ type CommandPaletteModel struct { // NewCommandPaletteModel creates a new command palette model. func NewCommandPaletteModel(database *db.DB, tasks []*db.Task, width, height int) *CommandPaletteModel { searchInput := textinput.New() - searchInput.Placeholder = "Search tasks or type a command..." + searchInput.Placeholder = "Search tasks — or > for plugin actions" searchInput.Focus() searchInput.CharLimit = 200 searchInput.Width = min(70, width-10) @@ -56,15 +63,53 @@ func NewCommandPaletteModel(database *db.DB, tasks []*db.Task, width, height int db: database, allTasks: tasks, projects: projects, + allActions: gatherPluginActions(), searchInput: searchInput, width: width, height: height, maxVisible: 10, } - m.filterTasks() + m.filter() return m } +// filter dispatches to task or action filtering based on the ">" prefix. +func (m *CommandPaletteModel) filter() { + q := strings.TrimSpace(m.searchInput.Value()) + if strings.HasPrefix(q, ">") { + m.actionMode = true + m.filterActions(strings.TrimSpace(strings.TrimPrefix(q, ">"))) + return + } + m.actionMode = false + m.filterTasks() +} + +// filterActions selects plugin actions matching query (by label, plugin, or id). +func (m *CommandPaletteModel) filterActions(query string) { + ql := strings.ToLower(query) + m.filteredActions = m.filteredActions[:0] + for _, it := range m.allActions { + if ql == "" || + strings.Contains(strings.ToLower(it.Action.DisplayLabel()), ql) || + strings.Contains(strings.ToLower(it.Plugin.Name), ql) || + strings.Contains(strings.ToLower(it.Action.ID), ql) { + m.filteredActions = append(m.filteredActions, it) + } + } + if m.selectedIndex >= len(m.filteredActions) { + m.selectedIndex = max(0, len(m.filteredActions)-1) + } +} + +// activeLen returns the length of the list the user is currently navigating. +func (m *CommandPaletteModel) activeLen() int { + if m.actionMode { + return len(m.filteredActions) + } + return len(m.filteredTasks) +} + // Init initializes the command palette. func (m *CommandPaletteModel) Init() tea.Cmd { return textinput.Blink @@ -79,6 +124,13 @@ func (m *CommandPaletteModel) Update(msg tea.Msg) (*CommandPaletteModel, tea.Cmd m.cancelled = true return m, nil case "enter": + if m.actionMode { + if len(m.filteredActions) > 0 && m.selectedIndex < len(m.filteredActions) { + sel := m.filteredActions[m.selectedIndex] + m.selectedAction = &sel + } + return m, nil + } query := strings.TrimSpace(m.searchInput.Value()) if len(m.filteredTasks) > 0 && m.selectedIndex < len(m.filteredTasks) { // If there are matching tasks and we have a selection, select that task @@ -92,13 +144,13 @@ func (m *CommandPaletteModel) Update(msg tea.Msg) (*CommandPaletteModel, tea.Cmd case "up", "ctrl+p", "ctrl+k": if m.selectedIndex > 0 { m.selectedIndex-- - } else if len(m.filteredTasks) > 0 { + } else if m.activeLen() > 0 { // Wrap to bottom - m.selectedIndex = len(m.filteredTasks) - 1 + m.selectedIndex = m.activeLen() - 1 } return m, nil case "down", "ctrl+n", "ctrl+j": - if m.selectedIndex < len(m.filteredTasks)-1 { + if m.selectedIndex < m.activeLen()-1 { m.selectedIndex++ } else { // Wrap to top @@ -113,8 +165,8 @@ func (m *CommandPaletteModel) Update(msg tea.Msg) (*CommandPaletteModel, tea.Cmd return m, nil case "pgdown": m.selectedIndex += m.maxVisible - if m.selectedIndex >= len(m.filteredTasks) { - m.selectedIndex = len(m.filteredTasks) - 1 + if m.selectedIndex >= m.activeLen() { + m.selectedIndex = m.activeLen() - 1 } if m.selectedIndex < 0 { m.selectedIndex = 0 @@ -125,7 +177,7 @@ func (m *CommandPaletteModel) Update(msg tea.Msg) (*CommandPaletteModel, tea.Cmd // Update search input var cmd tea.Cmd m.searchInput, cmd = m.searchInput.Update(msg) - m.filterTasks() + m.filter() return m, cmd } @@ -595,9 +647,11 @@ func (m *CommandPaletteModel) View() string { modalWidth := min(80, m.width-4) query := strings.TrimSpace(m.searchInput.Value()) - // Header - changes based on whether we have matching tasks + // Header - changes based on mode / whether we have matching tasks headerText := "Go to Task" - if len(m.filteredTasks) == 0 && query != "" { + if m.actionMode { + headerText = "Run Plugin Action" + } else if len(m.filteredTasks) == 0 && query != "" { headerText = "AI Command" } header := lipgloss.NewStyle(). @@ -614,9 +668,11 @@ func (m *CommandPaletteModel) View() string { Width(modalWidth - 6) searchBox := inputStyle.Render(m.searchInput.View()) - // Task list + // Task list (or plugin-action list in action mode) var taskList strings.Builder - if len(m.filteredTasks) == 0 { + if m.actionMode { + m.renderActionList(&taskList, modalWidth-6) + } else if len(m.filteredTasks) == 0 { emptyStyle := lipgloss.NewStyle(). Foreground(ColorMuted). Italic(true). @@ -684,9 +740,12 @@ func (m *CommandPaletteModel) View() string { Foreground(ColorMuted). MarginTop(1) var helpText string - if len(m.filteredTasks) == 0 && query != "" { + switch { + case m.actionMode: + helpText = "Enter: run Esc: cancel " + IconArrowUp() + "/" + IconArrowDown() + ": navigate" + case len(m.filteredTasks) == 0 && query != "": helpText = "Enter: run command Esc: cancel" - } else { + default: helpText = "Enter: select Esc: cancel " + IconArrowUp() + "/" + IconArrowDown() + ": navigate" } help := helpStyle.Render(helpText) @@ -717,6 +776,41 @@ func (m *CommandPaletteModel) View() string { Render(modalContent) } +// renderActionList renders the plugin-action list (action mode). +func (m *CommandPaletteModel) renderActionList(b *strings.Builder, width int) { + if len(m.filteredActions) == 0 { + b.WriteString(lipgloss.NewStyle(). + Foreground(ColorMuted). + Italic(true). + Padding(1, 0). + Render("No matching plugin actions. See docs/plugins.md.")) + return + } + for i, it := range m.filteredActions { + b.WriteString(m.renderActionRow(it, i == m.selectedIndex, width)) + if i < len(m.filteredActions)-1 { + b.WriteString("\n") + } + } +} + +// renderActionRow renders a single "label · plugin" action row. +func (m *CommandPaletteModel) renderActionRow(it PluginActionItem, selected bool, width int) string { + var line strings.Builder + if selected { + line.WriteString(lipgloss.NewStyle().Foreground(ColorPrimary).Bold(true).Render("> ")) + } else { + line.WriteString(" ") + } + labelStyle := lipgloss.NewStyle() + if selected { + labelStyle = labelStyle.Bold(true).Foreground(ColorPrimary) + } + line.WriteString(labelStyle.Render(it.Action.DisplayLabel())) + line.WriteString(lipgloss.NewStyle().Foreground(ColorMuted).Render(" · " + it.Plugin.Name)) + return line.String() +} + // renderTaskItem renders a single task in the list. func (m *CommandPaletteModel) renderTaskItem(task *db.Task, isSelected bool, width int) string { // Status icon @@ -781,6 +875,11 @@ func (m *CommandPaletteModel) SelectedTask() *db.Task { return m.selectedTask } +// SelectedAction returns the chosen plugin action (action mode), or nil. +func (m *CommandPaletteModel) SelectedAction() *PluginActionItem { + return m.selectedAction +} + // IsCancelled returns true if the user cancelled the palette. func (m *CommandPaletteModel) IsCancelled() bool { return m.cancelled diff --git a/internal/ui/command_palette_test.go b/internal/ui/command_palette_test.go index c3042e52..c0b45b9d 100644 --- a/internal/ui/command_palette_test.go +++ b/internal/ui/command_palette_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/bborn/workflow/internal/db" + "github.com/bborn/workflow/internal/hooks" ) func TestFuzzyMatch(t *testing.T) { @@ -156,6 +157,55 @@ func TestCommandPaletteFiltering(t *testing.T) { } } +func TestCommandPalette_ActionModePrefixAndFilter(t *testing.T) { + actions := []PluginActionItem{ + {Plugin: hooks.Plugin{Name: "notify"}, Action: hooks.Action{ID: "test", Label: "Send test"}}, + {Plugin: hooks.Plugin{Name: "sync"}, Action: hooks.Action{ID: "push", Label: "Push to tracker"}}, + } + + cases := []struct { + name string + query string + wantAction bool + wantCount int + }{ + {"no prefix stays task mode", "notify", false, 0}, + {"bare prefix lists all actions", ">", true, 2}, + {"prefix filters by label", ">push", true, 1}, + {"prefix filters by plugin", ">notify", true, 1}, + {"prefix no match", ">zzz", true, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := &CommandPaletteModel{allActions: actions} + m.searchInput.SetValue(tc.query) + m.filter() + if m.actionMode != tc.wantAction { + t.Fatalf("actionMode = %v, want %v", m.actionMode, tc.wantAction) + } + if tc.wantAction && len(m.filteredActions) != tc.wantCount { + t.Errorf("filteredActions = %d, want %d", len(m.filteredActions), tc.wantCount) + } + }) + } +} + +func TestCommandPalette_ActionModeEnterSelects(t *testing.T) { + m := &CommandPaletteModel{allActions: []PluginActionItem{ + {Plugin: hooks.Plugin{Name: "notify"}, Action: hooks.Action{ID: "test", Label: "Send test"}}, + }} + m.searchInput.SetValue(">") + m.filter() + m, _ = m.Update(keyFor("enter")) + if m.SelectedAction() == nil || m.SelectedAction().Action.ID != "test" { + t.Fatalf("SelectedAction = %v, want action test", m.SelectedAction()) + } + // Task selection must remain untouched in action mode. + if m.SelectedTask() != nil { + t.Error("SelectedTask should be nil in action mode") + } +} + func TestMatchesQuery(t *testing.T) { task := &db.Task{ ID: 123,