global plugin actions

This commit is contained in:
booleanmaybe 2026-04-14 19:08:54 -04:00
parent 12a0ea86f3
commit e275604e85
17 changed files with 791 additions and 236 deletions

View file

@ -66,12 +66,15 @@ Search order: user config dir (base) → `.doc/workflow.yaml` (project) → cwd
- Empty/zero fields in the override are ignored — the base value is kept
- Views that only exist in the override are appended
**Global plugin actions** (`views.actions`) — merged by key across files. If two files define a global action with the same key, the later file's action wins. Global actions are appended to each tiki plugin's action list; per-plugin actions with the same key take precedence.
A project only needs to define the views or fields it wants to change. Everything else is inherited from your user config.
To disable all user-level views for a project, create a `.doc/workflow.yaml` with an explicitly empty views list:
```yaml
views: []
views:
plugins: []
```
### config.yaml
@ -147,70 +150,111 @@ statuses:
done: true
views:
- name: Kanban
description: "Move tiki to new status, search, create or delete"
key: "F1"
lanes:
- name: Ready
filter: select where status = "ready" and type != "epic" order by priority, createdAt
action: update where id = id() set status="ready"
- name: In Progress
filter: select where status = "inProgress" and type != "epic" order by priority, createdAt
action: update where id = id() set status="inProgress"
- name: Review
filter: select where status = "review" and type != "epic" order by priority, createdAt
action: update where id = id() set status="review"
- name: Done
filter: select where status = "done" and type != "epic" order by priority, createdAt
action: update where id = id() set status="done"
- name: Backlog
description: "Tasks waiting to be picked up, sorted by priority"
key: "F3"
lanes:
- name: Backlog
columns: 4
filter: select where status = "backlog" and type != "epic" order by priority, id
actions:
- key: "b"
label: "Add to board"
action: update where id = id() set status="ready"
- name: Recent
description: "Tasks changed in the last 24 hours, most recent first"
key: Ctrl-R
lanes:
- name: Recent
columns: 4
filter: select where now() - updatedAt < 24hour order by updatedAt desc
- name: Roadmap
description: "Epics organized by Now, Next, and Later horizons"
key: "F4"
lanes:
- name: Now
columns: 1
width: 25
filter: select where type = "epic" and status = "ready" order by priority, points desc
action: update where id = id() set status="ready"
- name: Next
columns: 1
width: 25
filter: select where type = "epic" and status = "backlog" and priority = 1 order by priority, points desc
action: update where id = id() set status="backlog" priority=1
- name: Later
columns: 2
width: 50
filter: select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
action: update where id = id() set status="backlog" priority=2
view: expanded
- name: Help
description: "Keyboard shortcuts, navigation, and usage guide"
type: doki
fetcher: internal
text: "Help"
key: "?"
- name: Docs
description: "Project notes and documentation files"
type: doki
fetcher: file
url: "index.md"
key: "F2"
actions:
- key: "a"
label: "Assign to me"
action: update where id = id() set assignee=user()
plugins:
- name: Kanban
description: "Move tiki to new status, search, create or delete"
key: "F1"
lanes:
- name: Ready
filter: select where status = "ready" and type != "epic" order by priority, createdAt
action: update where id = id() set status="ready"
- name: In Progress
filter: select where status = "inProgress" and type != "epic" order by priority, createdAt
action: update where id = id() set status="inProgress"
- name: Review
filter: select where status = "review" and type != "epic" order by priority, createdAt
action: update where id = id() set status="review"
- name: Done
filter: select where status = "done" and type != "epic" order by priority, createdAt
action: update where id = id() set status="done"
- name: Backlog
description: "Tasks waiting to be picked up, sorted by priority"
key: "F3"
lanes:
- name: Backlog
columns: 4
filter: select where status = "backlog" and type != "epic" order by priority, id
actions:
- key: "b"
label: "Add to board"
action: update where id = id() set status="ready"
- name: Recent
description: "Tasks changed in the last 24 hours, most recent first"
key: Ctrl-R
lanes:
- name: Recent
columns: 4
filter: select where now() - updatedAt < 24hour order by updatedAt desc
- name: Roadmap
description: "Epics organized by Now, Next, and Later horizons"
key: "F4"
lanes:
- name: Now
columns: 1
width: 25
filter: select where type = "epic" and status = "ready" order by priority, points desc
action: update where id = id() set status="ready"
- name: Next
columns: 1
width: 25
filter: select where type = "epic" and status = "backlog" and priority = 1 order by priority, points desc
action: update where id = id() set status="backlog" priority=1
- name: Later
columns: 2
width: 50
filter: select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
action: update where id = id() set status="backlog" priority=2
view: expanded
- name: Help
description: "Keyboard shortcuts, navigation, and usage guide"
type: doki
fetcher: internal
text: "Help"
key: "?"
- name: Docs
description: "Project notes and documentation files"
type: doki
fetcher: file
url: "index.md"
key: "F2"
triggers:
- description: block completion with open dependencies
ruki: >
before update
where new.status = "done" and new.dependsOn any status != "done"
deny "cannot complete: has open dependencies"
- description: tasks must pass through review before completion
ruki: >
before update
where new.status = "done" and old.status != "review"
deny "tasks must go through review before marking done"
- description: remove deleted task from dependency lists
ruki: >
after delete
update where old.id in dependsOn set dependsOn=dependsOn - [old.id]
- description: clean up completed tasks after 24 hours
ruki: >
every 1day
delete where status = "done" and updatedAt < now() - 1day
- description: tasks must have an assignee before starting
ruki: >
before update
where new.status = "inProgress" and new.assignee is empty
deny "assign someone before moving to in-progress"
- description: auto-complete epics when all child tasks finish
ruki: >
after update
where new.status = "done" and new.type != "epic"
update where type = "epic" and new.id in dependsOn and dependsOn all status = "done"
set status="done"
- description: cannot delete tasks that are actively being worked
ruki: >
before delete
where old.status = "inProgress"
deny "cannot delete an in-progress task — move to backlog or done first"
```

View file

@ -69,17 +69,18 @@ how Backlog is defined:
```yaml
views:
- name: Backlog
description: "Tasks waiting to be picked up, sorted by priority"
key: "F3"
lanes:
- name: Backlog
columns: 4
filter: select where status = "backlog" and type != "epic" order by priority, id
actions:
- key: "b"
label: "Add to board"
action: update where id = id() set status="ready"
plugins:
- name: Backlog
description: "Tasks waiting to be picked up, sorted by priority"
key: "F3"
lanes:
- name: Backlog
columns: 4
filter: select where status = "backlog" and type != "epic" order by priority, id
actions:
- key: "b"
label: "Add to board"
action: update where id = id() set status="ready"
```
that translates to - show all tikis in the status `backlog`, sort by priority and then by ID arranged visually in 4 columns in a single lane.
@ -90,12 +91,13 @@ Likewise the documentation is just a plugin:
```yaml
views:
- name: Docs
description: "Project notes and documentation files"
type: doki
fetcher: file
url: "index.md"
key: "F2"
plugins:
- name: Docs
description: "Project notes and documentation files"
type: doki
fetcher: file
url: "index.md"
key: "F2"
```
that translates to - show `index.md` file located under `.doc/doki`
@ -151,7 +153,28 @@ lanes:
If no lanes specify width, all lanes are equally sized (the default behavior).
### Plugin actions
### Global plugin actions
You can define actions under `views.actions` that are available in **all** tiki plugin views. This avoids repeating common shortcuts in every plugin definition.
```yaml
views:
actions:
- key: "a"
label: "Assign to me"
action: update where id = id() set assignee=user()
plugins:
- name: Kanban
...
- name: Backlog
...
```
Global actions appear in the header alongside per-plugin actions. If a per-plugin action uses the same key as a global action, the per-plugin action takes precedence for that view.
When multiple workflow files define `views.actions`, they merge by key across files — later files override same-keyed globals from earlier files.
### Per-plugin actions
In addition to lane actions that trigger when moving tikis between lanes, you can define plugin-level actions
that apply to the currently selected tiki via a keyboard shortcut. These shortcuts are displayed in the header when the plugin is active.
@ -169,14 +192,16 @@ actions:
Each action has:
- `key` - a single printable character used as the keyboard shortcut
- `label` - description shown in the header
- `action` - a `ruki` `update` statement (same syntax as lane actions, see below)
- `action` - a `ruki` statement (`update`, `create`, `delete`, or `select`)
When the shortcut key is pressed, the action is applied to the currently selected tiki.
For example, pressing `b` in the Backlog plugin changes the selected tiki's status to `ready`, effectively moving it to the board.
`select` actions execute for side-effects only — the output is ignored. They don't require a selected tiki.
### ruki expressions
Plugin filters, lane actions, and plugin actions all use the [ruki](ruki/index.md) language. Filters use `select` statements and actions use `update` statements.
Plugin filters, lane actions, and plugin actions all use the [ruki](ruki/index.md) language. Filters use `select` statements. Actions support `update`, `create`, `delete`, and `select` statements (`select` for side-effects only, output ignored).
#### Filter (select)

View file

@ -13,17 +13,20 @@
- [Priority triage](#priority-triage--five-lane-plugin)
- [By topic](#by-topic--tag-based-lanes)
## Assign to me — plugin action
## Assign to me — global plugin action
Shortcut key that sets the selected task's assignee to the current git user.
Shortcut key that sets the selected task's assignee to the current git user. Defined under `views.actions`, this shortcut is available in all tiki plugin views.
```yaml
actions:
- key: "a"
label: "Assign to me"
action: update where id = id() set assignee=user()
views:
actions:
- key: "a"
label: "Assign to me"
action: update where id = id() set assignee=user()
```
The same format works as a per-plugin action (under a plugin's `actions:` key) if you only want it in a specific view.
## Add tag to task — plugin action
Appends a tag to the selected task's tag list without removing existing tags.

View file

@ -21,73 +21,78 @@ statuses:
done: true
views:
- name: Kanban
description: "Move tiki to new status, search, create or delete"
default: true
key: "F1"
lanes:
- name: Ready
filter: select where status = "ready" and type != "epic" order by priority, createdAt
action: update where id = id() set status="ready"
- name: In Progress
filter: select where status = "inProgress" and type != "epic" order by priority, createdAt
action: update where id = id() set status="inProgress"
- name: Review
filter: select where status = "review" and type != "epic" order by priority, createdAt
action: update where id = id() set status="review"
- name: Done
filter: select where status = "done" and type != "epic" order by priority, createdAt
action: update where id = id() set status="done"
- name: Backlog
description: "Tasks waiting to be picked up, sorted by priority"
key: "F3"
lanes:
- name: Backlog
columns: 4
filter: select where status = "backlog" and type != "epic" order by priority, id
actions:
- key: "b"
label: "Add to board"
action: update where id = id() set status="ready"
- name: Recent
description: "Tasks changed in the last 24 hours, most recent first"
key: Ctrl-R
lanes:
- name: Recent
columns: 4
filter: select where now() - updatedAt < 24hour order by updatedAt desc
- name: Roadmap
description: "Epics organized by Now, Next, and Later horizons"
key: "F4"
lanes:
- name: Now
columns: 1
width: 25
filter: select where type = "epic" and status = "ready" order by priority, points desc
action: update where id = id() set status="ready"
- name: Next
columns: 1
width: 25
filter: select where type = "epic" and status = "backlog" and priority = 1 order by priority, points desc
action: update where id = id() set status="backlog" priority=1
- name: Later
columns: 2
width: 50
filter: select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
action: update where id = id() set status="backlog" priority=2
view: expanded
- name: Help
description: "Keyboard shortcuts, navigation, and usage guide"
type: doki
fetcher: internal
text: "Help"
key: "?"
- name: Docs
description: "Project notes and documentation files"
type: doki
fetcher: file
url: "index.md"
key: "F2"
actions:
- key: "a"
label: "Assign to me"
action: update where id = id() set assignee=user()
plugins:
- name: Kanban
description: "Move tiki to new status, search, create or delete"
default: true
key: "F1"
lanes:
- name: Ready
filter: select where status = "ready" and type != "epic" order by priority, createdAt
action: update where id = id() set status="ready"
- name: In Progress
filter: select where status = "inProgress" and type != "epic" order by priority, createdAt
action: update where id = id() set status="inProgress"
- name: Review
filter: select where status = "review" and type != "epic" order by priority, createdAt
action: update where id = id() set status="review"
- name: Done
filter: select where status = "done" and type != "epic" order by priority, createdAt
action: update where id = id() set status="done"
- name: Backlog
description: "Tasks waiting to be picked up, sorted by priority"
key: "F3"
lanes:
- name: Backlog
columns: 4
filter: select where status = "backlog" and type != "epic" order by priority, id
actions:
- key: "b"
label: "Add to board"
action: update where id = id() set status="ready"
- name: Recent
description: "Tasks changed in the last 24 hours, most recent first"
key: Ctrl-R
lanes:
- name: Recent
columns: 4
filter: select where now() - updatedAt < 24hour order by updatedAt desc
- name: Roadmap
description: "Epics organized by Now, Next, and Later horizons"
key: "F4"
lanes:
- name: Now
columns: 1
width: 25
filter: select where type = "epic" and status = "ready" order by priority, points desc
action: update where id = id() set status="ready"
- name: Next
columns: 1
width: 25
filter: select where type = "epic" and status = "backlog" and priority = 1 order by priority, points desc
action: update where id = id() set status="backlog" priority=1
- name: Later
columns: 2
width: 50
filter: select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
action: update where id = id() set status="backlog" priority=2
view: expanded
- name: Help
description: "Keyboard shortcuts, navigation, and usage guide"
type: doki
fetcher: internal
text: "Help"
key: "?"
- name: Docs
description: "Project notes and documentation files"
type: doki
fetcher: file
url: "index.md"
key: "F2"
triggers:
- description: block completion with open dependencies

View file

@ -190,21 +190,40 @@ func GetConfig() *Config {
return appConfig
}
// viewsFileData represents the views section of workflow.yaml for read-modify-write.
type viewsFileData struct {
Actions []map[string]interface{} `yaml:"actions,omitempty"`
Plugins []map[string]interface{} `yaml:"plugins"`
}
// workflowFileData represents the YAML structure of workflow.yaml for read-modify-write.
// kept in config package to avoid import cycle with plugin package.
// all top-level sections must be listed here to survive round-trip serialization.
type workflowFileData struct {
Statuses []map[string]interface{} `yaml:"statuses,omitempty"`
Plugins []map[string]interface{} `yaml:"views"`
Views viewsFileData `yaml:"views,omitempty"`
Triggers []map[string]interface{} `yaml:"triggers,omitempty"`
}
// readWorkflowFile reads and unmarshals workflow.yaml from the given path.
// Handles both old list format (views: [...]) and new map format (views: {plugins: [...]}).
func readWorkflowFile(path string) (*workflowFileData, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading workflow.yaml: %w", err)
}
// convert legacy views list format to map format before unmarshaling
var raw map[string]interface{}
if err := yaml.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parsing workflow.yaml: %w", err)
}
ConvertViewsListToMap(raw)
data, err = yaml.Marshal(raw)
if err != nil {
return nil, fmt.Errorf("re-marshaling workflow.yaml: %w", err)
}
var wf workflowFileData
if err := yaml.Unmarshal(data, &wf); err != nil {
return nil, fmt.Errorf("parsing workflow.yaml: %w", err)
@ -212,6 +231,23 @@ func readWorkflowFile(path string) (*workflowFileData, error) {
return &wf, nil
}
// ConvertViewsListToMap converts old views list format to new map format in-place.
// Old: views: [{name: Kanban, ...}] → New: views: {plugins: [{name: Kanban, ...}]}
func ConvertViewsListToMap(raw map[string]interface{}) {
views, ok := raw["views"]
if !ok {
return
}
if _, isMap := views.(map[string]interface{}); isMap {
return
}
if list, isList := views.([]interface{}); isList {
raw["views"] = map[string]interface{}{
"plugins": list,
}
}
}
// writeWorkflowFile marshals and writes workflow.yaml to the given path.
func writeWorkflowFile(path string, wf *workflowFileData) error {
data, err := yaml.Marshal(wf)
@ -250,7 +286,7 @@ func getPluginViewModeFromWorkflow(pluginName string, defaultValue string) strin
return defaultValue
}
for _, p := range wf.Plugins {
for _, p := range wf.Views.Plugins {
if name, ok := p["name"].(string); ok && name == pluginName {
if view, ok := p["view"].(string); ok && view != "" {
return view
@ -279,13 +315,13 @@ func SavePluginViewMode(pluginName string, configIndex int, viewMode string) err
wf = &workflowFileData{}
}
if configIndex >= 0 && configIndex < len(wf.Plugins) {
if configIndex >= 0 && configIndex < len(wf.Views.Plugins) {
// update existing entry by index
wf.Plugins[configIndex]["view"] = viewMode
wf.Views.Plugins[configIndex]["view"] = viewMode
} else {
// find by name or create new entry
existingIndex := -1
for i, p := range wf.Plugins {
for i, p := range wf.Views.Plugins {
if name, ok := p["name"].(string); ok && name == pluginName {
existingIndex = i
break
@ -293,13 +329,13 @@ func SavePluginViewMode(pluginName string, configIndex int, viewMode string) err
}
if existingIndex >= 0 {
wf.Plugins[existingIndex]["view"] = viewMode
wf.Views.Plugins[existingIndex]["view"] = viewMode
} else {
newEntry := map[string]interface{}{
"name": pluginName,
"view": viewMode,
}
wf.Plugins = append(wf.Plugins, newEntry)
wf.Views.Plugins = append(wf.Views.Plugins, newEntry)
}
}

View file

@ -450,8 +450,8 @@ triggers:
}
// modify a view mode (same as SavePluginViewMode logic)
if len(wf.Plugins) > 0 {
wf.Plugins[0]["view"] = "compact"
if len(wf.Views.Plugins) > 0 {
wf.Views.Plugins[0]["view"] = "compact"
}
if err := writeWorkflowFile(workflowPath, wf); err != nil {

View file

@ -372,21 +372,29 @@ func FindWorkflowFiles() []string {
return result
}
// hasEmptyViews returns true if the workflow file has an explicit empty views list (views: []).
// hasEmptyViews returns true if the workflow file has an explicit empty views section.
// Handles both old list format (views: []) and new map format (views: {plugins: []}).
func hasEmptyViews(path string) bool {
data, err := os.ReadFile(path)
if err != nil {
return false
}
type viewsOnly struct {
Views []any `yaml:"views"`
var raw struct {
Views interface{} `yaml:"views"`
}
var vo viewsOnly
if err := yaml.Unmarshal(data, &vo); err != nil {
if err := yaml.Unmarshal(data, &raw); err != nil {
return false
}
switch v := raw.Views.(type) {
case []interface{}:
return len(v) == 0
case map[string]interface{}:
plugins, _ := v["plugins"].([]interface{})
return plugins != nil && len(plugins) == 0
default:
return false
}
// Explicitly empty (views: []) vs. not specified at all
return vo.Views != nil && len(vo.Views) == 0
}
// FindWorkflowFile searches for workflow.yaml in config search paths.

View file

@ -159,7 +159,12 @@ func (pc *PluginController) handlePluginAction(r rune) bool {
input := ruki.ExecutionInput{}
taskID := pc.getSelectedTaskID(pc.GetFilteredTasksForLane)
if pa.Action.IsUpdate() || pa.Action.IsDelete() || pa.Action.IsPipe() {
if pa.Action.IsSelect() && !pa.Action.IsPipe() {
// plain SELECT actions are side-effect only — pass task ID if available but don't require it
if taskID != "" {
input.SelectedTaskID = taskID
}
} else if pa.Action.IsUpdate() || pa.Action.IsDelete() || pa.Action.IsPipe() {
if taskID == "" {
return false
}
@ -183,6 +188,10 @@ func (pc *PluginController) handlePluginAction(r rune) bool {
ctx := context.Background()
switch {
case result.Select != nil:
slog.Info("select plugin action executed", "task_id", taskID, "key", string(r),
"label", pa.Label, "matched", len(result.Select.Tasks))
return true
case result.Update != nil:
for _, updated := range result.Update.Updated {
if err := pc.mutationGate.UpdateTask(ctx, updated); err != nil {

View file

@ -1224,6 +1224,70 @@ func TestPluginController_HandleToggleViewMode(t *testing.T) {
}
}
func TestPluginController_HandlePluginAction_Select(t *testing.T) {
taskStore := store.NewInMemoryStore()
_ = taskStore.CreateTask(&task.Task{
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
})
readyFilter := mustParseStmt(t, `select where status = "ready"`)
selectAction := mustParseStmt(t, `select where status = "ready"`)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
Actions: []plugin.PluginAction{
{Rune: 's', Label: "Search Ready", Action: selectAction},
},
}
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetLaneLayout([]int{1}, nil)
pluginConfig.SetSelectedLane(0)
pluginConfig.SetSelectedIndexForLane(0, 0)
schema := rukiRuntime.NewSchema()
gate := service.NewTaskMutationGate()
gate.SetStore(taskStore)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
if !pc.HandleAction(pluginActionID('s')) {
t.Error("expected true for SELECT plugin action")
}
// task should be unchanged (SELECT is side-effect only)
tk := taskStore.GetTask("T-1")
if tk.Status != task.StatusReady {
t.Errorf("expected status ready (unchanged), got %s", tk.Status)
}
}
func TestPluginController_HandlePluginAction_SelectNoSelectedTask(t *testing.T) {
taskStore := store.NewInMemoryStore()
emptyFilter := mustParseStmt(t, `select where status = "done"`)
selectAction := mustParseStmt(t, `select where status = "ready"`)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
Lanes: []plugin.TikiLane{{Name: "Empty", Columns: 1, Filter: emptyFilter}},
Actions: []plugin.PluginAction{
{Rune: 's', Label: "Search Ready", Action: selectAction},
},
}
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetLaneLayout([]int{1}, nil)
schema := rukiRuntime.NewSchema()
gate := service.NewTaskMutationGate()
gate.SetStore(taskStore)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
// SELECT should succeed even with no selected task
if !pc.HandleAction(pluginActionID('s')) {
t.Error("expected true for SELECT action even with no selected task")
}
}
func TestGetPluginActionRune(t *testing.T) {
tests := []struct {
name string

View file

@ -7,6 +7,7 @@ import (
"strings"
"sync"
"github.com/boolean-maybe/tiki/config"
"github.com/boolean-maybe/tiki/workflow"
)
@ -56,6 +57,12 @@ func NewLegacyConfigTransformer() *LegacyConfigTransformer {
return &LegacyConfigTransformer{}
}
// ConvertViewsFormat converts the old views list format to the new map format in-place.
// Delegates to config.ConvertViewsListToMap.
func (t *LegacyConfigTransformer) ConvertViewsFormat(raw map[string]interface{}) {
config.ConvertViewsListToMap(raw)
}
// ConvertPluginConfig converts all legacy expressions in a plugin config in-place.
// Returns the number of fields converted.
func (t *LegacyConfigTransformer) ConvertPluginConfig(cfg *pluginFileConfig) int {
@ -133,7 +140,8 @@ func isRukiAction(expr string) bool {
lower := strings.ToLower(strings.TrimSpace(expr))
return strings.HasPrefix(lower, "update") ||
strings.HasPrefix(lower, "create") ||
strings.HasPrefix(lower, "delete")
strings.HasPrefix(lower, "delete") ||
strings.HasPrefix(lower, "select")
}
// mergeSortIntoFilter appends an order-by clause to a filter, respecting existing order-by.

View file

@ -612,20 +612,31 @@ func TestLegacyWorkflowEndToEnd(t *testing.T) {
action: type = 'bug', priority = 1
`
// convert legacy views format (list → map) before unmarshaling
var raw map[string]interface{}
if err := yaml.Unmarshal([]byte(legacyYAML), &raw); err != nil {
t.Fatalf("failed to unmarshal raw YAML: %v", err)
}
transformer := NewLegacyConfigTransformer()
transformer.ConvertViewsFormat(raw)
normalizedData, err := yaml.Marshal(raw)
if err != nil {
t.Fatalf("failed to re-marshal: %v", err)
}
var wf WorkflowFile
if err := yaml.Unmarshal([]byte(legacyYAML), &wf); err != nil {
t.Fatalf("failed to unmarshal legacy YAML: %v", err)
if err := yaml.Unmarshal(normalizedData, &wf); err != nil {
t.Fatalf("failed to unmarshal workflow YAML: %v", err)
}
// convert legacy expressions
transformer := NewLegacyConfigTransformer()
for i := range wf.Plugins {
transformer.ConvertPluginConfig(&wf.Plugins[i])
for i := range wf.Views.Plugins {
transformer.ConvertPluginConfig(&wf.Views.Plugins[i])
}
// parse into plugin — this validates ruki parsing succeeds
schema := testSchema()
p, err := parsePluginConfig(wf.Plugins[0], "test", schema)
p, err := parsePluginConfig(wf.Views.Plugins[0], "test", schema)
if err != nil {
t.Fatalf("parsePluginConfig failed: %v", err)
}
@ -703,7 +714,7 @@ func TestLegacyWorkflowEndToEnd(t *testing.T) {
}
// verify sort was merged: backlog filter should have order by
backlogFilter := wf.Plugins[0].Lanes[0].Filter
backlogFilter := wf.Views.Plugins[0].Lanes[0].Filter
if !strings.Contains(backlogFilter, "order by") {
t.Errorf("expected sort merged into backlog filter, got: %s", backlogFilter)
}
@ -941,3 +952,67 @@ func TestConvertPluginConfig_PluginActionConvertError(t *testing.T) {
t.Errorf("malformed plugin action should be passed through unchanged, got %q", cfg.Actions[0].Action)
}
}
func TestConvertViewsFormat_ListToMap(t *testing.T) {
tr := NewLegacyConfigTransformer()
raw := map[string]interface{}{
"views": []interface{}{
map[string]interface{}{"name": "Kanban"},
map[string]interface{}{"name": "Backlog"},
},
}
tr.ConvertViewsFormat(raw)
views, ok := raw["views"].(map[string]interface{})
if !ok {
t.Fatalf("expected views to be a map after conversion, got %T", raw["views"])
}
plugins, ok := views["plugins"].([]interface{})
if !ok {
t.Fatalf("expected views.plugins to be a list, got %T", views["plugins"])
}
if len(plugins) != 2 {
t.Fatalf("expected 2 plugins, got %d", len(plugins))
}
}
func TestConvertViewsFormat_AlreadyMap(t *testing.T) {
tr := NewLegacyConfigTransformer()
raw := map[string]interface{}{
"views": map[string]interface{}{
"plugins": []interface{}{
map[string]interface{}{"name": "Kanban"},
},
"actions": []interface{}{},
},
}
tr.ConvertViewsFormat(raw)
// should be unchanged
views, ok := raw["views"].(map[string]interface{})
if !ok {
t.Fatalf("expected views to remain a map, got %T", raw["views"])
}
plugins, ok := views["plugins"].([]interface{})
if !ok {
t.Fatalf("expected views.plugins to be a list, got %T", views["plugins"])
}
if len(plugins) != 1 {
t.Fatalf("expected 1 plugin, got %d", len(plugins))
}
}
func TestConvertViewsFormat_NoViewsKey(t *testing.T) {
tr := NewLegacyConfigTransformer()
raw := map[string]interface{}{
"statuses": []interface{}{},
}
tr.ConvertViewsFormat(raw)
if _, ok := raw["views"]; ok {
t.Fatal("should not create views key when it doesn't exist")
}
}

View file

@ -13,33 +13,46 @@ import (
// WorkflowFile represents the YAML structure of a workflow.yaml file
type WorkflowFile struct {
Plugins []pluginFileConfig `yaml:"views"`
Views viewsSectionConfig `yaml:"views"`
}
// loadPluginsFromFile loads plugins from a single workflow.yaml file.
// Returns the successfully loaded plugins and any validation errors encountered.
func loadPluginsFromFile(path string, schema ruki.Schema) ([]Plugin, []string) {
// Returns the successfully loaded plugins, parsed global actions, and any validation errors encountered.
func loadPluginsFromFile(path string, schema ruki.Schema) ([]Plugin, []PluginAction, []string) {
data, err := os.ReadFile(path)
if err != nil {
slog.Warn("failed to read workflow.yaml", "path", path, "error", err)
return nil, []string{fmt.Sprintf("%s: %v", path, err)}
return nil, nil, []string{fmt.Sprintf("%s: %v", path, err)}
}
// pre-process raw YAML to handle legacy views format (list → map)
var raw map[string]interface{}
if err := yaml.Unmarshal(data, &raw); err != nil {
slog.Warn("failed to parse workflow.yaml", "path", path, "error", err)
return nil, nil, []string{fmt.Sprintf("%s: %v", path, err)}
}
transformer := NewLegacyConfigTransformer()
transformer.ConvertViewsFormat(raw)
normalizedData, err := yaml.Marshal(raw)
if err != nil {
slog.Warn("failed to re-marshal workflow.yaml after legacy conversion", "path", path, "error", err)
return nil, nil, []string{fmt.Sprintf("%s: %v", path, err)}
}
var wf WorkflowFile
if err := yaml.Unmarshal(data, &wf); err != nil {
if err := yaml.Unmarshal(normalizedData, &wf); err != nil {
slog.Warn("failed to parse workflow.yaml", "path", path, "error", err)
return nil, []string{fmt.Sprintf("%s: %v", path, err)}
return nil, nil, []string{fmt.Sprintf("%s: %v", path, err)}
}
if len(wf.Plugins) == 0 {
return nil, nil
if len(wf.Views.Plugins) == 0 {
return nil, nil, nil
}
// convert legacy expressions to ruki before parsing
transformer := NewLegacyConfigTransformer()
totalConverted := 0
for i := range wf.Plugins {
totalConverted += transformer.ConvertPluginConfig(&wf.Plugins[i])
for i := range wf.Views.Plugins {
totalConverted += transformer.ConvertPluginConfig(&wf.Views.Plugins[i])
}
if totalConverted > 0 {
slog.Info("converted legacy workflow expressions to ruki", "count", totalConverted, "path", path)
@ -47,7 +60,7 @@ func loadPluginsFromFile(path string, schema ruki.Schema) ([]Plugin, []string) {
var plugins []Plugin
var errs []string
for i, cfg := range wf.Plugins {
for i, cfg := range wf.Views.Plugins {
if cfg.Name == "" {
msg := fmt.Sprintf("%s: view at index %d has no name", path, i)
slog.Warn("skipping plugin with no name in workflow.yaml", "index", i, "path", path)
@ -76,7 +89,21 @@ func loadPluginsFromFile(path string, schema ruki.Schema) ([]Plugin, []string) {
slog.Info("loaded plugin", "name", p.GetName(), "path", path, "key", keyName(pk, pr), "modifier", pm)
}
return plugins, errs
// parse global plugin actions
var globalActions []PluginAction
if len(wf.Views.Actions) > 0 {
parser := ruki.NewParser(schema)
parsed, err := parsePluginActions(wf.Views.Actions, parser)
if err != nil {
slog.Warn("failed to parse global plugin actions", "path", path, "error", err)
errs = append(errs, fmt.Sprintf("%s: global actions: %v", path, err))
} else {
globalActions = parsed
slog.Info("loaded global plugin actions", "count", len(globalActions), "path", path)
}
}
return plugins, globalActions, errs
}
// mergePluginLists merges override plugins on top of base plugins.
@ -117,6 +144,7 @@ func mergePluginLists(base, overrides []Plugin) []Plugin {
// LoadPlugins loads all plugins from workflow.yaml files: user config (base) + project config (overrides).
// Files are discovered via config.FindWorkflowFiles() which returns user config first, then project config.
// Plugins from later files override same-named plugins from earlier files via field merging.
// Global actions are merged by key across files (later files override same-keyed globals from earlier files).
// Returns an error when workflow files were found but no valid plugins could be loaded.
func LoadPlugins(schema ruki.Schema) ([]Plugin, error) {
files := config.FindWorkflowFiles()
@ -126,20 +154,26 @@ func LoadPlugins(schema ruki.Schema) ([]Plugin, error) {
}
var allErrors []string
var allGlobalActions []PluginAction
// First file is the base (typically user config)
base, errs := loadPluginsFromFile(files[0], schema)
base, globalActions, errs := loadPluginsFromFile(files[0], schema)
allErrors = append(allErrors, errs...)
allGlobalActions = append(allGlobalActions, globalActions...)
// Remaining files are overrides, merged in order
for _, path := range files[1:] {
overrides, errs := loadPluginsFromFile(path, schema)
overrides, moreGlobals, errs := loadPluginsFromFile(path, schema)
allErrors = append(allErrors, errs...)
if len(overrides) > 0 {
base = mergePluginLists(base, overrides)
}
allGlobalActions = mergeGlobalActions(allGlobalActions, moreGlobals)
}
// merge global actions into each TikiPlugin
mergeGlobalActionsIntoPlugins(base, allGlobalActions)
if len(base) == 0 {
if len(allErrors) > 0 {
return nil, fmt.Errorf("no valid views loaded:\n %s\n\nTo install fresh defaults, remove the workflow file(s) and restart tiki:\n\n rm %s",
@ -154,6 +188,55 @@ func LoadPlugins(schema ruki.Schema) ([]Plugin, error) {
return base, nil
}
// mergeGlobalActions merges override global actions into base by key (rune).
// Overrides with the same rune replace the base action.
func mergeGlobalActions(base, overrides []PluginAction) []PluginAction {
if len(overrides) == 0 {
return base
}
byRune := make(map[rune]int, len(base))
result := make([]PluginAction, len(base))
copy(result, base)
for i, a := range result {
byRune[a.Rune] = i
}
for _, o := range overrides {
if idx, ok := byRune[o.Rune]; ok {
result[idx] = o
} else {
byRune[o.Rune] = len(result)
result = append(result, o)
}
}
return result
}
// mergeGlobalActionsIntoPlugins appends global actions to each TikiPlugin.
// Per-plugin actions with the same rune take precedence over globals (global is skipped).
func mergeGlobalActionsIntoPlugins(plugins []Plugin, globalActions []PluginAction) {
if len(globalActions) == 0 {
return
}
for _, p := range plugins {
tp, ok := p.(*TikiPlugin)
if !ok {
continue
}
localRunes := make(map[rune]bool, len(tp.Actions))
for _, a := range tp.Actions {
localRunes[a.Rune] = true
}
for _, ga := range globalActions {
if localRunes[ga.Rune] {
slog.Info("per-plugin action overrides global action",
"plugin", tp.Name, "key", string(ga.Rune), "global_label", ga.Label)
continue
}
tp.Actions = append(tp.Actions, ga)
}
}
}
// DefaultPlugin returns the first plugin marked as default, or the first plugin
// in the list if none are marked. The caller must ensure plugins is non-empty.
func DefaultPlugin(plugins []Plugin) Plugin {

View file

@ -4,6 +4,8 @@ import (
"os"
"path/filepath"
"testing"
"github.com/boolean-maybe/tiki/ruki"
)
func TestParsePluginConfig_FullyInline(t *testing.T) {
@ -140,7 +142,7 @@ func TestLoadPluginsFromFile_WorkflowFile(t *testing.T) {
t.Fatalf("Failed to write workflow.yaml: %v", err)
}
plugins, errs := loadPluginsFromFile(workflowPath, testSchema())
plugins, _, errs := loadPluginsFromFile(workflowPath, testSchema())
if len(errs) != 0 {
t.Fatalf("Expected no errors, got: %v", errs)
}
@ -174,7 +176,7 @@ func TestLoadPluginsFromFile_WorkflowFile(t *testing.T) {
func TestLoadPluginsFromFile_NoFile(t *testing.T) {
tmpDir := t.TempDir()
plugins, errs := loadPluginsFromFile(filepath.Join(tmpDir, "workflow.yaml"), testSchema())
plugins, _, errs := loadPluginsFromFile(filepath.Join(tmpDir, "workflow.yaml"), testSchema())
if plugins != nil {
t.Errorf("Expected nil plugins when no workflow.yaml, got %d", len(plugins))
}
@ -200,7 +202,7 @@ func TestLoadPluginsFromFile_InvalidPlugin(t *testing.T) {
}
// should load valid plugin and skip invalid one
plugins, errs := loadPluginsFromFile(workflowPath, testSchema())
plugins, _, errs := loadPluginsFromFile(workflowPath, testSchema())
if len(plugins) != 1 {
t.Fatalf("Expected 1 valid plugin (invalid skipped), got %d", len(plugins))
}
@ -253,7 +255,7 @@ func TestLoadPluginsFromFile_LegacyConversion(t *testing.T) {
t.Fatalf("write workflow.yaml: %v", err)
}
plugins, errs := loadPluginsFromFile(workflowPath, testSchema())
plugins, _, errs := loadPluginsFromFile(workflowPath, testSchema())
if len(errs) != 0 {
t.Fatalf("expected no errors, got: %v", errs)
}
@ -298,7 +300,7 @@ func TestLoadPluginsFromFile_UnnamedPlugin(t *testing.T) {
t.Fatalf("write workflow.yaml: %v", err)
}
plugins, errs := loadPluginsFromFile(workflowPath, testSchema())
plugins, _, errs := loadPluginsFromFile(workflowPath, testSchema())
// unnamed plugin should be skipped, valid one should load
if len(plugins) != 1 {
t.Fatalf("expected 1 valid plugin, got %d", len(plugins))
@ -318,7 +320,7 @@ func TestLoadPluginsFromFile_InvalidYAML(t *testing.T) {
t.Fatalf("write workflow.yaml: %v", err)
}
plugins, errs := loadPluginsFromFile(workflowPath, testSchema())
plugins, _, errs := loadPluginsFromFile(workflowPath, testSchema())
if plugins != nil {
t.Error("expected nil plugins for invalid YAML")
}
@ -336,7 +338,7 @@ func TestLoadPluginsFromFile_EmptyViews(t *testing.T) {
t.Fatalf("write workflow.yaml: %v", err)
}
plugins, errs := loadPluginsFromFile(workflowPath, testSchema())
plugins, _, errs := loadPluginsFromFile(workflowPath, testSchema())
if len(plugins) != 0 {
t.Errorf("expected 0 plugins for empty views, got %d", len(plugins))
}
@ -364,7 +366,7 @@ func TestLoadPluginsFromFile_DokiConfigIndex(t *testing.T) {
t.Fatalf("write workflow.yaml: %v", err)
}
plugins, errs := loadPluginsFromFile(workflowPath, testSchema())
plugins, _, errs := loadPluginsFromFile(workflowPath, testSchema())
if len(errs) != 0 {
t.Fatalf("expected no errors, got: %v", errs)
}
@ -416,6 +418,169 @@ func TestMergePluginLists(t *testing.T) {
}
}
func TestLoadPluginsFromFile_GlobalActions(t *testing.T) {
tmpDir := t.TempDir()
workflowContent := `views:
actions:
- key: "a"
label: "Assign to me"
action: update where id = id() set assignee=user()
plugins:
- name: Board
key: "B"
lanes:
- name: Todo
filter: select where status = "ready"
`
workflowPath := filepath.Join(tmpDir, "workflow.yaml")
if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil {
t.Fatalf("write workflow.yaml: %v", err)
}
plugins, globalActions, errs := loadPluginsFromFile(workflowPath, testSchema())
if len(errs) != 0 {
t.Fatalf("expected no errors, got: %v", errs)
}
if len(plugins) != 1 {
t.Fatalf("expected 1 plugin, got %d", len(plugins))
}
if len(globalActions) != 1 {
t.Fatalf("expected 1 global action, got %d", len(globalActions))
}
if globalActions[0].Rune != 'a' {
t.Errorf("expected rune 'a', got %q", globalActions[0].Rune)
}
if globalActions[0].Label != "Assign to me" {
t.Errorf("expected label 'Assign to me', got %q", globalActions[0].Label)
}
}
func TestLoadPluginsFromFile_LegacyFormatWithGlobalActions(t *testing.T) {
tmpDir := t.TempDir()
// old list format — should still load plugins (global actions not possible in old format)
workflowContent := `views:
- name: Board
key: "B"
lanes:
- name: Todo
filter: select where status = "ready"
`
workflowPath := filepath.Join(tmpDir, "workflow.yaml")
if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil {
t.Fatalf("write workflow.yaml: %v", err)
}
plugins, globalActions, errs := loadPluginsFromFile(workflowPath, testSchema())
if len(errs) != 0 {
t.Fatalf("expected no errors, got: %v", errs)
}
if len(plugins) != 1 {
t.Fatalf("expected 1 plugin, got %d", len(plugins))
}
if len(globalActions) != 0 {
t.Errorf("expected 0 global actions from legacy format, got %d", len(globalActions))
}
}
func TestMergeGlobalActions(t *testing.T) {
stmt := mustParseAction(t, `update where id = id() set status="ready"`)
base := []PluginAction{
{Rune: 'a', Label: "Assign", Action: stmt},
{Rune: 'b', Label: "Board", Action: stmt},
}
overrides := []PluginAction{
{Rune: 'b', Label: "Board Override", Action: stmt},
{Rune: 'c', Label: "Create", Action: stmt},
}
result := mergeGlobalActions(base, overrides)
if len(result) != 3 {
t.Fatalf("expected 3 actions, got %d", len(result))
}
// 'a' unchanged, 'b' overridden, 'c' appended
if result[0].Label != "Assign" {
t.Errorf("expected 'Assign', got %q", result[0].Label)
}
if result[1].Label != "Board Override" {
t.Errorf("expected 'Board Override', got %q", result[1].Label)
}
if result[2].Label != "Create" {
t.Errorf("expected 'Create', got %q", result[2].Label)
}
}
func TestMergeGlobalActions_EmptyOverrides(t *testing.T) {
stmt := mustParseAction(t, `update where id = id() set status="ready"`)
base := []PluginAction{{Rune: 'a', Label: "Assign", Action: stmt}}
result := mergeGlobalActions(base, nil)
if len(result) != 1 {
t.Fatalf("expected 1 action, got %d", len(result))
}
}
func TestMergeGlobalActionsIntoPlugins(t *testing.T) {
stmt := mustParseAction(t, `update where id = id() set status="ready"`)
plugins := []Plugin{
&TikiPlugin{
BasePlugin: BasePlugin{Name: "Board"},
Actions: []PluginAction{{Rune: 'b', Label: "Board action", Action: stmt}},
},
&TikiPlugin{
BasePlugin: BasePlugin{Name: "Backlog"},
Actions: nil,
},
&DokiPlugin{
BasePlugin: BasePlugin{Name: "Help"},
},
}
globals := []PluginAction{
{Rune: 'a', Label: "Assign", Action: stmt},
{Rune: 'b', Label: "Global board", Action: stmt}, // conflicts with Board's 'b'
}
mergeGlobalActionsIntoPlugins(plugins, globals)
// Board: should have 'b' (local) + 'a' (global) — 'b' global skipped
board, ok := plugins[0].(*TikiPlugin)
if !ok {
t.Fatalf("Board: expected *TikiPlugin, got %T", plugins[0])
}
if len(board.Actions) != 2 {
t.Fatalf("Board: expected 2 actions, got %d", len(board.Actions))
}
if board.Actions[0].Label != "Board action" {
t.Errorf("Board: first action should be local 'Board action', got %q", board.Actions[0].Label)
}
if board.Actions[1].Label != "Assign" {
t.Errorf("Board: second action should be global 'Assign', got %q", board.Actions[1].Label)
}
// Backlog: should have both globals ('a' and 'b')
backlog, ok := plugins[1].(*TikiPlugin)
if !ok {
t.Fatalf("Backlog: expected *TikiPlugin, got %T", plugins[1])
}
if len(backlog.Actions) != 2 {
t.Fatalf("Backlog: expected 2 actions, got %d", len(backlog.Actions))
}
// Help (DokiPlugin): should have no actions (skipped)
// DokiPlugin has no Actions field — nothing to check
}
func mustParseAction(t *testing.T, input string) *ruki.ValidatedStatement {
t.Helper()
parser := testParser()
stmt, err := parser.ParseAndValidateStatement(input, ruki.ExecutorRuntimePlugin)
if err != nil {
t.Fatalf("parse ruki statement %q: %v", input, err)
}
return stmt
}
func TestDefaultPlugin_MultipleDefaults(t *testing.T) {
plugins := []Plugin{
&TikiPlugin{BasePlugin: BasePlugin{Name: "A"}},

View file

@ -1,5 +1,15 @@
package plugin
// viewsSectionConfig represents the YAML structure of the views section.
// views:
//
// actions: [...] # global plugin actions
// plugins: [...] # plugin definitions
type viewsSectionConfig struct {
Actions []PluginActionConfig `yaml:"actions"`
Plugins []pluginFileConfig `yaml:"plugins"`
}
// pluginFileConfig represents the YAML structure of a plugin file
type pluginFileConfig struct {
Name string `yaml:"name"`

View file

@ -210,10 +210,6 @@ func parsePluginActions(configs []PluginActionConfig, parser *ruki.Parser) ([]Pl
if err != nil {
return nil, fmt.Errorf("parsing action %d (key %q): %w", i, cfg.Key, err)
}
if actionStmt.IsSelect() && !actionStmt.IsPipe() {
return nil, fmt.Errorf("action %d (key %q) must be UPDATE, CREATE, DELETE, or a piped SELECT — not plain SELECT", i, cfg.Key)
}
actions = append(actions, PluginAction{
Rune: r,
Label: cfg.Label,

View file

@ -495,17 +495,20 @@ func TestParsePluginConfig_LaneActionMustBeUpdate(t *testing.T) {
}
}
func TestParsePluginActions_SelectRejectedAsAction(t *testing.T) {
func TestParsePluginActions_SelectAllowedAsAction(t *testing.T) {
parser := testParser()
configs := []PluginActionConfig{
{Key: "s", Label: "Search", Action: `select where status = "ready"`},
}
_, err := parsePluginActions(configs, parser)
if err == nil {
t.Fatal("expected error for SELECT as plugin action")
actions, err := parsePluginActions(configs, parser)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(err.Error(), "not plain SELECT") {
t.Errorf("expected 'not plain SELECT' error, got: %v", err)
if len(actions) != 1 {
t.Fatalf("expected 1 action, got %d", len(actions))
}
if !actions[0].Action.IsSelect() {
t.Error("expected action to be a SELECT statement")
}
}

View file

@ -50,16 +50,17 @@ how Backlog is defined:
```yaml
views:
- name: Backlog
key: "F3"
lanes:
- name: Backlog
columns: 4
filter: select where status = "backlog" and type != "epic" order by priority, id
actions:
- key: "b"
label: "Add to board"
action: update where id = id() set status="ready"
plugins:
- name: Backlog
key: "F3"
lanes:
- name: Backlog
columns: 4
filter: select where status = "backlog" and type != "epic" order by priority, id
actions:
- key: "b"
label: "Add to board"
action: update where id = id() set status="ready"
```
that translates to - show all tikis in the status `backlog`, sort by priority and then by ID arranged visually in 4 columns in a single lane.
@ -70,11 +71,12 @@ Likewise the documentation is just a plugin:
```yaml
views:
- name: Docs
type: doki
fetcher: file
url: "index.md"
key: "F2"
plugins:
- name: Docs
type: doki
fetcher: file
url: "index.md"
key: "F2"
```
that translates to - show `index.md` file located under `.doc/doki`
@ -130,7 +132,24 @@ lanes:
If no lanes specify width, all lanes are equally sized (the default behavior).
### Plugin actions
### Global plugin actions
You can define actions under `views.actions` that are available in **all** tiki plugin views:
```yaml
views:
actions:
- key: "a"
label: "Assign to me"
action: update where id = id() set assignee=user()
plugins:
- name: Kanban
...
```
Global actions appear in the header alongside per-plugin actions. If a per-plugin action uses the same key, the per-plugin action takes precedence for that view. When multiple workflow files define `views.actions`, they merge by key — later files override same-keyed globals from earlier files.
### Per-plugin actions
In addition to lane actions that trigger when moving tikis between lanes, you can define plugin-level actions
that apply to the currently selected tiki via a keyboard shortcut. These shortcuts are displayed in the header when the plugin is active.
@ -148,14 +167,16 @@ actions:
Each action has:
- `key` - a single printable character used as the keyboard shortcut
- `label` - description shown in the header
- `action` - a `ruki` `update` statement (same syntax as lane actions, see below)
- `action` - a `ruki` statement (`update`, `create`, `delete`, or `select`)
When the shortcut key is pressed, the action is applied to the currently selected tiki.
For example, pressing `b` in the Backlog plugin changes the selected tiki's status to `ready`, effectively moving it to the board.
`select` actions execute for side-effects only — the output is ignored. They don't require a selected tiki.
### ruki expressions
Plugin filters, lane actions, and plugin actions all use the `ruki` language. Filters use `select` statements and actions use `update` statements.
Plugin filters, lane actions, and plugin actions all use the `ruki` language. Filters use `select` statements. Actions support `update`, `create`, `delete`, and `select` statements (`select` for side-effects only, output ignored).
#### Filter (select)