Compare commits

...

53 commits
v0.4.1 ... main

Author SHA1 Message Date
boolean-maybe
e1bf8bd4d9
Merge pull request #102 from boolean-maybe/dev
Some checks failed
Go / Test (push) Has been cancelled
Go / Lint (push) Has been cancelled
Go / Build (push) Has been cancelled
update readme
2026-04-19 23:32:17 -04:00
booleanmaybe
41883b510d update readme 2026-04-19 23:27:11 -04:00
boolean-maybe
4891de4163
Merge pull request #100 from boolean-maybe/dev
0.5.0
2026-04-19 22:09:28 -04:00
booleanmaybe
8a68544ac9 add version field 2026-04-19 22:05:00 -04:00
booleanmaybe
f4e425b08b remove v from version 2026-04-19 21:43:51 -04:00
boolean-maybe
129f847622
Merge pull request #99 from boolean-maybe/fix/reassign-action-palette-key
reassign action palette key
2026-04-19 20:24:55 -04:00
booleanmaybe
7abddcd4c6 reassign action palette key 2026-04-19 20:24:03 -04:00
boolean-maybe
ef25f5ff98
Merge pull request #98 from boolean-maybe/fix/actions-in-edit-mode
fix action firing on printable character
2026-04-19 19:48:04 -04:00
booleanmaybe
48e796877e fix action firing on printable character 2026-04-19 19:47:21 -04:00
booleanmaybe
8838c29149 reassign action palette to asterisk 2026-04-19 17:02:07 -04:00
boolean-maybe
0312ad8fca
Merge pull request #93 from boolean-maybe/feature/input-action
input action
2026-04-19 16:33:04 -04:00
booleanmaybe
232737940b input action 2026-04-19 16:32:10 -04:00
booleanmaybe
f377bb54dd single border by default 2026-04-19 10:13:18 -04:00
booleanmaybe
8cc1614724 add non-header action 2026-04-19 09:50:50 -04:00
booleanmaybe
025342c3af plugin expose selectable view interface 2026-04-19 09:24:55 -04:00
booleanmaybe
2175551e38 action palette single border 2026-04-19 09:03:24 -04:00
boolean-maybe
2a6c1201f8
Merge pull request #92 from boolean-maybe/feature/action-palette
Feature/action palette
2026-04-19 00:34:04 -04:00
booleanmaybe
936942d7b5 missing type default 2026-04-19 00:32:46 -04:00
booleanmaybe
2a50feb6fb fix action palette layout 2026-04-18 23:59:25 -04:00
booleanmaybe
50a3d8ca20 fix action palette transparency 2026-04-18 23:35:23 -04:00
booleanmaybe
1596bc9c39 action palette phase 3-4 2026-04-18 23:24:21 -04:00
booleanmaybe
db019108be action palette phase 2 2026-04-18 23:09:01 -04:00
booleanmaybe
7b7136ce5e action palette phase 1 2026-04-18 22:27:14 -04:00
boolean-maybe
80e7f1e510
Merge pull request #91 from boolean-maybe/feature/workflow-description
workflow description
2026-04-18 09:52:34 -04:00
booleanmaybe
f7ca8b44fa workflow description 2026-04-18 09:51:36 -04:00
boolean-maybe
11b4ae2a7b
Merge pull request #90 from boolean-maybe/feature/bug-tracker-workflow
bug-tracker workflow
2026-04-17 23:59:46 -04:00
booleanmaybe
529835df1c bug-tracker workflow 2026-04-17 23:58:52 -04:00
boolean-maybe
a63ad74845
Merge pull request #89 from boolean-maybe/worktree-feature+named-workflows
basic workflows
2026-04-16 21:45:25 -04:00
booleanmaybe
47a57afc1c basic workflows 2026-04-16 21:44:10 -04:00
boolean-maybe
351d7817e3
Merge pull request #88 from boolean-maybe/feature/install-workflow
tiki workflow command
2026-04-16 20:32:46 -04:00
booleanmaybe
56d3b64c22 tiki workflow command 2026-04-16 20:31:39 -04:00
boolean-maybe
6713b5b7c5
Merge pull request #86 from boolean-maybe/worktree-feature+custom-type
custom types
2026-04-16 15:42:20 -04:00
booleanmaybe
952c095372 custom types 2026-04-16 15:35:28 -04:00
boolean-maybe
73a95e2c5b
Merge pull request #85 from boolean-maybe/feature/config-command
config reset
2026-04-15 21:11:42 -04:00
booleanmaybe
7169f53c20 config reset 2026-04-15 21:01:00 -04:00
boolean-maybe
a226f433d4
Merge pull request #84 from boolean-maybe/feature/custom-fields
custom fields
2026-04-15 19:46:09 -04:00
booleanmaybe
3f86eb93ce custom fields 2026-04-15 18:32:55 -04:00
booleanmaybe
3b33659338 redefine skills based on ruki 2026-04-15 00:24:38 -04:00
boolean-maybe
80444f1848
Merge pull request #77 from boolean-maybe/feature/ruki-limit
add limit clause
2026-04-14 23:35:20 -04:00
booleanmaybe
31cfd46453 add limit clause 2026-04-14 23:31:32 -04:00
booleanmaybe
14d46d4802 shut up linter 2026-04-14 23:28:26 -04:00
booleanmaybe
ae3634da1b appease linter 2026-04-14 23:04:07 -04:00
booleanmaybe
70d572b7a4 appease linter 2026-04-14 22:54:44 -04:00
booleanmaybe
62468209bc appease linter 2026-04-14 22:41:16 -04:00
booleanmaybe
692e559d5f appease linter 2026-04-14 22:36:27 -04:00
booleanmaybe
b52e20d30f appease linter 2026-04-14 22:26:28 -04:00
booleanmaybe
d2c28655bd add llms.txt 2026-04-14 22:16:18 -04:00
booleanmaybe
aefb6a757d clipboard builtin 2026-04-14 21:15:09 -04:00
boolean-maybe
3c658f7332
Merge pull request #76 from boolean-maybe/feature/plugin-actions
global plugin actions
2026-04-14 19:31:08 -04:00
booleanmaybe
e275604e85 global plugin actions 2026-04-14 19:21:29 -04:00
boolean-maybe
12a0ea86f3
Merge pull request #75 from boolean-maybe/worktree-ruki-pipe-syntax
ruki pipes
2026-04-14 18:42:25 -04:00
booleanmaybe
9ec6588f6b ruki pipes 2026-04-14 18:13:50 -04:00
booleanmaybe
7f6f3654c3 enable skill 2026-04-14 10:17:24 -04:00
172 changed files with 14762 additions and 2522 deletions

View file

@ -0,0 +1,159 @@
# Command line options
## Usage
```
tiki [command] [options]
```
Running `tiki` with no arguments launches the TUI in an initialized project.
## Commands
### init
Initialize a tiki project in the current git repository. Creates the `.doc/tiki/` directory structure for task storage.
```bash
tiki init
```
### exec
Execute a [ruki](ruki/index.md) query and exit. Requires an initialized project.
```bash
tiki exec '<ruki-statement>'
```
Examples:
```bash
tiki exec 'select where status = "ready" order by priority'
tiki exec 'update where id = "TIKI-ABC123" set status="done"'
```
### workflow
Manage workflow configuration files.
#### workflow reset
Reset configuration files to their defaults.
```bash
tiki workflow reset [target] [--scope]
```
**Targets** (omit to reset all three files):
- `config` — config.yaml
- `workflow` — workflow.yaml
- `new` — new.md (task template)
**Scopes** (default: `--local`):
- `--global` — user config directory
- `--local` — project config directory (`.doc/`)
- `--current` — current working directory
For `--global`, workflow.yaml and new.md are overwritten with embedded defaults. config.yaml is deleted (built-in defaults take over).
For `--local` and `--current`, files are deleted so the next tier in the [precedence chain](config.md#precedence-and-merging) takes effect.
```bash
# restore all global config to defaults
tiki workflow reset --global
# remove project workflow overrides (falls back to global)
tiki workflow reset workflow --local
# remove cwd config override
tiki workflow reset config --current
```
#### workflow install
Install a named workflow from the tiki repository. Downloads `workflow.yaml` and `new.md` into the scope directory, overwriting any existing files.
```bash
tiki workflow install <name> [--scope]
```
**Scopes** (default: `--local`):
- `--global` — user config directory
- `--local` — project config directory (`.doc/`)
- `--current` — current working directory
```bash
# install the sprint workflow globally
tiki workflow install sprint --global
# install the kanban workflow for the current project
tiki workflow install kanban --local
```
#### workflow describe
Fetch a workflow's description from the tiki repository and print it to stdout.
Reads the top-level `description` field of the named workflow's `workflow.yaml`.
Prints nothing and exits 0 if the workflow has no description field.
```bash
tiki workflow describe <name>
```
**Examples:**
```bash
# preview the todo workflow before installing it
tiki workflow describe todo
# check what bug-tracker is for
tiki workflow describe bug-tracker
```
### demo
Clone the demo project and launch the TUI. If the `tiki-demo` directory already exists it is reused.
```bash
tiki demo
```
### sysinfo
Display system and terminal environment information useful for troubleshooting.
```bash
tiki sysinfo
```
## Markdown viewer
`tiki` doubles as a standalone markdown and image viewer. Pass a file path or URL as the first argument.
```bash
tiki file.md
tiki https://github.com/user/repo/blob/main/README.md
tiki image.png
echo "# Hello" | tiki -
```
See [Markdown viewer](markdown-viewer.md) for navigation and keybindings.
## Piped input
When stdin is piped and no positional arguments are given, tiki creates a task from the input. The first line becomes the title; the rest becomes the description.
```bash
echo "Fix the login bug" | tiki
tiki < bug-report.md
```
See [Quick capture](quick-capture.md) for more examples.
## Flags
| Flag | Description |
|---|---|
| `--help`, `-h` | Show usage information |
| `--version`, `-v` | Show version, commit, and build date |
| `--log-level <level>` | Set log level: `debug`, `info`, `warn`, `error` |

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,109 @@ 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()
- key: "A"
label: "Assign to..."
action: update where id = id() set assignee=input()
input: string
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: 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

@ -0,0 +1,249 @@
# Custom Fields
## Table of contents
- [Overview](#overview)
- [Defining custom fields](#defining-custom-fields)
- [Field types](#field-types)
- [Enum fields](#enum-fields)
- [Using custom fields in ruki](#using-custom-fields-in-ruki)
- [Storage and frontmatter](#storage-and-frontmatter)
- [Templates](#templates)
- [Missing field behavior](#missing-field-behavior)
## Overview
Custom fields let you extend tikis with project-specific data beyond the built-in fields (title, status, priority, etc.). Define them in `workflow.yaml` and they become first-class citizens: usable in ruki queries, persisted in task frontmatter, and available across all views.
Use cases include:
- tracking a sprint or milestone name
- adding an effort estimate or story-point alternative
- flagging tasks with a boolean (e.g. `blocked`, `reviewed`)
- recording a deadline timestamp with time-of-day precision
- categorizing tasks with a constrained set of values (enum)
- linking related tasks beyond `dependsOn`
## Defining custom fields
Add a `fields:` section to your `workflow.yaml`:
```yaml
fields:
- name: sprint
type: text
- name: effort
type: integer
- name: blocked
type: boolean
- name: deadline
type: datetime
- name: category
type: enum
values:
- frontend
- backend
- infra
- docs
- name: reviewers
type: stringList
- name: relatedTasks
type: taskIdList
```
Field names must not collide with built-in field names or ruki reserved keywords.
Custom fields follow the same merge semantics as other `workflow.yaml` sections. If the same field is defined identically in multiple files (user config, project config, cwd), the duplicate is silently accepted. If definitions conflict (different type or different enum values), loading fails with an error.
## Field types
| YAML type | Description | ruki type |
|---------------|---------------------------------------|------------------|
| `text` | free-form string | `string` |
| `integer` | whole number | `int` |
| `boolean` | true or false | `bool` |
| `datetime` | timestamp (RFC3339 or YYYY-MM-DD) | `timestamp` |
| `enum` | constrained string from `values` list | `enum` |
| `stringList` | list of strings | `list<string>` |
| `taskIdList` | list of tiki ID references | `list<ref>` |
## Enum fields
Enum fields require a `values:` list. Only those values are accepted when setting the field (case-insensitive matching, canonical casing preserved). Attempting to assign a value outside the list produces a validation error.
```yaml
fields:
- name: severity
type: enum
values:
- critical
- major
- minor
- trivial
```
Enum domains are field-scoped: two different enum fields maintain independent value sets. Cross-field enum assignment (e.g. `set severity = category`) is rejected even if the values happen to overlap.
Non-enum fields must not include a `values:` list.
## Using custom fields in ruki
Custom fields work the same as built-in fields in all ruki contexts: `select`, `update`, `create`, `order by`, `where`, and triggers.
### Filtering with select where
```sql
-- find blocked tasks
select where blocked = true
-- find tasks in a specific sprint
select where sprint = "sprint-7"
-- find critical tasks in the frontend category
select where severity = "critical" and category = "frontend"
-- find tasks with high effort
select where effort > 5
-- find tasks with a deadline before a date
select where deadline < 2026-05-01
```
### Updating with update set
```sql
-- assign a sprint
update where id = id() set sprint="sprint-7"
-- mark as blocked
update where id = id() set blocked=true
-- set category and severity
update where id = id() set category="backend" severity="major"
-- clear a custom field (set to empty)
update where id = id() set sprint=empty
-- add a reviewer
update where id = id() set reviewers=reviewers + ["alice"]
```
### Ordering with order by
```sql
-- sort by effort descending
select where status = "ready" order by effort desc
-- sort by category, then priority
select where status = "backlog" order by category, priority
-- sort by deadline
select where deadline is not empty order by deadline
```
### Creating with custom field defaults
```sql
-- create a task with custom fields
create title="New feature" category="frontend" effort=3
-- create with enum and boolean
create title="Fix crash" severity="critical" blocked=false
```
### Plugin filters and actions
Custom fields integrate into plugin definitions in `workflow.yaml`:
```yaml
views:
plugins:
- name: Sprint Board
key: "F5"
lanes:
- name: Current Sprint
filter: select where sprint = "sprint-7" and status != "done" order by effort desc
action: update where id = id() set sprint="sprint-7"
- name: Next Sprint
filter: select where sprint = "sprint-8" order by priority
action: update where id = id() set sprint="sprint-8"
actions:
- key: "b"
label: "Mark blocked"
action: update where id = id() set blocked=true
```
## Storage and frontmatter
Custom fields are stored in task frontmatter alongside built-in fields:
```yaml
---
title: Implement search
type: story
status: in_progress
priority: 2
points: 3
tags:
- search
sprint: sprint-7
blocked: false
category: backend
effort: 5
deadline: 2026-05-15T17:00:00Z
reviewers:
- alice
- bob
relatedTasks:
- TIKI-ABC123
---
Search implementation details...
```
Custom fields appear after the built-in fields, sorted alphabetically by name.
On load, unknown frontmatter keys that are not registered custom fields are preserved as-is and survive save-load round-trips. This allows workflow changes without losing data — see [Schema evolution](ruki/custom-fields-reference.md#schema-evolution-and-stale-data) for details.
## Templates
Custom fields can have defaults in `new.md`:
```markdown
---
type: story
status: backlog
priority: 3
points: 1
sprint: sprint-7
blocked: false
category: backend
---
```
Custom field values in the template are validated against their type definitions and enum constraints, the same as in task files.
## Missing field behavior
When a custom field is not set on a task, ruki returns the typed zero value for that field's type:
| Field type | Zero value |
|---------------|--------------------|
| `text` | `""` (empty string)|
| `integer` | `0` |
| `boolean` | `false` |
| `datetime` | zero time |
| `enum` | `""` (empty string)|
| `stringList` | `[]` (empty list) |
| `taskIdList` | `[]` (empty list) |
This means `select where blocked = false` matches both tasks explicitly set to `false` and tasks that never had the `blocked` field set. Use `is empty` / `is not empty` to distinguish:
```sql
-- tasks that explicitly have blocked set (to any value)
select where blocked is not empty
-- tasks where blocked was never set
select where blocked is empty
```
Note: for boolean and integer fields, the zero value (`false`, `0`) is also the `empty` value. An explicitly stored `false` and a missing boolean field are indistinguishable at query time. If you need the distinction, consider using an enum field with explicit values (e.g. `yes` / `no` / not set via `empty`).

View file

@ -0,0 +1,125 @@
# Custom Statuses and Types
Statuses and types are user-configurable via `workflow.yaml`.
Both follow the same structural rules with a few differences noted below.
## Configuration
### YAML Shape
```yaml
statuses:
- key: backlog
label: Backlog
emoji: "📥"
default: true
- key: inProgress
label: "In Progress"
emoji: "⚙️"
active: true
- key: done
label: Done
emoji: "✅"
done: true
types:
- key: story
label: Story
emoji: "🌀"
- key: bug
label: Bug
emoji: "💥"
```
### Shared Rules
These rules apply identically to both `statuses:` and `types:`:
| Rule | Detail |
|---|---|
| Canonical keys | Keys must already be in canonical form. Non-canonical keys are rejected with a suggested canonical form. |
| Label defaults to key | When `label` is omitted, the key is used as the label. |
| Empty labels rejected | Explicitly empty or whitespace-only labels are invalid. |
| Emoji trimmed | Leading/trailing whitespace is stripped from emoji values. |
| Unique display strings | Each entry must produce a unique `"Label Emoji"` display. Duplicates are rejected. |
| At least one entry | An empty list is invalid. |
| Duplicate keys rejected | Two entries with the same canonical key are invalid. |
| Unknown keys rejected | Only documented keys are allowed in each entry. |
### Status-Only Keys
Statuses support additional boolean flags that types do not:
| Key | Required | Description |
|---|---|---|
| `active` | no | Marks a status as active (in-progress work). |
| `default` | exactly one | The status assigned to newly created tasks. |
| `done` | exactly one | The terminal status representing completion. |
Valid keys in a status entry: `key`, `label`, `emoji`, `active`, `default`, `done`.
### Type-Only Behavior
Types have no boolean flags. The first configured type is used as the creation default.
Valid keys in a type entry: `key`, `label`, `emoji`.
### Key Normalization
Status and type keys use different normalization rules:
- **Status keys** use camelCase. Splits on `_`, `-`, ` `, and camelCase boundaries, then reassembles as camelCase.
Examples: `"in_progress"` -> `"inProgress"`, `"In Progress"` -> `"inProgress"`.
- **Type keys** are lowercased with all separators stripped.
Examples: `"My-Type"` -> `"mytype"`, `"some_thing"` -> `"something"`.
Keys in `workflow.yaml` must already be in their canonical form. Input normalization (from user queries, ruki expressions, etc.) still applies at lookup time.
### Inheritance and Override
- A section (`statuses:` or `types:`) absent from a workflow file means "no opinion" -- it does not override the inherited value.
- A non-empty section fully replaces inherited/built-in entries. No merging across files.
- The last file (most specific location) with the section present wins.
- If no file defines `types:`, built-in defaults are used (`story`, `bug`, `spike`, `epic`).
- Statuses have no built-in fallback -- at least one workflow file must define `statuses:`.
## Failure Behavior
### Invalid Configuration
| Scenario | Behavior |
|---|---|
| Empty list | Error |
| Non-canonical key | Error with suggested canonical form |
| Empty/whitespace label | Error |
| Duplicate display string | Error |
| Unknown key in entry | Error |
| Missing `default: true` (statuses) | Error |
| Missing `done: true` (statuses) | Error |
| Multiple `default: true` (statuses) | Error |
| Multiple `done: true` (statuses) | Error |
### Invalid Saved Tasks
- A tiki with a missing or unknown `type` fails to load and is skipped.
- On single-task reload (`ReloadTask`), an invalid file causes the task to be removed from memory.
### Invalid Templates
- Missing `type` in a template defaults to the first configured type.
- Invalid non-empty `type` in a template is a hard error; creation is aborted.
### Sample Tasks at Init
- Each embedded sample is validated against the active registries before writing.
- Incompatible samples are silently skipped.
- `tiki init` offers a "Create sample tasks" checkbox (default: enabled).
### Cross-Reference Errors
If a `types:` override removes type keys still referenced by inherited views, actions, or triggers, startup fails with a configuration error. There is no silent view-skipping or automatic remapping.
## Pre-Init Rules
Calling type or status helpers (`task.ParseType()`, `task.AllTypes()`, `task.DefaultType()`, `task.ParseStatus()`, etc.) before `config.LoadWorkflowRegistries()` is a programmer error and panics.

View file

@ -4,10 +4,23 @@ tiki is highly customizable. `workflow.yaml` lets you define your workflow statu
how tikis are displayed and organized. Statuses define the lifecycle stages your tasks move through,
while plugins control what you see and how you interact with your work. This section covers both.
## Description
An optional top-level `description:` field in `workflow.yaml` describes what
the workflow is for. It supports multi-line text via YAML's block scalar (`|`)
and is used by `tiki workflow describe <name>` to preview a workflow before
installing it.
```yaml
description: |
Release workflow. Coordinate feature rollout through
Planned → Building → Staging → Canary → Released.
```
## Statuses
Workflow statuses are defined in `workflow.yaml` under the `statuses:` key. Every tiki project must define
its statuses here — there is no hardcoded fallback. The default `workflow.yaml` ships with:
its statuses here — there is no hardcoded fallback. See [Custom statuses and types](custom-status-type.md). The default `workflow.yaml` ships with:
```yaml
statuses:
@ -19,7 +32,7 @@ statuses:
label: Ready
emoji: "📋"
active: true
- key: in_progress
- key: inProgress
label: "In Progress"
emoji: "⚙️"
active: true
@ -34,15 +47,43 @@ statuses:
```
Each status has:
- `key` — canonical identifier (lowercase, underscores). Used in filters, actions, and frontmatter.
- `label` — display name shown in the UI
- `key` — canonical camelCase identifier. Used in filters, actions, and frontmatter.
- `label` — display name shown in the UI (defaults to key when omitted)
- `emoji` — emoji shown alongside the label
- `active` — marks the status as "active work" (used for activity tracking)
- `default` — the status assigned to new tikis (exactly one status should have this)
- `done` — marks the status as "completed" (used for completion tracking)
- `default` — the status assigned to new tikis (exactly one required)
- `done` — marks the status as "completed" (exactly one required)
You can customize these to match your team's workflow. All filters and actions in view definitions (see below) must reference valid status keys.
## Types
Task types are defined in `workflow.yaml` under the `types:` key. If omitted, built-in defaults are used.
See [Custom statuses and types](custom-status-type.md) for the full validation and inheritance rules. The default `workflow.yaml` ships with:
```yaml
types:
- key: story
label: Story
emoji: "🌀"
- key: bug
label: Bug
emoji: "💥"
- key: spike
label: Spike
emoji: "🔍"
- key: epic
label: Epic
emoji: "🗂️"
```
Each type has:
- `key` — canonical lowercase identifier. Used in filters, actions, and frontmatter.
- `label` — display name shown in the UI (defaults to key when omitted)
- `emoji` — emoji shown alongside the label
The first configured type is used as the default for new tikis.
## Task Template
When you create a new tiki — whether in the TUI or command line — field defaults come from a template file.
@ -53,8 +94,7 @@ The file uses YAML frontmatter for field defaults
```markdown
---
type: story
status: backlog
title:
points: 1
priority: 3
tags:
@ -62,6 +102,8 @@ tags:
---
```
Type and status are omitted but can be added, otherwise they default to the first configured type and the status marked `default: true`.
## Plugins
tiki TUI app is much like a lego - everything is a customizable view. Here is, for example,
@ -69,17 +111,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 +133,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 +195,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.
@ -168,15 +233,75 @@ 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)
- `label` - description shown in the header and action palette
- `action` - a `ruki` statement (`update`, `create`, `delete`, or `select`)
- `hot` - (optional) controls header visibility. `hot: true` shows the action in the header, `hot: false` hides it. When absent, actions default to visible in the header. This does not affect the action palette — all actions are always discoverable via `?` regardless of the `hot` setting
- `input` - (optional) declares that the action prompts for user input before executing. The value is the scalar type of the input: `string`, `int`, `bool`, `date`, `timestamp`, or `duration`. The action's `ruki` statement must use `input()` to reference the value
Example — keeping a verbose action out of the header but still accessible from the palette:
```yaml
actions:
- key: "x"
label: "Archive and notify"
action: update where id = id() set status="done"
hot: false
```
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.
### Input-backed actions
Actions with `input:` prompt the user for a value before executing. When the action key is pressed, a modal input box opens with the action label as the prompt. The user types a value and presses Enter to execute, or Esc to cancel.
```yaml
actions:
- key: "A"
label: "Assign to..."
action: update where id = id() set assignee = input()
input: string
- key: "t"
label: "Add tag"
action: update where id = id() set tags = tags + [input()]
input: string
- key: "T"
label: "Remove tag"
action: update where id = id() set tags = tags - [input()]
input: string
- key: "p"
label: "Set points"
action: update where id = id() set points = input()
input: int
- key: "D"
label: "Set due date"
action: update where id = id() set due = input()
input: date
```
The input box is modal while editing — other actions are blocked until Enter or Esc. If the entered value is invalid for the declared type (e.g. non-numeric text for `int`), an error appears in the statusline and the prompt stays open for correction.
Supported `input:` types: `string`, `int`, `bool`, `date` (YYYY-MM-DD), `timestamp` (RFC3339 or YYYY-MM-DD), `duration` (e.g. `2day`, `1week`).
Validation rules:
- An action with `input:` must use `input()` in its `ruki` statement
- An action using `input()` must declare `input:` — otherwise the workflow fails to load
- `input()` may only appear once per action
### Search and input box interaction
The input box serves both search and action-input, with explicit mode tracking:
- **Search editing**: pressing `/` opens the input box focused for typing. Enter with text applies the search and transitions to **search passive** mode. Enter on empty text is a no-op. Esc clears search and closes the box.
- **Search passive**: the search box remains visible as a non-editable indicator showing the active query, while normal task navigation and actions are re-enabled. Pressing `/` again is blocked — dismiss the active search with Esc first, then open a new search. Esc clears the search results and closes the box.
- **Action input**: pressing an input-backed action key opens a modal prompt. If search was passive, the prompt temporarily replaces the search indicator. Valid Enter executes the action and restores the passive search indicator (or closes if no prior search). Esc cancels and likewise restores passive search. Invalid Enter keeps the prompt open for correction.
- **Modal blocking**: while search editing or action input is active, all other plugin actions and keyboard shortcuts are blocked. The action palette cannot open while the input box is editing.
### 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)
@ -215,7 +340,7 @@ update where id = id() set assignee=user()
- `id` - task identifier (e.g., "TIKI-M7N2XK")
- `title` - task title text
- `type` - task type: "story", "bug", "spike", or "epic"
- `type` - task type (must match a key defined in `workflow.yaml` types)
- `status` - workflow status (must match a key defined in `workflow.yaml` statuses)
- `assignee` - assigned user
- `priority` - numeric priority value (1-5)
@ -246,6 +371,7 @@ update where id = id() set assignee=user()
- `user()` — current user
- `now()` — current timestamp
- `id()` — currently selected tiki (in plugin context)
- `input()` — user-supplied value (in actions with `input:` declaration)
- `count(select where ...)` — count matching tikis
For the full language reference, see the [ruki documentation](ruki/index.md).
For the full language reference, see the [ruki documentation](ruki/index.md).

View file

@ -3,6 +3,7 @@
- [Assign to me](#assign-to-me--plugin-action)
- [Add tag to task](#add-tag-to-task--plugin-action)
- [Custom status + reject action](#custom-status--reject-action)
- [Implement with Claude Code](#implement-with-claude-code--pipe-action)
- [Search all tikis](#search-all-tikis--single-lane-plugin)
- [Quick assign](#quick-assign--lane-based-assignment)
- [Stale task detection](#stale-task-detection--time-trigger--plugin)
@ -10,19 +11,25 @@
- [Recent ideas](#recent-ideas--good-or-trash)
- [Auto-delete stale tasks](#auto-delete-stale-tasks--time-trigger)
- [Priority triage](#priority-triage--five-lane-plugin)
- [Sprint board](#sprint-board--custom-enum-lanes)
- [Severity triage](#severity-triage--custom-enum-filter--action)
- [Subtasks in epic](#subtasks-in-epic--custom-taskidlist--quantifier-trigger)
- [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.
@ -58,6 +65,19 @@ statuses:
action: update where id = id() set status="rejected"
```
## Implement with Claude Code — pipe action
Shortcut key that pipes the selected task's title and description to Claude Code for implementation.
```yaml
actions:
- key: "i"
label: "Implement"
action: >
select title, description where id = id()
| run("claude -p 'Implement this: $1. Details: $2'")
```
## Search all tikis — single-lane plugin
A plugin with one unfiltered lane shows every task. Press `/` to search across all of them.
@ -182,6 +202,109 @@ One lane per priority level. Moving a task between lanes reassigns its priority.
action: update where id = id() set priority=5
```
## Sprint board — custom enum lanes
Uses a custom `sprint` enum field. Lanes per sprint; moving a task between lanes reassigns it. The third lane catches unplanned backlog tasks.
Requires:
```yaml
fields:
- name: sprint
type: enum
values: [sprint-7, sprint-8, sprint-9]
```
```yaml
- name: Sprint Board
key: "F9"
lanes:
- name: Current Sprint
filter: select where sprint = "sprint-7" and status != "done" order by priority
action: update where id = id() set sprint="sprint-7"
- name: Next Sprint
filter: select where sprint = "sprint-8" order by priority
action: update where id = id() set sprint="sprint-8"
- name: Unplanned
filter: select where sprint is empty and status = "backlog" order by priority
action: update where id = id() set sprint=empty
```
## Severity triage — custom enum filter + action
Lanes per severity level. The last lane combines two values with `or`. A per-plugin action lets you mark a task as trivial without moving it.
Requires:
```yaml
fields:
- name: severity
type: enum
values: [critical, major, minor, trivial]
```
```yaml
- name: Severity
key: "F10"
lanes:
- name: Critical
filter: select where severity = "critical" order by updatedAt desc
action: update where id = id() set severity="critical"
- name: Major
filter: select where severity = "major" order by updatedAt desc
action: update where id = id() set severity="major"
- name: Minor & Trivial
columns: 2
filter: >
select where severity = "minor" or severity = "trivial"
order by severity, priority
action: update where id = id() set severity="minor"
actions:
- key: "t"
label: "Trivial"
action: update where id = id() set severity="trivial"
```
## Subtasks in epic — custom taskIdList + quantifier trigger
A `subtasks` field on parent tasks tracks their children (inverse of `dependsOn`). A trigger auto-completes the parent when every subtask is done. The plugin shows open vs. completed parents.
Requires:
```yaml
fields:
- name: subtasks
type: taskIdList
```
```yaml
triggers:
- description: close parent when all subtasks are done
ruki: >
every 5min
update where subtasks is not empty
and status != "done"
and all subtasks where status = "done"
set status="done"
```
```yaml
- name: Epics
key: "F11"
lanes:
- name: In Progress
filter: >
select where subtasks is not empty
and status != "done"
order by priority
- name: Completed
columns: 1
filter: >
select where subtasks is not empty
and status = "done"
order by updatedAt desc
```
## By topic — tag-based lanes
Split tasks into lanes by tag. Useful for viewing work across domains at a glance.

View file

@ -1,9 +1,12 @@
# Documentation
- [Quick start](quick-start.md)
- [Configuration](config.md)
- [Installation](install.md)
- [Configuration](config.md)
- [Command line options](command-line.md)
- [Markdown viewer](markdown-viewer.md)
- [Image support](image-requirements.md)
- [Custom fields](custom-fields.md)
- [Custom statuses and types](custom-status-type.md)
- [Customization](customization.md)
- [Themes](themes.md)
- [ruki](ruki/index.md)

View file

@ -16,7 +16,7 @@ cd /tmp && tiki demo
```
Move your tiki around the board with `Shift ←/Shift →`.
Make sure to press `?` for help.
Press `?` to open the Action Palette — it lists all available actions with their shortcuts.
Press `F1` to open a sample doc root. Follow links with `Tab/Enter`
## AI skills
@ -56,7 +56,7 @@ Store your notes in remotes!
`tiki` TUI tool allows creating, viewing, editing and deleting tikis as well as creating custom plugins to
view any selection, for example, Recent tikis, Architecture docs, Saved prompts, Security review, Future Roadmap
Read more by pressing `?` for help
Press `?` to open the Action Palette and discover all available actions
# AI skills

View file

@ -0,0 +1,175 @@
# Custom Fields Reference
## Table of contents
- [Overview](#overview)
- [Registration and loading](#registration-and-loading)
- [Naming constraints](#naming-constraints)
- [Type coercion rules](#type-coercion-rules)
- [Enum domain isolation](#enum-domain-isolation)
- [Validation rules](#validation-rules)
- [Persistence and round-trip behavior](#persistence-and-round-trip-behavior)
- [Schema evolution and stale data](#schema-evolution-and-stale-data)
- [Template defaults](#template-defaults)
- [Query behavior](#query-behavior)
- [Missing-field semantics](#missing-field-semantics)
## Overview
Custom fields extend tiki's built-in field catalog with user-defined fields declared in `workflow.yaml`. This reference covers the precise rules for how custom fields are loaded, validated, persisted, and queried — the behavioral contract behind the [Custom Fields](../custom-fields.md) user guide.
## Registration and loading
Custom field definitions are loaded from all `workflow.yaml` files across the three-tier search path (user config, project config, working directory). Files that define `fields:` but no `views:` still contribute field definitions.
Definitions from multiple files are merged by name:
- **identical redefinition** (same name, same type, same enum values in same order): silently accepted
- **conflicting redefinition** (same name, different type or values): fatal error naming both source files
After merging, fields are sorted by name for deterministic ordering and registered into the field catalog alongside built-in fields. Once registered, custom fields are available for ruki parsing, validation, and execution.
Registration happens during bootstrap before any task or template loading occurs.
## Naming constraints
Field names must:
- match the ruki identifier pattern (letters, digits, underscores; must start with a letter)
- not collide with ruki reserved keywords (`select`, `update`, `where`, `and`, `or`, `not`, `in`, `is`, `empty`, `order`, `by`, `asc`, `desc`, `set`, `create`, `delete`, `limit`, etc.)
- not collide with built-in field names, case-insensitively (`title`, `status`, `priority`, `tags`, `dependsOn`, etc.)
- not be `true` or `false` (reserved boolean literals)
Collision checks are case-insensitive: `Status` and `STATUS` both collide with the built-in `status`.
## Type coercion rules
When custom field values are read from frontmatter YAML, they are coerced to the expected type:
| Field type | Accepted YAML values | Coercion behavior |
|---------------|-----------------------------------------------|--------------------------------------------------|
| `text` | string | pass-through |
| `enum` | string | case-insensitive match against allowed values; stored in canonical casing |
| `integer` | integer or decimal number | integer pass-through; decimal accepted only if it represents a whole number (e.g. `3.0``3`, but `1.5` → error) |
| `boolean` | `true` / `false` | pass-through |
| `datetime` | timestamp or date string | native YAML timestamps pass through; strings parsed as RFC3339, with fallback to `YYYY-MM-DD` |
| `stringList` | YAML list of strings | each element must be a string |
| `taskIdList` | YAML list of strings | each element coerced to uppercase, whitespace trimmed, empty entries dropped |
## Enum domain isolation
Each enum field maintains its own independent set of allowed values. Two enum fields never share a domain, even if their values happen to overlap.
This isolation is enforced at three levels:
- **assignment**: `set severity = category` is rejected even if both are enum fields
- **comparison**: `where severity = category` is rejected (comparing different enum domains)
- **in-expression**: string literals in an `in` list are validated against the specific enum field's allowed values
Enum comparison is case-insensitive: `category = "Backend"` matches a stored `"backend"`.
## Validation rules
Custom fields follow the same validation pipeline as built-in fields:
- **type compatibility**: assignments and comparisons are type-checked (e.g. you cannot assign a string to an integer field, or compare an enum field with an integer literal)
- **enum value validation**: string literals assigned to or compared against an enum field must be in that field's allowed values
- **ordering**: custom fields of orderable types (`text`, `integer`, `boolean`, `datetime`, `enum`) can appear in `order by` clauses; list types (`stringList`, `taskIdList`) are not orderable
## Persistence and round-trip behavior
Custom fields are stored in task file frontmatter alongside built-in fields. When a task is saved:
- custom fields appear after built-in fields, sorted alphabetically by name
- values that look ambiguous in YAML (e.g. a text field containing `"true"`, `"42"`, or `"2026-05-15"`) are quoted to prevent YAML type coercion from corrupting them on reload
A save-then-load cycle preserves custom field values exactly. This holds as long as:
- the field definitions in `workflow.yaml` have not changed between save and load
- enum values use canonical casing (enforced automatically by coercion)
- timestamps round-trip through RFC3339 format
## Schema evolution and stale data
When `workflow.yaml` changes — fields renamed, enum values added or removed, field types changed — existing task files may contain values that no longer match the current schema. tiki handles this gracefully:
### Removed fields
If a frontmatter key no longer matches any registered custom field, it is preserved as an **unknown field**. Unknown fields survive load-save round-trips: they are written back to the file exactly as found. This allows manual cleanup or re-registration without data loss.
### Stale enum values
If a task file contains an enum value that is no longer in the field's allowed values list (e.g. `severity: critical` after `critical` was removed from the enum), the value is **demoted to an unknown field** with a warning. The task still loads and remains visible in views. The stale value is preserved in the file for repair.
### Type mismatches
If a value cannot be coerced to the field's current type (e.g. a text value `"not_a_number"` in a field that was changed to `integer`), the same demotion-to-unknown behavior applies: the task loads, the value is preserved, a warning is logged.
### General principle
tiki reads leniently and writes strictly. On load, unrecognized or incompatible values are preserved rather than rejected. On save, values are validated against the current schema.
## Template defaults
Custom fields in `new.md` templates follow the same coercion and validation rules as task files. If a template contains a value that cannot be coerced (e.g. a type mismatch), the invalid field is dropped with a warning and the template otherwise loads normally.
Template custom field values are copied into new tasks created via `create` statements or the new-task UI flow.
## Query behavior
Custom fields behave identically to built-in fields in ruki queries:
- usable in `where`, `order by`, `set`, `create`, and `select` field lists
- support `is empty` / `is not empty` checks
- support `in` / `not in` for list membership
- list-type fields support `+` (append) and `-` (remove) operations
- quantifiers (`any ... where`, `all ... where`) work on custom `taskIdList` fields
### Unset list fields
Unset custom list fields (`stringList`, `taskIdList`) behave the same as empty built-in list fields (`tags`, `dependsOn`). They return an empty list, not an absent value. This means:
- `"x" in labels` evaluates to `false` (not an error)
- `labels + ["new"]` produces `["new"]` (not an error)
- `labels is empty` evaluates to `true`
## Missing-field semantics
When a custom field has never been set on a task, ruki returns the typed zero value:
| Field type | Zero value |
|---------------|--------------------|
| `text` | `""` (empty string)|
| `integer` | `0` |
| `boolean` | `false` |
| `datetime` | zero time |
| `enum` | `""` (empty string)|
| `stringList` | `[]` (empty list) |
| `taskIdList` | `[]` (empty list) |
Setting a field to `empty` removes it entirely, making it indistinguishable from a field that was never set.
### Distinguishability by type
**Enum fields** preserve the missing-vs-set distinction: `""` is not a valid enum member, so `category is empty` only matches tasks where the field was never set (or was cleared). A task with `category = "frontend"` is never empty.
**Boolean and integer fields** do not preserve this distinction: `false` and `0` are both the zero value and the `empty` value. If you need to tell "never set" from "explicitly false" or "explicitly zero", use an enum field with named values (e.g. `yes` / `no`) instead.
### Worked examples
Suppose `blocked` is a custom boolean field:
| Query | never set | `false` | `true` |
|------------------------------------|-----------|---------|--------|
| `select where blocked = true` | no | no | yes |
| `select where blocked = false` | yes | yes | no |
| `select where blocked is empty` | yes | yes | no |
| `select where blocked is not empty`| no | no | yes |
Suppose `category` is a custom enum field with values `[frontend, backend, infra]`:
| Query | never set | `"frontend"` |
|--------------------------------------|-----------|--------------|
| `select where category = "frontend"` | no | yes |
| `select where category is empty` | yes | no |
| `select where category is not empty` | no | yes |

View file

@ -45,6 +45,14 @@ select where status = "done" order by updatedAt desc
select where "bug" in tags order by priority asc, createdAt desc
```
Select with limit:
```sql
select order by priority limit 3
select where "bug" in tags order by priority limit 5
select id, title order by priority limit 2 | clipboard()
```
Create a tiki:
```sql
@ -136,6 +144,14 @@ Use `call(...)` in a value:
create title=call("echo hi")
```
Pipe select results to a shell command or clipboard:
```sql
select id, title where status = "done" | run("myscript $1 $2")
select id where id = id() | clipboard()
select description where id = id() | clipboard()
```
## Before triggers
Block completion when dependencies remain open:

View file

@ -29,4 +29,5 @@ ready-to-use examples for common workflow patterns
- [Types And Values](types-and-values.md): value categories, literals, `empty`, enums, and schema-dependent typing.
- [Operators And Built-ins](operators-and-builtins.md): precedence, operators, built-in functions, and shell-adjacent capabilities.
- [Validation And Errors](validation-and-errors.md): parse errors, validation failures, edge cases, and strictness rules.
- [Custom Fields Reference](custom-fields-reference.md): coercion rules, enum isolation, persistence round-trips, schema evolution, and missing-field semantics.

View file

@ -210,6 +210,7 @@ create title="x" dependsOn=dependsOn + tags
| `next_date(...)` | `date` | exactly 1 | argument must be `recurrence` |
| `blocks(...)` | `list<ref>` | exactly 1 | argument must be `id`, `ref`, or string literal |
| `id()` | `id` | 0 | valid only in plugin runtime; resolves to selected tiki ID |
| `input()` | declared type | 0 | valid only in plugin actions with `input:` declaration |
| `call(...)` | `string` | exactly 1 | argument must be `string` |
| `user()` | `string` | 0 | no additional validation |
@ -223,6 +224,8 @@ select where blocks(id) is empty
select where id() in dependsOn
create title=call("echo hi")
select where assignee = user()
update where id = id() set assignee = input()
update where id = id() set tags = tags + [input()]
```
Runtime notes:
@ -231,6 +234,7 @@ Runtime notes:
- When a validated statement uses `id()`, plugin execution must provide a non-empty selected task ID.
- `id()` is rejected for CLI, event-trigger, and time-trigger semantic runtimes.
- `call(...)` is currently rejected by semantic validation.
- `input()` returns the value typed by the user at the action prompt. Its return type matches the `input:` declaration on the action (e.g. `input: string` means `input()` returns `string`). Only valid in plugin action statements that declare `input:`. May only appear once per action. Accepted `timestamp` input formats: RFC3339, with YYYY-MM-DD as a convenience fallback.
`run(...)`
@ -246,10 +250,41 @@ after update where new.status = "in progress" run("echo hello")
## Shell-related forms
`ruki` includes two shell-related forms:
`ruki` includes four shell-related forms:
- `call(...)` as a string-returning expression
- `run(...)` as an `after`-trigger action
**`call(...)`** — a string-returning expression
- `call(...)` returns a string
- `run(...)` is used as the top-level action of an `after` trigger
- Returns a string result from a shell command
- Can be used in any expression context
**`run(...)` in triggers** — an `after`-trigger action
- Used as the top-level action of an `after` trigger
- Command string may reference `old.` and `new.` fields
- Example: `after update where new.status = "in progress" run("echo hello")`
**`| run(...)` on select** — a pipe suffix
- Executes a command for each row returned by `select`
- Uses positional arguments `$1`, `$2`, etc. for field substitution
- Command must be a string literal or expression, but **field references are not allowed in the command** itself
- `|` is a statement suffix, not an operator
- Example: `select id, title where status = "done" | run("myscript $1 $2")`
**`| clipboard()` on select** — a pipe suffix
- Copies selected field values to the system clipboard
- Fields within a row are tab-separated; rows are newline-separated
- Takes no arguments — grammar enforces empty parentheses
- Cross-platform via `atotto/clipboard` (macOS, Linux, Windows)
- Example: `select id where id = id() | clipboard()`
Comparison of pipe targets and trigger actions:
| Form | Context | Field access | Behavior |
|---|---|---|---|
| trigger `run()` | after-trigger action | `old.`, `new.` allowed | shell execution via expression evaluation |
| pipe `\| run()` | select suffix | field refs disallowed in command | shell execution with positional args `$1`, `$2` |
| pipe `\| clipboard()` | select suffix | n/a (no arguments) | writes rows to system clipboard |
See [Semantics](semantics.md#pipe-actions-on-select) for pipe evaluation model.

View file

@ -40,6 +40,7 @@ select
select title, status
select id, title where status = "done"
select where "bug" in tags and priority <= 2
select where status != "done" order by priority limit 3
```
`create` writes one or more assignments:
@ -63,6 +64,15 @@ delete where id = "TIKI-ABC123"
delete where status = "cancelled" and "old" in tags
```
`select` may pipe results to a shell command or to the clipboard:
```sql
select id, title where status = "done" | run("myscript $1 $2")
select id where id = id() | clipboard()
```
`| run(...)` executes the command for each row with field values as positional arguments (`$1`, `$2`). `| clipboard()` copies the selected fields to the system clipboard.
## Conditions and expressions
Conditions support:

View file

@ -31,6 +31,12 @@ This page explains how `ruki` statements, triggers, conditions, and expressions
- Duplicate fields are rejected.
- Only bare field names are allowed — `old.` and `new.` qualifiers are not valid in `order by`.
`limit`
- Must be a positive integer.
- Applied after filtering and sorting, before any pipe action.
- If the limit exceeds the result count, all results are returned (no error).
`create`
- `create` is a list of assignments.
@ -167,3 +173,69 @@ Binary `+` and `-` are semantic rather than purely numeric:
For the detailed type rules and built-ins, see [Types And Values](types-and-values.md) and [Operators And Built-ins](operators-and-builtins.md).
## Pipe actions on select
`select` statements may include an optional pipe suffix:
```text
select <fields> where <condition> [order by ...] [limit N] | run(<command>)
select <fields> where <condition> [order by ...] [limit N] | clipboard()
```
### `| run(...)` — shell execution
Evaluation model:
- The `select` runs first, producing zero or more rows.
- For each row, the `run()` command is executed with positional arguments (`$1`, `$2`, etc.) substituted from the selected fields in left-to-right order.
- Each command execution has a **30-second timeout**.
- Command failures are **non-fatal** — remaining rows still execute.
- Stdout and stderr are **fire-and-forget** (not captured or returned).
Rules:
- Explicit field names are required — `select *` and bare `select` are rejected when used with a pipe.
- The command expression must be a string literal or string-typed expression, but **field references are not allowed** in the command string itself.
- Positional arguments `$1`, `$2`, etc. are substituted by the runtime before each command execution.
Example:
```sql
select id, title where status = "done" | run("myscript $1 $2")
```
For a task with `id = "TIKI-ABC123"` and `title = "Fix bug"`, the command becomes:
```bash
myscript "TIKI-ABC123" "Fix bug"
```
Pipe `| run(...)` on select is distinct from trigger `run()` actions. See [Triggers](triggers.md) for the difference.
### `| clipboard()` — copy to clipboard
Evaluation model:
- The `select` runs first, producing zero or more rows.
- The selected field values are written to the system clipboard.
- Fields within a row are **tab-separated**; rows are **newline-separated**.
- Uses `atotto/clipboard` internally — works on macOS, Linux (requires `xclip` or `xsel`), and Windows.
Rules:
- Explicit field names are required — same restriction as `| run(...)`.
- `clipboard()` takes no arguments — the grammar enforces empty parentheses.
Examples:
```sql
select id where id = id() | clipboard()
select id, title where status = "done" | clipboard()
```
For a single task with `id = "TIKI-ABC123"` and `title = "Fix bug"`, the clipboard receives:
```text
TIKI-ABC123 Fix bug
```

View file

@ -48,8 +48,10 @@ The following EBNF-style summary shows the grammar:
```text
statement = selectStmt | createStmt | updateStmt | deleteStmt ;
selectStmt = "select" [ fieldList | "*" ] [ "where" condition ] [ orderBy ] ;
selectStmt = "select" [ fieldList | "*" ] [ "where" condition ] [ orderBy ] [ "limit" int ] [ pipeAction ] ;
fieldList = identifier { "," identifier } ;
pipeAction = "|" ( runAction | clipboardAction ) ;
clipboardAction = "clipboard" "(" ")" ;
createStmt = "create" assignment { assignment } ;
orderBy = "order" "by" sortField { "," sortField } ;
@ -81,7 +83,8 @@ Notes:
- `update` requires both `where` and `set`.
- `delete` requires `where`.
- `order by` is only valid on `select`, not on subqueries inside `count(...)`.
- `asc`, `desc`, `order`, and `by` are contextual keywords — they are only special in the ORDER BY clause.
- `limit` truncates the result set to at most N rows, applied after filtering and sorting but before any pipe action.
- `asc`, `desc`, `order`, `by`, and `limit` are contextual keywords — they are only special in the SELECT clause.
- Bare `select` and `select *` both mean all fields. A field list like `select title, status` projects only the named fields.
- `every` wraps a CRUD statement with a periodic interval. Only `create`, `update`, and `delete` are allowed
@ -143,6 +146,14 @@ select where status = "done" order by updatedAt desc
select where "bug" in tags order by priority asc, createdAt desc
```
Limit:
```sql
select order by priority limit 3
select where status != "done" order by priority limit 5
select limit 1
```
## Expression grammar
Expressions support literals, field references, qualifiers, function calls, list literals, parenthesized expressions, subqueries, and left-associative `+` or `-` chains:

View file

@ -248,6 +248,8 @@ The maximum cascade depth is **8**. Termination is graceful — a warning is log
## The run() action
**Note:** This section describes trigger `run()` actions. For the pipe syntax `| run(...)` and `| clipboard()` on select statements, see [Semantics](semantics.md#pipe-actions-on-select) and [Operators And Built-ins](operators-and-builtins.md#shell-related-forms). Both `run()` and `clipboard()` are pipe-only targets — they cannot appear as trigger actions.
When an after-trigger uses `run(...)`, the command expression is evaluated to a string, then executed:
- The command runs via `sh -c <command-string>`.

View file

@ -111,10 +111,11 @@ select where due is empty
`type`
- normalized through the injected schema
- validated through the injected schema against the `types:` section of `workflow.yaml`
- production normalization lowercases, trims, and removes separators
- default built-in types are `story`, `bug`, `spike`, and `epic`
- default aliases include `feature` and `task` mapping to `story`
- type keys must be canonical (matching normalized form); aliases are not supported
- unknown type values are rejected — no silent fallback
Examples:

View file

@ -9,6 +9,7 @@
- [Type and operator errors](#type-and-operator-errors)
- [Enum and list errors](#enum-and-list-errors)
- [Order by errors](#order-by-errors)
- [Limit errors](#limit-errors)
- [Built-in and subquery errors](#built-in-and-subquery-errors)
## Overview
@ -216,6 +217,67 @@ Order by inside a subquery:
select where count(select where status = "done" order by priority) >= 1
```
## Limit errors
Validation error (must be positive):
```sql
select limit 0
```
Parse errors (invalid token after `limit`):
```sql
select limit -1
select limit "three"
select limit
```
## Pipe validation errors
Pipe actions (`| run(...)` and `| clipboard()`) on `select` have several restrictions:
`select *` with pipe:
```sql
select * where status = "done" | run("echo $1")
select * where status = "done" | clipboard()
```
Bare `select` with pipe:
```sql
select | run("echo $1")
select | clipboard()
```
Both are rejected because explicit field names are required when using a pipe. This applies to both `run()` and `clipboard()` targets.
Non-string command:
```sql
select id where status = "done" | run(42)
```
Field references in command:
```sql
select id, title where status = "done" | run(title + " " + id)
select id, title where status = "done" | run("echo " + title)
```
Field references are not allowed in the pipe command expression itself. Use positional arguments (`$1`, `$2`) instead.
Pipe on non-select statements:
```sql
update where status = "done" set priority=1 | run("echo done")
create title="x" | run("echo created")
delete where id = "TIKI-ABC123" | run("echo deleted")
```
Pipe suffix is only valid on `select` statements.
## Built-in and subquery errors
Unknown function:
@ -255,3 +317,51 @@ select where select = 1
create title=select
```
## input() errors
`input()` without `input:` declaration on the action:
```sql
update where id = id() set assignee = input()
```
Fails at workflow load time with: `input() requires 'input:' declaration on action`.
`input:` declared but `input()` not used:
```yaml
- key: "a"
label: "Ready"
action: update where id = id() set status="ready"
input: string
```
Fails at workflow load time: `declares 'input: string' but does not use input()`.
Duplicate `input()` (more than one call per action):
```sql
update where id = id() set assignee=input(), title=input()
```
Fails with: `input() may only be used once per action`.
Type mismatch (declared type incompatible with target field):
```yaml
- key: "a"
label: "Assign to"
action: update where id = id() set assignee = input()
input: int
```
Fails at workflow load time: `int` is not assignable to string field `assignee`.
`input()` with arguments:
```sql
update where id = id() set assignee = input("name")
```
Fails with: `input() takes no arguments`.

View file

@ -88,10 +88,11 @@ title: Implement user authentication
#### type
Optional string. Default: `story`.
Optional string. Defaults to the first type defined in `workflow.yaml`.
Valid values: `story`, `bug`, `spike`, `epic`. Aliases `feature` and `task` resolve to `story`.
In the TUI each type has an icon: Story 🌀, Bug 💥, Spike 🔍, Epic 🗂️.
Valid values are the type keys defined in the `types:` section of `workflow.yaml`.
Default types: `story`, `bug`, `spike`, `epic`. Each type can have a label and emoji
configured in `workflow.yaml`. Aliases are not supported; use the canonical key.
```yaml
type: bug

View file

@ -20,6 +20,10 @@ linters:
errcheck:
check-type-assertions: true
staticcheck:
checks:
- "-SA5011" # t.Fatal stops via runtime.Goexit; nil deref after guard is safe
govet:
enable-all: true
disable:

View file

@ -1,7 +1,7 @@
.PHONY: help build install clean test lint snapshot
# Build variables
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null | sed 's/^v//' || echo "dev")
COMMIT := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")
DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
LDFLAGS := -ldflags "-X github.com/boolean-maybe/tiki/config.Version=$(VERSION) -X github.com/boolean-maybe/tiki/config.GitCommit=$(COMMIT) -X github.com/boolean-maybe/tiki/config.BuildDate=$(DATE)"

View file

@ -2,7 +2,7 @@ Follow me on X: [![X Badge](https://img.shields.io/badge/-%23000000.svg?style=fl
# tiki
**Update:** [v0.4.0 and ruki](https://github.com/boolean-maybe/tiki/discussions/60)
**Update:** [v0.5.0 and custom fields](https://github.com/boolean-maybe/tiki/releases/tag/v0.5.0)
`tiki` is a terminal-first Markdown workspace for tasks, docs, prompts, and notes stored in your **git** repo
@ -92,7 +92,7 @@ this will clone and show a demo project. Once done you can try your own:
`cd` into your **git** repo and run `tiki init` to initialize.
Move your tiki around the board with `Shift ←/Shift →`.
Make sure to press `?` for help.
Press `?` to open the Action Palette — it lists all available actions with their shortcuts.
Press `F1` to open a sample doc root. Follow links with `Tab/Enter`
### AI skills
@ -142,7 +142,7 @@ Store your notes in remotes!
`tiki` TUI tool allows creating, viewing, editing and deleting tikis as well as creating custom plugins to
view any selection, for example, Recent tikis, Architecture docs, Saved prompts, Security review, Future Roadmap
Read more by pressing `?` for help
Press `?` to open the Action Palette and discover all available actions
## AI skills

View file

@ -1,237 +1,150 @@
---
name: tiki
description: view, create, update, delete tikis and manage dependencies
allowed-tools: Read, Grep, Glob, Update, Edit, Write, WriteFile, Bash(git add:*), Bash(git rm:*)
allowed-tools: Read, Grep, Glob, Write, Bash(tiki exec:*), Bash(git log:*), Bash(git blame:*)
---
# tiki
A tiki is a Markdown file in tiki format saved in the project `.doc/tiki` directory
with a name like `tiki-abc123.md` in all lower letters.
IMPORTANT! files are named in lowercase always
If this directory does not exist prompt user for creation
A tiki is a task stored as a Markdown file in `.doc/tiki/`.
Filename: `tiki-<6char>.md` (lowercase) → ID: `TIKI-<6CHAR>` (uppercase).
Example: `tiki-x7f4k2.md``TIKI-X7F4K2`
## tiki ID format
All CRUD operations go through `tiki exec '<ruki-statement>'`.
This handles validation, triggers, file persistence, and git staging automatically.
Never manually edit tiki files or run `git add`/`git rm` — `tiki exec` does it all.
ID format: `TIKI-ABC123` where ABC123 is 6-char random alphanumeric
**Derived from filename, NOT stored in frontmatter**
For full `ruki` syntax, see `.doc/doki/doc/ruki/`.
Examples:
- Filename: `tiki-x7f4k2.md` → ID: `TIKI-X7F4K2`
## Field reference
## tiki format
| Field | Type | Notes |
|---|---|---|
| `id` | `id` | immutable, auto-generated `TIKI-XXXXXX` |
| `title` | `string` | required on create |
| `description` | `string` | markdown body content |
| `status` | `status` | from `workflow.yaml`, default `backlog` |
| `type` | `type` | bug, feature, task, story, epic |
| `priority` | `int` | 15 (1=high, 5=low), default 3 |
| `points` | `int` | story points 110 |
| `assignee` | `string` | |
| `tags` | `list<string>` | |
| `dependsOn` | `list<ref>` | list of tiki IDs |
| `due` | `date` | `YYYY-MM-DD` format |
| `recurrence` | `recurrence` | cron: `0 0 * * *` (daily), `0 0 * * MON` (weekly), `0 0 1 * *` (monthly) |
| `createdBy` | `string` | immutable |
| `createdAt` | `timestamp` | immutable |
| `updatedAt` | `timestamp` | immutable |
A tiki format is Markdown with some requirements:
Priority descriptions: 1=high, 2=medium-high, 3=medium, 4=medium-low, 5=low.
Valid statuses are configurable — check `statuses:` in `~/.config/tiki/workflow.yaml`.
### frontmatter
## Query
```markdown
---
title: My ticket
type: story
status: backlog
priority: 3
points: 5
tags:
- markdown
- metadata
dependsOn:
- TIKI-ABC123
due: 2026-04-01
recurrence: 0 0 * * MON
---
```sh
tiki exec 'select' # all tasks
tiki exec 'select title, status' # field projection
tiki exec 'select id, title where status = "done"' # filter
tiki exec 'select where "bug" in tags order by priority' # tag filter + sort
tiki exec 'select where due < now()' # overdue
tiki exec 'select where due - now() < 7day' # due within 7 days
tiki exec 'select where dependsOn any status != "done"' # blocked tasks
tiki exec 'select where assignee = user()' # my tasks
```
where fields can have these values:
- type: bug, feature, task, story, epic
- status: configurable via `workflow.yaml`. Default statuses: backlog, ready, in_progress, review, done.
To find valid statuses for the current project, check the `statuses:` section in `~/.config/tiki/workflow.yaml`.
- priority: is any integer number from 1 to 5 where 1 is the highest priority. Mapped to priority description:
- high: 1
- medium-high: 2
- medium: 3
- medium-low: 4
- low: 5
- points: story points from 1 to 10
- dependsOn: list of tiki IDs (TIKI-XXXXXX format) this task depends on
- due: due date in YYYY-MM-DD format (optional, date-only)
- recurrence: recurrence pattern in cron format (optional). Supported: `0 0 * * *` (daily), `0 0 * * MON` (weekly on Monday, etc.), `0 0 1 * *` (monthly)
Output is an ASCII table. To read a tiki's full markdown body, use `Read` on `.doc/tiki/tiki-<id>.md`.
### body
## Create
The body of a tiki is normal Markdown
```sh
tiki exec 'create title="Fix login"' # minimal
tiki exec 'create title="Fix login" priority=2 status="ready" tags=["bug"]' # full
tiki exec 'create title="Review" due=2026-04-01 + 2day' # date arithmetic
tiki exec 'create title="Sprint review" recurrence="0 0 * * MON"' # recurrence
```
if a tiki needs an attachment it is implemented as a normal markdown link to file syntax for example:
- Logs are attached [logs](mylogs.log)
- Here is the ![screenshot](screenshot.jpg "check out this box")
- Check out docs: <https://www.markdownguide.org>
- Contact: <user@example.com>
## Describe
When asked a question about a tiki find its file and read it then answer the question
If the question is who created this tiki or who updated it last - use the git username
For example:
- who created this tiki? use `git log --follow --diff-filter=A -- <file_path>` to see who created it
- who edited this tiki? use `git blame <file_path>` to see who edited the file
## View
`Created` timestamp is taken from git file creation if available else from the file creation timestamp.
`Author` is taken from git history as the git user who created the file.
## Creation
When asked to create a tiki:
- Generate a random 6-character alphanumeric ID (lowercase letters and digits)
- The filename should be lowercase: `tiki-abc123.md`
- If status is not specified use the default status from `~/.config/tiki/workflow.yaml` (typically `backlog`)
- If priority is not specified use 3
- If type is not specified - prompt the user or use `story` by default
Example: for random ID `x7f4k2`:
- Filename: `tiki-x7f4k2.md`
- tiki ID: `TIKI-X7F4K2`
Output: `created TIKI-XXXXXX`. Defaults: status from workflow.yaml (typically `backlog`), priority 3, type `story`.
### Create from file
if asked to create a tiki from Markdown or text file - create only a single tiki and use the entire content of the
file as its description. Title should be a short sentence summarizing the file content
#### git
After a new tiki is created `git add` this file.
IMPORTANT - only add, never commit the file without user asking and permitting
When asked to create a tiki from a file:
1. Read the source file
2. Summarize its content into a short title
3. Use the file content as the description:
```sh
tiki exec 'create title="Summary of file" description="<escaped content>"'
```
Escape double quotes in the content with backslash.
## Update
When asked to update a tiki - edit its file
For example when user says "set TIKI-ABC123 in progress" find its file and edit its frontmatter line from
`status: backlog` to `status: in progress`
```sh
tiki exec 'update where id = "TIKI-X7F4K2" set status="done"' # status change
tiki exec 'update where id = "TIKI-X7F4K2" set priority=1' # priority
tiki exec 'update where status = "ready" set status="cancelled"' # bulk update
tiki exec 'update where id = "TIKI-X7F4K2" set tags=tags + ["urgent"]' # add tag
tiki exec 'update where id = "TIKI-X7F4K2" set due=2026-04-01' # set due date
tiki exec 'update where id = "TIKI-X7F4K2" set due=empty' # clear due date
tiki exec 'update where id = "TIKI-X7F4K2" set recurrence="0 0 * * MON"' # set recurrence
tiki exec 'update where id = "TIKI-X7F4K2" set recurrence=empty' # clear recurrence
```
### git
Output: `updated N tasks`.
After a tiki is updated `git add` this file
IMPORTANT - only add, never commit the file without user asking and permitting
### Implement
## Deletion
When asked to implement a tiki and the user approves implementation, set its status to `review`:
```sh
tiki exec 'update where id = "TIKI-X7F4K2" set status="review"'
```
When asked to delete a tiki `git rm` its file
If for any reason `git rm` cannot be executed and the file is still there - delete the file
## Delete
## Implement
```sh
tiki exec 'delete where id = "TIKI-X7F4K2"' # by ID
tiki exec 'delete where status = "cancelled"' # bulk
```
When asked to implement a tiki and the user approves implementation change its status to `review` and `git add` it
Output: `deleted N tasks`.
## Dependencies
Tikis can declare dependencies on other tikis via the `dependsOn` frontmatter field.
Values are tiki IDs in `TIKI-XXXXXX` format. A dependency means this tiki is blocked by or requires the listed tikis.
```sh
# view a tiki's dependencies
tiki exec 'select id, title, status, dependsOn where id = "TIKI-X7F4K2"'
### View dependencies
# find what depends on a tiki (reverse lookup)
tiki exec 'select id, title where "TIKI-X7F4K2" in dependsOn'
When asked about a tiki's dependencies, read its frontmatter `dependsOn` list.
For each dependency ID, read that tiki file to show its title, status, and assignee.
Highlight any dependencies that are not yet `done` -- these are blockers.
# find blocked tasks (any dependency not done)
tiki exec 'select id, title where dependsOn any status != "done"'
Example: "what blocks TIKI-X7F4K2?"
1. Read `.doc/tiki/tiki-x7f4k2.md` frontmatter
2. For each ID in `dependsOn`, read that tiki and report its status
# add a dependency
tiki exec 'update where id = "TIKI-X7F4K2" set dependsOn=dependsOn + ["TIKI-ABC123"]'
### Find dependents
When asked "what depends on TIKI-ABC123?" -- grep all tiki files for that ID in their `dependsOn` field:
# remove a dependency
tiki exec 'update where id = "TIKI-X7F4K2" set dependsOn=dependsOn - ["TIKI-ABC123"]'
```
grep -l "TIKI-ABC123" .doc/tiki/*.md
## Provenance
`ruki` does not access git history. Use git commands for authorship questions:
```sh
# who created this tiki
git log --follow --diff-filter=A -- .doc/tiki/tiki-x7f4k2.md
# who last edited this tiki
git blame .doc/tiki/tiki-x7f4k2.md
```
Then read each match and check if TIKI-ABC123 appears in its `dependsOn` list.
### Add dependency
Created timestamp and author are also available via:
```sh
tiki exec 'select createdAt, createdBy where id = "TIKI-X7F4K2"'
```
When asked to add a dependency (e.g. "TIKI-X7F4K2 depends on TIKI-ABC123"):
1. Verify the target tiki exists: check `.doc/tiki/tiki-abc123.md` exists
2. Read `.doc/tiki/tiki-x7f4k2.md`
3. Add `TIKI-ABC123` to the `dependsOn` list (create the field if missing)
4. Do not add duplicates
5. `git add` the modified file
## Important
### Remove dependency
When asked to remove a dependency:
1. Read the tiki file
2. Remove the specified ID from `dependsOn`
3. If `dependsOn` becomes empty, remove the field entirely (omitempty)
4. `git add` the modified file
### Validation
- Each value must be a valid tiki ID format: `TIKI-` followed by 6 alphanumeric characters
- Each referenced tiki must exist in `.doc/tiki/`
- Before adding, verify the target file exists; warn if it doesn't
- Circular dependencies: warn the user but do not block (e.g. A depends on B, B depends on A)
## Due Dates
Tikis can have a due date via the `due` frontmatter field.
The value must be in `YYYY-MM-DD` format (date-only, no time component).
### Set due date
When asked to set a due date (e.g. "set TIKI-X7F4K2 due date to April 1st 2026"):
1. Read `.doc/tiki/tiki-x7f4k2.md`
2. Add or update the `due` field with value `2026-04-01`
3. Format must be `YYYY-MM-DD` (4-digit year, 2-digit month, 2-digit day)
4. `git add` the modified file
### Remove due date
When asked to remove or clear a due date:
1. Read the tiki file
2. Remove the `due` field entirely (omitempty)
3. `git add` the modified file
### Query by due date
Users can filter tasks by due date in the TUI using `ruki` select statements:
- `select where due = 2026-04-01` - exact match
- `select where due < 2026-04-01` - before date
- `select where due < now()` - overdue tasks
- `select where due - now() < 7day` - due within 7 days
### Validation
- Date must be in `YYYY-MM-DD` format
- Must be a valid calendar date (no Feb 30, etc.)
- Date-only (no time component)
## Recurrence
Tikis can have a recurrence pattern via the `recurrence` frontmatter field.
The value must be a supported cron pattern. Displayed as English in the TUI (e.g. "Weekly on Monday").
This is metadata-only — it does not auto-create tasks on completion.
### Set recurrence
When asked to set a recurrence (e.g. "set TIKI-X7F4K2 to recur weekly on Monday"):
1. Read `.doc/tiki/tiki-x7f4k2.md`
2. Add or update the `recurrence` field with value `0 0 * * MON`
3. `git add` the modified file
Supported patterns:
- `0 0 * * *` — Daily
- `0 0 * * MON` — Weekly on Monday (through SUN for other days)
- `0 0 1 * *` — Monthly
### Remove recurrence
When asked to remove or clear a recurrence:
1. Read the tiki file
2. Remove the `recurrence` field entirely (omitempty)
3. `git add` the modified file
### Validation
- Must be one of the supported cron patterns listed above
- Empty/omitted means no recurrence
- `tiki exec` handles `git add` and `git rm` automatically — never do manual git staging for tikis
- Never commit without user permission
- Exit codes: 0 = ok, 2 = usage error, 3 = startup failure, 4 = query error

251
cmd_workflow.go Normal file
View file

@ -0,0 +1,251 @@
package main
import (
"errors"
"fmt"
"os"
"strings"
"github.com/boolean-maybe/tiki/config"
)
// runWorkflow dispatches workflow subcommands. Returns an exit code.
func runWorkflow(args []string) int {
if len(args) == 0 {
printWorkflowUsage()
return exitUsage
}
switch args[0] {
case "reset":
return runWorkflowReset(args[1:])
case "install":
return runWorkflowInstall(args[1:])
case "describe":
return runWorkflowDescribe(args[1:])
case "--help", "-h":
printWorkflowUsage()
return exitOK
default:
_, _ = fmt.Fprintf(os.Stderr, "unknown workflow command: %s\n", args[0])
printWorkflowUsage()
return exitUsage
}
}
// runWorkflowReset implements `tiki workflow reset [target] --scope`.
func runWorkflowReset(args []string) int {
positional, scope, err := parseScopeArgs(args)
if errors.Is(err, errHelpRequested) {
printWorkflowResetUsage()
return exitOK
}
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
printWorkflowResetUsage()
return exitUsage
}
target := config.ResetTarget(positional)
if !config.ValidResetTarget(target) {
_, _ = fmt.Fprintf(os.Stderr, "error: unknown target: %q (use config, workflow, or new)\n", positional)
printWorkflowResetUsage()
return exitUsage
}
affected, err := config.ResetConfig(scope, target)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
return exitInternal
}
if len(affected) == 0 {
fmt.Println("nothing to reset")
return exitOK
}
for _, path := range affected {
fmt.Println("reset", path)
}
return exitOK
}
// runWorkflowInstall implements `tiki workflow install <name> --scope`.
func runWorkflowInstall(args []string) int {
name, scope, err := parseScopeArgs(args)
if errors.Is(err, errHelpRequested) {
printWorkflowInstallUsage()
return exitOK
}
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
printWorkflowInstallUsage()
return exitUsage
}
if name == "" {
_, _ = fmt.Fprintln(os.Stderr, "error: workflow name required")
printWorkflowInstallUsage()
return exitUsage
}
results, err := config.InstallWorkflow(name, scope, config.DefaultWorkflowBaseURL)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
return exitInternal
}
for _, r := range results {
if r.Changed {
fmt.Println("installed", r.Path)
} else {
fmt.Println("unchanged", r.Path)
}
}
return exitOK
}
// runWorkflowDescribe implements `tiki workflow describe <name>`.
// describe is a read-only network call, so scope flags are rejected
// to keep the CLI surface honest.
func runWorkflowDescribe(args []string) int {
name, err := parsePositionalOnly(args)
if errors.Is(err, errHelpRequested) {
printWorkflowDescribeUsage()
return exitOK
}
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
printWorkflowDescribeUsage()
return exitUsage
}
if name == "" {
_, _ = fmt.Fprintln(os.Stderr, "error: workflow name required")
printWorkflowDescribeUsage()
return exitUsage
}
desc, err := config.DescribeWorkflow(name, config.DefaultWorkflowBaseURL)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
return exitInternal
}
if desc == "" {
return exitOK
}
if strings.HasSuffix(desc, "\n") {
fmt.Print(desc)
} else {
fmt.Println(desc)
}
return exitOK
}
// parseScopeArgs extracts an optional positional argument and a required --scope flag.
// Returns errHelpRequested for --help/-h.
func parseScopeArgs(args []string) (string, config.Scope, error) {
var positional string
var scopeStr string
for _, arg := range args {
switch arg {
case "--help", "-h":
return "", "", errHelpRequested
case "--global", "--local", "--current":
if scopeStr != "" {
return "", "", fmt.Errorf("only one scope allowed: already have --%s", scopeStr)
}
scopeStr = strings.TrimPrefix(arg, "--")
default:
if strings.HasPrefix(arg, "--") {
return "", "", fmt.Errorf("unknown flag: %s", arg)
}
if positional != "" {
return "", "", fmt.Errorf("multiple positional arguments: %q and %q", positional, arg)
}
positional = arg
}
}
if scopeStr == "" {
scopeStr = "local"
}
return positional, config.Scope(scopeStr), nil
}
// parsePositionalOnly extracts a single positional argument and rejects any
// flag other than --help/-h. Used by subcommands that don't take a scope.
func parsePositionalOnly(args []string) (string, error) {
var positional string
for _, arg := range args {
switch arg {
case "--help", "-h":
return "", errHelpRequested
}
if strings.HasPrefix(arg, "--") {
return "", fmt.Errorf("unknown flag: %s", arg)
}
if positional != "" {
return "", fmt.Errorf("multiple positional arguments: %q and %q", positional, arg)
}
positional = arg
}
return positional, nil
}
func printWorkflowUsage() {
fmt.Print(`Usage: tiki workflow <command>
Commands:
reset [target] [--scope] Reset config files to defaults
install <name> [--scope] Install a workflow from the tiki repository
describe <name> Fetch and print a workflow's description
Run 'tiki workflow <command> --help' for details.
`)
}
func printWorkflowResetUsage() {
fmt.Print(`Usage: tiki workflow reset [target] [--scope]
Reset configuration files to their defaults.
Targets (omit to reset all):
config Reset config.yaml
workflow Reset workflow.yaml
new Reset new.md (task template)
Scopes (default: --local):
--global User config directory
--local Project config directory (.doc/)
--current Current working directory
`)
}
func printWorkflowInstallUsage() {
fmt.Print(`Usage: tiki workflow install <name> [--scope]
Install a named workflow from the tiki repository.
Downloads workflow.yaml and new.md into the scope directory,
overwriting any existing files.
Scopes (default: --local):
--global User config directory
--local Project config directory (.doc/)
--current Current working directory
Example:
tiki workflow install sprint --global
`)
}
func printWorkflowDescribeUsage() {
fmt.Print(`Usage: tiki workflow describe <name>
Fetch a workflow's description from the tiki repository and print it.
Reads the top-level 'description' field of the named workflow.yaml.
Example:
tiki workflow describe todo
`)
}

414
cmd_workflow_test.go Normal file
View file

@ -0,0 +1,414 @@
package main
import (
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/boolean-maybe/tiki/config"
)
// setupWorkflowTest creates a temp config dir for workflow commands.
func setupWorkflowTest(t *testing.T) string {
t.Helper()
xdgDir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", xdgDir)
config.ResetPathManager()
t.Cleanup(config.ResetPathManager)
tikiDir := filepath.Join(xdgDir, "tiki")
if err := os.MkdirAll(tikiDir, 0750); err != nil {
t.Fatal(err)
}
return tikiDir
}
func overrideBaseURL(t *testing.T, url string) {
t.Helper()
orig := config.DefaultWorkflowBaseURL
config.DefaultWorkflowBaseURL = url
t.Cleanup(func() { config.DefaultWorkflowBaseURL = orig })
}
func TestParseScopeArgs(t *testing.T) {
tests := []struct {
name string
args []string
positional string
scope config.Scope
wantErr error
errSubstr string
}{
{
name: "global no positional",
args: []string{"--global"},
positional: "",
scope: config.ScopeGlobal,
},
{
name: "local with positional",
args: []string{"workflow", "--local"},
positional: "workflow",
scope: config.ScopeLocal,
},
{
name: "scope before positional",
args: []string{"--current", "config"},
positional: "config",
scope: config.ScopeCurrent,
},
{
name: "help flag",
args: []string{"--help"},
wantErr: errHelpRequested,
},
{
name: "short help flag",
args: []string{"-h"},
wantErr: errHelpRequested,
},
{
name: "missing scope defaults to local",
args: []string{"config"},
positional: "config",
scope: config.ScopeLocal,
},
{
name: "unknown flag",
args: []string{"--verbose"},
errSubstr: "unknown flag",
},
{
name: "multiple positional",
args: []string{"config", "workflow", "--global"},
errSubstr: "multiple positional arguments",
},
{
name: "duplicate scopes",
args: []string{"--global", "--local"},
errSubstr: "only one scope allowed",
},
{
name: "no args defaults to local",
args: nil,
scope: config.ScopeLocal,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
positional, scope, err := parseScopeArgs(tt.args)
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("expected error %v, got %v", tt.wantErr, err)
}
return
}
if tt.errSubstr != "" {
if err == nil {
t.Fatal("expected error, got nil")
}
if msg := err.Error(); !strings.Contains(msg, tt.errSubstr) {
t.Fatalf("expected error containing %q, got %q", tt.errSubstr, msg)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if positional != tt.positional {
t.Errorf("positional = %q, want %q", positional, tt.positional)
}
if scope != tt.scope {
t.Errorf("scope = %q, want %q", scope, tt.scope)
}
})
}
}
func TestParsePositionalOnly(t *testing.T) {
tests := []struct {
name string
args []string
positional string
wantErr error
errSubstr string
}{
{name: "no args", args: nil},
{name: "single positional", args: []string{"sprint"}, positional: "sprint"},
{name: "help flag", args: []string{"--help"}, wantErr: errHelpRequested},
{name: "short help flag", args: []string{"-h"}, wantErr: errHelpRequested},
{name: "rejects scope", args: []string{"sprint", "--global"}, errSubstr: "unknown flag"},
{name: "rejects unknown flag", args: []string{"sprint", "--verbose"}, errSubstr: "unknown flag"},
{name: "multiple positional", args: []string{"a", "b"}, errSubstr: "multiple positional arguments"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
positional, err := parsePositionalOnly(tt.args)
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("expected error %v, got %v", tt.wantErr, err)
}
return
}
if tt.errSubstr != "" {
if err == nil {
t.Fatal("expected error, got nil")
}
if msg := err.Error(); !strings.Contains(msg, tt.errSubstr) {
t.Fatalf("expected error containing %q, got %q", tt.errSubstr, msg)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if positional != tt.positional {
t.Errorf("positional = %q, want %q", positional, tt.positional)
}
})
}
}
// --- runWorkflow dispatch tests ---
func TestRunWorkflow_NoArgs(t *testing.T) {
if code := runWorkflow(nil); code != exitUsage {
t.Errorf("exit code = %d, want %d", code, exitUsage)
}
}
func TestRunWorkflow_UnknownSubcommand(t *testing.T) {
if code := runWorkflow([]string{"bogus"}); code != exitUsage {
t.Errorf("exit code = %d, want %d", code, exitUsage)
}
}
func TestRunWorkflow_Help(t *testing.T) {
if code := runWorkflow([]string{"--help"}); code != exitOK {
t.Errorf("exit code = %d, want %d", code, exitOK)
}
}
// --- runWorkflowReset integration tests ---
func TestRunWorkflowReset_GlobalAll(t *testing.T) {
tikiDir := setupWorkflowTest(t)
if err := os.WriteFile(filepath.Join(tikiDir, "workflow.yaml"), []byte("custom"), 0644); err != nil {
t.Fatal(err)
}
if code := runWorkflowReset([]string{"--global"}); code != exitOK {
t.Errorf("exit code = %d, want %d", code, exitOK)
}
got, err := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
if err != nil {
t.Fatal(err)
}
if string(got) == "custom" {
t.Error("workflow.yaml was not reset")
}
}
func TestRunWorkflowReset_NothingToReset(t *testing.T) {
_ = setupWorkflowTest(t)
if code := runWorkflowReset([]string{"config", "--global"}); code != exitOK {
t.Errorf("exit code = %d, want %d", code, exitOK)
}
}
func TestRunWorkflowReset_InvalidTarget(t *testing.T) {
_ = setupWorkflowTest(t)
if code := runWorkflowReset([]string{"themes", "--global"}); code != exitUsage {
t.Errorf("exit code = %d, want %d", code, exitUsage)
}
}
func TestRunWorkflowReset_DefaultsToLocal(t *testing.T) {
_ = setupWorkflowTest(t)
// without an initialized project, --local scope fails with exitInternal (not exitUsage)
if code := runWorkflowReset([]string{"config"}); code == exitUsage {
t.Error("missing scope should not produce usage error — it should default to --local")
}
}
func TestRunWorkflowReset_Help(t *testing.T) {
if code := runWorkflowReset([]string{"--help"}); code != exitOK {
t.Errorf("exit code = %d, want %d", code, exitOK)
}
}
// --- runWorkflowInstall integration tests ---
func TestRunWorkflowInstall_Success(t *testing.T) {
tikiDir := setupWorkflowTest(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/workflows/sprint/workflow.yaml":
_, _ = w.Write([]byte("sprint workflow"))
case "/workflows/sprint/new.md":
_, _ = w.Write([]byte("sprint template"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
overrideBaseURL(t, server.URL)
if code := runWorkflowInstall([]string{"sprint", "--global"}); code != exitOK {
t.Fatalf("exit code = %d, want %d", code, exitOK)
}
got, _ := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
if string(got) != "sprint workflow" {
t.Errorf("workflow.yaml = %q, want %q", got, "sprint workflow")
}
got, _ = os.ReadFile(filepath.Join(tikiDir, "new.md"))
if string(got) != "sprint template" {
t.Errorf("new.md = %q, want %q", got, "sprint template")
}
}
func TestRunWorkflowInstall_MissingName(t *testing.T) {
_ = setupWorkflowTest(t)
if code := runWorkflowInstall([]string{"--global"}); code != exitUsage {
t.Errorf("exit code = %d, want %d", code, exitUsage)
}
}
func TestRunWorkflowInstall_InvalidName(t *testing.T) {
_ = setupWorkflowTest(t)
if code := runWorkflowInstall([]string{"../../etc", "--global"}); code != exitInternal {
t.Errorf("exit code = %d, want %d", code, exitInternal)
}
}
func TestRunWorkflowInstall_NotFound(t *testing.T) {
_ = setupWorkflowTest(t)
server := httptest.NewServer(http.NotFoundHandler())
defer server.Close()
overrideBaseURL(t, server.URL)
if code := runWorkflowInstall([]string{"nonexistent", "--global"}); code != exitInternal {
t.Errorf("exit code = %d, want %d", code, exitInternal)
}
}
func TestRunWorkflowInstall_DefaultsToLocal(t *testing.T) {
_ = setupWorkflowTest(t)
// without an initialized project, --local scope fails with exitInternal (not exitUsage)
if code := runWorkflowInstall([]string{"sprint"}); code == exitUsage {
t.Error("missing scope should not produce usage error — it should default to --local")
}
}
func TestRunWorkflowInstall_Help(t *testing.T) {
if code := runWorkflowInstall([]string{"--help"}); code != exitOK {
t.Errorf("exit code = %d, want %d", code, exitOK)
}
}
// --- runWorkflowDescribe integration tests ---
func TestRunWorkflowDescribe_Success(t *testing.T) {
_ = setupWorkflowTest(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/workflows/sprint/workflow.yaml" {
http.NotFound(w, r)
return
}
_, _ = w.Write([]byte("description: |\n sprint desc\n"))
}))
defer server.Close()
overrideBaseURL(t, server.URL)
if code := runWorkflowDescribe([]string{"sprint"}); code != exitOK {
t.Errorf("exit code = %d, want %d", code, exitOK)
}
}
func TestRunWorkflowDescribe_MissingName(t *testing.T) {
_ = setupWorkflowTest(t)
if code := runWorkflowDescribe(nil); code != exitUsage {
t.Errorf("exit code = %d, want %d", code, exitUsage)
}
}
func TestRunWorkflowDescribe_InvalidName(t *testing.T) {
_ = setupWorkflowTest(t)
if code := runWorkflowDescribe([]string{"../../etc"}); code != exitInternal {
t.Errorf("exit code = %d, want %d", code, exitInternal)
}
}
func TestRunWorkflowDescribe_NotFound(t *testing.T) {
_ = setupWorkflowTest(t)
server := httptest.NewServer(http.NotFoundHandler())
defer server.Close()
overrideBaseURL(t, server.URL)
if code := runWorkflowDescribe([]string{"nonexistent"}); code != exitInternal {
t.Errorf("exit code = %d, want %d", code, exitInternal)
}
}
func TestRunWorkflowDescribe_UnknownFlag(t *testing.T) {
_ = setupWorkflowTest(t)
if code := runWorkflowDescribe([]string{"sprint", "--verbose"}); code != exitUsage {
t.Errorf("exit code = %d, want %d", code, exitUsage)
}
}
func TestRunWorkflowDescribe_RejectsScopeFlags(t *testing.T) {
_ = setupWorkflowTest(t)
for _, flag := range []string{"--global", "--local", "--current"} {
if code := runWorkflowDescribe([]string{"sprint", flag}); code != exitUsage {
t.Errorf("%s: exit code = %d, want %d", flag, code, exitUsage)
}
}
}
func TestRunWorkflowDescribe_Help(t *testing.T) {
if code := runWorkflowDescribe([]string{"--help"}); code != exitOK {
t.Errorf("exit code = %d, want %d", code, exitOK)
}
}
func TestRunWorkflow_DescribeDispatch(t *testing.T) {
_ = setupWorkflowTest(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("description: hi\n"))
}))
defer server.Close()
overrideBaseURL(t, server.URL)
if code := runWorkflow([]string{"describe", "sprint"}); code != exitOK {
t.Errorf("exit code = %d, want %d", code, exitOK)
}
}

View file

@ -4,23 +4,25 @@ import "path"
// AITool defines a supported AI coding assistant.
// To add a new tool, add an entry to the aiTools slice below.
// NOTE: also update view/help/tiki.md which lists tool names in prose.
// NOTE: the action palette (press Ctrl+A) surfaces available actions; update docs if tool names change.
type AITool struct {
Key string // config identifier: "claude", "gemini", "codex", "opencode"
DisplayName string // human-readable label for UI: "Claude Code"
Command string // CLI binary name
PromptFlag string // flag preceding the prompt arg, or "" for positional
SkillDir string // relative base dir for skills: ".claude/skills"
Key string // config identifier: "claude", "gemini", "codex", "opencode"
DisplayName string // human-readable label for UI: "Claude Code"
Command string // CLI binary name
PromptFlag string // flag preceding the prompt arg, or "" for positional
SkillDir string // relative base dir for skills: ".claude/skills"
SettingsFile string // relative path to local settings file, or "" if none
}
// aiTools is the single source of truth for all supported AI tools.
var aiTools = []AITool{
{
Key: "claude",
DisplayName: "Claude Code",
Command: "claude",
PromptFlag: "--append-system-prompt",
SkillDir: ".claude/skills",
Key: "claude",
DisplayName: "Claude Code",
Command: "claude",
PromptFlag: "--append-system-prompt",
SkillDir: ".claude/skills",
SettingsFile: ".claude/settings.local.json",
},
{
Key: "gemini",

View file

@ -25,6 +25,7 @@ func TestLookupAITool_Found(t *testing.T) {
tool, ok := LookupAITool("claude")
if !ok {
t.Fatal("expected to find claude")
return
}
if tool.Command != "claude" {
t.Errorf("expected command 'claude', got %q", tool.Command)

View file

@ -52,10 +52,10 @@ type ColorConfig struct {
ContentBackgroundColor Color
ContentTextColor Color
// Search box colors
SearchBoxLabelColor Color
SearchBoxBackgroundColor Color
SearchBoxTextColor Color
// Input box colors
InputBoxLabelColor Color
InputBoxBackgroundColor Color
InputBoxTextColor Color
// Input field colors (used in task detail edit mode)
InputFieldBackgroundColor Color
@ -226,10 +226,10 @@ func ColorsFromPalette(p Palette) *ColorConfig {
ContentBackgroundColor: p.ContentBackgroundColor,
ContentTextColor: p.TextColor,
// Search box
SearchBoxLabelColor: p.TextColor,
SearchBoxBackgroundColor: p.TransparentColor,
SearchBoxTextColor: p.TextColor,
// Input box
InputBoxLabelColor: p.TextColor,
InputBoxBackgroundColor: p.TransparentColor,
InputBoxTextColor: p.TextColor,
// Input field
InputFieldBackgroundColor: p.TransparentColor,

View file

@ -1,3 +1,7 @@
version: 0.5.0
description: |
Default tiki workflow. A lightweight kanban-style flow with
Backlog → Ready → In Progress → Review → Done, plus Story / Bug / Spike / Epic task types.
statuses:
- key: backlog
label: Backlog
@ -20,74 +24,118 @@ statuses:
emoji: "✅"
done: true
types:
- key: story
label: Story
emoji: "🌀"
- key: bug
label: Bug
emoji: "💥"
- key: spike
label: Spike
emoji: "🔍"
- key: epic
label: Epic
emoji: "🗂️"
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()
- key: "y"
label: "Copy ID"
action: select id where id = id() | clipboard()
- key: "Y"
label: "Copy content"
action: select title, description where id = id() | clipboard()
- key: "+"
label: "Priority up"
action: update where id = id() set priority = priority - 1
- key: "-"
label: "Priority down"
action: update where id = id() set priority = priority + 1
- key: "u"
label: "Flag urgent"
action: update where id = id() set priority=1 tags=tags+["urgent"]
hot: false
- key: "A"
label: "Assign to..."
action: update where id = id() set assignee=input()
input: string
hot: false
- key: "t"
label: "Add tag"
action: update where id = id() set tags=tags+[input()]
input: string
hot: false
- key: "T"
label: "Remove tag"
action: update where id = id() set tags=tags-[input()]
input: string
hot: false
plugins:
- name: Kanban
description: "Move tiki to change status, search, create or delete\nShift Left/Right to move"
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: Docs
description: "Project notes and documentation files"
type: doki
fetcher: file
url: "index.md"
key: "F2"
triggers:
- description: block completion with open dependencies
@ -124,3 +172,9 @@ triggers:
before delete
where old.status = "inProgress"
deny "cannot delete an in-progress task — move to backlog or done first"
- description: spawn next occurrence when recurring task completes
ruki: >
after update
where new.status = "done" and old.recurrence is not empty
create title=old.title priority=old.priority tags=old.tags
recurrence=old.recurrence due=next_date(old.recurrence) status="backlog"

View file

@ -13,8 +13,8 @@ const (
TaskBoxPaddingExpanded = 4 // Width padding in expanded mode
TaskBoxMinWidth = 10 // Minimum width fallback
// Search box dimensions
SearchBoxHeight = 3
// Input box dimensions
InputBoxHeight = 3
// TaskList default visible rows
TaskListDefaultMaxRows = 10

272
config/fields.go Normal file
View file

@ -0,0 +1,272 @@
package config
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"sync/atomic"
"github.com/boolean-maybe/tiki/workflow"
"gopkg.in/yaml.v3"
)
// customFieldYAML represents a single field entry in the workflow.yaml fields: section.
type customFieldYAML struct {
Name string `yaml:"name"`
Type string `yaml:"type"`
Values []string `yaml:"values,omitempty"` // enum only
}
// customFieldFileData is the minimal YAML structure for reading fields from workflow.yaml.
type customFieldFileData struct {
Fields []customFieldYAML `yaml:"fields"`
}
// registriesLoaded tracks whether LoadWorkflowRegistries has been called.
var registriesLoaded atomic.Bool
// RequireWorkflowRegistriesLoaded returns an error if LoadWorkflowRegistries
// (or LoadStatusRegistry + LoadCustomFields) has not been called yet.
// Intended for use by store/template code that needs registries to be ready
// but should not auto-load them from disk.
func RequireWorkflowRegistriesLoaded() error {
if !registriesLoaded.Load() {
return fmt.Errorf("workflow registries not loaded; call config.LoadWorkflowRegistries() first")
}
return nil
}
// MarkRegistriesLoadedForTest sets the registriesLoaded flag without loading
// from disk. Use in tests that call workflow.RegisterCustomFields directly.
func MarkRegistriesLoadedForTest() {
registriesLoaded.Store(true)
}
// ResetRegistriesLoadedForTest clears the registriesLoaded flag.
// Use in tests that need to verify the unloaded-registry error path.
func ResetRegistriesLoadedForTest() {
registriesLoaded.Store(false)
}
// LoadWorkflowRegistries is the shared startup helper that loads all
// workflow-registry-based sections (statuses, types, custom fields) from
// workflow.yaml files. Callers must build a fresh ruki.Schema after this returns.
func LoadWorkflowRegistries() error {
if err := LoadStatusRegistry(); err != nil {
return err
}
if err := LoadCustomFields(); err != nil {
return err
}
registriesLoaded.Store(true)
return nil
}
// LoadCustomFields reads the fields: section from all workflow.yaml files,
// validates and merges definitions, and registers them with workflow.RegisterCustomFields.
// Uses FindRegistryWorkflowFiles (no views filtering) so files with empty views:
// still contribute custom field definitions.
// Merge semantics: identical redefinitions allowed, conflicting redefinitions error.
func LoadCustomFields() error {
files := FindRegistryWorkflowFiles()
if len(files) == 0 {
// no workflow files at all — no custom fields to register, clear any stale state
workflow.ClearCustomFields()
return nil
}
// collect all field definitions with their source file
type fieldSource struct {
def customFieldYAML
file string
}
var allFields []fieldSource
for _, path := range files {
defs, err := readCustomFieldsFromFile(path)
if err != nil {
return fmt.Errorf("reading custom fields from %s: %w", path, err)
}
for _, d := range defs {
allFields = append(allFields, fieldSource{def: d, file: path})
}
}
if len(allFields) == 0 {
workflow.ClearCustomFields()
return nil
}
// merge: identical definitions allowed, conflicting definitions error
type mergedField struct {
def workflow.FieldDef
sourceFile string
}
merged := make(map[string]*mergedField)
for _, fs := range allFields {
def, err := convertCustomFieldDef(fs.def)
if err != nil {
return fmt.Errorf("field %q in %s: %w", fs.def.Name, fs.file, err)
}
if existing, ok := merged[def.Name]; ok {
if !fieldDefsEqual(existing.def, def) {
return fmt.Errorf("conflicting definition for custom field %q: defined differently in %s and %s",
def.Name, existing.sourceFile, fs.file)
}
// identical redefinition — skip
continue
}
merged[def.Name] = &mergedField{def: def, sourceFile: fs.file}
}
// build ordered slice for registration
defs := make([]workflow.FieldDef, 0, len(merged))
for _, m := range merged {
defs = append(defs, m.def)
}
// sort by name for deterministic ordering
sort.Slice(defs, func(i, j int) bool {
return defs[i].Name < defs[j].Name
})
if err := workflow.RegisterCustomFields(defs); err != nil {
return fmt.Errorf("registering custom fields: %w", err)
}
slog.Debug("loaded custom fields", "count", len(defs))
return nil
}
// FindRegistryWorkflowFiles returns all workflow.yaml files that exist,
// without the views-filtering that FindWorkflowFiles applies.
// Used by registry loaders (statuses, custom fields) that need to read
// configuration sections regardless of whether the file defines views.
func FindRegistryWorkflowFiles() []string {
pm := mustGetPathManager()
candidates := []string{
pm.UserConfigWorkflowFile(),
filepath.Join(pm.ProjectConfigDir(), defaultWorkflowFilename),
defaultWorkflowFilename, // relative to cwd
}
var result []string
seen := make(map[string]bool)
for _, path := range candidates {
abs, err := filepath.Abs(path)
if err != nil {
abs = path
}
if seen[abs] {
continue
}
if _, err := os.Stat(path); err != nil {
continue
}
seen[abs] = true
result = append(result, path)
}
return result
}
// readCustomFieldsFromFile reads the fields: section from a single workflow.yaml.
func readCustomFieldsFromFile(path string) ([]customFieldYAML, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var fd customFieldFileData
if err := yaml.Unmarshal(data, &fd); err != nil {
return nil, fmt.Errorf("parsing %s: %w", path, err)
}
return fd.Fields, nil
}
// convertCustomFieldDef converts a YAML field definition to a workflow.FieldDef.
func convertCustomFieldDef(def customFieldYAML) (workflow.FieldDef, error) {
if def.Name == "" {
return workflow.FieldDef{}, fmt.Errorf("field name is required")
}
if err := workflow.ValidateFieldName(def.Name); err != nil {
return workflow.FieldDef{}, err
}
vt, err := parseFieldType(def.Type)
if err != nil {
return workflow.FieldDef{}, err
}
fd := workflow.FieldDef{
Name: def.Name,
Type: vt,
Custom: true,
}
if vt == workflow.TypeEnum {
if len(def.Values) == 0 {
return workflow.FieldDef{}, fmt.Errorf("enum field requires non-empty values list")
}
fd.AllowedValues = make([]string, len(def.Values))
copy(fd.AllowedValues, def.Values)
} else if len(def.Values) > 0 {
return workflow.FieldDef{}, fmt.Errorf("values list is only valid for enum fields")
}
return fd, nil
}
// parseFieldType maps workflow.yaml type strings to workflow.ValueType.
func parseFieldType(s string) (workflow.ValueType, error) {
switch strings.ToLower(s) {
case "text":
return workflow.TypeString, nil
case "integer":
return workflow.TypeInt, nil
case "boolean":
return workflow.TypeBool, nil
case "datetime":
return workflow.TypeTimestamp, nil
case "enum":
return workflow.TypeEnum, nil
case "stringlist":
return workflow.TypeListString, nil
case "taskidlist":
return workflow.TypeListRef, nil
default:
return 0, fmt.Errorf("unknown field type %q (valid: text, integer, boolean, datetime, enum, stringList, taskIdList)", s)
}
}
// fieldDefsEqual returns true if two FieldDefs are structurally identical
// (same name, same type, and for enums, same normalized values).
func fieldDefsEqual(a, b workflow.FieldDef) bool {
if a.Name != b.Name || a.Type != b.Type {
return false
}
if a.Type == workflow.TypeEnum {
if len(a.AllowedValues) != len(b.AllowedValues) {
return false
}
// require exact spelling and order for duplicate enum declarations
for i := range a.AllowedValues {
if a.AllowedValues[i] != b.AllowedValues[i] {
return false
}
}
}
return true
}

225
config/fields_test.go Normal file
View file

@ -0,0 +1,225 @@
package config
import (
"os"
"path/filepath"
"testing"
"github.com/boolean-maybe/tiki/workflow"
)
// setupLoadCustomFieldsTest creates temp dirs and configures the path manager
// so LoadCustomFields can discover workflow.yaml files.
func setupLoadCustomFieldsTest(t *testing.T) (cwdDir string) {
t.Helper()
workflow.ClearCustomFields()
t.Cleanup(func() { workflow.ClearCustomFields() })
userDir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", userDir)
if err := os.MkdirAll(filepath.Join(userDir, "tiki"), 0750); err != nil {
t.Fatal(err)
}
projectDir := t.TempDir()
docDir := filepath.Join(projectDir, ".doc")
if err := os.MkdirAll(docDir, 0750); err != nil {
t.Fatal(err)
}
cwdDir = t.TempDir()
originalDir, _ := os.Getwd()
t.Cleanup(func() { _ = os.Chdir(originalDir) })
_ = os.Chdir(cwdDir)
ResetPathManager()
pm := mustGetPathManager()
pm.projectRoot = projectDir
return cwdDir
}
func TestLoadCustomFields_BasicTypes(t *testing.T) {
cwdDir := setupLoadCustomFieldsTest(t)
content := `
fields:
- name: notes
type: text
- name: score
type: integer
- name: active
type: boolean
- name: startedAt
type: datetime
- name: labels
type: stringList
- name: related
type: taskIdList
`
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
if err := LoadCustomFields(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
checks := []struct {
name string
wantType workflow.ValueType
}{
{"notes", workflow.TypeString},
{"score", workflow.TypeInt},
{"active", workflow.TypeBool},
{"startedAt", workflow.TypeTimestamp},
{"labels", workflow.TypeListString},
{"related", workflow.TypeListRef},
}
for _, c := range checks {
f, ok := workflow.Field(c.name)
if !ok {
t.Errorf("Field(%q) not found", c.name)
continue
}
if f.Type != c.wantType {
t.Errorf("Field(%q).Type = %v, want %v", c.name, f.Type, c.wantType)
}
if !f.Custom {
t.Errorf("Field(%q).Custom = false, want true", c.name)
}
}
}
func TestLoadCustomFields_EnumWithValues(t *testing.T) {
cwdDir := setupLoadCustomFieldsTest(t)
content := `
fields:
- name: severity
type: enum
values: [low, medium, high, critical]
`
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
if err := LoadCustomFields(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
f, ok := workflow.Field("severity")
if !ok {
t.Fatal("severity field not found")
}
if f.Type != workflow.TypeEnum {
t.Errorf("severity.Type = %v, want TypeEnum", f.Type)
}
wantVals := []string{"low", "medium", "high", "critical"}
if len(f.AllowedValues) != len(wantVals) {
t.Fatalf("severity.AllowedValues length = %d, want %d", len(f.AllowedValues), len(wantVals))
}
for i, v := range wantVals {
if f.AllowedValues[i] != v {
t.Errorf("AllowedValues[%d] = %q, want %q", i, f.AllowedValues[i], v)
}
}
}
func TestLoadCustomFields_BadTypeRejected(t *testing.T) {
cwdDir := setupLoadCustomFieldsTest(t)
content := `
fields:
- name: broken
type: nosuchtype
`
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
err := LoadCustomFields()
if err == nil {
t.Fatal("expected error for unknown type")
}
}
func TestLoadCustomFields_EnumWithoutValues(t *testing.T) {
cwdDir := setupLoadCustomFieldsTest(t)
content := `
fields:
- name: severity
type: enum
`
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
err := LoadCustomFields()
if err == nil {
t.Fatal("expected error for enum without values")
}
}
func TestLoadCustomFields_ConflictingRedefinition(t *testing.T) {
cwdDir := setupLoadCustomFieldsTest(t)
pm := mustGetPathManager()
// write field definition in project config
projectWorkflow := filepath.Join(pm.ProjectConfigDir(), "workflow.yaml")
content1 := `
fields:
- name: score
type: integer
`
if err := os.WriteFile(projectWorkflow, []byte(content1), 0644); err != nil {
t.Fatal(err)
}
// write conflicting definition in cwd
content2 := `
fields:
- name: score
type: text
`
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content2), 0644); err != nil {
t.Fatal(err)
}
err := LoadCustomFields()
if err == nil {
t.Fatal("expected error for conflicting redefinition")
}
}
func TestLoadCustomFields_IdenticalRedefinition(t *testing.T) {
cwdDir := setupLoadCustomFieldsTest(t)
pm := mustGetPathManager()
// write identical definitions in two locations
content := `
fields:
- name: score
type: integer
`
projectWorkflow := filepath.Join(pm.ProjectConfigDir(), "workflow.yaml")
if err := os.WriteFile(projectWorkflow, []byte(content), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
if err := LoadCustomFields(); err != nil {
t.Fatalf("identical redefinition should succeed: %v", err)
}
f, ok := workflow.Field("score")
if !ok {
t.Fatal("score field not found")
}
if f.Type != workflow.TypeInt {
t.Errorf("score.Type = %v, want TypeInt", f.Type)
}
}

View file

@ -1,6 +1,7 @@
package config
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
@ -25,10 +26,17 @@ func IsProjectInitialized() bool {
return info.IsDir()
}
// InitOptions holds user choices from the init dialog.
type InitOptions struct {
AITools []string
SampleTasks bool
}
// PromptForProjectInit presents a Huh form for project initialization.
// Returns (selectedAITools, proceed, error)
func PromptForProjectInit() ([]string, bool, error) {
var selectedAITools []string
// Returns (options, proceed, error)
func PromptForProjectInit() (InitOptions, bool, error) {
var opts InitOptions
opts.SampleTasks = true // default enabled
// Create custom theme with brighter description and help text
theme := huh.ThemeCharm()
@ -67,9 +75,9 @@ AI skills extend your AI assistant with commands to manage tasks and documentati
Select AI assistants to install (optional), then press Enter to continue.
Press Esc to cancel project initialization.`
options := make([]huh.Option[string], 0, len(AITools()))
aiOptions := make([]huh.Option[string], 0, len(AITools()))
for _, t := range AITools() {
options = append(options, huh.NewOption(fmt.Sprintf("%s (%s/)", t.DisplayName, t.SkillDir), t.Key))
aiOptions = append(aiOptions, huh.NewOption(fmt.Sprintf("%s (%s/)", t.DisplayName, t.SkillDir), t.Key))
}
form := huh.NewForm(
@ -77,9 +85,13 @@ Press Esc to cancel project initialization.`
huh.NewMultiSelect[string]().
Title("Initialize project").
Description(description).
Options(options...).
Options(aiOptions...).
Filterable(false).
Value(&selectedAITools),
Value(&opts.AITools),
huh.NewConfirm().
Title("Create sample tasks").
Description("Create example tikis in project").
Value(&opts.SampleTasks),
),
).WithTheme(theme).
WithKeyMap(keymap).
@ -88,12 +100,12 @@ Press Esc to cancel project initialization.`
err := form.Run()
if err != nil {
if errors.Is(err, huh.ErrUserAborted) {
return nil, false, nil
return InitOptions{}, false, nil
}
return nil, false, fmt.Errorf("form error: %w", err)
return InitOptions{}, false, fmt.Errorf("form error: %w", err)
}
return selectedAITools, true, nil
return opts, true, nil
}
// EnsureProjectInitialized bootstraps the project if .doc/tiki is missing.
@ -106,7 +118,7 @@ func EnsureProjectInitialized(tikiSkillMdContent, dokiSkillMdContent string) (bo
return false, fmt.Errorf("failed to stat task directory: %w", err)
}
selectedTools, proceed, err := PromptForProjectInit()
opts, proceed, err := PromptForProjectInit()
if err != nil {
return false, fmt.Errorf("failed to prompt for project initialization: %w", err)
}
@ -114,18 +126,18 @@ func EnsureProjectInitialized(tikiSkillMdContent, dokiSkillMdContent string) (bo
return false, nil
}
if err := BootstrapSystem(); err != nil {
if err := BootstrapSystem(opts.SampleTasks); err != nil {
return false, fmt.Errorf("failed to bootstrap project: %w", err)
}
// Install selected AI skills
if len(selectedTools) > 0 {
if err := installAISkills(selectedTools, tikiSkillMdContent, dokiSkillMdContent); err != nil {
if len(opts.AITools) > 0 {
if err := installAISkills(opts.AITools, tikiSkillMdContent, dokiSkillMdContent); err != nil {
// Non-fatal - log warning but continue
slog.Warn("some AI skills failed to install", "error", err)
fmt.Println("You can manually copy ai/skills/tiki/SKILL.md and ai/skills/doki/SKILL.md to the appropriate directories.")
} else {
fmt.Printf("✓ Installed AI skills for: %s\n", strings.Join(selectedTools, ", "))
fmt.Printf("✓ Installed AI skills for: %s\n", strings.Join(opts.AITools, ", "))
}
}
@ -145,6 +157,20 @@ func installAISkills(selectedTools []string, tikiSkillMdContent, dokiSkillMdCont
return fmt.Errorf("embedded doki SKILL.md content is empty")
}
type skillDef struct {
name string
content string
}
skills := []skillDef{
{"tiki", tikiSkillMdContent},
{"doki", dokiSkillMdContent},
}
skillNames := make([]string, len(skills))
for i, s := range skills {
skillNames[i] = s.name
}
var errs []error
for _, toolKey := range selectedTools {
tool, ok := LookupAITool(toolKey)
@ -153,14 +179,7 @@ func installAISkills(selectedTools []string, tikiSkillMdContent, dokiSkillMdCont
continue
}
// install each skill type (tiki, doki)
for _, skill := range []struct {
name string
content string
}{
{"tiki", tikiSkillMdContent},
{"doki", dokiSkillMdContent},
} {
for _, skill := range skills {
path := tool.SkillPath(skill.name)
dir := filepath.Dir(path)
//nolint:gosec // G301: 0755 is appropriate for user-owned skill directories
@ -172,6 +191,12 @@ func installAISkills(selectedTools []string, tikiSkillMdContent, dokiSkillMdCont
slog.Info("installed AI skill", "tool", toolKey, "skill", skill.name, "path", path)
}
}
if tool.SettingsFile != "" {
if err := ensureSkillPermissions(tool.SettingsFile, skillNames); err != nil {
errs = append(errs, fmt.Errorf("failed to update %s settings: %w", toolKey, err))
}
}
}
if len(errs) > 0 {
@ -179,3 +204,68 @@ func installAISkills(selectedTools []string, tikiSkillMdContent, dokiSkillMdCont
}
return nil
}
func skillPermissionEntry(name string) string {
return fmt.Sprintf("Skill(%s)", name)
}
// ensureSkillPermissions creates or updates a settings file to include
// Skill(<name>) entries in permissions.allow for each given skill name.
// Existing permissions and other top-level keys are preserved.
func ensureSkillPermissions(settingsPath string, skillNames []string) error {
settings := make(map[string]any)
data, err := os.ReadFile(settingsPath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("reading %s: %w", settingsPath, err)
}
if len(data) > 0 {
if err := json.Unmarshal(data, &settings); err != nil {
return fmt.Errorf("parsing %s: %w", settingsPath, err)
}
}
perms, _ := settings["permissions"].(map[string]any)
if perms == nil {
perms = make(map[string]any)
settings["permissions"] = perms
}
allowRaw, _ := perms["allow"].([]any)
existing := make(map[string]bool, len(allowRaw))
for _, v := range allowRaw {
if s, ok := v.(string); ok {
existing[s] = true
}
}
changed := false
for _, name := range skillNames {
entry := skillPermissionEntry(name)
if !existing[entry] {
allowRaw = append(allowRaw, entry)
changed = true
}
}
if !changed {
return nil
}
perms["allow"] = allowRaw
out, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return fmt.Errorf("marshaling settings: %w", err)
}
if err := os.MkdirAll(filepath.Dir(settingsPath), 0750); err != nil {
return fmt.Errorf("creating directory for %s: %w", settingsPath, err)
}
//nolint:gosec // G306: 0644 is appropriate for user settings files
if err := os.WriteFile(settingsPath, append(out, '\n'), 0644); err != nil {
return fmt.Errorf("writing %s: %w", settingsPath, err)
}
slog.Info("updated Claude settings", "path", settingsPath, "skills", skillNames)
return nil
}

182
config/init_test.go Normal file
View file

@ -0,0 +1,182 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
const settingsFile = ".claude/settings.local.json"
func TestEnsureSkillPermissions_CreatesFile(t *testing.T) {
t.Chdir(t.TempDir())
if err := ensureSkillPermissions(settingsFile, []string{"tiki", "doki"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := readSettings(t)
allow := getAllow(t, got)
if len(allow) != 2 {
t.Fatalf("expected 2 entries, got %d: %v", len(allow), allow)
}
if allow[0] != "Skill(tiki)" || allow[1] != "Skill(doki)" {
t.Errorf("unexpected entries: %v", allow)
}
}
func TestEnsureSkillPermissions_MergesExisting(t *testing.T) {
t.Chdir(t.TempDir())
writeSettings(t, map[string]any{
"permissions": map[string]any{
"allow": []any{"Bash(go test:*)"},
},
})
if err := ensureSkillPermissions(settingsFile, []string{"tiki"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := readSettings(t)
allow := getAllow(t, got)
if len(allow) != 2 {
t.Fatalf("expected 2 entries, got %d: %v", len(allow), allow)
}
if allow[0] != "Bash(go test:*)" {
t.Errorf("existing entry clobbered: %v", allow)
}
if allow[1] != "Skill(tiki)" {
t.Errorf("new entry missing: %v", allow)
}
}
func TestEnsureSkillPermissions_AlreadyPresent(t *testing.T) {
t.Chdir(t.TempDir())
writeSettings(t, map[string]any{
"permissions": map[string]any{
"allow": []any{"Skill(tiki)", "Skill(doki)"},
},
})
before, _ := os.ReadFile(settingsFile)
if err := ensureSkillPermissions(settingsFile, []string{"tiki", "doki"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
after, _ := os.ReadFile(settingsFile)
if string(before) != string(after) {
t.Error("file was modified despite all skills already present")
}
}
func TestEnsureSkillPermissions_PreservesOtherKeys(t *testing.T) {
t.Chdir(t.TempDir())
writeSettings(t, map[string]any{
"outputStyle": "Explanatory",
"permissions": map[string]any{
"allow": []any{"Bash(go test:*)"},
},
})
if err := ensureSkillPermissions(settingsFile, []string{"tiki"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := readSettings(t)
style, ok := got["outputStyle"].(string)
if !ok || style != "Explanatory" {
t.Errorf("outputStyle not preserved: got %v", got["outputStyle"])
}
}
func TestEnsureSkillPermissions_EmptyPermissions(t *testing.T) {
t.Chdir(t.TempDir())
writeSettings(t, map[string]any{
"permissions": map[string]any{},
})
if err := ensureSkillPermissions(settingsFile, []string{"tiki"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := readSettings(t)
allow := getAllow(t, got)
if len(allow) != 1 || allow[0] != "Skill(tiki)" {
t.Errorf("unexpected entries: %v", allow)
}
}
func TestEnsureSkillPermissions_MalformedJSON(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.MkdirAll(filepath.Dir(settingsFile), 0750); err != nil {
t.Fatal(err)
}
//nolint:gosec // G306: 0644 is appropriate for test fixture files
if err := os.WriteFile(settingsFile, []byte("{invalid json"), 0644); err != nil {
t.Fatal(err)
}
err := ensureSkillPermissions(settingsFile, []string{"tiki"})
if err == nil {
t.Fatal("expected error for malformed JSON")
}
}
// helpers
func readSettings(t *testing.T) map[string]any {
t.Helper()
data, err := os.ReadFile(settingsFile)
if err != nil {
t.Fatalf("reading %s: %v", settingsFile, err)
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("parsing %s: %v", settingsFile, err)
}
return m
}
func getAllow(t *testing.T, settings map[string]any) []string {
t.Helper()
perms, _ := settings["permissions"].(map[string]any)
if perms == nil {
t.Fatal("missing permissions key")
}
rawAllow, _ := perms["allow"].([]any)
if rawAllow == nil {
t.Fatal("missing allow key")
}
allow := make([]string, len(rawAllow))
for i, v := range rawAllow {
s, ok := v.(string)
if !ok {
t.Fatalf("allow[%d] is not a string: %v", i, v)
}
allow[i] = s
}
return allow
}
func writeSettings(t *testing.T, settings map[string]any) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(settingsFile), 0750); err != nil {
t.Fatal(err)
}
data, err := json.MarshalIndent(settings, "", " ")
if err != nil {
t.Fatal(err)
}
//nolint:gosec // G306: 0644 is appropriate for test fixture files
if err := os.WriteFile(settingsFile, append(data, '\n'), 0644); err != nil {
t.Fatal(err)
}
}

121
config/install.go Normal file
View file

@ -0,0 +1,121 @@
package config
import (
"context"
"fmt"
"io"
"net/http"
"path/filepath"
"regexp"
"time"
"gopkg.in/yaml.v3"
)
const (
httpTimeout = 15 * time.Second
maxResponseSize = 1 << 20 // 1 MiB
)
var DefaultWorkflowBaseURL = "https://raw.githubusercontent.com/boolean-maybe/tiki/main"
var validWorkflowName = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$`)
// InstallResult describes the outcome for a single installed file.
type InstallResult struct {
Path string
Changed bool
}
var installFiles = []string{
defaultWorkflowFilename,
templateFilename,
}
// InstallWorkflow fetches a named workflow from baseURL and writes its files
// to the directory for the given scope, overwriting existing files.
// baseURL is the root URL before "/workflows" (e.g. "https://raw.githubusercontent.com/boolean-maybe/tiki/main").
func InstallWorkflow(name string, scope Scope, baseURL string) ([]InstallResult, error) {
dir, err := resolveDir(scope)
if err != nil {
return nil, err
}
fetched := make(map[string]string, len(installFiles))
for _, filename := range installFiles {
content, err := fetchWorkflowFile(baseURL, name, filename)
if err != nil {
return nil, fmt.Errorf("fetch %s/%s: %w", name, filename, err)
}
fetched[filename] = string(content)
}
var results []InstallResult
for _, filename := range installFiles {
path := filepath.Join(dir, filename)
changed, err := writeFileIfChanged(path, fetched[filename])
if err != nil {
return results, fmt.Errorf("write %s: %w", filename, err)
}
results = append(results, InstallResult{Path: path, Changed: changed})
}
return results, nil
}
// DescribeWorkflow fetches the workflow.yaml for name from baseURL and
// returns the value of its top-level `description:` field. Returns empty
// string if the field is absent.
func DescribeWorkflow(name, baseURL string) (string, error) {
body, err := fetchWorkflowFile(baseURL, name, defaultWorkflowFilename)
if err != nil {
return "", err
}
var wf struct {
Description string `yaml:"description"`
}
if err := yaml.Unmarshal(body, &wf); err != nil {
return "", fmt.Errorf("parse %s/workflow.yaml: %w", name, err)
}
return wf.Description, nil
}
var httpClient = &http.Client{Timeout: httpTimeout}
// fetchWorkflowFile validates the workflow name and downloads a single file
// from baseURL. Returns the raw body bytes.
func fetchWorkflowFile(baseURL, name, filename string) ([]byte, error) {
if !validWorkflowName.MatchString(name) {
return nil, fmt.Errorf("invalid workflow name %q: use letters, digits, hyphens, dots, or underscores", name)
}
url := fmt.Sprintf("%s/workflows/%s/%s", baseURL, name, filename)
ctx, cancel := context.WithTimeout(context.Background(), httpTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("workflow %q not found (%s)", name, filename)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected HTTP %d for %s", resp.StatusCode, url)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
return body, nil
}

213
config/install_test.go Normal file
View file

@ -0,0 +1,213 @@
package config
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
func TestInstallWorkflow_Success(t *testing.T) {
tikiDir := setupResetTest(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/workflows/sprint/workflow.yaml":
_, _ = w.Write([]byte("statuses:\n - key: todo\n"))
case "/workflows/sprint/new.md":
_, _ = w.Write([]byte("---\ntitle:\n---\n"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
results, err := InstallWorkflow("sprint", ScopeGlobal, server.URL)
if err != nil {
t.Fatalf("InstallWorkflow() error = %v", err)
}
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
for _, r := range results {
if !r.Changed {
t.Errorf("expected %s to be changed on fresh install", r.Path)
}
}
got, err := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
if err != nil {
t.Fatalf("read workflow.yaml: %v", err)
}
if string(got) != "statuses:\n - key: todo\n" {
t.Errorf("workflow.yaml content = %q", string(got))
}
got, err = os.ReadFile(filepath.Join(tikiDir, "new.md"))
if err != nil {
t.Fatalf("read new.md: %v", err)
}
if string(got) != "---\ntitle:\n---\n" {
t.Errorf("new.md content = %q", string(got))
}
}
func TestInstallWorkflow_Overwrites(t *testing.T) {
tikiDir := setupResetTest(t)
if err := os.WriteFile(filepath.Join(tikiDir, "workflow.yaml"), []byte("old content"), 0644); err != nil {
t.Fatal(err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("new content"))
}))
defer server.Close()
results, err := InstallWorkflow("sprint", ScopeGlobal, server.URL)
if err != nil {
t.Fatalf("InstallWorkflow() error = %v", err)
}
for _, r := range results {
if !r.Changed {
t.Errorf("expected %s to be changed on overwrite", r.Path)
}
}
got, _ := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
if string(got) != "new content" {
t.Errorf("workflow.yaml not overwritten: %q", string(got))
}
}
func TestInstallWorkflow_NotFound(t *testing.T) {
_ = setupResetTest(t)
server := httptest.NewServer(http.NotFoundHandler())
defer server.Close()
_, err := InstallWorkflow("nonexistent", ScopeGlobal, server.URL)
if err == nil {
t.Fatal("expected error for nonexistent workflow, got nil")
}
}
func TestInstallWorkflow_AlreadyUpToDate(t *testing.T) {
_ = setupResetTest(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("same content"))
}))
defer server.Close()
if _, err := InstallWorkflow("sprint", ScopeGlobal, server.URL); err != nil {
t.Fatalf("first install: %v", err)
}
results, err := InstallWorkflow("sprint", ScopeGlobal, server.URL)
if err != nil {
t.Fatalf("second install: %v", err)
}
for _, r := range results {
if r.Changed {
t.Errorf("expected %s to be unchanged on repeat install", r.Path)
}
}
}
func TestInstallWorkflow_InvalidName(t *testing.T) {
_ = setupResetTest(t)
for _, name := range []string{"../../etc", "a b", "", "foo/bar", "-dash", "dot."} {
_, err := InstallWorkflow(name, ScopeGlobal, "http://unused")
if err == nil {
t.Errorf("expected error for name %q, got nil", name)
}
}
}
func TestDescribeWorkflow_Success(t *testing.T) {
_ = setupResetTest(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/workflows/sprint/workflow.yaml" {
http.NotFound(w, r)
return
}
_, _ = w.Write([]byte("description: |\n Sprint workflow.\n Two-week cycles.\nstatuses:\n - key: todo\n"))
}))
defer server.Close()
desc, err := DescribeWorkflow("sprint", server.URL)
if err != nil {
t.Fatalf("DescribeWorkflow() error = %v", err)
}
want := "Sprint workflow.\nTwo-week cycles.\n"
if desc != want {
t.Errorf("description = %q, want %q", desc, want)
}
}
func TestDescribeWorkflow_NoDescriptionField(t *testing.T) {
_ = setupResetTest(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("statuses:\n - key: todo\n"))
}))
defer server.Close()
desc, err := DescribeWorkflow("sprint", server.URL)
if err != nil {
t.Fatalf("DescribeWorkflow() error = %v", err)
}
if desc != "" {
t.Errorf("description = %q, want empty", desc)
}
}
func TestDescribeWorkflow_NotFound(t *testing.T) {
_ = setupResetTest(t)
server := httptest.NewServer(http.NotFoundHandler())
defer server.Close()
_, err := DescribeWorkflow("nonexistent", server.URL)
if err == nil {
t.Fatal("expected error for nonexistent workflow, got nil")
}
}
func TestDescribeWorkflow_InvalidName(t *testing.T) {
for _, name := range []string{"../../etc", "a b", "", "foo/bar", "-dash", "dot."} {
if _, err := DescribeWorkflow(name, "http://unused"); err == nil {
t.Errorf("expected error for name %q, got nil", name)
}
}
}
func TestInstallWorkflow_AtomicFetch(t *testing.T) {
tikiDir := setupResetTest(t)
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
if callCount == 1 {
_, _ = w.Write([]byte("ok"))
return
}
http.NotFound(w, r)
}))
defer server.Close()
_, err := InstallWorkflow("partial", ScopeGlobal, server.URL)
if err == nil {
t.Fatal("expected error for partial failure, got nil")
}
for _, filename := range []string{"workflow.yaml", "new.md"} {
if _, statErr := os.Stat(filepath.Join(tikiDir, filename)); !os.IsNotExist(statErr) {
t.Errorf("%s should not exist after fetch failure", filename)
}
}
}

View file

@ -21,6 +21,8 @@ var lastConfigFile string
// Config holds all application configuration loaded from config.yaml
type Config struct {
Version string `mapstructure:"version"`
// Logging configuration
Logging struct {
Level string `mapstructure:"level"` // "debug", "info", "warn", "error"
@ -190,21 +192,44 @@ 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"`
Triggers []map[string]interface{} `yaml:"triggers,omitempty"`
Version string `yaml:"version,omitempty"`
Description string `yaml:"description,omitempty"`
Statuses []map[string]interface{} `yaml:"statuses,omitempty"`
Types []map[string]interface{} `yaml:"types,omitempty"`
Views viewsFileData `yaml:"views,omitempty"`
Triggers []map[string]interface{} `yaml:"triggers,omitempty"`
Fields []map[string]interface{} `yaml:"fields,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 +237,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 +292,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 +321,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 +335,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 {
@ -494,6 +494,59 @@ triggers:
}
}
func TestSavePluginViewMode_PreservesDescription(t *testing.T) {
tmpDir := t.TempDir()
workflowContent := `description: |
Release workflow. Coordinate feature rollout through
Planned Building Staging Canary Released.
statuses:
- key: backlog
label: Backlog
default: true
- key: done
label: Done
done: true
views:
- name: Kanban
default: true
key: "F1"
lanes:
- name: Done
filter: status = 'done'
action: status = 'done'
sort: Priority, CreatedAt
`
workflowPath := filepath.Join(tmpDir, "workflow.yaml")
if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil {
t.Fatal(err)
}
wf, err := readWorkflowFile(workflowPath)
if err != nil {
t.Fatalf("readWorkflowFile failed: %v", err)
}
wantDesc := "Release workflow. Coordinate feature rollout through\nPlanned → Building → Staging → Canary → Released.\n"
if wf.Description != wantDesc {
t.Errorf("description after read = %q, want %q", wf.Description, wantDesc)
}
if len(wf.Views.Plugins) > 0 {
wf.Views.Plugins[0]["view"] = "compact"
}
if err := writeWorkflowFile(workflowPath, wf); err != nil {
t.Fatalf("writeWorkflowFile failed: %v", err)
}
wf2, err := readWorkflowFile(workflowPath)
if err != nil {
t.Fatalf("readWorkflowFile after write failed: %v", err)
}
if wf2.Description != wantDesc {
t.Errorf("description after round-trip = %q, want %q", wf2.Description, wantDesc)
}
}
func TestGetConfig(t *testing.T) {
// Reset appConfig
appConfig = nil

View file

@ -1,7 +1,5 @@
---
title:
type: story
status: backlog
points: 1
priority: 3
tags:

View file

@ -324,6 +324,9 @@ func GetUserConfigWorkflowFile() string {
return mustGetPathManager().UserConfigWorkflowFile()
}
// configFilename is the default name for the configuration file
const configFilename = "config.yaml"
// defaultWorkflowFilename is the default name for the workflow configuration file
const defaultWorkflowFilename = "workflow.yaml"
@ -372,21 +375,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.

168
config/reset.go Normal file
View file

@ -0,0 +1,168 @@
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
)
// Scope identifies which config tier to operate on.
type Scope string
// ResetTarget identifies which config file to reset.
type ResetTarget string
const (
ScopeGlobal Scope = "global"
ScopeLocal Scope = "local"
ScopeCurrent Scope = "current"
TargetAll ResetTarget = ""
TargetConfig ResetTarget = "config"
TargetWorkflow ResetTarget = "workflow"
TargetNew ResetTarget = "new"
)
// resetEntry pairs a filename with the default content to restore for global scope.
// If defaultContent is empty, the file is always deleted (no embedded default exists).
type resetEntry struct {
filename string
defaultContent string
}
var resetEntries = []resetEntry{
// TODO: embed a default config.yaml once one exists; until then, global reset deletes the file
{filename: configFilename, defaultContent: ""},
{filename: defaultWorkflowFilename, defaultContent: defaultWorkflowYAML},
{filename: templateFilename, defaultContent: defaultNewTaskTemplate},
}
// ResetConfig resets configuration files for the given scope and target.
// Returns the list of file paths that were actually modified or deleted.
func ResetConfig(scope Scope, target ResetTarget) ([]string, error) {
dir, err := resolveDir(scope)
if err != nil {
return nil, err
}
entries, err := filterEntries(target)
if err != nil {
return nil, err
}
var affected []string
for _, e := range entries {
path := filepath.Join(dir, e.filename)
changed, err := resetFile(path, scope, e.defaultContent)
if err != nil {
return affected, fmt.Errorf("reset %s: %w", e.filename, err)
}
if changed {
affected = append(affected, path)
}
}
return affected, nil
}
// resolveDir returns the directory path for the given scope.
func resolveDir(scope Scope) (string, error) {
switch scope {
case ScopeGlobal:
return GetConfigDir(), nil
case ScopeLocal:
if !IsProjectInitialized() {
return "", fmt.Errorf("not in an initialized tiki project (run 'tiki init' first)")
}
return GetProjectConfigDir(), nil
case ScopeCurrent:
cwd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("get working directory: %w", err)
}
return cwd, nil
default:
return "", fmt.Errorf("unknown scope: %s", scope)
}
}
// ValidResetTarget reports whether target is a recognized reset target.
func ValidResetTarget(target ResetTarget) bool {
switch target {
case TargetAll, TargetConfig, TargetWorkflow, TargetNew:
return true
default:
return false
}
}
// filterEntries returns the reset entries matching the target.
func filterEntries(target ResetTarget) ([]resetEntry, error) {
if target == TargetAll {
return resetEntries, nil
}
var filename string
switch target {
case TargetConfig:
filename = configFilename
case TargetWorkflow:
filename = defaultWorkflowFilename
case TargetNew:
filename = templateFilename
default:
return nil, fmt.Errorf("unknown target: %q (use config, workflow, or new)", target)
}
for _, e := range resetEntries {
if e.filename == filename {
return []resetEntry{e}, nil
}
}
return nil, fmt.Errorf("no reset entry for target %q", target)
}
// resetFile either overwrites or deletes a file depending on scope and available defaults.
// For global scope with non-empty default content, the file is overwritten.
// Otherwise the file is deleted. Returns true if the file was changed.
func resetFile(path string, scope Scope, defaultContent string) (bool, error) {
if scope == ScopeGlobal && defaultContent != "" {
return writeFileIfChanged(path, defaultContent)
}
return deleteIfExists(path)
}
// writeFileIfChanged writes content to path, skipping if the file already has identical content.
// Returns true if the file was actually changed.
func writeFileIfChanged(path string, content string) (bool, error) {
existing, err := os.ReadFile(path)
if err == nil && string(existing) == content {
return false, nil
}
return writeFile(path, content)
}
// writeFile writes content to path unconditionally, creating parent dirs if needed.
func writeFile(path string, content string) (bool, error) {
dir := filepath.Dir(path)
//nolint:gosec // G301: 0755 is appropriate for config directory
if err := os.MkdirAll(dir, 0755); err != nil {
return false, fmt.Errorf("create directory %s: %w", dir, err)
}
//nolint:gosec // G306: 0644 is appropriate for config file
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
return false, err
}
return true, nil
}
// deleteIfExists removes a file if it exists. Returns true if the file was deleted.
func deleteIfExists(path string) (bool, error) {
err := os.Remove(path)
if err == nil {
return true, nil
}
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, err
}

268
config/reset_test.go Normal file
View file

@ -0,0 +1,268 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
// setupResetTest creates a temp config dir, sets XDG_CONFIG_HOME, and resets
// the path manager so GetConfigDir() points to the temp dir.
// Returns the tiki config dir (e.g. <tmp>/tiki).
func setupResetTest(t *testing.T) string {
t.Helper()
xdgDir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", xdgDir)
ResetPathManager()
t.Cleanup(ResetPathManager)
tikiDir := filepath.Join(xdgDir, "tiki")
if err := os.MkdirAll(tikiDir, 0750); err != nil {
t.Fatal(err)
}
return tikiDir
}
// writeTestFile is a test helper that writes content to path.
func writeTestFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
func TestResetConfig_GlobalAll(t *testing.T) {
tikiDir := setupResetTest(t)
// seed all three files with custom content
writeTestFile(t, filepath.Join(tikiDir, "config.yaml"), "logging:\n level: debug\n")
writeTestFile(t, filepath.Join(tikiDir, "workflow.yaml"), "custom: true\n")
writeTestFile(t, filepath.Join(tikiDir, "new.md"), "custom template\n")
affected, err := ResetConfig(ScopeGlobal, TargetAll)
if err != nil {
t.Fatalf("ResetConfig() error = %v", err)
}
if len(affected) != 3 {
t.Fatalf("expected 3 affected files, got %d: %v", len(affected), affected)
}
// config.yaml should be deleted (no embedded default)
if _, err := os.Stat(filepath.Join(tikiDir, "config.yaml")); !os.IsNotExist(err) {
t.Error("config.yaml should be deleted after global reset")
}
// workflow.yaml should contain embedded default
got, err := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
if err != nil {
t.Fatalf("read workflow.yaml: %v", err)
}
if string(got) != GetDefaultWorkflowYAML() {
t.Error("workflow.yaml does not match embedded default after global reset")
}
// new.md should contain embedded default
got, err = os.ReadFile(filepath.Join(tikiDir, "new.md"))
if err != nil {
t.Fatalf("read new.md: %v", err)
}
if string(got) != GetDefaultNewTaskTemplate() {
t.Error("new.md does not match embedded default after global reset")
}
}
func TestResetConfig_GlobalSingleTarget(t *testing.T) {
tests := []struct {
target ResetTarget
filename string
deleted bool // true = file deleted, false = file overwritten with default
}{
{TargetConfig, "config.yaml", true},
{TargetWorkflow, "workflow.yaml", false},
{TargetNew, "new.md", false},
}
for _, tt := range tests {
t.Run(string(tt.target), func(t *testing.T) {
tikiDir := setupResetTest(t)
writeTestFile(t, filepath.Join(tikiDir, tt.filename), "custom\n")
affected, err := ResetConfig(ScopeGlobal, tt.target)
if err != nil {
t.Fatalf("ResetConfig() error = %v", err)
}
if len(affected) != 1 {
t.Fatalf("expected 1 affected file, got %d", len(affected))
}
_, statErr := os.Stat(filepath.Join(tikiDir, tt.filename))
if tt.deleted {
if !os.IsNotExist(statErr) {
t.Errorf("%s should be deleted", tt.filename)
}
} else {
if statErr != nil {
t.Errorf("%s should exist after reset: %v", tt.filename, statErr)
}
}
})
}
}
func TestResetConfig_LocalDeletesFiles(t *testing.T) {
tikiDir := setupResetTest(t)
// set up project dir with .doc/tiki so IsProjectInitialized() passes
projectDir := t.TempDir()
docDir := filepath.Join(projectDir, ".doc")
if err := os.MkdirAll(filepath.Join(docDir, "tiki"), 0750); err != nil {
t.Fatal(err)
}
pm := mustGetPathManager()
pm.projectRoot = projectDir
// seed project config files
writeTestFile(t, filepath.Join(docDir, "config.yaml"), "custom\n")
writeTestFile(t, filepath.Join(docDir, "workflow.yaml"), "custom\n")
writeTestFile(t, filepath.Join(docDir, "new.md"), "custom\n")
// also write global defaults so we can verify local doesn't overwrite
writeTestFile(t, filepath.Join(tikiDir, "workflow.yaml"), "global\n")
affected, err := ResetConfig(ScopeLocal, TargetAll)
if err != nil {
t.Fatalf("ResetConfig() error = %v", err)
}
if len(affected) != 3 {
t.Fatalf("expected 3 affected files, got %d: %v", len(affected), affected)
}
// all project files should be deleted
for _, name := range []string{"config.yaml", "workflow.yaml", "new.md"} {
if _, err := os.Stat(filepath.Join(docDir, name)); !os.IsNotExist(err) {
t.Errorf("project %s should be deleted after local reset", name)
}
}
// global workflow should be untouched
got, err := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
if err != nil {
t.Fatalf("global workflow.yaml should still exist: %v", err)
}
if string(got) != "global\n" {
t.Error("global workflow.yaml should be untouched after local reset")
}
}
func TestResetConfig_CurrentDeletesFiles(t *testing.T) {
_ = setupResetTest(t)
cwdDir := t.TempDir()
originalDir, _ := os.Getwd()
defer func() { _ = os.Chdir(originalDir) }()
_ = os.Chdir(cwdDir)
writeTestFile(t, filepath.Join(cwdDir, "workflow.yaml"), "override\n")
affected, err := ResetConfig(ScopeCurrent, TargetWorkflow)
if err != nil {
t.Fatalf("ResetConfig() error = %v", err)
}
if len(affected) != 1 {
t.Fatalf("expected 1 affected file, got %d", len(affected))
}
if _, err := os.Stat(filepath.Join(cwdDir, "workflow.yaml")); !os.IsNotExist(err) {
t.Error("cwd workflow.yaml should be deleted after current reset")
}
}
func TestResetConfig_IdempotentOnMissingFiles(t *testing.T) {
_ = setupResetTest(t)
// reset when no files exist — should succeed with 0 affected
affected, err := ResetConfig(ScopeGlobal, TargetConfig)
if err != nil {
t.Fatalf("ResetConfig() error = %v", err)
}
if len(affected) != 0 {
t.Errorf("expected 0 affected files for missing config.yaml, got %d", len(affected))
}
}
func TestResetConfig_GlobalWorkflowCreatesDir(t *testing.T) {
// use a fresh temp dir where tiki subdir doesn't exist yet
xdgDir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", xdgDir)
ResetPathManager()
t.Cleanup(ResetPathManager)
affected, err := ResetConfig(ScopeGlobal, TargetWorkflow)
if err != nil {
t.Fatalf("ResetConfig() error = %v", err)
}
if len(affected) != 1 {
t.Fatalf("expected 1 affected file, got %d", len(affected))
}
// should have created the directory and written the default
tikiDir := filepath.Join(xdgDir, "tiki")
got, err := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
if err != nil {
t.Fatalf("read workflow.yaml: %v", err)
}
if string(got) != GetDefaultWorkflowYAML() {
t.Error("workflow.yaml should match embedded default")
}
}
func TestResetConfig_GlobalSkipsWhenAlreadyDefault(t *testing.T) {
tikiDir := setupResetTest(t)
// write the embedded default content — reset should detect no change
writeTestFile(t, filepath.Join(tikiDir, "workflow.yaml"), GetDefaultWorkflowYAML())
affected, err := ResetConfig(ScopeGlobal, TargetWorkflow)
if err != nil {
t.Fatalf("ResetConfig() error = %v", err)
}
if len(affected) != 0 {
t.Errorf("expected 0 affected files when already default, got %d", len(affected))
}
}
func TestValidResetTarget(t *testing.T) {
valid := []ResetTarget{TargetAll, TargetConfig, TargetWorkflow, TargetNew}
for _, target := range valid {
if !ValidResetTarget(target) {
t.Errorf("ValidResetTarget(%q) = false, want true", target)
}
}
invalid := []ResetTarget{"themes", "invalid", "reset"}
for _, target := range invalid {
if ValidResetTarget(target) {
t.Errorf("ValidResetTarget(%q) = true, want false", target)
}
}
}
func TestResetConfig_LocalRejectsUninitializedProject(t *testing.T) {
// point projectRoot at a temp dir that has no .doc/tiki
xdgDir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", xdgDir)
ResetPathManager()
t.Cleanup(ResetPathManager)
pm := mustGetPathManager()
pm.projectRoot = t.TempDir() // empty dir — not initialized
_, err := ResetConfig(ScopeLocal, TargetAll)
if err == nil {
t.Fatal("expected error for uninitialized project, got nil")
}
if msg := err.Error(); !strings.Contains(msg, "not in an initialized tiki project") {
t.Errorf("unexpected error message: %s", msg)
}
}

View file

@ -0,0 +1,47 @@
package config
import (
"os"
"path/filepath"
"testing"
"gopkg.in/yaml.v3"
)
// TestShippedWorkflows_HaveDescription ensures every workflow.yaml shipped in
// the repo's top-level workflows/ directory has a non-empty top-level
// description field and parses cleanly as a full workflowFileData.
// Guards against a maintainer dropping the description when adding a new
// shipped workflow.
func TestShippedWorkflows_HaveDescription(t *testing.T) {
matches, err := filepath.Glob("../workflows/*/workflow.yaml")
if err != nil {
t.Fatalf("glob shipped workflows: %v", err)
}
if len(matches) == 0 {
t.Fatal("no shipped workflows found at ../workflows/*/workflow.yaml")
}
for _, path := range matches {
t.Run(filepath.Base(filepath.Dir(path)), func(t *testing.T) {
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var desc struct {
Description string `yaml:"description"`
}
if err := yaml.Unmarshal(data, &desc); err != nil {
t.Fatalf("unmarshal description from %s: %v", path, err)
}
if desc.Description == "" {
t.Errorf("%s: missing or empty top-level description", path)
}
if _, err := readWorkflowFile(path); err != nil {
t.Errorf("readWorkflowFile(%s) failed: %v", path, err)
}
})
}
}

View file

@ -28,38 +28,37 @@ var (
registryMu sync.RWMutex
)
// LoadStatusRegistry reads the statuses: section from workflow.yaml files.
// The last file from FindWorkflowFiles() that contains a non-empty statuses list wins
// (most specific location takes precedence, matching plugin merge behavior).
// LoadStatusRegistry reads statuses: and types: from workflow.yaml files.
// Both registries are loaded into locals first, then published atomically
// so no intermediate state exists where one is updated and the other stale.
// Returns an error if no statuses are defined anywhere (no Go fallback).
func LoadStatusRegistry() error {
files := FindWorkflowFiles()
files := FindRegistryWorkflowFiles()
if len(files) == 0 {
return fmt.Errorf("no workflow.yaml found; statuses must be defined in workflow.yaml")
}
reg, path, err := loadStatusRegistryFromFiles(files)
statusReg, statusPath, err := loadStatusRegistryFromFiles(files)
if err != nil {
return err
}
if reg == nil {
if statusReg == nil {
return fmt.Errorf("no statuses defined in workflow.yaml; add a statuses: section")
}
registryMu.Lock()
globalStatusRegistry = reg
registryMu.Unlock()
slog.Debug("loaded status registry", "file", path, "count", len(reg.All()))
// also initialize type registry with defaults
typeReg, err := workflow.NewTypeRegistry(workflow.DefaultTypeDefs())
typeReg, typePath, err := loadTypeRegistryFromFiles(files)
if err != nil {
return fmt.Errorf("initializing type registry: %w", err)
return err
}
// publish both atomically
registryMu.Lock()
globalStatusRegistry = statusReg
globalTypeRegistry = typeReg
registryMu.Unlock()
slog.Debug("loaded status registry", "file", statusPath, "count", len(statusReg.All()))
slog.Debug("loaded type registry", "file", typePath, "count", len(typeReg.All()))
return nil
}
@ -116,7 +115,8 @@ func MaybeGetTypeRegistry() (*workflow.TypeRegistry, bool) {
}
// ResetStatusRegistry replaces the global registry with one built from the given defs.
// Intended for tests only.
// Also resets types to built-in defaults and clears custom fields so test helpers
// don't leak registry state. Intended for tests only.
func ResetStatusRegistry(defs []workflow.StatusDef) {
reg, err := workflow.NewStatusRegistry(defs)
if err != nil {
@ -130,17 +130,35 @@ func ResetStatusRegistry(defs []workflow.StatusDef) {
globalStatusRegistry = reg
globalTypeRegistry = typeReg
registryMu.Unlock()
workflow.ClearCustomFields()
registriesLoaded.Store(true)
}
// ClearStatusRegistry removes the global registries. Intended for test teardown.
// ResetTypeRegistry replaces the global type registry with one built from the
// given defs, without touching the status registry. Intended for tests that
// need custom type configurations while keeping existing status setup.
func ResetTypeRegistry(defs []workflow.TypeDef) {
reg, err := workflow.NewTypeRegistry(defs)
if err != nil {
panic(fmt.Sprintf("ResetTypeRegistry: %v", err))
}
registryMu.Lock()
globalTypeRegistry = reg
registryMu.Unlock()
}
// ClearStatusRegistry removes the global registries and clears custom fields.
// Intended for test teardown.
func ClearStatusRegistry() {
registryMu.Lock()
globalStatusRegistry = nil
globalTypeRegistry = nil
registryMu.Unlock()
workflow.ClearCustomFields()
registriesLoaded.Store(false)
}
// --- internal ---
// --- internal: statuses ---
// workflowStatusData is the YAML shape we unmarshal to extract just the statuses key.
type workflowStatusData struct {
@ -164,3 +182,99 @@ func loadStatusesFromFile(path string) (*workflow.StatusRegistry, error) {
return workflow.NewStatusRegistry(ws.Statuses)
}
// --- internal: types ---
// validTypeDefKeys is the set of allowed keys inside a types: entry.
var validTypeDefKeys = map[string]bool{
"key": true, "label": true, "emoji": true,
}
// loadTypesFromFile loads types from a single workflow.yaml.
// Returns (registry, present, error):
// - (nil, false, nil) when the types: key is absent — file does not override
// - (reg, true, nil) when types: is present and valid
// - (nil, true, err) when types: is present but invalid (empty list, bad entries)
func loadTypesFromFile(path string) (*workflow.TypeRegistry, bool, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, false, fmt.Errorf("reading %s: %w", path, err)
}
// first pass: check whether the types key exists at all
var raw map[string]interface{}
if err := yaml.Unmarshal(data, &raw); err != nil {
return nil, false, fmt.Errorf("parsing %s: %w", path, err)
}
rawTypes, exists := raw["types"]
if !exists {
return nil, false, nil // absent — no opinion
}
// present: validate the raw structure
typesSlice, ok := rawTypes.([]interface{})
if !ok {
return nil, true, fmt.Errorf("types: must be a list, got %T", rawTypes)
}
if len(typesSlice) == 0 {
return nil, true, fmt.Errorf("types section must define at least one type")
}
// validate each entry for unknown keys and convert to TypeDef
defs := make([]workflow.TypeDef, 0, len(typesSlice))
for i, entry := range typesSlice {
entryMap, ok := entry.(map[string]interface{})
if !ok {
return nil, true, fmt.Errorf("type at index %d: expected mapping, got %T", i, entry)
}
for k := range entryMap {
if !validTypeDefKeys[k] {
return nil, true, fmt.Errorf("type at index %d: unknown key %q (valid keys: key, label, emoji)", i, k)
}
}
var def workflow.TypeDef
keyRaw, _ := entryMap["key"].(string)
def.Key = workflow.TaskType(keyRaw)
def.Label, _ = entryMap["label"].(string)
def.Emoji, _ = entryMap["emoji"].(string)
defs = append(defs, def)
}
reg, err := workflow.NewTypeRegistry(defs)
if err != nil {
return nil, true, err
}
return reg, true, nil
}
// loadTypeRegistryFromFiles iterates workflow files. The last file that has a
// types: key wins. Files without a types: key are skipped (no override).
// If no file defines types:, returns a registry built from DefaultTypeDefs().
func loadTypeRegistryFromFiles(files []string) (*workflow.TypeRegistry, string, error) {
var lastReg *workflow.TypeRegistry
var lastFile string
for _, path := range files {
reg, present, err := loadTypesFromFile(path)
if err != nil {
return nil, "", fmt.Errorf("loading types from %s: %w", path, err)
}
if present {
lastReg = reg
lastFile = path
}
}
if lastReg != nil {
return lastReg, lastFile, nil
}
// no file defined types: — fall back to built-in defaults
reg, err := workflow.NewTypeRegistry(workflow.DefaultTypeDefs())
if err != nil {
return nil, "", fmt.Errorf("building default type registry: %w", err)
}
return reg, "<built-in>", nil
}

View file

@ -120,6 +120,7 @@ func TestRegistry_Lookup(t *testing.T) {
def, ok := reg.Lookup("ready")
if !ok {
t.Fatal("expected to find 'ready'")
return
}
if def.Label != "Ready" {
t.Errorf("expected label 'Ready', got %q", def.Label)
@ -151,19 +152,32 @@ func TestRegistry_Keys(t *testing.T) {
}
}
func TestRegistry_NormalizesKeys(t *testing.T) {
custom := []workflow.StatusDef{
func TestRegistry_RejectsNonCanonicalKeys(t *testing.T) {
_, err := workflow.NewStatusRegistry([]workflow.StatusDef{
{Key: "In-Progress", Label: "In Progress", Default: true},
{Key: " DONE ", Label: "Done", Done: true},
{Key: "done", Label: "Done", Done: true},
})
if err == nil {
t.Fatal("expected error for non-canonical key")
}
if got := err.Error(); got != `status key "In-Progress" is not canonical; use "inProgress"` {
t.Errorf("got error %q", got)
}
}
func TestRegistry_CanonicalKeysWork(t *testing.T) {
custom := []workflow.StatusDef{
{Key: "inProgress", Label: "In Progress", Default: true},
{Key: "done", Label: "Done", Done: true},
}
setupTestRegistry(t, custom)
reg := GetStatusRegistry()
if !reg.IsValid("inProgress") {
t.Error("expected 'inProgress' to be valid after normalization")
t.Error("expected 'inProgress' to be valid")
}
if !reg.IsValid("done") {
t.Error("expected 'done' to be valid after normalization")
t.Error("expected 'done' to be valid")
}
}
@ -195,17 +209,87 @@ func TestBuildRegistry_Empty(t *testing.T) {
}
}
func TestBuildRegistry_DefaultFallsToFirst(t *testing.T) {
func TestBuildRegistry_RequiresExplicitDefault(t *testing.T) {
defs := []workflow.StatusDef{
{Key: "alpha", Label: "Alpha"},
{Key: "alpha", Label: "Alpha", Done: true},
{Key: "beta", Label: "Beta"},
}
_, err := workflow.NewStatusRegistry(defs)
if err == nil {
t.Fatal("expected error when no status is marked default")
}
}
func TestBuildRegistry_RequiresExplicitDone(t *testing.T) {
defs := []workflow.StatusDef{
{Key: "alpha", Label: "Alpha", Default: true},
{Key: "beta", Label: "Beta"},
}
_, err := workflow.NewStatusRegistry(defs)
if err == nil {
t.Fatal("expected error when no status is marked done")
}
}
func TestBuildRegistry_RejectsDuplicateDefault(t *testing.T) {
defs := []workflow.StatusDef{
{Key: "alpha", Label: "Alpha", Default: true},
{Key: "beta", Label: "Beta", Default: true, Done: true},
}
_, err := workflow.NewStatusRegistry(defs)
if err == nil {
t.Fatal("expected error for duplicate default")
}
}
func TestBuildRegistry_RejectsDuplicateDone(t *testing.T) {
defs := []workflow.StatusDef{
{Key: "alpha", Label: "Alpha", Default: true, Done: true},
{Key: "beta", Label: "Beta", Done: true},
}
_, err := workflow.NewStatusRegistry(defs)
if err == nil {
t.Fatal("expected error for duplicate done")
}
}
func TestBuildRegistry_RejectsDuplicateDisplay(t *testing.T) {
defs := []workflow.StatusDef{
{Key: "alpha", Label: "Open", Emoji: "🟢", Default: true},
{Key: "beta", Label: "Open", Emoji: "🟢", Done: true},
}
_, err := workflow.NewStatusRegistry(defs)
if err == nil {
t.Fatal("expected error for duplicate display")
}
}
func TestBuildRegistry_LabelDefaultsToKey(t *testing.T) {
defs := []workflow.StatusDef{
{Key: "alpha", Emoji: "🔵", Default: true},
{Key: "beta", Emoji: "🔴", Done: true},
}
reg, err := workflow.NewStatusRegistry(defs)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if reg.DefaultKey() != "alpha" {
t.Errorf("expected default to fall back to first status 'alpha', got %q", reg.DefaultKey())
def, ok := reg.Lookup("alpha")
if !ok {
t.Fatal("expected to find alpha")
}
if def.Label != "alpha" {
t.Errorf("expected label to default to key, got %q", def.Label)
}
}
func TestBuildRegistry_EmptyWhitespaceLabel(t *testing.T) {
defs := []workflow.StatusDef{
{Key: "alpha", Label: " ", Default: true},
{Key: "beta", Done: true},
}
_, err := workflow.NewStatusRegistry(defs)
if err == nil {
t.Fatal("expected error for whitespace-only label")
}
}
@ -429,6 +513,9 @@ statuses:
- key: alpha
label: Alpha
default: true
- key: beta
label: Beta
done: true
`)
f2 := writeTempWorkflow(t, dir2, `
statuses: [[[invalid yaml
@ -614,3 +701,240 @@ func TestLoadStatusRegistryFromFiles_AllFilesEmpty(t *testing.T) {
t.Errorf("expected empty path, got %q", path)
}
}
// --- type loading tests ---
func TestLoadTypesFromFile_HappyPath(t *testing.T) {
dir := t.TempDir()
f := writeTempWorkflow(t, dir, `
types:
- key: story
label: Story
emoji: "🌀"
- key: bug
label: Bug
emoji: "💥"
`)
reg, present, err := loadTypesFromFile(f)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !present {
t.Fatal("expected present=true")
}
if reg == nil {
t.Fatal("expected non-nil registry")
}
if !reg.IsValid("story") {
t.Error("expected story to be valid")
}
if !reg.IsValid("bug") {
t.Error("expected bug to be valid")
}
}
func TestLoadTypesFromFile_Absent(t *testing.T) {
dir := t.TempDir()
f := writeTempWorkflow(t, dir, `
statuses:
- key: open
label: Open
default: true
- key: done
label: Done
done: true
`)
reg, present, err := loadTypesFromFile(f)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if present {
t.Error("expected present=false when types: key is absent")
}
if reg != nil {
t.Error("expected nil registry when absent")
}
}
func TestLoadTypesFromFile_EmptyList(t *testing.T) {
dir := t.TempDir()
f := writeTempWorkflow(t, dir, `types: []`)
_, present, err := loadTypesFromFile(f)
if err == nil {
t.Fatal("expected error for types: []")
}
if !present {
t.Error("expected present=true even for empty list")
}
}
func TestLoadTypesFromFile_NonCanonicalKey(t *testing.T) {
dir := t.TempDir()
f := writeTempWorkflow(t, dir, `
types:
- key: Story
label: Story
`)
_, _, err := loadTypesFromFile(f)
if err == nil {
t.Fatal("expected error for non-canonical key")
}
}
func TestLoadTypesFromFile_UnknownKey(t *testing.T) {
dir := t.TempDir()
f := writeTempWorkflow(t, dir, `
types:
- key: story
label: Story
aliases:
- feature
`)
_, _, err := loadTypesFromFile(f)
if err == nil {
t.Fatal("expected error for unknown key 'aliases'")
}
}
func TestLoadTypesFromFile_MissingLabelDefaultsToKey(t *testing.T) {
dir := t.TempDir()
f := writeTempWorkflow(t, dir, `
types:
- key: task
emoji: "📋"
`)
reg, _, err := loadTypesFromFile(f)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := reg.TypeLabel("task"); got != "task" {
t.Errorf("expected label to default to key, got %q", got)
}
}
func TestLoadTypesFromFile_InvalidYAML(t *testing.T) {
dir := t.TempDir()
f := writeTempWorkflow(t, dir, `types: [[[invalid`)
_, _, err := loadTypesFromFile(f)
if err == nil {
t.Fatal("expected error for invalid YAML")
}
}
func TestLoadTypeRegistryFromFiles_LastFileWithTypesWins(t *testing.T) {
dir1 := t.TempDir()
dir2 := t.TempDir()
f1 := writeTempWorkflow(t, dir1, `
types:
- key: alpha
label: Alpha
`)
f2 := writeTempWorkflow(t, dir2, `
types:
- key: beta
label: Beta
- key: gamma
label: Gamma
`)
reg, path, err := loadTypeRegistryFromFiles([]string{f1, f2})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if path != f2 {
t.Errorf("expected path %q, got %q", f2, path)
}
if reg.IsValid("alpha") {
t.Error("expected alpha to NOT be valid (overridden)")
}
if !reg.IsValid("beta") {
t.Error("expected beta to be valid")
}
}
func TestLoadTypeRegistryFromFiles_SkipsFilesWithoutTypes(t *testing.T) {
dir1 := t.TempDir()
dir2 := t.TempDir()
f1 := writeTempWorkflow(t, dir1, `
types:
- key: alpha
label: Alpha
`)
f2 := writeTempWorkflow(t, dir2, `
statuses:
- key: open
label: Open
default: true
- key: done
label: Done
done: true
`)
reg, path, err := loadTypeRegistryFromFiles([]string{f1, f2})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if path != f1 {
t.Errorf("expected path %q (file with types), got %q", f1, path)
}
if !reg.IsValid("alpha") {
t.Error("expected alpha to be valid")
}
}
func TestLoadTypeRegistryFromFiles_FallbackToBuiltins(t *testing.T) {
dir := t.TempDir()
f := writeTempWorkflow(t, dir, `
statuses:
- key: open
label: Open
default: true
- key: done
label: Done
done: true
`)
reg, path, err := loadTypeRegistryFromFiles([]string{f})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if path != "<built-in>" {
t.Errorf("expected built-in path, got %q", path)
}
if !reg.IsValid("story") {
t.Error("expected built-in story type")
}
}
func TestLoadTypeRegistryFromFiles_ParseErrorStops(t *testing.T) {
dir := t.TempDir()
f := writeTempWorkflow(t, dir, `
types:
- key: Story
label: Story
`)
_, _, err := loadTypeRegistryFromFiles([]string{f})
if err == nil {
t.Fatal("expected error for non-canonical key")
}
}
func TestResetTypeRegistry(t *testing.T) {
setupTestRegistry(t, defaultTestStatuses())
custom := []workflow.TypeDef{
{Key: "task", Label: "Task"},
{Key: "incident", Label: "Incident"},
}
ResetTypeRegistry(custom)
reg := GetTypeRegistry()
if !reg.IsValid("task") {
t.Error("expected 'task' to be valid after ResetTypeRegistry")
}
if reg.IsValid("story") {
t.Error("expected 'story' to NOT be valid after ResetTypeRegistry")
}
}

View file

@ -3,9 +3,11 @@ package config
import (
_ "embed"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
gonanoid "github.com/matoous/go-nanoid/v2"
@ -56,8 +58,35 @@ func GenerateRandomID() string {
return id
}
// sampleFrontmatterRe extracts type and status values from sample tiki frontmatter.
var sampleFrontmatterRe = regexp.MustCompile(`(?m)^(type|status):\s*(.+)$`)
// validateSampleTiki checks whether a sample tiki's type and status
// are valid against the current workflow registries.
func validateSampleTiki(template string) bool {
matches := sampleFrontmatterRe.FindAllStringSubmatch(template, -1)
statusReg := GetStatusRegistry()
typeReg := GetTypeRegistry()
for _, m := range matches {
key, val := m[1], strings.TrimSpace(m[2])
switch key {
case "type":
if _, ok := typeReg.ParseType(val); !ok {
return false
}
case "status":
if !statusReg.IsValid(val) {
return false
}
}
}
return true
}
// BootstrapSystem creates the task storage and seeds the initial tiki.
func BootstrapSystem() error {
// If createSamples is true, embedded sample tikis are validated against
// the active workflow registries and only valid ones are written.
func BootstrapSystem(createSamples bool) error {
// Create all necessary directories
if err := EnsureDirs(); err != nil {
return fmt.Errorf("ensure directories: %w", err)
@ -66,59 +95,50 @@ func BootstrapSystem() error {
taskDir := GetTaskDir()
var createdFiles []string
// Helper function to create a sample tiki
createSampleTiki := func(template string) (string, error) {
randomID := GenerateRandomID()
taskID := fmt.Sprintf("TIKI-%s", randomID)
taskFilename := fmt.Sprintf("tiki-%s.md", randomID)
taskPath := filepath.Join(taskDir, taskFilename)
// Replace placeholder in template
taskContent := strings.Replace(template, "TIKI-XXXXXX", taskID, 1)
if err := os.WriteFile(taskPath, []byte(taskContent), 0644); err != nil {
return "", fmt.Errorf("write task: %w", err)
if createSamples {
// ensure workflow registries are loaded before validating samples;
// on first run this may require installing the default workflow first
if err := InstallDefaultWorkflow(); err != nil {
slog.Warn("failed to install default workflow for sample validation", "error", err)
}
if err := LoadWorkflowRegistries(); err != nil {
slog.Warn("failed to load workflow registries for sample validation; skipping samples", "error", err)
createSamples = false
}
return taskPath, nil
}
// Create board sample (original welcome tiki)
boardPath, err := createSampleTiki(initialTaskTemplate)
if err != nil {
return fmt.Errorf("create board sample: %w", err)
}
createdFiles = append(createdFiles, boardPath)
if createSamples {
type sampleDef struct {
name string
template string
}
samples := []sampleDef{
{"board", initialTaskTemplate},
{"backlog 1", backlogSample1},
{"backlog 2", backlogSample2},
{"roadmap now", roadmapNowSample},
{"roadmap next", roadmapNextSample},
{"roadmap later", roadmapLaterSample},
}
// Create backlog samples
backlog1Path, err := createSampleTiki(backlogSample1)
if err != nil {
return fmt.Errorf("create backlog sample 1: %w", err)
}
createdFiles = append(createdFiles, backlog1Path)
for _, s := range samples {
if !validateSampleTiki(s.template) {
slog.Info("skipping incompatible sample tiki", "name", s.name)
continue
}
backlog2Path, err := createSampleTiki(backlogSample2)
if err != nil {
return fmt.Errorf("create backlog sample 2: %w", err)
}
createdFiles = append(createdFiles, backlog2Path)
randomID := GenerateRandomID()
taskID := fmt.Sprintf("TIKI-%s", randomID)
taskFilename := fmt.Sprintf("tiki-%s.md", randomID)
taskPath := filepath.Join(taskDir, taskFilename)
// Create roadmap samples
roadmapNowPath, err := createSampleTiki(roadmapNowSample)
if err != nil {
return fmt.Errorf("create roadmap now sample: %w", err)
taskContent := strings.Replace(s.template, "TIKI-XXXXXX", taskID, 1)
if err := os.WriteFile(taskPath, []byte(taskContent), 0644); err != nil {
return fmt.Errorf("create sample %s: %w", s.name, err)
}
createdFiles = append(createdFiles, taskPath)
}
}
createdFiles = append(createdFiles, roadmapNowPath)
roadmapNextPath, err := createSampleTiki(roadmapNextSample)
if err != nil {
return fmt.Errorf("create roadmap next sample: %w", err)
}
createdFiles = append(createdFiles, roadmapNextPath)
roadmapLaterPath, err := createSampleTiki(roadmapLaterSample)
if err != nil {
return fmt.Errorf("create roadmap later sample: %w", err)
}
createdFiles = append(createdFiles, roadmapLaterPath)
// Write doki documentation files
dokiDir := GetDokiDir()
@ -165,6 +185,11 @@ func GetDefaultNewTaskTemplate() string {
return defaultNewTaskTemplate
}
// GetDefaultWorkflowYAML returns the embedded default workflow.yaml content
func GetDefaultWorkflowYAML() string {
return defaultWorkflowYAML
}
// InstallDefaultWorkflow installs the default workflow.yaml to the user config directory
// if it does not already exist. This runs on every launch to handle first-run and upgrade cases.
func InstallDefaultWorkflow() error {

View file

@ -19,6 +19,7 @@ const (
ActionRefresh ActionID = "refresh"
ActionToggleViewMode ActionID = "toggle_view_mode"
ActionToggleHeader ActionID = "toggle_header"
ActionOpenPalette ActionID = "open_palette"
)
// ActionID values for task navigation and manipulation (used by plugins).
@ -92,6 +93,7 @@ func InitPluginActions(plugins []PluginInfo) {
if p.Key == 0 && p.Rune == 0 {
continue // skip plugins without key binding
}
pluginViewID := model.MakePluginViewID(p.Name)
pluginActionRegistry.Register(Action{
ID: ActionID("plugin:" + p.Name),
Key: p.Key,
@ -99,6 +101,12 @@ func InitPluginActions(plugins []PluginInfo) {
Modifier: p.Modifier,
Label: p.Name,
ShowInHeader: true,
IsEnabled: func(view *ViewEntry, _ View) bool {
if view == nil {
return true
}
return view.ViewID != pluginViewID
},
})
}
}
@ -124,12 +132,14 @@ func GetPluginNameFromAction(id ActionID) string {
// Action represents a keyboard shortcut binding
type Action struct {
ID ActionID
Key tcell.Key
Rune rune // for letter keys (when Key == tcell.KeyRune)
Label string
Modifier tcell.ModMask
ShowInHeader bool // whether to display in header bar
ID ActionID
Key tcell.Key
Rune rune // for letter keys (when Key == tcell.KeyRune)
Label string
Modifier tcell.ModMask
ShowInHeader bool // whether to display in header bar
HideFromPalette bool // when true, action is excluded from the action palette (zero value = visible)
IsEnabled func(view *ViewEntry, activeView View) bool
}
// keyWithMod is a composite map key for special-key lookups, disambiguating
@ -246,13 +256,23 @@ func (r *ActionRegistry) Match(event *tcell.EventKey) *Action {
return nil
}
// selectionRequired is an IsEnabled predicate that returns true only when
// the active view has a non-empty selection (for use with plugin/deps actions).
func selectionRequired(_ *ViewEntry, activeView View) bool {
if sv, ok := activeView.(SelectableView); ok {
return sv.GetSelectedID() != ""
}
return false
}
// DefaultGlobalActions returns common actions available in all views
func DefaultGlobalActions() *ActionRegistry {
r := NewActionRegistry()
r.Register(Action{ID: ActionBack, Key: tcell.KeyEscape, Label: "Back", ShowInHeader: true})
r.Register(Action{ID: ActionBack, Key: tcell.KeyEscape, Label: "Back", ShowInHeader: true, HideFromPalette: true})
r.Register(Action{ID: ActionQuit, Key: tcell.KeyRune, Rune: 'q', Label: "Quit", ShowInHeader: true})
r.Register(Action{ID: ActionRefresh, Key: tcell.KeyRune, Rune: 'r', Label: "Refresh", ShowInHeader: true})
r.Register(Action{ID: ActionToggleHeader, Key: tcell.KeyF10, Label: "Hide Header", ShowInHeader: true})
r.Register(Action{ID: ActionToggleHeader, Key: tcell.KeyF10, Label: "Toggle Header", ShowInHeader: true})
r.Register(Action{ID: ActionOpenPalette, Key: tcell.KeyCtrlA, Modifier: tcell.ModCtrl, Label: "All", ShowInHeader: true, HideFromPalette: true})
return r
}
@ -267,6 +287,39 @@ func (r *ActionRegistry) GetHeaderActions() []Action {
return result
}
// GetPaletteActions returns palette-visible actions, deduped by ActionID (first registration wins).
func (r *ActionRegistry) GetPaletteActions() []Action {
if r == nil {
return nil
}
seen := make(map[ActionID]bool)
var result []Action
for _, a := range r.actions {
if a.HideFromPalette {
continue
}
if seen[a.ID] {
continue
}
seen[a.ID] = true
result = append(result, a)
}
return result
}
// ContainsID returns true if the registry has an action with the given ID.
func (r *ActionRegistry) ContainsID(id ActionID) bool {
if r == nil {
return false
}
for _, a := range r.actions {
if a.ID == id {
return true
}
}
return false
}
// ToHeaderActions converts the registry's header actions to model.HeaderAction slice.
// This bridges the controller→model boundary without requiring callers to do the mapping.
func (r *ActionRegistry) ToHeaderActions() []model.HeaderAction {
@ -293,15 +346,22 @@ func (r *ActionRegistry) ToHeaderActions() []model.HeaderAction {
func TaskDetailViewActions() *ActionRegistry {
r := NewActionRegistry()
r.Register(Action{ID: ActionEditTitle, Key: tcell.KeyRune, Rune: 'e', Label: "Edit", ShowInHeader: true})
r.Register(Action{ID: ActionEditDesc, Key: tcell.KeyRune, Rune: 'D', Label: "Edit desc", ShowInHeader: true})
r.Register(Action{ID: ActionEditSource, Key: tcell.KeyRune, Rune: 's', Label: "Edit source", ShowInHeader: true})
taskDetailEnabled := func(view *ViewEntry, _ View) bool {
if view == nil || view.ViewID != model.TaskDetailViewID {
return false
}
return model.DecodeTaskDetailParams(view.Params).TaskID != ""
}
r.Register(Action{ID: ActionEditTitle, Key: tcell.KeyRune, Rune: 'e', Label: "Edit", ShowInHeader: true, IsEnabled: taskDetailEnabled})
r.Register(Action{ID: ActionEditDesc, Key: tcell.KeyRune, Rune: 'D', Label: "Edit desc", ShowInHeader: true, IsEnabled: taskDetailEnabled})
r.Register(Action{ID: ActionEditSource, Key: tcell.KeyRune, Rune: 's', Label: "Edit source", ShowInHeader: true, IsEnabled: taskDetailEnabled})
r.Register(Action{ID: ActionFullscreen, Key: tcell.KeyRune, Rune: 'f', Label: "Full screen", ShowInHeader: true})
r.Register(Action{ID: ActionEditDeps, Key: tcell.KeyCtrlD, Modifier: tcell.ModCtrl, Label: "Dependencies", ShowInHeader: true})
r.Register(Action{ID: ActionEditTags, Key: tcell.KeyRune, Rune: 'T', Label: "Edit tags", ShowInHeader: true})
r.Register(Action{ID: ActionEditDeps, Key: tcell.KeyCtrlD, Modifier: tcell.ModCtrl, Label: "Dependencies", ShowInHeader: true, IsEnabled: taskDetailEnabled})
r.Register(Action{ID: ActionEditTags, Key: tcell.KeyRune, Rune: 'T', Label: "Edit tags", ShowInHeader: true, IsEnabled: taskDetailEnabled})
if config.GetAIAgent() != "" {
r.Register(Action{ID: ActionChat, Key: tcell.KeyRune, Rune: 'c', Label: "Chat", ShowInHeader: true})
r.Register(Action{ID: ActionChat, Key: tcell.KeyRune, Rune: 'c', Label: "Chat", ShowInHeader: true, IsEnabled: taskDetailEnabled})
}
return r
@ -321,8 +381,8 @@ func TaskEditViewActions() *ActionRegistry {
r := NewActionRegistry()
r.Register(Action{ID: ActionSaveTask, Key: tcell.KeyCtrlS, Label: "Save", ShowInHeader: true})
r.Register(Action{ID: ActionNextField, Key: tcell.KeyTab, Label: "Next", ShowInHeader: true})
r.Register(Action{ID: ActionPrevField, Key: tcell.KeyBacktab, Label: "Prev", ShowInHeader: true})
r.Register(Action{ID: ActionNextField, Key: tcell.KeyTab, Label: "Next", ShowInHeader: true, HideFromPalette: true})
r.Register(Action{ID: ActionPrevField, Key: tcell.KeyBacktab, Label: "Prev", ShowInHeader: true, HideFromPalette: true})
return r
}
@ -330,15 +390,15 @@ func TaskEditViewActions() *ActionRegistry {
// CommonFieldNavigationActions returns actions available in all field editors (Tab/Shift-Tab navigation)
func CommonFieldNavigationActions() *ActionRegistry {
r := NewActionRegistry()
r.Register(Action{ID: ActionNextField, Key: tcell.KeyTab, Label: "Next field", ShowInHeader: true})
r.Register(Action{ID: ActionPrevField, Key: tcell.KeyBacktab, Label: "Prev field", ShowInHeader: true})
r.Register(Action{ID: ActionNextField, Key: tcell.KeyTab, Label: "Next field", ShowInHeader: true, HideFromPalette: true})
r.Register(Action{ID: ActionPrevField, Key: tcell.KeyBacktab, Label: "Prev field", ShowInHeader: true, HideFromPalette: true})
return r
}
// TaskEditTitleActions returns actions available when editing the title field
func TaskEditTitleActions() *ActionRegistry {
r := NewActionRegistry()
r.Register(Action{ID: ActionQuickSave, Key: tcell.KeyEnter, Label: "Quick Save", ShowInHeader: true})
r.Register(Action{ID: ActionQuickSave, Key: tcell.KeyEnter, Label: "Quick Save", ShowInHeader: true, HideFromPalette: true})
r.Register(Action{ID: ActionSaveTask, Key: tcell.KeyCtrlS, Label: "Save", ShowInHeader: true})
r.Merge(CommonFieldNavigationActions())
return r
@ -347,16 +407,16 @@ func TaskEditTitleActions() *ActionRegistry {
// TaskEditStatusActions returns actions available when editing the status field
func TaskEditStatusActions() *ActionRegistry {
r := CommonFieldNavigationActions()
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true})
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true})
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true, HideFromPalette: true})
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true, HideFromPalette: true})
return r
}
// TaskEditTypeActions returns actions available when editing the type field
func TaskEditTypeActions() *ActionRegistry {
r := CommonFieldNavigationActions()
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true})
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true})
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true, HideFromPalette: true})
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true, HideFromPalette: true})
return r
}
@ -370,8 +430,8 @@ func TaskEditPriorityActions() *ActionRegistry {
// TaskEditAssigneeActions returns actions available when editing the assignee field
func TaskEditAssigneeActions() *ActionRegistry {
r := CommonFieldNavigationActions()
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true})
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true})
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true, HideFromPalette: true})
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true, HideFromPalette: true})
return r
}
@ -385,19 +445,19 @@ func TaskEditPointsActions() *ActionRegistry {
// TaskEditDueActions returns actions available when editing the due date field
func TaskEditDueActions() *ActionRegistry {
r := CommonFieldNavigationActions()
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true})
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true})
r.Register(Action{ID: ActionClearField, Key: tcell.KeyCtrlU, Label: "Clear", ShowInHeader: true})
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true, HideFromPalette: true})
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true, HideFromPalette: true})
r.Register(Action{ID: ActionClearField, Key: tcell.KeyCtrlU, Label: "Clear", ShowInHeader: true, HideFromPalette: true})
return r
}
// TaskEditRecurrenceActions returns actions available when editing the recurrence field
func TaskEditRecurrenceActions() *ActionRegistry {
r := CommonFieldNavigationActions()
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true})
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true})
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "← Part", ShowInHeader: true})
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "Part →", ShowInHeader: true})
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true, HideFromPalette: true})
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true, HideFromPalette: true})
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "← Part", ShowInHeader: true, HideFromPalette: true})
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "Part →", ShowInHeader: true, HideFromPalette: true})
return r
}
@ -455,22 +515,22 @@ func GetActionsForField(field model.EditField) *ActionRegistry {
func PluginViewActions() *ActionRegistry {
r := NewActionRegistry()
// navigation (not shown in header)
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyUp, Label: "↑"})
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyDown, Label: "↓"})
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "←"})
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "→"})
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyRune, Rune: 'k', Label: "↑"})
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyRune, Rune: 'j', Label: "↓"})
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyRune, Rune: 'h', Label: "←"})
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRune, Rune: 'l', Label: "→"})
// navigation (not shown in header, hidden from palette)
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyUp, Label: "↑", HideFromPalette: true})
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyDown, Label: "↓", HideFromPalette: true})
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "←", HideFromPalette: true})
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "→", HideFromPalette: true})
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyRune, Rune: 'k', Label: "↑", HideFromPalette: true})
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyRune, Rune: 'j', Label: "↓", HideFromPalette: true})
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyRune, Rune: 'h', Label: "←", HideFromPalette: true})
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRune, Rune: 'l', Label: "→", HideFromPalette: true})
// plugin actions (shown in header)
r.Register(Action{ID: ActionOpenFromPlugin, Key: tcell.KeyEnter, Label: "Open", ShowInHeader: true})
r.Register(Action{ID: ActionMoveTaskLeft, Key: tcell.KeyLeft, Modifier: tcell.ModShift, Label: "Move ←", ShowInHeader: true})
r.Register(Action{ID: ActionMoveTaskRight, Key: tcell.KeyRight, Modifier: tcell.ModShift, Label: "Move →", ShowInHeader: true})
r.Register(Action{ID: ActionOpenFromPlugin, Key: tcell.KeyEnter, Label: "Open", ShowInHeader: true, IsEnabled: selectionRequired})
r.Register(Action{ID: ActionMoveTaskLeft, Key: tcell.KeyLeft, Modifier: tcell.ModShift, Label: "Move ←", ShowInHeader: true, IsEnabled: selectionRequired})
r.Register(Action{ID: ActionMoveTaskRight, Key: tcell.KeyRight, Modifier: tcell.ModShift, Label: "Move →", ShowInHeader: true, IsEnabled: selectionRequired})
r.Register(Action{ID: ActionNewTask, Key: tcell.KeyRune, Rune: 'n', Label: "New", ShowInHeader: true})
r.Register(Action{ID: ActionDeleteTask, Key: tcell.KeyRune, Rune: 'd', Label: "Delete", ShowInHeader: true})
r.Register(Action{ID: ActionDeleteTask, Key: tcell.KeyRune, Rune: 'd', Label: "Delete", ShowInHeader: true, IsEnabled: selectionRequired})
r.Register(Action{ID: ActionSearch, Key: tcell.KeyRune, Rune: '/', Label: "Search", ShowInHeader: true})
r.Register(Action{ID: ActionToggleViewMode, Key: tcell.KeyRune, Rune: 'v', Label: "View mode", ShowInHeader: true})
@ -484,24 +544,24 @@ func PluginViewActions() *ActionRegistry {
func DepsViewActions() *ActionRegistry {
r := NewActionRegistry()
// navigation (not shown in header)
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyUp, Label: "↑"})
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyDown, Label: "↓"})
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "←"})
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "→"})
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyRune, Rune: 'k', Label: "↑"})
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyRune, Rune: 'j', Label: "↓"})
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyRune, Rune: 'h', Label: "←"})
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRune, Rune: 'l', Label: "→"})
// navigation (not shown in header, hidden from palette)
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyUp, Label: "↑", HideFromPalette: true})
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyDown, Label: "↓", HideFromPalette: true})
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "←", HideFromPalette: true})
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "→", HideFromPalette: true})
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyRune, Rune: 'k', Label: "↑", HideFromPalette: true})
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyRune, Rune: 'j', Label: "↓", HideFromPalette: true})
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyRune, Rune: 'h', Label: "←", HideFromPalette: true})
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRune, Rune: 'l', Label: "→", HideFromPalette: true})
// move task between lanes (shown in header)
r.Register(Action{ID: ActionMoveTaskLeft, Key: tcell.KeyLeft, Modifier: tcell.ModShift, Label: "Move ←", ShowInHeader: true})
r.Register(Action{ID: ActionMoveTaskRight, Key: tcell.KeyRight, Modifier: tcell.ModShift, Label: "Move →", ShowInHeader: true})
r.Register(Action{ID: ActionMoveTaskLeft, Key: tcell.KeyLeft, Modifier: tcell.ModShift, Label: "Move ←", ShowInHeader: true, IsEnabled: selectionRequired})
r.Register(Action{ID: ActionMoveTaskRight, Key: tcell.KeyRight, Modifier: tcell.ModShift, Label: "Move →", ShowInHeader: true, IsEnabled: selectionRequired})
// task actions
r.Register(Action{ID: ActionOpenFromPlugin, Key: tcell.KeyEnter, Label: "Open", ShowInHeader: true})
r.Register(Action{ID: ActionOpenFromPlugin, Key: tcell.KeyEnter, Label: "Open", ShowInHeader: true, IsEnabled: selectionRequired})
r.Register(Action{ID: ActionNewTask, Key: tcell.KeyRune, Rune: 'n', Label: "New", ShowInHeader: true})
r.Register(Action{ID: ActionDeleteTask, Key: tcell.KeyRune, Rune: 'd', Label: "Delete", ShowInHeader: true})
r.Register(Action{ID: ActionDeleteTask, Key: tcell.KeyRune, Rune: 'd', Label: "Delete", ShowInHeader: true, IsEnabled: selectionRequired})
// view mode and search
r.Register(Action{ID: ActionSearch, Key: tcell.KeyRune, Rune: '/', Label: "Search", ShowInHeader: true})

View file

@ -404,11 +404,11 @@ func TestDefaultGlobalActions(t *testing.T) {
registry := DefaultGlobalActions()
actions := registry.GetActions()
if len(actions) != 4 {
t.Errorf("expected 4 global actions, got %d", len(actions))
if len(actions) != 5 {
t.Errorf("expected 5 global actions, got %d", len(actions))
}
expectedActions := []ActionID{ActionBack, ActionQuit, ActionRefresh, ActionToggleHeader}
expectedActions := []ActionID{ActionBack, ActionQuit, ActionRefresh, ActionToggleHeader, ActionOpenPalette}
for i, expected := range expectedActions {
if i >= len(actions) {
t.Errorf("missing action at index %d: want %v", i, expected)
@ -417,8 +417,30 @@ func TestDefaultGlobalActions(t *testing.T) {
if actions[i].ID != expected {
t.Errorf("action at index %d: want %v, got %v", i, expected, actions[i].ID)
}
if !actions[i].ShowInHeader {
t.Errorf("global action %v should have ShowInHeader=true", expected)
}
// ActionOpenPalette should show in header with label "All" and use Ctrl+A binding
for _, a := range actions {
if a.ID == ActionOpenPalette {
if !a.ShowInHeader {
t.Error("ActionOpenPalette should have ShowInHeader=true")
}
if a.Label != "All" {
t.Errorf("ActionOpenPalette label = %q, want %q", a.Label, "All")
}
if a.Key != tcell.KeyCtrlA {
t.Errorf("ActionOpenPalette Key = %v, want KeyCtrlA", a.Key)
}
if a.Modifier != tcell.ModCtrl {
t.Errorf("ActionOpenPalette Modifier = %v, want ModCtrl", a.Modifier)
}
if a.Rune != 0 {
t.Errorf("ActionOpenPalette Rune = %v, want 0", a.Rune)
}
continue
}
if !a.ShowInHeader {
t.Errorf("global action %v should have ShowInHeader=true", a.ID)
}
}
}
@ -630,3 +652,163 @@ func TestMatchWithModifiers(t *testing.T) {
t.Error("M (no modifier) should not match action with Alt-M binding")
}
}
func TestGetPaletteActions_HidesMarkedActions(t *testing.T) {
r := NewActionRegistry()
r.Register(Action{ID: ActionQuit, Key: tcell.KeyRune, Rune: 'q', Label: "Quit"})
r.Register(Action{ID: ActionBack, Key: tcell.KeyEscape, Label: "Back", HideFromPalette: true})
r.Register(Action{ID: ActionRefresh, Key: tcell.KeyRune, Rune: 'r', Label: "Refresh"})
actions := r.GetPaletteActions()
if len(actions) != 2 {
t.Fatalf("expected 2 palette actions, got %d", len(actions))
}
if actions[0].ID != ActionQuit {
t.Errorf("expected first action to be Quit, got %v", actions[0].ID)
}
if actions[1].ID != ActionRefresh {
t.Errorf("expected second action to be Refresh, got %v", actions[1].ID)
}
}
func TestGetPaletteActions_DedupsByActionID(t *testing.T) {
r := NewActionRegistry()
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyUp, Label: "↑"})
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyRune, Rune: 'k', Label: "↑ (vim)"})
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyDown, Label: "↓"})
actions := r.GetPaletteActions()
if len(actions) != 2 {
t.Fatalf("expected 2 deduped actions, got %d", len(actions))
}
if actions[0].ID != ActionNavUp {
t.Errorf("expected first action to be NavUp, got %v", actions[0].ID)
}
if actions[0].Key != tcell.KeyUp {
t.Error("dedup should keep first registered binding (arrow key), not vim key")
}
if actions[1].ID != ActionNavDown {
t.Errorf("expected second action to be NavDown, got %v", actions[1].ID)
}
}
func TestGetPaletteActions_NilRegistry(t *testing.T) {
var r *ActionRegistry
if actions := r.GetPaletteActions(); actions != nil {
t.Errorf("expected nil from nil registry, got %v", actions)
}
}
func TestContainsID(t *testing.T) {
r := NewActionRegistry()
r.Register(Action{ID: ActionQuit, Key: tcell.KeyRune, Rune: 'q', Label: "Quit"})
r.Register(Action{ID: ActionBack, Key: tcell.KeyEscape, Label: "Back"})
if !r.ContainsID(ActionQuit) {
t.Error("expected ContainsID to find ActionQuit")
}
if !r.ContainsID(ActionBack) {
t.Error("expected ContainsID to find ActionBack")
}
if r.ContainsID(ActionRefresh) {
t.Error("expected ContainsID to not find ActionRefresh")
}
}
func TestContainsID_NilRegistry(t *testing.T) {
var r *ActionRegistry
if r.ContainsID(ActionQuit) {
t.Error("expected false from nil registry")
}
}
func TestDefaultGlobalActions_BackHiddenFromPalette(t *testing.T) {
registry := DefaultGlobalActions()
paletteActions := registry.GetPaletteActions()
for _, a := range paletteActions {
if a.ID == ActionBack {
t.Error("ActionBack should be hidden from palette")
}
}
if !registry.ContainsID(ActionBack) {
t.Error("ActionBack should still be registered in global actions")
}
}
func TestPluginViewActions_NavHiddenFromPalette(t *testing.T) {
registry := PluginViewActions()
paletteActions := registry.GetPaletteActions()
navIDs := map[ActionID]bool{
ActionNavUp: true, ActionNavDown: true,
ActionNavLeft: true, ActionNavRight: true,
}
for _, a := range paletteActions {
if navIDs[a.ID] {
t.Errorf("navigation action %v should be hidden from palette", a.ID)
}
}
// semantic actions should remain visible
found := map[ActionID]bool{}
for _, a := range paletteActions {
found[a.ID] = true
}
for _, want := range []ActionID{ActionOpenFromPlugin, ActionNewTask, ActionDeleteTask, ActionSearch} {
if !found[want] {
t.Errorf("expected palette-visible action %v", want)
}
}
}
func TestTaskEditActions_FieldLocalHidden_SaveVisible(t *testing.T) {
registry := TaskEditTitleActions()
paletteActions := registry.GetPaletteActions()
found := map[ActionID]bool{}
for _, a := range paletteActions {
found[a.ID] = true
}
if !found[ActionSaveTask] {
t.Error("Save should be palette-visible in task edit")
}
if found[ActionQuickSave] {
t.Error("Quick Save should be hidden from palette")
}
if found[ActionNextField] {
t.Error("Next field should be hidden from palette")
}
if found[ActionPrevField] {
t.Error("Prev field should be hidden from palette")
}
}
func TestInitPluginActions_ActivePluginDisabled(t *testing.T) {
InitPluginActions([]PluginInfo{
{Name: "Kanban", Key: tcell.KeyRune, Rune: '1'},
{Name: "Backlog", Key: tcell.KeyRune, Rune: '2'},
})
registry := GetPluginActions()
actions := registry.GetPaletteActions()
kanbanViewEntry := &ViewEntry{ViewID: model.MakePluginViewID("Kanban")}
backlogViewEntry := &ViewEntry{ViewID: model.MakePluginViewID("Backlog")}
for _, a := range actions {
if a.ID == "plugin:Kanban" {
if a.IsEnabled == nil {
t.Fatal("expected IsEnabled on plugin:Kanban")
}
if a.IsEnabled(kanbanViewEntry, nil) {
t.Error("Kanban activation should be disabled when Kanban view is active")
}
if !a.IsEnabled(backlogViewEntry, nil) {
t.Error("Kanban activation should be enabled when Backlog view is active")
}
}
}
}

View file

@ -3,6 +3,7 @@ package controller
import (
"github.com/boolean-maybe/tiki/model"
"github.com/boolean-maybe/tiki/plugin"
"github.com/boolean-maybe/tiki/ruki"
)
// DokiController handles doki plugin view actions (documentation/markdown navigation).
@ -59,6 +60,14 @@ func (dc *DokiController) HandleAction(actionID ActionID) bool {
}
// HandleSearch is not applicable for DokiPlugins (documentation views don't have search)
func (dc *DokiController) HandleSearch(query string) {
// No-op: Doki plugins don't support search
func (dc *DokiController) HandleSearch(query string) {}
func (dc *DokiController) GetActionInputSpec(ActionID) (string, ruki.ValueType, bool) {
return "", 0, false
}
func (dc *DokiController) CanStartActionInput(ActionID) (string, ruki.ValueType, bool) {
return "", 0, false
}
func (dc *DokiController) HandleActionInput(ActionID, string) InputSubmitResult {
return InputKeepEditing
}

View file

@ -24,6 +24,9 @@ type PluginControllerInterface interface {
HandleAction(ActionID) bool
HandleSearch(string)
ShowNavigation() bool
GetActionInputSpec(ActionID) (prompt string, typ ruki.ValueType, hasInput bool)
CanStartActionInput(ActionID) (prompt string, typ ruki.ValueType, ok bool)
HandleActionInput(ActionID, string) InputSubmitResult
}
// TikiViewProvider is implemented by controllers that back a TikiPlugin view.
@ -54,6 +57,8 @@ type InputRouter struct {
statusline *model.StatuslineConfig
schema ruki.Schema
registerPlugin func(name string, cfg *model.PluginConfig, def plugin.Plugin, ctrl PluginControllerInterface)
headerConfig *model.HeaderConfig
paletteConfig *model.ActionPaletteConfig
}
// NewInputRouter creates an input router
@ -79,6 +84,16 @@ func NewInputRouter(
}
}
// SetHeaderConfig wires the header config for fullscreen-aware header toggling.
func (ir *InputRouter) SetHeaderConfig(hc *model.HeaderConfig) {
ir.headerConfig = hc
}
// SetPaletteConfig wires the palette config for ActionOpenPalette dispatch.
func (ir *InputRouter) SetPaletteConfig(pc *model.ActionPaletteConfig) {
ir.paletteConfig = pc
}
// HandleInput processes a key event for the current view and routes it to the appropriate handler.
// It processes events through multiple handlers in order:
// 1. Search input (if search is active)
@ -91,6 +106,28 @@ func NewInputRouter(
func (ir *InputRouter) HandleInput(event *tcell.EventKey, currentView *ViewEntry) bool {
slog.Debug("input received", "name", event.Name(), "key", int(event.Key()), "rune", string(event.Rune()), "modifiers", int(event.Modifiers()))
// palette fires regardless of focus context (Ctrl+A can't conflict with typing)
if action := ir.globalActions.Match(event); action != nil {
if action.ID == ActionOpenPalette {
return ir.handleGlobalAction(action.ID)
}
}
// if the input box is focused, let it handle all remaining input (including F10)
if activeView := ir.navController.GetActiveView(); activeView != nil {
if iv, ok := activeView.(InputableView); ok && iv.IsInputBoxFocused() {
return false
}
}
// pre-gate: global actions that must fire before task-edit Prepare() and before
// search/fullscreen/editor gates
if action := ir.globalActions.Match(event); action != nil {
if action.ID == ActionToggleHeader {
return ir.handleGlobalAction(action.ID)
}
}
if currentView == nil {
return false
}
@ -104,7 +141,7 @@ func (ir *InputRouter) HandleInput(event *tcell.EventKey, currentView *ViewEntry
ir.taskEditCoord.Prepare(activeView, model.DecodeTaskEditParams(currentView.Params))
}
if stop, handled := ir.maybeHandleSearchInput(activeView, event); stop {
if stop, handled := ir.maybeHandleInputBox(activeView, event); stop {
return handled
}
if stop, handled := ir.maybeHandleFullscreenEscape(activeView, event); stop {
@ -137,20 +174,20 @@ func (ir *InputRouter) HandleInput(event *tcell.EventKey, currentView *ViewEntry
}
}
// maybeHandleSearchInput handles search box focus/visibility semantics.
// maybeHandleInputBox handles input box focus/visibility semantics.
// stop=true means input routing should stop and return handled.
func (ir *InputRouter) maybeHandleSearchInput(activeView View, event *tcell.EventKey) (stop bool, handled bool) {
searchableView, ok := activeView.(SearchableView)
func (ir *InputRouter) maybeHandleInputBox(activeView View, event *tcell.EventKey) (stop bool, handled bool) {
inputableView, ok := activeView.(InputableView)
if !ok {
return false, false
}
if searchableView.IsSearchBoxFocused() {
// Search box has focus and handles input through tview.
if inputableView.IsInputBoxFocused() {
return true, false
}
// Search is visible but grid has focus - handle Esc to close search.
if searchableView.IsSearchVisible() && event.Key() == tcell.KeyEscape {
searchableView.HideSearch()
// visible but not focused (passive mode): Esc dismisses via cancel path
// so search-specific teardown fires (clearing results)
if inputableView.IsInputBoxVisible() && event.Key() == tcell.KeyEscape {
inputableView.CancelInputBox()
return true, true
}
return false, false
@ -262,28 +299,29 @@ func (ir *InputRouter) openDepsEditor(taskID string) bool {
// handlePluginInput routes input to the appropriate plugin controller
func (ir *InputRouter) handlePluginInput(event *tcell.EventKey, viewID model.ViewID) bool {
pluginName := model.GetPluginName(viewID)
controller, ok := ir.pluginControllers[pluginName]
ctrl, ok := ir.pluginControllers[pluginName]
if !ok {
slog.Warn("plugin controller not found", "plugin", pluginName)
return false
}
registry := controller.GetActionRegistry()
registry := ctrl.GetActionRegistry()
if action := registry.Match(event); action != nil {
// Handle search action specially - show search box
if action.ID == ActionSearch {
return ir.handleSearchAction(controller)
return ir.handleSearchInput(ctrl)
}
// Handle plugin activation keys - switch to different plugin
if targetPluginName := GetPluginNameFromAction(action.ID); targetPluginName != "" {
targetViewID := model.MakePluginViewID(targetPluginName)
if viewID != targetViewID {
ir.navController.ReplaceView(targetViewID, nil)
return true
}
return true // already on this plugin, consume the event
return true
}
return controller.HandleAction(action.ID)
if _, _, hasInput := ctrl.GetActionInputSpec(action.ID); hasInput {
return ir.startActionInput(ctrl, action.ID)
}
return ctrl.HandleAction(action.ID)
}
return false
}
@ -293,8 +331,6 @@ func (ir *InputRouter) handleGlobalAction(actionID ActionID) bool {
switch actionID {
case ActionBack:
if v := ir.navController.GetActiveView(); v != nil && v.GetViewID() == model.TaskEditViewID {
// Cancel edit session (discards changes) and close.
// This keeps the ActionBack behavior consistent across input paths.
return ir.taskEditCoord.CancelAndClose()
}
return ir.navController.HandleBack()
@ -304,32 +340,262 @@ func (ir *InputRouter) handleGlobalAction(actionID ActionID) bool {
case ActionRefresh:
_ = ir.taskStore.Reload()
return true
case ActionOpenPalette:
if ir.paletteConfig != nil {
ir.paletteConfig.SetVisible(true)
}
return true
case ActionToggleHeader:
ir.toggleHeader()
return true
default:
return false
}
}
// handleSearchAction is a generic handler for ActionSearch across all searchable views
func (ir *InputRouter) handleSearchAction(controller interface{ HandleSearch(string) }) bool {
// toggleHeader toggles the stored user preference and recomputes effective visibility
// against the live active view so fullscreen/header-hidden views stay force-hidden.
func (ir *InputRouter) toggleHeader() {
if ir.headerConfig == nil {
return
}
newPref := !ir.headerConfig.GetUserPreference()
ir.headerConfig.SetUserPreference(newPref)
visible := newPref
if v := ir.navController.GetActiveView(); v != nil {
if hv, ok := v.(interface{ RequiresHeaderHidden() bool }); ok && hv.RequiresHeaderHidden() {
visible = false
}
if fv, ok := v.(FullscreenView); ok && fv.IsFullscreen() {
visible = false
}
}
ir.headerConfig.SetVisible(visible)
}
// HandleAction dispatches a palette-selected action by ID against the given view entry.
// This is the controller-side fallback for palette execution — the palette tries
// view.HandlePaletteAction first, then falls back here.
func (ir *InputRouter) HandleAction(id ActionID, currentView *ViewEntry) bool {
if currentView == nil {
return false
}
// block palette-dispatched actions while an input box is in editing mode
if activeView := ir.navController.GetActiveView(); activeView != nil {
if iv, ok := activeView.(InputableView); ok && iv.IsInputBoxFocused() {
return false
}
}
// global actions
if ir.globalActions.ContainsID(id) {
return ir.handleGlobalAction(id)
}
activeView := ir.navController.GetActiveView()
searchableView, ok := activeView.(SearchableView)
switch currentView.ViewID {
case model.TaskDetailViewID:
taskID := model.DecodeTaskDetailParams(currentView.Params).TaskID
if taskID != "" {
ir.taskController.SetCurrentTask(taskID)
}
return ir.dispatchTaskAction(id, currentView.Params)
case model.TaskEditViewID:
if activeView != nil {
ir.taskEditCoord.Prepare(activeView, model.DecodeTaskEditParams(currentView.Params))
}
return ir.dispatchTaskEditAction(id, activeView)
default:
if model.IsPluginViewID(currentView.ViewID) {
return ir.dispatchPluginAction(id, currentView.ViewID)
}
return false
}
}
// dispatchTaskAction handles palette-dispatched task detail actions by ActionID.
func (ir *InputRouter) dispatchTaskAction(id ActionID, _ map[string]interface{}) bool {
switch id {
case ActionEditTitle:
taskID := ir.taskController.GetCurrentTaskID()
if taskID == "" {
return false
}
ir.navController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{
TaskID: taskID,
Focus: model.EditFieldTitle,
}))
return true
case ActionFullscreen:
activeView := ir.navController.GetActiveView()
if fullscreenView, ok := activeView.(FullscreenView); ok {
if fullscreenView.IsFullscreen() {
fullscreenView.ExitFullscreen()
} else {
fullscreenView.EnterFullscreen()
}
return true
}
return false
case ActionEditDesc:
taskID := ir.taskController.GetCurrentTaskID()
if taskID == "" {
return false
}
ir.navController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{
TaskID: taskID,
Focus: model.EditFieldDescription,
DescOnly: true,
}))
return true
case ActionEditTags:
taskID := ir.taskController.GetCurrentTaskID()
if taskID == "" {
return false
}
ir.navController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{
TaskID: taskID,
TagsOnly: true,
}))
return true
case ActionEditDeps:
taskID := ir.taskController.GetCurrentTaskID()
if taskID == "" {
return false
}
return ir.openDepsEditor(taskID)
case ActionChat:
agent := config.GetAIAgent()
if agent == "" {
return false
}
taskID := ir.taskController.GetCurrentTaskID()
if taskID == "" {
return false
}
filename := strings.ToLower(taskID) + ".md"
taskFilePath := filepath.Join(config.GetTaskDir(), filename)
name, args := resolveAgentCommand(agent, taskFilePath)
ir.navController.SuspendAndRun(name, args...)
_ = ir.taskStore.ReloadTask(taskID)
return true
case ActionCloneTask:
return ir.taskController.HandleAction(id)
default:
return ir.taskController.HandleAction(id)
}
}
// dispatchTaskEditAction handles palette-dispatched task edit actions by ActionID.
func (ir *InputRouter) dispatchTaskEditAction(id ActionID, activeView View) bool {
switch id {
case ActionSaveTask:
if activeView != nil {
return ir.taskEditCoord.CommitAndClose(activeView)
}
return false
default:
return false
}
}
// dispatchPluginAction handles palette-dispatched plugin actions by ActionID.
func (ir *InputRouter) dispatchPluginAction(id ActionID, viewID model.ViewID) bool {
if targetPluginName := GetPluginNameFromAction(id); targetPluginName != "" {
targetViewID := model.MakePluginViewID(targetPluginName)
if viewID != targetViewID {
ir.navController.ReplaceView(targetViewID, nil)
return true
}
return true
}
pluginName := model.GetPluginName(viewID)
ctrl, ok := ir.pluginControllers[pluginName]
if !ok {
return false
}
if id == ActionSearch {
return ir.handleSearchInput(ctrl)
}
if _, _, hasInput := ctrl.GetActionInputSpec(id); hasInput {
return ir.startActionInput(ctrl, id)
}
return ctrl.HandleAction(id)
}
// startActionInput opens the input box for an action that requires user input.
func (ir *InputRouter) startActionInput(ctrl PluginControllerInterface, actionID ActionID) bool {
_, _, ok := ctrl.CanStartActionInput(actionID)
if !ok {
return false
}
activeView := ir.navController.GetActiveView()
inputableView, ok := activeView.(InputableView)
if !ok {
return false
}
// Set up focus callback
app := ir.navController.GetApp()
searchableView.SetFocusSetter(func(p tview.Primitive) {
inputableView.SetFocusSetter(func(p tview.Primitive) {
app.SetFocus(p)
})
// Wire up search submit handler to controller
searchableView.SetSearchSubmitHandler(controller.HandleSearch)
inputableView.SetInputSubmitHandler(func(text string) InputSubmitResult {
return ctrl.HandleActionInput(actionID, text)
})
// Show search box and focus it
searchBox := searchableView.ShowSearch()
if searchBox != nil {
app.SetFocus(searchBox)
inputableView.SetInputCancelHandler(func() {
inputableView.CancelInputBox()
})
inputBox := inputableView.ShowInputBox("> ", "")
if inputBox != nil {
app.SetFocus(inputBox)
}
return true
}
// handleSearchInput opens the input box in search mode for the active view.
// Blocked when search is already passive — user must Esc first.
func (ir *InputRouter) handleSearchInput(ctrl interface{ HandleSearch(string) }) bool {
activeView := ir.navController.GetActiveView()
inputableView, ok := activeView.(InputableView)
if !ok {
return false
}
if inputableView.IsSearchPassive() {
return true
}
app := ir.navController.GetApp()
inputableView.SetFocusSetter(func(p tview.Primitive) {
app.SetFocus(p)
})
inputableView.SetInputSubmitHandler(func(text string) InputSubmitResult {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return InputKeepEditing
}
ctrl.HandleSearch(trimmed)
return InputShowPassive
})
inputBox := inputableView.ShowSearchBox()
if inputBox != nil {
app.SetFocus(inputBox)
}
return true

View file

@ -51,24 +51,46 @@ type SelectableView interface {
SetSelectedID(id string)
}
// SearchableView is a view that supports search functionality
type SearchableView interface {
// InputSubmitResult controls what happens to the input box after a submit callback.
type InputSubmitResult int
const (
InputKeepEditing InputSubmitResult = iota // keep the box open and focused for correction
InputShowPassive // keep the box visible but unfocused/non-editable
InputClose // close/hide the box
)
// InputableView is a view that supports an input box (for search, action input, etc.)
type InputableView interface {
View
// ShowSearch displays the search box and returns the primitive to focus
ShowSearch() tview.Primitive
// ShowInputBox displays the input box for action-input mode.
// If search is passive, it temporarily replaces the search indicator.
ShowInputBox(prompt, initial string) tview.Primitive
// HideSearch hides the search box
HideSearch()
// ShowSearchBox opens the input box in search-editing mode.
ShowSearchBox() tview.Primitive
// IsSearchVisible returns whether the search box is currently visible
IsSearchVisible() bool
// HideInputBox hides the input box (generic widget teardown only, no search state)
HideInputBox()
// IsSearchBoxFocused returns whether the search box currently has focus
IsSearchBoxFocused() bool
// CancelInputBox triggers mode-aware cancel (search clears results, action-input restores passive search)
CancelInputBox()
// SetSearchSubmitHandler sets the callback for when search is submitted
SetSearchSubmitHandler(handler func(text string))
// IsInputBoxVisible returns whether the input box is currently visible (any mode)
IsInputBoxVisible() bool
// IsInputBoxFocused returns whether the input box currently has focus
IsInputBoxFocused() bool
// IsSearchPassive returns true if search is applied and the box is in passive/unfocused mode
IsSearchPassive() bool
// SetInputSubmitHandler sets the callback for when input is submitted
SetInputSubmitHandler(handler func(text string) InputSubmitResult)
// SetInputCancelHandler sets the callback for when input is cancelled
SetInputCancelHandler(handler func())
// SetFocusSetter sets the callback for requesting focus changes
SetFocusSetter(setter func(p tview.Primitive))
@ -272,6 +294,27 @@ type RecurrencePartNavigable interface {
IsRecurrenceValueFocused() bool
}
// PaletteActionHandler is implemented by views that handle palette-dispatched actions
// directly (e.g., DokiView replays navigation as synthetic key events).
// The palette tries this before falling back to InputRouter.HandleAction.
type PaletteActionHandler interface {
HandlePaletteAction(id ActionID) bool
}
// FocusRestorer is implemented by views that can recover focus after the palette closes
// when the originally saved focused primitive is no longer valid (e.g., TaskDetailView
// rebuilds its description primitive during store-driven refresh).
type FocusRestorer interface {
RestoreFocus() bool
}
// ActionChangeNotifier is implemented by views that mutate their action registry
// or live enablement/presentation state while the same view instance stays active.
// RootLayout wires the handler and reruns syncViewContextFromView when fired.
type ActionChangeNotifier interface {
SetActionChangeHandler(handler func())
}
// ViewInfoProvider is a view that provides its name and description for the header info section
type ViewInfoProvider interface {
GetViewName() string

View file

@ -55,13 +55,17 @@ func NewPluginController(
"plugin", pluginDef.Name, "key", string(a.Rune),
"plugin_action", a.Label, "built_in_action", existing.Label)
}
pc.registry.Register(Action{
action := Action{
ID: pluginActionID(a.Rune),
Key: tcell.KeyRune,
Rune: a.Rune,
Label: a.Label,
ShowInHeader: true,
})
ShowInHeader: a.ShowInHeader,
}
if a.Action != nil && (a.Action.IsUpdate() || a.Action.IsDelete() || a.Action.IsPipe()) {
action.IsEnabled = selectionRequired
}
pc.registry.Register(action)
}
return pc
@ -139,29 +143,33 @@ func (pc *PluginController) HandleSearch(query string) {
})
}
// handlePluginAction applies a plugin shortcut action to the currently selected task.
func (pc *PluginController) handlePluginAction(r rune) bool {
// find the matching action definition
var pa *plugin.PluginAction
// getPluginAction looks up a plugin action by ActionID.
func (pc *PluginController) getPluginAction(actionID ActionID) (*plugin.PluginAction, rune, bool) {
r := getPluginActionRune(actionID)
if r == 0 {
return nil, 0, false
}
for i := range pc.pluginDef.Actions {
if pc.pluginDef.Actions[i].Rune == r {
pa = &pc.pluginDef.Actions[i]
break
return &pc.pluginDef.Actions[i], r, true
}
}
if pa == nil {
return false
}
executor := pc.newExecutor()
allTasks := pc.taskStore.GetAllTasks()
return nil, 0, false
}
// buildExecutionInput builds the base ExecutionInput for an action, performing
// selection/create-template preflight. Returns ok=false if the action can't run.
func (pc *PluginController) buildExecutionInput(pa *plugin.PluginAction) (ruki.ExecutionInput, bool) {
input := ruki.ExecutionInput{}
taskID := pc.getSelectedTaskID(pc.GetFilteredTasksForLane)
if pa.Action.IsUpdate() || pa.Action.IsDelete() {
if pa.Action.IsSelect() && !pa.Action.IsPipe() {
if taskID != "" {
input.SelectedTaskID = taskID
}
} else if pa.Action.IsUpdate() || pa.Action.IsDelete() || pa.Action.IsPipe() {
if taskID == "" {
return false
return input, false
}
input.SelectedTaskID = taskID
}
@ -169,20 +177,35 @@ func (pc *PluginController) handlePluginAction(r rune) bool {
if pa.Action.IsCreate() {
template, err := pc.taskStore.NewTaskTemplate()
if err != nil {
slog.Error("failed to create task template for plugin action", "key", string(r), "error", err)
return false
slog.Error("failed to create task template for plugin action", "error", err)
return input, false
}
input.CreateTemplate = template
}
return input, true
}
// executeAndApply runs the executor and applies the result (store mutations, pipe, clipboard).
func (pc *PluginController) executeAndApply(pa *plugin.PluginAction, input ruki.ExecutionInput, r rune) bool {
executor := pc.newExecutor()
allTasks := pc.taskStore.GetAllTasks()
result, err := executor.Execute(pa.Action, allTasks, input)
if err != nil {
slog.Error("failed to execute plugin action", "task_id", taskID, "key", string(r), "error", err)
slog.Error("failed to execute plugin action", "task_id", input.SelectedTaskID, "key", string(r), "error", err)
if pc.statusline != nil {
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
}
return false
}
ctx := context.Background()
switch {
case result.Select != nil:
slog.Info("select plugin action executed", "task_id", input.SelectedTaskID, "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 {
@ -212,12 +235,93 @@ func (pc *PluginController) handlePluginAction(r rune) bool {
return false
}
}
case result.Pipe != nil:
for _, row := range result.Pipe.Rows {
if err := service.ExecutePipeCommand(ctx, result.Pipe.Command, row); err != nil {
slog.Error("pipe command failed", "command", result.Pipe.Command, "args", row, "key", string(r), "error", err)
if pc.statusline != nil {
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
}
}
}
case result.Clipboard != nil:
if err := service.ExecuteClipboardPipe(result.Clipboard.Rows); err != nil {
slog.Error("clipboard pipe failed", "key", string(r), "error", err)
if pc.statusline != nil {
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
}
return false
}
if pc.statusline != nil {
pc.statusline.SetMessage("copied to clipboard", model.MessageLevelInfo, true)
}
}
slog.Info("plugin action applied", "task_id", taskID, "key", string(r), "label", pa.Label, "plugin", pc.pluginDef.Name)
slog.Info("plugin action applied", "task_id", input.SelectedTaskID, "key", string(r), "label", pa.Label, "plugin", pc.pluginDef.Name)
return true
}
// handlePluginAction applies a plugin shortcut action to the currently selected task.
func (pc *PluginController) handlePluginAction(r rune) bool {
pa, _, ok := pc.getPluginAction(pluginActionID(r))
if !ok {
return false
}
input, ok := pc.buildExecutionInput(pa)
if !ok {
return false
}
return pc.executeAndApply(pa, input, r)
}
// GetActionInputSpec returns the prompt and input type for an action, if it has input.
func (pc *PluginController) GetActionInputSpec(actionID ActionID) (string, ruki.ValueType, bool) {
pa, _, ok := pc.getPluginAction(actionID)
if !ok || !pa.HasInput {
return "", 0, false
}
return pa.Label + ": ", pa.InputType, true
}
// CanStartActionInput checks whether an input-backed action can currently run
// (selection/create-template preflight passes).
func (pc *PluginController) CanStartActionInput(actionID ActionID) (string, ruki.ValueType, bool) {
pa, _, ok := pc.getPluginAction(actionID)
if !ok || !pa.HasInput {
return "", 0, false
}
if _, ok := pc.buildExecutionInput(pa); !ok {
return "", 0, false
}
return pa.Label + ": ", pa.InputType, true
}
// HandleActionInput handles submitted text for an input-backed action.
func (pc *PluginController) HandleActionInput(actionID ActionID, text string) InputSubmitResult {
pa, r, ok := pc.getPluginAction(actionID)
if !ok || !pa.HasInput {
return InputKeepEditing
}
val, err := ruki.ParseScalarValue(pa.InputType, text)
if err != nil {
if pc.statusline != nil {
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
}
return InputKeepEditing
}
input, ok := pc.buildExecutionInput(pa)
if !ok {
return InputClose
}
input.InputValue = val
input.HasInput = true
pc.executeAndApply(pa, input, r)
return InputClose
}
func (pc *PluginController) handleMoveTask(offset int) bool {
taskID := pc.getSelectedTaskID(pc.GetFilteredTasksForLane)
if taskID == "" {

View file

@ -36,6 +36,15 @@ func (pb *pluginBase) newExecutor() *ruki.Executor {
func (pb *pluginBase) GetActionRegistry() *ActionRegistry { return pb.registry }
func (pb *pluginBase) GetPluginName() string { return pb.pluginDef.Name }
// default no-op implementations for input-backed action methods
func (pb *pluginBase) GetActionInputSpec(ActionID) (string, ruki.ValueType, bool) {
return "", 0, false
}
func (pb *pluginBase) CanStartActionInput(ActionID) (string, ruki.ValueType, bool) {
return "", 0, false
}
func (pb *pluginBase) HandleActionInput(ActionID, string) InputSubmitResult { return InputKeepEditing }
func (pb *pluginBase) handleNav(direction string, filteredTasks func(int) []*task.Task) bool {
lane := pb.pluginConfig.GetSelectedLane()
tasks := filteredTasks(lane)

View file

@ -1224,6 +1224,230 @@ 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 mustParseStmtWithInput(t *testing.T, input string, inputType ruki.ValueType) *ruki.ValidatedStatement {
t.Helper()
schema := rukiRuntime.NewSchema()
parser := ruki.NewParser(schema)
stmt, err := parser.ParseAndValidateStatementWithInput(input, ruki.ExecutorRuntimePlugin, inputType)
if err != nil {
t.Fatalf("parse ruki statement %q: %v", input, err)
}
return stmt
}
func TestPluginController_HandleActionInput_ValidInput(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"`)
assignAction := mustParseStmtWithInput(t, `update where id = id() set assignee = input()`, ruki.ValueString)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
Actions: []plugin.PluginAction{
{Rune: 'a', Label: "Assign to...", Action: assignAction, InputType: ruki.ValueString, HasInput: true},
},
}
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetLaneLayout([]int{1}, nil)
pluginConfig.SetSelectedLane(0)
statusline := model.NewStatuslineConfig()
gate := service.BuildGate()
gate.SetStore(taskStore)
schema := rukiRuntime.NewSchema()
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, statusline, schema)
result := pc.HandleActionInput(pluginActionID('a'), "alice")
if result != InputClose {
t.Fatalf("expected InputClose for valid input, got %d", result)
}
updated := taskStore.GetTask("T-1")
if updated.Assignee != "alice" {
t.Fatalf("expected assignee=alice, got %q", updated.Assignee)
}
}
func TestPluginController_HandleActionInput_InvalidInput(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"`)
pointsAction := mustParseStmtWithInput(t, `update where id = id() set points = input()`, ruki.ValueInt)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
Actions: []plugin.PluginAction{
{Rune: 'p', Label: "Set points", Action: pointsAction, InputType: ruki.ValueInt, HasInput: true},
},
}
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetLaneLayout([]int{1}, nil)
pluginConfig.SetSelectedLane(0)
statusline := model.NewStatuslineConfig()
gate := service.BuildGate()
gate.SetStore(taskStore)
schema := rukiRuntime.NewSchema()
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, statusline, schema)
result := pc.HandleActionInput(pluginActionID('p'), "abc")
if result != InputKeepEditing {
t.Fatalf("expected InputKeepEditing for invalid int input, got %d", result)
}
msg, level, _ := statusline.GetMessage()
if level != model.MessageLevelError {
t.Fatalf("expected error message in statusline, got level %v msg %q", level, msg)
}
}
func TestPluginController_HandleActionInput_ExecutionFailure_StillCloses(t *testing.T) {
taskStore := store.NewInMemoryStore()
// no tasks in store — executor will find no match for id(), which means
// the update produces no results (not an error), but executeAndApply still returns true.
// Instead, test with a task that exists but use input on a field
// where the assignment succeeds at parse/execution level.
_ = 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"`)
assignAction := mustParseStmtWithInput(t, `update where id = id() set assignee = input()`, ruki.ValueString)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
Actions: []plugin.PluginAction{
{Rune: 'a', Label: "Assign to...", Action: assignAction, InputType: ruki.ValueString, HasInput: true},
},
}
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetLaneLayout([]int{1}, nil)
pluginConfig.SetSelectedLane(0)
statusline := model.NewStatuslineConfig()
gate := service.BuildGate()
gate.SetStore(taskStore)
schema := rukiRuntime.NewSchema()
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, statusline, schema)
// valid parse, successful execution — still returns InputClose
result := pc.HandleActionInput(pluginActionID('a'), "bob")
if result != InputClose {
t.Fatalf("expected InputClose after valid parse (regardless of execution outcome), got %d", result)
}
}
func TestPluginController_GetActionInputSpec(t *testing.T) {
readyFilter := mustParseStmt(t, `select where status = "ready"`)
assignAction := mustParseStmtWithInput(t, `update where id = id() set assignee = input()`, ruki.ValueString)
markDoneAction := mustParseStmt(t, `update where id = id() set status = "done"`)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
Actions: []plugin.PluginAction{
{Rune: 'a', Label: "Assign to...", Action: assignAction, InputType: ruki.ValueString, HasInput: true},
{Rune: 'd', Label: "Done", Action: markDoneAction},
},
}
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetLaneLayout([]int{1}, nil)
schema := rukiRuntime.NewSchema()
gate := service.BuildGate()
gate.SetStore(store.NewInMemoryStore())
pc := NewPluginController(store.NewInMemoryStore(), gate, pluginConfig, pluginDef, nil, nil, schema)
prompt, typ, hasInput := pc.GetActionInputSpec(pluginActionID('a'))
if !hasInput {
t.Fatal("expected hasInput=true for 'a' action")
}
if typ != ruki.ValueString {
t.Fatalf("expected ValueString, got %d", typ)
}
if prompt != "Assign to...: " {
t.Fatalf("expected prompt 'Assign to...: ', got %q", prompt)
}
_, _, hasInput = pc.GetActionInputSpec(pluginActionID('d'))
if hasInput {
t.Fatal("expected hasInput=false for non-input 'd' action")
}
}
func TestGetPluginActionRune(t *testing.T) {
tests := []struct {
name string

View file

@ -817,6 +817,7 @@ func TestTaskController_GetCurrentTask(t *testing.T) {
current := tc.GetCurrentTask()
if current == nil {
t.Fatal("GetCurrentTask returned nil")
return
}
if current.ID != original.ID {

View file

@ -141,6 +141,7 @@ func TestTaskEditCoordinator_Commit_SavesTags(t *testing.T) {
saved := taskStore.GetTask(draft.ID)
if saved == nil {
t.Fatal("task not found in store after commit")
return
}
if len(saved.Tags) != 2 || saved.Tags[0] != "api" || saved.Tags[1] != "backend" {
t.Errorf("saved tags = %v, want [api backend]", saved.Tags)

View file

@ -30,6 +30,7 @@ func TestNavigationState_PushPop(t *testing.T) {
entry := nav.pop()
if entry == nil {
t.Fatal("pop() returned nil, want ViewEntry")
return
}
if entry.ViewID != model.TaskDetailViewID {
t.Errorf("ViewID = %v, want %v", entry.ViewID, model.TaskDetailViewID)
@ -47,6 +48,7 @@ func TestNavigationState_PushPop(t *testing.T) {
entry = nav.pop()
if entry == nil {
t.Fatal("pop() returned nil, want ViewEntry")
return
}
if entry.ViewID != model.TaskDetailViewID {
t.Errorf("ViewID = %v, want %v", entry.ViewID, model.TaskDetailViewID)
@ -81,6 +83,7 @@ func TestNavigationState_CurrentView(t *testing.T) {
entry = nav.currentView()
if entry == nil {
t.Fatal("currentView() returned nil")
return
}
if entry.ViewID != model.TaskEditViewID {
t.Errorf("ViewID = %v, want %v", entry.ViewID, model.TaskEditViewID)
@ -139,6 +142,7 @@ func TestNavigationState_PreviousView(t *testing.T) {
entry = nav.previousView()
if entry == nil {
t.Fatal("previousView() returned nil, want ViewEntry")
return
}
if entry.ViewID != model.TaskDetailViewID {
t.Errorf("previousView() ViewID = %v, want %v", entry.ViewID, model.TaskDetailViewID)
@ -149,6 +153,7 @@ func TestNavigationState_PreviousView(t *testing.T) {
entry = nav.previousView()
if entry == nil {
t.Fatal("previousView() returned nil")
return
}
if entry.ViewID != model.TaskDetailViewID {
t.Errorf("previousView() ViewID = %v, want %v", entry.ViewID, model.TaskDetailViewID)

2
go.mod
View file

@ -5,6 +5,7 @@ go 1.25.0
require (
github.com/alecthomas/chroma/v2 v2.14.0
github.com/alecthomas/participle/v2 v2.1.4
github.com/atotto/clipboard v0.1.4
github.com/boolean-maybe/navidown v0.4.18
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
github.com/charmbracelet/bubbletea v1.3.10
@ -25,7 +26,6 @@ require (
dario.cat/mergo v1.0.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/catppuccin/go v0.3.0 // indirect

View file

@ -0,0 +1,164 @@
package integration
import (
"testing"
"github.com/boolean-maybe/tiki/controller"
"github.com/boolean-maybe/tiki/model"
taskpkg "github.com/boolean-maybe/tiki/task"
"github.com/boolean-maybe/tiki/testutil"
"github.com/gdamore/tcell/v2"
)
func TestActionPalette_OpenAndClose(t *testing.T) {
ta := testutil.NewTestApp(t)
defer ta.Cleanup()
ta.Draw()
// Ctrl+A opens the palette
ta.SendKey(tcell.KeyCtrlA, 0, tcell.ModCtrl)
if !ta.GetPaletteConfig().IsVisible() {
t.Fatal("palette should be visible after pressing Ctrl+A")
}
// Esc closes it
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
if ta.GetPaletteConfig().IsVisible() {
t.Fatal("palette should be hidden after pressing Esc")
}
}
func TestActionPalette_F10TogglesHeader(t *testing.T) {
ta := testutil.NewTestApp(t)
defer ta.Cleanup()
ta.Draw()
hc := ta.GetHeaderConfig()
initialVisible := hc.IsVisible()
// F10 should toggle header via the router
ta.SendKey(tcell.KeyF10, 0, tcell.ModNone)
if hc.IsVisible() == initialVisible {
t.Fatal("F10 should toggle header visibility")
}
// toggle back
ta.SendKey(tcell.KeyF10, 0, tcell.ModNone)
if hc.IsVisible() != initialVisible {
t.Fatal("second F10 should restore header visibility")
}
}
func TestActionPalette_ModalBlocksGlobals(t *testing.T) {
ta := testutil.NewTestApp(t)
defer ta.Cleanup()
ta.Draw()
hc := ta.GetHeaderConfig()
startVisible := hc.IsVisible()
// open palette
ta.SendKey(tcell.KeyCtrlA, 0, tcell.ModCtrl)
if !ta.GetPaletteConfig().IsVisible() {
t.Fatal("palette should be open")
}
// F10 while palette is open should NOT toggle header
// (app capture returns event unchanged, palette input handler swallows F10)
ta.SendKey(tcell.KeyF10, 0, tcell.ModNone)
if hc.IsVisible() != startVisible {
t.Fatal("F10 should be blocked while palette is modal")
}
// close palette
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
}
func TestActionPalette_AsteriskIsFilterTextInPalette(t *testing.T) {
ta := testutil.NewTestApp(t)
defer ta.Cleanup()
ta.Draw()
// open palette
ta.SendKey(tcell.KeyCtrlA, 0, tcell.ModCtrl)
if !ta.GetPaletteConfig().IsVisible() {
t.Fatal("palette should be open")
}
// typing '*' while palette is open should be treated as filter text, not open another palette
ta.SendKeyToFocused(tcell.KeyRune, '*', tcell.ModNone)
// palette should still be open
if !ta.GetPaletteConfig().IsVisible() {
t.Fatal("palette should remain open when '*' is typed as filter")
}
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
}
func TestActionPalette_AsteriskDoesNotOpenPalette(t *testing.T) {
ta := testutil.NewTestApp(t)
defer ta.Cleanup()
ta.Draw()
// send '*' as a rune on the plugin view
ta.SendKey(tcell.KeyRune, '*', tcell.ModNone)
if ta.GetPaletteConfig().IsVisible() {
t.Fatal("palette should NOT open when '*' is pressed — only Ctrl+A should open it")
}
}
func TestActionPalette_OpensInTaskEdit(t *testing.T) {
ta := testutil.NewTestApp(t)
defer ta.Cleanup()
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Test", taskpkg.StatusReady, taskpkg.TypeStory); err != nil {
t.Fatalf("create task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
t.Fatalf("reload: %v", err)
}
ta.NavController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{
TaskID: "TIKI-1",
Focus: model.EditFieldTitle,
}))
ta.Draw()
// Ctrl+A should open the palette even in task edit
ta.SendKey(tcell.KeyCtrlA, 0, tcell.ModCtrl)
if !ta.GetPaletteConfig().IsVisible() {
t.Fatal("palette should open when Ctrl+A is pressed in task edit view")
}
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
}
func TestActionPalette_OpensWithInputBoxFocused(t *testing.T) {
ta := testutil.NewTestApp(t)
defer ta.Cleanup()
ta.NavController.PushView(model.MakePluginViewID("Kanban"), nil)
ta.Draw()
// open search to focus input box
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
v := ta.NavController.GetActiveView()
iv, ok := v.(controller.InputableView)
if !ok || !iv.IsInputBoxFocused() {
t.Fatal("input box should be focused after '/'")
}
// Ctrl+A should open the palette even with input box focused
ta.SendKey(tcell.KeyCtrlA, 0, tcell.ModCtrl)
if !ta.GetPaletteConfig().IsVisible() {
t.Fatal("palette should open when Ctrl+A is pressed with input box focused")
}
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
}

View file

@ -66,6 +66,7 @@ func TestTaskDetailView_ChatModifiesTask(t *testing.T) {
updated := ta.TaskStore.GetTask(taskID)
if updated == nil {
t.Fatal("task not found after chat")
return
}
if updated.Title != "AI Modified Title" {
t.Errorf("title = %q, want %q", updated.Title, "AI Modified Title")
@ -114,6 +115,7 @@ func TestTaskDetailView_ChatNotAvailableWithoutConfig(t *testing.T) {
task := ta.TaskStore.GetTask(taskID)
if task == nil {
t.Fatal("task not found")
return
}
if task.Title != "Unchanged Title" {
t.Errorf("title = %q, want %q", task.Title, "Unchanged Title")

View file

@ -158,6 +158,7 @@ func TestDepsEditor_MoveTask_AllToDepends_PersistsOnDisk(t *testing.T) {
updated := ta.TaskStore.GetTask(contextID)
if updated == nil {
t.Fatalf("context task not found in store")
return
}
if !slices.Contains(updated.DependsOn, freeID) {
t.Errorf("DependsOn = %v, want it to contain %s", updated.DependsOn, freeID)
@ -170,6 +171,7 @@ func TestDepsEditor_MoveTask_AllToDepends_PersistsOnDisk(t *testing.T) {
reloaded := ta.TaskStore.GetTask(contextID)
if reloaded == nil {
t.Fatalf("context task not found after reload")
return
}
if !slices.Contains(reloaded.DependsOn, freeID) {
t.Errorf("after reload: DependsOn = %v, want it to contain %s", reloaded.DependsOn, freeID)
@ -223,6 +225,7 @@ func TestDepsEditor_MoveTask_DependsToAll_RemovesDep(t *testing.T) {
updated := ta.TaskStore.GetTask(contextID)
if updated == nil {
t.Fatalf("context task not found in store")
return
}
if slices.Contains(updated.DependsOn, depID) {
t.Errorf("DependsOn = %v, should not contain %s after removal", updated.DependsOn, depID)
@ -235,6 +238,7 @@ func TestDepsEditor_MoveTask_DependsToAll_RemovesDep(t *testing.T) {
reloaded := ta.TaskStore.GetTask(contextID)
if reloaded == nil {
t.Fatalf("context task not found after reload")
return
}
if slices.Contains(reloaded.DependsOn, depID) {
t.Errorf("after reload: DependsOn = %v, should not contain %s", reloaded.DependsOn, depID)

View file

@ -0,0 +1,574 @@
package integration
import (
"os"
"path/filepath"
"testing"
"github.com/boolean-maybe/tiki/controller"
"github.com/boolean-maybe/tiki/model"
"github.com/boolean-maybe/tiki/task"
"github.com/boolean-maybe/tiki/testutil"
"github.com/gdamore/tcell/v2"
)
const inputActionWorkflow = `views:
plugins:
- name: InputTest
key: "F4"
lanes:
- name: All
columns: 1
filter: select where status = "backlog" order by id
actions:
- key: "A"
label: "Assign to..."
action: update where id = id() set assignee=input()
input: string
- key: "t"
label: "Add tag"
action: update where id = id() set tags=tags+[input()]
input: string
- key: "p"
label: "Set points"
action: update where id = id() set points=input()
input: int
- key: "b"
label: "Add to board"
action: update where id = id() set status="ready"
`
func setupInputActionTest(t *testing.T) *testutil.TestApp {
t.Helper()
tmpDir := t.TempDir()
if err := os.WriteFile(filepath.Join(tmpDir, "workflow.yaml"), []byte(inputActionWorkflow), 0644); err != nil {
t.Fatalf("failed to write workflow.yaml: %v", err)
}
origDir, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get cwd: %v", err)
}
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(origDir) })
ta := testutil.NewTestApp(t)
if err := ta.LoadPlugins(); err != nil {
t.Fatalf("failed to load plugins: %v", err)
}
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Test Task", task.StatusBacklog, task.TypeStory); err != nil {
t.Fatalf("failed to create task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
t.Fatalf("failed to reload: %v", err)
}
ta.NavController.PushView(model.MakePluginViewID("InputTest"), nil)
ta.Draw()
return ta
}
func getActiveInputableView(ta *testutil.TestApp) controller.InputableView {
v := ta.NavController.GetActiveView()
iv, _ := v.(controller.InputableView)
return iv
}
func TestInputAction_KeyOpensPrompt(t *testing.T) {
ta := setupInputActionTest(t)
defer ta.Cleanup()
iv := getActiveInputableView(ta)
if iv == nil {
t.Fatal("active view does not implement InputableView")
}
if iv.IsInputBoxVisible() {
t.Fatal("input box should not be visible initially")
}
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
if !iv.IsInputBoxVisible() {
t.Fatal("input box should be visible after pressing 'A'")
}
if !iv.IsInputBoxFocused() {
t.Fatal("input box should be focused after pressing 'A'")
}
}
func TestInputAction_EnterAppliesMutation(t *testing.T) {
ta := setupInputActionTest(t)
defer ta.Cleanup()
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
ta.SendText("alice")
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
iv := getActiveInputableView(ta)
if iv.IsInputBoxVisible() {
t.Fatal("input box should be hidden after valid submit")
}
if err := ta.TaskStore.Reload(); err != nil {
t.Fatalf("failed to reload: %v", err)
}
updated := ta.TaskStore.GetTask("TIKI-1")
if updated == nil {
t.Fatal("task not found")
}
if updated.Assignee != "alice" {
t.Fatalf("expected assignee=alice, got %q", updated.Assignee)
}
}
func TestInputAction_EscCancelsWithoutMutation(t *testing.T) {
ta := setupInputActionTest(t)
defer ta.Cleanup()
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
ta.SendText("bob")
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
iv := getActiveInputableView(ta)
if iv.IsInputBoxVisible() {
t.Fatal("input box should be hidden after Esc")
}
if err := ta.TaskStore.Reload(); err != nil {
t.Fatalf("failed to reload: %v", err)
}
updated := ta.TaskStore.GetTask("TIKI-1")
if updated == nil {
t.Fatal("task not found")
}
if updated.Assignee != "" {
t.Fatalf("expected empty assignee after cancel, got %q", updated.Assignee)
}
}
func TestInputAction_NonInputActionStillWorks(t *testing.T) {
ta := setupInputActionTest(t)
defer ta.Cleanup()
// 'b' is a non-input action — should execute immediately without prompt
ta.SendKey(tcell.KeyRune, 'b', tcell.ModNone)
iv := getActiveInputableView(ta)
if iv.IsInputBoxVisible() {
t.Fatal("non-input action should not open input box")
}
if err := ta.TaskStore.Reload(); err != nil {
t.Fatalf("failed to reload: %v", err)
}
updated := ta.TaskStore.GetTask("TIKI-1")
if updated == nil {
t.Fatal("task not found")
}
if updated.Status != task.StatusReady {
t.Fatalf("expected status ready, got %v", updated.Status)
}
}
func TestInputAction_ModalBlocksOtherActions(t *testing.T) {
ta := setupInputActionTest(t)
defer ta.Cleanup()
// open action-input prompt
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
iv := getActiveInputableView(ta)
if !iv.IsInputBoxFocused() {
t.Fatal("input box should be focused")
}
// while modal, 'b' should NOT execute the non-input action
ta.SendKey(tcell.KeyRune, 'b', tcell.ModNone)
if err := ta.TaskStore.Reload(); err != nil {
t.Fatalf("failed to reload: %v", err)
}
updated := ta.TaskStore.GetTask("TIKI-1")
if updated.Status != task.StatusBacklog {
t.Fatalf("expected status backlog (action should be blocked while modal), got %v", updated.Status)
}
// cancel and verify box closes
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
if iv.IsInputBoxVisible() {
t.Fatal("input box should be hidden after Esc")
}
}
func TestInputAction_SearchPassiveBlocksNewSearch(t *testing.T) {
ta := setupInputActionTest(t)
defer ta.Cleanup()
iv := getActiveInputableView(ta)
// open search
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
if !iv.IsInputBoxFocused() {
t.Fatal("search box should be focused after '/'")
}
// type and submit search
ta.SendText("Test")
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
// should be in passive mode: visible but not focused
if !iv.IsInputBoxVisible() {
t.Fatal("search box should remain visible in passive mode")
}
if iv.IsInputBoxFocused() {
t.Fatal("search box should not be focused in passive mode")
}
if !iv.IsSearchPassive() {
t.Fatal("expected search-passive state")
}
// pressing '/' again should NOT re-enter search editing
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
if iv.IsInputBoxFocused() {
t.Fatal("'/' should be blocked while search is passive — user must Esc first")
}
// Esc clears search and closes
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
if iv.IsInputBoxVisible() {
t.Fatal("input box should be hidden after Esc from passive mode")
}
}
func TestInputAction_PassiveSearchReplacedByActionInput(t *testing.T) {
ta := setupInputActionTest(t)
defer ta.Cleanup()
iv := getActiveInputableView(ta)
// set up passive search
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
ta.SendText("Test")
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
if !iv.IsSearchPassive() {
t.Fatal("expected search-passive state")
}
// action-input should temporarily replace the passive search
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
if !iv.IsInputBoxFocused() {
t.Fatal("action-input should be focused, replacing passive search")
}
if iv.IsSearchPassive() {
t.Fatal("should no longer be in search-passive while action-input is active")
}
// submit action input
ta.SendText("carol")
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
// should restore passive search
if !iv.IsInputBoxVisible() {
t.Fatal("passive search should be restored after action-input closes")
}
if !iv.IsSearchPassive() {
t.Fatal("should be back in search-passive mode")
}
// verify the mutation happened
if err := ta.TaskStore.Reload(); err != nil {
t.Fatalf("failed to reload: %v", err)
}
updated := ta.TaskStore.GetTask("TIKI-1")
if updated == nil {
t.Fatal("task not found")
}
if updated.Assignee != "carol" {
t.Fatalf("expected assignee=carol, got %q", updated.Assignee)
}
}
func TestInputAction_ActionInputEscRestoresPassiveSearch(t *testing.T) {
ta := setupInputActionTest(t)
defer ta.Cleanup()
iv := getActiveInputableView(ta)
// set up passive search
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
ta.SendText("Test")
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
// open action-input
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
ta.SendText("dave")
// Esc should restore passive search, not clear it
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
if !iv.IsSearchPassive() {
t.Fatal("Esc from action-input should restore passive search")
}
// verify no mutation
if err := ta.TaskStore.Reload(); err != nil {
t.Fatalf("failed to reload: %v", err)
}
updated := ta.TaskStore.GetTask("TIKI-1")
if updated.Assignee != "" {
t.Fatalf("expected empty assignee after cancel, got %q", updated.Assignee)
}
}
func TestInputAction_SearchEditingBlocksPluginActions(t *testing.T) {
ta := setupInputActionTest(t)
defer ta.Cleanup()
iv := getActiveInputableView(ta)
// open search
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
if !iv.IsInputBoxFocused() {
t.Fatal("search box should be focused")
}
// while search editing is active, 'b' (non-input action) should be blocked
ta.SendKey(tcell.KeyRune, 'b', tcell.ModNone)
if err := ta.TaskStore.Reload(); err != nil {
t.Fatalf("failed to reload: %v", err)
}
updated := ta.TaskStore.GetTask("TIKI-1")
if updated.Status != task.StatusBacklog {
t.Fatalf("expected status backlog (action blocked during search editing), got %v", updated.Status)
}
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
}
func TestInputAction_EmptySearchEnterIsNoOp(t *testing.T) {
ta := setupInputActionTest(t)
defer ta.Cleanup()
iv := getActiveInputableView(ta)
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
if !iv.IsInputBoxFocused() {
t.Fatal("search box should be focused")
}
// Enter on empty text should keep editing open
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
if !iv.IsInputBoxFocused() {
t.Fatal("empty search Enter should keep box focused (no-op)")
}
if iv.IsSearchPassive() {
t.Fatal("empty search Enter should not transition to passive")
}
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
}
func TestInputAction_PaletteOpensDuringModal(t *testing.T) {
ta := setupInputActionTest(t)
defer ta.Cleanup()
// open action-input
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
iv := getActiveInputableView(ta)
if !iv.IsInputBoxFocused() {
t.Fatal("input box should be focused")
}
// Ctrl+A should open the palette even while input box is focused
ta.SendKey(tcell.KeyCtrlA, 0, tcell.ModCtrl)
if !ta.GetPaletteConfig().IsVisible() {
t.Fatal("palette should open when Ctrl+A is pressed with input box focused")
}
// clean up
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
}
func TestInputAction_PaletteDispatchOpensPrompt(t *testing.T) {
ta := setupInputActionTest(t)
defer ta.Cleanup()
iv := getActiveInputableView(ta)
// simulate palette dispatch: call HandleAction directly with the input-backed action ID
actionID := controller.ActionID("plugin_action:A")
ta.InputRouter.HandleAction(actionID, ta.NavController.CurrentView())
ta.Draw()
if !iv.IsInputBoxVisible() {
t.Fatal("palette-dispatched input action should open the prompt")
}
if !iv.IsInputBoxFocused() {
t.Fatal("prompt should be focused after palette dispatch")
}
// type and submit
ta.SendText("eve")
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
if iv.IsInputBoxVisible() {
t.Fatal("prompt should close after valid submit")
}
if err := ta.TaskStore.Reload(); err != nil {
t.Fatalf("failed to reload: %v", err)
}
updated := ta.TaskStore.GetTask("TIKI-1")
if updated.Assignee != "eve" {
t.Fatalf("expected assignee=eve via palette dispatch, got %q", updated.Assignee)
}
}
func TestInputAction_InvalidInputKeepsPromptOpen(t *testing.T) {
ta := setupInputActionTest(t)
defer ta.Cleanup()
iv := getActiveInputableView(ta)
originalTask := ta.TaskStore.GetTask("TIKI-1")
originalPoints := originalTask.Points
// open int input (points)
ta.SendKey(tcell.KeyRune, 'p', tcell.ModNone)
if !iv.IsInputBoxFocused() {
t.Fatal("prompt should be focused")
}
// type non-numeric text and submit
ta.SendText("abc")
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
// prompt should stay open — invalid input
if !iv.IsInputBoxFocused() {
t.Fatal("prompt should remain focused after invalid input")
}
if !iv.IsInputBoxVisible() {
t.Fatal("prompt should remain visible after invalid input")
}
// verify no mutation
if err := ta.TaskStore.Reload(); err != nil {
t.Fatalf("failed to reload: %v", err)
}
updated := ta.TaskStore.GetTask("TIKI-1")
if updated.Points != originalPoints {
t.Fatalf("expected points=%d (unchanged), got %d", originalPoints, updated.Points)
}
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
}
func TestInputAction_PreflightNoTaskSelected_NoPrompt(t *testing.T) {
tmpDir := t.TempDir()
// workflow with an empty lane (no tasks will match)
workflow := `views:
plugins:
- name: EmptyTest
key: "F4"
lanes:
- name: Empty
columns: 1
filter: select where status = "nonexistent" order by id
actions:
- key: "A"
label: "Assign to..."
action: update where id = id() set assignee=input()
input: string
`
if err := os.WriteFile(filepath.Join(tmpDir, "workflow.yaml"), []byte(workflow), 0644); err != nil {
t.Fatalf("failed to write workflow.yaml: %v", err)
}
origDir, _ := os.Getwd()
_ = os.Chdir(tmpDir)
t.Cleanup(func() { _ = os.Chdir(origDir) })
ta := testutil.NewTestApp(t)
if err := ta.LoadPlugins(); err != nil {
t.Fatalf("failed to load plugins: %v", err)
}
defer ta.Cleanup()
// create a task, but it won't match the filter
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Test", task.StatusBacklog, task.TypeStory); err != nil {
t.Fatalf("failed to create task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
t.Fatalf("failed to reload: %v", err)
}
ta.NavController.PushView(model.MakePluginViewID("EmptyTest"), nil)
ta.Draw()
iv := getActiveInputableView(ta)
// press 'A' — no task selected, preflight should fail, no prompt
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
if iv != nil && iv.IsInputBoxVisible() {
t.Fatal("input prompt should not open when no task is selected")
}
}
func TestInputAction_DraftSearchSurvivesRefresh(t *testing.T) {
ta := setupInputActionTest(t)
defer ta.Cleanup()
iv := getActiveInputableView(ta)
// open search and type (but don't submit)
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
ta.SendText("draft")
if !iv.IsInputBoxFocused() {
t.Fatal("search box should be focused")
}
// simulate a store refresh (which triggers view rebuild)
if err := ta.TaskStore.Reload(); err != nil {
t.Fatalf("failed to reload: %v", err)
}
ta.Draw()
// search box should still be visible after refresh
if !iv.IsInputBoxVisible() {
t.Fatal("draft search should survive store refresh/rebuild")
}
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
}
func TestInputAction_AddTagMutation(t *testing.T) {
ta := setupInputActionTest(t)
defer ta.Cleanup()
ta.SendKey(tcell.KeyRune, 't', tcell.ModNone)
ta.SendText("urgent")
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
if err := ta.TaskStore.Reload(); err != nil {
t.Fatalf("failed to reload: %v", err)
}
updated := ta.TaskStore.GetTask("TIKI-1")
if updated == nil {
t.Fatal("task not found")
}
found := false
for _, tag := range updated.Tags {
if tag == "urgent" {
found = true
break
}
}
if !found {
t.Fatalf("expected 'urgent' tag, got %v", updated.Tags)
}
}

View file

@ -71,6 +71,7 @@ func TestPluginView_MoveTaskAppliesLaneAction(t *testing.T) {
updated := ta.TaskStore.GetTask("TIKI-1")
if updated == nil {
t.Fatalf("expected task TIKI-1 to exist")
return
}
if updated.Status != task.StatusDone {
t.Fatalf("expected status done, got %v", updated.Status)
@ -87,6 +88,7 @@ func TestPluginView_MoveTaskAppliesLaneAction(t *testing.T) {
updated = ta.TaskStore.GetTask("TIKI-1")
if updated == nil {
t.Fatalf("expected task TIKI-1 to exist")
return
}
if updated.Status != task.StatusBacklog {
t.Fatalf("expected status backlog, got %v", updated.Status)

View file

@ -401,6 +401,7 @@ func TestPluginActions_DeleteTask_DKey(t *testing.T) {
task := ta.TaskStore.GetTask("DELETE-1")
if task == nil {
t.Fatal("Test task DELETE-1 not found before deletion")
return
}
// Press 'd' to delete (assumes first task is selected)

View file

@ -105,6 +105,7 @@ func TestRefresh_ExternalModification(t *testing.T) {
taskAfter := ta.TaskStore.GetTask(taskID)
if taskAfter == nil {
t.Fatalf("task should still exist after refresh")
return
}
if taskAfter.Title != "Modified Title" {
t.Errorf("task title in store = %q, want %q", taskAfter.Title, "Modified Title")

View file

@ -171,6 +171,7 @@ func TestTaskDetailView_InlineTitleEdit_Save(t *testing.T) {
task := ta.TaskStore.GetTask(taskID)
if task == nil {
t.Fatalf("task not found")
return
}
if task.Title != "New Edited Title" {
t.Errorf("title = %q, want %q", task.Title, "New Edited Title")
@ -214,6 +215,7 @@ func TestTaskDetailView_InlineTitleEdit_Cancel(t *testing.T) {
task := ta.TaskStore.GetTask(taskID)
if task == nil {
t.Fatalf("task not found")
return
}
if task.Title != originalTitle {
t.Errorf("title = %q, want %q (should not have changed)", task.Title, originalTitle)
@ -471,6 +473,7 @@ func TestTaskDetailView_InlineEdit_PreservesOtherFields(t *testing.T) {
task := ta.TaskStore.GetTask(taskID)
if task == nil {
t.Fatalf("task not found")
return
}
if task.Title != "New Title" {

View file

@ -51,6 +51,7 @@ func TestTaskEdit_ShiftTabBackward(t *testing.T) {
task := ta.TaskStore.GetTask(taskID)
if task == nil {
t.Fatalf("task not found")
return
}
// Verify points changed from default 1 to 2
@ -102,6 +103,7 @@ func TestTaskEdit_StatusCycling(t *testing.T) {
task := ta.TaskStore.GetTask(taskID)
if task == nil {
t.Fatalf("task not found")
return
}
if task.Status != taskpkg.StatusDone {
@ -151,6 +153,7 @@ func TestTaskEdit_TypeToggling(t *testing.T) {
task := ta.TaskStore.GetTask(taskID)
if task == nil {
t.Fatalf("task not found")
return
}
if task.Type != taskpkg.TypeEpic {
@ -199,6 +202,7 @@ func TestTaskEdit_AssigneeInput(t *testing.T) {
task := ta.TaskStore.GetTask(taskID)
if task == nil {
t.Fatalf("task not found")
return
}
// Current behavior: appends to default "Unassigned" text
@ -240,6 +244,7 @@ func TestTaskEdit_SaveAndContinue(t *testing.T) {
task := ta.TaskStore.GetTask(taskID)
if task == nil {
t.Fatalf("task not found")
return
}
if task.Title != "New Title" {
@ -298,6 +303,7 @@ func TestTaskEdit_EscapeAndReEdit(t *testing.T) {
task := ta.TaskStore.GetTask(taskID)
if task == nil {
t.Fatalf("task not found")
return
}
if task.Title != "New Title" {
@ -345,6 +351,7 @@ func TestTaskEdit_PriorityRange(t *testing.T) {
task := ta.TaskStore.GetTask(taskID)
if task == nil {
t.Fatalf("task not found")
return
}
if task.Priority != 5 {
@ -394,6 +401,7 @@ func TestTaskEdit_PointsRange(t *testing.T) {
task := ta.TaskStore.GetTask(taskID)
if task == nil {
t.Fatalf("task not found")
return
}
if task.Points != 7 {

View file

@ -53,6 +53,7 @@ func TestNewTask_Enter_SavesAndCreatesFile(t *testing.T) {
task := findTaskByTitle(ta.TaskStore.GetAllTasks(), "My New Task")
if task == nil {
t.Fatalf("new task not found in store")
return
}
if task.Title != "My New Task" {
t.Errorf("title = %q, want %q", task.Title, "My New Task")
@ -130,6 +131,7 @@ func TestNewTask_CtrlS_SavesAndCreatesFile(t *testing.T) {
task := findTaskByTitle(ta.TaskStore.GetAllTasks(), "Task Saved With CtrlS")
if task == nil {
t.Fatalf("new task not found in store")
return
}
if task.Title != "Task Saved With CtrlS" {
t.Errorf("title = %q, want %q", task.Title, "Task Saved With CtrlS")
@ -277,6 +279,7 @@ func TestTaskEdit_EnterInPointsFieldDoesNotSave(t *testing.T) {
task := ta.TaskStore.GetTask(taskID)
if task == nil {
t.Fatalf("task not found")
return
}
if task.Title != originalTitle {
t.Errorf("title was saved when it shouldn't have been: got %q, want %q", task.Title, originalTitle)
@ -320,6 +323,7 @@ func TestTaskEdit_TitleChangesSaved(t *testing.T) {
task := ta.TaskStore.GetTask(taskID)
if task == nil {
t.Fatalf("task not found")
return
}
if task.Title != "Updated Title" {
t.Errorf("title = %q, want %q", task.Title, "Updated Title")
@ -371,6 +375,7 @@ func TestTaskEdit_CtrlS_FromPointsField_Saves(t *testing.T) {
task := ta.TaskStore.GetTask(taskID)
if task == nil {
t.Fatalf("task not found")
return
}
if task.Title != "Modified Title" {
t.Errorf("title = %q, want %q (Ctrl+S should save from any field)", task.Title, "Modified Title")
@ -413,6 +418,7 @@ func TestTaskEdit_Escape_FromTitleField_Cancels(t *testing.T) {
task := ta.TaskStore.GetTask(taskID)
if task == nil {
t.Fatalf("task not found")
return
}
if task.Title != originalTitle {
t.Errorf("title = %q, want %q (Escape should cancel)", task.Title, originalTitle)
@ -498,6 +504,7 @@ func TestTaskEdit_Escape_FromPointsField_Cancels(t *testing.T) {
task := ta.TaskStore.GetTask(taskID)
if task == nil {
t.Fatalf("task not found")
return
}
if task.Title != originalTitle {
t.Errorf("title = %q, want %q (Escape should cancel from any field)", task.Title, originalTitle)
@ -560,6 +567,7 @@ func TestTaskEdit_Tab_NavigatesForward(t *testing.T) {
task := ta.TaskStore.GetTask(taskID)
if task == nil {
t.Fatalf("task not found")
return
}
if task.Points != 5 {
t.Errorf("points = %d, want 5 (Tab should navigate to Points field)", task.Points)
@ -611,6 +619,7 @@ func TestTaskEdit_Navigation_PreservesChanges(t *testing.T) {
task := ta.TaskStore.GetTask(taskID)
if task == nil {
t.Fatalf("task not found")
return
}
if task.Title != "New Title" {
t.Errorf("title = %q, want %q (changes should be preserved during navigation)", task.Title, "New Title")
@ -675,6 +684,7 @@ func TestTaskEdit_MultipleFields_AllSaved(t *testing.T) {
task := ta.TaskStore.GetTask(taskID)
if task == nil {
t.Fatalf("task not found")
return
}
if task.Title != "New Multi-Field Title" {
t.Errorf("title = %q, want %q", task.Title, "New Multi-Field Title")
@ -703,6 +713,7 @@ func TestTaskEdit_MultipleFields_AllDiscarded(t *testing.T) {
task := ta.TaskStore.GetTask(taskID)
if task == nil {
t.Fatalf("task not found after creation")
return
}
task.Priority = 3
task.Points = 5
@ -745,6 +756,7 @@ func TestTaskEdit_MultipleFields_AllDiscarded(t *testing.T) {
task = ta.TaskStore.GetTask(taskID)
if task == nil {
t.Fatalf("task not found")
return
}
if task.Title != "Original Title" {
t.Errorf("title = %q, want %q (all changes should be discarded)", task.Title, "Original Title")
@ -795,6 +807,7 @@ func TestNewTask_MultipleFields_AllSaved(t *testing.T) {
task := findTaskByTitle(ta.TaskStore.GetAllTasks(), "New Task With Multiple Fields")
if task == nil {
t.Fatalf("new task not found in store")
return
}
if task.Title != "New Task With Multiple Fields" {
t.Errorf("title = %q, want %q", task.Title, "New Task With Multiple Fields")
@ -854,6 +867,7 @@ func TestNewTask_AfterEditingExistingTask_StatusAndTypeNotCorrupted(t *testing.T
newTask := findTaskByTitle(ta.TaskStore.GetAllTasks(), "New Task After Edit")
if newTask == nil {
t.Fatalf("new task not found in store")
return
}
if newTask.Title != "New Task After Edit" {
t.Errorf("title = %q, want %q", newTask.Title, "New Task After Edit")
@ -908,6 +922,7 @@ func TestNewTask_WithStatusAndType_Saves(t *testing.T) {
task := findTaskByTitle(ta.TaskStore.GetAllTasks(), "Hey")
if task == nil {
t.Fatalf("new task not found in store")
return
}
t.Logf("Task found: Title=%q, Status=%v, Type=%v", task.Title, task.Status, task.Type)

View file

@ -4,19 +4,22 @@ import (
"fmt"
"github.com/rivo/tview"
"github.com/boolean-maybe/tiki/view"
)
// NewApp creates a tview application.
func NewApp() *tview.Application {
tview.Borders.HorizontalFocus = tview.Borders.Horizontal
tview.Borders.VerticalFocus = tview.Borders.Vertical
tview.Borders.TopLeftFocus = tview.Borders.TopLeft
tview.Borders.TopRightFocus = tview.Borders.TopRight
tview.Borders.BottomLeftFocus = tview.Borders.BottomLeft
tview.Borders.BottomRightFocus = tview.Borders.BottomRight
return tview.NewApplication()
}
// Run runs the tview application.
// Returns an error if the application fails to run.
func Run(app *tview.Application, rootLayout *view.RootLayout) error {
app.SetRoot(rootLayout.GetPrimitive(), true).EnableMouse(false)
// Run runs the tview application with the given root primitive (typically a tview.Pages).
func Run(app *tview.Application, root tview.Primitive) error {
app.SetRoot(root, true).EnableMouse(false)
if err := app.Run(); err != nil {
return fmt.Errorf("run application: %w", err)
}

View file

@ -9,22 +9,27 @@ import (
)
// InstallGlobalInputCapture installs the global keyboard handler
// (header toggle, statusline auto-hide dismiss, router dispatch).
// (palette modal short-circuit, statusline auto-hide dismiss, router dispatch).
// F10 (toggle header) and * (open palette) are both routed through InputRouter
// rather than handled here, so keyboard and palette-entered globals behave identically.
func InstallGlobalInputCapture(
app *tview.Application,
headerConfig *model.HeaderConfig,
paletteConfig *model.ActionPaletteConfig,
statuslineConfig *model.StatuslineConfig,
inputRouter *controller.InputRouter,
navController *controller.NavigationController,
) {
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
// while the palette is visible, pass the event through unchanged so the
// focused palette input field receives it. Do not dismiss statusline or
// dispatch through InputRouter — the palette is modal.
if paletteConfig != nil && paletteConfig.IsVisible() {
return event
}
// dismiss auto-hide statusline messages on any keypress
statuslineConfig.DismissAutoHide()
if event.Key() == tcell.KeyF10 {
headerConfig.ToggleUserPreference()
return nil
}
if inputRouter.HandleInput(event, navController.CurrentView()) {
return nil
}

View file

@ -5,6 +5,7 @@ import (
"fmt"
"log/slog"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"github.com/boolean-maybe/tiki/config"
@ -20,6 +21,7 @@ import (
"github.com/boolean-maybe/tiki/util/sysinfo"
"github.com/boolean-maybe/tiki/view"
"github.com/boolean-maybe/tiki/view/header"
"github.com/boolean-maybe/tiki/view/palette"
"github.com/boolean-maybe/tiki/view/statusline"
)
@ -47,6 +49,10 @@ type Result struct {
StatuslineConfig *model.StatuslineConfig
StatuslineWidget *statusline.StatuslineWidget
RootLayout *view.RootLayout
PaletteConfig *model.ActionPaletteConfig
ActionPalette *palette.ActionPalette
ViewContext *model.ViewContext
AppRoot tview.Primitive // Pages root for app.SetRoot
Context context.Context
CancelFunc context.CancelFunc
TikiSkillContent string
@ -77,9 +83,9 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
slog.Warn("failed to install default workflow", "error", err)
}
// Phase 2.7: Load status definitions from workflow.yaml
if err := config.LoadStatusRegistry(); err != nil {
return nil, fmt.Errorf("load status registry: %w", err)
// Phase 2.7: Load workflow registries (statuses, types, custom fields)
if err := config.LoadWorkflowRegistries(); err != nil {
return nil, fmt.Errorf("load workflow registries: %w", err)
}
// Phase 3: Configuration and logging
@ -116,7 +122,7 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
return nil, err
}
InitPluginActionRegistry(plugins)
syncHeaderPluginActions(headerConfig)
viewContext := model.NewViewContext()
pluginConfigs, pluginDefs := BuildPluginConfigsAndDefs(plugins)
// Phase 6.5: Trigger system
@ -163,11 +169,12 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
viewFactory.RegisterPlugin(name, cfg, def, ctrl)
})
headerWidget := header.NewHeaderWidget(headerConfig)
headerWidget := header.NewHeaderWidget(headerConfig, viewContext)
statuslineWidget := statusline.NewStatuslineWidget(statuslineConfig)
rootLayout := view.NewRootLayout(view.RootLayoutOpts{
Header: headerWidget,
HeaderConfig: headerConfig,
ViewContext: viewContext,
LayoutModel: layoutModel,
ViewFactory: viewFactory,
TaskStore: taskStore,
@ -184,9 +191,38 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
background.StartBurndownHistoryBuilder(ctx, tikiStore, headerConfig, application)
triggerEngine.StartScheduler(ctx)
// Phase 11.5: Action palette
paletteConfig := model.NewActionPaletteConfig()
inputRouter.SetHeaderConfig(headerConfig)
inputRouter.SetPaletteConfig(paletteConfig)
actionPalette := palette.NewActionPalette(viewContext, paletteConfig, inputRouter, controllers.Nav)
actionPalette.SetChangedFunc()
// Build Pages root: base = rootLayout, overlay = palette
pages := tview.NewPages()
pages.AddPage("base", rootLayout.GetPrimitive(), true, true)
paletteOverlay := buildPaletteOverlay(actionPalette)
pages.AddPage("palette", paletteOverlay, true, false)
// Wire palette visibility to Pages show/hide and focus management
var previousFocus tview.Primitive
paletteConfig.AddListener(func() {
if paletteConfig.IsVisible() {
previousFocus = application.GetFocus()
actionPalette.OnShow()
pages.ShowPage("palette")
application.SetFocus(actionPalette.GetFilterInput())
} else {
pages.HidePage("palette")
restoreFocusAfterPalette(application, previousFocus, rootLayout)
previousFocus = nil
}
})
// Phase 12: Navigation and input wiring
wireNavigation(controllers.Nav, layoutModel, rootLayout)
app.InstallGlobalInputCapture(application, headerConfig, statuslineConfig, inputRouter, controllers.Nav)
app.InstallGlobalInputCapture(application, paletteConfig, statuslineConfig, inputRouter, controllers.Nav)
// Phase 13: Initial view — use the first plugin marked default: true,
// or fall back to the first plugin in the list.
@ -212,6 +248,10 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
StatuslineConfig: statuslineConfig,
StatuslineWidget: statuslineWidget,
RootLayout: rootLayout,
PaletteConfig: paletteConfig,
ActionPalette: actionPalette,
ViewContext: viewContext,
AppRoot: pages,
Context: ctx,
CancelFunc: cancel,
TikiSkillContent: tikiSkillContent,
@ -219,12 +259,6 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
}, nil
}
// syncHeaderPluginActions syncs plugin action shortcuts from the controller registry
// into the header model.
func syncHeaderPluginActions(headerConfig *model.HeaderConfig) {
headerConfig.SetPluginActions(controller.GetPluginActions().ToHeaderActions())
}
// wireOnViewActivated wires focus setters into views as they become active.
func wireOnViewActivated(rootLayout *view.RootLayout, app *tview.Application) {
rootLayout.SetOnViewActivated(func(v controller.View) {
@ -246,6 +280,59 @@ func wireNavigation(navController *controller.NavigationController, layoutModel
navController.SetActiveViewGetter(rootLayout.GetContentView)
}
// paletteOverlayFlex is a Flex that recomputes the palette width on every draw
// to maintain 1/3 terminal width with a minimum floor.
type paletteOverlayFlex struct {
*tview.Flex
palette tview.Primitive
spacer *tview.Flex
lastPaletteSize int
}
func buildPaletteOverlay(ap *palette.ActionPalette) *paletteOverlayFlex {
overlay := &paletteOverlayFlex{
Flex: tview.NewFlex(),
palette: ap.GetPrimitive(),
}
overlay.spacer = tview.NewFlex()
overlay.Flex.AddItem(overlay.spacer, 0, 1, false)
overlay.Flex.AddItem(overlay.palette, palette.PaletteMinWidth, 0, true)
overlay.lastPaletteSize = palette.PaletteMinWidth
return overlay
}
func (o *paletteOverlayFlex) Draw(screen tcell.Screen) {
_, _, w, _ := o.GetRect()
pw := w / 3
if pw < palette.PaletteMinWidth {
pw = palette.PaletteMinWidth
}
if pw != o.lastPaletteSize {
o.Flex.Clear()
o.Flex.AddItem(o.spacer, 0, 1, false)
o.Flex.AddItem(o.palette, pw, 0, true)
o.lastPaletteSize = pw
}
o.Flex.Draw(screen)
}
// restoreFocusAfterPalette restores focus to the previously focused primitive,
// falling back to FocusRestorer on the active view, then to the content view root.
func restoreFocusAfterPalette(application *tview.Application, previousFocus tview.Primitive, rootLayout *view.RootLayout) {
if previousFocus != nil {
application.SetFocus(previousFocus)
return
}
if contentView := rootLayout.GetContentView(); contentView != nil {
if restorer, ok := contentView.(controller.FocusRestorer); ok {
if restorer.RestoreFocus() {
return
}
}
application.SetFocus(contentView.GetPrimitive())
}
}
// InitColorAndGradientSupport collects system information, auto-corrects TERM if needed,
// and initializes gradient support flags based on terminal color capabilities.
// Returns the collected SystemInfo for use in bootstrap result.

View file

@ -82,9 +82,9 @@ func CreateTaskFromReader(r io.Reader) (string, error) {
return "", fmt.Errorf("project not initialized: run 'tiki init' first")
}
// Load status definitions before creating tasks
if err := config.LoadStatusRegistry(); err != nil {
return "", fmt.Errorf("load status registry: %w", err)
// load workflow registries (statuses, types, custom fields) before creating tasks
if err := config.LoadWorkflowRegistries(); err != nil {
return "", fmt.Errorf("load workflow registries: %w", err)
}
gate := service.BuildGate()

View file

@ -134,6 +134,17 @@ func extractFieldValue(t *task.Task, name string) interface{} {
case "updatedAt":
return t.UpdatedAt
default:
fd, ok := workflow.Field(name)
if !ok || !fd.Custom {
return nil
}
if t.CustomFields != nil {
if v, exists := t.CustomFields[name]; exists {
return v
}
}
// unset custom field — return nil to match executor semantics;
// renderValue converts nil to "" so unset fields display as blank
return nil
}
}
@ -153,6 +164,10 @@ func renderValue(val interface{}, vt workflow.ValueType) string {
return renderList(val)
case workflow.TypeInt:
return renderInt(val)
case workflow.TypeEnum:
return escapeScalar(fmt.Sprint(val))
case workflow.TypeBool:
return fmt.Sprint(val)
default:
return escapeScalar(fmt.Sprint(val))
}

View file

@ -9,6 +9,7 @@ import (
"github.com/boolean-maybe/tiki/ruki"
"github.com/boolean-maybe/tiki/task"
"github.com/boolean-maybe/tiki/workflow"
)
func TestTableFormatterProjectedFields(t *testing.T) {
@ -556,3 +557,139 @@ func TestEscapeScalar(t *testing.T) {
}
}
}
func TestFormatCustomFields(t *testing.T) {
// register custom fields so extractFieldValue and resolveFields find them
initTestRegistries()
if err := workflow.RegisterCustomFields([]workflow.FieldDef{
{Name: "severity", Type: workflow.TypeEnum, AllowedValues: []string{"low", "medium", "high"}},
{Name: "score", Type: workflow.TypeInt},
{Name: "active", Type: workflow.TypeBool},
{Name: "notes", Type: workflow.TypeString},
}); err != nil {
t.Fatalf("register custom fields: %v", err)
}
t.Cleanup(func() { workflow.ClearCustomFields() })
proj := &ruki.TaskProjection{
Fields: []string{"severity", "score", "active", "notes"},
Tasks: []*task.Task{
{
ID: "TIKI-CF0001", Title: "Custom", Status: "ready",
CustomFields: map[string]interface{}{
"severity": "high",
"score": 42,
"active": true,
"notes": "important",
},
},
},
}
var buf bytes.Buffer
if err := NewTableFormatter().Format(&buf, proj); err != nil {
t.Fatal(err)
}
out := buf.String()
if !strings.Contains(out, "high") {
t.Errorf("missing severity value:\n%s", out)
}
if !strings.Contains(out, "42") {
t.Errorf("missing score value:\n%s", out)
}
if !strings.Contains(out, "true") {
t.Errorf("missing active value:\n%s", out)
}
if !strings.Contains(out, "important") {
t.Errorf("missing notes value:\n%s", out)
}
}
func TestFormatMissingCustomFields(t *testing.T) {
initTestRegistries()
if err := workflow.RegisterCustomFields([]workflow.FieldDef{
{Name: "severity", Type: workflow.TypeEnum, AllowedValues: []string{"low", "medium", "high"}},
{Name: "score", Type: workflow.TypeInt},
{Name: "active", Type: workflow.TypeBool},
{Name: "notes", Type: workflow.TypeString},
}); err != nil {
t.Fatalf("register custom fields: %v", err)
}
t.Cleanup(func() { workflow.ClearCustomFields() })
// task with no custom fields set — should render as empty (nil → "")
proj := &ruki.TaskProjection{
Fields: []string{"score", "active"},
Tasks: []*task.Task{
{ID: "TIKI-CF0002", Title: "Empty", Status: "ready"},
},
}
var buf bytes.Buffer
if err := NewTableFormatter().Format(&buf, proj); err != nil {
t.Fatal(err)
}
out := buf.String()
lines := strings.Split(out, "\n")
// data row is lines[3]
dataRow := lines[3]
parts := strings.Split(dataRow, "|")
// score (int, unset) → ""
scoreCell := strings.TrimSpace(parts[1])
if scoreCell != "" {
t.Errorf("unset int custom field should render as empty, got %q", scoreCell)
}
// active (bool, unset) → ""
activeCell := strings.TrimSpace(parts[2])
if activeCell != "" {
t.Errorf("unset bool custom field should render as empty, got %q", activeCell)
}
}
func TestFormatSetToZeroVsUnset(t *testing.T) {
initTestRegistries()
if err := workflow.RegisterCustomFields([]workflow.FieldDef{
{Name: "score", Type: workflow.TypeInt},
{Name: "active", Type: workflow.TypeBool},
}); err != nil {
t.Fatalf("register custom fields: %v", err)
}
t.Cleanup(func() { workflow.ClearCustomFields() })
proj := &ruki.TaskProjection{
Fields: []string{"score", "active"},
Tasks: []*task.Task{
{ID: "TIKI-Z00001", Title: "Explicit zero", Status: "ready",
CustomFields: map[string]interface{}{"score": 0, "active": false}},
{ID: "TIKI-Z00002", Title: "Unset", Status: "ready"},
},
}
var buf bytes.Buffer
if err := NewTableFormatter().Format(&buf, proj); err != nil {
t.Fatal(err)
}
out := buf.String()
lines := strings.Split(out, "\n")
// first data row (explicit zero): score=0, active=false
row1 := strings.Split(lines[3], "|")
if s := strings.TrimSpace(row1[1]); s != "0" {
t.Errorf("explicit zero int should render as '0', got %q", s)
}
if s := strings.TrimSpace(row1[2]); s != "false" {
t.Errorf("explicit false bool should render as 'false', got %q", s)
}
// second data row (unset): both empty
row2 := strings.Split(lines[4], "|")
if s := strings.TrimSpace(row2[1]); s != "" {
t.Errorf("unset int should render as empty, got %q", s)
}
if s := strings.TrimSpace(row2[2]); s != "" {
t.Errorf("unset bool should render as empty, got %q", s)
}
}

View file

@ -204,6 +204,7 @@ func TestRunQueryUpdatePersists(t *testing.T) {
updated := s.GetTask("TIKI-AAA001")
if updated == nil {
t.Fatal("task not found after update")
return
}
if updated.Title != "Updated API" {
t.Errorf("expected title 'Updated API', got %q", updated.Title)
@ -386,6 +387,7 @@ func TestRunQueryCreatePersists(t *testing.T) {
}
if found == nil {
t.Fatal("created task not found in store")
return
}
if !strings.HasPrefix(found.ID, "TIKI-") || len(found.ID) != 11 {
t.Errorf("ID = %q, want TIKI-XXXXXX format (11 chars)", found.ID)
@ -431,14 +433,15 @@ func TestRunQueryCreateTemplateDefaults(t *testing.T) {
}
if found == nil {
t.Fatal("created task not found in store")
return
}
// InMemoryStore template has tags=["idea"], so result should be ["idea", "extra"]
if len(found.Tags) != 2 || found.Tags[0] != "idea" || found.Tags[1] != "extra" {
t.Errorf("tags = %v, want [idea extra]", found.Tags)
}
// priority should be template default (7)
if found.Priority != 7 {
t.Errorf("priority = %d, want 7 (template default)", found.Priority)
// priority should be template default (3 = medium)
if found.Priority != 3 {
t.Errorf("priority = %d, want 3 (template default)", found.Priority)
}
}

View file

@ -6,32 +6,54 @@ import (
"github.com/boolean-maybe/tiki/workflow"
)
// workflowSchema adapts workflow.Fields(), config.GetStatusRegistry(), and
// config.GetTypeRegistry() into the ruki.Schema interface used by the parser
// and executor.
// workflowSchema adapts a snapshot of workflow.Fields(), config.GetStatusRegistry(),
// and config.GetTypeRegistry() into the ruki.Schema interface used by the parser
// and executor. The field catalog is snapshotted at construction time so an old
// schema never observes newly loaded custom fields through live global lookups.
type workflowSchema struct {
statusReg *workflow.StatusRegistry
typeReg *workflow.TypeRegistry
statusReg *workflow.StatusRegistry
typeReg *workflow.TypeRegistry
fieldsByName map[string]ruki.FieldSpec // snapshotted at construction
}
// NewSchema constructs a ruki.Schema backed by the loaded workflow registries.
// Must be called after config.LoadStatusRegistry().
// Snapshots the current field catalog (built-in + custom) so the schema is
// immutable after creation. Must be called after config.LoadStatusRegistry()
// (and config.LoadCustomFields() if custom fields are in use).
func NewSchema() ruki.Schema {
fields := workflow.Fields() // includes custom fields
byName := make(map[string]ruki.FieldSpec, len(fields))
for _, fd := range fields {
spec := ruki.FieldSpec{
Name: fd.Name,
Type: mapValueType(fd.Type),
Custom: fd.Custom,
}
if fd.AllowedValues != nil {
spec.AllowedValues = make([]string, len(fd.AllowedValues))
copy(spec.AllowedValues, fd.AllowedValues)
}
byName[fd.Name] = spec
}
return &workflowSchema{
statusReg: config.GetStatusRegistry(),
typeReg: config.GetTypeRegistry(),
statusReg: config.GetStatusRegistry(),
typeReg: config.GetTypeRegistry(),
fieldsByName: byName,
}
}
func (s *workflowSchema) Field(name string) (ruki.FieldSpec, bool) {
fd, ok := workflow.Field(name)
spec, ok := s.fieldsByName[name]
if !ok {
return ruki.FieldSpec{}, false
}
return ruki.FieldSpec{
Name: fd.Name,
Type: mapValueType(fd.Type),
}, true
// return a defensive copy so callers cannot mutate schema state
out := spec
if spec.AllowedValues != nil {
out.AllowedValues = make([]string, len(spec.AllowedValues))
copy(out.AllowedValues, spec.AllowedValues)
}
return out, true
}
func (s *workflowSchema) NormalizeStatus(raw string) (string, bool) {
@ -77,6 +99,8 @@ func mapValueType(wt workflow.ValueType) ruki.ValueType {
return ruki.ValueStatus
case workflow.TypeTaskType:
return ruki.ValueTaskType
case workflow.TypeEnum:
return ruki.ValueEnum
default:
return ruki.ValueString
}

View file

@ -43,6 +43,7 @@ func TestSchemaFieldMapping(t *testing.T) {
spec, ok := s.Field(tt.name)
if !ok {
t.Fatalf("Field(%q) not found", tt.name)
return
}
if spec.Type != tt.wantType {
t.Errorf("Field(%q).Type = %d, want %d", tt.name, spec.Type, tt.wantType)
@ -103,9 +104,9 @@ func TestSchemaNormalizeType(t *testing.T) {
}{
{"story", "story", true},
{"bug", "bug", true},
{"feature", "story", true}, // alias
{"task", "story", true}, // alias
{"unknown_type", "story", false}, // falls back to first type
{"feature", "", false}, // no aliases
{"task", "", false}, // no aliases
{"unknown_type", "", false}, // unknown returns empty
}
for _, tt := range tests {

View file

@ -17,6 +17,7 @@ func TestParseViewerInputGitHubBlobURL(t *testing.T) {
}
if !ok {
t.Fatal("expected viewer mode")
return
}
if spec.Kind != InputGitHub {
t.Fatalf("expected github input, got %s", spec.Kind)
@ -96,6 +97,7 @@ func TestParseViewerInputGitHubSingleSegmentFallsThrough(t *testing.T) {
}
if !ok {
t.Fatal("expected viewer mode (treated as file path)")
return
}
if spec.Kind != InputFile {
t.Fatalf("single-segment github path should fall through to file, got %s", spec.Kind)
@ -114,6 +116,7 @@ func TestParseViewerInputGitLabBasic(t *testing.T) {
}
if !ok {
t.Fatal("expected viewer mode")
return
}
if spec.Kind != InputGitLab {
t.Fatalf("expected gitlab input, got %s", spec.Kind)
@ -167,6 +170,7 @@ func TestParseViewerInputGitLabHTTPS(t *testing.T) {
}
if !ok {
t.Fatal("expected viewer mode")
return
}
if spec.Kind != InputGitLab {
t.Fatalf("expected gitlab input, got %s", spec.Kind)
@ -183,6 +187,7 @@ func TestParseViewerInputGitLabSingleSegmentFallsThrough(t *testing.T) {
}
if !ok {
t.Fatal("expected viewer mode (file path fallthrough)")
return
}
if spec.Kind != InputFile {
t.Fatalf("single-segment gitlab path should fall through to file, got %s", spec.Kind)

View file

@ -13,6 +13,7 @@ func TestParseViewerInputFile(t *testing.T) {
}
if !ok {
t.Fatalf("expected viewer mode")
return
}
if spec.Kind != InputFile {
t.Fatalf("expected file input, got %s", spec.Kind)
@ -36,6 +37,7 @@ func TestParseViewerInputStdin(t *testing.T) {
}
if !ok {
t.Fatalf("expected viewer mode")
return
}
if spec.Kind != InputStdin {
t.Fatalf("expected stdin input, got %s", spec.Kind)
@ -49,6 +51,7 @@ func TestParseViewerInputURL(t *testing.T) {
}
if !ok {
t.Fatalf("expected viewer mode")
return
}
if spec.Kind != InputURL {
t.Fatalf("expected url input, got %s", spec.Kind)
@ -65,6 +68,7 @@ func TestParseViewerInputGitHub(t *testing.T) {
}
if !ok {
t.Fatalf("expected viewer mode")
return
}
if spec.Kind != InputGitHub {
t.Fatalf("expected github input, got %s", spec.Kind)
@ -100,6 +104,7 @@ func TestParseViewerInputFlags(t *testing.T) {
}
if !ok {
t.Fatalf("expected viewer mode")
return
}
if spec.Kind != InputFile {
t.Fatalf("expected file input, got %s", spec.Kind)
@ -113,6 +118,7 @@ func TestParseViewerInputLogLevelMissingValue(t *testing.T) {
}
if !ok {
t.Fatalf("expected viewer mode")
return
}
if spec.Kind != InputFile {
t.Fatalf("expected file input, got %s", spec.Kind)
@ -165,6 +171,7 @@ func TestParseViewerInputImageFile(t *testing.T) {
}
if !ok {
t.Fatalf("expected viewer mode")
return
}
if spec.Kind != InputImage {
t.Fatalf("expected image input, got %s", spec.Kind)

43
llms.txt Normal file
View file

@ -0,0 +1,43 @@
# tiki
> Terminal-first Markdown workspace for tasks, docs, and notes — stored in git, queried with ruki, extended with plugins and AI skills
tiki manages two kinds of content: **tikis** (tasks/tickets as Markdown+YAML in `.doc/tiki/`) and **dokis** (documentation as Markdown in `.doc/doki/`). It includes a standalone Markdown viewer with images, Mermaid diagrams, and link navigation. Tasks support priority, status, assignee, size, tags, and dependencies. Views are Kanban/Scrum boards with burndown charts.
Everything beyond the core is plugin-driven. `workflow.yaml` defines statuses and plugin views. Each plugin uses **ruki** — an SQL-like language for selecting, filtering, sorting, and updating tasks. Triggers (ruki statements that fire on task changes) enforce workflow rules. AI skills let Claude Code, Gemini CLI, Codex, and Opencode manage tikis and dokis through natural language.
## Docs
- [Quick Start](.doc/doki/doc/quick-start.md): getting started — markdown viewer, file management, tiki board, keyboard shortcuts
- [Tiki Format](.doc/doki/doc/tiki-format.md): .doc directory structure, tiki Markdown+YAML spec, field types
- [Configuration](.doc/doki/doc/config.md): config.yaml, workflow.yaml, config directories, settings reference
- [Custom Statuses and Types](.doc/doki/doc/custom-status-type.md): type and status definition rules, validation, key normalization, inheritance
- [Customization](.doc/doki/doc/customization.md): workflow statuses, plugin definitions, lanes, actions, views
- [Installation](.doc/doki/doc/install.md): macOS, Linux, Windows, manual install
- [Markdown Viewer](.doc/doki/doc/markdown-viewer.md): pager commands, link navigation, image/diagram rendering
- [Quick Capture](.doc/doki/doc/quick-capture.md): creating tikis from CLI without opening the TUI
## Ruki Language
- [Quick Start](.doc/doki/doc/ruki/quick-start.md): mental model, CRUD operations, conditions, pipes, triggers
- [Examples](.doc/doki/doc/ruki/examples.md): practical select/update/trigger statements with explanations
- [Syntax](.doc/doki/doc/ruki/syntax.md): lexical structure, grammar, EBNF, operator tokens
- [Semantics](.doc/doki/doc/ruki/semantics.md): statement behavior, trigger execution, expression evaluation
- [Triggers](.doc/doki/doc/ruki/triggers.md): trigger configuration, patterns, execution pipeline, error handling
- [Types and Values](.doc/doki/doc/ruki/types-and-values.md): value types, field catalog, type coercion
- [Operators and Built-ins](.doc/doki/doc/ruki/operators-and-builtins.md): operator precedence, built-in functions
## AI Skills
- [AI Collaboration](.doc/doki/doc/ai.md): overview of AI integration, supported tools, prompt conventions
- [Tiki Skill](ai/skills/tiki/SKILL.md): AI skill for creating, querying, updating, and deleting tikis
- [Doki Skill](ai/skills/doki/SKILL.md): AI skill for managing documentation files
## Optional
- [Customization Examples](.doc/doki/doc/ideas/plugins.md): plugin recipes — assign, tags, Claude integration, scoped views
- [Trigger Ideas](.doc/doki/doc/ideas/triggers.md): workflow trigger patterns — WIP limits, auto-assignment, staleness
- [Themes](.doc/doki/doc/themes.md): theme configuration and available palettes
- [Image Requirements](.doc/doki/doc/image-requirements.md): terminal image protocol support, SVG rendering dependencies
- [Validation and Errors](.doc/doki/doc/ruki/validation-and-errors.md): ruki validation layers, error types, diagnostics
- [Contributing](CONTRIBUTING.md): development setup, PR workflow, coding conventions

22
main.go
View file

@ -62,6 +62,11 @@ func main() {
os.Exit(1)
}
// Handle workflow command
if len(os.Args) > 1 && os.Args[1] == "workflow" {
os.Exit(runWorkflow(os.Args[2:]))
}
// Handle exec command: execute ruki statement and exit
if len(os.Args) > 1 && os.Args[1] == "exec" {
os.Exit(runExec(os.Args[2:]))
@ -83,7 +88,7 @@ func main() {
// Handle viewer mode (standalone markdown viewer)
// "init" is reserved to prevent treating it as a markdown file
viewerInput, runViewer, err := viewer.ParseViewerInput(os.Args[1:], map[string]struct{}{"init": {}, "demo": {}, "exec": {}})
viewerInput, runViewer, err := viewer.ParseViewerInput(os.Args[1:], map[string]struct{}{"init": {}, "demo": {}, "exec": {}, "workflow": {}})
if err != nil {
if errors.Is(err, viewer.ErrMultipleInputs) {
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
@ -121,10 +126,11 @@ func main() {
defer result.App.Stop()
defer result.HeaderWidget.Cleanup()
defer result.RootLayout.Cleanup()
defer result.ActionPalette.Cleanup()
defer result.CancelFunc()
// Run application
if err := app.Run(result.App, result.RootLayout); err != nil {
if err := app.Run(result.App, result.AppRoot); err != nil {
slog.Error("application error", "error", err)
os.Exit(1)
}
@ -178,7 +184,11 @@ func runDemo() error {
return nil
}
// exit codes for tiki exec
// errHelpRequested is returned by arg parsers when the user asks for help.
// Callers should print usage and exit cleanly — not treat it as a real error.
var errHelpRequested = errors.New("help requested")
// exit codes for CLI subcommands
const (
exitOK = 0
exitInternal = 1
@ -216,8 +226,8 @@ func runExec(args []string) int {
_, _ = fmt.Fprintf(os.Stderr, "warning: install default workflow: %v\n", err)
}
if err := config.LoadStatusRegistry(); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "error: load status registry: %v\n", err)
if err := config.LoadWorkflowRegistries(); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "error: load workflow registries: %v\n", err)
return exitStartupFailure
}
@ -254,6 +264,8 @@ Usage:
tiki Launch TUI in initialized repo
tiki init Initialize project in current git repo
tiki exec '<statement>' Execute a ruki query and exit
tiki workflow reset [target] Reset config files (--global, --local, --current)
tiki workflow install <name> Install a workflow (--global, --local, --current)
tiki demo Clone demo project and launch TUI
tiki file.md/URL View markdown file or image
echo "Title" | tiki Create task from piped input

View file

@ -0,0 +1,80 @@
package model
import "sync"
// ActionPaletteConfig manages the visibility state of the action palette overlay.
// The palette reads view metadata from ViewContext and action rows from live
// controller registries — this config only tracks open/close state.
type ActionPaletteConfig struct {
mu sync.RWMutex
visible bool
listeners map[int]func()
nextListener int
}
// NewActionPaletteConfig creates a new palette config (hidden by default).
func NewActionPaletteConfig() *ActionPaletteConfig {
return &ActionPaletteConfig{
listeners: make(map[int]func()),
nextListener: 1,
}
}
// IsVisible returns whether the palette is currently visible.
func (pc *ActionPaletteConfig) IsVisible() bool {
pc.mu.RLock()
defer pc.mu.RUnlock()
return pc.visible
}
// SetVisible sets the palette visibility and notifies listeners on change.
func (pc *ActionPaletteConfig) SetVisible(visible bool) {
pc.mu.Lock()
changed := pc.visible != visible
pc.visible = visible
pc.mu.Unlock()
if changed {
pc.notifyListeners()
}
}
// ToggleVisible toggles the palette visibility.
func (pc *ActionPaletteConfig) ToggleVisible() {
pc.mu.Lock()
pc.visible = !pc.visible
pc.mu.Unlock()
pc.notifyListeners()
}
// AddListener registers a callback for palette config changes.
// Returns a listener ID for removal.
func (pc *ActionPaletteConfig) AddListener(listener func()) int {
pc.mu.Lock()
defer pc.mu.Unlock()
id := pc.nextListener
pc.nextListener++
pc.listeners[id] = listener
return id
}
// RemoveListener removes a previously registered listener by ID.
func (pc *ActionPaletteConfig) RemoveListener(id int) {
pc.mu.Lock()
defer pc.mu.Unlock()
delete(pc.listeners, id)
}
func (pc *ActionPaletteConfig) notifyListeners() {
pc.mu.RLock()
listeners := make([]func(), 0, len(pc.listeners))
for _, listener := range pc.listeners {
listeners = append(listeners, listener)
}
pc.mu.RUnlock()
for _, listener := range listeners {
listener()
}
}

View file

@ -0,0 +1,87 @@
package model
import (
"testing"
)
func TestActionPaletteConfig_DefaultHidden(t *testing.T) {
pc := NewActionPaletteConfig()
if pc.IsVisible() {
t.Error("palette should be hidden by default")
}
}
func TestActionPaletteConfig_SetVisible(t *testing.T) {
pc := NewActionPaletteConfig()
pc.SetVisible(true)
if !pc.IsVisible() {
t.Error("palette should be visible after SetVisible(true)")
}
pc.SetVisible(false)
if pc.IsVisible() {
t.Error("palette should be hidden after SetVisible(false)")
}
}
func TestActionPaletteConfig_ToggleVisible(t *testing.T) {
pc := NewActionPaletteConfig()
pc.ToggleVisible()
if !pc.IsVisible() {
t.Error("palette should be visible after first toggle")
}
pc.ToggleVisible()
if pc.IsVisible() {
t.Error("palette should be hidden after second toggle")
}
}
func TestActionPaletteConfig_ListenerNotifiedOnChange(t *testing.T) {
pc := NewActionPaletteConfig()
called := 0
pc.AddListener(func() { called++ })
pc.SetVisible(true)
if called != 1 {
t.Errorf("listener should be called once on change, got %d", called)
}
// no-op (already visible)
pc.SetVisible(true)
if called != 1 {
t.Errorf("listener should not be called on no-op SetVisible, got %d", called)
}
pc.SetVisible(false)
if called != 2 {
t.Errorf("listener should be called on hide, got %d", called)
}
}
func TestActionPaletteConfig_ToggleAlwaysNotifies(t *testing.T) {
pc := NewActionPaletteConfig()
called := 0
pc.AddListener(func() { called++ })
pc.ToggleVisible()
pc.ToggleVisible()
if called != 2 {
t.Errorf("expected 2 notifications from toggle, got %d", called)
}
}
func TestActionPaletteConfig_RemoveListener(t *testing.T) {
pc := NewActionPaletteConfig()
called := 0
id := pc.AddListener(func() { called++ })
pc.SetVisible(true)
if called != 1 {
t.Errorf("expected 1 call, got %d", called)
}
pc.RemoveListener(id)
pc.SetVisible(false)
if called != 1 {
t.Errorf("expected no more calls after removal, got %d", called)
}
}

View file

@ -25,17 +25,13 @@ type StatValue struct {
Priority int
}
// HeaderConfig manages ALL header state - both content AND visibility.
// HeaderConfig manages header visibility and burndown state.
// View identity and actions are now in ViewContext.
// Thread-safe model that notifies listeners when state changes.
type HeaderConfig struct {
mu sync.RWMutex
// Content state
viewActions []HeaderAction
pluginActions []HeaderAction
viewName string // current view name for info section
viewDescription string // current view description for info section
burndown []store.BurndownPoint
burndown []store.BurndownPoint
// Visibility state
visible bool // current header visibility (may be overridden by fullscreen view)
@ -56,59 +52,6 @@ func NewHeaderConfig() *HeaderConfig {
}
}
// SetViewActions updates the view-specific header actions
func (hc *HeaderConfig) SetViewActions(actions []HeaderAction) {
hc.mu.Lock()
hc.viewActions = actions
hc.mu.Unlock()
hc.notifyListeners()
}
// GetViewActions returns the current view's header actions
func (hc *HeaderConfig) GetViewActions() []HeaderAction {
hc.mu.RLock()
defer hc.mu.RUnlock()
return hc.viewActions
}
// SetPluginActions updates the plugin navigation header actions
func (hc *HeaderConfig) SetPluginActions(actions []HeaderAction) {
hc.mu.Lock()
hc.pluginActions = actions
hc.mu.Unlock()
hc.notifyListeners()
}
// GetPluginActions returns the plugin navigation header actions
func (hc *HeaderConfig) GetPluginActions() []HeaderAction {
hc.mu.RLock()
defer hc.mu.RUnlock()
return hc.pluginActions
}
// SetViewInfo sets the current view name and description for the header info section
func (hc *HeaderConfig) SetViewInfo(name, description string) {
hc.mu.Lock()
hc.viewName = name
hc.viewDescription = description
hc.mu.Unlock()
hc.notifyListeners()
}
// GetViewName returns the current view name
func (hc *HeaderConfig) GetViewName() string {
hc.mu.RLock()
defer hc.mu.RUnlock()
return hc.viewName
}
// GetViewDescription returns the current view description
func (hc *HeaderConfig) GetViewDescription() string {
hc.mu.RLock()
defer hc.mu.RUnlock()
return hc.viewDescription
}
// SetBurndown updates the burndown chart data
func (hc *HeaderConfig) SetBurndown(points []store.BurndownPoint) {
hc.mu.Lock()

View file

@ -6,8 +6,6 @@ import (
"time"
"github.com/boolean-maybe/tiki/store"
"github.com/gdamore/tcell/v2"
)
func TestNewHeaderConfig(t *testing.T) {
@ -17,7 +15,6 @@ func TestNewHeaderConfig(t *testing.T) {
t.Fatal("NewHeaderConfig() returned nil")
}
// Initial visibility should be true
if !hc.IsVisible() {
t.Error("initial IsVisible() = false, want true")
}
@ -26,125 +23,11 @@ func TestNewHeaderConfig(t *testing.T) {
t.Error("initial GetUserPreference() = false, want true")
}
// Initial collections should be empty
if len(hc.GetViewActions()) != 0 {
t.Error("initial GetViewActions() should be empty")
}
if len(hc.GetPluginActions()) != 0 {
t.Error("initial GetPluginActions() should be empty")
}
if hc.GetViewName() != "" {
t.Error("initial GetViewName() should be empty")
}
if hc.GetViewDescription() != "" {
t.Error("initial GetViewDescription() should be empty")
}
if len(hc.GetBurndown()) != 0 {
t.Error("initial GetBurndown() should be empty")
}
}
func TestHeaderConfig_ViewActions(t *testing.T) {
hc := NewHeaderConfig()
actions := []HeaderAction{
{
ID: "action1",
Key: tcell.KeyEnter,
Label: "Enter",
ShowInHeader: true,
},
{
ID: "action2",
Key: tcell.KeyEscape,
Label: "Esc",
ShowInHeader: true,
},
}
hc.SetViewActions(actions)
got := hc.GetViewActions()
if len(got) != 2 {
t.Errorf("len(GetViewActions()) = %d, want 2", len(got))
}
if got[0].ID != "action1" {
t.Errorf("ViewActions[0].ID = %q, want %q", got[0].ID, "action1")
}
if got[1].ID != "action2" {
t.Errorf("ViewActions[1].ID = %q, want %q", got[1].ID, "action2")
}
}
func TestHeaderConfig_PluginActions(t *testing.T) {
hc := NewHeaderConfig()
actions := []HeaderAction{
{
ID: "plugin1",
Rune: '1',
Label: "Plugin 1",
ShowInHeader: true,
},
}
hc.SetPluginActions(actions)
got := hc.GetPluginActions()
if len(got) != 1 {
t.Errorf("len(GetPluginActions()) = %d, want 1", len(got))
}
if got[0].ID != "plugin1" {
t.Errorf("PluginActions[0].ID = %q, want %q", got[0].ID, "plugin1")
}
}
func TestHeaderConfig_ViewInfo(t *testing.T) {
hc := NewHeaderConfig()
hc.SetViewInfo("Kanban", "Tasks moving through stages")
if got := hc.GetViewName(); got != "Kanban" {
t.Errorf("GetViewName() = %q, want %q", got, "Kanban")
}
if got := hc.GetViewDescription(); got != "Tasks moving through stages" {
t.Errorf("GetViewDescription() = %q, want %q", got, "Tasks moving through stages")
}
// update overwrites
hc.SetViewInfo("Backlog", "Upcoming tasks")
if got := hc.GetViewName(); got != "Backlog" {
t.Errorf("GetViewName() after update = %q, want %q", got, "Backlog")
}
if got := hc.GetViewDescription(); got != "Upcoming tasks" {
t.Errorf("GetViewDescription() after update = %q, want %q", got, "Upcoming tasks")
}
}
func TestHeaderConfig_ViewInfoEmptyDescription(t *testing.T) {
hc := NewHeaderConfig()
hc.SetViewInfo("Task Detail", "")
if got := hc.GetViewName(); got != "Task Detail" {
t.Errorf("GetViewName() = %q, want %q", got, "Task Detail")
}
if got := hc.GetViewDescription(); got != "" {
t.Errorf("GetViewDescription() = %q, want empty", got)
}
}
func TestHeaderConfig_Burndown(t *testing.T) {
hc := NewHeaderConfig()
@ -177,18 +60,15 @@ func TestHeaderConfig_Burndown(t *testing.T) {
func TestHeaderConfig_Visibility(t *testing.T) {
hc := NewHeaderConfig()
// Default should be visible
if !hc.IsVisible() {
t.Error("default IsVisible() = false, want true")
}
// Set invisible
hc.SetVisible(false)
if hc.IsVisible() {
t.Error("IsVisible() after SetVisible(false) = true, want false")
}
// Set visible again
hc.SetVisible(true)
if !hc.IsVisible() {
t.Error("IsVisible() after SetVisible(true) = false, want true")
@ -198,12 +78,10 @@ func TestHeaderConfig_Visibility(t *testing.T) {
func TestHeaderConfig_UserPreference(t *testing.T) {
hc := NewHeaderConfig()
// Default preference should be true
if !hc.GetUserPreference() {
t.Error("default GetUserPreference() = false, want true")
}
// Set preference
hc.SetUserPreference(false)
if hc.GetUserPreference() {
t.Error("GetUserPreference() after SetUserPreference(false) = true, want false")
@ -218,27 +96,21 @@ func TestHeaderConfig_UserPreference(t *testing.T) {
func TestHeaderConfig_ToggleUserPreference(t *testing.T) {
hc := NewHeaderConfig()
// Initial state
initialPref := hc.GetUserPreference()
initialVisible := hc.IsVisible()
// Toggle
hc.ToggleUserPreference()
// Preference should be toggled
if hc.GetUserPreference() == initialPref {
t.Error("ToggleUserPreference() did not toggle preference")
}
// Visible should match new preference
if hc.IsVisible() != hc.GetUserPreference() {
t.Error("visible state should match preference after toggle")
}
// Toggle back
hc.ToggleUserPreference()
// Should return to initial state
if hc.GetUserPreference() != initialPref {
t.Error("ToggleUserPreference() twice did not return to initial state")
}
@ -258,14 +130,10 @@ func TestHeaderConfig_ListenerNotification(t *testing.T) {
listenerID := hc.AddListener(listener)
// Test various operations trigger notification
tests := []struct {
name string
action func()
}{
{"SetViewActions", func() { hc.SetViewActions([]HeaderAction{{ID: "test"}}) }},
{"SetPluginActions", func() { hc.SetPluginActions([]HeaderAction{{ID: "test"}}) }},
{"SetViewInfo", func() { hc.SetViewInfo("Test", "desc") }},
{"SetBurndown", func() { hc.SetBurndown([]store.BurndownPoint{{Date: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}}) }},
{"SetVisible", func() { hc.SetVisible(false); hc.SetVisible(true) }},
{"ToggleUserPreference", func() { hc.ToggleUserPreference() }},
@ -284,11 +152,10 @@ func TestHeaderConfig_ListenerNotification(t *testing.T) {
})
}
// Remove listener
hc.RemoveListener(listenerID)
called = false
hc.SetViewActions([]HeaderAction{{ID: "test2"}})
hc.SetBurndown(nil)
time.Sleep(10 * time.Millisecond)
@ -305,8 +172,7 @@ func TestHeaderConfig_SetVisibleNoChangeNoNotify(t *testing.T) {
callCount++
})
// Set to current value (no change)
hc.SetVisible(true) // Already true by default
hc.SetVisible(true) // already true by default
time.Sleep(10 * time.Millisecond)
@ -314,7 +180,6 @@ func TestHeaderConfig_SetVisibleNoChangeNoNotify(t *testing.T) {
t.Error("listener called when visibility didn't change")
}
// Now change it
hc.SetVisible(false)
time.Sleep(10 * time.Millisecond)
@ -344,8 +209,7 @@ func TestHeaderConfig_MultipleListeners(t *testing.T) {
id1 := hc.AddListener(listener1)
id2 := hc.AddListener(listener2)
// Both should be notified
hc.SetViewInfo("Test", "desc")
hc.SetBurndown(nil)
time.Sleep(10 * time.Millisecond)
@ -355,10 +219,9 @@ func TestHeaderConfig_MultipleListeners(t *testing.T) {
}
mu.Unlock()
// Remove one
hc.RemoveListener(id1)
hc.SetViewInfo("Test2", "desc2")
hc.SetBurndown(nil)
time.Sleep(10 * time.Millisecond)
@ -368,10 +231,9 @@ func TestHeaderConfig_MultipleListeners(t *testing.T) {
}
mu.Unlock()
// Remove second
hc.RemoveListener(id2)
hc.SetViewInfo("Test3", "desc3")
hc.SetBurndown(nil)
time.Sleep(10 * time.Millisecond)
@ -387,24 +249,6 @@ func TestHeaderConfig_ConcurrentAccess(t *testing.T) {
done := make(chan bool)
// Writer goroutine - actions
go func() {
for i := range 50 {
hc.SetViewActions([]HeaderAction{{ID: string(rune('a' + i%26))}})
hc.SetPluginActions([]HeaderAction{{ID: string(rune('A' + i%26))}})
}
done <- true
}()
// Writer goroutine - view info
go func() {
for i := range 50 {
hc.SetViewInfo("View"+string(rune('a'+i%26)), "desc")
}
done <- true
}()
// Writer goroutine - visibility
go func() {
for i := range 50 {
hc.SetVisible(i%2 == 0)
@ -415,13 +259,15 @@ func TestHeaderConfig_ConcurrentAccess(t *testing.T) {
done <- true
}()
// Reader goroutine
go func() {
for range 50 {
hc.SetBurndown(nil)
}
done <- true
}()
go func() {
for range 100 {
_ = hc.GetViewActions()
_ = hc.GetPluginActions()
_ = hc.GetViewName()
_ = hc.GetViewDescription()
_ = hc.GetBurndown()
_ = hc.IsVisible()
_ = hc.GetUserPreference()
@ -429,30 +275,14 @@ func TestHeaderConfig_ConcurrentAccess(t *testing.T) {
done <- true
}()
// Wait for all
for range 4 {
for range 3 {
<-done
}
// If we get here without panic, test passes
}
func TestHeaderConfig_EmptyCollections(t *testing.T) {
func TestHeaderConfig_EmptyBurndown(t *testing.T) {
hc := NewHeaderConfig()
// Set empty actions
hc.SetViewActions([]HeaderAction{})
if len(hc.GetViewActions()) != 0 {
t.Error("GetViewActions() should return empty slice")
}
// Set nil actions
hc.SetPluginActions(nil)
if len(hc.GetPluginActions()) != 0 {
t.Error("GetPluginActions() with nil input should return empty slice")
}
// Set empty burndown
hc.SetBurndown([]store.BurndownPoint{})
if len(hc.GetBurndown()) != 0 {
t.Error("GetBurndown() should return empty slice")

View file

@ -13,6 +13,7 @@ func TestNewPluginConfig(t *testing.T) {
if pc == nil {
t.Fatal("NewPluginConfig() returned nil")
return
}
if pc.GetPluginName() != "testplugin" {

96
model/view_context.go Normal file
View file

@ -0,0 +1,96 @@
package model
import "sync"
// ViewContext holds the active view's identity and action DTOs.
// Subscribed to by HeaderWidget and ActionPalette. Written by RootLayout
// via syncViewContextFromView when the active view or its actions change.
type ViewContext struct {
mu sync.RWMutex
viewID ViewID
viewName string
viewDescription string
viewActions []HeaderAction
pluginActions []HeaderAction
listeners map[int]func()
nextListener int
}
func NewViewContext() *ViewContext {
return &ViewContext{
listeners: make(map[int]func()),
nextListener: 1,
}
}
// SetFromView atomically updates all view-context fields and fires exactly one notification.
func (vc *ViewContext) SetFromView(id ViewID, name, description string, viewActions, pluginActions []HeaderAction) {
vc.mu.Lock()
vc.viewID = id
vc.viewName = name
vc.viewDescription = description
vc.viewActions = viewActions
vc.pluginActions = pluginActions
vc.mu.Unlock()
vc.notifyListeners()
}
func (vc *ViewContext) GetViewID() ViewID {
vc.mu.RLock()
defer vc.mu.RUnlock()
return vc.viewID
}
func (vc *ViewContext) GetViewName() string {
vc.mu.RLock()
defer vc.mu.RUnlock()
return vc.viewName
}
func (vc *ViewContext) GetViewDescription() string {
vc.mu.RLock()
defer vc.mu.RUnlock()
return vc.viewDescription
}
func (vc *ViewContext) GetViewActions() []HeaderAction {
vc.mu.RLock()
defer vc.mu.RUnlock()
return vc.viewActions
}
func (vc *ViewContext) GetPluginActions() []HeaderAction {
vc.mu.RLock()
defer vc.mu.RUnlock()
return vc.pluginActions
}
func (vc *ViewContext) AddListener(listener func()) int {
vc.mu.Lock()
defer vc.mu.Unlock()
id := vc.nextListener
vc.nextListener++
vc.listeners[id] = listener
return id
}
func (vc *ViewContext) RemoveListener(id int) {
vc.mu.Lock()
defer vc.mu.Unlock()
delete(vc.listeners, id)
}
func (vc *ViewContext) notifyListeners() {
vc.mu.RLock()
listeners := make([]func(), 0, len(vc.listeners))
for _, listener := range vc.listeners {
listeners = append(listeners, listener)
}
vc.mu.RUnlock()
for _, listener := range listeners {
listener()
}
}

View file

@ -0,0 +1,93 @@
package model
import (
"sync/atomic"
"testing"
)
func TestViewContext_SetFromView_SingleNotification(t *testing.T) {
vc := NewViewContext()
var count int32
vc.AddListener(func() { atomic.AddInt32(&count, 1) })
vc.SetFromView("plugin:Kanban", "Kanban", "desc", nil, nil)
if got := atomic.LoadInt32(&count); got != 1 {
t.Errorf("expected exactly 1 notification, got %d", got)
}
}
func TestViewContext_Getters(t *testing.T) {
vc := NewViewContext()
viewActions := []HeaderAction{{ID: "edit", Label: "Edit"}}
pluginActions := []HeaderAction{{ID: "plugin:Kanban", Label: "Kanban"}}
vc.SetFromView(TaskDetailViewID, "Tiki Detail", "desc", viewActions, pluginActions)
if vc.GetViewID() != TaskDetailViewID {
t.Errorf("expected %v, got %v", TaskDetailViewID, vc.GetViewID())
}
if vc.GetViewName() != "Tiki Detail" {
t.Errorf("expected 'Tiki Detail', got %q", vc.GetViewName())
}
if vc.GetViewDescription() != "desc" {
t.Errorf("expected 'desc', got %q", vc.GetViewDescription())
}
if len(vc.GetViewActions()) != 1 || vc.GetViewActions()[0].ID != "edit" {
t.Errorf("unexpected view actions: %v", vc.GetViewActions())
}
if len(vc.GetPluginActions()) != 1 || vc.GetPluginActions()[0].ID != "plugin:Kanban" {
t.Errorf("unexpected plugin actions: %v", vc.GetPluginActions())
}
}
func TestViewContext_RemoveListener(t *testing.T) {
vc := NewViewContext()
var count int32
id := vc.AddListener(func() { atomic.AddInt32(&count, 1) })
vc.SetFromView("v1", "n", "d", nil, nil)
if atomic.LoadInt32(&count) != 1 {
t.Fatal("listener should have fired once")
}
vc.RemoveListener(id)
vc.SetFromView("v2", "n", "d", nil, nil)
if atomic.LoadInt32(&count) != 1 {
t.Error("listener should not fire after removal")
}
}
func TestViewContext_MultipleListeners(t *testing.T) {
vc := NewViewContext()
var a, b int32
vc.AddListener(func() { atomic.AddInt32(&a, 1) })
vc.AddListener(func() { atomic.AddInt32(&b, 1) })
vc.SetFromView("v1", "n", "d", nil, nil)
if atomic.LoadInt32(&a) != 1 {
t.Errorf("listener A: expected 1, got %d", atomic.LoadInt32(&a))
}
if atomic.LoadInt32(&b) != 1 {
t.Errorf("listener B: expected 1, got %d", atomic.LoadInt32(&b))
}
}
func TestViewContext_ZeroValueIsEmpty(t *testing.T) {
vc := NewViewContext()
if vc.GetViewID() != "" {
t.Errorf("expected empty view ID, got %v", vc.GetViewID())
}
if vc.GetViewName() != "" {
t.Errorf("expected empty view name, got %q", vc.GetViewName())
}
if vc.GetViewActions() != nil {
t.Errorf("expected nil view actions, got %v", vc.GetViewActions())
}
if vc.GetPluginActions() != nil {
t.Errorf("expected nil plugin actions, got %v", vc.GetPluginActions())
}
}

View file

@ -83,13 +83,18 @@ type PluginActionConfig struct {
Key string `yaml:"key" mapstructure:"key"`
Label string `yaml:"label" mapstructure:"label"`
Action string `yaml:"action" mapstructure:"action"`
Hot *bool `yaml:"hot,omitempty" mapstructure:"hot"`
Input string `yaml:"input,omitempty" mapstructure:"input"`
}
// PluginAction represents a parsed shortcut action bound to a key.
type PluginAction struct {
Rune rune
Label string
Action *ruki.ValidatedStatement
Rune rune
Label string
Action *ruki.ValidatedStatement
ShowInHeader bool
InputType ruki.ValueType
HasInput bool
}
// PluginLaneConfig represents a lane in YAML or config definitions.

View file

@ -7,32 +7,63 @@ import (
"strings"
"sync"
"github.com/boolean-maybe/tiki/config"
"github.com/boolean-maybe/tiki/workflow"
)
// fieldNameMap maps lowercase field names to their canonical form.
// Built once from workflow.Fields() + the "tag"→"tags" alias.
// Rebuilt from workflow.Fields() on demand; invalidated when custom fields change.
var (
fieldNameMap map[string]string
fieldNameOnce sync.Once
fieldNameMap map[string]string
fieldNameMu sync.RWMutex
)
func init() {
workflow.OnCustomFieldsChanged(InvalidateFieldNameCache)
}
func buildFieldNameMap() {
fieldNameOnce.Do(func() {
fields := workflow.Fields()
fieldNameMap = make(map[string]string, len(fields)+1)
for _, f := range fields {
fieldNameMap[strings.ToLower(f.Name)] = f.Name
}
fieldNameMap["tag"] = "tags" // singular alias
})
fieldNameMu.RLock()
if fieldNameMap != nil {
fieldNameMu.RUnlock()
return
}
fieldNameMu.RUnlock()
fieldNameMu.Lock()
defer fieldNameMu.Unlock()
if fieldNameMap != nil {
return // double-check
}
rebuildFieldNameMapLocked()
}
func rebuildFieldNameMapLocked() {
fields := workflow.Fields()
m := make(map[string]string, len(fields)+1)
for _, f := range fields {
m[strings.ToLower(f.Name)] = f.Name
}
m["tag"] = "tags" // singular alias
fieldNameMap = m
}
// InvalidateFieldNameCache clears the cached field-name lookup so the next
// legacy conversion picks up newly registered custom fields.
func InvalidateFieldNameCache() {
fieldNameMu.Lock()
fieldNameMap = nil
fieldNameMu.Unlock()
}
// normalizeFieldName returns the canonical field name for a case-insensitive input.
// Returns the input unchanged if not found in the catalog.
func normalizeFieldName(name string) string {
buildFieldNameMap()
if canonical, ok := fieldNameMap[strings.ToLower(name)]; ok {
fieldNameMu.RLock()
canonical, ok := fieldNameMap[strings.ToLower(name)]
fieldNameMu.RUnlock()
if ok {
return canonical
}
return name
@ -56,6 +87,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 +170,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.
@ -273,7 +311,7 @@ func expandInClause(fieldName, valuesStr string) string {
}
// scalar field: just lowercase the IN
canonical := normalizeFieldName(fieldName)
return canonical + " in [" + normalizeQuotedValues(valuesStr) + "]"
return canonical + " in [" + normalizeQuotedValues(valuesStr, canonical) + "]"
}
// expandNotInClause handles field NOT IN [...] expansion.
@ -293,11 +331,11 @@ func expandNotInClause(fieldName, valuesStr string) string {
return "(" + strings.Join(parts, " and ") + ")"
}
canonical := normalizeFieldName(fieldName)
return canonical + " not in [" + normalizeQuotedValues(valuesStr) + "]"
return canonical + " not in [" + normalizeQuotedValues(valuesStr, canonical) + "]"
}
// parseBracketValues parses a comma-separated list of values from inside [...].
// Ensures all values are double-quoted.
// All values are double-quoted as string literals.
func parseBracketValues(s string) []string {
parts := strings.Split(s, ",")
result := make([]string, 0, len(parts))
@ -306,15 +344,16 @@ func parseBracketValues(s string) []string {
if p == "" {
continue
}
// strip existing quotes and re-quote with double quotes
p = strings.Trim(p, `"'`)
result = append(result, `"`+p+`"`)
}
return result
}
// normalizeQuotedValues ensures all values in a comma-separated list use double quotes.
func normalizeQuotedValues(s string) string {
// normalizeQuotedValues ensures all values in a comma-separated list use
// type-aware quoting based on the target field.
func normalizeQuotedValues(s string, fieldName string) string {
ft := lookupFieldType(fieldName)
parts := strings.Split(s, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
@ -323,7 +362,7 @@ func normalizeQuotedValues(s string) string {
continue
}
p = strings.Trim(p, `"'`)
result = append(result, `"`+p+`"`)
result = append(result, quoteListElementForField(p, ft))
}
return strings.Join(result, ", ")
}
@ -447,13 +486,15 @@ func convertActionSegment(seg string) (string, error) {
if idx := strings.Index(seg, "+="); idx > 0 {
fieldName := normalizeFieldName(strings.TrimSpace(seg[:idx]))
value := strings.TrimSpace(seg[idx+2:])
converted := convertBracketValues(value)
ft := lookupFieldType(fieldName)
converted := convertBracketValuesTyped(value, ft)
return fieldName + "=" + fieldName + "+" + converted, nil
}
if idx := strings.Index(seg, "-="); idx > 0 {
fieldName := normalizeFieldName(strings.TrimSpace(seg[:idx]))
value := strings.TrimSpace(seg[idx+2:])
converted := convertBracketValues(value)
ft := lookupFieldType(fieldName)
converted := convertBracketValuesTyped(value, ft)
return fieldName + "=" + fieldName + "-" + converted, nil
}
@ -473,8 +514,9 @@ func convertActionSegment(seg string) (string, error) {
value = reSingleQuoted.ReplaceAllString(value, `"$1"`)
// convert CURRENT_USER
value = reCurrentUser.ReplaceAllString(value, "user()")
// quote bare identifiers
value = quoteIfBareIdentifier(value)
// quote with type awareness
ft := lookupFieldType(fieldName)
value = quoteValueForField(value, ft)
return fieldName + "=" + value, nil
}
@ -497,14 +539,24 @@ func convertBracketValues(s string) string {
if p == "" {
continue
}
// strip existing quotes
p = strings.Trim(p, `"'`)
// re-quote — all values in action brackets must be strings
converted = append(converted, `"`+p+`"`)
converted = append(converted, quoteListElement(p))
}
return "[" + strings.Join(converted, ", ") + "]"
}
// quoteListElement quotes a list element value for ruki syntax.
// Bool literals and numerics stay bare; everything else gets double-quoted.
func quoteListElement(value string) string {
if strings.EqualFold(value, "true") || strings.EqualFold(value, "false") {
return strings.ToLower(value)
}
if reNumeric.MatchString(value) {
return value
}
return `"` + value + `"`
}
var (
// matches function calls like now(), user(), id()
reFunctionCall = regexp.MustCompile(`^\w+\(\)$`)
@ -516,6 +568,7 @@ var (
// quoteIfBareIdentifier wraps a value in double quotes if it's a bare identifier
// (not numeric, not a function call, not already quoted).
// Bool literals (true/false) are left bare so the parser produces BoolLiteral nodes.
func quoteIfBareIdentifier(value string) string {
if value == "" {
return value
@ -529,12 +582,111 @@ func quoteIfBareIdentifier(value string) string {
if reFunctionCall.MatchString(value) {
return value // function call
}
if strings.EqualFold(value, "true") || strings.EqualFold(value, "false") {
return strings.ToLower(value) // bool literal — keep bare
}
if reBareIdentifier.MatchString(value) {
return `"` + value + `"`
}
return value
}
// lookupFieldType returns a pointer to the field's ValueType, or nil if unknown.
func lookupFieldType(fieldName string) *workflow.ValueType {
fd, ok := workflow.Field(fieldName)
if !ok {
return nil
}
t := fd.Type
return &t
}
// quoteValueForField quotes a value according to the target field's type.
// Only leaves bools bare for TypeBool, numbers bare for TypeInt.
// Unknown field type defaults to quoting (safe fallback).
func quoteValueForField(value string, ft *workflow.ValueType) string {
if value == "" {
return value
}
if strings.HasPrefix(value, `"`) {
return value
}
if reFunctionCall.MatchString(value) {
return value
}
if ft != nil {
switch *ft {
case workflow.TypeBool:
if strings.EqualFold(value, "true") || strings.EqualFold(value, "false") {
return strings.ToLower(value)
}
case workflow.TypeInt:
if reNumeric.MatchString(value) {
return value
}
}
// all other field types: quote bare identifiers, boolish and numeric strings
if reBareIdentifier.MatchString(value) || reNumeric.MatchString(value) ||
strings.EqualFold(value, "true") || strings.EqualFold(value, "false") {
return `"` + value + `"`
}
return value
}
// unknown field type: quote bare identifiers (safe fallback)
if reBareIdentifier.MatchString(value) {
return `"` + value + `"`
}
return value
}
// quoteListElementForField quotes a list element according to the target field's type.
func quoteListElementForField(value string, ft *workflow.ValueType) string {
if ft != nil {
switch *ft {
case workflow.TypeBool:
if strings.EqualFold(value, "true") || strings.EqualFold(value, "false") {
return strings.ToLower(value)
}
case workflow.TypeInt:
if reNumeric.MatchString(value) {
return value
}
}
return `"` + value + `"`
}
// unknown field type: always quote (safe fallback)
return `"` + value + `"`
}
// convertBracketValuesTyped converts a bracket-enclosed list with type-aware quoting.
func convertBracketValuesTyped(s string, ft *workflow.ValueType) string {
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, "[") || !strings.HasSuffix(s, "]") {
s = reSingleQuoted.ReplaceAllString(s, `"$1"`)
return quoteValueForField(s, ft)
}
// for list fields, elements are always strings
elemType := ft
if ft != nil && (*ft == workflow.TypeListString || *ft == workflow.TypeListRef) {
st := workflow.TypeString
elemType = &st
}
inner := s[1 : len(s)-1]
parts := strings.Split(inner, ",")
converted := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
p = strings.Trim(p, `"'`)
converted = append(converted, quoteListElementForField(p, elemType))
}
return "[" + strings.Join(converted, ", ") + "]"
}
// splitTopLevelCommas splits a string on commas, respecting [...] brackets and quotes.
func splitTopLevelCommas(input string) ([]string, error) {
var result []string

View file

@ -4,8 +4,10 @@ import (
"strings"
"testing"
"github.com/boolean-maybe/tiki/config"
"github.com/boolean-maybe/tiki/ruki"
"github.com/boolean-maybe/tiki/task"
"github.com/boolean-maybe/tiki/workflow"
"gopkg.in/yaml.v3"
)
@ -612,20 +614,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)
}
@ -633,6 +646,7 @@ func TestLegacyWorkflowEndToEnd(t *testing.T) {
tp, ok := p.(*TikiPlugin)
if !ok {
t.Fatalf("expected TikiPlugin, got %T", p)
return
}
if tp.Name != "Board" {
@ -703,7 +717,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 +955,215 @@ 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")
}
}
func TestLegacyConvert_BoolLiteralBare(t *testing.T) {
config.MarkRegistriesLoadedForTest()
if err := workflow.RegisterCustomFields([]workflow.FieldDef{
{Name: "flag", Type: workflow.TypeBool, Custom: true},
}); err != nil {
t.Fatalf("register: %v", err)
}
t.Cleanup(func() { workflow.ClearCustomFields() })
tr := NewLegacyConfigTransformer()
// true/false values are emitted bare for TypeBool fields
got, err := tr.ConvertAction("flag=true")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := `update where id = id() set flag=true`
if got != want {
t.Errorf("ConvertAction(flag=true)\n got: %q\n want: %q", got, want)
}
got, err = tr.ConvertAction("flag=false")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want = `update where id = id() set flag=false`
if got != want {
t.Errorf("ConvertAction(flag=false)\n got: %q\n want: %q", got, want)
}
}
func TestLegacyConvert_BoolInList(t *testing.T) {
config.MarkRegistriesLoadedForTest()
if err := workflow.RegisterCustomFields([]workflow.FieldDef{
{Name: "flag", Type: workflow.TypeBool, Custom: true},
}); err != nil {
t.Fatalf("register: %v", err)
}
t.Cleanup(func() { workflow.ClearCustomFields() })
tr := NewLegacyConfigTransformer()
// bool values in lists are emitted bare for TypeBool fields
got := tr.ConvertFilter("flag IN [true, false]")
want := `select where flag in [true, false]`
if got != want {
t.Errorf("ConvertFilter(flag IN [true, false])\n got: %q\n want: %q", got, want)
}
}
func TestLegacyConvert_TypeAwareQuoting(t *testing.T) {
config.MarkRegistriesLoadedForTest()
if err := workflow.RegisterCustomFields([]workflow.FieldDef{
{Name: "severity", Type: workflow.TypeEnum, Custom: true, AllowedValues: []string{"low", "high", "true"}},
{Name: "notes", Type: workflow.TypeString, Custom: true},
{Name: "active", Type: workflow.TypeBool, Custom: true},
{Name: "score", Type: workflow.TypeInt, Custom: true},
}); err != nil {
t.Fatalf("register: %v", err)
}
t.Cleanup(func() { workflow.ClearCustomFields() })
tr := NewLegacyConfigTransformer()
tests := []struct {
name string
input string
want string
}{
{
name: "enum field with bool-like value is quoted",
input: "severity=true",
want: `update where id = id() set severity="true"`,
},
{
name: "string field with numeric value is quoted",
input: "notes=42",
want: `update where id = id() set notes="42"`,
},
{
name: "bool field with true stays bare",
input: "active=true",
want: `update where id = id() set active=true`,
},
{
name: "int field with number stays bare",
input: "score=42",
want: `update where id = id() set score=42`,
},
{
name: "string field with bool-like value is quoted",
input: "notes=false",
want: `update where id = id() set notes="false"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tr.ConvertAction(tt.input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("ConvertAction(%q)\n got: %q\n want: %q", tt.input, got, tt.want)
}
})
}
}
func TestLegacyConvert_TypeAwareListQuoting(t *testing.T) {
config.MarkRegistriesLoadedForTest()
if err := workflow.RegisterCustomFields([]workflow.FieldDef{
{Name: "labels", Type: workflow.TypeListString, Custom: true},
}); err != nil {
t.Fatalf("register: %v", err)
}
t.Cleanup(func() { workflow.ClearCustomFields() })
tr := NewLegacyConfigTransformer()
// list<string> field: all elements must be quoted, even bool-like and numeric
got, err := tr.ConvertAction("labels+=[true, 42, hello]")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := `update where id = id() set labels=labels+["true", "42", "hello"]`
if got != want {
t.Errorf("ConvertAction(labels+=[true, 42, hello])\n got: %q\n want: %q", got, want)
}
}
func TestLegacyConvert_UnknownFieldDefaultsToQuoting(t *testing.T) {
config.MarkRegistriesLoadedForTest()
t.Cleanup(func() { workflow.ClearCustomFields() })
tr := NewLegacyConfigTransformer()
// unknown field: "true" should be quoted as safe fallback
got, err := tr.ConvertAction("unknown_field=true")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := `update where id = id() set unknown_field="true"`
if got != want {
t.Errorf("ConvertAction(unknown_field=true)\n got: %q\n want: %q", got, want)
}
}

View file

@ -13,41 +13,57 @@ import (
// WorkflowFile represents the YAML structure of a workflow.yaml file
type WorkflowFile struct {
Plugins []pluginFileConfig `yaml:"views"`
Version string `yaml:"version,omitempty"`
Description string `yaml:"description,omitempty"`
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 && len(wf.Views.Actions) == 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])
}
totalConverted += convertLegacyGlobalActions(transformer, wf.Views.Actions)
if totalConverted > 0 {
slog.Info("converted legacy workflow expressions to ruki", "count", totalConverted, "path", path)
}
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 +92,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,7 +147,9 @@ 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.
// Returns an error when workflow files were found but no valid plugins could be loaded.
// 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,
// or when type-reference errors indicate an inconsistent merged workflow.
func LoadPlugins(schema ruki.Schema) ([]Plugin, error) {
files := config.FindWorkflowFiles()
if len(files) == 0 {
@ -126,20 +158,33 @@ 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)
}
// type-reference errors in views/actions are fatal merged-workflow errors,
// not ordinary per-view parse errors that can be skipped
if typeErrs := filterTypeErrors(allErrors); len(typeErrs) > 0 {
return nil, fmt.Errorf("merged workflow references invalid types:\n %s\n\nIf you redefined types: in a later workflow file, update views/actions/triggers to match",
strings.Join(typeErrs, "\n "))
}
// 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 +199,87 @@ func LoadPlugins(schema ruki.Schema) ([]Plugin, error) {
return base, nil
}
// filterTypeErrors extracts errors that mention unknown type references.
func filterTypeErrors(errs []string) []string {
var typeErrs []string
for _, e := range errs {
if strings.Contains(e, "unknown type") {
typeErrs = append(typeErrs, e)
}
}
return typeErrs
}
// 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)
}
}
}
// convertLegacyGlobalActions converts legacy action expressions in global views.actions
// to ruki format, matching the same conversion applied to per-plugin actions.
func convertLegacyGlobalActions(transformer *LegacyConfigTransformer, actions []PluginActionConfig) int {
count := 0
for i := range actions {
action := &actions[i]
if action.Action != "" && !isRukiAction(action.Action) {
newAction, err := transformer.ConvertAction(action.Action)
if err != nil {
slog.Warn("failed to convert legacy global action, passing through",
"error", err, "action", action.Action, "key", action.Key)
continue
}
slog.Debug("converted legacy global action", "old", action.Action, "new", newAction, "key", action.Key)
action.Action = newAction
count++
}
}
return count
}
// 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) {
@ -29,6 +31,7 @@ func TestParsePluginConfig_FullyInline(t *testing.T) {
tp, ok := def.(*TikiPlugin)
if !ok {
t.Fatalf("Expected TikiPlugin, got %T", def)
return
}
if tp.Name != "Inline Test" {
@ -72,6 +75,7 @@ func TestParsePluginConfig_Minimal(t *testing.T) {
tp, ok := def.(*TikiPlugin)
if !ok {
t.Fatalf("Expected TikiPlugin, got %T", def)
return
}
if tp.Name != "Minimal" {
@ -140,7 +144,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 +178,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 +204,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 +257,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 +302,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 +322,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 +340,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 +368,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)
}
@ -376,6 +380,7 @@ func TestLoadPluginsFromFile_DokiConfigIndex(t *testing.T) {
dp, ok := plugins[1].(*DokiPlugin)
if !ok {
t.Fatalf("expected DokiPlugin, got %T", plugins[1])
return
}
if dp.ConfigIndex != 1 {
t.Errorf("expected DokiPlugin ConfigIndex 1, got %d", dp.ConfigIndex)
@ -416,6 +421,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

@ -206,18 +206,45 @@ func parsePluginActions(configs []PluginActionConfig, parser *ruki.Parser) ([]Pl
return nil, fmt.Errorf("action %d (key %q) missing 'action'", i, cfg.Key)
}
actionStmt, err := parser.ParseAndValidateStatement(cfg.Action, ruki.ExecutorRuntimePlugin)
if err != nil {
return nil, fmt.Errorf("parsing action %d (key %q): %w", i, cfg.Key, err)
}
if actionStmt.IsSelect() {
return nil, fmt.Errorf("action %d (key %q) must be UPDATE, CREATE, or DELETE — not SELECT", i, cfg.Key)
var (
actionStmt *ruki.ValidatedStatement
inputType ruki.ValueType
hasInput bool
)
if cfg.Input != "" {
typ, err := ruki.ParseScalarTypeName(cfg.Input)
if err != nil {
return nil, fmt.Errorf("action %d (key %q) input: %w", i, cfg.Key, err)
}
actionStmt, err = parser.ParseAndValidateStatementWithInput(cfg.Action, ruki.ExecutorRuntimePlugin, typ)
if err != nil {
return nil, fmt.Errorf("parsing action %d (key %q): %w", i, cfg.Key, err)
}
if !actionStmt.UsesInputBuiltin() {
return nil, fmt.Errorf("action %d (key %q) declares 'input: %s' but does not use input()", i, cfg.Key, cfg.Input)
}
inputType = typ
hasInput = true
} else {
var err error
actionStmt, err = parser.ParseAndValidateStatement(cfg.Action, ruki.ExecutorRuntimePlugin)
if err != nil {
return nil, fmt.Errorf("parsing action %d (key %q): %w", i, cfg.Key, err)
}
}
showInHeader := true
if cfg.Hot != nil {
showInHeader = *cfg.Hot
}
actions = append(actions, PluginAction{
Rune: r,
Label: cfg.Label,
Action: actionStmt,
Rune: r,
Label: cfg.Label,
Action: actionStmt,
ShowInHeader: showInHeader,
InputType: inputType,
HasInput: hasInput,
})
}

View file

@ -264,6 +264,7 @@ background: "#0000ff"
tikiPlugin, ok := plugin.(*TikiPlugin)
if !ok {
t.Fatalf("Expected TikiPlugin, got %T", plugin)
return
}
if tikiPlugin.GetName() != "Test Plugin" {
@ -495,17 +496,37 @@ 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 SELECT") {
t.Errorf("expected 'not 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")
}
}
func TestParsePluginActions_PipeAcceptedAsAction(t *testing.T) {
parser := testParser()
configs := []PluginActionConfig{
{Key: "c", Label: "Copy ID", Action: `select id where id = id() | run("echo $1")`},
}
actions, err := parsePluginActions(configs, parser)
if err != nil {
t.Fatalf("expected pipe action to be accepted, got error: %v", err)
}
if len(actions) != 1 {
t.Fatalf("expected 1 action, got %d", len(actions))
}
if !actions[0].Action.IsPipe() {
t.Error("expected IsPipe() = true for pipe action")
}
}
@ -725,6 +746,7 @@ foreground: "#00ff00"
dokiPlugin, ok := plugin.(*DokiPlugin)
if !ok {
t.Fatalf("Expected DokiPlugin, got %T", plugin)
return
}
if dokiPlugin.GetName() != "Doc Plugin" {
@ -739,3 +761,168 @@ foreground: "#00ff00"
t.Errorf("Expected URL, got %q", dokiPlugin.URL)
}
}
func TestParsePluginActions_HotDefault(t *testing.T) {
parser := testParser()
configs := []PluginActionConfig{
{Key: "b", Label: "Board", Action: `update where id = id() set status="ready"`},
}
actions, err := parsePluginActions(configs, parser)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !actions[0].ShowInHeader {
t.Error("absent hot should default to ShowInHeader=true")
}
}
func TestParsePluginActions_HotExplicitFalse(t *testing.T) {
parser := testParser()
hotFalse := false
configs := []PluginActionConfig{
{Key: "b", Label: "Board", Action: `update where id = id() set status="ready"`, Hot: &hotFalse},
}
actions, err := parsePluginActions(configs, parser)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if actions[0].ShowInHeader {
t.Error("hot: false should set ShowInHeader=false")
}
}
func TestParsePluginActions_HotExplicitTrue(t *testing.T) {
parser := testParser()
hotTrue := true
configs := []PluginActionConfig{
{Key: "b", Label: "Board", Action: `update where id = id() set status="ready"`, Hot: &hotTrue},
}
actions, err := parsePluginActions(configs, parser)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !actions[0].ShowInHeader {
t.Error("hot: true should set ShowInHeader=true")
}
}
func TestParsePluginYAML_HotFlagFromYAML(t *testing.T) {
yamlData := []byte(`
name: Test
key: T
lanes:
- name: Backlog
filter: select where status = "backlog"
actions:
- key: "b"
label: "Board"
action: update where id = id() set status = "ready"
hot: false
- key: "a"
label: "Assign"
action: update where id = id() set assignee = user()
`)
p, err := parsePluginYAML(yamlData, "test.yaml", testSchema())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
tiki, ok := p.(*TikiPlugin)
if !ok {
t.Fatalf("expected TikiPlugin, got %T", p)
}
if tiki.Actions[0].ShowInHeader {
t.Error("action with hot: false should have ShowInHeader=false")
}
if !tiki.Actions[1].ShowInHeader {
t.Error("action without hot should default to ShowInHeader=true")
}
}
func TestParsePluginActions_InputValid(t *testing.T) {
parser := testParser()
configs := []PluginActionConfig{
{Key: "a", Label: "Assign to", Action: `update where id = id() set assignee=input()`, Input: "string"},
}
actions, err := parsePluginActions(configs, parser)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(actions) != 1 {
t.Fatalf("expected 1 action, got %d", len(actions))
}
if !actions[0].HasInput {
t.Error("expected HasInput=true")
}
if actions[0].InputType != ruki.ValueString {
t.Errorf("expected InputType=ValueString, got %d", actions[0].InputType)
}
}
func TestParsePluginActions_InputIntValid(t *testing.T) {
parser := testParser()
configs := []PluginActionConfig{
{Key: "p", Label: "Set points", Action: `update where id = id() set points=input()`, Input: "int"},
}
actions, err := parsePluginActions(configs, parser)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !actions[0].HasInput {
t.Error("expected HasInput=true")
}
if actions[0].InputType != ruki.ValueInt {
t.Errorf("expected InputType=ValueInt, got %d", actions[0].InputType)
}
}
func TestParsePluginActions_InputTypeMismatch(t *testing.T) {
parser := testParser()
configs := []PluginActionConfig{
{Key: "a", Label: "Assign to", Action: `update where id = id() set assignee=input()`, Input: "int"},
}
_, err := parsePluginActions(configs, parser)
if err == nil {
t.Fatal("expected error for input type mismatch (int into string field)")
}
}
func TestParsePluginActions_InputWithoutInputFunc(t *testing.T) {
parser := testParser()
configs := []PluginActionConfig{
{Key: "a", Label: "Ready", Action: `update where id = id() set status="ready"`, Input: "string"},
}
_, err := parsePluginActions(configs, parser)
if err == nil {
t.Fatal("expected error: input: declared but input() not used")
}
if !strings.Contains(err.Error(), "does not use input()") {
t.Fatalf("unexpected error message: %v", err)
}
}
func TestParsePluginActions_InputUnsupportedType(t *testing.T) {
parser := testParser()
configs := []PluginActionConfig{
{Key: "a", Label: "Assign to", Action: `update where id = id() set assignee=input()`, Input: "enum"},
}
_, err := parsePluginActions(configs, parser)
if err == nil {
t.Fatal("expected error for unsupported input type")
}
}
func TestParsePluginActions_NoInputField_NoHasInput(t *testing.T) {
parser := testParser()
configs := []PluginActionConfig{
{Key: "a", Label: "Ready", Action: `update where id = id() set status="ready"`},
}
actions, err := parsePluginActions(configs, parser)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if actions[0].HasInput {
t.Error("expected HasInput=false for action without input: field")
}
}

View file

@ -13,13 +13,25 @@ type Statement struct {
Delete *DeleteStmt
}
// SelectStmt represents "select [fields] [where <condition>] [order by <field> [asc|desc], ...]".
// SelectStmt represents "select [fields] [where <condition>] [order by ...] [limit N] [| run(...) | clipboard()]".
type SelectStmt struct {
Fields []string // nil = all ("select" or "select *"); non-nil = specific fields
Where Condition // nil = select all
OrderBy []OrderByClause // nil = unordered
Limit *int // nil = no limit; positive = max result count
Pipe *PipeAction // optional pipe suffix: "| run(...)" or "| clipboard()"
}
// PipeAction is a discriminated union for pipe targets.
// Exactly one variant is non-nil.
type PipeAction struct {
Run *RunAction
Clipboard *ClipboardAction
}
// ClipboardAction represents "clipboard()" as a pipe target.
type ClipboardAction struct{}
// CreateStmt represents "create <field>=<value>...".
type CreateStmt struct {
Assignments []Assignment
@ -180,6 +192,11 @@ type BinaryExpr struct {
Right Expr
}
// BoolLiteral represents a bare true/false identifier lowered from FieldRef.
type BoolLiteral struct {
Value bool
}
// SubQuery represents "select [where <condition>]" used inside count().
type SubQuery struct {
Where Condition // nil = select all
@ -195,6 +212,7 @@ func (*ListLiteral) exprNode() {}
func (*EmptyLiteral) exprNode() {}
func (*FunctionCall) exprNode() {}
func (*BinaryExpr) exprNode() {}
func (*BoolLiteral) exprNode() {}
func (*SubQuery) exprNode() {}
// --- order by ---

View file

@ -34,10 +34,25 @@ func NewExecutor(schema Schema, userFunc func() string, runtime ExecutorRuntime)
// Result holds the output of executing a statement.
// Exactly one variant is non-nil.
type Result struct {
Select *TaskProjection
Update *UpdateResult
Create *CreateResult
Delete *DeleteResult
Select *TaskProjection
Update *UpdateResult
Create *CreateResult
Delete *DeleteResult
Pipe *PipeResult
Clipboard *ClipboardResult
}
// ClipboardResult holds the row data from a clipboard-piped select.
// The service layer writes these to the system clipboard.
type ClipboardResult struct {
Rows [][]string
}
// PipeResult holds the shell command and per-row positional args from a piped select.
// The ruki executor builds this; the service layer performs the actual shell execution.
type PipeResult struct {
Command string
Rows [][]string
}
// UpdateResult holds the cloned, mutated tasks produced by an UPDATE statement.
@ -138,6 +153,19 @@ func (e *Executor) executeSelect(sel *SelectStmt, tasks []*task.Task) (*Result,
e.sortTasks(filtered, sel.OrderBy)
}
if sel.Limit != nil && *sel.Limit < len(filtered) {
filtered = filtered[:*sel.Limit]
}
if sel.Pipe != nil {
switch {
case sel.Pipe.Run != nil:
return e.buildPipeResult(sel.Pipe.Run, sel.Fields, filtered, tasks)
case sel.Pipe.Clipboard != nil:
return e.buildClipboardResult(sel.Fields, filtered)
}
}
return &Result{
Select: &TaskProjection{
Tasks: filtered,
@ -146,6 +174,53 @@ func (e *Executor) executeSelect(sel *SelectStmt, tasks []*task.Task) (*Result,
}, nil
}
func (e *Executor) buildPipeResult(pipe *RunAction, fields []string, matched []*task.Task, allTasks []*task.Task) (*Result, error) {
// evaluate command once with a nil-sentinel task — validation ensures no field refs
cmdVal, err := e.evalExpr(pipe.Command, nil, allTasks)
if err != nil {
return nil, fmt.Errorf("pipe command: %w", err)
}
cmdStr, ok := cmdVal.(string)
if !ok {
return nil, fmt.Errorf("pipe command must evaluate to string, got %T", cmdVal)
}
rows := e.buildFieldRows(fields, matched)
return &Result{Pipe: &PipeResult{Command: cmdStr, Rows: rows}}, nil
}
func (e *Executor) buildClipboardResult(fields []string, matched []*task.Task) (*Result, error) {
rows := e.buildFieldRows(fields, matched)
return &Result{Clipboard: &ClipboardResult{Rows: rows}}, nil
}
// buildFieldRows extracts the requested fields from matched tasks as string rows.
// Shared by both run() and clipboard() pipe targets.
func (e *Executor) buildFieldRows(fields []string, matched []*task.Task) [][]string {
rows := make([][]string, len(matched))
for i, t := range matched {
row := make([]string, len(fields))
for j, f := range fields {
row[j] = pipeArgString(e.extractField(t, f))
}
rows[i] = row
}
return rows
}
// pipeArgString space-joins list fields (tags, dependsOn) instead of using Go's
// default fmt.Sprint which produces "[a b c]" with brackets.
func pipeArgString(val interface{}) string {
if list, ok := val.([]interface{}); ok {
parts := make([]string, len(list))
for i, elem := range list {
parts[i] = normalizeToString(elem)
}
return strings.Join(parts, " ")
}
return normalizeToString(val)
}
func (e *Executor) executeUpdate(upd *UpdateStmt, tasks []*task.Task) (*Result, error) {
matched, err := e.filterTasks(upd.Where, tasks)
if err != nil {
@ -309,7 +384,7 @@ func (e *Executor) setField(t *task.Task, name string, val interface{}) error {
t.DependsOn = nil
return nil
}
t.DependsOn = toStringSlice(val)
t.DependsOn = normalizeRefList(toStringSlice(val))
case "due":
if val == nil {
@ -348,7 +423,22 @@ func (e *Executor) setField(t *task.Task, name string, val interface{}) error {
t.Assignee = s
default:
return fmt.Errorf("unknown field %q", name)
fs, ok := e.schema.Field(name)
if !ok || !fs.Custom {
return fmt.Errorf("unknown field %q", name)
}
if val == nil {
delete(t.CustomFields, name)
return nil
}
coerced, err := coerceCustomFieldValue(fs, val)
if err != nil {
return fmt.Errorf("field %q: %w", name, err)
}
if t.CustomFields == nil {
t.CustomFields = make(map[string]interface{})
}
t.CustomFields[name] = coerced
}
return nil
}
@ -365,6 +455,73 @@ func toStringSlice(val interface{}) []string {
return result
}
func coerceCustomFieldValue(fs FieldSpec, val interface{}) (interface{}, error) {
switch fs.Type {
case ValueString:
s, ok := val.(string)
if !ok {
return nil, fmt.Errorf("expected string, got %T", val)
}
return s, nil
case ValueInt:
n, ok := val.(int)
if !ok {
return nil, fmt.Errorf("expected int, got %T", val)
}
return n, nil
case ValueBool:
if b, ok := val.(bool); ok {
return b, nil
}
if s, ok := val.(string); ok {
if b, err := parseBoolString(s); err == nil {
return b, nil
}
}
return nil, fmt.Errorf("expected bool, got %T", val)
case ValueTimestamp:
tv, ok := val.(time.Time)
if !ok {
return nil, fmt.Errorf("expected time.Time, got %T", val)
}
return tv, nil
case ValueEnum:
s, ok := val.(string)
if !ok {
return nil, fmt.Errorf("expected string, got %T", val)
}
for _, av := range fs.AllowedValues {
if strings.EqualFold(av, s) {
return av, nil
}
}
return nil, fmt.Errorf("invalid enum value %q", s)
case ValueListString:
return toStringSlice(val), nil
case ValueListRef:
raw := toStringSlice(val)
return normalizeRefList(raw), nil
default:
return nil, fmt.Errorf("unsupported custom field type")
}
}
// normalizeRefList trims whitespace and uppercases task ID references.
func normalizeRefList(ss []string) []string {
var result []string
for _, s := range ss {
s = strings.TrimSpace(s)
if s == "" {
continue
}
result = append(result, strings.ToUpper(s))
}
if result == nil {
result = []string{}
}
return result
}
// --- filtering ---
func (e *Executor) filterTasks(where Condition, tasks []*task.Task) ([]*task.Task, error) {
@ -468,10 +625,17 @@ func (e *Executor) evalIn(c *InExpr, t *task.Task, allTasks []*task.Task) (bool,
// list membership mode
if list, ok := collVal.([]interface{}); ok {
// unset field (nil) is not a member of any list
if val == nil {
return c.Negated, nil
}
valStr := normalizeToString(val)
// use case-insensitive comparison for enum-like fields
foldCase := isEnumLikeField(e.exprFieldType(c.Value))
found := false
for _, elem := range list {
if normalizeToString(elem) == valStr {
elemStr := normalizeToString(elem)
if foldCase && strings.EqualFold(valStr, elemStr) || !foldCase && valStr == elemStr {
found = true
break
}
@ -556,13 +720,15 @@ func (e *Executor) evalQuantifier(q *QuantifierExpr, t *task.Task, allTasks []*t
func (e *Executor) evalExpr(expr Expr, t *task.Task, allTasks []*task.Task) (interface{}, error) {
switch expr := expr.(type) {
case *FieldRef:
return extractField(t, expr.Name), nil
return e.extractField(t, expr.Name), nil
case *QualifiedRef:
return nil, fmt.Errorf("qualified references (old./new.) are not supported in standalone SELECT")
case *StringLiteral:
return expr.Value, nil
case *IntLiteral:
return expr.Value, nil
case *BoolLiteral:
return expr.Value, nil
case *DateLiteral:
return expr.Value, nil
case *DurationLiteral:
@ -614,6 +780,8 @@ func (e *Executor) evalFunctionCall(fc *FunctionCall, t *task.Task, allTasks []*
return e.evalNextDate(fc, t, allTasks)
case "blocks":
return e.evalBlocks(fc, t, allTasks)
case "input":
return e.evalInput()
case "call":
return nil, fmt.Errorf("call() is not supported yet")
default:
@ -621,6 +789,13 @@ func (e *Executor) evalFunctionCall(fc *FunctionCall, t *task.Task, allTasks []*
}
}
func (e *Executor) evalInput() (interface{}, error) {
if !e.currentInput.HasInput {
return nil, &MissingInputValueError{}
}
return e.currentInput.InputValue, nil
}
func (e *Executor) evalID() (interface{}, error) {
if e.runtime.Mode != ExecutorRuntimePlugin {
return nil, fmt.Errorf("id() is only available in plugin runtime")
@ -779,8 +954,8 @@ func subtractValues(left, right interface{}) (interface{}, error) {
func (e *Executor) sortTasks(tasks []*task.Task, clauses []OrderByClause) {
sort.SliceStable(tasks, func(i, j int) bool {
for _, c := range clauses {
vi := extractField(tasks[i], c.Field)
vj := extractField(tasks[j], c.Field)
vi := e.extractField(tasks[i], c.Field)
vj := e.extractField(tasks[j], c.Field)
cmp := compareForSort(vi, vj)
if cmp == 0 {
continue
@ -812,6 +987,15 @@ func compareForSort(a, b interface{}) int {
case string:
bv, _ := b.(string)
return strings.Compare(av, bv)
case bool:
bv, _ := b.(bool)
if av == bv {
return 0
}
if !av && bv {
return -1 // false < true
}
return 1
case task.Status:
bv, _ := b.(task.Status)
return strings.Compare(string(av), string(bv))
@ -858,7 +1042,7 @@ func compareInts(a, b int) int {
func (e *Executor) compareValues(left, right interface{}, op string, leftExpr, rightExpr Expr) (bool, error) {
if left == nil || right == nil {
return compareWithNil(left, right, op)
return compareWithNil(left, right, op, leftExpr, rightExpr)
}
if leftList, ok := left.([]interface{}); ok {
@ -871,6 +1055,20 @@ func (e *Executor) compareValues(left, right interface{}, op string, leftExpr, r
if rb, ok := right.(bool); ok {
return compareBools(lb, rb, op)
}
// resilience: coerce string-encoded bool on right side
if rs, ok := right.(string); ok {
if rb, err := parseBoolString(rs); err == nil {
return compareBools(lb, rb, op)
}
}
}
if rb, ok := right.(bool); ok {
// resilience: coerce string-encoded bool on left side
if ls, ok := left.(string); ok {
if lb, err := parseBoolString(ls); err == nil {
return compareBools(lb, rb, op)
}
}
}
compType := e.resolveComparisonType(leftExpr, rightExpr)
@ -886,6 +1084,10 @@ func (e *Executor) compareValues(left, right interface{}, op string, leftExpr, r
ls := e.normalizeTypeStr(normalizeToString(left))
rs := e.normalizeTypeStr(normalizeToString(right))
return compareStrings(ls, rs, op)
case ValueEnum:
ls := strings.ToLower(normalizeToString(left))
rs := strings.ToLower(normalizeToString(right))
return compareStrings(ls, rs, op)
}
switch lv := left.(type) {
@ -923,10 +1125,10 @@ func (e *Executor) compareValues(left, right interface{}, op string, leftExpr, r
// resolveComparisonType returns the dominant field type for a comparison,
// checking both sides for enum/id fields that need special handling.
func (e *Executor) resolveComparisonType(left, right Expr) ValueType {
if t := e.exprFieldType(left); t == ValueID || t == ValueStatus || t == ValueTaskType {
if t := e.exprFieldType(left); t == ValueID || t == ValueStatus || t == ValueTaskType || t == ValueEnum {
return t
}
if t := e.exprFieldType(right); t == ValueID || t == ValueStatus || t == ValueTaskType {
if t := e.exprFieldType(right); t == ValueID || t == ValueStatus || t == ValueTaskType || t == ValueEnum {
return t
}
return -1
@ -954,6 +1156,13 @@ func (e *Executor) exprFieldType(expr Expr) ValueType {
return fs.Type
}
// isEnumLikeField returns true for field types that use case-insensitive
// comparison in equality checks and should also use it for in/not-in.
// Includes ValueBool so that "True"/"true"/"TRUE" all match in bool in-lists.
func isEnumLikeField(t ValueType) bool {
return t == ValueEnum || t == ValueStatus || t == ValueTaskType || t == ValueID || t == ValueBool
}
func (e *Executor) normalizeStatusStr(s string) string {
if norm, ok := e.schema.NormalizeStatus(s); ok {
return norm
@ -968,16 +1177,32 @@ func (e *Executor) normalizeTypeStr(s string) string {
return s
}
func compareWithNil(left, right interface{}, op string) (bool, error) {
// treat nil as empty; treat zero-valued non-nil as also matching empty
leftEmpty := isZeroValue(left)
rightEmpty := isZeroValue(right)
bothEmpty := leftEmpty && rightEmpty
func compareWithNil(left, right interface{}, op string, leftExpr, rightExpr Expr) (bool, error) {
// when comparing against EmptyLiteral, use zero-value semantics:
// nil and typed zeros both count as "empty"
_, leftIsEmpty := leftExpr.(*EmptyLiteral)
_, rightIsEmpty := rightExpr.(*EmptyLiteral)
if leftIsEmpty || rightIsEmpty {
leftEmpty := isZeroValue(left)
rightEmpty := isZeroValue(right)
bothEmpty := leftEmpty && rightEmpty
switch op {
case "=":
return bothEmpty, nil
case "!=":
return !bothEmpty, nil
default:
return false, nil
}
}
// concrete comparison: nil (unset field) only equals nil
bothNil := left == nil && right == nil
switch op {
case "=":
return bothEmpty, nil
return bothNil, nil
case "!=":
return !bothEmpty, nil
return !bothNil, nil
default:
return false, nil
}
@ -1111,7 +1336,7 @@ func compareDurations(a, b time.Duration, op string) (bool, error) {
// --- field extraction ---
func extractField(t *task.Task, name string) interface{} {
func (e *Executor) extractField(t *task.Task, name string) interface{} {
switch name {
case "id":
return t.ID
@ -1144,12 +1369,44 @@ func extractField(t *task.Task, name string) interface{} {
case "updatedAt":
return t.UpdatedAt
default:
fs, ok := e.schema.Field(name)
if !ok || !fs.Custom {
return nil
}
if t.CustomFields != nil {
if v, exists := t.CustomFields[name]; exists {
if fs.Type == ValueListString || fs.Type == ValueListRef {
if ss, ok := v.([]string); ok {
return toInterfaceSlice(ss)
}
}
return v
}
}
// unset custom field: list types return empty list (consistent
// with built-in tags/dependsOn), scalars return nil
if fs.Type == ValueListString || fs.Type == ValueListRef {
return []interface{}{}
}
return nil
}
}
// --- helpers ---
// parseBoolString converts a string "true"/"false" (case-insensitive) to a bool.
// Returns an error for any other string.
func parseBoolString(s string) (bool, error) {
switch strings.ToLower(s) {
case "true":
return true, nil
case "false":
return false, nil
default:
return false, fmt.Errorf("not a bool string: %q", s)
}
}
func toInterfaceSlice(ss []string) []interface{} {
if ss == nil {
return []interface{}{}

View file

@ -38,6 +38,8 @@ func (r ExecutorRuntime) normalize() ExecutorRuntime {
type ExecutionInput struct {
SelectedTaskID string
CreateTemplate *task.Task
InputValue interface{} // value returned by input() builtin
HasInput bool // distinguishes nil from unset
}
// RuntimeMismatchError reports execution with a wrapper validated for a
@ -68,6 +70,13 @@ func (e *MissingCreateTemplateError) Error() string {
return "create template is required for create execution"
}
// MissingInputValueError reports execution of input() without a provided value.
type MissingInputValueError struct{}
func (e *MissingInputValueError) Error() string {
return "input value is required when input() is used"
}
var (
// ErrRuntimeMismatch is used with errors.Is for runtime mismatch failures.
ErrRuntimeMismatch = errors.New("runtime mismatch")

View file

@ -2,6 +2,7 @@ package ruki
import (
"fmt"
"reflect"
"strings"
"testing"
"time"
@ -299,6 +300,72 @@ func TestExecuteSelectNoOrderByPreservesInputOrder(t *testing.T) {
}
}
// --- limit ---
func TestExecuteSelectLimit(t *testing.T) {
e := newTestExecutor()
p := newTestParser()
tasks := makeTasks()
tests := []struct {
name string
input string
wantIDs []string
}{
{
"limit fewer than available",
"select order by priority limit 2",
[]string{"TIKI-000002", "TIKI-000001"},
},
{
"limit equal to count",
"select limit 4",
[]string{"TIKI-000001", "TIKI-000002", "TIKI-000003", "TIKI-000004"},
},
{
"limit greater than count",
"select limit 100",
[]string{"TIKI-000001", "TIKI-000002", "TIKI-000003", "TIKI-000004"},
},
{
"limit 1",
"select order by priority limit 1",
[]string{"TIKI-000002"},
},
{
"limit with where",
"select where priority <= 2 order by priority limit 1",
[]string{"TIKI-000002"},
},
{
"limit without order by",
"select limit 2",
[]string{"TIKI-000001", "TIKI-000002"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stmt, err := p.ParseStatement(tt.input)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(result.Select.Tasks) != len(tt.wantIDs) {
t.Fatalf("expected %d tasks, got %d", len(tt.wantIDs), len(result.Select.Tasks))
}
for i, wantID := range tt.wantIDs {
if result.Select.Tasks[i].ID != wantID {
t.Errorf("task[%d].ID = %q, want %q", i, result.Select.Tasks[i].ID, wantID)
}
}
})
}
}
// --- enum normalization ---
func TestExecuteEnumNormalization(t *testing.T) {
@ -1398,6 +1465,7 @@ func TestExecuteSortByStatusTypeRecurrence(t *testing.T) {
// --- extractField additional branches ---
func TestExtractFieldAllFields(t *testing.T) {
e := newTestExecutor()
tk := &task.Task{
ID: "T1", Title: "hi", Description: "desc", Status: "ready",
Type: "bug", Priority: 1, Points: 3, Tags: []string{"a"},
@ -1412,12 +1480,12 @@ func TestExtractFieldAllFields(t *testing.T) {
"createdBy", "createdAt", "updatedAt",
}
for _, f := range fields {
v := extractField(tk, f)
v := e.extractField(tk, f)
if v == nil {
t.Errorf("extractField(%q) returned nil", f)
}
}
if v := extractField(tk, "nonexistent"); v != nil {
if v := e.extractField(tk, "nonexistent"); v != nil {
t.Errorf("extractField(nonexistent) should be nil, got %v", v)
}
}
@ -1478,6 +1546,7 @@ func TestDurationLiteralUnknownUnitError(t *testing.T) {
add, ok := cmp.Right.(*BinaryExpr)
if !ok {
t.Fatal("expected *BinaryExpr")
return
}
add.Right = &DurationLiteral{Value: 1, Unit: "bogus"}
@ -3738,3 +3807,603 @@ func TestExecuteUnsupportedStatementType(t *testing.T) {
t.Errorf("expected 'unsupported statement type' error, got: %v", err)
}
}
// --- custom field executor tests ---
func newCustomExecutor() *Executor {
return NewExecutor(customTestSchema{}, func() string { return "alice" }, ExecutorRuntime{Mode: ExecutorRuntimeCLI})
}
func newCustomParser2() *Parser {
return NewParser(customTestSchema{})
}
func TestExecutor_CustomFieldExtraction(t *testing.T) {
e := newCustomExecutor()
// task with no custom fields — extractField returns nil for unset fields
tk := &task.Task{ID: "T1", Title: "x", Status: "ready"}
if v := e.extractField(tk, "notes"); v != nil {
t.Errorf("notes unset: got %v, want nil", v)
}
if v := e.extractField(tk, "score"); v != nil {
t.Errorf("score unset: got %v, want nil", v)
}
if v := e.extractField(tk, "flag"); v != nil {
t.Errorf("flag unset: got %v, want nil", v)
}
if v := e.extractField(tk, "severity"); v != nil {
t.Errorf("severity unset: got %v, want nil", v)
}
// task with custom fields set — returns actual values
tk2 := &task.Task{
ID: "T2", Title: "y", Status: "ready",
CustomFields: map[string]interface{}{
"notes": "hello",
"score": 42,
"flag": true,
"severity": "high",
},
}
if v := e.extractField(tk2, "notes"); v != "hello" {
t.Errorf("notes: got %v, want hello", v)
}
if v := e.extractField(tk2, "score"); v != 42 {
t.Errorf("score: got %v, want 42", v)
}
if v := e.extractField(tk2, "flag"); v != true {
t.Errorf("flag: got %v, want true", v)
}
if v := e.extractField(tk2, "severity"); v != "high" {
t.Errorf("severity: got %v, want high", v)
}
}
func TestExecutor_CustomFieldSetAndGet(t *testing.T) {
e := newCustomExecutor()
tk := &task.Task{ID: "T1", Title: "x", Status: "ready"}
if err := e.setField(tk, "notes", "hello"); err != nil {
t.Fatalf("setField notes: %v", err)
}
if err := e.setField(tk, "score", 42); err != nil {
t.Fatalf("setField score: %v", err)
}
if err := e.setField(tk, "flag", true); err != nil {
t.Fatalf("setField flag: %v", err)
}
if err := e.setField(tk, "severity", "high"); err != nil {
t.Fatalf("setField severity: %v", err)
}
if v := e.extractField(tk, "notes"); v != "hello" {
t.Errorf("notes: got %v, want hello", v)
}
if v := e.extractField(tk, "score"); v != 42 {
t.Errorf("score: got %v, want 42", v)
}
if v := e.extractField(tk, "flag"); v != true {
t.Errorf("flag: got %v, want true", v)
}
if v := e.extractField(tk, "severity"); v != "high" {
t.Errorf("severity: got %v, want high", v)
}
}
func TestExecutor_UnsetCustomListFieldReturnsEmptyList(t *testing.T) {
e := newCustomExecutor()
// task with no custom fields at all
tk := &task.Task{ID: "T1", Title: "x", Status: "ready"}
// unset custom list fields should return []interface{}{}, not nil
labelsVal := e.extractField(tk, "labels")
if labelsVal == nil {
t.Fatal("unset labels should return empty list, got nil")
}
labels, ok := labelsVal.([]interface{})
if !ok {
t.Fatalf("labels type = %T, want []interface{}", labelsVal)
}
if len(labels) != 0 {
t.Errorf("labels len = %d, want 0", len(labels))
}
relVal := e.extractField(tk, "related")
if relVal == nil {
t.Fatal("unset related should return empty list, got nil")
}
related, ok := relVal.([]interface{})
if !ok {
t.Fatalf("related type = %T, want []interface{}", relVal)
}
if len(related) != 0 {
t.Errorf("related len = %d, want 0", len(related))
}
}
func TestExecutor_UnsetCustomListField_InExprWorks(t *testing.T) {
e := newCustomExecutor()
p := newCustomParser2()
// task with no "labels" set — in-expr should work without error
tasks := []*task.Task{
{ID: "T1", Title: "A", Status: "ready"},
}
stmt, err := p.ParseStatement(`select where "bug" in labels`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute should not error on unset custom list: %v", err)
}
if len(result.Select.Tasks) != 0 {
t.Errorf("expected 0 matching tasks, got %d", len(result.Select.Tasks))
}
}
func TestExecutor_UnsetCustomListField_AddWorks(t *testing.T) {
e := newCustomExecutor()
p := newCustomParser2()
// task with no "labels" set — update adding to list should work
tasks := []*task.Task{
{ID: "T1", Title: "A", Status: "ready"},
}
stmt, err := p.ParseStatement(`update where id = "T1" set labels = labels + ["new"]`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute should not error on unset custom list add: %v", err)
}
if len(result.Update.Updated) != 1 {
t.Fatalf("expected 1 updated task, got %d", len(result.Update.Updated))
}
}
func TestExecutor_CustomFieldNilDelete(t *testing.T) {
e := newCustomExecutor()
tk := &task.Task{
ID: "T1", Title: "x", Status: "ready",
CustomFields: map[string]interface{}{
"notes": "hello",
"score": 42,
},
}
if err := e.setField(tk, "notes", nil); err != nil {
t.Fatalf("setField nil: %v", err)
}
if _, exists := tk.CustomFields["notes"]; exists {
t.Error("notes should be deleted from CustomFields after nil set")
}
// extractField should return nil for deleted (unset) field
if v := e.extractField(tk, "notes"); v != nil {
t.Errorf("notes after delete: got %v, want nil", v)
}
}
func TestExecutor_SelectWhereCustomEnum(t *testing.T) {
e := newCustomExecutor()
p := newCustomParser2()
tasks := []*task.Task{
{ID: "T1", Title: "A", Status: "ready", CustomFields: map[string]interface{}{"severity": "low"}},
{ID: "T2", Title: "B", Status: "ready", CustomFields: map[string]interface{}{"severity": "high"}},
{ID: "T3", Title: "C", Status: "ready", CustomFields: map[string]interface{}{"severity": "low"}},
{ID: "T4", Title: "D", Status: "ready"}, // no severity set
}
stmt, err := p.ParseStatement(`select where severity = "low"`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(result.Select.Tasks) != 2 {
ids := make([]string, len(result.Select.Tasks))
for i, tk := range result.Select.Tasks {
ids[i] = tk.ID
}
t.Fatalf("expected 2 tasks, got %d: %v", len(result.Select.Tasks), ids)
}
if result.Select.Tasks[0].ID != "T1" || result.Select.Tasks[1].ID != "T3" {
t.Errorf("expected T1, T3; got %s, %s", result.Select.Tasks[0].ID, result.Select.Tasks[1].ID)
}
}
func TestExecutor_UpdateSetCustomField(t *testing.T) {
e := newCustomExecutor()
p := newCustomParser2()
tasks := []*task.Task{
{ID: "T1", Title: "A", Status: "ready", CustomFields: map[string]interface{}{"severity": "low"}},
}
stmt, err := p.ParseStatement(`update where id = "T1" set severity="high"`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
if result.Update == nil || len(result.Update.Updated) != 1 {
t.Fatal("expected 1 updated task")
}
updated := result.Update.Updated[0]
if updated.CustomFields["severity"] != "high" {
t.Errorf("severity = %v, want high", updated.CustomFields["severity"])
}
}
func TestExecutor_OrderByCustomEnum(t *testing.T) {
e := newCustomExecutor()
p := newCustomParser2()
tasks := []*task.Task{
{ID: "T1", Title: "A", Status: "ready", CustomFields: map[string]interface{}{"severity": "high"}},
{ID: "T2", Title: "B", Status: "ready", CustomFields: map[string]interface{}{"severity": "critical"}},
{ID: "T3", Title: "C", Status: "ready", CustomFields: map[string]interface{}{"severity": "low"}},
}
stmt, err := p.ParseStatement(`select order by severity`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
// enum values are compared as strings: "critical" < "high" < "low"
wantIDs := []string{"T2", "T1", "T3"}
if len(result.Select.Tasks) != 3 {
t.Fatalf("expected 3 tasks, got %d", len(result.Select.Tasks))
}
for i, wantID := range wantIDs {
if result.Select.Tasks[i].ID != wantID {
t.Errorf("task[%d].ID = %q, want %q", i, result.Select.Tasks[i].ID, wantID)
}
}
}
func TestExecutor_OrderByCustomBool(t *testing.T) {
e := newCustomExecutor()
p := newCustomParser2()
tasks := []*task.Task{
{ID: "T1", Title: "A", Status: "ready", CustomFields: map[string]interface{}{"flag": true}},
{ID: "T2", Title: "B", Status: "ready", CustomFields: map[string]interface{}{"flag": false}},
{ID: "T3", Title: "C", Status: "ready", CustomFields: map[string]interface{}{"flag": true}},
}
stmt, err := p.ParseStatement(`select order by flag`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
// false < true: T2 first, then T1 and T3 (stable order)
if len(result.Select.Tasks) != 3 {
t.Fatalf("expected 3 tasks, got %d", len(result.Select.Tasks))
}
if result.Select.Tasks[0].ID != "T2" {
t.Errorf("task[0].ID = %q, want T2 (false before true)", result.Select.Tasks[0].ID)
}
if result.Select.Tasks[1].ID != "T1" {
t.Errorf("task[1].ID = %q, want T1", result.Select.Tasks[1].ID)
}
if result.Select.Tasks[2].ID != "T3" {
t.Errorf("task[2].ID = %q, want T3", result.Select.Tasks[2].ID)
}
}
func TestExecutor_BoolLiteralEval(t *testing.T) {
e := newCustomExecutor()
tasks := []*task.Task{
{ID: "T1", Title: "A", Status: "ready", CustomFields: map[string]interface{}{"flag": true}},
{ID: "T2", Title: "B", Status: "ready", CustomFields: map[string]interface{}{"flag": false}},
}
// manually construct statement with BoolLiteral (as lowering produces)
stmt := &Statement{
Select: &SelectStmt{
Where: &CompareExpr{
Left: &FieldRef{Name: "flag"},
Op: "=",
Right: &BoolLiteral{Value: true},
},
},
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(result.Select.Tasks) != 1 || result.Select.Tasks[0].ID != "T1" {
t.Fatalf("expected T1, got %v", result.Select.Tasks)
}
// test false literal
stmt2 := &Statement{
Select: &SelectStmt{
Where: &CompareExpr{
Left: &FieldRef{Name: "flag"},
Op: "=",
Right: &BoolLiteral{Value: false},
},
},
}
result2, err := e.Execute(stmt2, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(result2.Select.Tasks) != 1 || result2.Select.Tasks[0].ID != "T2" {
t.Fatalf("expected T2, got %v", result2.Select.Tasks)
}
}
func TestExecutor_BoolStringCoercion(t *testing.T) {
e := newCustomExecutor()
tasks := []*task.Task{
{ID: "T1", Title: "A", Status: "ready", CustomFields: map[string]interface{}{"flag": true}},
{ID: "T2", Title: "B", Status: "ready", CustomFields: map[string]interface{}{"flag": false}},
}
// compare bool field against string "true" — should coerce and match
stmt := &Statement{
Select: &SelectStmt{
Where: &CompareExpr{
Left: &FieldRef{Name: "flag"},
Op: "=",
Right: &StringLiteral{Value: "true"},
},
},
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(result.Select.Tasks) != 1 || result.Select.Tasks[0].ID != "T1" {
t.Errorf("bool=string 'true': got %v, want [T1]", result.Select.Tasks)
}
// compare bool field against string "FALSE" — case-insensitive coercion
stmt2 := &Statement{
Select: &SelectStmt{
Where: &CompareExpr{
Left: &FieldRef{Name: "flag"},
Op: "=",
Right: &StringLiteral{Value: "FALSE"},
},
},
}
result2, err := e.Execute(stmt2, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(result2.Select.Tasks) != 1 || result2.Select.Tasks[0].ID != "T2" {
t.Errorf("bool=string 'FALSE': got %v, want [T2]", result2.Select.Tasks)
}
}
func TestExecutor_BoolInCaseInsensitive(t *testing.T) {
e := newCustomExecutor()
p := newCustomParser2()
tasks := []*task.Task{
{ID: "T1", Title: "A", Status: "ready", CustomFields: map[string]interface{}{"flag": true}},
{ID: "T2", Title: "B", Status: "ready", CustomFields: map[string]interface{}{"flag": false}},
}
// bool field in list of string bool-literals with mixed case
stmt, err := p.ParseStatement(`select where flag in ["True", "FALSE"]`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(result.Select.Tasks) != 2 {
t.Errorf("bool in [True, FALSE]: got %d tasks, want 2", len(result.Select.Tasks))
}
}
func TestExecutor_NilDoesNotMatchZero(t *testing.T) {
e := newCustomExecutor()
p := newCustomParser2()
tasks := []*task.Task{
{ID: "T1", Title: "A", Status: "ready"}, // flag unset, score unset
{ID: "T2", Title: "B", Status: "ready", CustomFields: map[string]interface{}{"flag": false, "score": 0}},
{ID: "T3", Title: "C", Status: "ready", CustomFields: map[string]interface{}{"flag": true, "score": 42}},
}
// "flag = false" should only match T2 (explicitly false), not T1 (unset)
stmt, err := p.ParseStatement(`select where flag = false`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(result.Select.Tasks) != 1 || result.Select.Tasks[0].ID != "T2" {
ids := make([]string, len(result.Select.Tasks))
for i, tk := range result.Select.Tasks {
ids[i] = tk.ID
}
t.Errorf("flag=false: got %v, want [T2]", ids)
}
// "score = 0" should only match T2, not T1
stmt2, err := p.ParseStatement(`select where score = 0`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result2, err := e.Execute(stmt2, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(result2.Select.Tasks) != 1 || result2.Select.Tasks[0].ID != "T2" {
ids := make([]string, len(result2.Select.Tasks))
for i, tk := range result2.Select.Tasks {
ids[i] = tk.ID
}
t.Errorf("score=0: got %v, want [T2]", ids)
}
}
func TestExecutor_NilMatchesIsEmpty(t *testing.T) {
e := newCustomExecutor()
p := newCustomParser2()
tasks := []*task.Task{
{ID: "T1", Title: "A", Status: "ready"}, // flag unset
{ID: "T2", Title: "B", Status: "ready", CustomFields: map[string]interface{}{"flag": true}},
}
// "flag is empty" should match T1 (unset → nil → isZeroValue true)
stmt, err := p.ParseStatement(`select where flag is empty`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(result.Select.Tasks) != 1 || result.Select.Tasks[0].ID != "T1" {
ids := make([]string, len(result.Select.Tasks))
for i, tk := range result.Select.Tasks {
ids[i] = tk.ID
}
t.Errorf("flag is empty: got %v, want [T1]", ids)
}
}
func TestExecutor_NilSortsFirst(t *testing.T) {
e := newCustomExecutor()
p := newCustomParser2()
tasks := []*task.Task{
{ID: "T1", Title: "A", Status: "ready", CustomFields: map[string]interface{}{"score": 10}},
{ID: "T2", Title: "B", Status: "ready"}, // score unset → nil
{ID: "T3", Title: "C", Status: "ready", CustomFields: map[string]interface{}{"score": 0}},
}
// "order by score" — nil sorts before 0 before 10
stmt, err := p.ParseStatement(`select order by score`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
gotIDs := make([]string, len(result.Select.Tasks))
for i, tk := range result.Select.Tasks {
gotIDs[i] = tk.ID
}
wantIDs := []string{"T2", "T3", "T1"} // nil, 0, 10
if !reflect.DeepEqual(gotIDs, wantIDs) {
t.Errorf("order by score: got %v, want %v", gotIDs, wantIDs)
}
}
func TestExecutor_NilNotInList(t *testing.T) {
e := newCustomExecutor()
p := newCustomParser2()
tasks := []*task.Task{
{ID: "T1", Title: "A", Status: "ready"}, // severity unset
{ID: "T2", Title: "B", Status: "ready", CustomFields: map[string]interface{}{"severity": "low"}},
}
// unset field should not be "in" any list
stmt, err := p.ParseStatement(`select where severity in ["low"]`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(result.Select.Tasks) != 1 || result.Select.Tasks[0].ID != "T2" {
ids := make([]string, len(result.Select.Tasks))
for i, tk := range result.Select.Tasks {
ids[i] = tk.ID
}
t.Errorf("severity in [low]: got %v, want [T2]", ids)
}
}
func TestExecutor_EnumInCaseInsensitive(t *testing.T) {
e := newCustomExecutor()
p := newCustomParser2()
tasks := []*task.Task{
{ID: "T1", Title: "A", Status: "done", Type: "story", CustomFields: map[string]interface{}{"severity": "low"}},
{ID: "T2", Title: "B", Status: "ready", Type: "bug", CustomFields: map[string]interface{}{"severity": "high"}},
{ID: "T3", Title: "C", Status: "ready", Type: "story", CustomFields: map[string]interface{}{"severity": "critical"}},
}
tests := []struct {
name string
query string
wantIDs []string
}{
{
name: "custom enum in with different case",
query: `select where severity in ["LOW"]`,
wantIDs: []string{"T1"},
},
{
name: "custom enum not-in with different case",
query: `select where severity not in ["LOW", "HIGH"]`,
wantIDs: []string{"T3"},
},
{
name: "custom enum in with mixed case list",
query: `select where severity in ["High", "Critical"]`,
wantIDs: []string{"T2", "T3"},
},
{
name: "built-in status in canonical case",
query: `select where status in ["done"]`,
wantIDs: []string{"T1"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stmt, err := p.ParseStatement(tt.query)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
gotIDs := make([]string, len(result.Select.Tasks))
for i, tk := range result.Select.Tasks {
gotIDs[i] = tk.ID
}
if !reflect.DeepEqual(gotIDs, tt.wantIDs) {
t.Errorf("got IDs %v, want %v", gotIDs, tt.wantIDs)
}
})
}
}

Some files were not shown because too many files have changed in this diff Show more